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 工具条清单补上「素材类型」
248 lines
9.1 KiB
TypeScript
248 lines
9.1 KiB
TypeScript
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>
|
||
);
|
||
}
|