diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceClassificationTagPanel.css b/apps/ai-game-creator-shell/src/features/project-workspace/resourceClassificationTagPanel.css new file mode 100644 index 000000000..790143817 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceClassificationTagPanel.css @@ -0,0 +1,51 @@ +/* + * 「编辑素材标签」面板的标签 pill。 + * + * 单独一个文件而不是塞进 styles.css:这份样式是本次「编辑素材标签」面板的专属表现, + * 与工作台其它区块没有共享选择器,独立文件让改动边界更清楚,也不会与同一时段 + * 其它 Agent 在 styles.css 里的编辑互相踩。 + * + * 删除按钮嵌在 pill 内部(不是飘在外面的独立图标):pill 是 `PlatformPillBadge` + * 给的 `inline-flex`,按钮作为它的子元素自然落在胶囊内。视觉图标小(11px), + * 热区给足 32×32,并用负外边距抵消,避免把 pill 撑高。 + */ + +.game-resource-tag-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.game-resource-tag-list > li { + display: flex; + min-width: 0; +} + +.game-resource-tag-pill { + max-width: 100%; + overflow-wrap: anywhere; +} + +.game-resource-tag-remove { + display: grid; + width: 32px; + height: 32px; + flex: 0 0 32px; + margin: -10px -8px -10px 0; + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: inherit; + place-items: center; + cursor: pointer; +} + +.game-resource-tag-remove:hover, +.game-resource-tag-remove:focus-visible { + background: rgb(0 0 0 / 10%); +} 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 3a70afbdd..ada481912 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 @@ -1,18 +1,18 @@ +import '../../features/project-workspace/resourceClassificationTagPanel.css'; + +import { X } from 'lucide-react'; import { useState } from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; -import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; +import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge'; import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; import { - GAME_CREATION_APP_ASSET_CATEGORIES, - type GameCreationAppAssetCategory, gameCreationAppAssetCategory, type GameCreationAppAssetManifestEntry, gameCreationAppAssetTags, normalizeGameCreationAppAssetTags, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { ThemedModal } from '../../components/modal/ThemedModal'; -import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences'; import { ResourceAssetDeleteDialog, type ResourceAssetReferenceVersion, @@ -35,21 +35,40 @@ type ReadLocalProjectAssetReferencesResult = { versions: ResourceAssetReferenceVersion[]; }; -const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS = - GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({ - id: category, - label: resourceReferenceCategoryLabel(category), - })); - -/** 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。 */ +/** + * 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。 + * 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。 + */ function splitResourceClassificationTagsDraft(value: string) { return normalizeGameCreationAppAssetTags(value.split(/[,,、\n]/u)); } +/** 把标签草稿里的新标签并入已有标签,重复项沿用归一化语义直接丢弃。 */ +function mergeResourceClassificationTagDraft( + tags: readonly string[], + draft: string, +) { + const next = [...tags]; + for (const tag of splitResourceClassificationTagsDraft(draft)) { + if (!next.includes(tag)) next.push(tag); + } + return next; +} + +/** + * 素材名取 `localPath` 的 basename:manifest 资产没有独立的显示名字段, + * 与资源卡、`@` 面板的显示口径一致。 + */ +function resourceAssetDisplayName(localPath: string) { + const normalized = localPath.replaceAll('\\', '/'); + const segments = normalized.split('/'); + return segments[segments.length - 1] || localPath; +} + function resourceClassificationErrorMessage(error: unknown) { if (typeof error === 'string' && error.trim()) return error; if (error instanceof Error && error.message) return error.message; - return '保存资源分类与标签失败'; + return '保存素材标签失败'; } function resourceDeleteErrorMessage(error: unknown) { @@ -75,12 +94,16 @@ export function ResourceClassificationPanel({ onSaved, onDeleted, }: ResourceClassificationPanelProps) { - const [category, setCategory] = useState(() => - gameCreationAppAssetCategory(asset), - ); - const [tagsDraft, setTagsDraft] = useState(() => - gameCreationAppAssetTags(asset).join('、'), + /** + * 分类取值优先级由落盘 `category` + `kind` 派生决定,本面板不再提供手动设置入口。 + * 写入命令的 `category` 是必填,这里读一次当前权威值并在保存时原样回传, + * 保证「只改标签」不会顺带改动分类。 + */ + const [category] = useState(() => gameCreationAppAssetCategory(asset)); + const [tags, setTags] = useState(() => + gameCreationAppAssetTags(asset), ); + const [tagDraft, setTagDraft] = useState(''); const [saving, setSaving] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogPreparing, setDeleteDialogPreparing] = useState(false); @@ -92,10 +115,23 @@ export function ResourceClassificationPanel({ const [deleting, setDeleting] = useState(false); const [error, setError] = useState(null); + /** 回车 / 逗号 / 顿号都按同一口径切分;输入框仍是同一套提交方式。 */ + function commitTagDraft() { + if (!tagDraft.trim()) return; + setTags((current) => + mergeResourceClassificationTagDraft(current, tagDraft), + ); + setTagDraft(''); + } + + function removeTag(tag: string) { + setTags((current) => current.filter((item) => item !== tag)); + } + async function saveResourceClassification() { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { - setError('资源分类与标签需要在客户端内保存'); + setError('编辑素材标签需要在客户端内保存'); return; } setSaving(true); @@ -108,6 +144,9 @@ export function ResourceClassificationPanel({ if (!Number.isSafeInteger(status.revision) || status.revision < 0) { throw new Error('项目 revision 无效'); } + // 输入框里还没按回车 / 逗号落成 pill 的尾巴也要一起保存, + // 沿用"整段文本在保存时统一切分"的既有行为,不让用户白输入。 + const tagsToSave = mergeResourceClassificationTagDraft(tags, tagDraft); const result = await invoke( 'update_local_project_resource_classification', @@ -117,8 +156,9 @@ export function ResourceClassificationPanel({ expectedProjectId: projectId, expectedProjectRevision: status.revision, assetId: asset.id, + // 分类不再由本面板编辑:读到的权威值原样回传。 category, - tags: splitResourceClassificationTagsDraft(tagsDraft), + tags: normalizeGameCreationAppAssetTags(tagsToSave), }, }, ); @@ -196,35 +236,61 @@ export function ResourceClassificationPanel({ return (
-

资源分类与标签

-

{asset.localPath}

+

编辑素材标签

+

{resourceAssetDisplayName(asset.localPath)}

-
- + {tags.length > 0 ? ( +
    + {tags.map((tag) => ( + // 单点使用,先不抽到 packages/shared。若第二处出现带删除按钮的标签 pill, + // 抽到 `packages/shared` 做 `PlatformRemovableTagPill`,不要复制这份实现。 +
  • + + {tag} + + +
  • + ))} +
+ ) : null} setTagsDraft(event.currentTarget.value)} + aria-label="新增标签" + placeholder="新增标签,多个用逗号分隔" + value={tagDraft} + onChange={(event) => setTagDraft(event.currentTarget.value)} + onBlur={commitTagDraft} + onKeyDown={(event) => { + if ( + event.key === 'Enter' || + event.key === ',' || + event.key === ',' + ) { + event.preventDefault(); + commitTagDraft(); + } + }} /> {error ? (

@@ -252,7 +318,7 @@ export function ResourceClassificationPanel({ onClick={() => void saveResourceClassification()} disabled={saving || deleting} > - 保存 + 保存标签 {deleteDialogOpen ? ( 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 1524176a4..029ff4fb1 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 @@ -5719,8 +5719,8 @@ export default function ProjectDevelopmentView({ {selectedResource?.manifestAssetId ? ( } onClick={() => setResourceClassificationAssetId( @@ -5728,7 +5728,7 @@ export default function ProjectDevelopmentView({ ) } > - 分类与标签 + 编辑标签 ) : null} {selectedResource?.manifestAssetId ? ( diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx index d4cfad5a9..2ed45a429 100644 --- a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx @@ -1,5 +1,14 @@ // @vitest-environment jsdom -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -36,13 +45,136 @@ function removeInvoke() { ).__TAURI__; } +function renderPanel( + overrides: { + asset?: GameCreationAppAssetManifestEntry; + onClose?: () => void; + onSaved?: (result: unknown) => void; + onDeleted?: (result: unknown) => void; + } = {}, +) { + render( + , + ); +} + +/** 已有标签按 pill 文本读出(pill 内的删除按钮文本是 `×`)。 */ +function pillLabels() { + const list = screen.queryByRole('list', { name: '已有标签' }); + if (!list) return []; + return within(list) + .getAllByRole('listitem') + .map((item) => item.textContent?.replace('×', '').trim()); +} + +function classificationWrites(invoke: ReturnType) { + return invoke.mock.calls.filter( + ([command]) => command === 'update_local_project_resource_classification', + ); +} + afterEach(() => { cleanup(); removeInvoke(); }); -describe('ResourceClassificationPanel', () => { - test('writes the category and normalized tags through the controlled native command', async () => { +describe('ResourceClassificationPanel 编辑素材标签', () => { + test('对齐用户截图:标题、素材名副标题、标签占位与底部按钮', () => { + installInvoke(async () => undefined); + renderPanel({ + asset: { ...asset, localPath: 'assets/UI Assets/生成UI设计图.png' }, + }); + + expect( + screen.getByRole('heading', { name: '编辑素材标签' }), + ).not.toBeNull(); + // 副标题是素材名(localPath 的 basename),不再是资源路径。 + expect(screen.getByText('生成UI设计图.png')).not.toBeNull(); + expect(screen.queryByText('assets/UI Assets/生成UI设计图.png')).toBeNull(); + expect( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + ).not.toBeNull(); + expect(screen.getByRole('button', { name: '保存标签' })).not.toBeNull(); + expect(screen.getByRole('button', { name: '取消' })).not.toBeNull(); + // 「管理全部标签」没有实现,分类也不再由这个面板编辑。 + expect(screen.queryByRole('button', { name: '管理全部标签' })).toBeNull(); + expect(screen.queryByRole('button', { name: '角色与对象' })).toBeNull(); + expect(screen.queryByRole('button', { name: '场景与环境' })).toBeNull(); + }); + + test('每个已有标签渲染成自带删除按钮的 pill,且 aria-label 可区分', () => { + installInvoke(async () => undefined); + renderPanel({ asset: { ...asset, tags: ['主页', '节日'] } }); + + expect(pillLabels()).toEqual(['主页', '节日']); + // 删除按钮在 pill 内部(不是飘在外面的独立图标)。 + const pill = screen.getByRole('button', { name: '删除标签 主页' }); + expect(pill.closest('.game-resource-tag-pill')).not.toBeNull(); + expect( + screen.getByRole('button', { name: '删除标签 节日' }), + ).not.toBeNull(); + }); + + test('点某个 pill 的删除按钮只删掉那一个标签', async () => { + const user = userEvent.setup(); + installInvoke(async () => undefined); + renderPanel({ asset: { ...asset, tags: ['主页', '节日'] } }); + + await user.click(screen.getByRole('button', { name: '删除标签 主页' })); + + expect(pillLabels()).toEqual(['节日']); + expect(screen.queryByRole('button', { name: '删除标签 主页' })).toBeNull(); + expect( + screen.getByRole('button', { name: '删除标签 节日' }), + ).not.toBeNull(); + }); + + test('删除在保存前可撤销:点取消不写盘', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const invoke = installInvoke(async () => undefined); + renderPanel({ + asset: { ...asset, tags: ['主页', '节日'] }, + onClose, + }); + + await user.click(screen.getByRole('button', { name: '删除标签 主页' })); + expect(pillLabels()).toEqual(['节日']); + + await user.click(screen.getByRole('button', { name: '取消' })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(classificationWrites(invoke)).toHaveLength(0); + }); + + test('新增标签沿用同一套提交方式:回车 / 中英文逗号 / 顿号都切分', async () => { + const user = userEvent.setup(); + installInvoke(async () => undefined); + renderPanel({ asset: { ...asset, tags: [] } }); + + const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔'); + await user.type(field, '主页,'); + expect(pillLabels()).toEqual(['主页']); + + await user.type(field, '节日,'); + expect(pillLabels()).toEqual(['主页', '节日']); + + await user.type(field, '新春{Enter}'); + expect(pillLabels()).toEqual(['主页', '节日', '新春']); + + // 纯空白草稿不会落成空标签。 + await user.type(field, ' {Enter}'); + expect(pillLabels()).toEqual(['主页', '节日', '新春']); + }); + + test('保存时把剩余标签写成数组,并原样回传读到的分类', async () => { const user = userEvent.setup(); const onSaved = vi.fn(); const invoke = installInvoke(async (command) => { @@ -51,56 +183,60 @@ describe('ResourceClassificationPanel', () => { } if (command === 'update_local_project_resource_classification') { return { - asset: { ...asset, category: 'scene', tags: ['主舞台', '日夜'] }, + asset: { ...asset, tags: ['节日', '新春'] }, committedProjectRevision: 8, }; } throw new Error(`unexpected command: ${command}`); }); + renderPanel({ asset: { ...asset, tags: ['主页', '节日'] }, onSaved }); - render( - , + await user.click(screen.getByRole('button', { name: '删除标签 主页' })); + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '新春', ); - - await user.click(screen.getByRole('button', { name: '场景与环境' })); - const tagsField = screen.getByLabelText('资源标签'); - await user.clear(tagsField); - await user.type(tagsField, ' 主舞台 ,日夜,主舞台、'); - await user.click(screen.getByRole('button', { name: '保存' })); + await user.click(screen.getByRole('button', { name: '保存标签' })); await waitFor(() => { expect(onSaved).toHaveBeenCalledTimes(1); }); - expect(invoke).toHaveBeenCalledWith( - 'update_local_project_resource_classification', - expect.objectContaining({ - input: expect.objectContaining({ - projectPath: 'C:/project', - expectedProjectId: 'project-1', - expectedProjectRevision: 7, - assetId: 'asset-hero', - category: 'scene', - }), - }), - ); - const [, args] = invoke.mock.calls.find( - ([command]) => command === 'update_local_project_resource_classification', - )!; + const [, args] = classificationWrites(invoke)[0]!; + expect(args).toEqual({ + input: { + projectPath: 'C:/project', + expectedProjectId: 'project-1', + expectedProjectRevision: 7, + assetId: 'asset-hero', + // 本面板不编辑分类:读到的权威值原样回传,不因为只改标签而漂移。 + category: 'character', + tags: ['节日', '新春'], + }, + }); + }); + + test('归一化口径不变:重复与空白标签在保存前被收敛', async () => { + const user = userEvent.setup(); + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + return { asset, committedProjectRevision: 8 }; + }); + renderPanel({ asset: { ...asset, tags: [] } }); + + const field = screen.getByPlaceholderText('新增标签,多个用逗号分隔'); + await user.type(field, ' 主舞台 ,日夜,主舞台、'); + await user.click(screen.getByRole('button', { name: '保存标签' })); + + await waitFor(() => { + expect(classificationWrites(invoke)).toHaveLength(1); + }); + const [, args] = classificationWrites(invoke)[0]!; expect((args as { input: { tags: string[] } }).input.tags).toEqual([ '主舞台', '日夜', ]); - expect(onSaved.mock.calls[0]?.[0]).toEqual({ - asset: { ...asset, category: 'scene', tags: ['主舞台', '日夜'] }, - committedProjectRevision: 8, - }); }); test('surfaces the native rejection without reporting a save', async () => { @@ -113,18 +249,9 @@ describe('ResourceClassificationPanel', () => { throw '非法资源分类:future-category'; }); - render( - , - ); + renderPanel({ onSaved }); - await user.click(screen.getByRole('button', { name: '保存' })); + await user.click(screen.getByRole('button', { name: '保存标签' })); await screen.findByRole('alert'); expect(screen.getByRole('alert').textContent).toContain('非法资源分类'); @@ -134,24 +261,52 @@ describe('ResourceClassificationPanel', () => { test('does not attempt a write outside the native client', async () => { const user = userEvent.setup(); const onSaved = vi.fn(); - render( - , - ); + renderPanel({ onSaved }); - await user.click(screen.getByRole('button', { name: '保存' })); + await user.click(screen.getByRole('button', { name: '保存标签' })); await screen.findByRole('alert'); expect(screen.getByRole('alert').textContent).toContain('客户端'); expect(onSaved).not.toHaveBeenCalled(); }); + test('资源卡工具条入口 label 与面板标题一致,不再是「分类与标签」', () => { + const viewSource = readFileSync( + resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/view/project-development/index.tsx', + ), + 'utf8', + ); + + // 工具条写着「分类与标签」却打开纯标签面板会误导用户,入口必须与面板同名。 + expect(viewSource).toContain('label="编辑标签"'); + expect(viewSource).toContain('title="编辑标签"'); + expect(viewSource).toContain('编辑标签'); + expect(viewSource).not.toContain('label="分类与标签"'); + }); + + test('标签 pill 的删除按钮热区不小于 32px', () => { + const panelStyles = readFileSync( + resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/features/project-workspace/resourceClassificationTagPanel.css', + ), + 'utf8', + ); + + const rule = panelStyles.match( + /\.game-resource-tag-remove\s*\{([^}]*)\}/s, + )?.[1]; + expect(rule).toBeDefined(); + // 视觉图标可以小,热区不能小:这是"看得到却点不中"的防线。 + expect(rule).toMatch(/width:\s*32px/); + expect(rule).toMatch(/height:\s*32px/); + expect(rule).toMatch(/flex:\s*0 0 32px/); + }); +}); + +describe('ResourceClassificationPanel 删除资源', () => { test('deletes the asset registration only after the confirmation panel is submitted', async () => { const user = userEvent.setup(); const onDeleted = vi.fn(); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index f7fdf9619..fd081e991 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -357,7 +357,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-10 收口):落盘 `category` 是权威值(用户可在「分类与标签」面板手动设置),缺失或非法时按 `assets[].kind` 派生。唯一例外是读时自愈——落盘值为 `unclassified` 而该资产 `kind` 能派生出明确的非 `unclassified` 分类时采用派生值,用于修复历史上被系统误写成 `unclassified` 的存量数据(无需迁移脚本、永久自愈);`kind` 派生结果本身就是 `unclassified` 的(`image / video / code / publication-material`)仍信任落盘值。该例外的已知盲区是「用户手动把 kind 已能明确分类的资产设为待归类」会被覆盖,属有意接受的最小覆盖窗口。写入侧必须只产出 canonical kind(画板导出推断同样如此),别名表仅用于兼容存量数据。 +分类取值优先级(2026-09-11 收口):落盘 `category` 是权威值,缺失或非法时按 `assets[].kind` 派生。唯一例外是读时自愈——落盘值为 `unclassified` 而该资产 `kind` 能派生出明确的非 `unclassified` 分类时采用派生值,用于修复历史上被系统误写成 `unclassified` 的存量数据(无需迁移脚本、永久自愈);`kind` 派生结果本身就是 `unclassified` 的(`image / video / code / publication-material`)仍信任落盘值。该例外的已知盲区是「资产 `kind` 已能明确分类而落盘值为 `unclassified`」会被读时自愈覆盖,属有意接受的最小覆盖窗口。写入侧必须只产出 canonical kind(画板导出推断同样如此),别名表仅用于兼容存量数据。分类没有用户手动设置的入口:资源卡工具条的「编辑标签」面板只编辑 manifest `assets[].tags`,保存时把读到的 `category` 权威值原样回传。 资源身份固定使用 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 5927fbd39..1432ae9e9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,14 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-09-11 资源卡「编辑标签」面板只编辑标签,分类不再有手动设置入口 + +- 背景:用户给出的目标样式截图里,这个面板标题是「编辑素材标签」、副标题是素材名、已有标签是自带删除按钮的胶囊 pill、输入框提示「新增标签,多个用逗号分隔」、底部只有「取消」与「保存标签」,**没有分类那一排**。而面板原实现同时承担 6 类 `category` 手动设置与 `assets[].tags` 编辑,PRD §5.3 也写着「用户可在「分类与标签」面板手动设置 `category`」。资源卡浮出工具条上只有这一个相关入口(`label="分类与标签"` → `setResourceClassificationAssetId`),不存在第二个「编辑素材标签」入口,所以两种读法只能二选一。截图里的标题/按钮文案在全仓(含 `docs/**`、`.codex/**`、各类型源码)检索均无命中,属仓库之外的来源,因此本次改动以用户截图为准、不宣称是 PRD 明文。 +- 决策:**该面板只编辑 manifest `assets[].tags`,移除分类 chip 那一排。** 随之的事实是:**「用户手动设置 `category`」这项能力就此移除**,`category` 只由落盘值与 `assets[].kind` 派生加读时自愈决定。写入命令 `update_local_project_resource_classification` 的 `category` 是必填,前端读一次当前权威值并在保存时**原样回传**,因此「只改标签」不会顺带改动分类,Rust 侧与 manifest 字段构成都不改。面板标题改「编辑素材标签」、入口按钮 label 改「编辑标签」(否则工具条写着「分类与标签」却打开纯标签面板,属误导)。「删除资源」按钮截图未画但保留:`openDeleteResourceDialog` 只在这个面板里被调用,删掉会让用户失去唯一的资源删除入口。 +- 影响范围:`apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx`(标题/副标题/标签状态由整段字符串改为字符串数组/pill 列表/底部按钮文案)、`index.tsx` 的入口按钮 label、`apps/ai-game-creator-shell/src/styles.css` 的标签 pill 选择器块;`packages/shared` 的标签归一化(`normalizeGameCreationAppAssetTags`)与写入契约不变。 +- 验证方式:`ResourceClassificationPanel` 定向测试覆盖「已有标签渲染成 pill」「点某个 `×` 只删对应标签」「每个删除按钮 `aria-label` 可区分」「保存 payload 的 `tags` 是数组且分类原样回传」「取消不写盘/删除在保存前可撤销」;`npm run ai-game-creator-shell:typecheck`、AGC 全量测试、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` §5.3、`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`。 + ## 2026-09-10 AGC 资源画布分区改为 6 类资产分类加项目版本栏目,旧栏目坐标读时归并 - 背景:资源画布原分区轴是「扩展名 + mediaType」派生的 `document / art / audio / code / version`,普通画布只显示其中四栏并隐藏游戏代码,与 `@` 面板、资源详情筛选已经收敛的单一权威 `category`(`ui-interaction / character / scene / audio / document / unclassified`)不一致。只登记游戏代码的项目会因资源签名非空进入分页画布,但 code 不在可见栏目里,于是四栏全空、一张卡都不显示。旧布局 sidecar 已存有旧 `section` 值,而 PRD 要求历史手动坐标只读恢复,不删除、不重置、不迁移。