18252e24b8
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
合并 origin/master 的 AGC 画布参考与项目能力更新 解决 canvas 生成恢复与任务模型冲突并继续使用共享 GameCreationAppAssetKind 保留 run_id、generationKind 与外部协议字符串的独立边界
522 lines
18 KiB
TypeScript
522 lines
18 KiB
TypeScript
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 (
|
||
<ul className="game-resource-tag-list" aria-label={ariaLabel}>
|
||
{tags.map((tag) => (
|
||
<li key={tag}>
|
||
<PlatformPillBadge
|
||
tone="warning"
|
||
size="xs"
|
||
className="game-resource-tag-pill"
|
||
>
|
||
{tag}
|
||
<button
|
||
type="button"
|
||
className="game-resource-tag-remove"
|
||
aria-label={removeAriaLabel(tag)}
|
||
disabled={disabled}
|
||
onClick={() => onRemove(tag)}
|
||
>
|
||
<X size={11} aria-hidden="true" />
|
||
</button>
|
||
</PlatformPillBadge>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
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<string>();
|
||
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' ? (
|
||
<ResourceBatchClassificationPanel {...props} />
|
||
) : (
|
||
<ResourceSingleClassificationPanel {...props} />
|
||
);
|
||
}
|
||
|
||
/** 单素材标签编辑:既有增删标签行为,一次一份素材。 */
|
||
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<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、不落盘,保存又只能借道标签的
|
||
「添加」,导致"改了类型没生效"。
|
||
*/}
|
||
<ResourceTagPillList
|
||
ariaLabel="已有标签"
|
||
removeAriaLabel={(tag) => `删除标签 ${tag}`}
|
||
tags={tags}
|
||
onRemove={removeTag}
|
||
/>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 批量追加标签:编辑对象是打开面板时冻结的整组素材 ID。
|
||
*
|
||
* 面板里**只有待追加草稿** —— 各素材已有标签的并集既不显示、也不进入提交值,
|
||
* 因为「把并集分发给每一项」会给每份素材都补上别人的标签。保存就是一次
|
||
* `add_local_project_resource_tags`,绝不逐素材循环调用单素材写入命令。
|
||
*/
|
||
function ResourceBatchClassificationPanel({
|
||
projectPath,
|
||
projectId,
|
||
assetIds,
|
||
onClose,
|
||
onSaved,
|
||
}: ResourceBatchClassificationPanelProps) {
|
||
/**
|
||
* 冻结目标集:只取第一帧。宿主在打开时已经快照了一份,这里再冻一次,
|
||
* 保证「面板已开、用户又改了画布选中或资源面板筛选」时这一批的写入对象不变。
|
||
*/
|
||
const [targetAssetIds] = useState(() => dedupeResourceAssetIds(assetIds));
|
||
const [draftTags, setDraftTags] = useState<string[]>([]);
|
||
const [tagDraft, setTagDraft] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(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<AddLocalProjectResourceTagsResult>(
|
||
'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 (
|
||
<ThemedModal
|
||
open
|
||
ariaLabel="批量追加标签"
|
||
onClose={onClose}
|
||
// 与单素材模式同一把保存锁:保存在飞时 Escape / 点遮罩 / 头部 × 都不能关。
|
||
closeOnBackdrop={!saving}
|
||
closeOnEscape={!saving}
|
||
panelClassName="game-approval-dialog game-resource-classification-dialog"
|
||
>
|
||
<header>
|
||
<div>
|
||
<h2>批量追加标签</h2>
|
||
<p>{`已选 ${targetAssetIds.length} 项素材`}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
aria-label="关闭批量追加标签"
|
||
disabled={saving}
|
||
onClick={onClose}
|
||
>
|
||
×
|
||
</button>
|
||
</header>
|
||
<div className="game-resource-classification-body">
|
||
{/*
|
||
只渲染待追加草稿:素材原有标签既不展示也不参与提交。删除按钮在这里删的是
|
||
草稿,不是已落盘的标签 —— 批量删除既有标签不在本次范围。
|
||
*/}
|
||
<ResourceTagPillList
|
||
ariaLabel="待追加标签"
|
||
removeAriaLabel={(tag) => `移除待追加标签 ${tag}`}
|
||
tags={draftTags}
|
||
onRemove={removeDraftTag}
|
||
// 保存在飞时不许改草稿:新输入的标签不会被这次提交带上,成功后又会被清空,
|
||
// 用户会以为"改了但没保存"。锁住输入与删除,语义才是"这一批正在写"。
|
||
disabled={saving}
|
||
/>
|
||
<PlatformTextField
|
||
aria-label="新增标签"
|
||
placeholder="新增标签,多个用逗号分隔"
|
||
value={tagDraft}
|
||
disabled={saving}
|
||
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>
|
||
<footer className="game-resource-classification-footer">
|
||
<PlatformActionButton
|
||
onClick={() => void appendDraftAndSave()}
|
||
disabled={saving || !hasTagsToAppend}
|
||
>
|
||
追加标签
|
||
</PlatformActionButton>
|
||
</footer>
|
||
</ThemedModal>
|
||
);
|
||
}
|