Merge pull request '优化 AGC 工作台顶栏布局:一行到底、版本入口钉右分级、播放并入运行页签(Issue #613)' (#615) from fix/agc-toolbar-layout into master
Project CI / AI game creator shell Rust crates (push) Successful in 6m22s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m32s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m17s
Project CI / Backend tests (push) Successful in 9m12s
Project CI / Frontend tests (push) Successful in 4m13s
Project CI / AI game creator shell web tests (push) Successful in 4m34s
Project CI / Repository checks (push) Successful in 8m36s
Project CI / Native shell tests (push) Successful in 10m32s

Reviewed-on: #615
This commit was merged in pull request #615.
This commit is contained in:
2026-10-04 20:04:30 +08:00
25 changed files with 1136 additions and 133 deletions
@@ -272,6 +272,7 @@ export function LocalGamePreviewFrame({
title,
className,
inspectMode = false,
reloadNonce = 0,
onInspectSelection,
onInspectExit,
}: {
@@ -279,6 +280,14 @@ export function LocalGamePreviewFrame({
title: string;
className?: string;
inspectMode?: boolean;
/**
* 换一个值就把 iframe 重新挂载一次=重新载入运行页面(使用方的「刷新运行画面」)。
*
* 游戏预览不是 vite dev 的实时刷新:改完代码要看到新一版只有重载页面这一条路。`src` 不变,
* 靠 React 的 `key` 换元素身份——跨域 iframe 里 `contentWindow.location.reload()` 会被
* 浏览器挡掉(运行页面在另一个端口上)。
*/
reloadNonce?: number;
onInspectSelection?: (selection: LocalGamePreviewInspectSelection) => void;
onInspectExit?: () => void;
}) {
@@ -425,6 +434,7 @@ export function LocalGamePreviewFrame({
<div ref={containerRef} className="local-game-preview-frame">
<iframe
ref={iframeRef}
key={reloadNonce}
className={className}
title={title}
src={embeddedUrl}
@@ -3,7 +3,11 @@ import { createPortal } from 'react-dom';
import type { GameIterationVersion } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
import { formatIterationVersionLabel } from './resourceCanvasVersionBindingModel';
import {
formatIterationVersionDetail,
formatIterationVersionLabel,
formatIterationVersionTitle,
} from './resourceCanvasVersionBindingModel';
type GameRunVersionPickerProps = {
versions: readonly GameIterationVersion[];
@@ -64,6 +68,12 @@ export function GameRunVersionPicker({
if (!currentVersion) {
return null;
}
const currentVersionIndex = Math.max(
0,
versions.findIndex(
(version) => version.versionId === currentVersion.versionId,
),
);
return (
<div ref={rootRef} className="game-run-version-picker">
@@ -72,16 +82,25 @@ export function GameRunVersionPicker({
className="game-run-version-trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`当前版本:${formatIterationVersionLabel(currentVersion)}`}
aria-label={`当前版本:${formatIterationVersionLabel(
currentVersion,
currentVersionIndex,
)}`}
onClick={() => setOpen((current) => !current)}
>
{/*
版本名可能很长(`初始版本 · 2026/9/19 02:10:03`)。按钮是 flex 容器,直接放文本节点
时 `text-overflow: ellipsis` 不生效(匿名 flex item 不参与父级省略),所以套一层
span 由它省略(见样式里的 `.game-run-version-trigger-label`)。
按钮是 flex 容器,直接放文本节点时 `text-overflow: ellipsis` 不生效(匿名 flex item
不参与父级省略),所以序号与括号里那一截各套一层 span(见样式里的
`.game-run-version-trigger-label`)。分两层还让「空间不够只留 `版本 1`」成为一次
`display: none`——顶栏窄到放不下完整标识时收掉的是括号那一层,不是整枚入口。
*/}
<span className="game-run-version-trigger-label">
{formatIterationVersionLabel(currentVersion)}
<span className="game-run-version-trigger-title">
{formatIterationVersionTitle(currentVersionIndex)}
</span>
<span className="game-run-version-trigger-detail">
({formatIterationVersionDetail(currentVersion)})
</span>
</span>
</button>
{open
@@ -106,7 +125,7 @@ export function GameRunVersionPicker({
: '0',
}}
>
{versions.map((version) => {
{versions.map((version, index) => {
const selected = version.versionId === currentVersion.versionId;
return (
<button
@@ -114,7 +133,7 @@ export function GameRunVersionPicker({
type="button"
role="option"
aria-selected={selected}
aria-label={`切换到${formatIterationVersionLabel(version)}`}
aria-label={`切换到${formatIterationVersionLabel(version, index)}`}
className={selected ? 'is-selected' : undefined}
onClick={() => {
setOpen(false);
@@ -122,7 +141,7 @@ export function GameRunVersionPicker({
onSelectVersion(version.versionId);
}}
>
<span>{formatIterationVersionLabel(version)}</span>
<span>{formatIterationVersionLabel(version, index)}</span>
<small>{version.versionId}</small>
</button>
);
@@ -20,7 +20,20 @@ export const ITERATION_VERSION_REASON_LABELS: Record<
};
/**
* 版本在 UI 里的可读标识:创建原因 + 创建时间。
* 版本序号:`manifest.versions[]` 的下标 + 1。
*
* 与资源画布「项目版本」卡片的 `版本 N`(`resourceProjectionModel` 里同一份下标口径)是同一个
* 编号——两处都按 manifest 里版本的落盘顺序数,不要在别处另起一套编号。
*/
export function formatIterationVersionTitle(ordinalIndex: number) {
return `版本 ${ordinalIndex + 1}`;
}
/**
* 版本标识里「括号里那一截」:创建原因 + 创建时间。
*
* 顶栏宽度不够时整截收起、只留 `版本 N`(见 `workbenchToolbarModel` 的档位),所以它是独立
* 一段文本,而不是拼在序号里的后缀。
*
* `GameIterationVersion.createdAt` 的单位是 **Unix 秒**(写入侧为
* `project/manifest.rs` 的 `created_at: unix_timestamp()`,而 `unix_timestamp()` 取
@@ -28,7 +41,7 @@ export const ITERATION_VERSION_REASON_LABELS: Record<
* 渲染成 1970 年(例如 1788075047 会显示成 1970/1/22)。
* 同一约定在 `ResourceAssetDeleteDialog` 与待确认资源编辑的创建时间标签里一致使用。
*/
export function formatIterationVersionLabel(
export function formatIterationVersionDetail(
version: Pick<GameIterationVersion, 'createdReason' | 'createdAt'>,
) {
const reason =
@@ -41,6 +54,21 @@ export function formatIterationVersionLabel(
return `${reason} · ${time}`;
}
/**
* 版本在 UI 里的完整标识:`版本 1(智能体修订 · 2026/10/3 14:21:05)`。
*
* 视觉上空间不够时可以只显示 `formatIterationVersionTitle`,但可访问名、菜单项与排障文案一律
* 用这一条完整的——收起来的是排版,不是信息。
*/
export function formatIterationVersionLabel(
version: Pick<GameIterationVersion, 'createdReason' | 'createdAt'>,
ordinalIndex: number,
) {
return `${formatIterationVersionTitle(ordinalIndex)}(${formatIterationVersionDetail(
version,
)})`;
}
/**
* C5 / C7 卡片边框:一个资源是否正在被「当前版本」使用。
*
+143 -46
View File
@@ -6275,14 +6275,19 @@ iframe.preview-frame {
min-height: 48px;
padding: 8px 12px;
background: transparent;
/* 一行到底:空间不够不再换行,而是按 `data-layout` 降级(顺序与判据见
* `view/project-development/workbenchToolbarModel.ts`)。`overflow: hidden` 有两个用处:
* 溢出的那一截不会画到对话列上;`scrollWidth` 仍然把溢出的内容算进内容宽度,档位判定靠它。 */
flex-wrap: nowrap;
overflow: hidden;
}
/* 左侧控件组:「资源管理 / 运行」分段 + 紧贴其后的「播放」。
/* 左侧控件组:只剩「资源管理 / 运行」分段(播放并进「运行」页签,见 index.tsx)。
* 整组在工具条里左对齐,取代播放按钮原先的居中悬浮(absolute + translateX(-50%))。 */
.game-workbench-view-tabs {
flex: 0 0 auto;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
}
@@ -6290,10 +6295,16 @@ iframe.preview-frame {
.game-workbench-view-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 7px;
}
/* 动作区:宽度够时按内容排布并贴右,不够时由 `min-width: 0` 让出宽度给降级判定
* (不设它的话 flex 子项不会缩到内容宽度以下,溢出永远检测不到)。 */
.game-workbench-view-actions {
flex: 0 1 auto;
min-width: 0;
}
.game-workbench-tabs {
padding: 3px;
border: 1px solid var(--platform-subpanel-border);
@@ -6302,16 +6313,20 @@ iframe.preview-frame {
}
.game-resource-sort-tabs {
/* 与版本入口同一课:`overflow: hidden` 的 flex 子项可以缩到 0,把溢出「吃掉」,
* 顶栏的档位判定就永远量不到真实溢出(实测窄宽度下「依赖 / 类型」被压成 0 宽)。
* 这一组不参与收缩。 */
flex: 0 0 auto;
flex-wrap: nowrap;
gap: 0;
padding: 0;
overflow: hidden;
}
/* 「依赖 / 类型」内部用 0 间距(共享边界的连通分段),但与同一行其他按钮之间必须跟行的间距
* 一致:这一行是 `gap: 7px` 的 flex 容器,分段控件曾在它里面自带 `padding: 3px`,靠那 3px
* 把自己和前一枚按钮顶开,于是「整理画布 → 生成任务 → 依赖」的视觉间距只有 10px,
* 而其余按钮之间是 24px——一行里两套间距。这里把它补到 24px,整行只剩一套间距。 */
/* 「依赖 / 类型」内部用 0 间距(共享边界的连通分段),但它与同一行其他按钮之间必须跟行的间距
* 一致:这一行是 `gap: 7px` 的 flex 容器,分段控件自带 `padding: 3px`,靠那 3px 把自己和前
* 一枚按钮顶开,于是相邻间距只有 10px,而其余按钮之间是 24px——一行里两套间距。这里把它补到
* 24px,整行只剩一套间距(它与左邻是谁无关:现在左邻是版本入口,此前是「整理画布 / 恢复草稿」)。 */
.game-workbench-tabs.game-resource-sort-tabs {
margin-left: 17px;
}
@@ -6330,7 +6345,8 @@ iframe.preview-frame {
border-color: transparent;
}
/* 左侧控件组里的按钮(目前只有「播放」)与分段控件、右侧动作区共用同一套基础外观。 */
/* 模式分段(含并进来的「运行 ▶」)、动作区按钮(含「更多」里的条目共用同一套基础外观的入口
* 那一枚)共用同一套基础外观。 */
.game-workbench-tabs button,
.game-workbench-view-tabs button,
.game-workbench-view-actions button {
@@ -6383,21 +6399,84 @@ iframe.preview-frame {
color: var(--platform-button-secondary-text);
}
/* 「播放」与「资源管理 / 运行」同组,外观仍按主按钮走;禁用时退回次级按钮。 */
.game-workbench-view-tabs .game-workbench-play-button {
position: static;
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
transform: none;
/*
* 顶栏降级档位:`data-layout` 由 `useWorkbenchToolbarLayout` 按实测内容宽度写在工具条上,
* 取 `full` / `compact-version` / `collapsed-actions`,顺序与判据在 `workbenchToolbarModel`。
* 这里只声明每一档「藏什么」——藏起来的是排版,不是信息:可访问名与菜单项都保留完整口径。
*/
.game-workbench-toolbar[data-layout='compact-version']
.game-run-version-trigger-detail {
display: none;
}
.game-workbench-view-tabs .game-workbench-play-button:disabled {
border-color: var(--platform-surface-border);
background: var(--platform-button-secondary-fill);
color: var(--platform-text-muted);
/* 「更多」那一档同样只留 `版本 N`:它比 `compact-version` 更窄,没有理由把括号段放回来。 */
.game-workbench-toolbar[data-layout='collapsed-actions']
.game-run-version-trigger-detail {
display: none;
}
/* `打开项目目录 / 资源面板 / 整理画布`:收进「更多」的那一档才藏,且与菜单互为开关——
* 默认(未量到宽度、'full')保持行内形态,与脚本关闭的环境一致。 */
.game-workbench-toolbar[data-layout='collapsed-actions']
.game-workbench-foldable-action {
display: none;
}
/* 「更多」下拉:只在动作收进菜单的那一档出现。 */
.game-workbench-more-actions {
display: none;
}
.game-workbench-toolbar[data-layout='collapsed-actions']
.game-workbench-more-actions {
display: inline-flex;
align-items: center;
}
/* 最后一档仍放不下(视口远小于 1280 合同宽度)时改成右对齐:溢出的那一截甩到左边被裁掉,
* 而不是把钉在最右的版本入口一起裁没。放得下时这条与 `margin-left: auto` 结果相同。 */
.game-workbench-toolbar[data-layout='collapsed-actions']
.game-workbench-view-actions {
justify-content: flex-end;
}
.game-workbench-more-menu {
display: grid;
gap: 4px;
min-width: 11rem;
padding: 6px;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
background: var(--platform-subpanel-fill);
box-shadow: 0 16px 34px rgb(75 47 32 / 18%);
z-index: 80;
}
.game-workbench-more-menu button {
display: flex;
align-items: center;
justify-content: flex-start;
min-height: 34px;
gap: 8px;
padding: 0 9px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.game-workbench-more-menu button:hover,
.game-workbench-more-menu button:focus-visible {
background: var(--platform-nav-item-hover-fill);
outline: 0;
}
.game-workbench-more-menu button:disabled {
cursor: not-allowed;
opacity: 0.6;
opacity: 0.45;
}
.game-workbench-view-actions .game-workbench-inspect-button.is-active {
@@ -8759,18 +8838,46 @@ iframe.preview-frame {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
/* 固定在最右:它是动作区的最后一枚,且行内空间富余时贴右缘(`margin-left: auto` 吃掉
* 左侧所有剩余空间)。空间不够时它自己先收成 `版本 N`(`data-layout` 档位),
* 而不是被换行甩到第二行。 */
margin-left: auto;
}
.game-run-version-trigger {
max-width: min(18rem, 60vw);
/* 默认**不参与收缩**(`max-content`):收缩 + 省略号会把溢出「吃掉」,让顶栏的档位判定
* (`workbenchToolbarModel`)永远量不到真实溢出——窄到该降档时它却原地把版本名截断。
* 只有降到最后一档、动作已经全收进「更多」时才允许它省略兜底(见下面那条选择器)。 */
min-width: max-content;
}
.game-workbench-toolbar[data-layout='collapsed-actions']
.game-run-version-trigger {
min-width: 0;
}
/*
* 版本名单独一层才省得掉:按钮是 flex 容器,文本直接挂在按钮上时 `text-overflow`
* 落在匿名 flex item 上、不生效(见 `GameRunVersionPicker` 里的注释)。
* 层里再分「序号」与「括号那一截」两层:窄宽度收掉的是后者(`display: none`),
* 序号永远留着。
*/
.game-run-version-trigger-label {
display: inline-flex;
align-items: baseline;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-run-version-trigger-title {
flex: 0 0 auto;
}
.game-run-version-trigger-detail {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -8837,13 +8944,13 @@ iframe.preview-frame {
}
/*
* 画面右下角的「全屏预览」。压在游戏画面上,所以用深色半透明底 + 白图标:任何游戏配色下都
* 看得清,也不在画面中间抢位置。全屏那一格还是它自己(`:fullscreen` 铺满屏幕),所以这枚按钮
* 在全屏里照旧可用,用户点它就能退出来。
* 画面右下角两枚浮起控件:「刷新运行画面」(左)与「全屏预览」(右)。压在游戏画面上,所以用
* 深色半透明底 + 白图标:任何游戏配色下都看得清,也不在画面中间抢位置。全屏那一格还是它自己
* (`:fullscreen` 铺满屏幕),所以两枚按钮在全屏里照旧可用——刷新照旧刷画面,点全屏那枚就退出。
*/
.game-run-preview-fullscreen {
.game-run-preview-fullscreen,
.game-run-preview-refresh {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 2;
display: inline-flex;
@@ -8859,8 +8966,18 @@ iframe.preview-frame {
backdrop-filter: blur(6px);
}
.game-run-preview-refresh {
right: 50px;
}
.game-run-preview-fullscreen {
right: 10px;
}
.game-run-preview-fullscreen:hover,
.game-run-preview-fullscreen:focus-visible {
.game-run-preview-fullscreen:focus-visible,
.game-run-preview-refresh:hover,
.game-run-preview-refresh:focus-visible {
background: rgb(12 14 20 / 84%);
outline: 0;
}
@@ -10377,15 +10494,6 @@ iframe.preview-frame {
.game-workbench-layout {
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.42fr);
}
.game-workbench-toolbar {
align-items: stretch;
flex-direction: column;
}
.game-workbench-view-actions {
justify-content: flex-end;
}
}
@media (max-width: 760px) {
@@ -10410,17 +10518,6 @@ iframe.preview-frame {
border-left: 0;
}
.game-workbench-toolbar,
.game-workbench-view-actions {
align-items: stretch;
flex-direction: column;
}
.game-workbench-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.game-resource-section {
min-width: 460px;
}
@@ -0,0 +1,111 @@
import { MoreHorizontal } from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
export type WorkbenchMoreAction = {
key: string;
label: string;
/** 与内联形态同一枚图标:菜单里逐条重画,不新造一套语义。 */
icon: ReactNode;
disabled?: boolean;
onSelect: () => void;
};
/**
* 顶栏动作的「更多」下拉:窄宽度下 `打开项目目录 / 资源面板 / 整理画布` 的收容处。
*
* 只在顶栏落到 `collapsed-actions` 档时由 CSS 显示(见 `styles.css` 里的 `[data-layout]`
* 选择器),档位判据在 `workbenchToolbarModel`。菜单 portal 到 `document.body` 并每次渲染
* 按触发钮的实时 rect 定位:顶栏会随对话列收放横移,菜单挂在按钮旁边而不是挂在某个坐标上。
*/
export function WorkbenchMoreActionsMenu({
actions,
}: {
readonly actions: readonly WorkbenchMoreAction[];
}) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// 定位每次渲染都按触发钮实时 rect 重算;滚动 / 缩放后必须重算,否则 fixed 定位的菜单会漂在
// 旧位置上。用一个 tick 驱动重算,「打开当帧就有菜单」的时序不变。
const [, setMenuAnchorTick] = useState(0);
useEffect(() => {
if (!open) return undefined;
const refreshMenuAnchor = () => setMenuAnchorTick((tick) => tick + 1);
window.addEventListener('scroll', refreshMenuAnchor, true);
window.addEventListener('resize', refreshMenuAnchor);
return () => {
window.removeEventListener('scroll', refreshMenuAnchor, true);
window.removeEventListener('resize', refreshMenuAnchor);
};
}, [open]);
// 菜单在 `rootRef` 子树外的 portal 里,必须一起登记成「点这里不算外部」,否则落在菜单项上的
// 那一下会被当成点外部,菜单在选项收到 click 之前就被收掉。
useImageCanvasFloatingOptionDismiss({
isOpen: open,
boundaryRefs: [rootRef, menuRef],
onDismiss: () => setOpen(false),
});
if (actions.length === 0) {
return null;
}
return (
<div ref={rootRef} className="game-workbench-more-actions">
<button
type="button"
className="game-workbench-more-trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label="更多项目操作"
onClick={() => setOpen((current) => !current)}
>
<MoreHorizontal size={15} aria-hidden="true" />
更多
</button>
{open
? createPortal(
<div
ref={menuRef}
className="game-workbench-more-menu"
role="menu"
aria-label="更多项目操作"
style={{
position: 'fixed',
top: rootRef.current
? `${rootRef.current.getBoundingClientRect().bottom + 6}px`
: '0',
left: rootRef.current
? `${Math.max(
8,
rootRef.current.getBoundingClientRect().left,
)}px`
: '0',
}}
>
{actions.map((action) => (
<button
key={action.key}
type="button"
role="menuitem"
disabled={action.disabled}
onClick={() => {
setOpen(false);
action.onSelect();
}}
>
{action.icon}
{action.label}
</button>
))}
</div>,
document.body,
)
: null}
</div>
);
}
@@ -46,6 +46,7 @@ import {
Redo2,
Replace,
RotateCcw,
RotateCw,
Search,
Shapes,
SlidersHorizontal,
@@ -443,6 +444,11 @@ import {
import { useProjectResourceCardPreviews } from './useProjectResourceCardPreviews';
import { useProjectResourceSectionHeights } from './useProjectResourceSectionHeights';
import { useResourceAssetDeleteFlow } from './useResourceAssetDeleteFlow';
import { useWorkbenchToolbarLayout } from './useWorkbenchToolbarLayout';
import {
type WorkbenchMoreAction,
WorkbenchMoreActionsMenu,
} from './WorkbenchMoreActionsMenu';
export type {
ProjectAgentResultSummary,
@@ -1837,6 +1843,12 @@ export default function ProjectDevelopmentView({
// 运行画面的「全屏预览」:全屏的是画面那一格(`.game-run-preview`),不是整块工作台,
// 所以按钮与 ref 都归运行表现层自己持有。
const runPreviewFullscreen = useElementFullscreen<HTMLDivElement>();
// 「刷新运行画面」的载入次数:递增一次=换 iframe 的元素身份=重新载入运行页面。
const [runPreviewReloadNonce, setRunPreviewReloadNonce] = useState(0);
// 顶栏按实测宽度自己在三档之间退(见 `workbenchToolbarModel`):档位只写在 DOM 属性上,
// 不参与 React 状态。
const workbenchToolbarRef = useRef<HTMLDivElement>(null);
useWorkbenchToolbarLayout(workbenchToolbarRef);
const [resourceBookState, dispatchResourceBook] = useReducer(
resourceBookReducer,
initialResourceBookState,
@@ -2789,6 +2801,17 @@ export default function ProjectDevelopmentView({
});
}
}, [embeddedPreviewUrl, onNotice]);
/**
* 「刷新运行画面」:先把预览确认成活的(`onPlay` 命中活体预览只切视图、不重启服务),再换一次
* iframe 的元素身份把页面重新载入。
*
* 游戏预览不是 vite dev 的实时刷新,改完代码只有重载页面才看得到新一版;以前只能靠切到资源
* 管理再切回来,这一枚把同一个动作放回运行画面本身。
*/
const refreshRunPreview = useCallback(() => {
onPlay?.();
setRunPreviewReloadNonce((current) => current + 1);
}, [onPlay]);
const runAvailable =
embeddedPreviewUrl !== null ||
manifest.tasks.some(
@@ -2799,6 +2822,29 @@ export default function ProjectDevelopmentView({
setRuntimeInspectMode(false);
}
}, [embeddedPreviewUrl, mode]);
/**
* 站在运行视图上却没有画面可看时,自动按一次「运行」。
*
* 「运行」页签就是播放入口,但**进入项目**这条路上用户可能一进来就站在运行页:从别的项目
* 切过来时工作台不重挂(`mode` 是工作台自己的 state),上一条项目的运行视图会原样留下,
* 画面却已经没了;此时页面上只有一句「点击顶部「运行」页签」,看起来就是坏的。这里把缺的那
* 一下自动补上——只在**没有画面**时发,画面还在就不重复启动。
*
* 记账用 ref 而不是 state:`showRunView`(用户点页签)会先记上再自己发一次播放,这里就不会
* 跟着重复发;同一个项目只自动发一次,失败后不打转,手动重跑仍走页签或画面上的刷新按钮。
*/
const autoRunPreviewProjectRef = useRef<string | null>(null);
useEffect(() => {
if (mode !== 'run' || embeddedPreviewUrl || !runAvailable || !onPlay) {
return;
}
if (autoRunPreviewProjectRef.current === projectPath) {
return;
}
autoRunPreviewProjectRef.current = projectPath;
onPlay();
}, [embeddedPreviewUrl, mode, onPlay, projectPath, runAvailable]);
const handleRuntimeInspectSelection = useCallback(
(selection: LocalGamePreviewInspectSelection) => {
const sourceName = selection.sourcePath?.split('/').pop()?.toLowerCase();
@@ -7347,6 +7393,17 @@ export default function ProjectDevelopmentView({
uiEditorRoute,
]);
/**
* 切到运行视图**并载入游戏**:原来的「播放」按钮合并进「运行」页签,两者本来就是同一件事
* (切视图 + 启动本地预览)。页签因此在不可运行时不置灰、只给提示(见 `run-unavailable-hint`),
* 点了不切视图也不发播放请求。
*
* 这里记一次账(`autoRunPreviewProjectRef`)再发播放:下一条 effect 会在「运行视图 + 没有
* 画面」时自动补发,不记账就会跟着重复发一次。
*
* 自动切运行那两条路径(会话内已确认的预览、播放请求)不走这里,它们只是**呈现**运行结果,
* 不该再发起一次播放。
*/
function showRunView() {
if (!runAvailable || uiEditorRoute) {
return;
@@ -7360,6 +7417,8 @@ export default function ProjectDevelopmentView({
captureResourceSectionScrollPositions();
stopActiveCardMedia();
setMode('run');
autoRunPreviewProjectRef.current = projectPath;
onPlay?.();
}
const showRunUnavailableHint = !runAvailable && !uiEditorRoute;
@@ -9588,6 +9647,54 @@ export default function ProjectDevelopmentView({
],
);
/**
* 「整理画布」的可用判据:布局还没读出来,或当前范围里一个素材都没有时不放行。行内按钮与
* 「更多」里的同一条动作共用这一份,别在两处各写一遍。
*/
const resourceCanvasOrganizeDisabled =
!resourceLayoutReady ||
(resourceOrganizeSections !== null &&
resourceOrganizeSections.length === 0);
/**
* 顶栏动作区里可被「更多」下拉收容的三枚动作,与行内形态是**同一批动作**:出现条件与禁用
* 口径必须一起改(行内按钮见 `game-workbench-foldable-action`)。
*/
const workbenchMoreActionCandidates: Array<WorkbenchMoreAction | null> = [
onRevealProjectDirectory
? {
key: 'reveal-project-directory',
label: '打开项目目录',
icon: <FolderOpen size={15} aria-hidden="true" />,
onSelect: () => void onRevealProjectDirectory(),
}
: null,
mode === 'resources' && !uiEditorRoute
? {
key: 'resource-panel',
label: '资源面板',
icon: <FolderTree size={15} aria-hidden="true" />,
disabled: canvasResources.length === 0,
onSelect: () => {
setResourcePanelNotice('');
setResourcePanelOpen((current) => !current);
},
}
: null,
mode === 'resources' && !uiEditorRoute
? {
key: 'organize-canvas',
label: '整理画布',
icon: <LayoutGrid size={15} aria-hidden="true" />,
disabled: resourceCanvasOrganizeDisabled,
onSelect: organizeResourceCanvasLayout,
}
: null,
];
const workbenchMoreActions = workbenchMoreActionCandidates.filter(
(action): action is WorkbenchMoreAction => action !== null,
);
if (planningStartMode) {
return (
<section
@@ -9644,11 +9751,11 @@ export default function ProjectDevelopmentView({
: undefined
}
>
<div className="game-workbench-toolbar">
<div className="game-workbench-toolbar" ref={workbenchToolbarRef}>
{/*
「资源管理 / 运行」与「播放」是同一组左侧控件:播放紧贴模式切换之后、整组左对齐,
不再居中悬浮。**两个页签下都常驻**——运行视图空态与预览失败态都要靠它重跑
(空态文案就是「点击顶部播放按钮后将在这里直接运行游戏」)。
左侧只剩模式切换:「资源管理 / 运行」。**运行页签本身就是播放入口**(前面带 ▶):
切过去即按「播放」载入游戏资源,不再单开一枚「播放」按钮——两枚入口说的是同一件事,
并排只会把右侧动作区挤到第二行。运行视图空态与预览失败态都靠再点一次这枚页签重跑。
*/}
<div className="game-workbench-view-tabs">
<div className="game-workbench-tabs" role="tablist">
@@ -9676,30 +9783,22 @@ export default function ProjectDevelopmentView({
disabled={uiEditorRoute !== null}
onClick={showRunView}
>
<Play size={14} aria-hidden="true" />
运行
</button>
</div>
<button
type="button"
className="game-workbench-play-button"
disabled={!runAvailable || !onPlay || uiEditorRoute !== null}
onClick={() => {
showRunView();
onPlay?.();
}}
title={
runAvailable ? '运行当前游戏' : '首个可运行原型完成后才能播放'
}
>
<Play size={15} aria-hidden="true" />
播放
</button>
</div>
<div className="game-workbench-view-actions">
{/*
窄宽度收容:`打开项目目录 / 资源面板 / 整理画布` 在顶栏掉到 `collapsed-actions`
档时改从这里进(宽的时候仍是行内按钮)。档位顺序与判据在
`workbenchToolbarModel`;两处渲染的是同一批动作,出现条件与禁用口径要一起改。
*/}
<WorkbenchMoreActionsMenu actions={workbenchMoreActions} />
{onRevealProjectDirectory ? (
<button
type="button"
className="game-workbench-resource-panel-button"
className="game-workbench-resource-panel-button game-workbench-foldable-action"
aria-label="打开项目目录"
onClick={() => void onRevealProjectDirectory()}
>
@@ -9745,7 +9844,7 @@ export default function ProjectDevelopmentView({
<>
<button
type="button"
className="game-workbench-resource-panel-button"
className="game-workbench-resource-panel-button game-workbench-foldable-action"
aria-expanded={resourcePanelOpen}
disabled={canvasResources.length === 0}
onClick={() => {
@@ -9770,12 +9869,9 @@ export default function ProjectDevelopmentView({
*/}
<button
type="button"
className="game-workbench-foldable-action"
aria-label="整理画布"
disabled={
!resourceLayoutReady ||
(resourceOrganizeSections !== null &&
resourceOrganizeSections.length === 0)
}
disabled={resourceCanvasOrganizeDisabled}
aria-busy={resourceLayoutSaving}
onClick={organizeResourceCanvasLayout}
>
@@ -9804,32 +9900,6 @@ export default function ProjectDevelopmentView({
})}
</button>
) : null}
<div
className="game-workbench-tabs game-resource-sort-tabs"
role="group"
aria-label="资源排列方式"
>
<button
type="button"
className={sortMode === 'dependency' ? 'is-active' : ''}
aria-label="按依赖"
aria-pressed={sortMode === 'dependency'}
onClick={() => showResourceSortMode('dependency')}
>
<FolderTree size={15} aria-hidden="true" />
依赖
</button>
<button
type="button"
className={sortMode === 'type' ? 'is-active' : ''}
aria-label="按类型"
aria-pressed={sortMode === 'type'}
onClick={() => showResourceSortMode('type')}
>
<ListFilter size={15} aria-hidden="true" />
类型
</button>
</div>
</>
) : null}
{/*
@@ -9847,6 +9917,39 @@ export default function ProjectDevelopmentView({
onSelectVersion={selectActiveVersion}
/>
)}
{/*
排序分段(依赖 / 类型)收在**行尾**:它与左侧「资源管理 / 运行」是顶栏里两枚体量
最大的分组控件,分居这一行的两端;版本入口紧邻它左侧(用户口径:「版本应该在
依赖 / 类型左边」),所以版本入口不放在最右。
*/}
{mode === 'resources' && !uiEditorRoute ? (
<div
className="game-workbench-tabs game-resource-sort-tabs"
role="group"
aria-label="资源排列方式"
>
<button
type="button"
className={sortMode === 'dependency' ? 'is-active' : ''}
aria-label="按依赖"
aria-pressed={sortMode === 'dependency'}
onClick={() => showResourceSortMode('dependency')}
>
<FolderTree size={15} aria-hidden="true" />
依赖
</button>
<button
type="button"
className={sortMode === 'type' ? 'is-active' : ''}
aria-label="按类型"
aria-pressed={sortMode === 'type'}
onClick={() => showResourceSortMode('type')}
>
<ListFilter size={15} aria-hidden="true" />
类型
</button>
</div>
) : null}
</div>
{/*
布局状态提示**不进动作行**:它是一段随保存过程变长的文案(空 →「保存中」→
@@ -11029,6 +11132,7 @@ export default function ProjectDevelopmentView({
title={`${projectName} 游戏运行画面`}
preview={preview}
inspectMode={runtimeInspectMode}
reloadNonce={runPreviewReloadNonce}
onInspectSelection={handleRuntimeInspectSelection}
onInspectExit={() => setRuntimeInspectMode(false)}
/>
@@ -11036,9 +11140,25 @@ export default function ProjectDevelopmentView({
<div className="game-run-preview-empty">
<Sparkles size={28} aria-hidden="true" />
<strong>客户端运行画面尚未载入</strong>
<span>点击顶部播放按钮后将在这里直接运行游戏</span>
<span>点击顶部「运行」页签后将在这里直接运行游戏</span>
</div>
)}
{/*
刷新运行画面:贴在画面右下角、全屏那一枚的左边。游戏预览不是 vite dev 的实时
刷新,改完代码要看到新一版只有重载页面这一条路(以前得切到资源管理再切回来)。
没有活预览时不渲染——那时该走的是顶栏「运行」页签。
*/}
{embeddedPreviewUrl ? (
<button
type="button"
className="game-run-preview-refresh"
aria-label="刷新运行画面"
title="重新载入运行画面(游戏不是实时刷新,改完代码用这一枚重载)"
onClick={refreshRunPreview}
>
<RotateCw size={16} aria-hidden="true" />
</button>
) : null}
{/*
全屏预览:贴在画面右下角。**只有画面这一格进全屏**——顶部页签、右侧对话与
底部信息栏都不跟着放大,符合「预览画面」而不是「全屏应用」。没有活预览时不渲染,
@@ -0,0 +1,87 @@
import { type RefObject, useLayoutEffect } from 'react';
import {
resolveWorkbenchToolbarLayout,
type WorkbenchToolbarLayout,
} from './workbenchToolbarModel';
/**
* 顶栏当前档位挂在 DOM 属性上(`data-layout`),并**只由这个 hook 写**。
*
* 不让 React 渲染这个属性:档位是量出来的,不是 state 推出来的。这条链路上每次量宽都要先把
* 候选档位真的写到 DOM(否则量到的还是当前档位的宽度),React 受控属性会被这类「量一次写一次」
* 的中间值污染,且每次窗口拖动都要触发一轮工作台整树重渲染——顶栏只有这一处样式差异,不值得。
* React 不声明它就不会覆盖它,见 `styles.css` 里的 `[data-layout=...]` 选择器。
*/
export const WORKBENCH_TOOLBAR_LAYOUT_ATTRIBUTE = 'data-layout';
/**
* 把候选档位写到 DOM 上量一次内容宽度。
*
* 顶栏是 `flex-wrap: nowrap` + `overflow: hidden`:放不下时溢出的那部分仍计入 `scrollWidth`,
* 所以「量得的内容宽度 > clientWidth」就是这一档放不下。
*/
function measureToolbarContentWidth(
element: HTMLElement,
candidate: WorkbenchToolbarLayout,
): number {
element.dataset.layout = candidate;
return element.scrollWidth;
}
/** 重新爬一次档位阶梯并落定;返回落定后的档位(测试与排障用)。 */
export function applyWorkbenchToolbarLayout(
element: HTMLElement,
): WorkbenchToolbarLayout {
const layout = resolveWorkbenchToolbarLayout(
element.clientWidth,
(candidate) => measureToolbarContentWidth(element, candidate),
);
element.dataset.layout = layout;
return layout;
}
/**
* 让顶栏按实际可用宽度自己选档位(`workbenchToolbarModel` 给了顺序与判据)。
*
* 两个信号都要:
* - `ResizeObserver`:可用宽度变了(窗口拖动、对话列收放);
* - `MutationObserver`:宽度不变但**内容**变了(切运行页、出现「恢复草稿」、面板开合)。
* 这种变化不改顶栏宽度,只看尺寸会停在旧档位上——动作变少了却还收在「更多」里。
*
* 观测本身不引起递归:写的是被观测节点自己的属性,而属性不在观测范围内(`childList` /
* `characterData` / `subtree` 都不含根节点自身属性),且档位变化不改顶栏尺寸。
* 没有 `ResizeObserver` 的环境(jsdom)只按内容变化重算,量到 0 宽就停在默认的 `full`——
* 与关闭脚本前的默认形态一致。
*/
export function useWorkbenchToolbarLayout(
toolbarRef: RefObject<HTMLElement | null>,
): void {
useLayoutEffect(() => {
const element = toolbarRef.current;
if (!element) {
return undefined;
}
applyWorkbenchToolbarLayout(element);
const resizeObserver =
typeof ResizeObserver === 'function'
? new ResizeObserver(() => applyWorkbenchToolbarLayout(element))
: null;
resizeObserver?.observe(element);
const mutationObserver =
typeof MutationObserver === 'function'
? new MutationObserver(() => applyWorkbenchToolbarLayout(element))
: null;
mutationObserver?.observe(element, {
childList: true,
characterData: true,
subtree: true,
});
return () => {
resizeObserver?.disconnect();
mutationObserver?.disconnect();
};
}, [toolbarRef]);
}
@@ -0,0 +1,49 @@
/**
* 工作台顶栏在横向空间不足时的降级档位。
*
* 顶栏是**一行到底**的 flex 行:左侧「资源管理 / 运行」,右侧动作区 + 版本入口。以前两侧都可以
* `flex-wrap: wrap`,窄一点就把「播放」和版本入口甩到第二行——同一行控件分裂成两行,用户明确
* 否掉了这种换行。现在不换行,空间不够按固定顺序退:
*
* 1. `full`:动作全部内联,版本入口显示 `版本 1(智能体修订 · 时间)`;
* 2. `compact-version`:版本入口只留 `版本 1`(括号里那截原因与时间整体收起);
* 3. `collapsed-actions`:`打开项目目录 / 资源面板 / 整理画布` 收进「更多」下拉;
* 4. 再不够就停在 `collapsed-actions`:版本名由省略号兜底,动作区不再继续牺牲。
*
* 档位只决定「隐藏什么」,具体隐藏由 `styles.css` 里 `[data-layout=...]` 的选择器完成;
* 每一档到底占多宽由真实 DOM 量(`useWorkbenchToolbarLayout`),这里只留纯判据,便于单测。
*/
export const WORKBENCH_TOOLBAR_LAYOUTS = [
'full',
'compact-version',
'collapsed-actions',
] as const;
export type WorkbenchToolbarLayout = (typeof WORKBENCH_TOOLBAR_LAYOUTS)[number];
/**
* 「这一档放不下」的余量。
*
* 判定用 `scrollWidth > clientWidth`,两者都取整到整数像素,且 125% 缩放下同一份内容在不同
* 设备像素比下会差 1px 上下;不给余量就会在临界宽度上反复在相邻两档之间抖。
*/
const OVERFLOW_TOLERANCE = 1;
/**
* 按档位顺序取第一个放得下的档位。
*
* `measureContentWidth(candidate)` 由调用方提供:它把候选档位真的写到 DOM 上再量一次内容宽度
* (见 `useWorkbenchToolbarLayout`),所以这里不缓存任何宽度、也不做增量推算——每一档的宽度
* 都随模式(资源页 / 运行页)、动作出现与否(恢复草稿、点选素材)变化。
*/
export function resolveWorkbenchToolbarLayout(
availableWidth: number,
measureContentWidth: (candidate: WorkbenchToolbarLayout) => number,
): WorkbenchToolbarLayout {
for (const candidate of WORKBENCH_TOOLBAR_LAYOUTS) {
if (measureContentWidth(candidate) - availableWidth <= OVERFLOW_TOLERANCE) {
return candidate;
}
}
return 'collapsed-actions';
}
@@ -461,6 +461,9 @@ export function registerProjectWorkbenchFoundationTests() {
],
});
// 「运行」页签就是播放入口(原「播放」按钮并进来):这枚 spy 用来证明不可运行时
// 点了它既不切视图、也不向宿主发播放请求。
const onPlay = vi.fn();
render(
React.createElement(ProjectDevelopmentView, {
projectName: '陶泥儿工作台测试',
@@ -483,6 +486,7 @@ export function registerProjectWorkbenchFoundationTests() {
),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
onPlay,
}),
);
@@ -500,14 +504,13 @@ export function registerProjectWorkbenchFoundationTests() {
const runTab = screen.getByRole('tab', {
name: '运行',
}) as HTMLButtonElement;
// 「运行」页签就是播放入口(原「播放」按钮并进来):不可运行时不置灰、只给提示,
// 点了既不切视图、也不向宿主发播放请求。
expect(runTab.disabled).toBe(false);
expect(runTab.getAttribute('data-unavailable')).toBe('true');
const playButton = screen.getByRole('button', {
name: '播放',
}) as HTMLButtonElement;
expect(playButton.disabled).toBe(true);
fireEvent.click(runTab);
expect(runTab.getAttribute('aria-selected')).toBe('false');
expect(onPlay).not.toHaveBeenCalled();
expect(
screen.getByText('首个可运行原型尚未完成,运行视图暂不可用'),
).not.toBeNull();
@@ -5895,15 +5898,25 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styles).toMatch(
/\.game-workbench-view-tabs\s*\{[^}]*display:\s*flex[^}]*align-items:\s*center/s,
);
// 播放按钮跟着「资源管理 / 运行」左对齐,不再居中悬浮。
// 播放并进「运行」页签:顶栏左侧只剩模式分段,「播放」按钮与它的整套外观规则一起退役。
expect(styles).not.toMatch(/game-workbench-play-button/);
// 顶栏一行到底:空间不够走 `data-layout` 降级,不再换行(换行会把版本入口甩到第二行)。
expect(styles).toMatch(
/\.game-workbench-view-tabs \.game-workbench-play-button\s*\{[^}]*position:\s*static[^}]*transform:\s*none/s,
/\.game-workbench-toolbar\s*\{[^}]*flex-wrap:\s*nowrap[^}]*overflow:\s*hidden/s,
);
expect(styles).not.toMatch(
/\.game-workbench-play-button\s*\{[^}]*position:\s*absolute/s,
// 两档降级:先收版本名的括号段,再收「打开项目目录 / 资源面板 / 整理画布」进「更多」,
// 且版本入口钉在动作区最右。
expect(styles).toMatch(
/\.game-workbench-toolbar\[data-layout='compact-version'\]\s*\.game-run-version-trigger-detail\s*\{[^}]*display:\s*none/s,
);
// 新位置必须并进按钮基础外观与焦点环的规则列表:不然播放按钮会掉成零圆角、零内边距、
// 无边框、默认字号的裸按钮,而颜色规则看起来仍然生效。
expect(styles).toMatch(
/\.game-workbench-toolbar\[data-layout='collapsed-actions'\]\s*\.game-workbench-foldable-action\s*\{[^}]*display:\s*none/s,
);
expect(styles).toMatch(
/\.game-run-version-picker\s*\{[^}]*margin-left:\s*auto/s,
);
// 模式分段与动作区按钮必须共用同一条基础外观规则与焦点环:不然新进来的「运行 ▶」页签会掉成
// 零圆角、零内边距、无边框、默认字号的裸按钮,而颜色规则看起来仍然生效。
expect(styles).toMatch(
/\.game-workbench-tabs button,\s*\.game-workbench-view-tabs button,\s*\.game-workbench-view-actions button\s*\{[^}]*border-radius:\s*999px/s,
);
@@ -6513,7 +6526,8 @@ export function registerProjectWorkbenchFoundationTests() {
}),
);
fireEvent.click(screen.getByRole('button', { name: '播放' }));
// 「运行」页签就是播放入口:切过去即向宿主发一次播放请求,并落到运行表现层。
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(onPlay).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
@@ -6521,7 +6535,9 @@ export function registerProjectWorkbenchFoundationTests() {
name: '运行',
}) as HTMLButtonElement;
expect(runTab.disabled).toBe(false);
// 再点一次同一枚页签=重跑:空态与预览失败态都靠它。
fireEvent.click(runTab);
expect(onPlay).toHaveBeenCalledTimes(2);
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
const previewFrame = screen.getByTitle(
'可运行工作台 游戏运行画面',
@@ -6747,7 +6763,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.queryByTitle('远程预览拒绝测试 游戏运行画面')).toBeNull();
expect(screen.getByText('客户端运行画面尚未载入')).not.toBeNull();
expect(
screen.getByText('点击顶部播放按钮后将在这里直接运行游戏'),
screen.getByText('点击顶部「运行」页签后将在这里直接运行游戏'),
).not.toBeNull();
expect(screen.queryByText(/\/run|\/preview/)).toBeNull();
});
@@ -56,12 +56,25 @@ describe('运行页顶栏动作区样式', () => {
expect(trigger.has('border')).toBe(false);
expect(trigger.has('background')).toBe(false);
expect(trigger.has('color')).toBe(false);
expect(declaration(trigger, 'min-width')).toBe('0');
// 入口默认不参与收缩(`max-content`):收缩 + 省略号会把溢出「吃掉」,顶栏的档位判定
// 就永远量不到真实溢出。只有降到最后一档(动作已全收进「更多」)才允许它省略兜底。
expect(declaration(trigger, 'min-width')).toBe('max-content');
expect(
declaration(
resolved([
".game-workbench-toolbar[data-layout='collapsed-actions'] .game-run-version-trigger",
]),
'min-width',
),
).toBe('0');
// 省略号要真的生效:按钮是 flex 容器,文本必须挂在自带 overflow 的 span 上。
const label = resolved(['.game-run-version-trigger-label']);
expect(declaration(label, 'overflow')).toBe('hidden');
expect(declaration(label, 'text-overflow')).toBe('ellipsis');
expect(declaration(label, 'white-space')).toBe('nowrap');
// 括号里那一截(原因 + 时间)单独一层,窄档位收掉的就是它,序号永远留着。
expect(hasRule('.game-run-version-trigger-detail')).toBe(true);
expect(hasRule('.game-run-version-trigger-title')).toBe(true);
expect(hasRule('.game-run-version-trigger:hover')).toBe(false);
expect(hasRule('.game-run-version-trigger:focus-visible')).toBe(false);
@@ -147,8 +147,12 @@ describe('「生成任务」侧栏的开合与入口位置', () => {
(button) => button.getAttribute('aria-label') === '按依赖',
);
expect(sortIndex).toBeGreaterThanOrEqual(0);
// 依赖 / 类型仍然收在行尾。
// 「依赖 / 类型」占行尾两枚(连通分段,内部 0 间距),也是动作行最后一个子元素:
// 顶栏里「资源管理 / 运行」与它分居这一行两端(版本入口的先后见资源版本切换用例)。
expect(sortIndex).toBe(buttons.length - 2);
const sortGroup = actionsRow!.querySelector('.game-resource-sort-tabs');
expect(sortGroup).not.toBeNull();
expect(actionsRow!.lastElementChild).toBe(sortGroup);
// 锚点挂在主视窗(stage)的**画布那一格**里:右上角坐标是相对画布工作面算的,
// 不是相对整个窗口,也不会压在工具条那一行上。
@@ -2,7 +2,9 @@ import { describe, expect, test } from 'vitest';
import {
currentVersionResourceBindingIds,
formatIterationVersionDetail,
formatIterationVersionLabel,
formatIterationVersionTitle,
isResourceUsedByCurrentVersion,
} from '../src/features/resource-canvas/resourceCanvasVersionBindingModel';
@@ -86,10 +88,18 @@ describe('运行模块版本绑定判定', () => {
});
test('labels a version with its Chinese created reason and creation time', () => {
expect(formatIterationVersionLabel(versions[0]!)).toBe(
`初始版本 · ${new Date(1_788_075_047 * 1000).toLocaleString('zh-CN')}`,
// 完整标识=`版本 N(原因 · 时间)`;`N` 取 manifest 里的落盘顺序(与资源画布
// 「项目卡片」上的 `版本 N` 同一编号)。
expect(formatIterationVersionLabel(versions[0]!, 0)).toBe(
`版本 1(初始版本 · ${new Date(1_788_075_047 * 1000).toLocaleString('zh-CN')})`,
);
expect(formatIterationVersionLabel(versions[1]!)).toBe(
expect(formatIterationVersionLabel(versions[1]!, 1)).toBe(
`版本 2(智能体修订 · ${new Date(1_788_075_104 * 1000).toLocaleString('zh-CN')})`,
);
// 顶栏窄到只放得下序号时显示的就是 `formatIterationVersionTitle`,括号里那一截整段收起
// (样式按 `data-layout` 隐藏 `.game-run-version-trigger-detail`)。两半各自是独立口径。
expect(formatIterationVersionTitle(1)).toBe('版本 2');
expect(formatIterationVersionDetail(versions[1]!)).toBe(
`智能体修订 · ${new Date(1_788_075_104 * 1000).toLocaleString('zh-CN')}`,
);
});
@@ -100,8 +110,8 @@ describe('运行模块版本绑定判定', () => {
const createdAtLocal = new Date(1_788_075_047 * 1000).toLocaleString(
'zh-CN',
);
const label = formatIterationVersionLabel(versions[0]!);
expect(label).toBe(`初始版本 · ${createdAtLocal}`);
const label = formatIterationVersionLabel(versions[0]!, 0);
expect(label).toBe(`版本 1(初始版本 · ${createdAtLocal})`);
// 年 / 月 / 日必须来自真实时刻,而不是 1970。这里用显式数值选项断言,
// 不依赖 `toLocaleString` 的具体排版(不同 ICU 版本会给出不同分隔符)。
@@ -194,6 +194,25 @@ describe('C7 运行模块版本切换', () => {
expect(trigger.getAttribute('aria-label')).toContain('智能体修订');
});
test('顶栏里版本入口排在「依赖 / 类型」左边,排序分段收在行尾', async () => {
installResourceCardIntersectionObserver();
renderWorkbench(createVersionedManifest());
await screen.findByLabelText(/^当前版本:/);
const actionsRow = document.querySelector('.game-workbench-view-actions');
const versionPicker = actionsRow?.querySelector('.game-run-version-picker');
const sortGroup = actionsRow?.querySelector('.game-resource-sort-tabs');
expect(actionsRow).not.toBeNull();
expect(versionPicker).not.toBeNull();
expect(sortGroup).not.toBeNull();
// 顶栏这一行两端是「资源管理 / 运行」与「依赖 / 类型」两枚大分组;版本入口紧邻排序分段
// 左侧——用户口径是「版本应该在依赖 / 类型左边」,别把版本放回最右。
expect(versionPicker!.compareDocumentPosition(sortGroup!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(actionsRow!.lastElementChild).toBe(sortGroup);
});
test('switches the current version through the entry and reports it to the host', async () => {
installResourceCardIntersectionObserver();
const manifest = createVersionedManifest();
@@ -0,0 +1,182 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createGameCreationAppManifest,
type GameCreationAppPreviewState,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import ProjectDevelopmentView from '../src/view/project-development';
import { installTauriRuntime } from './tauriRuntimeFake';
/**
* 「进入项目时已经站在运行页,却没有画面」要自动载入一次。
*
* 现场:从另一个项目切过来时工作台不重挂(`mode` 是工作台自己的 state),上一条项目留下的运行
* 视图会原样保留,画面却已经没了;用户看到的就是「客户端运行画面尚未载入 + 点击顶部「运行」
* 页签」,像坏了一样。
*/
const OFFLINE_PREVIEW: GameCreationAppPreviewState = {
status: 'running',
url: 'https://example.com/game',
port: 443,
};
/** 有完成的代码原型才算「可运行」——运行页签才会真的发播放请求。 */
function buildManifest(id: string, name: string) {
const manifest = createGameCreationAppManifest(id, name);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype' ? { ...task, status: 'completed' } : task,
);
return manifest;
}
function installInvoke() {
installTauriRuntime({
core: {
invoke: vi.fn(async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: [],
referenceEdges: [],
taskFlows: [],
categories: [],
diagnostics: [],
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
}),
},
});
}
function renderWorkbench({
projectPath,
name,
preview = OFFLINE_PREVIEW,
onPlay,
}: {
projectPath: string;
name: string;
preview?: GameCreationAppPreviewState;
onPlay: () => void;
}) {
installInvoke();
return render(
<ProjectDevelopmentView
projectName={name}
projectPath={projectPath}
manifest={buildManifest('run-auto-load', name)}
attachments={[]}
recentRunStatus="completed"
recentRunStopReason={null}
preview={preview}
chat={null}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
onPlay={onPlay}
/>,
);
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe('运行视图没有画面时的自动载入', () => {
it('切到另一个项目时自动补一次播放,同一个项目不重复发', async () => {
const onPlay = vi.fn();
const view = renderWorkbench({
projectPath: '/tmp/project-a',
name: '项目 A',
onPlay,
});
// 先在「项目 A」上把视图切到运行(没有可嵌入的预览:切过去只有空态)。
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
await screen.findByText('客户端运行画面尚未载入');
expect(onPlay).toHaveBeenCalledTimes(1);
// 工作台不重挂,只换项目:运行视图原样留下,画面是空的 → 自动补一次。
view.rerender(
<ProjectDevelopmentView
projectName="项目 B"
projectPath="/tmp/project-b"
manifest={buildManifest('run-auto-load-b', '项目 B')}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
preview={OFFLINE_PREVIEW}
chat={null}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
onPlay={onPlay}
/>,
);
expect(onPlay).toHaveBeenCalledTimes(2);
// 同一个项目再渲染一次(manifest 刷新、对话更新之类)不该反复发播放请求。
view.rerender(
<ProjectDevelopmentView
projectName="项目 B"
projectPath="/tmp/project-b"
manifest={buildManifest('run-auto-load-b', '项目 B 更新')}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
preview={OFFLINE_PREVIEW}
chat={null}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
onPlay={onPlay}
/>,
);
expect(onPlay).toHaveBeenCalledTimes(2);
});
it('点「运行」页签本身只发一次播放,不被自动载入重复触发', async () => {
const onPlay = vi.fn();
renderWorkbench({
projectPath: '/tmp/project-a',
name: '项目 A',
onPlay,
});
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(onPlay).toHaveBeenCalledTimes(1);
});
it('有活体预览时不补发:画面已经在,没必要再启动一次', async () => {
const onPlay = vi.fn();
renderWorkbench({
projectPath: '/tmp/project-a',
name: '项目 A',
preview: {
status: 'running',
url: 'http://127.0.0.1:4173/',
port: 4173,
},
onPlay,
});
// 会话内确认过的预览会自动切到运行视图;此时画面在,谁都不该发播放请求。
await screen.findByTitle('项目 A 游戏运行画面');
expect(onPlay).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createGameCreationAppManifest,
type GameCreationAppPreviewState,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import ProjectDevelopmentView from '../src/view/project-development';
import { installTauriRuntime } from './tauriRuntimeFake';
const PREVIEW_URL = 'http://127.0.0.1:4173/';
function installInvoke() {
installTauriRuntime({
core: {
invoke: vi.fn(async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return {
resourceIds: [],
referenceEdges: [],
taskFlows: [],
categories: [],
diagnostics: [],
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
throw new Error(`unexpected invoke ${command}`);
}),
},
});
}
function renderRunView(
preview: GameCreationAppPreviewState = {
status: 'running',
url: PREVIEW_URL,
port: 4173,
},
) {
installInvoke();
const onPlay = vi.fn();
const manifest = createGameCreationAppManifest(
'run-preview-refresh',
'运行页刷新画面',
);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype' ? { ...task, status: 'completed' } : task,
);
render(
<ProjectDevelopmentView
projectName={manifest.name}
projectPath="/tmp/run-preview-refresh"
manifest={manifest}
attachments={[]}
recentRunStatus="completed"
recentRunStopReason={null}
preview={preview}
chat={null}
onHomeOpen={vi.fn()}
onProjectsOpen={vi.fn()}
onNotice={vi.fn()}
onPlay={onPlay}
/>,
);
return { onPlay };
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe('运行页「刷新运行画面」', () => {
it('点一下就重新载入画面:iframe 换新节点、地址不变,并顺带把预览确认成活体', async () => {
const { onPlay } = renderRunView();
await screen.findByTitle('运行页刷新画面 游戏运行画面');
const before = document.querySelector('iframe');
expect(before).not.toBeNull();
expect(before?.getAttribute('src')).toBe(PREVIEW_URL);
fireEvent.click(screen.getByRole('button', { name: '刷新运行画面' }));
const after = document.querySelector('iframe');
// 换元素身份=浏览器重新载入这一页:游戏预览不是 vite dev 的实时刷新,只有这一条路。
expect(after).not.toBe(before);
expect(after?.getAttribute('src')).toBe(PREVIEW_URL);
expect(onPlay).toHaveBeenCalledTimes(1);
});
it('没有活预览时不渲染刷新,而不是留一个点了没反应的入口', async () => {
// 非 loopback 地址会被 `resolveEmbeddedPreviewUrl` 拒掉。此时切到运行页只有空态,
// 该走的是顶栏「运行」页签——刷新入口不该在没画面可刷的时候出现。
renderRunView({
status: 'running',
url: 'https://example.com/game',
port: 443,
});
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
await screen.findByText('客户端运行画面尚未载入');
expect(screen.queryByRole('button', { name: '刷新运行画面' })).toBeNull();
});
});
@@ -0,0 +1,83 @@
import { describe, expect, test } from 'vitest';
import {
resolveWorkbenchToolbarLayout,
type WorkbenchToolbarLayout,
} from '../src/view/project-development/workbenchToolbarModel';
/**
* 工作台顶栏降级阶梯的判据:顺序、余量与「都放不下」的兜底。
*
* 每一档的真实宽度由 DOM 量(`useWorkbenchToolbarLayout` 把候选档位写上去再读 `scrollWidth`),
* 所以这里只喂一个假的量宽函数;真机几何由浏览器实测确认,这一条钉的是**顺序**。
*/
function ladder(
availableWidth: number,
widths: Record<WorkbenchToolbarLayout, number>,
) {
const measured: WorkbenchToolbarLayout[] = [];
const layout = resolveWorkbenchToolbarLayout(availableWidth, (candidate) => {
measured.push(candidate);
return widths[candidate];
});
return { layout, measured };
}
describe('工作台顶栏降级阶梯', () => {
test('放得下就停在 full,不去量后面的档位', () => {
const { layout, measured } = ladder(1000, {
full: 900,
'compact-version': 800,
'collapsed-actions': 400,
});
expect(layout).toBe('full');
expect(measured).toEqual(['full']);
});
test('full 放不下先收版本名的括号段,动作一枚都不动', () => {
const { layout, measured } = ladder(1000, {
full: 1100,
'compact-version': 900,
'collapsed-actions': 500,
});
expect(layout).toBe('compact-version');
expect(measured).toEqual(['full', 'compact-version']);
});
test('收掉括号段还不够,才把「打开项目目录 / 资源面板 / 整理画布」收进「更多」', () => {
const { layout, measured } = ladder(1000, {
full: 1400,
'compact-version': 1100,
'collapsed-actions': 700,
});
expect(layout).toBe('collapsed-actions');
expect(measured).toEqual(['full', 'compact-version', 'collapsed-actions']);
});
test('三档都放不下时停在最后一档,不回到前面已经试过的档位', () => {
const { layout, measured } = ladder(500, {
full: 1400,
'compact-version': 1100,
'collapsed-actions': 700,
});
expect(layout).toBe('collapsed-actions');
expect(measured).toEqual(['full', 'compact-version', 'collapsed-actions']);
});
test('临界宽度留 1px 余量:正好贴边算放得下,多出 2px 才降档', () => {
expect(
ladder(1000, {
full: 1001,
'compact-version': 900,
'collapsed-actions': 500,
}).layout,
).toBe('full');
expect(
ladder(1000, {
full: 1002,
'compact-version': 900,
'collapsed-actions': 500,
}).layout,
).toBe('compact-version');
});
});
+1
View File
@@ -78,6 +78,7 @@
- [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md)
- [AGC 栏目画布底部工具栏入口矩阵](./technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md):栏目画布底部工具栏的入口矩阵、可用条件与验收口径。
- [AGC 资源工作台三处交互收口改动前对照图](./technical/assets/agc-resource-workbench-ui-before-20260914/README.md):任务侧栏两个关闭入口、左侧贴边折叠把手、顶部播放按钮居中悬浮三张改动前截图与问题说明。
- [AGC 顶栏布局收口改动后对照图](./technical/assets/agc-toolbar-layout-after-20261004/README.md):顶栏一行到底、版本入口钉右并三档降级、「播放」并入「运行」页签、运行画面刷新入口四张改动后截图与口径。
- [AGC 资源派生与非破坏性编辑合同](./technical/【技术方案】AGC资源派生与非破坏性编辑合同-2026-09-09.md):AGC 全类型现有资源非破坏性编辑的权威合同,约束资源派生、替换与写回边界。
- [AGC 聊天素材引用](./【功能说明】AGC聊天素材引用-2026-09-08.md):聊天输入框 @ 引用项目素材的入口、引用模型与「当前版本素材」口径。
- [AGC 聊天 AI 润色与发送前提醒](./【功能说明】AGC聊天AI润色与发送前提醒-2026-09-10.md):提示词润色与发送前提醒的交互、失败与取消口径。
@@ -131,7 +131,7 @@
- 资源总览的“资源依赖 / 资源类型”视图切换使用连通的分段按钮组,相邻选项共享边界并保持唯一选中语义。每个分段都必须有清晰的键盘焦点指示,焦点环不得被分段容器的圆角或 `overflow` 裁切。
- 右侧 Supervisor 对话中,用户消息使用右对齐、最大宽度受限的主题暖色气泡,assistant 消息保持左对齐;消息换行不得产生水平溢出,执行过程卡继续占满消息区可用宽度。消息列表必须约束在右侧对话列内并独立滚动,不得覆盖中央资源或运行视图;提交按钮必须保留随状态变化的可访问名称。气泡正文在 light / dark 平台主题下均须满足 WCAG AA 普通文本 `4.5:1` 对比度。
- 客户端正式产品仍只按最小 `1280×720` 横屏合同交付,并保留 `1280×800` 默认窗口与既有基线验收;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。
- 工作台顶部播放按钮紧贴「资源管理 / 运行」模式切换之后、整组左对齐;不再在桌面视窗中水平居中悬浮;两个页签下都常驻(运行视图空态与预览失败态要靠它重跑)。资源管理视窗触发播放时直接切换到运行视窗并启动本地预览,不再弹出 `game.run_local` 二次确认。
- 工作台顶部工具条固定**一行到底**,**两枚体量最大的分组控件分居这一行两端**:左端「资源管理 / 运行」模式分段,右端「依赖 / 类型」排序分段;中间依次是动作按钮与版本入口,**版本入口紧邻排序分段左侧**(不放在最右)、固定显示 `版本 N`,空间够时补上括号里的「创建原因 + 时间」。「运行」页签本身就是播放入口(页签前带 ▶ 图标;切过去即启动本地预览,不再单开一枚「播放」按钮,也不再弹出 `game.run_local` 二次确认;运行视图空态与预览失败态靠再点一次该页签重跑,不可运行时页签不置灰、点了不切视图并出提示)。**站在运行视图上却没有画面时自动补发一次载入**(典型场景:从别的项目切过来,工作台不重挂,运行视图原样留下而画面已经没了),同一个项目只自动补一次,画面已在就不重复启动。空间不足按固定顺序降级:先只留 `版本 N`,再把「打开项目目录 / 资源面板 / 整理画布」收进「更多」下拉——任何宽度都不把控件甩到第二行,右端始终由「依赖 / 类型」收住。运行画面右下角提供「刷新运行画面」:游戏预览不是实时刷新,改完代码需要重载页面。
- 创建模式素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择。图片精修继承源名称和用途,不显示创建模式保存设置;候选图片只从选中图片的“设为最终图”提交。精修顶栏只保留返回、导入、定位当前最终图、撤销和重做,删除进入图片上下文工具栏,通用 AI 生成只保留给创建模式。图片输出统一使用 PNG;创建模式工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。
- 首页创作输入区与“最近项目”之间不展示共享项目状态文本,“最近项目”标题下也不追加解释性副标题;默认、成功、进行中或失败状态均不得在该位置形成文字行,项目管理页继续保留自己的状态反馈。
@@ -32,6 +32,18 @@
- 权威文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的 `game_distribution_game` 节,以及 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 的「游玩计数(已实现)」节。
- 验证:`cargo check -p api-server` 与 `cargo test -p api-server game_play_counter`(9 passed)通过;前端定向 vitest(点击上报断言 + clientId 稳定性)与 `eslint --max-warnings 0` 通过;`npm run check:server-rs-ddd`、`npm run check:generated-bindings`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。
## 2026-10-04 AGC 工作台顶栏一行到底:三档降级 + 播放并入运行页签 + 运行画面刷新
- 背景:用户现场截图指出四个问题(改动前的形态与口径见 `docs/technical/assets/agc-toolbar-layout-after-20261004/README.md`):① 顶栏放不下时两侧容器各自 `flex-wrap: wrap`,第二行只剩「播放」与版本入口,两行控件分裂;② 版本入口不贴右缘,被前面按钮的文本宽度顶开;③「播放」与「运行」两个入口说的是同一件事;④ 运行画面里的游戏不是 vite dev 的实时刷新,改完代码只能切到资源管理再切回来才能重载页面(飞书讨论里提的「单独的刷新」)。
- 决策(顶栏排版):工具条改**一行到底**(`flex-wrap: nowrap` + `overflow: hidden`),空间不足不再换行,而是按固定顺序降级、档位写在工具条的 `data-layout` 上:`full` → `compact-version`(版本入口只留 `版本 N`)→ `collapsed-actions`(`打开项目目录 / 资源面板 / 整理画布` 收进「更多」下拉)。顺序与 1px 判定余量是纯函数(`workbenchToolbarModel.ts`,单独用例钉顺序);档位由 `useWorkbenchToolbarLayout` 实测写入——`ResizeObserver` 管可用宽度、`MutationObserver` 管**内容**变化(切运行页、出现「恢复草稿」都不改顶栏宽度,只看尺寸会停在旧档位上),每次量宽都把候选档位真的写到 DOM 再读 `scrollWidth`。档位不参与 React 状态:写的是工具条自己的属性,React 不声明就不会覆盖;也没用容器查询(阈值随模式与按钮出现与否变化,写死必然抖)。
- 决策(两个「吞掉溢出」的坑,都是实测踩出来的):`overflow: hidden` 的 flex 子项能缩到 0 或靠省略号吸收溢出,档位判定就永远量不到真实溢出——所以版本入口默认 `min-width: max-content`(只在 `collapsed-actions` 档放开为 `0`,那一档已退无可退,省略号才是兜底)、「依赖 / 类型」分段补 `flex: 0 0 auto`(此前窄宽度下被压成 0 宽)。`max-width: 1000px` / `max-width: 760px` 两处把工具条改成 `flex-direction: column` 的媒体查询删除(那是「第二行」的另一个来源,降级已由档位负责),`≤1000px` 里给动作区的 `justify-content: flex-end` 一并删除(溢出会甩到左边,`scrollWidth` 看不见)。最后一档确实放不下时(视口远小于 1280 合同宽度)才改右对齐:宁可裁左边,也不把钉在最右的版本入口裁没。
- 决策(版本入口与两端分组):工具条这一行**两端留给体量最大的两枚分组控件**——左端「资源管理 / 运行」、右端「依赖 / 类型」(用户口径:「最大的这两个放两边」),中间依次是动作按钮与版本入口;版本入口 `margin-left: auto` 推到动作区右侧,且紧邻排序分段左侧(用户口径:「版本应该在依赖 / 类型左边」),不再是最右那一枚。显示 `版本 N(原因 · 时间)`,其中 `版本 N` 复用资源画布版本卡的编号口径(`manifest.versions` 落盘顺序 + 1,`formatIterationVersionTitle`),括号里那截是独立一层(`formatIterationVersionDetail`)——窄档位收掉的是这一层而不是整枚入口;可访问名、菜单项与排障文案一律保留完整标识。DOM 顺序由 `tests/resourceVersionSwitch.test.tsx`(版本入口在排序分段之前)与 `tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx`(排序分段是动作行最后一个子元素)双向钉住。
- 决策(播放并入运行 + 刷新入口):删掉独立的「播放」按钮,「运行」页签前加 ▶ 图标,点页签=`showRunView()` + `onPlay?.()`(与旧播放按钮逐字等价,含「再点一次=重跑」);不可运行时页签不置灰、点了既不切视图也不发播放请求,只出既有提示。运行画面右下角新增「刷新运行画面」(全屏那一枚左侧):`onPlay` 命中活体预览只切视图、不重启服务,真正重载页面靠换 `iframe` 的元素身份(`LocalGamePreviewFrame` 新增 `reloadNonce`)——运行页在另一个端口上,跨域 iframe 里 `contentWindow.location.reload()` 会被浏览器挡掉。
- 决策(进入项目自动载入):站在运行视图上却没有画面可看时自动补发一次 `onPlay`——典型现场是从别的项目切过来(工作台不重挂,`mode` 是工作台自己的 state,上一条项目的运行视图原样留下而画面已经没了),用户只会看到「客户端运行画面尚未载入」,像坏了一样。只在**没有画面且可运行**时发;`showRunView` 先记账再自己发播放(`autoRunPreviewProjectRef`)所以点页签不会被重复触发;同一个项目只自动补一次,失败不打转,手动重跑仍走页签或画面上的刷新按钮。
- 边界:不改后端、契约与 SpacetimeDB;`runAvailable` / `showRunView` 的门槛语义不变,自动切运行的两条路径(会话内已确认的预览、播放请求)不走 `showRunView`,不会多发播放请求。窄于合同宽度只保证不崩,不做移动端布局。
- 影响范围:`apps/ai-game-creator-shell/src/{styles.css,view/project-development/{index.tsx,workbenchToolbarModel.ts,useWorkbenchToolbarLayout.ts,WorkbenchMoreActionsMenu.tsx},features/resource-canvas/{GameRunVersionPicker.tsx,resourceCanvasVersionBindingModel.ts},features/project-workspace/LocalGamePreviewFrame.tsx}`;用例 `tests/{workbenchToolbarLayout,runPreviewRefresh,runAutoLoadOnEnter}.test.ts(x)`(新增)、`tests/appSurface/project-development.suite.ts`、`tests/{gameRunToolbarActionsStyle,resourceCanvasVersionBindingModel}.test.ts`;文档 `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S17 改写 + 新增 S17a)、`docs/README.md`、新目录 `docs/technical/assets/agc-toolbar-layout-after-20261004/`。
- 验证:`npx vitest run apps/ai-game-creator-shell/tests`(200 passed / 1 skipped 文件,1929 passed / 17 skipped 用例,末次全量);定向 8 个文件 253 passed;`npm run typecheck`(在 `apps/ai-game-creator-shell`,含 `check:tests:types`——只跑 `tsc -p tsconfig.json` 覆盖不到 `tests/`)、eslint `--max-warnings 0`、`prettier --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 全绿。真机几何用一次性 Vite 夹具在真实 Chromium 里逐档实测(视口 1412 / 1240 / 1100 / 1024 / 960 / 860 / 800 / 760 / 700 / 640 / 560 / 480 / 400):始终单行、排序分段贴右缘(`工具栏右缘 - padding - 排序分段右缘 = 0`)、版本入口始终在排序分段左侧、1240/1100 走 `compact-version`、1024/800/700/640/560/480 走 `collapsed-actions`,「更多」下拉三条动作可点且点「资源面板」真的开面板、点「刷新运行画面」`iframe` 换新节点而 `src` 不变;截图见 `docs/technical/assets/agc-toolbar-layout-after-20261004/`。
## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602)
- 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,24 @@
# AGC 顶栏布局收口:改动后对照图(2026-10-04)
本目录是「优化 AGC 标题栏布局」(`fix/agc-toolbar-layout`)的改动后截图,供 PR 与后续回归对照使用。四张图都取自一次性冒烟夹具(`.codex/skills/agc-workbench-browser-smoke`:真实 Chromium 挂 `ProjectDevelopmentView`,不是 Tauri 客户端),因此没有客户端窗口边框与左侧启动器侧栏。
| 文件 | 画面 | 改动后的形态 |
| ------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `01-toolbar-resources-full-1412.jpg` | 资源管理页(窗口 1412) | 顶栏一行到底:**两端是两枚大分组**——左端「资源管理 / 运行」,右端「依赖 / 类型」;中间依次是动作按钮与版本入口,版本入口紧邻排序分段左侧并显示完整标识 `版本 1(智能体修订 · 2026/10/3 14:21:05)` |
| `02-toolbar-resources-compact-1240.jpg` | 资源管理页(窗口 1240) | 空间不足第一档:版本入口收成 `版本 1`,其余一枚不动、不换行,行尾仍由「依赖 / 类型」收住 |
| `03-toolbar-more-menu-800.jpg` | 资源管理页(窗口 800) | 空间不足第二档:`打开项目目录 / 资源面板 / 整理画布` 收进「更多」下拉,展开即同一批动作;行尾仍由「依赖 / 类型」收住 |
| `04-toolbar-run-tab-and-preview-refresh-1412.jpg` | 运行页(窗口 1412) | 「播放」并入「运行」页签(页签前带 ▶);运行画面右下角多一枚「刷新运行画面」,与「全屏预览」并列 |
改动前的问题(用户现场截图):
1. 顶栏放不下时两侧各自 `flex-wrap: wrap`:第二行只剩「播放」与版本入口,两行控件分裂;
2. 版本入口不贴右缘,被前面按钮的文本宽度顶开;
3. 「播放」与「运行」是两个入口说同一件事;
4. 运行画面里的游戏不是实时刷新,改完代码只能切到资源管理再切回来重载;
5. 两枚体量最大的分组控件(「资源管理 / 运行」「依赖 / 类型」)挤在同一侧,版本入口又压在「依赖 / 类型」右边——现在两枚大分组分居一行两端,版本入口在排序分段左侧。
相关文档口径已同步到:
- [`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../../../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md)(顶部工具条那一行)
- [`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`](../【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md)(S17 播放、S17a 刷新)
- [`docs/project-memory/shared-memory/decision-log.md`](../../../project-memory/shared-memory/decision-log.md)(顶栏降级档位与两端分组决策)
@@ -63,8 +63,9 @@
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| ---------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **S16** 版本切换 | 运行模块**右上角**版本选择器 → 切到另一版本 | 触发钮显示当前版本;切换后资源卡「当前使用」高亮与 **@ 面板「当前版本素材」同步更新**,并重载当前预览 | L165、L190、L324、L530;#309 C7 | `aria-label="当前版本:<label>"`、菜单 `role="listbox" aria-label="切换游戏版本"`、选项 `role="option" aria-selected`;`[data-used-by-current-version="true"]` 集合随切换变化 | **C7 只做记录层 + UI 层**;"历史版本独有资源集合被运行时按版本加载"需新增版本化资源解析机制,**本轮未实现**;版本卡卡面**不渲染**「项目修订 / 父版本」文字 |
| **S17** 播放 | 顶部「播放」 | 直接切运行视图并启动本地预览;**不再弹 `game.run_local` 二次确认**;预览占满中央可用区、无 iframe 滚动条 | L16、L71–72、L122、L493 | `data-resource-view-state` 变为 `undefined`;`await window.__TAURI__.core.invoke('get_local_game_preview_status',{projectPath})` → `status==='running'` 且有 `url`(形如 `http://127.0.0.1:<随机端口>/`) | 播放按钮 `disabled={!runAvailable \|\| !onPlay \|\| uiEditorRoute!==null}`,**不可用时点不动**;不可用提示实现文案为「首个可运行原型尚未完成,运行视图暂不可用」,与 PRD L165 表述不同(见 7.4) |
| **S16** 版本切换 | 顶栏动作区的版本入口(「依赖 / 类型」左侧,运行页与资源页都在)→ 切到另一版本 | 触发钮显示当前版本;切换后资源卡「当前使用」高亮与 **@ 面板「当前版本素材」同步更新**,并重载当前预览 | L165、L190、L324、L530;#309 C7 | `aria-label="当前版本:<label>"`、菜单 `role="listbox" aria-label="切换游戏版本"`、选项 `role="option" aria-selected`;`[data-used-by-current-version="true"]` 集合随切换变化 | **C7 只做记录层 + UI 层**;"历史版本独有资源集合被运行时按版本加载"需新增版本化资源解析机制,**本轮未实现**;版本卡卡面**不渲染**「项目修订 / 父版本」文字 |
| **S17** 播放 | 顶部「运行」页签(页签前带 ▶ 图标) | 切到运行视图并启动本地预览;**不再弹 `game.run_local` 二次确认**;预览占满中央可用区、无 iframe 滚动条;**站在运行视图却没有画面时(例如从别的项目切过来)自动补发一次载入**,同一个项目不重复补发 | L16、L71–72、L122、L493 | `data-resource-view-state` 变为 `undefined`;`await window.__TAURI__.core.invoke('get_local_game_preview_status',{projectPath})` → `status==='running'` 且有 `url`(形如 `http://127.0.0.1:<随机端口>/`) | 播放并进「运行」页签,**没有独立的「播放」按钮**:不可运行时(`runAvailable === false`)页签不置灰,点了既不切视图也不发播放请求,只出提示「首个可运行原型尚未完成,运行视图暂不可用」(与 PRD L165 表述不同,见 7.4) |
| **S17a** 刷新运行画面 | 运行画面右下角「刷新运行画面」(全屏那一枚左边) | 重新载入游戏页面:游戏预览不是 vite dev 的实时刷新,改完代码要看到新一版只有重载页面这一条路(此前只能切到资源管理再切回来) | L134 | `iframe` 换成新 DOM 节点且 `src` 不变(跨域 iframe 里 `contentWindow.location.reload()` 会被浏览器挡掉,实现走 React `key` 换元素身份);点击同时向宿主确认一次预览还活着(`onPlay` 命中活体预览不重启服务)。没有活预览时**不渲染**这枚按钮 | 全屏里照旧可用(两枚按钮都在画面那一格内);只重载页面,不重跑构建 |
| **S18** 停止与退出收尾 | 聊天侧工程操作组点「停止预览」;或关闭客户端 | 预览停止、manifest `preview` 对齐 `stopped`(清 url/port);退出时也统一收尾 | L165 | 停止后 `get_local_game_preview_status` 非 running;`.agent/logs/preview.log` 追加 `stopped`;退出后重开同一项目应落回 `resource-overview` | 停止按钮**不在**运行视图工具栏,在工作台右侧工程操作组;退出失败会打 `preview.gui_exit.stop_failed` |
### E 阶段 · 对话侧
@@ -170,6 +171,7 @@ window.__TAURI__.core.invoke = (cmd, args) => {
| S15 | 弹窗是否出现 `被 N 个游戏版本使用`;不勾选时版本记录仍在(可读但悬空) |
| S16 | `aria-label="当前版本:…"` 是否随切换更新;`[data-used-by-current-version="true"]` 集合是否变化 |
| S17 | `get_local_game_preview_status` 返回的 `status/url/port`;`.agent/logs/preview.log` 的 `running` 行 |
| S17a | `iframe` 是否是**新的** DOM 节点(`document.querySelector('iframe')` 前后比较)而 `src` 不变;没有活预览时 `button[aria-label="刷新运行画面"]` 是否为 `null` |
| S18 | 退出后重开项目是否落回资源总览;Rust 日志中的 `preview.gui_exit.stop_failed` |
| S19 | chip 的 `data-resource-reference-id`;两页签 `role="tab"` 的 `aria-selected`;空态文案(`当前版本还没有绑定素材` / `当前项目还没有已登记素材` / `没有匹配的素材`) |
| S20 | 弹窗标题「发送前提醒」;`localStorage['agc.chat.prompt-polish-reminder.disabled']` 的值;`润色中…` 或 `AI 润色失败,可重试` 状态行 |
@@ -204,6 +206,7 @@ api-server 是否本次重启:□ 是 □ 否
| S15 删除三分支 | ☐ 无引用 ☐ 引用未勾选 ☐ 勾选连带 | |
| S16 版本切换 + 当前使用高亮 + @ 同步 | ☐ 过 ☐ 不过 | |
| S17 播放 → 运行视图 + 本地预览 | ☐ 过 ☐ 不过 | url:******\_\_****** |
| S17a 刷新运行画面(iframe 换节点、src 不变) | ☐ 过 ☐ 不过 | |
| S18 停止预览 / 退出收尾对齐 stopped | ☐ 过 ☐ 不过 | |
| S19 @ 引用两页签 + 独立筛选 + chip 原子性 | ☐ 过 ☐ 不过 | |
| S19a @ 面板标签筛选(多标签 AND,叠加关键字与功能分类) | ☐ 过 ☐ 不过 | |
@@ -264,7 +267,7 @@ api-server 是否本次重启:□ 是 □ 否
| 偏差 | 文档表述 | 代码实际 | 建议 |
| ---------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| 资源卡「打开详情」 | PRD L61–62 要求"打开详情"与"播放 / 暂停"是可分别键盘聚焦的同级按钮 | 不存在该按钮(测试显式断言为 `null`);真正入口是 `选中资源:<栏目> <名>`,播放按钮是 `播放/暂停 <名>`;**只读信息浮层已回归**(2026-09-11 工具条「信息」动作) | 按 #309 C3(取消资源详情面板与全屏编辑路由)判,**C3 取消的仍只是可编辑详情面板与全屏编辑路由**;PRD L61–62 建议同步修订 |
| 运行入口不可用文案 | PRD L165 显示"当前无可运行版本",但仍允许点击 | 源码中无此字符串;实现为「首个可运行原型尚未完成,运行视图暂不可用」;且播放按钮 `disabled`,只有"运行"tab 不 disabled、用 `data-unavailable` 表达 | 建议按"tab 可点 + 播放按钮置灰"判,并把 PRD L165 文案对齐实现 |
| 运行入口不可用文案 | PRD L165 显示"当前无可运行版本",但仍允许点击 | 源码中无此字符串;实现为「首个可运行原型尚未完成,运行视图暂不可用」;「运行」页签不置灰、用 `data-unavailable` 表达,点了不切视图也不发播放请求 | 按"页签可点 + 不可用时什么都不做并出提示"判,并把 PRD L165 文案对齐实现 |
| 画布生成入口 | #309「画布生成入口未接」 | 已接两条:浮层入口只做视频;音频与图片类在栏目画布底部工具栏(见 S11a / PRD §3.10) | 按代码与当前产品口径判,并更新 #309 进度段 |
| 分类手设入口 | 旧版 PRD「用户可在分类与标签面板手动设置 category」 | 已移除;面板只编辑 tags(已随 `6bdc8bbd9` 入库) | 按新口径判 |
| 真机 manifest 分类分布 | — | 落盘 `unclassified 57 / ui-interaction 3 / scene 1` | **读时自愈**会把 kind 可明确分类的资产显示到正确栏目,判定必须看 UI 栏目计数,不能看 manifest 文件,否则必然误报"分类不生效" |