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:
+51
@@ -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%);
|
||||||
|
}
|
||||||
+104
-38
@@ -1,18 +1,18 @@
|
|||||||
|
import '../../features/project-workspace/resourceClassificationTagPanel.css';
|
||||||
|
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
|
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 { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
|
||||||
import {
|
import {
|
||||||
GAME_CREATION_APP_ASSET_CATEGORIES,
|
|
||||||
type GameCreationAppAssetCategory,
|
|
||||||
gameCreationAppAssetCategory,
|
gameCreationAppAssetCategory,
|
||||||
type GameCreationAppAssetManifestEntry,
|
type GameCreationAppAssetManifestEntry,
|
||||||
gameCreationAppAssetTags,
|
gameCreationAppAssetTags,
|
||||||
normalizeGameCreationAppAssetTags,
|
normalizeGameCreationAppAssetTags,
|
||||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||||
import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences';
|
|
||||||
import {
|
import {
|
||||||
ResourceAssetDeleteDialog,
|
ResourceAssetDeleteDialog,
|
||||||
type ResourceAssetReferenceVersion,
|
type ResourceAssetReferenceVersion,
|
||||||
@@ -35,21 +35,40 @@ type ReadLocalProjectAssetReferencesResult = {
|
|||||||
versions: ResourceAssetReferenceVersion[];
|
versions: ResourceAssetReferenceVersion[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS =
|
/**
|
||||||
GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({
|
* 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。
|
||||||
id: category,
|
* 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。
|
||||||
label: resourceReferenceCategoryLabel(category),
|
*/
|
||||||
}));
|
|
||||||
|
|
||||||
/** 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。 */
|
|
||||||
function splitResourceClassificationTagsDraft(value: string) {
|
function splitResourceClassificationTagsDraft(value: string) {
|
||||||
return normalizeGameCreationAppAssetTags(value.split(/[,,、\n]/u));
|
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) {
|
function resourceClassificationErrorMessage(error: unknown) {
|
||||||
if (typeof error === 'string' && error.trim()) return error;
|
if (typeof error === 'string' && error.trim()) return error;
|
||||||
if (error instanceof Error && error.message) return error.message;
|
if (error instanceof Error && error.message) return error.message;
|
||||||
return '保存资源分类与标签失败';
|
return '保存素材标签失败';
|
||||||
}
|
}
|
||||||
|
|
||||||
function resourceDeleteErrorMessage(error: unknown) {
|
function resourceDeleteErrorMessage(error: unknown) {
|
||||||
@@ -75,12 +94,16 @@ export function ResourceClassificationPanel({
|
|||||||
onSaved,
|
onSaved,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
}: ResourceClassificationPanelProps) {
|
}: ResourceClassificationPanelProps) {
|
||||||
const [category, setCategory] = useState<GameCreationAppAssetCategory>(() =>
|
/**
|
||||||
gameCreationAppAssetCategory(asset),
|
* 分类取值优先级由落盘 `category` + `kind` 派生决定,本面板不再提供手动设置入口。
|
||||||
);
|
* 写入命令的 `category` 是必填,这里读一次当前权威值并在保存时原样回传,
|
||||||
const [tagsDraft, setTagsDraft] = useState(() =>
|
* 保证「只改标签」不会顺带改动分类。
|
||||||
gameCreationAppAssetTags(asset).join('、'),
|
*/
|
||||||
|
const [category] = useState(() => gameCreationAppAssetCategory(asset));
|
||||||
|
const [tags, setTags] = useState<string[]>(() =>
|
||||||
|
gameCreationAppAssetTags(asset),
|
||||||
);
|
);
|
||||||
|
const [tagDraft, setTagDraft] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [deleteDialogPreparing, setDeleteDialogPreparing] = useState(false);
|
const [deleteDialogPreparing, setDeleteDialogPreparing] = useState(false);
|
||||||
@@ -92,10 +115,23 @@ export function ResourceClassificationPanel({
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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() {
|
async function saveResourceClassification() {
|
||||||
const invoke = window.__TAURI__?.core?.invoke;
|
const invoke = window.__TAURI__?.core?.invoke;
|
||||||
if (!invoke) {
|
if (!invoke) {
|
||||||
setError('资源分类与标签需要在客户端内保存');
|
setError('编辑素材标签需要在客户端内保存');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -108,6 +144,9 @@ export function ResourceClassificationPanel({
|
|||||||
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
|
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
|
||||||
throw new Error('项目 revision 无效');
|
throw new Error('项目 revision 无效');
|
||||||
}
|
}
|
||||||
|
// 输入框里还没按回车 / 逗号落成 pill 的尾巴也要一起保存,
|
||||||
|
// 沿用"整段文本在保存时统一切分"的既有行为,不让用户白输入。
|
||||||
|
const tagsToSave = mergeResourceClassificationTagDraft(tags, tagDraft);
|
||||||
const result =
|
const result =
|
||||||
await invoke<UpdateLocalProjectResourceClassificationResult>(
|
await invoke<UpdateLocalProjectResourceClassificationResult>(
|
||||||
'update_local_project_resource_classification',
|
'update_local_project_resource_classification',
|
||||||
@@ -117,8 +156,9 @@ export function ResourceClassificationPanel({
|
|||||||
expectedProjectId: projectId,
|
expectedProjectId: projectId,
|
||||||
expectedProjectRevision: status.revision,
|
expectedProjectRevision: status.revision,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
|
// 分类不再由本面板编辑:读到的权威值原样回传。
|
||||||
category,
|
category,
|
||||||
tags: splitResourceClassificationTagsDraft(tagsDraft),
|
tags: normalizeGameCreationAppAssetTags(tagsToSave),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -196,35 +236,61 @@ export function ResourceClassificationPanel({
|
|||||||
return (
|
return (
|
||||||
<ThemedModal
|
<ThemedModal
|
||||||
open
|
open
|
||||||
ariaLabel="资源分类与标签"
|
ariaLabel="编辑素材标签"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
panelClassName="game-approval-dialog game-resource-classification-dialog"
|
panelClassName="game-approval-dialog game-resource-classification-dialog"
|
||||||
>
|
>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<h2>资源分类与标签</h2>
|
<h2>编辑素材标签</h2>
|
||||||
<p>{asset.localPath}</p>
|
<p>{resourceAssetDisplayName(asset.localPath)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label="关闭资源分类与标签" onClick={onClose}>
|
<button type="button" aria-label="关闭编辑素材标签" onClick={onClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="game-resource-classification-body">
|
<div className="game-resource-classification-body">
|
||||||
<PlatformSegmentedTabs
|
{tags.length > 0 ? (
|
||||||
items={RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS}
|
<ul className="game-resource-tag-list" aria-label="已有标签">
|
||||||
activeId={category}
|
{tags.map((tag) => (
|
||||||
onChange={setCategory}
|
// 单点使用,先不抽到 packages/shared。若第二处出现带删除按钮的标签 pill,
|
||||||
layout="scroll"
|
// 抽到 `packages/shared` 做 `PlatformRemovableTagPill`,不要复制这份实现。
|
||||||
gap="sm"
|
<li key={tag}>
|
||||||
frame="bare"
|
<PlatformPillBadge
|
||||||
surface="transparent"
|
tone="warning"
|
||||||
size="compact"
|
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
|
<PlatformTextField
|
||||||
aria-label="资源标签"
|
aria-label="新增标签"
|
||||||
placeholder="用、或逗号分隔标签"
|
placeholder="新增标签,多个用逗号分隔"
|
||||||
value={tagsDraft}
|
value={tagDraft}
|
||||||
onChange={(event) => setTagsDraft(event.currentTarget.value)}
|
onChange={(event) => setTagDraft(event.currentTarget.value)}
|
||||||
|
onBlur={commitTagDraft}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === 'Enter' ||
|
||||||
|
event.key === ',' ||
|
||||||
|
event.key === ','
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
commitTagDraft();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="game-resource-classification-error" role="alert">
|
<p className="game-resource-classification-error" role="alert">
|
||||||
@@ -252,7 +318,7 @@ export function ResourceClassificationPanel({
|
|||||||
onClick={() => void saveResourceClassification()}
|
onClick={() => void saveResourceClassification()}
|
||||||
disabled={saving || deleting}
|
disabled={saving || deleting}
|
||||||
>
|
>
|
||||||
保存
|
保存标签
|
||||||
</PlatformActionButton>
|
</PlatformActionButton>
|
||||||
</footer>
|
</footer>
|
||||||
{deleteDialogOpen ? (
|
{deleteDialogOpen ? (
|
||||||
|
|||||||
@@ -5719,8 +5719,8 @@ export default function ProjectDevelopmentView({
|
|||||||
{selectedResource?.manifestAssetId ? (
|
{selectedResource?.manifestAssetId ? (
|
||||||
<CanvasChromeButton
|
<CanvasChromeButton
|
||||||
className="image-canvas-editor__floating-toolbar-text-button"
|
className="image-canvas-editor__floating-toolbar-text-button"
|
||||||
label="分类与标签"
|
label="编辑标签"
|
||||||
title="分类与标签"
|
title="编辑标签"
|
||||||
icon={<ListFilter className="h-4 w-4" />}
|
icon={<ListFilter className="h-4 w-4" />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setResourceClassificationAssetId(
|
setResourceClassificationAssetId(
|
||||||
@@ -5728,7 +5728,7 @@ export default function ProjectDevelopmentView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>分类与标签</span>
|
<span>编辑标签</span>
|
||||||
</CanvasChromeButton>
|
</CanvasChromeButton>
|
||||||
) : null}
|
) : null}
|
||||||
{selectedResource?.manifestAssetId ? (
|
{selectedResource?.manifestAssetId ? (
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
// @vitest-environment jsdom
|
// @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 userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -36,13 +45,136 @@ function removeInvoke() {
|
|||||||
).__TAURI__;
|
).__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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
removeInvoke();
|
removeInvoke();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ResourceClassificationPanel', () => {
|
describe('ResourceClassificationPanel 编辑素材标签', () => {
|
||||||
test('writes the category and normalized tags through the controlled native command', async () => {
|
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 user = userEvent.setup();
|
||||||
const onSaved = vi.fn();
|
const onSaved = vi.fn();
|
||||||
const invoke = installInvoke(async (command) => {
|
const invoke = installInvoke(async (command) => {
|
||||||
@@ -51,56 +183,60 @@ describe('ResourceClassificationPanel', () => {
|
|||||||
}
|
}
|
||||||
if (command === 'update_local_project_resource_classification') {
|
if (command === 'update_local_project_resource_classification') {
|
||||||
return {
|
return {
|
||||||
asset: { ...asset, category: 'scene', tags: ['主舞台', '日夜'] },
|
asset: { ...asset, tags: ['节日', '新春'] },
|
||||||
committedProjectRevision: 8,
|
committedProjectRevision: 8,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error(`unexpected command: ${command}`);
|
throw new Error(`unexpected command: ${command}`);
|
||||||
});
|
});
|
||||||
|
renderPanel({ asset: { ...asset, tags: ['主页', '节日'] }, onSaved });
|
||||||
|
|
||||||
render(
|
await user.click(screen.getByRole('button', { name: '删除标签 主页' }));
|
||||||
<ResourceClassificationPanel
|
await user.type(
|
||||||
projectPath="C:/project"
|
screen.getByPlaceholderText('新增标签,多个用逗号分隔'),
|
||||||
projectId="project-1"
|
'新春',
|
||||||
asset={asset}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
onSaved={onSaved}
|
|
||||||
onDeleted={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存标签' }));
|
||||||
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 waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
expect(invoke).toHaveBeenCalledWith(
|
const [, args] = classificationWrites(invoke)[0]!;
|
||||||
'update_local_project_resource_classification',
|
expect(args).toEqual({
|
||||||
expect.objectContaining({
|
input: {
|
||||||
input: expect.objectContaining({
|
projectPath: 'C:/project',
|
||||||
projectPath: 'C:/project',
|
expectedProjectId: 'project-1',
|
||||||
expectedProjectId: 'project-1',
|
expectedProjectRevision: 7,
|
||||||
expectedProjectRevision: 7,
|
assetId: 'asset-hero',
|
||||||
assetId: 'asset-hero',
|
// 本面板不编辑分类:读到的权威值原样回传,不因为只改标签而漂移。
|
||||||
category: 'scene',
|
category: 'character',
|
||||||
}),
|
tags: ['节日', '新春'],
|
||||||
}),
|
},
|
||||||
);
|
});
|
||||||
const [, args] = invoke.mock.calls.find(
|
});
|
||||||
([command]) => command === 'update_local_project_resource_classification',
|
|
||||||
)!;
|
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((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 () => {
|
test('surfaces the native rejection without reporting a save', async () => {
|
||||||
@@ -113,18 +249,9 @@ describe('ResourceClassificationPanel', () => {
|
|||||||
throw '非法资源分类:future-category';
|
throw '非法资源分类:future-category';
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
renderPanel({ onSaved });
|
||||||
<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.click(screen.getByRole('button', { name: '保存标签' }));
|
||||||
|
|
||||||
await screen.findByRole('alert');
|
await screen.findByRole('alert');
|
||||||
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
|
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
|
||||||
@@ -134,24 +261,52 @@ describe('ResourceClassificationPanel', () => {
|
|||||||
test('does not attempt a write outside the native client', async () => {
|
test('does not attempt a write outside the native client', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSaved = vi.fn();
|
const onSaved = vi.fn();
|
||||||
render(
|
renderPanel({ onSaved });
|
||||||
<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.click(screen.getByRole('button', { name: '保存标签' }));
|
||||||
|
|
||||||
await screen.findByRole('alert');
|
await screen.findByRole('alert');
|
||||||
expect(screen.getByRole('alert').textContent).toContain('客户端');
|
expect(screen.getByRole('alert').textContent).toContain('客户端');
|
||||||
expect(onSaved).not.toHaveBeenCalled();
|
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 () => {
|
test('deletes the asset registration only after the confirmation panel is submitted', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onDeleted = vi.fn();
|
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):资源画布分区口径是 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`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
|
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,14 @@
|
|||||||
- 关联文档:相关 PRD、技术文档、提交或 Issue
|
- 关联文档:相关 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 类资产分类加项目版本栏目,旧栏目坐标读时归并
|
## 2026-09-10 AGC 资源画布分区改为 6 类资产分类加项目版本栏目,旧栏目坐标读时归并
|
||||||
|
|
||||||
- 背景:资源画布原分区轴是「扩展名 + mediaType」派生的 `document / art / audio / code / version`,普通画布只显示其中四栏并隐藏游戏代码,与 `@` 面板、资源详情筛选已经收敛的单一权威 `category`(`ui-interaction / character / scene / audio / document / unclassified`)不一致。只登记游戏代码的项目会因资源签名非空进入分页画布,但 code 不在可见栏目里,于是四栏全空、一张卡都不显示。旧布局 sidecar 已存有旧 `section` 值,而 PRD 要求历史手动坐标只读恢复,不删除、不重置、不迁移。
|
- 背景:资源画布原分区轴是「扩展名 + mediaType」派生的 `document / art / audio / code / version`,普通画布只显示其中四栏并隐藏游戏代码,与 `@` 面板、资源详情筛选已经收敛的单一权威 `category`(`ui-interaction / character / scene / audio / document / unclassified`)不一致。只登记游戏代码的项目会因资源签名非空进入分页画布,但 code 不在可见栏目里,于是四栏全空、一张卡都不显示。旧布局 sidecar 已存有旧 `section` 值,而 PRD 要求历史手动坐标只读恢复,不删除、不重置、不迁移。
|
||||||
|
|||||||
Reference in New Issue
Block a user