diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 537cd91f7..d195ba73a 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5739,6 +5739,24 @@ iframe.preview-frame { white-space: nowrap; } +.game-resource-outline button .game-resource-outline-label { + position: relative; + overflow: visible; +} + +.game-resource-outline-unread { + position: absolute; + top: -2px; + right: -6px; + width: 6px; + height: 6px; + border: 1px solid var(--platform-nav-fill); + border-radius: 50%; + background: #e5484d; + box-shadow: 0 0 0 1px rgb(229 72 77 / 18%); + pointer-events: none; +} + .game-resource-page { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 087b6d2a4..5eb599eec 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -1387,6 +1387,174 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.queryByRole('button', { name: /放大.*内容/ })).toBeNull(); }); + it('marks newly added resources on inactive section tabs until the user opens them', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-resource-unread', + '资源未读标识测试', + ); + manifest.assets = [ + { + id: 'unread-art-initial', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/initial.png', + source: { kind: 'generated' }, + }, + ]; + let layoutRevision = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutRevision, + positions: [], + updatedAt: layoutRevision, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutRevision += 1; + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutRevision, + positions: args?.positions, + updatedAt: layoutRevision, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const viewProps = { + projectName: manifest.name, + projectPath: '/tmp/workbench-resource-unread', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }; + const rendered = render( + React.createElement(ProjectDevelopmentView, viewProps), + ); + const outline = await screen.findByLabelText('资源栏目大纲'); + + expect( + within(outline).queryByRole('button', { name: /有新资源/ }), + ).toBeNull(); + expect( + within(outline) + .getByRole('button', { name: '美术资源' }) + .getAttribute('aria-current'), + ).toBe('page'); + + const manifestWithCurrentArt = { + ...manifest, + assets: [ + ...manifest.assets, + { + id: 'unread-art-current', + kind: 'character' as const, + mediaType: 'image/png', + localPath: 'assets/current.png', + source: { kind: 'generated' as const }, + }, + ], + }; + rendered.rerender( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + manifest: manifestWithCurrentArt, + }), + ); + expect( + within(outline).queryByRole('button', { name: /有新资源/ }), + ).toBeNull(); + + const manifestWithAudio = { + ...manifestWithCurrentArt, + assets: [ + ...manifestWithCurrentArt.assets, + { + id: 'unread-audio-new', + kind: 'background-music' as const, + mediaType: 'audio/mpeg', + localPath: 'assets/new.mp3', + source: { kind: 'generated' as const }, + }, + ], + }; + rendered.rerender( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + manifest: manifestWithAudio, + }), + ); + + const unreadAudioTab = await within(outline).findByRole('button', { + name: '音乐音效,有新资源', + }); + expect( + unreadAudioTab.querySelector('.game-resource-outline-unread'), + ).not.toBeNull(); + expect( + within(outline).queryByRole('button', { name: '美术资源,有新资源' }), + ).toBeNull(); + + fireEvent.click(unreadAudioTab); + await waitFor(() => { + expect( + within(outline) + .getByRole('button', { name: '音乐音效' }) + .getAttribute('aria-current'), + ).toBe('page'); + expect( + within(outline).queryByRole('button', { name: /有新资源/ }), + ).toBeNull(); + }); + + const nextProjectManifest = createGameCreationAppManifest( + 'workbench-resource-unread-next', + '下一个资源项目', + ); + nextProjectManifest.assets = [ + { + id: 'next-project-code', + kind: 'game-code', + mediaType: 'text/javascript', + localPath: 'game/game.js', + source: { kind: 'generated' }, + }, + ]; + rendered.rerender( + React.createElement(ProjectDevelopmentView, { + ...viewProps, + projectName: nextProjectManifest.name, + projectPath: '/tmp/workbench-resource-unread-next', + manifest: nextProjectManifest, + }), + ); + await waitFor(() => { + expect( + within(screen.getByLabelText('资源栏目大纲')).queryByRole('button', { + name: /有新资源/, + }), + ).toBeNull(); + }); + }); + it('keeps the empty resource overview identical in both modes', async () => { const manifest = createGameCreationAppManifest( 'workbench-empty-section-overview', diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index a74487190..788cf40be 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -321,6 +321,7 @@ type UpdateProjectResourceCanvasLayoutResult = - 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。 - 窗口尺寸变化只改变当前栏目的可视范围,不回写或裁切持久坐标,也不因资源 extent 或 resize 把已平移的 viewport 拉回内容边界。当前客户端继续以 `1280×800` 横屏合同验收。 - 任一栏目出现资源后,资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本` 五栏目分页画布;每个栏目按 `projectId + mode + category` 保留独立 viewport。普通 wheel 切换栏目,`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。 +- 首次载入项目中的既有资源不显示未读标识。当前会话内,非当前栏目出现稳定 ID 的新资源时,在对应栏目名称右上角显示红点;当前栏目新增资源不显示红点,用户通过点击、滚轮或程序跳转进入该栏目后立即清除。未读状态只属于当前前端会话,并按 `projectPath + projectId` 隔离,切换项目时清空,不写入 manifest、布局 sidecar 或后端。 - 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready` 或 `failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。 - 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。当前 scope 内资源自动协调写入使用单写者 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。 - 自动协调 CAS 冲突时直接载入返回的最新布局;仍需协调时可以基于权威 revision 最多追加 `2` 次重试,持续跨窗口写入时不得无限自旋。当前提示只说明“布局已在其他窗口更新”,不得要求用户重新拖动。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 916b22ecc..bfe147026 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -578,6 +578,7 @@ game-project/ - Tauri 命令固定为 `read_local_project_resource_canvas_layout` 与 `update_local_project_resource_canvas_layout`。写命令先只读确认有效 manifest,再通过持久 `.layout.lock` 入口获取句柄级跨窗口系统锁,并在锁内复核 projectId、重新读取当前 sidecar。Unix 使用 `flock`,Windows 使用不共享文件句柄;释放只通过句柄 Drop / 进程退出完成,不使用 mtime stale 回收,也不删除锁文件。其余写入继续复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。 - 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;自动协调写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。资源卡是普通可点击按钮,不绑定卡片级 Pointer 拖动处理器;指针移动不修改 CSS 坐标、不调用 SVG preview、不提交手动布局 CAS。 - 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。 +- 五栏目页签的未读红点由 `ProjectDevelopmentView` 在会话内比较各栏目稳定资源 ID 派生:首次快照只建立基线,只有非当前栏目后续出现新 ID 才加入未读集合;所有栏目切换路径统一以当前栏目变化清除对应未读,项目 scope 变化时重建基线并清空。该状态以 `projectPath + projectId` 隔离,不进入 manifest、布局 sidecar、localStorage、sessionStorage 或后端契约。 - type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序。manifest 资产的 subtype 使用 `asset.kind`,任务产物、导入附件和 Agent 文本成果使用稳定的来源 fallback;subtype 必须进入资源协调签名,不能因 MIME 相同而退化成按名称混排。 - 自动协调保存失败时保留当前会话布局;CAS 冲突载入对方最新布局,需要继续协调时最多追加两次重试,持续跨窗口竞争时停止自旋。用户提示只说明“布局已在其他窗口更新”,不要求重新拖动。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。 - 本布局持久化切片不包含资源关系线、资源替换、聚焦态持久化、分区高度、分区内容倍率、整个画布平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。资源关系线、当前会话内中央聚焦、分区独立高度与内容倍率已在后续独立前端切片接入,都不改变本段 sidecar 合同;其余 P1 能力继续独立实施。