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 (
{tags.map((tag) => (
-
{tag}
))}
);
}
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 (
{/*
本面板没有素材类型控件:类型是「设置素材类型」面板的编辑对象,入口在资源卡选中
工具条上。曾长在这里的类型 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 内部。
*/}
);
}
/**
* 批量追加标签:编辑对象是打开面板时冻结的整组素材 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 (
{/*
只渲染待追加草稿:素材原有标签既不展示也不参与提交。删除按钮在这里删的是
草稿,不是已落盘的标签 —— 批量删除既有标签不在本次范围。
*/}
`移除待追加标签 ${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}
);
}