Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx
T
k88936 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
合并主线并保留资源kind枚举收口
合并 origin/master 的 AGC 画布参考与项目能力更新

解决 canvas 生成恢复与任务模型冲突并继续使用共享 GameCreationAppAssetKind

保留 run_id、generationKind 与外部协议字符串的独立边界
2026-09-19 01:50:09 +08:00

522 lines
18 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 { 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>
);
}