隐藏资源画布游戏代码并补充类型标识
Project CI / Repository checks (pull_request) Successful in 3m28s
Project CI / Backend tests (pull_request) Failing after 4m59s
Project CI / Frontend tests (pull_request) Successful in 4m11s
Project CI / Native shell tests (pull_request) Failing after 6m8s

- 资源画布仅展示文档、美术、音频、项目版本四个可见栏目,保留五类内部投影和依赖事实。

- 为可见资源卡增加可访问、可测试的中文类型 Tag,并隐藏代码端点依赖边。

- 补充资源投影、画布回归测试及 PRD 四栏目验收描述。
This commit is contained in:
2026-08-29 16:51:03 +08:00
parent 63abea0b3e
commit 14818c2c0c
7 changed files with 388 additions and 47 deletions
+25
View File
@@ -5457,6 +5457,31 @@ iframe.preview-frame {
visibility: visible;
}
.game-resource-card-type-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
display: inline-flex;
max-width: calc(100% - 16px);
align-items: center;
min-width: 0;
padding: 4px 8px;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 72%);
border-radius: 999px;
background: rgb(75 48 38 / 84%);
color: #fff;
font-size: 10px;
font-weight: 900;
line-height: 1;
letter-spacing: 0.02em;
pointer-events: none;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 8px 18px rgb(96 62 47 / 20%);
}
.game-resource-card-open {
position: absolute;
z-index: 1;
@@ -86,6 +86,7 @@ import {
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
RESOURCE_CANVAS_SECTION_ORDER,
RESOURCE_CANVAS_VISIBLE_SECTION_ORDER,
type ResourceCanvasCardSize,
resourceCanvasCardSize,
resourceCanvasContentBounds,
@@ -123,6 +124,7 @@ import {
type ProjectResource,
type ProjectResourceCategory,
projectResourcesFromReadModels,
projectResourceTypeLabel,
} from './resourceProjectionModel';
import {
clampProjectResourceSectionZoom,
@@ -393,6 +395,7 @@ export type ProjectDevelopmentViewProps = {
};
const categoryOrder = RESOURCE_CANVAS_SECTION_ORDER;
const visibleCategoryOrder = RESOURCE_CANVAS_VISIBLE_SECTION_ORDER;
const categoryLabels: Record<ResourceCategory, string> = {
code: '游戏代码',
@@ -592,6 +595,7 @@ const ResourceCard = memo(function ResourceCard({
const [decodedIdentity, setDecodedIdentity] = useState<string | null>(null);
const Icon = categoryIcons[resource.category];
const kind = projectResourceCardPreviewKind(resource);
const resourceTypeLabel = projectResourceTypeLabel(resource);
const isMedia = kind === 'video' || kind === 'audio';
const mediaActive = activeMediaIdentity === previewIdentity;
const sourceUrl =
@@ -797,6 +801,13 @@ const ResourceCard = memo(function ResourceCard({
<span className="game-resource-card-visual" aria-hidden="true">
{visual}
</span>
<span
className="game-resource-card-type-badge"
data-resource-type={resourceTypeLabel}
title={resourceTypeLabel}
>
{resourceTypeLabel}
</span>
<button
type="button"
className="game-resource-card-open"
@@ -1243,6 +1254,10 @@ export default function ProjectDevelopmentView({
}),
[manifestTaskById, projectedResources, resourceGraph],
);
const canvasResources = useMemo(
() => resources.filter((resource) => resource.category !== 'code'),
[resources],
);
// 做方案在拿到第一个产物之前,左边的资源画布是空的(文档 0 项、项目版本 0 项),而澄清
// 问答、决定卡和 GDD 审批全挤在右边那一列里。这段时间先把画布收掉让对话铺满,一旦有第
// 一个已登记资源就自动恢复双栏。用 CSS 收而不是不渲染:画布里的页签、缩放和选中状态都
@@ -1288,7 +1303,7 @@ export default function ProjectDevelopmentView({
projectPath,
projectId: manifest.projectId,
mode: sortMode,
resources,
resources: canvasResources,
canvasRef: resourceCanvasRef,
eagerPreviewLimit: 12,
previewVersionByResourceId: resourcePreviewVersionByResourceId,
@@ -1382,7 +1397,7 @@ export default function ProjectDevelopmentView({
const normalizedSearch = searchText.trim().toLowerCase();
const visibleResources = useMemo(
() =>
resources.filter((resource) =>
canvasResources.filter((resource) =>
normalizedSearch
? [
resource.label,
@@ -1392,7 +1407,7 @@ export default function ProjectDevelopmentView({
].some((value) => value.toLowerCase().includes(normalizedSearch))
: true,
),
[normalizedSearch, resources],
[canvasResources, normalizedSearch],
);
const visibleResourceIds = useMemo(
() => new Set(visibleResources.map((resource) => resource.id)),
@@ -1401,7 +1416,7 @@ export default function ProjectDevelopmentView({
const visibleResourcesByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
visibleCategoryOrder.map((category) => [
category,
visibleResources.filter((resource) => resource.category === category),
]),
@@ -1411,17 +1426,17 @@ export default function ProjectDevelopmentView({
const projectResourcesByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
visibleCategoryOrder.map((category) => [
category,
resources.filter((resource) => resource.category === category),
canvasResources.filter((resource) => resource.category === category),
]),
),
[resources],
[canvasResources],
);
const resourceIdsByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
visibleCategoryOrder.map((category) => [
category,
new Set(
(projectResourcesByCategory.get(category) ?? []).map(
@@ -1437,8 +1452,8 @@ export default function ProjectDevelopmentView({
[manifest.projectId, projectPath],
);
const resourcePageCategories = useMemo(
() => (resources.length > 0 ? categoryOrder : []),
[resources.length],
() => (canvasResources.length > 0 ? visibleCategoryOrder : []),
[canvasResources.length],
);
const activePageCategory =
resourcePageCategories.find(
@@ -1468,16 +1483,18 @@ export default function ProjectDevelopmentView({
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),
);
});
const categoriesWithNewResources = visibleCategoryOrder.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,
@@ -1683,9 +1700,11 @@ export default function ProjectDevelopmentView({
}
}, [activePageCategory, activeResourceCanvasViewport]);
const selectedResource =
resources.find((resource) => resource.id === selectedResourceId) ?? null;
canvasResources.find((resource) => resource.id === selectedResourceId) ??
null;
const focusedResource =
resources.find((resource) => resource.id === focusedResourceId) ?? null;
canvasResources.find((resource) => resource.id === focusedResourceId) ??
null;
const focusedResourceDependencyDetails = useMemo(() => {
if (!focusedResource) {
return {
@@ -5035,7 +5054,7 @@ export default function ProjectDevelopmentView({
</div>
) : (
<div className="game-resource-section-stack">
{categoryOrder.map((category) => {
{visibleCategoryOrder.map((category) => {
const Icon = categoryIcons[category];
const sectionHeight =
resourceSectionHeights.sectionStates.get(category);
@@ -44,6 +44,14 @@ export const RESOURCE_CANVAS_FIT_PADDING = 16;
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
['document', 'art', 'audio', 'code', 'version'];
/**
* Ordinary resource-canvas navigation intentionally omits game code. The
* complete five-section order above remains the internal layout/sidecar
* contract so existing code-resource coordinates are not rewritten.
*/
export const RESOURCE_CANVAS_VISIBLE_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
['document', 'art', 'audio', 'version'];
export type ResourceCanvasCardSize = {
width: number;
height: number;
@@ -55,6 +55,18 @@ export type ProjectResource = {
imageSequenceDurationMs?: number | null;
};
export type ProjectResourceTypeLabel =
| '图片'
| 'SVG'
| '视频'
| '音频'
| '文档'
| '任务产物'
| 'Agent 回执'
| '项目版本'
| '游戏代码'
| '未知';
const documentExtension =
/\.(md|markdown|mdx|txt|json|ya?ml|toml|csv|ini|conf|xml)$/iu;
const gameCodeExtension =
@@ -116,6 +128,55 @@ export function classifyProjectedResource(input: {
return null;
}
/**
* Converts an already projected resource into short user-facing type text.
* This is deliberately display-only: it derives from existing projection
* fields and does not add a backend/read-model attribute.
*/
export function projectResourceTypeLabel(
resource: Pick<
ProjectResource,
'category' | 'subtype' | 'path' | 'mediaType'
>,
): ProjectResourceTypeLabel {
const subtype = resource.subtype.trim().toLowerCase();
const path = resource.path.trim().toLowerCase();
const mediaType = resource.mediaType.trim().toLowerCase();
if (resource.category === 'version' || subtype === 'project-version') {
return '项目版本';
}
if (subtype === 'agent-result') {
return 'Agent 回执';
}
if (resource.category === 'code') {
return '游戏代码';
}
if (mediaType === 'image/svg+xml' || /\.svg$/iu.test(path)) {
return 'SVG';
}
if (
resource.category === 'audio' ||
mediaType.startsWith('audio/') ||
audioExtension.test(path)
) {
return '音频';
}
if (mediaType.startsWith('video/') || /\.(mp4|webm|mov)$/iu.test(path)) {
return '视频';
}
if (resource.category === 'document' || mediaType.startsWith('text/')) {
return '文档';
}
if (resource.category === 'art' || mediaType.startsWith('image/')) {
return '图片';
}
if (subtype === 'task-artifact') {
return '任务产物';
}
return '未知';
}
function resourcePriority(resource: ProjectResource) {
if (resource.manifestAssetId) {
return 4;
@@ -429,6 +429,13 @@ export function registerProjectWorkbenchFoundationTests() {
localPath: 'assets/section.mp3',
source: { kind: 'generated', taskId: 'audio-asset-plan' },
},
{
id: 'section-code',
kind: 'game-code',
mediaType: 'text/javascript',
localPath: 'game/section.js',
source: { kind: 'generated', taskId: 'code-prototype' },
},
];
manifest.versions = [
{
@@ -448,9 +455,13 @@ export function registerProjectWorkbenchFoundationTests() {
);
addSectionResources(manifest);
let layoutRevision = 0;
let graphResourceIds: string[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
graphResourceIds = (
(args?.resources as Array<{ resourceId: string }> | undefined) ?? []
).map(({ resourceId }) => resourceId);
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
@@ -529,7 +540,12 @@ export function registerProjectWorkbenchFoundationTests() {
Array.from(outline.querySelectorAll('strong')).map(
(item) => item.textContent,
),
).toEqual(['设计文档', '美术资源', '音乐音效', '游戏代码', '项目版本']);
).toEqual(['设计文档', '美术资源', '音乐音效', '项目版本']);
expect(graphResourceIds).toContain('asset:section-code');
expect(screen.queryByRole('button', { name: /section\.js/ })).toBeNull();
expect(
screen.queryByRole('region', { name: '游戏代码资源画布' }),
).toBeNull();
expect(outline.querySelectorAll('small')).toHaveLength(0);
expect(
screen.getByRole('region', { name: '设计文档资源画布' }),
@@ -541,11 +557,12 @@ export function registerProjectWorkbenchFoundationTests() {
screen.getByRole('button', { name: /下一页\s*美术资源/ }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /游戏代码/ }));
expect(
screen.getByRole('region', { name: '游戏代码资源画布' }),
).not.toBeNull();
expect(screen.getByText('没有匹配资源')).not.toBeNull();
within(outline).queryByRole('button', { name: /游戏代码/ }),
).toBeNull();
expect(
screen.queryByRole('region', { name: '游戏代码资源画布' }),
).toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /文档/ }));
const dispatchPageWheel = (target: Element, deltaY = 160) => {
@@ -1051,10 +1068,16 @@ export function registerProjectWorkbenchFoundationTests() {
}),
);
await waitFor(() => {
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4);
expect(
within(screen.getByLabelText('资源栏目大纲')).queryByRole('button', {
name: /有新资源/,
}),
screen
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--paged'),
).toBe(false);
expect(screen.queryByRole('button', { name: /game\.js/ })).toBeNull();
expect(
screen.queryByRole('region', { name: '游戏代码资源画布' }),
).toBeNull();
});
});
@@ -1224,14 +1247,8 @@ export function registerProjectWorkbenchFoundationTests() {
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--dependency'),
).toBe(false);
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(5);
for (const label of [
'设计文档',
'美术资源',
'音乐音效',
'游戏代码',
'项目版本',
]) {
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(4);
for (const label of ['设计文档', '美术资源', '音乐音效', '项目版本']) {
expect(screen.getByRole('region', { name: label })).not.toBeNull();
}
};
@@ -1394,6 +1411,9 @@ export function registerProjectWorkbenchFoundationTests() {
expect(heroCard?.textContent).not.toContain('hero.png');
expect(heroCard?.textContent).not.toContain('assets/hero.png');
expect(heroCard?.textContent).not.toContain('Agent 生成');
expect(
heroCard?.querySelector('[data-resource-type="图片"]')?.textContent,
).toBe('图片');
expect(
heroCard?.querySelector('.game-resource-card-open button'),
).toBeNull();
@@ -1411,7 +1431,12 @@ export function registerProjectWorkbenchFoundationTests() {
});
showResourcePage('设计文档');
act(() => observer.triggerVisible());
await screen.findByText(/这是安全的卡片正文摘要。/);
const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/);
expect(
documentSummary
.closest('.game-resource-card')
?.querySelector('[data-resource-type="文档"]'),
).not.toBeNull();
showResourcePage('美术资源');
expect(
invoke.mock.calls.some(
@@ -1425,6 +1450,9 @@ export function registerProjectWorkbenchFoundationTests() {
name: '播放 intro.mp4',
});
const videoCard = videoControl.closest('.game-resource-card');
expect(
videoCard?.querySelector('[data-resource-type="视频"]'),
).not.toBeNull();
const video = videoCard?.querySelector('video');
expect(video).not.toBeNull();
expect(video?.preload).toBe('auto');
@@ -1475,7 +1503,13 @@ export function registerProjectWorkbenchFoundationTests() {
expect(document.activeElement).toBe(stableVideoControl);
showResourcePage('音乐音效');
fireEvent.click(screen.getByRole('button', { name: '播放 theme.mp3' }));
const audioControl = screen.getByRole('button', { name: '播放 theme.mp3' });
expect(
audioControl
.closest('.game-resource-card')
?.querySelector('[data-resource-type="音频"]'),
).not.toBeNull();
fireEvent.click(audioControl);
await waitFor(() => {
expect(
invoke.mock.calls.some(
@@ -2800,6 +2834,82 @@ export function registerProjectWorkbenchFoundationTests() {
});
});
it('hides dependency edges whose code endpoint is not visible while preserving visible edges', async () => {
const visibleEdgeId = 'asset-reference:["art-a","art-b"]';
const hiddenEdgeId = 'asset-reference:["code","art-a"]';
const graph = normalizeProjectResourceGraph({
resourceIds: ['code', 'art-a', 'art-b'],
referenceEdges: [
{
id: visibleEdgeId,
kind: 'asset-reference',
sourceResourceId: 'art-a',
targetResourceId: 'art-b',
cyclic: false,
},
{
id: hiddenEdgeId,
kind: 'asset-reference',
sourceResourceId: 'code',
targetResourceId: 'art-a',
cyclic: false,
},
],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
dependencyDepths: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
});
const positions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'code',
section: 'code',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'art-a',
section: 'art',
x: 0,
y: 0,
manuallyPlaced: false,
},
{
resourceId: 'art-b',
section: 'art',
x: 0,
y: 144,
manuallyPlaced: false,
},
];
render(
React.createElement(ResourceDependencyOverlay, {
graph,
positions,
section: 'art',
visibleResourceIds: new Set(['art-a', 'art-b']),
}),
);
const overlay = await screen.findByTestId(
'resource-dependency-overlay-art',
);
await waitFor(() => {
const edgeById = (edgeId: string) =>
Array.from(
overlay.querySelectorAll<SVGPathElement>('[data-edge-id]'),
).find((edge) => edge.getAttribute('data-edge-id') === edgeId);
expect(edgeById(visibleEdgeId)).not.toBeNull();
expect(edgeById(hiddenEdgeId)).toBeUndefined();
});
});
it('coalesces section scroll geometry, keeps partial endpoints stable, and cleans one dependency observer', async () => {
const referenceId = 'asset-reference:["resource-a","resource-b"]';
const graph = normalizeProjectResourceGraph({
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import { projectResourcesFromReadModels } from '../src/view/project-development/resourceProjectionModel';
import {
projectResourcesFromReadModels,
projectResourceTypeLabel,
} from '../src/view/project-development/resourceProjectionModel';
describe('项目资源投影', () => {
it('只把明确资源投影到固定分类,未知任务产物不会伪装成项目版本', () => {
@@ -225,4 +228,119 @@ describe('项目资源投影', () => {
expect(versions[0]?.version?.childVersionIds).toEqual(['version-child']);
expect(versions[1]?.version?.parentVersionId).toBe('version-root');
});
it('按稳定优先级推导资源卡类型标识并为未知类型兜底', () => {
expect(
projectResourceTypeLabel({
category: 'version',
subtype: 'project-version',
path: 'versions/v1',
mediaType: '正式项目版本',
}),
).toBe('项目版本');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'agent-result',
path: '专业 Agent 文本回执',
mediaType: 'Agent 历史文本回执',
}),
).toBe('Agent 回执');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'task-artifact',
path: 'memory/plan.md',
mediaType: '项目文档',
}),
).toBe('文档');
expect(
projectResourceTypeLabel({
category: 'audio',
subtype: 'task-artifact',
path: 'audio/theme.wav',
mediaType: '音乐音效产物',
}),
).toBe('音频');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'task-artifact',
path: 'assets/hero.svg',
mediaType: '美术产物',
}),
).toBe('SVG');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'task-artifact',
path: 'assets/unknown',
mediaType: '美术产物',
}),
).toBe('图片');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'character',
path: 'assets/hero.svg',
mediaType: 'image/svg+xml',
}),
).toBe('SVG');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'video',
path: 'assets/intro.mp4',
mediaType: 'video/mp4',
}),
).toBe('视频');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'character',
path: 'assets/hero.png',
mediaType: 'image/png',
}),
).toBe('图片');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'attachment',
path: 'docs/rules.yaml',
mediaType: 'application/yaml',
}),
).toBe('文档');
expect(
projectResourceTypeLabel({
category: 'document',
subtype: 'unknown',
path: 'data/blob',
mediaType: 'application/octet-stream',
}),
).toBe('文档');
expect(
projectResourceTypeLabel({
category: 'code',
subtype: 'source',
path: 'game/main.ts',
mediaType: 'text/typescript',
}),
).toBe('游戏代码');
expect(
projectResourceTypeLabel({
category: 'art',
subtype: 'unknown',
path: 'assets/blob',
mediaType: 'application/octet-stream',
}),
).toBe('图片');
expect(
projectResourceTypeLabel({
category: 'unsupported' as never,
subtype: 'unknown',
path: 'data/blob',
mediaType: 'application/octet-stream',
}),
).toBe('未知');
});
});
@@ -322,7 +322,7 @@ type UpdateProjectResourceCanvasLayoutResult =
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的历史坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪、`dependencyDepth` 或资源拓扑身份签名(精确引用端点和聚合 task-flow 成员)变化后按最终拓扑确定性重算。签名以稳定资源 ID 的规范端点 / 成员序列生成固定大小摘要,不使用显示名称或浏览器测量值;自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变当前栏目的可视范围,不回写或裁切持久坐标,也不因资源 extent 或 resize 把已平移的 viewport 拉回内容边界。当前客户端继续以 `1280×800` 横屏合同验收。
- 任一栏目出现资源后,资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本` 栏目分页画布;每个栏目按 `projectId + mode + category` 保留独立 viewport普通 wheel 切换栏目,`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容。
- 任一可见资源出现后,普通用户资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 项目版本` 栏目分页画布;游戏代码仍保留在内部资源、布局和依赖事实中,但不进入普通资源画布的导航、分页、卡片、搜索或详情入口。每个可见栏目按 `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 + 系统锁` 仲裁已发出的请求。
@@ -355,7 +355,7 @@ type UpdateProjectResourceCanvasLayoutResult =
### 5.3 资源类型与替换兼容性(P1)
实现状态(2026-08-23):当前资源投影与栏目页顺序收口为“设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本”。设计文档接收受支持的 UTF-8 文档、代码资产中的文档类型和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术资源接收图片、SVG、动画和视频类产物;音乐音效接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物;游戏代码接收 Direct Codex / 任务产物登记的 HTML、CSS 和 JavaScript。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。
实现状态(2026-08-29):内部资源投影仍识别“设计文档、美术资源、音乐音效、游戏代码、项目版本”五类事实,但普通用户资源画布只展示“设计文档 -> 美术资源 -> 音乐音效 -> 项目版本”四个栏目;游戏代码不进入画布导航、分页、卡片、搜索或详情入口。设计文档接收受支持的 UTF-8 文档、代码资产中的文档类型和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术资源接收图片、SVG、动画和视频类产物;音乐音效接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物;游戏代码继续接收 Direct Codex / 任务产物登记的 HTML、CSS 和 JavaScript,底层文件、manifest 事实、生成/编辑/运行能力、项目版本引用与依赖关系不变。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
@@ -533,12 +533,12 @@ type ProjectAgentMudPointAttribution = {
### 7.5 资源栏目分页无限画布验收
1. 完全空项目继续显示全部栏目的分区展览;任一栏目出现资源后,dependency / type 都切换为固定栏目分页画布,悬浮 Dock、底部下一页标题和普通 wheel 可访问全部栏目,空栏目也可打开空画布。
2. 每个 `projectId + dependency|type + document|art|audio|code|version` 组合保留独立 viewport;切换栏目、模式、项目和打开 / 关闭详情后恢复对应平移与缩放,窗口 resize、媒体测量和资源 extent 变化不得重置用户 viewport。
1. 完全空项目继续显示四个可见栏目的分区展览;任一可见栏目出现资源后,dependency / type 都切换为固定栏目分页画布,悬浮 Dock、底部下一页标题和普通 wheel 可访问全部可见栏目,空栏目也可打开空画布;游戏代码栏目和代码卡片不出现
2. 每个 `projectId + dependency|type + document|art|audio|version` 组合保留独立 viewport隐藏代码资源的历史内部坐标和 sidecar 会话状态不被删除或重写,切换栏目、模式、项目和打开 / 关闭详情后恢复对应可见栏目平移与缩放,窗口 resize、媒体测量和资源 extent 变化不得重置用户 viewport。
3. 当前栏目允许空白拖动无限平移;`Ctrl/Cmd + wheel` 以指针为锚点缩放,显式复位按包含负坐标资源在内的完整 bounds 适配内容。非空状态不显示分区高度、分区内部滚动或分区内容倍率操作。
4. 资源卡超过 `5px` 阈值后进入拖动,预览和 dependency 线同步移动;成功释放只提交一次 `manuallyPlaced=true` CAS,取消、移出释放、媒体控制点击和未超过阈值均不写布局。
5. dependency 引导线消费当前栏目的同类型精确引用,并与卡片共享同一 viewport transform;平移、缩放、拖动预览、搜索和 resize 后端点保持对齐,type 模式不渲染引导线。
6. 栏目分页、viewport 和资源卡拖动只修改工作台会话状态或资源布局 sidecar,不改 manifest、项目 mutation revision、Runtime verification、Agent 权限和预览状态;图片、视频、音频、文档、代码、版本卡片及非模态详情回归全部通过
6. 栏目分页、viewport 和资源卡拖动只修改工作台会话状态或资源布局 sidecar,不改 manifest、项目 mutation revision、Runtime verification、Agent 权限和预览状态;图片、SVG、视频、音频、文档、任务产物、Agent 回执、项目版本卡片及非模态详情回归全部通过,游戏代码仅保留内部事实而不进入普通资源画布
### 7.6 阶段七完整验收