前端改成直接替换:不切版本、尺寸规格降级为提示
- 口径跟进:前端从「版本级替换」改成「直接替换」——成功后**不再切换版本**(没有新版本可切),只重读 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:
+49
-11
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user