From 9eb537c6ffe869f2a1f7a148ea47bdbbc7f95109 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sun, 4 Oct 2026 15:56:41 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E8=A7=86=E5=9B=BE=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E7=94=BB=E9=9D=A2=E6=97=B6=E8=87=AA=E5=8A=A8=E8=A1=A5?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E8=BD=BD=E5=85=A5=EF=BC=88=E8=BF=9B=E5=85=A5?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE/=E5=88=87=E9=A1=B9=E7=9B=AE=E7=9A=84?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=A1=B5=E7=A9=BA=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.tsx:新增 autoRunPreviewProjectRef 记账,站在运行视图却没有可嵌入预览且可运行时自动补发一次 onPlay,同一个项目只补一次 - index.tsx:showRunView 先记账再发播放,避免点页签时被自动补发重复触发一次 - 用例:新增 runAutoLoadOnEnter(切项目自动补发一次、同项目不重复、点页签只发一次、有活体预览时不补发) - 文档:PRD 顶栏条款、验收用例 S17 判据、decision-log 同步自动载入口径 --- .../src/view/project-development/index.tsx | 27 +++ .../tests/runAutoLoadOnEnter.test.tsx | 182 ++++++++++++++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 2 +- .../shared-memory/decision-log.md | 5 +- ...用例】AGC资源工作台V3端到端验收-2026-09-11.md | 2 +- 5 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/runAutoLoadOnEnter.test.tsx diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index ba1a6db96..d33097fe6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -2816,6 +2816,29 @@ export default function ProjectDevelopmentView({ setRuntimeInspectMode(false); } }, [embeddedPreviewUrl, mode]); + + /** + * 站在运行视图上却没有画面可看时,自动按一次「运行」。 + * + * 「运行」页签就是播放入口,但**进入项目**这条路上用户可能一进来就站在运行页:从别的项目 + * 切过来时工作台不重挂(`mode` 是工作台自己的 state),上一条项目的运行视图会原样留下, + * 画面却已经没了;此时页面上只有一句「点击顶部「运行」页签」,看起来就是坏的。这里把缺的那 + * 一下自动补上——只在**没有画面**时发,画面还在就不重复启动。 + * + * 记账用 ref 而不是 state:`showRunView`(用户点页签)会先记上再自己发一次播放,这里就不会 + * 跟着重复发;同一个项目只自动发一次,失败后不打转,手动重跑仍走页签或画面上的刷新按钮。 + */ + const autoRunPreviewProjectRef = useRef(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(); @@ -7369,6 +7392,9 @@ export default function ProjectDevelopmentView({ * (切视图 + 启动本地预览)。页签因此在不可运行时不置灰、只给提示(见 `run-unavailable-hint`), * 点了不切视图也不发播放请求。 * + * 这里记一次账(`autoRunPreviewProjectRef`)再发播放:下一条 effect 会在「运行视图 + 没有 + * 画面」时自动补发,不记账就会跟着重复发一次。 + * * 自动切运行那两条路径(会话内已确认的预览、播放请求)不走这里,它们只是**呈现**运行结果, * 不该再发起一次播放。 */ @@ -7385,6 +7411,7 @@ export default function ProjectDevelopmentView({ captureResourceSectionScrollPositions(); stopActiveCardMedia(); setMode('run'); + autoRunPreviewProjectRef.current = projectPath; onPlay?.(); } diff --git a/apps/ai-game-creator-shell/tests/runAutoLoadOnEnter.test.tsx b/apps/ai-game-creator-shell/tests/runAutoLoadOnEnter.test.tsx new file mode 100644 index 000000000..fb2e37060 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runAutoLoadOnEnter.test.tsx @@ -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) => { + 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( + , + ); +} + +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( + , + ); + expect(onPlay).toHaveBeenCalledTimes(2); + + // 同一个项目再渲染一次(manifest 刷新、对话更新之类)不该反复发播放请求。 + view.rerender( + , + ); + 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(); + }); +}); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index c4c80f8ff..d0912e09d 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -131,7 +131,7 @@ - 资源总览的“资源依赖 / 资源类型”视图切换使用连通的分段按钮组,相邻选项共享边界并保持唯一选中语义。每个分段都必须有清晰的键盘焦点指示,焦点环不得被分段容器的圆角或 `overflow` 裁切。 - 右侧 Supervisor 对话中,用户消息使用右对齐、最大宽度受限的主题暖色气泡,assistant 消息保持左对齐;消息换行不得产生水平溢出,执行过程卡继续占满消息区可用宽度。消息列表必须约束在右侧对话列内并独立滚动,不得覆盖中央资源或运行视图;提交按钮必须保留随状态变化的可访问名称。气泡正文在 light / dark 平台主题下均须满足 WCAG AA 普通文本 `4.5:1` 对比度。 - 客户端正式产品仍只按最小 `1280×720` 横屏合同交付,并保留 `1280×800` 默认窗口与既有基线验收;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。 -- 工作台顶部工具条固定**一行到底**:左侧只有「资源管理 / 运行」模式切换,**「运行」页签本身就是播放入口**(页签前带 ▶ 图标;切过去即启动本地预览,不再单开一枚「播放」按钮,也不再弹出 `game.run_local` 二次确认;运行视图空态与预览失败态靠再点一次该页签重跑,不可运行时页签不置灰、点了不切视图并出提示)。右侧动作区贴右,**版本入口钉在最右缘**、固定显示 `版本 N`,空间够时补上括号里的「创建原因 + 时间」,空间不足按固定顺序降级:先只留 `版本 N`,再把「打开项目目录 / 资源面板 / 整理画布」收进「更多」下拉——任何宽度都不把控件甩到第二行。运行画面右下角提供「刷新运行画面」:游戏预览不是实时刷新,改完代码需要重载页面。 +- 工作台顶部工具条固定**一行到底**:左侧只有「资源管理 / 运行」模式切换,**「运行」页签本身就是播放入口**(页签前带 ▶ 图标;切过去即启动本地预览,不再单开一枚「播放」按钮,也不再弹出 `game.run_local` 二次确认;运行视图空态与预览失败态靠再点一次该页签重跑,不可运行时页签不置灰、点了不切视图并出提示)。**站在运行视图上却没有画面时自动补发一次载入**(典型场景:从别的项目切过来,工作台不重挂,运行视图原样留下而画面已经没了),同一个项目只自动补一次,画面已在就不重复启动。右侧动作区贴右,**版本入口钉在最右缘**、固定显示 `版本 N`,空间够时补上括号里的「创建原因 + 时间」,空间不足按固定顺序降级:先只留 `版本 N`,再把「打开项目目录 / 资源面板 / 整理画布」收进「更多」下拉——任何宽度都不把控件甩到第二行。运行画面右下角提供「刷新运行画面」:游戏预览不是实时刷新,改完代码需要重载页面。 - 创建模式素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择。图片精修继承源名称和用途,不显示创建模式保存设置;候选图片只从选中图片的“设为最终图”提交。精修顶栏只保留返回、导入、定位当前最终图、撤销和重做,删除进入图片上下文工具栏,通用 AI 生成只保留给创建模式。图片输出统一使用 PNG;创建模式工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。 - 首页创作输入区与“最近项目”之间不展示共享项目状态文本,“最近项目”标题下也不追加解释性副标题;默认、成功、进行中或失败状态均不得在该位置形成文字行,项目管理页继续保留自己的状态反馈。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 850492ae8..d5262531d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -26,9 +26,10 @@ - 决策(两个「吞掉溢出」的坑,都是实测踩出来的):`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`)——窄档位收掉的是这一层而不是整枚入口;可访问名、菜单项与排障文案一律保留完整标识。 - 决策(播放并入运行 + 刷新入口):删掉独立的「播放」按钮,「运行」页签前加 ▶ 图标,点页签=`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}.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`(199 passed / 1 skipped 文件,1925 passed / 17 skipped 用例,末次全量);定向 8 个文件 253 passed;`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、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):始终单行、版本入口贴右(`chipRightGap = 0`)、1240/1100 走 `compact-version`、1024/800/700/640/560/480 走 `collapsed-actions`,「更多」下拉三条动作可点且点「资源面板」真的开面板、点「刷新运行画面」`iframe` 换新节点而 `src` 不变;截图见 `docs/technical/assets/agc-toolbar-layout-after-20261004/`。 +- 影响范围:`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 文件,1928 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):始终单行、版本入口贴右(`chipRightGap = 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) diff --git a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md index 6be4b730f..46654292b 100644 --- a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md +++ b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md @@ -64,7 +64,7 @@ | 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 | | ---------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **S16** 版本切换 | 运行模块**右上角**版本选择器 → 切到另一版本 | 触发钮显示当前版本;切换后资源卡「当前使用」高亮与 **@ 面板「当前版本素材」同步更新**,并重载当前预览 | L165、L190、L324、L530;#309 C7 | `aria-label="当前版本: