AGC 资源分类面板改为「编辑素材标签」:只编辑标签、分类不再有手动入口

- ResourceClassificationPanel.tsx:面板改为只编辑 manifest `assets[].tags`,移除分类 6 类
  chip 那一排;标题与 aria-label 改「编辑素材标签」,副标题由资源路径改为素材名
  (`localPath` 的 basename,仓库没有独立显示名字段)
- ResourceClassificationPanel.tsx:标签状态由「整段逗号分隔字符串」改为字符串数组,
  每个已有标签渲染成自带删除按钮的胶囊 pill;保存时把未落成 pill 的输入尾巴一并切分,
  归一化仍走 `normalizeGameCreationAppAssetTags`,回车 / 中英文逗号 / 顿号提交方式不变
- ResourceClassificationPanel.tsx:写入命令 `category` 是必填,读一次当前权威值并
  在保存时原样回传,保证「只改标签」不漂移分类;底部按钮「保存」改「保存标签」,
  删除资源入口按既有保留(截图未画,但它是唯一的资源删除入口)
- resourceClassificationTagPanel.css:新增标签 pill 样式,删除按钮嵌在 pill 内部
  (视觉图标 11px、热区 32×32、负外边距抵消不撑高 pill);若第二处出现带删除按钮的
  标签 pill,抽到 `packages/shared` 做 `PlatformRemovableTagPill`,不要复制这份实现
- index.tsx:资源卡工具条入口 label / title / 文案由「分类与标签」改「编辑标签」,
  与面板标题一致(否则入口写着分类、打开的是纯标签面板)
- tests/resourceClassificationPanel.test.tsx:断言对齐截图文案、逐个 pill 自带删除、
  点某个 × 只删对应标签、aria-label 可区分、保存 payload 的 tags 数组与 category 原样回传、
  取消不写盘、入口 label 与面板标题一致、删除热区不小于 32px
- 文档:PRD §5.3「分类取值优先级」改写为当前状态(分类没有用户手动设置入口、
  该面板只编辑标签),并新增 decision-log 条目记录本次产品决策与「用户手动设
  category 的能力就此移除」这一事实
- 说明:截图文案(编辑素材标签 / 新增标签,多个用逗号分隔 / 保存标签)在全仓检索
  无命中,属仓库之外来源,本次按用户截图对齐,不是 PRD 明文
This commit is contained in:
2026-09-11 15:34:36 +08:00
parent 2a157ea6f8
commit 6bdc8bbd93
6 changed files with 382 additions and 102 deletions
@@ -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%);
}
@@ -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` 的 basenamemanifest 资产没有独立的显示名字段,
* 与资源卡、`@` 面板的显示口径一致。
*/
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>(() =>
gameCreationAppAssetCategory(asset),
);
const [tagsDraft, setTagsDraft] = useState(() =>
gameCreationAppAssetTags(asset).join('、'),
/**
* 分类取值优先级由落盘 `category` + `kind` 派生决定,本面板不再提供手动设置入口。
* 写入命令的 `category` 是必填,这里读一次当前权威值并在保存时原样回传,
* 保证「只改标签」不会顺带改动分类。
*/
const [category] = useState(() => gameCreationAppAssetCategory(asset));
const [tags, setTags] = useState<string[]>(() =>
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<string | null>(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<UpdateLocalProjectResourceClassificationResult>(
'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 (
<ThemedModal
open
ariaLabel="资源分类与标签"
ariaLabel="编辑素材标签"
onClose={onClose}
panelClassName="game-approval-dialog game-resource-classification-dialog"
>
<header>
<div>
<h2></h2>
<p>{asset.localPath}</p>
<h2></h2>
<p>{resourceAssetDisplayName(asset.localPath)}</p>
</div>
<button type="button" aria-label="关闭资源分类与标签" onClick={onClose}>
<button type="button" aria-label="关闭编辑素材标签" onClick={onClose}>
×
</button>
</header>
<div className="game-resource-classification-body">
<PlatformSegmentedTabs
items={RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS}
activeId={category}
onChange={setCategory}
layout="scroll"
gap="sm"
frame="bare"
surface="transparent"
size="compact"
/>
{tags.length > 0 ? (
<ul className="game-resource-tag-list" aria-label="已有标签">
{tags.map((tag) => (
// 单点使用,先不抽到 packages/shared。若第二处出现带删除按钮的标签 pill,
// 抽到 `packages/shared` 做 `PlatformRemovableTagPill`,不要复制这份实现。
<li key={tag}>
<PlatformPillBadge
tone="warning"
size="xs"
className="game-resource-tag-pill"
>
{tag}
<button
type="button"
className="game-resource-tag-remove"
aria-label={`删除标签 ${tag}`}
onClick={() => removeTag(tag)}
>
<X size={11} aria-hidden="true" />
</button>
</PlatformPillBadge>
</li>
))}
</ul>
) : null}
<PlatformTextField
aria-label="资源标签"
placeholder="用、或逗号分隔标签"
value={tagsDraft}
onChange={(event) => 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 ? (
<p className="game-resource-classification-error" role="alert">
@@ -252,7 +318,7 @@ export function ResourceClassificationPanel({
onClick={() => void saveResourceClassification()}
disabled={saving || deleting}
>
</PlatformActionButton>
</footer>
{deleteDialogOpen ? (
@@ -5719,8 +5719,8 @@ export default function ProjectDevelopmentView({
{selectedResource?.manifestAssetId ? (
<CanvasChromeButton
className="image-canvas-editor__floating-toolbar-text-button"
label="分类与标签"
title="分类与标签"
label="编辑标签"
title="编辑标签"
icon={<ListFilter className="h-4 w-4" />}
onClick={() =>
setResourceClassificationAssetId(
@@ -5728,7 +5728,7 @@ export default function ProjectDevelopmentView({
)
}
>
<span></span>
<span></span>
</CanvasChromeButton>
) : null}
{selectedResource?.manifestAssetId ? (
@@ -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(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={overrides.asset ?? asset}
onClose={overrides.onClose ?? vi.fn()}
onSaved={overrides.onSaved ?? vi.fn()}
onDeleted={overrides.onDeleted ?? vi.fn()}
/>,
);
}
/** 已有标签按 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<typeof vi.fn>) {
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(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={onSaved}
onDeleted={vi.fn()}
/>,
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(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={onSaved}
onDeleted={vi.fn()}
/>,
);
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(
<ResourceClassificationPanel
projectPath="C:/project"
projectId="project-1"
asset={asset}
onClose={vi.fn()}
onSaved={onSaved}
onDeleted={vi.fn()}
/>,
);
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('<span>编辑标签</span>');
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();
@@ -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`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
@@ -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 要求历史手动坐标只读恢复,不删除、不重置、不迁移。