diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts index 018a378e4..316d481ea 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceVersionReplacementModel.ts @@ -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 { + const hints: Record = {}; + 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; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 3a8dcc622..b6819c01b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -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`,直接给 `` 会挂破图。 */} @@ -6599,6 +6601,7 @@ export default function ProjectDevelopmentView({ singleSelect selectionNoun="替换素材" assetBlockedReasons={resourceReplacementBlockedReasonMap} + assetHints={resourceReplacementHintMap} errorMessage={resourceReplacementError} renderAssetMedia={(asset) => (