c1ffb2386b
- 删除 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 归待归类)与新写入命令契约
156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
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>
|
||
);
|
||
}
|