Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx
T
suzmii c1ffb2386b 资源功能分类与自定义标签:筛选口径收敛为 category 并接入写入 UI
- 删除 resourceReferences.ts 中 mediaType 优先的 7 桶筛选口径与 resourceReferenceFilterKind,改由 resourceReferenceCategory / resourceReferenceMatchesCategoryFilter 以 manifest 资产 category 为唯一权威筛选口径
- RESOURCE_REFERENCE_FILTERS 改为 6 类 + 全部(全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类),并新增 resourceReferenceCategoryLabel 让中文名只定义一处
- ResourceReference 新增 category / tags,由 resourceReferenceFromAsset 经 gameCreationAppAssetCategory / gameCreationAppAssetTags 从 manifest 资产投影;资源画布 ProjectResource 同步新增 assetCategory / assetTags 投影与 projectResourceAssetCategory 待归类兜底
- 新增 Tauri 命令 update_local_project_resource_classification 与 UpdateLocalProjectResourceClassificationInput/Result,实现落在 project/manifest.rs 的 update_manifest_asset_classification_at,复用既有 mutate_manifest_at manifest 写锁与原子写入
- 命令持项目写锁后按 expectedProjectId / expectedProjectRevision 做 CAS,冲突分别返回 project-identity-conflict 与 project-revision-conflict;category 非 6 个合法值即报错且不回退到 kind 派生;tags 走 normalize_game_creation_app_asset_tags;只改目标条目的 category / tags 并在成功后推进一次项目 revision
- 新增 Rust 定向测试 project/manifest/classification_tests.rs:更新成功、非法分类被拒、tags 归一化、未引用资产更新不影响其它字段、revision/CAS 与既有写入一致、输入拒绝未知字段
- 前端新增 ResourceClassificationPanel(ThemedModal + PlatformSegmentedTabs + PlatformTextField),资源详情面板以「分类与标签」按钮打开独立面板,保存后重读 manifest 并经 onManifestChange 反映到筛选与投影
- @ 面板与资源画布共用 packages/shared 的 PlatformSegmentedTabs 渲染同一组功能分类标签;画布筛选状态与画布视口一样按「按依赖 / 按类型」分别保存,复用既有 sortMode 状态形状
- 更新 resourceReferenceInput.test.tsx 与 projectResourceProjectionModel.test.ts 覆盖新口径,新增 resourceClassificationPanel.test.tsx 覆盖写入命令的输入归一化与失败不回退
- 同步技术方案与 decision-log:记录按 mediaType 分类到按 category 分类的语义变化(图片 / 视频桶消失,image / video / code / publication-material 归待归类)与新写入命令契约
2026-09-10 10:25:22 +08:00

156 lines
4.9 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 { useState } from 'react';
import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField';
import {
GAME_CREATION_APP_ASSET_CATEGORIES,
type GameCreationAppAssetCategory,
gameCreationAppAssetCategory,
type GameCreationAppAssetManifestEntry,
gameCreationAppAssetTags,
normalizeGameCreationAppAssetTags,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { ThemedModal } from '../../components/modal/ThemedModal';
import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences';
type UpdateLocalProjectResourceClassificationResult = {
asset: GameCreationAppAssetManifestEntry;
committedProjectRevision: number;
};
const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS =
GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({
id: category,
label: resourceReferenceCategoryLabel(category),
}));
/** 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。 */
function splitResourceClassificationTagsDraft(value: string) {
return normalizeGameCreationAppAssetTags(value.split(/[,,、\n]/u));
}
function resourceClassificationErrorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '保存资源分类与标签失败';
}
type ResourceClassificationPanelProps = {
projectPath: string;
projectId: string;
asset: GameCreationAppAssetManifestEntry;
onClose: () => void;
onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void;
};
export function ResourceClassificationPanel({
projectPath,
projectId,
asset,
onClose,
onSaved,
}: ResourceClassificationPanelProps) {
const [category, setCategory] = useState<GameCreationAppAssetCategory>(() =>
gameCreationAppAssetCategory(asset),
);
const [tagsDraft, setTagsDraft] = useState(() =>
gameCreationAppAssetTags(asset).join('、'),
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function saveResourceClassification() {
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,
tags: splitResourceClassificationTagsDraft(tagsDraft),
},
},
);
onSaved(result);
} catch (saveError) {
setError(resourceClassificationErrorMessage(saveError));
} finally {
setSaving(false);
}
}
return (
<ThemedModal
open
ariaLabel="资源分类与标签"
onClose={onClose}
panelClassName="game-approval-dialog game-resource-classification-dialog"
>
<header>
<div>
<h2>资源分类与标签</h2>
<p>{asset.localPath}</p>
</div>
<button type="button" aria-label="关闭资源分类与标签" onClick={onClose}>
×
</button>
</header>
<div className="game-resource-classification-body">
<PlatformSegmentedTabs
items={RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS}
activeId={category}
onChange={setCategory}
columns="threeToSix"
gap="sm"
size="compact"
/>
<PlatformTextField
aria-label="资源标签"
placeholder="用、或逗号分隔标签"
value={tagsDraft}
onChange={(event) => setTagsDraft(event.currentTarget.value)}
/>
{error ? (
<p className="game-resource-classification-error" role="alert">
{error}
</p>
) : null}
</div>
<footer>
<PlatformActionButton
tone="secondary"
onClick={onClose}
disabled={saving}
>
取消
</PlatformActionButton>
<PlatformActionButton
onClick={() => void saveResourceClassification()}
disabled={saving}
>
保存
</PlatformActionButton>
</footer>
</ThemedModal>
);
}