接线 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:
+161
@@ -0,0 +1,161 @@
|
||||
import type { EditorAsset } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||
import type { ProjectResource } from '../../view/project-development/resourceProjectionModel';
|
||||
import {
|
||||
resourceCanvasAssetKind,
|
||||
resourceCanvasMediaType,
|
||||
} from './resourceCanvasToolbarModel';
|
||||
|
||||
/**
|
||||
* 「替换素材」的展示层口径:类型、禁用原因与失败文案。
|
||||
*
|
||||
* 三项兼容性的**判据在 Rust**(`project/version_resource_replacement.rs`),前端只做呈现,
|
||||
* 不在这里重算判据 —— 否则同一个规则会出现两份实现,迟早分叉。
|
||||
*/
|
||||
|
||||
/** PRD §5.3 的三项兼容性;三项必须同时为 `true` 才能创建下一版本。 */
|
||||
export type ProjectVersionResourceCompatibility = {
|
||||
categoryEqual: boolean;
|
||||
subtypeEqual: boolean;
|
||||
sizeSpecEqual: boolean;
|
||||
};
|
||||
|
||||
export type LocalProjectVersionReplacementCandidate = {
|
||||
resourceId: string;
|
||||
compatible: boolean;
|
||||
compatibility: ProjectVersionResourceCompatibility;
|
||||
blockedReason: string | null;
|
||||
};
|
||||
|
||||
export type ReadLocalProjectVersionReplacementCandidatesResult = {
|
||||
sourceVersionId: string;
|
||||
sourceResourceId: string;
|
||||
candidates: LocalProjectVersionReplacementCandidate[];
|
||||
};
|
||||
|
||||
/** PRD §5.3 的 `ProjectVersionResourceReplacement`。 */
|
||||
export type ProjectVersionResourceReplacement = {
|
||||
sourceVersionId: string;
|
||||
sourceResourceId: string;
|
||||
replacementResourceId: string;
|
||||
compatibility: ProjectVersionResourceCompatibility;
|
||||
};
|
||||
|
||||
export type ReplaceLocalProjectVersionResourceResult = {
|
||||
versionId: string;
|
||||
parentVersionId: string;
|
||||
committedProjectRevision: number;
|
||||
replacement: ProjectVersionResourceReplacement;
|
||||
};
|
||||
|
||||
/**
|
||||
* 三项兼容性对应的中文原因,按 PRD §5.3 的字段顺序取第一条不等的维度。
|
||||
*
|
||||
* 后端已经给出 `blockedReason`;这里只在它缺失(旧后端 / 手工构造的候选)时按同一顺序派生,
|
||||
* 不让界面出现"不可选但不说原因"的条目。
|
||||
*/
|
||||
export function resourceReplacementBlockedReason(
|
||||
candidate: LocalProjectVersionReplacementCandidate,
|
||||
): string | null {
|
||||
if (candidate.compatible) return null;
|
||||
const reason = candidate.blockedReason?.trim();
|
||||
if (reason) return reason;
|
||||
if (!candidate.compatibility.categoryEqual) return '分类不同';
|
||||
if (!candidate.compatibility.subtypeEqual) return '类型不同';
|
||||
if (!candidate.compatibility.sizeSpecEqual) return '尺寸规格不同';
|
||||
return '替换兼容性未通过';
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选 → 禁用原因表,喂给弹窗的 `assetBlockedReasons`。
|
||||
*
|
||||
* 不兼容候选**渲染但禁用**:隐藏会让用户以为"素材不存在",而真实原因是它不能替换这个素材。
|
||||
*/
|
||||
export function resourceReplacementBlockedReasons(
|
||||
candidates: readonly LocalProjectVersionReplacementCandidate[],
|
||||
): Record<string, string> {
|
||||
const reasons: Record<string, string> = {};
|
||||
for (const candidate of candidates) {
|
||||
const reason = resourceReplacementBlockedReason(candidate);
|
||||
if (reason) reasons[candidate.resourceId] = reason;
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选 → 弹窗素材。
|
||||
*
|
||||
* `src` 固定为空字符串:AGC 的原生预览读取走带 scope 的调度器 + Blob URL,弹窗里拿不到同步
|
||||
* `src`,所以缩略图由调用方通过弹窗的 `renderAssetMedia` opt-in 渲染,**不给 `<img>` 喂空串**
|
||||
* (那会挂破图)。分类筛选复用语资源画布同一条媒体类型口径。
|
||||
*/
|
||||
export function resourceReplacementPickerAssets(
|
||||
resources: readonly ProjectResource[],
|
||||
candidates: readonly LocalProjectVersionReplacementCandidate[],
|
||||
): EditorAsset[] {
|
||||
const resourceByAssetId = new Map<string, ProjectResource>();
|
||||
for (const resource of resources) {
|
||||
const assetId = resource.manifestAssetId;
|
||||
if (assetId && !resourceByAssetId.has(assetId)) {
|
||||
resourceByAssetId.set(assetId, resource);
|
||||
}
|
||||
}
|
||||
return candidates.flatMap((candidate) => {
|
||||
const resource = resourceByAssetId.get(candidate.resourceId);
|
||||
if (!resource) return [];
|
||||
const assetKind = resourceCanvasAssetKind(resource);
|
||||
return [
|
||||
{
|
||||
id: candidate.resourceId,
|
||||
label: resource.label,
|
||||
src: '',
|
||||
mediaType: resourceCanvasMediaType(resource) ?? 'image',
|
||||
width: 0,
|
||||
height: 0,
|
||||
folderId: 'project-assets',
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: 'generated',
|
||||
persisted: true,
|
||||
...(assetKind ? { assetKind } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** 入口判据复用现役口径:`isResourceUsedByCurrentVersion`(manifest 身份 + 被当前版本绑定)。 */
|
||||
|
||||
/**
|
||||
* 把 Rust 的结构化拒绝翻成用户可读中文。
|
||||
*
|
||||
* 不认识的错误原样透出:宁可显示后端原文,也不把原因吞掉换成"替换失败"这种无信息文案。
|
||||
*/
|
||||
export function resourceVersionReplacementErrorMessage(error: unknown): string {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error ?? '').trim();
|
||||
if (!message) return '替换素材失败';
|
||||
if (message.includes('project-revision-conflict')) {
|
||||
return '项目已被其它操作改动,请重试替换';
|
||||
}
|
||||
if (message.includes('project-identity-conflict')) {
|
||||
return '项目身份不一致,请重新打开项目后再试';
|
||||
}
|
||||
if (message.includes('resource-replacement-incompatible')) {
|
||||
const reason = message.split(':').pop()?.trim();
|
||||
return reason ? `替换素材不兼容:${reason}` : '替换素材不兼容';
|
||||
}
|
||||
if (message.includes('源项目版本不存在')) {
|
||||
return '当前版本已不存在,请刷新项目后重试';
|
||||
}
|
||||
if (message.includes('源版本未绑定该素材')) {
|
||||
return '该素材不在当前版本的绑定里,不能替换';
|
||||
}
|
||||
if (message.includes('项目资源不存在')) {
|
||||
return '替换素材未登记或已被删除';
|
||||
}
|
||||
if (message.includes('替换素材与源素材相同')) {
|
||||
return '替换素材与源素材相同';
|
||||
}
|
||||
if (message.includes('需要在客户端内执行')) {
|
||||
return '替换素材需要在客户端内执行';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
ReadLocalProjectVersionReplacementCandidatesResult,
|
||||
ReplaceLocalProjectVersionResourceResult,
|
||||
} from './resourceVersionReplacementModel';
|
||||
|
||||
/**
|
||||
* 版本级资源替换的 IPC 边界。
|
||||
*
|
||||
* 两个命令都只吃单个 `input` 对象:Rust 侧入参是 `deny_unknown_fields` 的结构体,
|
||||
* 多传字段会直接失败关闭。
|
||||
*/
|
||||
|
||||
function replacementInvoke() {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
// 与素材删除同口径:不在客户端内执行时明确报错,不做"看起来成功"的静默返回。
|
||||
throw new Error('替换素材需要在客户端内执行');
|
||||
}
|
||||
return invoke;
|
||||
}
|
||||
|
||||
/** 读取源素材可用的替换候选与后端权威的三项兼容性结论(只读)。 */
|
||||
export async function readVersionResourceReplacementCandidates(input: {
|
||||
projectPath: string;
|
||||
sourceVersionId: string;
|
||||
sourceResourceId: string;
|
||||
}): Promise<ReadLocalProjectVersionReplacementCandidatesResult> {
|
||||
const invoke = replacementInvoke();
|
||||
return invoke<ReadLocalProjectVersionReplacementCandidatesResult>(
|
||||
'read_local_project_version_resource_replacement_candidates',
|
||||
{ input },
|
||||
);
|
||||
}
|
||||
|
||||
/** 改 manifest 绑定并追加下一迭代版本(不回滚:成功即已落盘)。 */
|
||||
export async function replaceVersionResource(input: {
|
||||
projectPath: string;
|
||||
expectedProjectId: string;
|
||||
expectedProjectRevision: number;
|
||||
sourceVersionId: string;
|
||||
sourceResourceId: string;
|
||||
replacementResourceId: string;
|
||||
}): Promise<ReplaceLocalProjectVersionResourceResult> {
|
||||
const invoke = replacementInvoke();
|
||||
return invoke<ReplaceLocalProjectVersionResourceResult>(
|
||||
'replace_local_project_version_resource',
|
||||
{ input },
|
||||
);
|
||||
}
|
||||
@@ -6341,6 +6341,21 @@ iframe.preview-frame {
|
||||
color: #9b5537;
|
||||
}
|
||||
|
||||
/*
|
||||
* 「替换素材」候选弹窗里的类型占位。
|
||||
*
|
||||
* AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,弹窗内没有同步 `src`,
|
||||
* 所以候选行只渲染稳定类型图标,不挂 `<img>`、不出现破图。
|
||||
*/
|
||||
.game-resource-replacement-media {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #8d7a6b;
|
||||
}
|
||||
|
||||
.game-attachment-errors {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Sparkles,
|
||||
Undo2,
|
||||
Users,
|
||||
Video,
|
||||
X,
|
||||
ZoomOut,
|
||||
} from 'lucide-react';
|
||||
@@ -74,6 +75,7 @@ import type {
|
||||
CanvasLayer,
|
||||
QuickEditPanelState,
|
||||
} from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||
import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
|
||||
import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
|
||||
import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView';
|
||||
import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
|
||||
@@ -84,6 +86,7 @@ import {
|
||||
} from '../../features/project-workspace/LocalGamePreviewFrame';
|
||||
import {
|
||||
dispatchResourceReferenceInsert,
|
||||
resolveActiveIterationVersion,
|
||||
resourceReferenceCategoryLabel,
|
||||
} from '../../features/project-workspace/resourceReferences';
|
||||
import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker';
|
||||
@@ -132,6 +135,16 @@ import {
|
||||
currentVersionResourceBindingIds,
|
||||
isResourceUsedByCurrentVersion,
|
||||
} from '../../features/resource-canvas/resourceCanvasVersionBindingModel';
|
||||
import {
|
||||
resourceReplacementBlockedReasons,
|
||||
resourceReplacementPickerAssets,
|
||||
resourceVersionReplacementErrorMessage,
|
||||
type LocalProjectVersionReplacementCandidate,
|
||||
} from '../../features/resource-canvas/resourceVersionReplacementModel';
|
||||
import {
|
||||
readVersionResourceReplacementCandidates,
|
||||
replaceVersionResource,
|
||||
} from '../../features/resource-canvas/resourceVersionReplacementTransport';
|
||||
import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge';
|
||||
import {
|
||||
currentPlatformSessionGeneration,
|
||||
@@ -1427,6 +1440,24 @@ export default function ProjectDevelopmentView({
|
||||
const [resourceRenameError, setResourceRenameError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
/**
|
||||
* 版本级资源替换(PRD §3.2 / §5.3):改 manifest 绑定,落盘为"新版本 + 新绑定"。
|
||||
*
|
||||
* 源身份在打开弹窗时冻结(`sourceVersionId + sourceResourceId`),候选与兼容性结论全部来自
|
||||
* Rust;前端不重算判据,也不在失败时伪造成功。
|
||||
*/
|
||||
const [resourceReplacementSource, setResourceReplacementSource] = useState<{
|
||||
versionId: string;
|
||||
assetId: string;
|
||||
} | null>(null);
|
||||
const [resourceReplacementCandidates, setResourceReplacementCandidates] =
|
||||
useState<LocalProjectVersionReplacementCandidate[]>([]);
|
||||
const [resourceReplacementOpen, setResourceReplacementOpen] = useState(false);
|
||||
const [resourceReplacementLoading, setResourceReplacementLoading] =
|
||||
useState(false);
|
||||
const [resourceReplacementError, setResourceReplacementError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [pendingResourceEditActionIds, setPendingResourceEditActionIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] =
|
||||
@@ -2578,6 +2609,121 @@ export default function ProjectDevelopmentView({
|
||||
},
|
||||
[reloadManifestAfterAssetCommand],
|
||||
);
|
||||
/**
|
||||
* 打开「替换素材」:先向后端要一次权威候选,拿到了才开弹窗。
|
||||
*
|
||||
* 只读失败(项目未初始化 / 源版本已不存在)时不弹空壳弹窗,直接把原因说明白,
|
||||
* 避免出现"打开了但什么也选不了"的假入口。
|
||||
*/
|
||||
const openResourceVersionReplacement = useCallback(
|
||||
async (resource: ProjectResource) => {
|
||||
const assetId = resource.manifestAssetId;
|
||||
const version = resolveActiveIterationVersion(
|
||||
projectVersions,
|
||||
activeVersionId,
|
||||
);
|
||||
if (!assetId || !version) return;
|
||||
const source = { versionId: version.versionId, assetId };
|
||||
setResourceReplacementError(null);
|
||||
setResourceReplacementCandidates([]);
|
||||
setResourceReplacementSource(source);
|
||||
setResourceReplacementLoading(true);
|
||||
try {
|
||||
const result = await readVersionResourceReplacementCandidates({
|
||||
projectPath,
|
||||
sourceVersionId: source.versionId,
|
||||
sourceResourceId: source.assetId,
|
||||
});
|
||||
setResourceReplacementCandidates(result.candidates);
|
||||
setResourceReplacementOpen(true);
|
||||
} catch (error) {
|
||||
setResourceReplacementSource(null);
|
||||
setResourceWorkbenchNotice(
|
||||
resourceVersionReplacementErrorMessage(error),
|
||||
);
|
||||
} finally {
|
||||
setResourceReplacementLoading(false);
|
||||
}
|
||||
},
|
||||
[activeVersionId, projectPath, projectVersions],
|
||||
);
|
||||
const closeResourceVersionReplacement = useCallback(() => {
|
||||
setResourceReplacementOpen(false);
|
||||
setResourceReplacementSource(null);
|
||||
setResourceReplacementCandidates([]);
|
||||
setResourceReplacementError(null);
|
||||
}, []);
|
||||
/**
|
||||
* 确认替换:CAS 写入「新版本 + 新绑定」,成功后重读 manifest 并把当前版本切到新版本。
|
||||
*
|
||||
* 失败一律保留弹窗与选择并把原因显示在弹窗里:不切版本、不动高亮、不提示成功。
|
||||
* 按 PRD §3.2 末条,新版本不自动被运行中的预览消费;入口只在资源视图出现,
|
||||
* 因此这里的 `selectActiveVersion` 不会顺手重载运行画面。
|
||||
*/
|
||||
const confirmResourceVersionReplacement = useCallback(
|
||||
async (assetIds: string[]) => {
|
||||
const source = resourceReplacementSource;
|
||||
const replacementResourceId = assetIds[0];
|
||||
if (!source || !replacementResourceId) {
|
||||
setResourceReplacementError('请选择一个替换素材');
|
||||
return;
|
||||
}
|
||||
if (resourceReplacementLoading) return;
|
||||
setResourceReplacementLoading(true);
|
||||
setResourceReplacementError(null);
|
||||
try {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
throw new Error('替换素材需要在客户端内执行');
|
||||
}
|
||||
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 replaceVersionResource({
|
||||
projectPath,
|
||||
expectedProjectId: manifest.projectId,
|
||||
expectedProjectRevision: status.revision,
|
||||
sourceVersionId: source.versionId,
|
||||
sourceResourceId: source.assetId,
|
||||
replacementResourceId,
|
||||
});
|
||||
setResourceReplacementOpen(false);
|
||||
setResourceReplacementSource(null);
|
||||
setResourceReplacementCandidates([]);
|
||||
selectActiveVersion(result.versionId);
|
||||
await reloadManifestAfterAssetCommand(
|
||||
result.committedProjectRevision,
|
||||
`version-resource-replacement:${result.versionId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
setResourceReplacementError(
|
||||
resourceVersionReplacementErrorMessage(error),
|
||||
);
|
||||
} finally {
|
||||
setResourceReplacementLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
manifest.projectId,
|
||||
projectPath,
|
||||
reloadManifestAfterAssetCommand,
|
||||
resourceReplacementLoading,
|
||||
resourceReplacementSource,
|
||||
],
|
||||
);
|
||||
const resourceReplacementPickerEntries = useMemo(
|
||||
() =>
|
||||
resourceReplacementPickerAssets(resources, resourceReplacementCandidates),
|
||||
[resourceReplacementCandidates, resources],
|
||||
);
|
||||
const resourceReplacementBlockedReasonMap = useMemo(
|
||||
() => resourceReplacementBlockedReasons(resourceReplacementCandidates),
|
||||
[resourceReplacementCandidates],
|
||||
);
|
||||
/**
|
||||
* 素材重命名:只改磁盘文件名与 manifest 的 `localPath`,资产 id 不变。
|
||||
* Rust 入参是 `deny_unknown_fields` 的结构体,这里必须只传这三个字段。
|
||||
@@ -6405,6 +6551,33 @@ export default function ProjectDevelopmentView({
|
||||
onConfirm={(newFileName) => void confirmResourceRename(newFileName)}
|
||||
/>
|
||||
) : null}
|
||||
{/*
|
||||
版本级资源替换:复用美术画布的参考图弹窗(单选 + 禁用原因 + 失败原因三个 opt-in prop)。
|
||||
缩略图走 `renderAssetMedia` 的类型占位:AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,
|
||||
弹窗里没有同步 `src`,直接给 `<img>` 会挂破图。
|
||||
*/}
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open={resourceReplacementOpen}
|
||||
assets={resourceReplacementPickerEntries}
|
||||
selectedAssetIds={[]}
|
||||
singleSelect
|
||||
selectionNoun="替换素材"
|
||||
assetBlockedReasons={resourceReplacementBlockedReasonMap}
|
||||
errorMessage={resourceReplacementError}
|
||||
renderAssetMedia={(asset) => (
|
||||
<span className="game-resource-replacement-media" aria-hidden="true">
|
||||
{asset.mediaType === 'audio' ? (
|
||||
<Music2 className="h-5 w-5" />
|
||||
) : asset.mediaType === 'video' ? (
|
||||
<Video className="h-5 w-5" />
|
||||
) : (
|
||||
<Image className="h-5 w-5" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
onCancel={closeResourceVersionReplacement}
|
||||
onConfirm={(assetIds) => void confirmResourceVersionReplacement(assetIds)}
|
||||
/>
|
||||
{resourceRecoveryPanelOpen ? (
|
||||
<div
|
||||
className="game-approval-backdrop"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user