Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx
T
suzmii 7ce4a3a9c6 把素材类型从「编辑素材标签」面板拆成独立入口
- 新增 ResourceTypePanel(src/view/project-development/ResourceTypePanel.tsx):类型选一项即落盘,category 传用户选中值、tags 逐字回传落盘原值,保存在飞时沿用 closeOnBackdrop/closeOnEscape = !saving
- 新增 resourceAssetDisplayName.ts:两块面板共用同一个素材名副标题口径(localPath basename),不再各写一份
- 新增 resourceTypePanel.css:类型面板的弹窗骨架与信息浮层「分类」行入口样式,按 AGC 内独立文件约定,不动 packages/** 与 styles.css
- ResourceClassificationPanel 删掉类型 chip 与 categoryChoice:保存时恒回传 gameCreationAppAssetPersistedCategory(asset),「只改标签不动分类」由结构保证
- index.tsx 新增 resourceTypeAssetId 与 resourceTypeAsset;工具条在「编辑标签」旁新增「素材类型」按钮(Shapes 图标)
- index.tsx 用合并后的 resourceClassificationOverlayOpen 纳入点外部/Esc 浮层遮挡判据,并同步 useEffect 依赖项
- index.tsx 渲染新面板:保存成功后由宿主收起面板并沿用 manifest 重载路径,让卡片立刻落到新栏目
- 信息浮层「分类」行接出第二入口:按钮渲染在 dd 之外,不污染 dt/dd 字段读取口径;非 manifest 资产不给入口
- resourceCanvasInfoModel 导出 RESOURCE_INFO_CATEGORY_FIELD_LABEL,模型与入口共用同一行标识
- 新增 tests/resourceTypePanel.test.tsx:选中即落盘、tags 逐字回传、没碰过不写盘、显式待归类、失败回退、saving 锁、Esc/遮罩、面板骨架共 11 条
- 迁移「没碰过分类就回传落盘原值」对照用例并保留两条(标签面板侧),chip 用例搬到新面板文件
- 集成用例改走新入口:选中即落盘并自动关窗,另补 Esc/点外部不串台、只改标签后 manifest category 逐字不变两条宿主级用例
- appSurface 工具条清单补上「素材类型」
2026-09-14 17:07:15 +08:00

248 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import '../../features/project-workspace/resourceClassificationTagPanel.css';
import { X } from 'lucide-react';
import { useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge';
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
import {
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetPersistedCategory,
gameCreationAppAssetTags,
normalizeGameCreationAppAssetTags,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage';
import { resourceAssetDisplayName } from './resourceAssetDisplayName';
type UpdateLocalProjectResourceClassificationResult = {
asset: GameCreationAppAssetManifestEntry;
committedProjectRevision: number;
};
/**
* 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。
* 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 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;
}
function resourceClassificationErrorMessage(error: unknown) {
// 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出;
// 与重命名、删除共用同一份映射。
return projectAssetCommandErrorMessage(error, '保存素材标签失败');
}
type ResourceClassificationPanelProps = {
projectPath: string;
projectId: string;
asset: GameCreationAppAssetManifestEntry;
onClose: () => void;
onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void;
};
export function ResourceClassificationPanel({
projectPath,
projectId,
asset,
onClose,
onSaved,
}: ResourceClassificationPanelProps) {
/**
* 本面板只编辑标签:素材类型(功能分类)在「设置素材类型」面板里单独设置。
*
* **写回必须用落盘口径** `gameCreationAppAssetPersistedCategory`,不能用读显示口径
* `gameCreationAppAssetCategory`:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind`
* 能派生出明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类
* (真机上同一条 `kind:"ui"` 资产同时出现过 `unclassified` 与 `ui-interaction` 两种落盘值)。
*
* 拆出类型面板后这条不变量不再依赖"用户是否碰过控件",而是结构性的:
* 本面板没有类型控件,`category` 恒为落盘原值。
* `tests/resourceClassificationPanel.test.tsx` 两个方向各有用例钉住它。
*/
const [tags, setTags] = useState<string[]>(() =>
gameCreationAppAssetTags(asset),
);
const [tagDraft, setTagDraft] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
/** 回车 / 逗号 / 顿号都按同一口径切分:把草稿落成 pill。 */
function commitTagDraft() {
if (!tagDraft.trim()) return;
setTags((current) =>
mergeResourceClassificationTagDraft(current, tagDraft),
);
setTagDraft('');
}
function removeTag(tag: string) {
setTags((current) => current.filter((item) => item !== tag));
}
/**
* 保存标签:`tagsToSave` 由调用方给出(「添加」把输入框里还未落成 pill 的尾巴一起并入)。
*/
async function saveResourceClassification(
tagsToSave: readonly string[],
): Promise<void> {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setError('编辑素材标签需要在客户端内保存');
return;
}
setSaving(true);
setError(null);
try {
const status = await invoke<{ revision: number }>(
'get_local_game_project_revision',
{ projectPath },
);
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
throw new Error('项目 revision 无效');
}
const result =
await invoke<UpdateLocalProjectResourceClassificationResult>(
'update_local_project_resource_classification',
{
input: {
projectPath,
expectedProjectId: projectId,
expectedProjectRevision: status.revision,
assetId: asset.id,
// 分类不由本面板编辑:恒回传落盘原值(不含读时自愈)。
category: gameCreationAppAssetPersistedCategory(asset),
tags: normalizeGameCreationAppAssetTags(tagsToSave),
},
},
);
onSaved(result);
} catch (saveError) {
setError(resourceClassificationErrorMessage(saveError));
} finally {
setSaving(false);
}
}
/**
* 底部唯一的「添加」:把输入框里的内容(**含没按回车的尾巴**)落成 pill,然后按同一写入路径保存。
*
* 落 pill 是即时反馈,保存是本次动作的语义本身 —— 拆成"先落 pill 再等用户去点保存"要求
* 用户理解两步,而这里只有一步。
*/
async function addTagDraftAndSave() {
const tagsToSave = mergeResourceClassificationTagDraft(tags, tagDraft);
setTags(tagsToSave);
setTagDraft('');
await saveResourceClassification(tagsToSave);
}
return (
<ThemedModal
open
ariaLabel="编辑素材标签"
onClose={onClose}
// 保存在飞时不许用 Escape / 点遮罩把面板关掉:关掉后迟到的 `onSaved`
// 会打到一个已经卸载的面板上。头部 × 同样按 `saving` 禁用。
closeOnBackdrop={!saving}
closeOnEscape={!saving}
panelClassName="game-approval-dialog game-resource-classification-dialog"
>
<header>
<div>
<h2>编辑素材标签</h2>
<p>{resourceAssetDisplayName(asset.localPath)}</p>
</div>
<button
type="button"
aria-label="关闭编辑素材标签"
disabled={saving}
onClick={onClose}
>
×
</button>
</header>
<div className="game-resource-classification-body">
{/*
本面板没有素材类型控件:类型是「设置素材类型」面板的编辑对象,入口在资源卡选中
工具条上。曾长在这里的类型 chip 只改本地 state、不落盘,保存又只能借道标签的
「添加」,导致"改了类型没生效"。
*/}
{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={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">
{error}
</p>
) : null}
</div>
{/*
底部只留一个「添加」:它 = 落 pill + 保存。删除素材挪到资源卡选中工具条
(破坏性操作与它要改的对象放在一起),标签自身的删除按钮仍在每个 pill 内部。
*/}
<footer className="game-resource-classification-footer">
<PlatformActionButton
onClick={() => void addTagDraftAndSave()}
disabled={saving}
>
添加
</PlatformActionButton>
</footer>
</ThemedModal>
);
}