增加资源页签新资源红点
按项目会话追踪非当前栏目新增资源并在切换后清除 为页签名称添加红点样式和无障碍标识 补充资源页签行为测试及工作台文档
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -841,6 +841,15 @@ export default function ProjectDevelopmentView({
|
||||
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
|
||||
const [activeResourceCategory, setActiveResourceCategory] =
|
||||
useState<ResourceCategory | null>(null);
|
||||
const [unreadResourceCategoryState, setUnreadResourceCategoryState] =
|
||||
useState<{
|
||||
scopeKey: string;
|
||||
categories: Set<ResourceCategory>;
|
||||
}>(() => ({ scopeKey: '', categories: new Set() }));
|
||||
const resourceCategorySnapshotRef = useRef<{
|
||||
scopeKey: string;
|
||||
resourceIdsByCategory: Map<ResourceCategory, Set<string>>;
|
||||
} | null>(null);
|
||||
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -1388,6 +1397,24 @@ export default function ProjectDevelopmentView({
|
||||
),
|
||||
[resources],
|
||||
);
|
||||
const resourceIdsByCategory = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
categoryOrder.map((category) => [
|
||||
category,
|
||||
new Set(
|
||||
(projectResourcesByCategory.get(category) ?? []).map(
|
||||
(resource) => resource.id,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
[projectResourcesByCategory],
|
||||
);
|
||||
const resourceCategoryScopeKey = useMemo(
|
||||
() => JSON.stringify([projectPath, manifest.projectId]),
|
||||
[manifest.projectId, projectPath],
|
||||
);
|
||||
const resourcePageCategories = useMemo(
|
||||
() => (resources.length > 0 ? categoryOrder : []),
|
||||
[resources.length],
|
||||
@@ -1401,6 +1428,63 @@ export default function ProjectDevelopmentView({
|
||||
) ??
|
||||
resourcePageCategories[0] ??
|
||||
null;
|
||||
const viewedResourceCategory =
|
||||
mode === 'resources' ? activePageCategory : null;
|
||||
useEffect(() => {
|
||||
const previousSnapshot = resourceCategorySnapshotRef.current;
|
||||
if (
|
||||
!previousSnapshot ||
|
||||
previousSnapshot.scopeKey !== resourceCategoryScopeKey
|
||||
) {
|
||||
resourceCategorySnapshotRef.current = {
|
||||
scopeKey: resourceCategoryScopeKey,
|
||||
resourceIdsByCategory,
|
||||
};
|
||||
setUnreadResourceCategoryState({
|
||||
scopeKey: resourceCategoryScopeKey,
|
||||
categories: new Set(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const categoriesWithNewResources = categoryOrder.filter((category) => {
|
||||
if (category === viewedResourceCategory) {
|
||||
return false;
|
||||
}
|
||||
const previousIds =
|
||||
previousSnapshot.resourceIdsByCategory.get(category) ?? new Set();
|
||||
return Array.from(resourceIdsByCategory.get(category) ?? []).some(
|
||||
(resourceId) => !previousIds.has(resourceId),
|
||||
);
|
||||
});
|
||||
resourceCategorySnapshotRef.current = {
|
||||
scopeKey: resourceCategoryScopeKey,
|
||||
resourceIdsByCategory,
|
||||
};
|
||||
setUnreadResourceCategoryState((current) => {
|
||||
const categories =
|
||||
current.scopeKey === resourceCategoryScopeKey
|
||||
? new Set(current.categories)
|
||||
: new Set<ResourceCategory>();
|
||||
let changed = current.scopeKey !== resourceCategoryScopeKey;
|
||||
if (viewedResourceCategory && categories.delete(viewedResourceCategory)) {
|
||||
changed = true;
|
||||
}
|
||||
categoriesWithNewResources.forEach((category) => {
|
||||
if (!categories.has(category)) {
|
||||
categories.add(category);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed
|
||||
? { scopeKey: resourceCategoryScopeKey, categories }
|
||||
: current;
|
||||
});
|
||||
}, [resourceCategoryScopeKey, resourceIdsByCategory, viewedResourceCategory]);
|
||||
const unreadResourceCategories =
|
||||
unreadResourceCategoryState.scopeKey === resourceCategoryScopeKey
|
||||
? unreadResourceCategoryState.categories
|
||||
: new Set<ResourceCategory>();
|
||||
resourceCanvasPageWheelRef.current.activeCategory = activePageCategory;
|
||||
const nextPageCategory =
|
||||
activePageCategory && resourcePageCategories.length > 1
|
||||
@@ -4506,10 +4590,15 @@ export default function ProjectDevelopmentView({
|
||||
>
|
||||
{resourcePageCategories.map((category) => {
|
||||
const Icon = categoryIcons[category];
|
||||
const hasUnreadResources =
|
||||
unreadResourceCategories.has(category);
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
aria-label={`${categoryLabels[category]}${
|
||||
hasUnreadResources ? ',有新资源' : ''
|
||||
}`}
|
||||
aria-current={
|
||||
category === activePageCategory
|
||||
? 'page'
|
||||
@@ -4525,7 +4614,15 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<strong>{categoryLabels[category]}</strong>
|
||||
<strong className="game-resource-outline-label">
|
||||
{categoryLabels[category]}
|
||||
{hasUnreadResources ? (
|
||||
<span
|
||||
className="game-resource-outline-unread"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</strong>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -319,6 +319,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 能力继续独立实施。
|
||||
|
||||
Reference in New Issue
Block a user