import '../../features/project-workspace/resourceClassificationTagPanel.css'; import { X } from 'lucide-react'; import { useRef, 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; }; /** * 批量追加标签的原生响应:`assets` 是按去重后请求 ID 首现顺序返回的整组最新条目, * `committedProjectRevision` 是本批写入后的项目 revision(整批无变化时是当前值)。 */ export type AddLocalProjectResourceTagsResult = { assets: 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, fallback = '保存素材标签失败', ) { // 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出; // 与重命名、删除共用同一份映射。 return projectAssetCommandErrorMessage(error, fallback); } /** * 标签 pill 列表:单素材模式渲染**已有标签**(逐项可删),批量模式渲染**待追加草稿** * (逐项可删,但删的是草稿、不是素材上已落盘的标签)。 * * 两个模式共用这一份实现:同在「编辑素材标签」面板内,不需要为此抽到 `packages/shared`; * 真有第二个宿主面板用到带删除按钮的 pill 时再抽 `PlatformRemovableTagPill`,不要复制。 */ function ResourceTagPillList({ ariaLabel, removeAriaLabel, tags, onRemove, disabled = false, }: { ariaLabel: string; removeAriaLabel: (tag: string) => string; tags: readonly string[]; onRemove: (tag: string) => void; /** 保存期间锁住 pill 上的删除(批量模式用;单素材模式保持既有行为不传)。 */ disabled?: boolean; }) { if (tags.length === 0) return null; return ( ); } type ResourceClassificationPanelCommonProps = { projectPath: string; projectId: string; onClose: () => void; }; type ResourceSingleClassificationPanelProps = ResourceClassificationPanelCommonProps & { mode?: 'single'; asset: GameCreationAppAssetManifestEntry; onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void; }; type ResourceBatchClassificationPanelProps = ResourceClassificationPanelCommonProps & { mode: 'batch'; /** * 打开面板时由宿主冻结的目标集(去重、首现顺序)。本面板只读第一帧的值: * 之后画布选中或资源面板筛选再变,也不改这一批的写入对象。 */ assetIds: readonly string[]; onSaved: (result: AddLocalProjectResourceTagsResult) => void; }; export type ResourceClassificationPanelProps = | ResourceSingleClassificationPanelProps | ResourceBatchClassificationPanelProps; function dedupeResourceAssetIds(assetIds: readonly string[]) { const seen = new Set(); const deduped: string[] = []; for (const assetId of assetIds) { if (seen.has(assetId)) continue; seen.add(assetId); deduped.push(assetId); } return deduped; } /** * 「编辑素材标签」面板:单素材模式与批量模式共用同一个面板骨架、同一套标签草稿 * 拆分口径和同一把保存锁,只有编辑对象与提交命令不同。 * * - 单素材:既有增删标签行为不变(写入命令 `update_local_project_resource_classification`)。 * - 批量:只把草稿里的标签**追加**到整组冻结素材(写入命令 `add_local_project_resource_tags`), * 面板里不显示、也不允许删除各素材已有标签,更不会把已有标签并集当作提交值。 */ export function ResourceClassificationPanel( props: ResourceClassificationPanelProps, ) { return props.mode === 'batch' ? ( ) : ( ); } /** 单素材标签编辑:既有增删标签行为,一次一份素材。 */ function ResourceSingleClassificationPanel({ projectPath, projectId, asset, onClose, onSaved, }: ResourceSingleClassificationPanelProps) { /** * 本面板只编辑标签:素材类型(功能分类)在「设置素材类型」面板里单独设置。 * * **写回必须用落盘口径** `gameCreationAppAssetPersistedCategory`,不能用读显示口径 * `gameCreationAppAssetCategory`:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind` * 能派生出明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类 * (真机上同一条 UI 设计资产同时出现过 `unclassified` 与 `ui-interaction` 两种落盘值)。 * * 拆出类型面板后这条不变量不再依赖"用户是否碰过控件",而是结构性的: * 本面板没有类型控件,`category` 恒为落盘原值。 * `tests/resourceClassificationPanel.test.tsx` 两个方向各有用例钉住它。 */ const [tags, setTags] = useState(() => gameCreationAppAssetTags(asset), ); const [tagDraft, setTagDraft] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(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 { 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( '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 (

编辑素材标签

{resourceAssetDisplayName(asset.localPath)}

{/* 本面板没有素材类型控件:类型是「设置素材类型」面板的编辑对象,入口在资源卡选中 工具条上。曾长在这里的类型 chip 只改本地 state、不落盘,保存又只能借道标签的 「添加」,导致"改了类型没生效"。 */} `删除标签 ${tag}`} tags={tags} onRemove={removeTag} /> setTagDraft(event.currentTarget.value)} onBlur={commitTagDraft} onKeyDown={(event) => { if ( event.key === 'Enter' || event.key === ',' || event.key === ',' ) { event.preventDefault(); commitTagDraft(); } }} /> {error ? (

{error}

) : null}
{/* 底部只留一个「添加」:它 = 落 pill + 保存。删除素材挪到资源卡选中工具条 (破坏性操作与它要改的对象放在一起),标签自身的删除按钮仍在每个 pill 内部。 */}
void addTagDraftAndSave()} disabled={saving} > 添加
); } /** * 批量追加标签:编辑对象是打开面板时冻结的整组素材 ID。 * * 面板里**只有待追加草稿** —— 各素材已有标签的并集既不显示、也不进入提交值, * 因为「把并集分发给每一项」会给每份素材都补上别人的标签。保存就是一次 * `add_local_project_resource_tags`,绝不逐素材循环调用单素材写入命令。 */ function ResourceBatchClassificationPanel({ projectPath, projectId, assetIds, onClose, onSaved, }: ResourceBatchClassificationPanelProps) { /** * 冻结目标集:只取第一帧。宿主在打开时已经快照了一份,这里再冻一次, * 保证「面板已开、用户又改了画布选中或资源面板筛选」时这一批的写入对象不变。 */ const [targetAssetIds] = useState(() => dedupeResourceAssetIds(assetIds)); const [draftTags, setDraftTags] = useState([]); const [tagDraft, setTagDraft] = useState(''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); /** * 同一事件里重复提交的同步兜底:`saving` 是 state,按钮的 `disabled` 要等下一次 * 渲染才生效,双击 / 回车与点击连着来时会各发一次请求。这里用 ref 立刻上锁。 */ const inFlightRef = useRef(false); /** 与单素材模式同一份切分口径:回车 / 中英文逗号 / 顿号把草稿落成待追加 pill。 */ function commitTagDraft() { // 保存在飞时输入框已禁用,这里再兜一次:草稿改了也不会被提交,清掉只会误导用户。 if (saving) return; if (!tagDraft.trim()) return; setDraftTags((current) => mergeResourceClassificationTagDraft(current, tagDraft), ); setTagDraft(''); } function removeDraftTag(tag: string) { if (saving) return; setDraftTags((current) => current.filter((item) => item !== tag)); } async function appendResourceTags(tagsToAppend: readonly string[]) { const tags = normalizeGameCreationAppAssetTags(tagsToAppend); // 空草稿没有可追加内容:不提交、不读 revision,避免"空操作也报保存"。 if (tags.length === 0) return; if (inFlightRef.current) return; const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { setError('批量追加标签需要在客户端内保存'); return; } inFlightRef.current = true; setSaving(true); setError(null); try { // 与单素材写入同一口径:先读项目 revision,再带项目身份与版本 CAS 提交。 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( 'add_local_project_resource_tags', { input: { projectPath, expectedProjectId: projectId, expectedProjectRevision: status.revision, assetIds: targetAssetIds, tags, }, }, ); // 只有原生确认写入后才清草稿:失败(含 CAS 冲突)保留待追加标签供直接重试。 setDraftTags([]); setTagDraft(''); onSaved(result); } catch (saveError) { setError( resourceClassificationErrorMessage(saveError, '批量追加标签失败'), ); } finally { inFlightRef.current = false; setSaving(false); } } /** 底部唯一的「追加标签」= 把输入框尾巴(含没按回车的部分)落成 pill,然后一次保存。 */ async function appendDraftAndSave() { const tagsToAppend = mergeResourceClassificationTagDraft( draftTags, tagDraft, ); setDraftTags(tagsToAppend); setTagDraft(''); await appendResourceTags(tagsToAppend); } const hasTagsToAppend = normalizeGameCreationAppAssetTags( mergeResourceClassificationTagDraft(draftTags, tagDraft), ).length > 0; return (

批量追加标签

{`已选 ${targetAssetIds.length} 项素材`}

{/* 只渲染待追加草稿:素材原有标签既不展示也不参与提交。删除按钮在这里删的是 草稿,不是已落盘的标签 —— 批量删除既有标签不在本次范围。 */} `移除待追加标签 ${tag}`} tags={draftTags} onRemove={removeDraftTag} // 保存在飞时不许改草稿:新输入的标签不会被这次提交带上,成功后又会被清空, // 用户会以为"改了但没保存"。锁住输入与删除,语义才是"这一批正在写"。 disabled={saving} /> setTagDraft(event.currentTarget.value)} onBlur={commitTagDraft} onKeyDown={(event) => { if ( event.key === 'Enter' || event.key === ',' || event.key === ',' ) { event.preventDefault(); commitTagDraft(); } }} /> {error ? (

{error}

) : null}
); }