diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx index 5350aec11..133ad07ff 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx @@ -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(null); + const displayedCategory = + categoryChoice ?? gameCreationAppAssetCategory(asset); const [tags, setTags] = useState(() => 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({
+ {/* + 素材类型选择器:选中的那一项就是这张卡当前所在的画布栏目。 + 点任意一项即视为用户主动改类型(即便点的是当前已选中的那一项), + 与「没碰过就回传落盘原值」的分叉保持同一条判据,不做隐式 no-op。 + */} + {tags.length > 0 ? (
    {tags.map((tag) => ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 779889167..00cc8ef2c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -721,7 +721,15 @@ const ResourceCard = memo(function ResourceCard({ const [decodedIdentity, setDecodedIdentity] = useState(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({ - {resourceTypeLabel} + {resourceCategoryLabel}
} + 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(); + + 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(); + + 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(); + }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx index 19d568950..61eb07fab 100644 --- a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx @@ -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(); }); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 95e10902c..1131a7ef9 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -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`,从而避免布局、依赖边、选择和聚焦状态因改名失效。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 90dd71f64..940ee575a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 才红」就会反复发生。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 29e16979a..3c48500bf 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -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 资源功能分类与自定义标签(数据层) diff --git a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md index 16a0b131b..b48371e63 100644 --- a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md +++ b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md @@ -39,7 +39,7 @@ | 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 | | --- | --- | --- | --- | --- | --- | | **S4** 首轮生成 | 在右侧 Supervisor 对话里发起首轮素材生成 | 生成由**对话启动**(不用画布按钮);素材写入对应栏目并登记进 manifest;未生成的素材不显示为可编辑对象 | L25、L94、L500;飞书 §4 首轮生成 | 对话完成后 `.agent/manifest.json` 的 `assets[]` 长度增加;新卡 `data-resource-card-id` 为 `asset:` 形态 | 「首轮进度投影」只在 Issue 交付范围的"应该"清单出现、**无编码级判据**;现有可见性由底部 Agent 状态栏承载 | -| **S5** 栏目归属 | 看资源总览的栏目缩略卡与各栏目卡片 | 固定 7 栏:`UI 交互 → 角色与对象 → 场景与环境 → 音频 → 文档 → 待归类 → 项目版本`;分区口径是 manifest `assets[].category`,前端交互不能改分类 | L50、L56、L324、L358–360、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、L358–360、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、L61–62、**L67**、L347–349 | 见 5.1 的四条脚本:区分"没读 / 读失败 / 解码为空 / 不适用" | **可见卡停 `idle` 的真 bug 已修但未提交**(`useProjectResourceCardPreviews.ts`);未用新构建时会出现假失败 | ### C 阶段 · 资源管理闭环