接线 AGC 版本级资源替换的前端链路与弹窗复用

- 新增 resourceVersionReplacementTransport.ts:两个 IPC 边界(read_..._candidates / replace_..._resource)只传一个 input 对象,不在客户端内执行时明确报错;这两个 invoke 调用点是 check-config.mjs「每个 Tauri 命令都要有 App invoke 调用方」的落点
- 新增 resourceVersionReplacementModel.ts:PRD §5.3 的 ProjectVersionResourceReplacement / 兼容性类型、禁用原因与失败文案映射、候选→弹窗素材映射;判据不在前端重算,只做呈现
- 候选→弹窗素材复用资源投影口径(resourceCanvasMediaType / resourceCanvasAssetKind),src 固定为空串,缩略图交给弹窗的 renderAssetMedia,不给 img 喂空 src
- ImageCanvasProjectAssetPickerDialog 以 6 个可选 prop 扩展:singleSelect / assetBlockedReasons / renderAssetMedia / selectionNoun / errorMessage 与新的禁用原因文案;全部默认值保持网页端美术画布行为逐字不变(默认仍是多选、仍是 img、仍是「参考图」文案、不渲染禁用与错误区)
- project-development/index.tsx 接线:状态与处理函数(读候选→开弹窗→CAS 写入→重读 manifest→selectActiveVersion),失败保留弹窗并显示原因,候选读取失败不弹空壳弹窗而是说明原因;本提交暂不放行工具条入口
- styles.css 增 .game-resource-replacement-media:候选行的类型占位样式,避免挂破图
This commit is contained in:
2026-09-11 17:42:00 +08:00
parent fc4a2c12f4
commit 097d86d337
5 changed files with 471 additions and 16 deletions
@@ -1,8 +1,9 @@
import { Check, ImageIcon, Music, Search, Video } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton';
import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar';
import { PlatformStatusMessage } from '../../../packages/shared/src/components/PlatformStatusMessage';
import { UnifiedModal } from '../common/UnifiedModal';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import {
@@ -20,6 +21,35 @@ type ImageCanvasProjectAssetPickerDialogProps = {
selectedAssetIds: readonly string[];
onCancel: () => void;
onConfirm: (assetIds: string[]) => void;
/**
* 单选模式:点击即整组替换当前选择,不再渲染「已选」chip 行。
* 默认 `false`,网页端美术画布的参考图多选行为逐字不变。
*/
singleSelect?: boolean;
/**
* 被禁用的素材 id → 用户可见的禁用原因。默认空,即所有素材都可选。
*
* 禁用项仍然渲染(不隐藏):隐藏会让用户以为"素材不存在",而真实原因是它不可替换。
*/
assetBlockedReasons?: Readonly<Record<string, string>>;
/**
* 素材缩略图渲染器。默认 `undefined` → 沿用 `<img src={thumbnailSrc || src}>`。
*
* 宿主(如 AGC 资源工作台)没有同步 `src` 时必须传它:AGC 的预览读取走带 scope 的
* 原生调度器 + Blob URL,弹窗内取不到,直接给空 `src` 会挂破图。
*/
renderAssetMedia?: (asset: EditorAsset) => ReactNode;
/**
* 选择对象的中文名词,用于拼弹窗标题与可访问名称。默认「参考图」,
* 即网页端美术画布的现有文案逐字不变。
*/
selectionNoun?: string;
/**
* 宿主的失败原因(例如后端拒绝了这次替换)。默认 `undefined` → 不渲染。
*
* 用于在弹窗内说明"为什么这次操作没成功",而不是静默关闭弹窗让用户以为成功了。
*/
errorMessage?: string | null;
};
function assetIcon(asset: EditorAsset) {
@@ -46,6 +76,11 @@ export function ImageCanvasProjectAssetPickerDialog({
selectedAssetIds,
onCancel,
onConfirm,
singleSelect = false,
assetBlockedReasons,
renderAssetMedia,
selectionNoun = '参考图',
errorMessage,
}: ImageCanvasProjectAssetPickerDialogProps) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ProjectAssetPickerCategory>('all');
@@ -76,20 +111,24 @@ export function ImageCanvasProjectAssetPickerDialog({
});
function toggleAsset(assetId: string) {
setSelection((current) =>
current.includes(assetId)
if (assetBlockedReasons?.[assetId]) return;
setSelection((current) => {
if (singleSelect) {
return current.includes(assetId) ? [] : [assetId];
}
return current.includes(assetId)
? current.filter((item) => item !== assetId)
: [...current, assetId],
);
: [...current, assetId];
});
}
return (
<UnifiedModal
open={open}
title="选择参考图"
title={`选择${selectionNoun}`}
size="lg"
portalTheme="light"
closeLabel="关闭选择参考图"
closeLabel={`关闭选择${selectionNoun}`}
onClose={onCancel}
panelClassName="image-canvas-editor__project-asset-picker"
bodyClassName="image-canvas-editor__project-asset-picker-body"
@@ -118,7 +157,7 @@ export function ImageCanvasProjectAssetPickerDialog({
<PlatformActionButton
type="button"
size="sm"
aria-label="确认选择参考图"
aria-label={`确认选择${selectionNoun}`}
onClick={() => onConfirm(selection)}
>
确认
@@ -126,13 +165,18 @@ export function ImageCanvasProjectAssetPickerDialog({
</>
}
>
{selectedAssets.length > 0 ? (
<div className="mb-3 flex flex-wrap gap-1.5" aria-label="已选参考图">
{errorMessage ? (
<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}`}>
{selectedAssets.map((asset) => (
<button
key={asset.id}
type="button"
aria-label={`取消参考图${asset.label}`}
aria-label={`取消${selectionNoun}${asset.label}`}
className="platform-category-chip gap-1 px-2 text-xs"
onClick={() => toggleAsset(asset.id)}
>
@@ -163,28 +207,36 @@ export function ImageCanvasProjectAssetPickerDialog({
<div
className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4"
role="listbox"
aria-label="参考图素材"
aria-multiselectable
aria-label={`${selectionNoun}素材`}
aria-multiselectable={!singleSelect}
>
{visibleAssets.map((asset) => {
const selected = selection.includes(asset.id);
const blockedReason = assetBlockedReasons?.[asset.id] ?? null;
return (
<button
key={asset.id}
type="button"
role="option"
aria-selected={selected}
aria-label={`选择参考图${asset.label}`}
aria-label={`选择${selectionNoun}${asset.label}`}
disabled={blockedReason !== null}
title={blockedReason ?? undefined}
className={[
'relative flex min-h-[7.5rem] flex-col overflow-hidden rounded-[0.9rem] border text-left transition',
selected
? 'border-[var(--platform-accent)] bg-white shadow-sm'
: 'border-[var(--platform-subpanel-border)] bg-white/70 hover:bg-white',
].join(' ')}
blockedReason !== null ? 'cursor-not-allowed opacity-60' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => toggleAsset(asset.id)}
>
<span className="flex h-20 w-full items-center justify-center overflow-hidden bg-white/60">
{projectAssetPickerCategory(asset) === 'image' ? (
{renderAssetMedia ? (
renderAssetMedia(asset)
) : projectAssetPickerCategory(asset) === 'image' ? (
<img
src={asset.thumbnailSrc || asset.src}
alt=""
@@ -205,6 +257,11 @@ export function ImageCanvasProjectAssetPickerDialog({
/>
) : null}
</span>
{blockedReason !== null ? (
<span className="block px-2 pb-1.5 text-[0.6875rem] leading-snug text-[var(--platform-text-base)]">
{blockedReason}
</span>
) : null}
</button>
);
})}