前端改成直接替换:不切版本、尺寸规格降级为提示

- 口径跟进:前端从「版本级替换」改成「直接替换」——成功后**不再切换版本**(没有新版本可切),只重读 manifest;「当前使用」高亮会随新绑定自动移动(改的仍是同一个源版本)
- resourceVersionReplacementModel:兼容性判据的展示口径收窄为「硬门禁只有分类与类型」,尺寸规格不再产生禁用原因,改为非阻断提示「格式与源素材不同」;新增 resourceReplacementWarning 与 resourceReplacementAssetHints;类型去掉 parentVersionId、加上 warning
- index.tsx:confirmResourceVersionReplacement 去掉 selectActiveVersion 调用,失败仍保留弹窗与选择并显示原因(不切版本、不动高亮、不提示成功);新增 hint 映射并传给弹窗
- ImageCanvasProjectAssetPickerDialog:以可选 prop assetHints 扩展(默认 undefined → 不渲染),用于展示"可选但有差异"的提示;与 assetBlockedReasons 的区别是提示不改变可点性,默认行为仍逐字不变
- 失败文案补一条:版本写入边界拒绝(「不可修改、删除或重排」)翻成「替换被项目版本写入边界拒绝,请刷新项目后重试」,不让用户看到内部口径原文
- 用例改造:模型 9 条(尺寸规格不再禁用、提示表只收可选候选、提示不覆盖真正的不兼容原因、边界拒绝文案)、真链路 4 条(候选弹窗禁用硬门禁项 + 格式提示仍可选、写入载荷精确、成功后不切版本且不重载预览、失败保留弹窗且不重读 manifest、候选读取失败不弹空壳弹窗)
- 验证:两个前端用例文件 13 passed / 0 failed;npm run ai-game-creator-shell:typecheck(含 check-config)exit 0
This commit is contained in:
2026-09-11 20:03:44 +08:00
parent b6a2eae38d
commit 3245739385
5 changed files with 181 additions and 65 deletions
@@ -6,13 +6,16 @@ import {
} from './resourceCanvasToolbarModel';
/**
* 「替换素材」的展示层口径:类型、禁用原因与失败文案。
* 「替换素材」的展示层口径:类型、禁用原因、提示与失败文案。
*
* 三项兼容性的**判据在 Rust**`project/version_resource_replacement.rs`),前端只做呈现,
* 不在这里重算判据 —— 否则同一个规则会出现两份实现,迟早分叉。
* 兼容性判据在 Rust`project/version_resource_replacement.rs`),前端只做呈现,不在这里重算
* 判据 —— 否则同一个规则会出现两份实现,迟早分叉。当前形态是**直接替换**:改 manifest 里该
* 版本的绑定,不产生新版本。
*/
/** PRD §5.3 的三项兼容性;三项必须同时为 `true` 才能创建下一版本。 */
/**
* 兼容性三项。只有前两项是硬门禁(不满足则候选不可选);`sizeSpecEqual` 只作提示。
*/
export type ProjectVersionResourceCompatibility = {
categoryEqual: boolean;
subtypeEqual: boolean;
@@ -24,6 +27,8 @@ export type LocalProjectVersionReplacementCandidate = {
compatible: boolean;
compatibility: ProjectVersionResourceCompatibility;
blockedReason: string | null;
/** 非阻断提示(当前只有"格式与源素材不同")。 */
warning: string | null;
};
export type ReadLocalProjectVersionReplacementCandidatesResult = {
@@ -32,26 +37,27 @@ export type ReadLocalProjectVersionReplacementCandidatesResult = {
candidates: LocalProjectVersionReplacementCandidate[];
};
/** PRD §5.3 的 `ProjectVersionResourceReplacement`。 */
/** 这一次替换的记录:哪个版本的哪个绑定指向改成了哪个素材(没有新版本,也就没有 parentVersionId。 */
export type ProjectVersionResourceReplacement = {
sourceVersionId: string;
versionId: string;
sourceResourceId: string;
replacementResourceId: string;
compatibility: ProjectVersionResourceCompatibility;
warning: string | null;
};
export type ReplaceLocalProjectVersionResourceResult = {
versionId: string;
parentVersionId: string;
committedProjectRevision: number;
replacement: ProjectVersionResourceReplacement;
};
/**
* 三项兼容性对应的中文原因,按 PRD §5.3 的字段顺序取第一条不等的维度
* 候选不可选的原因:**只看硬门禁**(分类 / 类型)
*
* 后端已经给出 `blockedReason`;这里只在它缺失(旧后端 / 手工构造的候选)时按同一顺序派生,
* 不让界面出现"不可选但不说原因"的条目。
* 不让界面出现"不可选但不说原因"的条目。尺寸规格不参与该判据:它今天不完整,用不完整的判据
* 拒绝会误伤 `png ↔ webp` 这类正常替换。
*/
export function resourceReplacementBlockedReason(
candidate: LocalProjectVersionReplacementCandidate,
@@ -61,10 +67,26 @@ export function resourceReplacementBlockedReason(
if (reason) return reason;
if (!candidate.compatibility.categoryEqual) return '分类不同';
if (!candidate.compatibility.subtypeEqual) return '类型不同';
if (!candidate.compatibility.sizeSpecEqual) return '尺寸规格不同';
return '替换兼容性未通过';
}
/** 候选的非阻断提示:可选但仍值得说一声(当前只有尺寸规格/格式差异)。 */
export function resourceReplacementWarning(
candidate: LocalProjectVersionReplacementCandidate,
): string | null {
const warning = candidate.warning?.trim();
if (warning) return warning;
if (
candidate.compatible &&
candidate.compatibility.categoryEqual &&
candidate.compatibility.subtypeEqual &&
!candidate.compatibility.sizeSpecEqual
) {
return '格式与源素材不同';
}
return null;
}
/**
* 候选 → 禁用原因表,喂给弹窗的 `assetBlockedReasons`。
*
@@ -81,6 +103,18 @@ export function resourceReplacementBlockedReasons(
return reasons;
}
/** 候选 → 提示表,喂给弹窗的 `assetHints`(默认不渲染,只有传了才显示)。 */
export function resourceReplacementAssetHints(
candidates: readonly LocalProjectVersionReplacementCandidate[],
): Record<string, string> {
const hints: Record<string, string> = {};
for (const candidate of candidates) {
const warning = resourceReplacementWarning(candidate);
if (warning) hints[candidate.resourceId] = warning;
}
return hints;
}
/**
* 候选 → 弹窗素材。
*
@@ -154,7 +188,11 @@ export function resourceVersionReplacementErrorMessage(error: unknown): string {
if (message.includes('替换素材与源素材相同')) {
return '替换素材与源素材相同';
}
if (message.includes('需要在客户端内执行')) {
if (message.includes('不可修改、删除或重排')) {
// 版本记录写入边界的拒绝:直接替换只允许改一个版本的绑定,走到这里说明写入被守门挡下了。
return '替换被项目版本写入边界拒绝,请刷新项目后重试';
}
if (message.includes('必须在客户端内执行')) {
return '替换素材需要在客户端内执行';
}
return message;
@@ -138,6 +138,7 @@ import {
} from '../../features/resource-canvas/resourceCanvasVersionBindingModel';
import {
type LocalProjectVersionReplacementCandidate,
resourceReplacementAssetHints,
resourceReplacementBlockedReasons,
resourceReplacementPickerAssets,
resourceVersionReplacementErrorMessage,
@@ -5072,11 +5073,10 @@ export default function ProjectDevelopmentView({
setResourceReplacementError(null);
}, []);
/**
* 确认替换:CAS 写入「新版本 + 新绑定」,成功后重读 manifest 并把当前版本切到新版本
* 确认替换:直接改该版本的绑定(不建新版本),成功后重读 manifest。
*
* 失败一律保留弹窗与选择并把原因显示在弹窗里:不版本、不动高亮、不提示成功。
* 按 PRD §3.2 末条,新版本不自动被运行中的预览消费;入口只在资源视图出现,
* 因此这里的 `selectActiveVersion` 不会顺手重载运行画面。
* 失败一律保留弹窗与选择并把原因显示在弹窗里:不版本、不动高亮、不提示成功。
* 成功后**不切换版本**——没有新版本可切;「当前使用」高亮会随新绑定自动移动(同一个源版本)。
*/
const confirmResourceVersionReplacement = useCallback(
async (assetIds: string[]) => {
@@ -5112,7 +5112,6 @@ export default function ProjectDevelopmentView({
setResourceReplacementOpen(false);
setResourceReplacementSource(null);
setResourceReplacementCandidates([]);
selectActiveVersion(result.versionId);
await reloadManifestAfterAssetCommand(
result.committedProjectRevision,
`version-resource-replacement:${result.versionId}`,
@@ -5131,7 +5130,6 @@ export default function ProjectDevelopmentView({
reloadManifestAfterAssetCommand,
resourceReplacementLoading,
resourceReplacementSource,
selectActiveVersion,
],
);
const resourceReplacementPickerEntries = useMemo(
@@ -5143,6 +5141,10 @@ export default function ProjectDevelopmentView({
() => resourceReplacementBlockedReasons(resourceReplacementCandidates),
[resourceReplacementCandidates],
);
const resourceReplacementHintMap = useMemo(
() => resourceReplacementAssetHints(resourceReplacementCandidates),
[resourceReplacementCandidates],
);
function showRunView() {
if (!runAvailable || uiEditorRoute) {
@@ -6588,7 +6590,7 @@ export default function ProjectDevelopmentView({
/>
) : null}
{/*
版本级资源替换:复用美术画布的参考图弹窗(单选 + 禁用原因 + 失败原因三个 opt-in prop)。
版本级资源替换(直接替换):复用美术画布的参考图弹窗(单选 + 禁用原因 + 提示 + 失败原因)。
缩略图走 `renderAssetMedia` 的类型占位:AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL
弹窗里没有同步 `src`,直接给 `<img>` 会挂破图。
*/}
@@ -6599,6 +6601,7 @@ export default function ProjectDevelopmentView({
singleSelect
selectionNoun="替换素材"
assetBlockedReasons={resourceReplacementBlockedReasonMap}
assetHints={resourceReplacementHintMap}
errorMessage={resourceReplacementError}
renderAssetMedia={(asset) => (
<span className="game-resource-replacement-media" aria-hidden="true">
@@ -87,6 +87,13 @@ function replacementManifest() {
localPath: 'assets/late.png',
source: { kind: 'generated' as const },
},
{
id: 'asset-webp',
kind: 'character',
mediaType: 'image/webp',
localPath: 'assets/final.webp',
source: { kind: 'generated' as const },
},
];
manifest.versions = [
{
@@ -118,6 +125,19 @@ const REPLACEMENT_CANDIDATES = {
sizeSpecEqual: true,
},
blockedReason: null,
warning: null,
},
{
// 同分类同类型、只有媒体格式不同:可选,但带一条提示。
resourceId: 'asset-webp',
compatible: true,
compatibility: {
categoryEqual: true,
subtypeEqual: true,
sizeSpecEqual: false,
},
blockedReason: null,
warning: '格式与源素材不同',
},
{
resourceId: 'asset-scene',
@@ -128,16 +148,17 @@ const REPLACEMENT_CANDIDATES = {
sizeSpecEqual: true,
},
blockedReason: '分类不同',
warning: null,
},
],
};
// 直接替换:不产生新版本,返回的就是被改的那个版本。
const REPLACEMENT_RESULT = {
versionId: 'replace-6',
parentVersionId: SOURCE_VERSION_ID,
versionId: SOURCE_VERSION_ID,
committedProjectRevision: 6,
replacement: {
sourceVersionId: SOURCE_VERSION_ID,
versionId: SOURCE_VERSION_ID,
sourceResourceId: 'asset-legacy',
replacementResourceId: 'asset-final',
compatibility: {
@@ -145,6 +166,7 @@ const REPLACEMENT_RESULT = {
subtypeEqual: true,
sizeSpecEqual: true,
},
warning: null,
},
};
@@ -160,22 +182,16 @@ let observer: ReturnType<
function renderReplacementWorkbench(options: RenderOptions = {}) {
observer = installResourceCardIntersectionObserver();
const manifest = replacementManifest();
// 直接替换后的 manifest:版本数量不变,只有该版本的绑定被改写。
const nextManifest = {
...manifest,
versions: [
...manifest.versions,
{
versionId: 'replace-6',
parentVersionId: SOURCE_VERSION_ID,
projectRevision: 6,
resourceBindings: [
{ slotId: 'asset:asset-final', resourceId: 'asset-final' },
{ slotId: 'asset:asset-scene', resourceId: 'asset-scene' },
],
createdReason: 'resource-replacement' as const,
createdAt: 1_700_000_100,
},
],
versions: manifest.versions.map((version) => ({
...version,
resourceBindings: [
{ slotId: 'asset:asset-final', resourceId: 'asset-final' },
{ slotId: 'asset:asset-scene', resourceId: 'asset-scene' },
],
})),
};
let layoutRevision = 0;
const invoke = vi.fn(
@@ -386,7 +402,7 @@ describe('版本级资源替换', () => {
).not.toBeNull();
});
it('从入口一路走到写入:候选弹窗禁用不兼容项、写入载荷精确、成功后切版本但不重载预览', async () => {
it('从入口一路走到写入:候选弹窗禁用硬门禁项、给出格式提示、直接替换且不产生新版本', async () => {
const { invoke, onActiveVersionChange, onPlay, onManifestChange } =
renderReplacementWorkbench();
@@ -429,6 +445,12 @@ describe('版本级资源替换', () => {
name: '选择替换素材final.png',
}) as HTMLButtonElement;
expect(compatibleOption.disabled).toBe(false);
// 尺寸规格降级为提示:格式不同的候选仍可选,但把差异说清楚。
const hintedOption = within(dialog).getByRole('option', {
name: '选择替换素材final.webp',
}) as HTMLButtonElement;
expect(hintedOption.disabled).toBe(false);
expect(within(dialog).getByText('格式与源素材不同')).not.toBeNull();
fireEvent.click(compatibleOption);
fireEvent.click(
@@ -456,16 +478,15 @@ describe('版本级资源替换', () => {
},
);
// 成功后:重读 manifest 并按新 revision 提交;记录层当前版本切到新版本
// 直接替换:重读 manifest 并按新 revision 提交,但**不切版本**(没有新版本可切)
await waitFor(() =>
expect(onActiveVersionChange).toHaveBeenCalledWith('replace-6'),
expect(onManifestChange).toHaveBeenCalledWith(
PROJECT_PATH,
expect.objectContaining({ projectId: PROJECT_ID }),
expect.objectContaining({ revision: 6, source: 'asset-command' }),
),
);
expect(onManifestChange).toHaveBeenCalledWith(
PROJECT_PATH,
expect.objectContaining({ projectId: PROJECT_ID }),
expect.objectContaining({ revision: 6, source: 'asset-command' }),
);
// PRD §3.2 末条:替换不自动重载/重启运行中的预览。
expect(onActiveVersionChange).not.toHaveBeenCalled();
expect(onPlay).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(),
@@ -7,9 +7,11 @@ import type {
ProjectVersionResourceCompatibility,
} from '../src/features/resource-canvas/resourceVersionReplacementModel';
import {
resourceReplacementAssetHints,
resourceReplacementBlockedReason,
resourceReplacementBlockedReasons,
resourceReplacementPickerAssets,
resourceReplacementWarning,
resourceVersionReplacementErrorMessage,
} from '../src/features/resource-canvas/resourceVersionReplacementModel';
import {
@@ -31,10 +33,11 @@ function candidate(
};
return {
resourceId,
compatible:
resolved.categoryEqual && resolved.subtypeEqual && resolved.sizeSpecEqual,
// 直接替换口径:只有分类与类型参与"能不能选"。
compatible: resolved.categoryEqual && resolved.subtypeEqual,
compatibility: resolved,
blockedReason: null,
warning: null,
...overrides,
};
}
@@ -61,20 +64,20 @@ function manifestWithAssets(
}
describe('资源替换的展示层口径', () => {
it('兼容时没有禁用原因,不兼容时优先用后端给的原因', () => {
it('可选时没有禁用原因,不可选时优先用后端给的原因', () => {
expect(resourceReplacementBlockedReason(candidate('asset-ok'))).toBeNull();
expect(
resourceReplacementBlockedReason(
candidate(
'asset-x',
{ sizeSpecEqual: false },
{ blockedReason: '尺寸规格不同' },
{ categoryEqual: false },
{ blockedReason: '分类不同' },
),
),
).toBe('尺寸规格不同');
).toBe('分类不同');
});
it('后端没给原因时按 PRD §5.3 的字段顺序派生第一项不等的维度', () => {
it('后端没给原因时按硬门禁顺序派生(分类 → 类型),尺寸规格不参与禁用', () => {
expect(
resourceReplacementBlockedReason(
candidate('asset-category', {
@@ -92,14 +95,15 @@ describe('资源替换的展示层口径', () => {
}),
),
).toBe('类型不同');
// 尺寸规格不同**不再**产生禁用原因:它是提示,不是门禁。
expect(
resourceReplacementBlockedReason(
candidate('asset-size', { sizeSpecEqual: false }),
),
).toBe('尺寸规格不同');
).toBeNull();
});
it('被标记为不兼容但三项都是 true 时仍给出兜底原因,不静默放行', () => {
it('被标记为不可选但硬门禁都通过时仍给出兜底原因,不静默放行', () => {
expect(
resourceReplacementBlockedReason(
candidate('asset-unknown', {}, { compatible: false }),
@@ -107,13 +111,36 @@ describe('资源替换的展示层口径', () => {
).toBe('替换兼容性未通过');
});
it('禁用原因表只收录被禁用的候选:兼容候选不得出现禁用文案', () => {
it('尺寸规格差异只做提示,且不覆盖真正的不兼容原因', () => {
expect(
resourceReplacementBlockedReasons([
candidate('asset-ok'),
candidate('asset-scene', { categoryEqual: false }),
]),
).toEqual({ 'asset-scene': '分类不同' });
resourceReplacementWarning(
candidate('asset-format', { sizeSpecEqual: false }),
),
).toBe('格式与源素材不同');
expect(resourceReplacementWarning(candidate('asset-ok'))).toBeNull();
expect(
resourceReplacementWarning(
candidate(
'asset-blocked',
{ categoryEqual: false },
{ compatible: false },
),
),
).toBeNull();
});
it('禁用原因表只收录不可选候选;提示表只收录可选但有差异的候选', () => {
const candidates = [
candidate('asset-ok'),
candidate('asset-format', { sizeSpecEqual: false }),
candidate('asset-scene', { categoryEqual: false }),
];
expect(resourceReplacementBlockedReasons(candidates)).toEqual({
'asset-scene': '分类不同',
});
expect(resourceReplacementAssetHints(candidates)).toEqual({
'asset-format': '格式与源素材不同',
});
});
it('候选映射成弹窗素材:复用资源投影口径、不给 img 喂空 src、缺投影的候选不合成条目', () => {
@@ -164,9 +191,9 @@ describe('资源替换的展示层口径', () => {
).toBe('项目身份不一致,请重新打开项目后再试');
expect(
resourceVersionReplacementErrorMessage(
new Error('resource-replacement-incompatible尺寸规格不同'),
new Error('resource-replacement-incompatible分类不同'),
),
).toBe('替换素材不兼容:尺寸规格不同');
).toBe('替换素材不兼容:分类不同');
expect(
resourceVersionReplacementErrorMessage(
new Error('源项目版本不存在:initial-1'),
@@ -185,6 +212,11 @@ describe('资源替换的展示层口径', () => {
expect(
resourceVersionReplacementErrorMessage(new Error('替换素材与源素材相同')),
).toBe('替换素材与源素材相同');
expect(
resourceVersionReplacementErrorMessage(
new Error('项目版本记录写入后不可修改、删除或重排'),
),
).toBe('替换被项目版本写入边界拒绝,请刷新项目后重试');
expect(
resourceVersionReplacementErrorMessage(
new Error('替换素材需要在客户端内执行'),
@@ -1,5 +1,5 @@
import { Check, ImageIcon, Music, Search, Video } from 'lucide-react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton';
import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar';
@@ -32,6 +32,12 @@ type ImageCanvasProjectAssetPickerDialogProps = {
* 禁用项仍然渲染(不隐藏):隐藏会让用户以为"素材不存在",而真实原因是它不可替换。
*/
assetBlockedReasons?: Readonly<Record<string, string>>;
/**
* 可选素材 id → 非阻断提示(例如"格式与源素材不同")。默认空,即不显示任何提示。
*
* 与 `assetBlockedReasons` 的区别:提示不改变可点性,只把差异说清楚。
*/
assetHints?: Readonly<Record<string, string>>;
/**
* 素材缩略图渲染器。默认 `undefined` → 沿用 `<img src={thumbnailSrc || src}>`。
*
@@ -78,6 +84,7 @@ export function ImageCanvasProjectAssetPickerDialog({
onConfirm,
singleSelect = false,
assetBlockedReasons,
assetHints,
renderAssetMedia,
selectionNoun = '参考图',
errorMessage,
@@ -166,12 +173,20 @@ export function ImageCanvasProjectAssetPickerDialog({
}
>
{errorMessage ? (
<PlatformStatusMessage tone="error" size="xs" className="mb-3" role="alert">
<PlatformStatusMessage
tone="error"
size="xs"
className="mb-3"
role="alert"
>
{errorMessage}
</PlatformStatusMessage>
) : null}
{selectedAssets.length > 0 && !singleSelect ? (
<div className="mb-3 flex flex-wrap gap-1.5" aria-label={`已选${selectionNoun}`}>
<div
className="mb-3 flex flex-wrap gap-1.5"
aria-label={`已选${selectionNoun}`}
>
{selectedAssets.map((asset) => (
<button
key={asset.id}
@@ -213,6 +228,9 @@ export function ImageCanvasProjectAssetPickerDialog({
{visibleAssets.map((asset) => {
const selected = selection.includes(asset.id);
const blockedReason = assetBlockedReasons?.[asset.id] ?? null;
const hint = blockedReason
? null
: (assetHints?.[asset.id] ?? null);
return (
<button
key={asset.id}
@@ -221,7 +239,7 @@ export function ImageCanvasProjectAssetPickerDialog({
aria-selected={selected}
aria-label={`选择${selectionNoun}${asset.label}`}
disabled={blockedReason !== null}
title={blockedReason ?? undefined}
title={blockedReason ?? hint ?? undefined}
className={[
'relative flex min-h-[7.5rem] flex-col overflow-hidden rounded-[0.9rem] border text-left transition',
selected
@@ -261,6 +279,10 @@ export function ImageCanvasProjectAssetPickerDialog({
<span className="block px-2 pb-1.5 text-[0.6875rem] leading-snug text-[var(--platform-text-base)]">
{blockedReason}
</span>
) : hint !== null ? (
<span className="block px-2 pb-1.5 text-[0.6875rem] leading-snug text-[var(--platform-text-base)]">
{hint}
</span>
) : null}
</button>
);