增加资源页签新资源红点 #191

Merged
kdletters merged 2 commits from codex/resource-tab-unread-dot into master 2026-08-25 10:48:12 +08:00
4 changed files with 188 additions and 0 deletions
+18
View File
@@ -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;
@@ -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<string, unknown>) => {
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',
@@ -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` 次重试,持续跨窗口写入时不得无限自旋。当前提示只说明“布局已在其他窗口更新”,不得要求用户重新拖动。
@@ -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 能力继续独立实施。