资源工作台可变更素材类型:面板加类型选择器、卡片角标改显示资源类型

- ResourceClassificationPanel.tsx:加素材类型(功能分类)选择器,与标签同一次保存、同一条写入路径;选项 = GAME_CREATION_APP_ASSET_CATEGORIES × resourceReferenceCategoryLabel,不新造第二套中文译名
- ResourceClassificationPanel.tsx:选择器读显示口径(与画布栏目 projectResourceAssetCategory 同源,选中项就是卡片所在栏目);写回分两路——没碰过类型控件回传 gameCreationAppAssetPersistedCategory 的落盘原值,主动选过才写用户选的值(categoryChoice ?? persisted),保证「只改标签」不漂移分类
- index.tsx:资源卡右上角角标由「文件/媒体类型」改为「资源类型」,取 categoryLabels[resource.category](栏目与筛选共用的同一份文案),角标恒等于该卡所在栏目;projectResourceTypeLabel 仍服务资源管理面板小字与总览摞分列,不再用于角标
- tests/resourceClassificationPanel.test.tsx:新增「变更素材类型」6 条用例(选择器 6 项与中文口径、选择器显示显示口径自愈值、主动选值写入载荷、只改标签回传落盘原值对照、显式待归类写入 unclassified、连续两次各带当次选择且不关窗),并按新增控件更新既有按钮序与「分类不再由面板编辑」两条断言
- tests/projectResourceLiveIntegration.test.tsx:新增真宿主跟随链用例(角标显示资源类型并与栏目同名、改类型后卡片进目标栏目且角标跟着变、原栏目不再有它),配套状态化宿主 fixture、分类写入与 get_local_game_manifest 两条命令桩、切栏目与读角标助手
- tests/appSurface/project-development.suite.ts:原 data-resource-type 媒体类型断言改为资源类型(角色与对象 / 待归类 / 音频),并说明卡面正文走媒体分支、角标走资源类型这两个独立轴
- docs:PRD §5.3 由「分类没有用户手动设置入口」改为当前两口径分叉(选择器读显示口径、写回按是否主动选过分流)与「显式待归类被读时自愈覆盖」盲区
- docs:AGC 资源工作台 V3 端到端验收 S5 的「前端交互不能改分类」改写为可改分类,补判据(角标读 .game-resource-card-type-badge)与盲区说明;实施计划写入命令一节的面板名与职责同步为「编辑素材标签(标签 + 素材类型)」
- docs:decision-log 新增条目记录本次决策(Rust 无需改动、画布跟随链无需额外迁移代码、待归类盲区、四条变异验证结果与一条不可构造的变异)
- 说明:Rust、manifest 字段构成与 SpacetimeDB 均不改;写入命令 update_local_project_resource_classification 早已接受任意合法 category
This commit is contained in:
2026-09-12 16:54:24 +08:00
parent 46788edbb8
commit 1079f6c91b
9 changed files with 593 additions and 28 deletions
@@ -5,20 +5,37 @@ import { useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
import {
GAME_CREATION_APP_ASSET_CATEGORIES,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetPersistedCategory,
gameCreationAppAssetTags,
normalizeGameCreationAppAssetTags,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences';
type UpdateLocalProjectResourceClassificationResult = {
asset: GameCreationAppAssetManifestEntry;
committedProjectRevision: number;
};
/**
* 素材类型(功能分类)选项 = 合法分类枚举 × 既有中文展示名。
*
* 展示名只从 `resourceReferenceCategoryLabel`(筛选与栏目的同一份口径)取,
* 不在业务页另写一张译名表 —— 面板说「角色与对象」而栏目说别的,用户会以为是两个东西。
*/
const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS =
GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({
id: category,
label: resourceReferenceCategoryLabel(category),
}));
/**
* 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。
* 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。
@@ -71,17 +88,26 @@ export function ResourceClassificationPanel({
onSaved,
}: ResourceClassificationPanelProps) {
/**
* 分类取值优先级由落盘 `category` + `kind` 派生决定,本面板不再提供手动设置入口
* 写入命令的 `category` 是必填,这里读一次「落盘原值」并在保存时原样回传,
* 保证「只改标签」不会顺带改动分类。
* 素材类型(功能分类)从本面板设置,与标签同一次保存、同一条写入路径
*
* 必须用 `gameCreationAppAssetPersistedCategory`(写回口径)而不是
* `gameCreationAppAssetCategory`(读显示口径):后者会把落盘 `unclassified` 自愈成
* kind 派生值,回传它就等于在一个只改标签的面板里静默改掉分类
* **选择器读的是「显示口径」** `gameCreationAppAssetCategory`:它与资源画布栏目
* `projectResourceAssetCategory` / `projectResourceCanvasCategory`)同一份读数,
* 所以用户看到的选中项恰好就是他看到的那一栏,不存在「面板说 A、卡片在 B 栏」
*
* **写回不能用这个读数**:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind` 能派生出
* 明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类
* (真机上同一条 `kind:"ui"` 资产同时出现过 `unclassified` 与 `ui-interaction` 两种落盘值)。
*
* 因此用 `categoryChoice` 表达「用户是否主动选过」:
* - `null`(没碰过分类控件)→ 回传 `gameCreationAppAssetPersistedCategory` 的落盘原值;
* - 用户选过 → 回传用户选的那个值。
* 这条分叉是本次改动的核心不变量,两个方向都由
* `tests/resourceClassificationPanel.test.tsx` 的对照用例钉住。
*/
const [category] = useState(() =>
gameCreationAppAssetPersistedCategory(asset),
);
const [categoryChoice, setCategoryChoice] =
useState<GameCreationAppAssetCategory | null>(null);
const displayedCategory =
categoryChoice ?? gameCreationAppAssetCategory(asset);
const [tags, setTags] = useState<string[]>(() =>
gameCreationAppAssetTags(asset),
);
@@ -132,8 +158,9 @@ export function ResourceClassificationPanel({
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetId: asset.id,
// 分类不再由本面板编辑:读到的权威值原样回传
category,
// 用户没主动选类型就原样回传落盘值(不含读时自愈),选了就写用户选的那个
category:
categoryChoice ?? gameCreationAppAssetPersistedCategory(asset),
tags: normalizeGameCreationAppAssetTags(tagsToSave),
},
},
@@ -176,6 +203,21 @@ export function ResourceClassificationPanel({
</button>
</header>
<div className="game-resource-classification-body">
{/*
素材类型选择器:选中的那一项就是这张卡当前所在的画布栏目。
点任意一项即视为用户主动改类型(即便点的是当前已选中的那一项),
与「没碰过就回传落盘原值」的分叉保持同一条判据,不做隐式 no-op。
*/}
<PlatformSegmentedTabs
items={RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS}
activeId={displayedCategory}
onChange={setCategoryChoice}
layout="scroll"
gap="sm"
frame="bare"
surface="transparent"
size="compact"
/>
{tags.length > 0 ? (
<ul className="game-resource-tag-list" aria-label="已有标签">
{tags.map((tag) => (
@@ -721,7 +721,15 @@ 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);
/**
* ****"文件/媒体类型"
*
* `resource.category`
* `categoryLabels` `resourceReferenceCategoryLabel`
* "图片 / 视频 / 文档"
* / / /
*/
const resourceCategoryLabel = categoryLabels[resource.category];
const isMedia = kind === 'video' || kind === 'audio';
const mediaActive = activeMediaIdentity === previewIdentity;
const sourceUrl =
@@ -945,10 +953,10 @@ const ResourceCard = memo(function ResourceCard({
</span>
<span
className="game-resource-card-type-badge"
data-resource-type={resourceTypeLabel}
title={resourceTypeLabel}
data-resource-type={resourceCategoryLabel}
title={resourceCategoryLabel}
>
{resourceTypeLabel}
{resourceCategoryLabel}
</span>
<button
type="button"
@@ -2564,7 +2564,7 @@ export function registerProjectWorkbenchFoundationTests() {
{
id: 'intro-video',
// 视频与角色图必须落在同一栏目才能同屏比较卡片本体与单媒体播放,
// 因此用同为「角色与对象」的 character-animation显示类型仍按 video/mp4 判为视频
// 因此用同为「角色与对象」的 character-animation卡面预览仍按 video/mp4 走 <video> 分支
kind: 'character-animation',
mediaType: 'video/mp4',
localPath: 'assets/intro.mp4',
@@ -2692,9 +2692,10 @@ 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('图片');
heroCard?.querySelector('[data-resource-type="角色与对象"]')?.textContent,
).toBe('角色与对象');
expect(
heroCard?.querySelector('.game-resource-card-open button'),
).toBeNull();
@@ -2713,10 +2714,12 @@ export function registerProjectWorkbenchFoundationTests() {
await showResourcePage('待归类');
act(() => observer.triggerVisible());
const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/);
// 卡面正文按扩展名走文档分支(媒体类型),但角标是资源类型:`design-document` 不在
// canonical 目录里 → 落「待归类」,与它所在的栏目一致(这正是改前"标着文档、却在待归类栏"的分叉)。
expect(
documentSummary
.closest('.game-resource-card')
?.querySelector('[data-resource-type="文档"]'),
?.querySelector('[data-resource-type="待归类"]'),
).not.toBeNull();
await showResourcePage('角色与对象');
expect(
@@ -2732,7 +2735,7 @@ export function registerProjectWorkbenchFoundationTests() {
});
const videoCard = videoControl.closest('.game-resource-card');
expect(
videoCard?.querySelector('[data-resource-type="视频"]'),
videoCard?.querySelector('[data-resource-type="角色与对象"]'),
).not.toBeNull();
const video = videoCard?.querySelector('video');
expect(video).not.toBeNull();
@@ -20,6 +20,7 @@ import {
findResourceSelectButton,
fireEvent,
openResourceSearch,
queryResourceSelectButton,
render,
screen,
setComposerText,
@@ -29,6 +30,79 @@ import {
const projectPath = '/tmp/live-canvas-integration';
/**
* 切栏目走「资源总览」的栏目缩略卡片(左侧大纲导航已删除)。
* 与两个 appSurface suite 里的同名助手同口径:切完必须落到目标栏目的子画布。
*/
async function openResourceBookCategory(label: string) {
const categoryByLabel: Record<string, string> = {
'UI 交互': 'ui-interaction',
: 'character',
: 'scene',
: 'audio',
: 'document',
: 'unclassified',
: 'version',
};
if (document.querySelector('[data-resource-book-view="child"]')) {
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
}
fireEvent.click(await screen.findByRole('button', { name: `打开${label}` }));
await waitFor(() => {
const manager = document.querySelector('[data-resource-book-view="child"]');
expect(manager).not.toBeNull();
expect(
manager?.querySelector(
`.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${categoryByLabel[label]}"]`,
),
).not.toBeNull();
});
}
/** 资源卡右上角角标的文本(找不到卡片或角标时返回 null)。 */
async function cardBadgeText(fileName: string) {
const select = await findResourceSelectButton(fileName);
return (
select
.closest('.game-resource-card')
?.querySelector('.game-resource-card-type-badge')?.textContent ?? null
);
}
/**
* 总览态「所有资源」摞里那张预览卡的角标文本。
*
* 覆盖用户看到的另一个面(总览缩略摞铺在上面的真实卡片本体),它与栏目卡共用
* `ResourceCard` 里那一处角标渲染,所以角标口径一处改、两处都跟着走。
*/
function overviewStackBadgeText(fileName: string) {
const cards = Array.from(
document.querySelectorAll(
'.game-resource-book-preview-card[data-resource-book-category="all"] .game-resource-card',
),
);
const card = cards.find((entry) =>
entry
.querySelector('.game-resource-card-select')
?.getAttribute('aria-label')
?.includes(fileName),
);
return (
card?.querySelector('.game-resource-card-type-badge')?.textContent ?? null
);
}
function classificationWrites(invoke: ReturnType<typeof vi.fn>) {
return invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_classification',
);
}
type PendingResourceEditFixture = {
operationId: string;
editKind: string;
@@ -130,6 +204,55 @@ function LiveWorkbench() {
);
}
/**
* 素材类型用例的宿主:manifest 由状态持有、`onManifestChange` 真的回写,
* 「改完类型后卡片进目标栏目、角标跟着变」走的才是真链路
* (命令 → 重读 manifest → 重投影 → 栏目分组 → 渲染),而不是断言桩。
*/
function ClassificationWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
initial.assets = [
{
id: 'asset-hero',
kind: 'character',
category: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'generated', resourceId: 'hero-resource' },
},
{
id: 'asset-ui',
// `ui` 不在 canonical 目录里:`kind` 派生值经别名落到 `ui-interaction`
// 而落盘值仍是 `unclassified`。用来钉住"角标与栏目都走读显示口径"。
kind: 'ui',
category: 'unclassified',
mediaType: 'image/png',
localPath: 'assets/panel.png',
source: { kind: 'generated', resourceId: 'ui-resource' },
},
];
const [manifest, setManifest] = useState(initial);
canvasFixture.manifest = manifest;
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={projectPath}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
/>
);
}
function DerivedWorkbench({
includeArt = false,
includeCharacter = false,
@@ -493,6 +616,45 @@ describe('project resource live canvas integration', () => {
manifest: nextManifest,
};
}
if (command === 'update_local_project_resource_classification') {
const input = (args?.input ?? {}) as {
assetId?: string;
category?: string;
tags?: string[];
};
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
// 与 Rust `update_manifest_asset_classification_at` 同语义:只改目标条目的
// `category` / `tags`,并推进一次项目 revision。
canvasFixture.revision += 1;
const nextManifest = {
...base,
assets: base.assets.map((asset) =>
asset.id === input.assetId
? ({
...asset,
category: input.category,
tags: input.tags ?? [],
} as (typeof base.assets)[number])
: asset,
),
};
canvasFixture.manifest = nextManifest;
const asset = nextManifest.assets.find(
(entry) => entry.id === input.assetId,
);
if (!asset) throw new Error(`missing asset ${input.assetId}`);
return {
asset,
committedProjectRevision: canvasFixture.revision,
};
}
if (command === 'get_local_game_manifest') {
if (!canvasFixture.manifest) {
throw new Error('missing manifest fixture');
}
return canvasFixture.manifest;
}
if (command === 'polish_local_project_prompt') {
polishCalls.push(structuredClone(args ?? {}));
if (options.polishResult) {
@@ -1474,4 +1636,112 @@ describe('project resource live canvas integration', () => {
'焦点测试恢复失败',
);
});
/**
* 卡片右上角角标显示的是**资源类型**(功能分类中文名),等于这张卡所在的画布栏目。
*
* 两条判据缺一不可:
* - `asset-hero`(落盘 `character`):角标「角色与对象」,不可能是媒体类型「图片」;
* - `asset-ui`(落盘 `unclassified` + `kind:"ui"`):读显示口径自愈成 `ui-interaction`
* 所以角标与栏目都是「UI 交互」——不是落盘值推出的「待归类」。
*
* 变异验证:把角标改回媒体类型口径(`projectResourceTypeLabel(resource)`),
* 两条都会变红(分别是「图片」与「图片」)。
*/
it('角标显示资源类型(与所在栏目同名),落盘 unclassified 的 UI 资产按自愈口径显示', async () => {
installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('角色与对象');
expect(await cardBadgeText('hero.png')).toBe('角色与对象');
await openResourceBookCategory('UI 交互');
expect(await cardBadgeText('panel.png')).toBe('UI 交互');
});
/**
* 用户改素材类型后的**整条跟随链**(真宿主:命令 → 重读 manifest → 重投影 → 栏目分组):
* 1. 总览「所有资源」摞里那张预览卡的角标跟着变;
* 2. 卡片挪到新类型对应的栏目,角标等于新栏目名;
* 3. 原栏目里不再有它。
*
* 变异验证(已实测):
* - 把写入改回"永远回传落盘原值" → 载荷 category 不是 `scene`,第一步就红;
* - 把角标改回媒体类型口径(`projectResourceTypeLabel(resource)`)→ 角标恒为「图片」,红。
*
* 已实测**不可构造**的变异:「角标只在挂载时算一次」(`useState(() => …)`)在本用例下仍然绿。
* 原因是选中一张资源卡本身就会把画布切进它所在栏目的子画布,而改类型又让这张卡换到另一个
* 栏目的分组里 —— React 每次都会卸载重建卡片宿主,因此挂载快照与实时读数在 DOM 上同形。
* 换句话说:这条链路在当前结构下不存在"卡片留在原地、值却过期"的窗口,
* 跟随用例能钉住的是**取值来源**(分类 vs 媒体类型),不是缓存时机。
*/
it('改素材类型后卡片进目标栏目、角标跟着变', async () => {
const { invoke } = installTauri();
render(<ClassificationWorkbench />);
await openResourceBookCategory('角色与对象');
expect(await cardBadgeText('hero.png')).toBe('角色与对象');
fireEvent.click(await findResourceSelectButton('hero.png'));
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' }));
const dialog = await screen.findByRole('dialog', {
name: '编辑素材标签',
});
// 面板里的类型选择器显示的就是卡片当前所在栏目。
const categoryTab = within(dialog).getByRole('button', {
name: '角色与对象',
});
expect(categoryTab.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(within(dialog).getByRole('button', { name: '场景与环境' }));
fireEvent.click(within(dialog).getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const [, args] = classificationWrites(invoke)[0]!;
expect(args).toEqual({
input: {
projectPath,
expectedProjectId: 'live-canvas-project',
expectedProjectRevision: expect.any(Number),
assetId: 'asset-hero',
category: 'scene',
tags: [],
},
});
// 关掉面板再看栏目(保存本身不关窗,关窗是头部 × 的职责)。
fireEvent.click(
within(dialog).getByRole('button', { name: '关闭编辑素材标签' }),
);
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(),
);
// 跟随一:总览「所有资源」摞里那张预览卡的角标**就地**跟着变(同一个卡片宿主,
// 不是卸载重建)—— 这一条才钉得住"角标取值来自当前投影、不是挂载时算一次的旧值"。
fireEvent.click(await screen.findByRole('button', { name: '收起资源' }));
await waitFor(() =>
expect(
document.querySelector('[data-resource-book-view="main"]'),
).not.toBeNull(),
);
expect(overviewStackBadgeText('hero.png')).toBe('场景与环境');
// 跟随二:卡片出现在「场景与环境」栏,且角标同步成新类型名。
await openResourceBookCategory('场景与环境');
const select = await findResourceSelectButton('hero.png');
expect(
select
.closest('[data-resource-book-category]')
?.getAttribute('data-resource-book-category'),
).toBe('scene');
expect(await cardBadgeText('hero.png')).toBe('场景与环境');
// 跟随三:原栏目里没有它了。
await openResourceBookCategory('角色与对象');
expect(queryResourceSelectButton('hero.png')).toBeNull();
});
});
@@ -107,10 +107,19 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
expect(screen.queryByRole('button', { name: '取消' })).toBeNull();
expect(screen.queryByRole('button', { name: '删除资源' })).toBeNull();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
// 「管理全部标签」没有实现,分类也不再由这个面板编辑
// 「管理全部标签」没有实现。
expect(screen.queryByRole('button', { name: '管理全部标签' })).toBeNull();
expect(screen.queryByRole('button', { name: '角色与对象' })).toBeNull();
expect(screen.queryByRole('button', { name: '场景与环境' })).toBeNull();
// 素材类型(功能分类)重新有了用户入口:6 个合法分类各一项。
for (const label of [
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
]) {
expect(screen.getByRole('button', { name: label })).not.toBeNull();
}
});
test('每个已有标签渲染成自带删除按钮的 pill,且 aria-label 可区分', () => {
@@ -450,12 +459,223 @@ describe('ResourceClassificationPanel 编辑素材标签', () => {
});
});
/**
* 素材类型(功能分类)的用户入口。这一组钉住本次改动的核心不变量,两个方向互为对照:
*
* - 用户**主动选过**类型 → 载荷 `category` 必须是用户选的那个值;
* - 用户**没碰过**类型控件(只改标签)→ 载荷 `category` 必须仍是**落盘原值**
* 不能等于选择器显示的读显示值。
*
* 任何一边被改成另一边的口径,都会有一条例用变红,见各用例上的「变异验证」。
*/
describe('ResourceClassificationPanel 变更素材类型', () => {
/**
* 落盘 `unclassified` 而 `kind:"ui"` 能派生出 `ui-interaction`:这是读时自愈的触发条件,
* 也正是"选择器显示值 ≠ 落盘值"的现场。真机 57 条 `kind:"ui"` 资产就是这个形状。
*/
const selfHealingAsset: GameCreationAppAssetManifestEntry = {
...asset,
id: 'asset-ui',
kind: 'ui',
category: 'unclassified',
tags: [],
};
function installClassificationInvoke() {
return installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7 };
}
if (command === 'update_local_project_resource_classification') {
return { asset, committedProjectRevision: 8 };
}
throw new Error(`unexpected command: ${command}`);
});
}
function segmentedTabItem(label: string) {
return screen
.getByRole('button', { name: label })
.closest('.platform-segmented-tabs');
}
test('选择器给出 6 个合法分类,中文名与画布栏目同一份口径', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
for (const label of [
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
]) {
// 6 项都在同一个分段页签容器里,用的是与资源筛选同一份中文展示名。
expect(segmentedTabItem(label)).not.toBeNull();
}
});
/**
* 选择器的选中项是**读显示口径**(与资源卡栏目同源):该资产落盘是 `unclassified`
* 但画布把它放在「UI 交互」栏,选择器必须也显示「UI 交互」。
*
* 变异验证:把选择器读数换成 `gameCreationAppAssetPersistedCategory(asset)`(落盘口径),
* 选中项会变成「待归类」,本用例必须失败 —— 那正是"面板说待归类、卡片在 UI 交互"的错位。
*/
test('选择器显示的是显示口径:落盘 unclassified + kind ui 的资产选中「UI 交互」', () => {
installInvoke(async () => undefined);
renderPanel({ asset: selfHealingAsset });
expect(
screen
.getByRole('button', { name: 'UI 交互' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
.getByRole('button', { name: '待归类' })
.getAttribute('aria-pressed'),
).toBe('false');
});
/**
* 主动改类型:载荷 `category` 是用户选的那个值,而不是落盘原值。
*
* 变异验证:把写入改回"永远回传落盘原值"
* `category: gameCreationAppAssetPersistedCategory(asset)`),载荷会变成 `unclassified`
* 本用例必须失败。
*/
test('主动选中某个素材类型后保存:载荷 category 是用户选的那个值', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: selfHealingAsset, onSaved: vi.fn() });
await user.click(screen.getByRole('button', { name: '场景与环境' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
assetId: 'asset-ui',
category: 'scene',
tags: [],
},
});
});
/**
* 只改标签(**没碰过**类型控件):载荷 `category` 必须仍是落盘原值 `unclassified`
* 即使选择器上显示的是自愈出来的 `ui-interaction`。
*
* 这是与上一条互为对照的关键用例:没有它,任何"顺手把显示值写回去"的实现都会绿。
*
* 变异验证:把写入改成"永远回传读显示口径"`gameCreationAppAssetCategory(asset)`),
* 载荷会变成 `ui-interaction` —— 也就是把自愈值写回落盘、让"只改标签"静默改分类,
* 本用例必须失败。
*/
test('只改标签时载荷 category 仍是落盘原值,不把选择器上的自愈值写回去', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: selfHealingAsset });
expect(
screen
.getByRole('button', { name: 'UI 交互' })
.getAttribute('aria-pressed'),
).toBe('true');
await user.type(
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
'界面',
);
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
expect(classificationWrites(invoke)[0]?.[1]).toEqual({
input: {
projectPath: 'C:/project',
expectedProjectId: 'project-1',
expectedProjectRevision: 7,
assetId: 'asset-ui',
category: 'unclassified',
tags: ['界面'],
},
});
});
/**
* 显式选「待归类」写下去的就是 `unclassified`。
*
* 注意这里只钉**写入值**,不给"卡片会挪到待归类栏目"的承诺:读显示口径会把
* `kind` 已能明确分类的资产自愈回派生栏目(`unclassified` 无法区分"没有明确分类"
* 与"用户显式选了待归类",这是 `gameCreationApp.ts` 记录的有意取舍)。
* 选一个**能**被派生值承认的分类(上一条的 `scene`)不受该取舍影响。
*/
test('显式选中「待归类」:载荷 category 是 unclassified', async () => {
const user = userEvent.setup();
const invoke = installClassificationInvoke();
renderPanel({ asset: { ...asset, category: 'character' } });
await user.click(screen.getByRole('button', { name: '待归类' }));
await user.click(screen.getByRole('button', { name: '添加' }));
await waitFor(() => {
expect(classificationWrites(invoke)).toHaveLength(1);
});
const input = (
classificationWrites(invoke)[0]?.[1] as {
input: { category: string };
}
).input;
expect(input.category).toBe('unclassified');
});
/**
* 改类型与「点添加不关窗、可连续添加」不互斥:第一次改类型保存后,第二次接着改回另一类型
* 仍然各写一次盘,两次的 `category` 分别是用户当次选的值。
*
* 变异验证:把 `categoryChoice` 只在首次生效(保存后重置为落盘值),第二次写入的
* `category` 会退回落盘值,本用例必须失败。
*/
test('连续两次「添加」各带当次选择的类型,且面板不自己关窗', async () => {
const invoke = installClassificationInvoke();
const onClose = vi.fn();
renderPanel({ asset: selfHealingAsset, onClose });
const add = () => screen.getByRole('button', { name: '添加' });
fireEvent.click(screen.getByRole('button', { name: '角色与对象' }));
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '音频' }));
fireEvent.click(add());
await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(2));
expect(
classificationWrites(invoke).map(
([, args]) => (args as { input: { category: string } }).input.category,
),
).toEqual(['character', 'audio']);
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByRole('dialog', { name: '编辑素材标签' })).not.toBeNull();
});
});
describe('ResourceClassificationPanel 不再承载删除素材', () => {
/**
* 删除素材挪到资源卡选中工具条(破坏性动作与它要改的对象放在一起),面板里**一个入口都不该再有**:
* 旧版 footer 那个 `tone="danger"` 的「删除」删的是素材,属于放错地方。
*
* 顺序也一并钉住:头部关闭 → 标签 pill 内的删除标签 → 底部「添加」,多出任何一个按钮都会红。
* 顺序也一并钉住:头部关闭 → 素材类型选择器 6 项 → 标签 pill 内的删除标签 → 底部「添加」,
* 多出任何一个按钮都会红。
* 变异验证:把 footer 的删除素材按钮加回来,本用例必须失败。
*/
test('面板里没有任何删除素材入口,打开面板不读写删除相关命令', async () => {
@@ -470,7 +690,18 @@ describe('ResourceClassificationPanel 不再承载删除素材', () => {
.map(
(button) => button.getAttribute('aria-label') ?? button.textContent,
),
).toEqual(['关闭编辑素材标签', '删除标签 主角', '添加']);
).toEqual([
'关闭编辑素材标签',
// 素材类型选择器:6 个合法分类,顺序就是画布栏目顺序。
'UI 交互',
'角色与对象',
'场景与环境',
'音频',
'文档',
'待归类',
'删除标签 主角',
'添加',
]);
// 打开面板不读引用、不写盘:删除那条链路在这里已经完全不存在。
expect(invoke).not.toHaveBeenCalled();
});
@@ -369,7 +369,7 @@ type UpdateProjectResourceCanvasLayoutResult =
实现状态(2026-09-10):资源画布分区口径是 manifest 资产的功能分类 `category``ui-interaction / character / scene / audio / document / unclassified`)加末尾独立的「项目版本」栏目,不再按扩展名或 mediaType 派生分区。`icon / icon-spritesheet / icon-spec / ui-design` 进入 UI 交互,`character / character-animation` 进入角色与对象,`scene` 进入场景与环境,`sound-effect / background-music / audio` 进入音频,`spec` 与合法 Agent 文本回执进入文档;`image / video / code / publication-material` 以及任务产物、导入附件进入待归类,只登记游戏代码的项目因此有可见栏目与卡片,不再出现四栏全空;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本。扩展名分类器只保留准入与卡片显示类型职责:无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。既有布局 sidecar 的旧栏目坐标按读时归并继续生效,`x / y / manuallyPlaced` 原样保留。
分类取值口径(2026-09-11 收口,2026-09-11 二次收口统一跨端实现):分「读显示」与「写回」两个口径,两者不是同一个函数。**读显示口径**:落盘 `category` 是权威值,缺失或非法时按 `assets[].kind` 派生;唯一例外是读时自愈——落盘值为 `unclassified` 而该资产 `kind` 能派生出明确的非 `unclassified` 分类时采用派生值,用于修复历史上被系统误写成 `unclassified` 的存量数据(无需迁移脚本、永久自愈);`kind` 派生结果本身就是 `unclassified` 的(`image / video / code / publication-material`)仍信任落盘值。该例外的已知盲区是「资产 `kind` 已能明确分类而落盘值为 `unclassified`」会被读时自愈覆盖,属有意接受的最小覆盖窗口。**这个口径必须跨端同构**:TS 侧 `gameCreationAppAssetCategory`、Rust 侧 `game_creation_app_asset_effective_category`,由 `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析 Rust 源码里的 `EFFECTIVE_CATEGORY_CONTRACT` 决策矩阵交叉钉住;Agent 侧的资源投影(`direct_tool_bridge.rs`)走 Rust 那条,不许再直接透传落盘 `category`——否则同一条资产会出现「UI 显示 UI 交互、Agent 读到待归类」。**写回口径**:`gameCreationAppAssetPersistedCategory`(只做缺失 / 非法兜底,不套自愈),等于 manifest 反序列化后的落盘原值。分类有用户手动设置入口:资源卡工具条的「编辑标签」面板编辑 manifest `assets[].tags`,保存时回传**落盘原值**(不是自愈值),保证「只改标签」不会静默改分类。写入侧必须只产出 canonical kind(画板导出推断同样如此);alias 表**大小写不敏感**,因为 UI 设计资产的现役写入侧写的是大写 `"UI"`,字体上传写 `font`——两者都必须落进明确栏目(`"UI" → ui-design → UI 交互``font → document → 文档`),留在别名表外就会落成 `image → unclassified` 且读时自愈也救不回来。
分类取值口径(2026-09-11 收口,2026-09-11 二次收口统一跨端实现):分「读显示」与「写回」两个口径,两者不是同一个函数。**读显示口径**:落盘 `category` 是权威值,缺失或非法时按 `assets[].kind` 派生;唯一例外是读时自愈——落盘值为 `unclassified` 而该资产 `kind` 能派生出明确的非 `unclassified` 分类时采用派生值,用于修复历史上被系统误写成 `unclassified` 的存量数据(无需迁移脚本、永久自愈);`kind` 派生结果本身就是 `unclassified` 的(`image / video / code / publication-material`)仍信任落盘值。该例外的已知盲区是「资产 `kind` 已能明确分类而落盘值为 `unclassified`」会被读时自愈覆盖,属有意接受的最小覆盖窗口。**这个口径必须跨端同构**:TS 侧 `gameCreationAppAssetCategory`、Rust 侧 `game_creation_app_asset_effective_category`,由 `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析 Rust 源码里的 `EFFECTIVE_CATEGORY_CONTRACT` 决策矩阵交叉钉住;Agent 侧的资源投影(`direct_tool_bridge.rs`)走 Rust 那条,不许再直接透传落盘 `category`——否则同一条资产会出现「UI 显示 UI 交互、Agent 读到待归类」。**写回口径**:`gameCreationAppAssetPersistedCategory`(只做缺失 / 非法兜底,不套自愈),等于 manifest 反序列化后的落盘原值。分类有用户手动设置入口:资源卡工具条的「编辑标签」面板同时编辑 manifest `assets[].tags` 与素材类型(功能分类)。面板里的类型选择器显示的是**读显示口径**(与画布栏目同源,用户看到的选中项就是他看到的栏目);**写回**则分两路——用户没碰过类型控件时回传**落盘原值**(不是自愈值),保证「只改标签」不会静默改分类;用户主动选了类型时写用户选的那个值。已知盲区:把 `kind` 已能明确分类的资产显式设为「待归类」会被读时自愈覆盖回派生栏目,因为落盘 `unclassified` 无法区分「没有明确分类」与「用户显式选了待归类」(要真正支持需在 manifest 里区分两者,属独立决策)。资源卡右上角角标显示**资源类型**(同一份功能分类中文名,恒等于该卡所在栏目),不再显示「图片 / 视频 / 文档」这类媒体类型;媒体类型仍由卡面视觉表达。写入侧必须只产出 canonical kind(画板导出推断同样如此);alias 表**大小写不敏感**,因为 UI 设计资产的现役写入侧写的是大写 `"UI"`,字体上传写 `font`——两者都必须落进明确栏目(`"UI" → ui-design → UI 交互``font → document → 文档`),留在别名表外就会落成 `image → unclassified` 且读时自愈也救不回来。
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
@@ -15,6 +15,17 @@
- 关联文档:相关 PRD、技术文档、提交或 Issue
```
## 2026-09-11 素材类型(功能分类)重新提供用户入口;资源卡角标改显示资源类型而非媒体类型
- 背景:`6bdc8bbd9` 把「分类与标签」面板收敛为纯标签面板,并明确记下「随之的事实是:**「用户手动设置 `category`」这项能力就此移除**」(本文件 2026-09-11 那条以「用户给出的目标样式截图里,这个面板标题是「编辑素材标签」…」开头的条目,其决策一行即该结论)。用户随后要求「用户可以自己变更素材类型」,并追加要求把资源卡右上角角标从"文件/媒体类型"(图片 / 视频 / 文档 …)改成"资源类型"(功能分类中文名)。现状核实:写入链路本来就是完整的 —— `update_local_project_resource_classification``src-tauri/src/commands.rs:2162`)→ `update_manifest_asset_classification_at``src-tauri/src/project/manifest.rs:1112`)已接受任意合法 `category` 并校验 6 个合法值,**本次不需要改 Rust**;缺的只有 UI 入口与角标口径。本条**只回收「面板不再编辑分类」这一项**,「面板标题仍是「编辑素材标签」、删除资源入口仍在选中工具条」等其余结论不变。
- 决策一(入口):类型选择器加进**「编辑素材标签」面板**(与标签同一次保存、同一条写入路径,不新增第二条命令、不在工具条另开第二个入口)。选择器复用 `GAME_CREATION_APP_ASSET_CATEGORIES` × `resourceReferenceCategoryLabel`,不新造第二套中文译名。
- 决策二(两个口径的分叉,本次核心不变量):选择器**读显示口径** `gameCreationAppAssetCategory`(与画布栏目 `projectResourceAssetCategory` 同源,用户看到的选中项就是他看到的栏目);**写回**用 `categoryChoice` 区分用户是否主动选过 —— `null`(没碰过控件)回传 `gameCreationAppAssetPersistedCategory` 的落盘原值,非 `null` 写用户选的值。这条分叉同时满足"只改标签不漂移分类"与"用户选了就写用户的值",两个方向都有对照用例(见验证方式)。
- 决策三(角标):资源卡右上角角标改为**资源类型**,取值 `categoryLabels[resource.category]`(栏目与筛选共用的同一份文案),因此角标恒等于该卡所在栏目;媒体类型仍由卡面视觉(图片 / 视频 / 音频 / 文档摘要)表达。只改这一处渲染(`index.tsx``ResourceCard`),三处面(栏目画布卡、「所有资源」展开态卡、总览缩略摞上铺的卡)自动一致;`projectResourceTypeLabel` 保留给「资源管理面板」的「分类 · 类型」小字与总览摞分列,不再用于角标。
- 画布跟随链(核实结论,**无需额外迁移代码**):`useProjectResourceCanvasLayout``createResourceSignature` 已把 `resource.category` 计入签名,分类变化 → 签名变化 → `reconcileResourceCanvasLayout` 按新的 `section` 归并(`resourceCanvasSectionMapping.resolveResourceCanvasSection`:现行栏目值原样归到资源当前分类,x / y / `manuallyPlaced` 原样保留)→ 需要时写回 sidecar。卡片随分组落到新栏目。
- 已知盲区(**有意保留,未修**):把 `kind` 已能明确分类的资产显式设为「待归类」会被读时自愈覆盖回派生栏目(落盘 `unclassified` 无法区分"没有明确分类"与"用户显式选了待归类")。修它需要在 manifest 里区分"未设置"与"显式 `unclassified`",属契约级改动 + 迁移,超出本次范围;相应地,面板不给"卡片会挪到待归类"的承诺,只有明确用例钉住写入值是 `unclassified`
- 影响范围:`src/view/project-development/ResourceClassificationPanel.tsx`(选择器 + 写回分叉)、`src/view/project-development/index.tsx`(角标取值)、`tests/resourceClassificationPanel.test.tsx``tests/projectResourceLiveIntegration.test.tsx`(真宿主跟随链)、`tests/appSurface/project-development.suite.ts`(原媒体类型角标断言改资源类型)、PRD §5.3、AGC 资源工作台 V3 端到端验收用例 S5、[`【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的写入命令一节。Rust / manifest 字段构成 / SpacetimeDB 都不动。
- 验证方式:`npm run test -- apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx apps/ai-game-creator-shell/tests/appSurface.test.ts` + `npm run typecheck` + `npm run check:encoding` + `git diff --check`。变异验证(均已实测):①写入改回"永远回传落盘原值" → 3 条面板用例红;②没碰控件时改回"回传自愈值"(`gameCreationAppAssetCategory`)→ 2 条对照用例红;③选择器改读落盘值 → 选择器用例红;④角标改回 `projectResourceTypeLabel` → 角标用例与 appSurface 断言红。**不可构造**的变异:把角标"只在挂载时算一次"—— 选中资源本身就会把画布切进它所在栏目,改类型又让卡片换到另一个栏目分组,React 每次都卸载重建卡片宿主,挂载快照与实时读数在 DOM 上同形。
## 2026-09-11 pre-commit 补 Rust 格式守卫:lint-staged 增 *.rs,本地不再只靠 CI 的 check:rustfmt
- 背景:本 PR 已因 `check:rustfmt` 红过一次(`15660a98b` 修掉本批遗留的 8 处格式偏差)。根因是 `.husky/pre-commit` 只跑 `lint-staged`,而它的 glob 只覆盖 `*.{js,mjs,cjs,ts,tsx}` —— **Rust 格式在本地没有任何守卫**,唯一防线是 CI 那一侧(`check-repository-ci.sh``npm run lint``check:rustfmt`);本地没人跑得到的门禁等于没有门禁,「本地全绿、CI 才红」就会反复发生。
@@ -18,7 +18,7 @@
资源筛选口径收敛为单一权威:`category``apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts` 删除按 mediaType 优先的 7 桶口径(`image / video / audio / ui / document / other` + `all`)与 `resourceReferenceFilterKind`,改为导出 `resourceReferenceCategory``resourceReferenceMatchesCategoryFilter``RESOURCE_REFERENCE_FILTERS``全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`,对应 `all` + 6 个合法分类)与 `resourceReferenceCategoryLabel``ResourceReference` 新增 `category``tags`,由 `resourceReferenceFromAsset``gameCreationAppAssetCategory` / `gameCreationAppAssetTags` 从 manifest 资产投影;资源画布的 `ProjectResource` 同步投影 `assetCategory` / `assetTags`,非 manifest 资产(任务产物、附件、Agent 回执、项目版本)没有 `category` 事实源,统一按 `unclassified` 参与筛选,不按扩展名另造分类。语义变化:旧「图片」「视频」桶在 category 口径下不再独立存在,`image` / `video` / `code` / `publication-material``待归类``icon / icon-spritesheet / icon-spec / ui-design``UI 交互``character / character-animation``角色与对象``scene``场景与环境`。资源画布分区使用同一口径:`PROJECT_RESOURCE_CANVAS_SECTIONS` = 6 类资产分类加末尾独立的「项目版本」栏目(项目版本不是资源资产,固定单独成栏),扩展名分类器降级为只负责准入与卡片显示类型,不再决定分区;布局 sidecar 里的旧栏目取值按资源当前分类读时归并,坐标原样保留。
分类与标签的唯一写入命令是 `update_local_project_resource_classification`(输入 `projectPath``expectedProjectId``expectedProjectRevision``assetId``category``tags`),实现落在 `project/manifest.rs``update_manifest_asset_classification_at`:走 `mutate_manifest_at` 既有 manifest 写锁与原子写入,命令包装层只做 `asset.register` 权限校验;持项目写锁后按 `expectedProjectId` 与当前项目 revision 做 CAS,不匹配分别返回 `project-identity-conflict``project-revision-conflict``category` 必须是 6 个合法值之一,非法值报错失败且不回退到 `kind` 派生;`tags``normalize_game_creation_app_asset_tags`(trim、去空、去重)。写入只改目标条目的 `category` / `tags`,保持 `GameCreationAppAssetManifestEntry` 字段构成与顺序不变,成功后项目 revision 前进一次。UI 上 `@` 面板与资源画布共用 `packages/shared``PlatformSegmentedTabs` 渲染同一组筛选标签,画布筛选状态与画布视口一样按「按依赖 / 按类型」分别保存;资源详情面板提供「分类与标签」独立面板(`ThemedModal` + `PlatformSegmentedTabs` + `PlatformTextField`)做最小编辑,保存后重读 manifest 并经 `onManifestChange` 反映到筛选与投影。
分类与标签的唯一写入命令是 `update_local_project_resource_classification`(输入 `projectPath``expectedProjectId``expectedProjectRevision``assetId``category``tags`),实现落在 `project/manifest.rs``update_manifest_asset_classification_at`:走 `mutate_manifest_at` 既有 manifest 写锁与原子写入,命令包装层只做 `asset.register` 权限校验;持项目写锁后按 `expectedProjectId` 与当前项目 revision 做 CAS,不匹配分别返回 `project-identity-conflict``project-revision-conflict``category` 必须是 6 个合法值之一,非法值报错失败且不回退到 `kind` 派生;`tags``normalize_game_creation_app_asset_tags`(trim、去空、去重)。写入只改目标条目的 `category` / `tags`,保持 `GameCreationAppAssetManifestEntry` 字段构成与顺序不变,成功后项目 revision 前进一次。UI 上 `@` 面板与资源画布共用 `packages/shared``PlatformSegmentedTabs` 渲染同一组筛选标签,画布筛选状态与画布视口一样按「按依赖 / 按类型」分别保存;资源详情面板提供「编辑素材标签」独立面板(`ThemedModal` + `PlatformSegmentedTabs` + `PlatformTextField`)做最小编辑(标签 + 素材类型),保存后重读 manifest 并经 `onManifestChange` 反映到筛选、栏目与投影。
## 2026-09-09 manifest 资源功能分类与自定义标签(数据层)
@@ -39,7 +39,7 @@
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| --- | --- | --- | --- | --- | --- |
| **S4** 首轮生成 | 在右侧 Supervisor 对话里发起首轮素材生成 | 生成由**对话启动**(不用画布按钮);素材写入对应栏目并登记进 manifest;未生成的素材不显示为可编辑对象 | L25、L94、L500;飞书 §4 首轮生成 | 对话完成后 `.agent/manifest.json``assets[]` 长度增加;新卡 `data-resource-card-id``asset:<id>` 形态 | 「首轮进度投影」只在 Issue 交付范围的"应该"清单出现、**无编码级判据**;现有可见性由底部 Agent 状态栏承载 |
| **S5** 栏目归属 | 看资源总览的栏目缩略卡与各栏目卡片 | 固定 7 栏:`UI 交互 → 角色与对象 → 场景与环境 → 音频 → 文档 → 待归类 → 项目版本`;分区口径是 manifest `assets[].category`,前端交互不能改分类 | L50、L56、L324、L358360、L539 | `[...document.querySelectorAll('.game-resource-book-thumbnail[data-resource-book-category]')].map(e=>e.dataset.resourceBookCategory)`;再数每栏 `.game-resource-card` 数量 | **读时自愈**:落盘 `unclassified``kind` 能明确分类时按派生值显示(L360)。真机 57 条落盘 `unclassified` 在 UI 上应显示到正确栏目,**判定必须看 UI 计数,不能看 manifest 文件计数**。⚠ 反过来**不能**把自愈值当落盘值:判定「编辑标签分类有没有被改」必须比 manifest 文件(自愈只在读显示层,写回用 `gameCreationAppAssetPersistedCategory` |
| **S5** 栏目归属 | 看资源总览的栏目缩略卡与各栏目卡片 | 固定 7 栏:`UI 交互 → 角色与对象 → 场景与环境 → 音频 → 文档 → 待归类 → 项目版本`;分区口径是 manifest `assets[].category`。**用户可经「编辑标签」面板的类型选择器改分类**(选择器显示读显示口径;写回:没碰过控件回传落盘原值、主动选过写用户选的值),改完卡片应落到新分类对应栏目、角标同步 | L50、L56、L324、L358360、L539 | `[...document.querySelectorAll('.game-resource-book-thumbnail[data-resource-book-category]')].map(e=>e.dataset.resourceBookCategory)`;再数每栏 `.game-resource-card` 数量;角标读 `.game-resource-card-type-badge``data-resource-type` | **读时自愈**:落盘 `unclassified``kind` 能明确分类时按派生值显示(L360)。真机 57 条落盘 `unclassified` 在 UI 上应显示到正确栏目,**判定必须看 UI 计数,不能看 manifest 文件计数**。⚠ 反过来**不能**把自愈值当落盘值:判定「只改标签分类有没有被改」必须比 manifest 文件(自愈只在读显示层,写回用 `gameCreationAppAssetPersistedCategory`。⚠ 显式把 `kind` 已能明确分类的资产设为「待归类」会被自愈覆盖回派生栏目——已知盲区,不作为本用例的通过判据 |
| **S6** 卡片预览台账 | 逐栏目看卡片是否显示真实主体 | 图片与安全 SVG 直接显示主体并保留透明棋盘底;视频、音频、文档、项目版本用稳定固定卡;失败显示类型占位、不挂破图、不降级为项目外 URL | L55、L6162、**L67**、L347349 | 见 5.1 的四条脚本:区分"没读 / 读失败 / 解码为空 / 不适用" | **可见卡停 `idle` 的真 bug 已修但未提交**`useProjectResourceCardPreviews.ts`);未用新构建时会出现假失败 |
### C 阶段 · 资源管理闭环