资源工作台「替换素材」候选窗接上预览管线,补齐候选排序、禁用态可读性与删除素材入口
- 「替换素材」候选卡缩略图接入资源卡同一条预览管线:新增 ResourcePreviewMedia,复用同一份预览身份、同一条队列与 LRU 缓存,不再只渲染类型占位图标 - 预览 hook 暴露 sweepVisiblePreviews:弹窗内容挂在 portal 下、不在资源画本 observer 的 root 子树内,改为在「弹窗打开」与「候选区滚动」两个几何变化点显式按同一套视口几何判据兜底放行,不新增第二条加载通路 - 候选排序抽出纯函数 sortResourceReplacementCandidates:可替换(无硬门禁失败)的排最前,其余按后端原名次跟随且仍渲染但禁用;判据与弹窗禁用状态同源,不会出现「排最前却点不动」 - 抽出纯函数 projectResourcesByManifestAssetId,让弹窗素材映射与候选缩略图共用同一份资源投影口径 - 共享选择弹窗的禁用态不再整卡压透明度:变灰只压在缩略图与名称上,「分类不同」这类禁用原因保持全对比度可读(网页端美术画布不传禁用原因,视觉逐字不变) - 资源卡选中工具条末位新增「删除素材」,前置共享工具条同一套分隔线与前面的非破坏性动作隔开,只在 manifestAssetId 存在时才渲染 - 删除流程抽成 useResourceAssetDeleteFlow(读引用信息 → ResourceAssetDeleteDialog 二次确认 → delete_local_project_asset 的 deleteReferencedVersions 三分支),工具条与面板共用同一份实现,没有第二套删除 - 「编辑素材标签」面板底部只留右下角一个「添加」:点它把输入框内容(含未按回车的尾巴)落成 pill 并按现有写入路径保存,分类仍原样回传落盘原值;删除素材/取消/保存标签三个按钮移除,关闭仍走头部 × - 标签 pill 内的删除标签按钮保持不变 - 更新与补充用例:候选排序、排序与禁用同源、候选缩略图真链路与滚动兜底放行、工具条删除入口位置与 IPC 载荷、连带删除分支、面板无删除入口与「添加」语义、禁用态可读性类名契约、工具条动作清单
This commit is contained in:
+46
-7
@@ -115,6 +115,51 @@ export function resourceReplacementAssetHints(
|
||||
return hints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选排序:**可替换(无硬门禁失败)的排在最前,其余按原名次跟随**。
|
||||
*
|
||||
* 判据与弹窗的禁用状态同源(`resourceReplacementBlockedReason`),所以"排在最前"与
|
||||
* "点得动"永远是同一批条目 —— 不会出现排在最前却点不动的候选,也不会出现能点的候选被
|
||||
* 挤在不可替换项后面。同档内显式按原名次排(不依赖 `Array.prototype.sort` 的稳定性),
|
||||
* 保证任何引擎下结果一致。
|
||||
*
|
||||
* 不可替换项**不隐藏**:仍然渲染但禁用,把"为什么不能替换"留在界面上。
|
||||
*/
|
||||
export function sortResourceReplacementCandidates<
|
||||
Candidate extends LocalProjectVersionReplacementCandidate,
|
||||
>(candidates: readonly Candidate[]): Candidate[] {
|
||||
return candidates
|
||||
.map((candidate, index) => ({ candidate, index }))
|
||||
.sort((left, right) => {
|
||||
const leftBlocked =
|
||||
resourceReplacementBlockedReason(left.candidate) !== null;
|
||||
const rightBlocked =
|
||||
resourceReplacementBlockedReason(right.candidate) !== null;
|
||||
if (leftBlocked !== rightBlocked) return leftBlocked ? 1 : -1;
|
||||
return left.index - right.index;
|
||||
})
|
||||
.map((entry) => entry.candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest 资产 id → 资源投影(同一资产生成多个投影时取第一个,与资源画布口径一致)。
|
||||
*
|
||||
* 抽成纯函数是为了让「候选条目 ↔ 资源投影 ↔ 预览身份」这条链在弹窗素材映射与宿主渲染处
|
||||
* 共用同一份口径,而不是各写一份 `forEach`。
|
||||
*/
|
||||
export function projectResourcesByManifestAssetId(
|
||||
resources: readonly ProjectResource[],
|
||||
): Map<string, ProjectResource> {
|
||||
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 resourceByAssetId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选 → 弹窗素材。
|
||||
*
|
||||
@@ -126,13 +171,7 @@ 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);
|
||||
}
|
||||
}
|
||||
const resourceByAssetId = projectResourcesByManifestAssetId(resources);
|
||||
return candidates.flatMap((candidate) => {
|
||||
const resource = resourceByAssetId.get(candidate.resourceId);
|
||||
if (!resource) return [];
|
||||
|
||||
@@ -6325,6 +6325,17 @@ iframe.preview-frame {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 「编辑素材标签」底部只有一个「添加」,靠右下角。
|
||||
*
|
||||
* 面板本身是 `display: grid`,footer 作为网格项默认铺满整行;这里只把它改成靠右的行内排布,
|
||||
* 按钮宽度仍由共享的 `PlatformActionButton` 决定。
|
||||
*/
|
||||
.game-resource-classification-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.game-resource-classification-error {
|
||||
margin: 0;
|
||||
color: #b3261e;
|
||||
@@ -6413,10 +6424,11 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
/*
|
||||
* 「替换素材」候选弹窗里的类型占位。
|
||||
* 「替换素材」候选弹窗里候选卡的媒体区。
|
||||
*
|
||||
* AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,弹窗内没有同步 `src`,
|
||||
* 所以候选行只渲染稳定类型图标,不挂 `<img>`、不出现破图。
|
||||
* 缩略图由资源卡同一条预览管线(`useProjectResourceCardPreviews`)给出 Blob URL 后渲染,
|
||||
* 所以这里只负责"铺满弹窗给的 20 高度媒体槽 + 居中":读不到预览时它是类型占位的容器,
|
||||
* 读到之后就是真实缩略图的容器,两种状态同一块版面、不跳尺寸。
|
||||
*/
|
||||
.game-resource-replacement-media {
|
||||
display: flex;
|
||||
@@ -6424,6 +6436,7 @@ iframe.preview-frame {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: #8d7a6b;
|
||||
}
|
||||
|
||||
|
||||
+24
-127
@@ -13,28 +13,12 @@ import {
|
||||
normalizeGameCreationAppAssetTags,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { ThemedModal } from '../../components/modal/ThemedModal';
|
||||
import {
|
||||
ResourceAssetDeleteDialog,
|
||||
type ResourceAssetReferenceVersion,
|
||||
} from './ResourceAssetDeleteDialog';
|
||||
|
||||
type UpdateLocalProjectResourceClassificationResult = {
|
||||
asset: GameCreationAppAssetManifestEntry;
|
||||
committedProjectRevision: number;
|
||||
};
|
||||
|
||||
type DeleteLocalProjectAssetResult = {
|
||||
assetId: string;
|
||||
localPath: string;
|
||||
committedProjectRevision: number;
|
||||
fileRetained: boolean;
|
||||
};
|
||||
|
||||
type ReadLocalProjectAssetReferencesResult = {
|
||||
assetId: string;
|
||||
versions: ResourceAssetReferenceVersion[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。
|
||||
* 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。
|
||||
@@ -71,19 +55,12 @@ function resourceClassificationErrorMessage(error: unknown) {
|
||||
return '保存素材标签失败';
|
||||
}
|
||||
|
||||
function resourceDeleteErrorMessage(error: unknown) {
|
||||
if (typeof error === 'string' && error.trim()) return error;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return '删除资源失败';
|
||||
}
|
||||
|
||||
type ResourceClassificationPanelProps = {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
asset: GameCreationAppAssetManifestEntry;
|
||||
onClose: () => void;
|
||||
onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void;
|
||||
onDeleted: (result: DeleteLocalProjectAssetResult) => void;
|
||||
};
|
||||
|
||||
export function ResourceClassificationPanel({
|
||||
@@ -92,7 +69,6 @@ export function ResourceClassificationPanel({
|
||||
asset,
|
||||
onClose,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: ResourceClassificationPanelProps) {
|
||||
/**
|
||||
* 分类取值优先级由落盘 `category` + `kind` 派生决定,本面板不再提供手动设置入口。
|
||||
@@ -111,17 +87,9 @@ export function ResourceClassificationPanel({
|
||||
);
|
||||
const [tagDraft, setTagDraft] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteDialogPreparing, setDeleteDialogPreparing] = useState(false);
|
||||
const [deleteReferencedVersions, setDeleteReferencedVersions] =
|
||||
useState(false);
|
||||
const [referencedVersions, setReferencedVersions] = useState<
|
||||
readonly ResourceAssetReferenceVersion[]
|
||||
>([]);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/** 回车 / 逗号 / 顿号都按同一口径切分;输入框仍是同一套提交方式。 */
|
||||
/** 回车 / 逗号 / 顿号都按同一口径切分:把草稿落成 pill。 */
|
||||
function commitTagDraft() {
|
||||
if (!tagDraft.trim()) return;
|
||||
setTags((current) =>
|
||||
@@ -134,7 +102,12 @@ export function ResourceClassificationPanel({
|
||||
setTags((current) => current.filter((item) => item !== tag));
|
||||
}
|
||||
|
||||
async function saveResourceClassification() {
|
||||
/**
|
||||
* 保存标签:`tagsToSave` 由调用方给出(「添加」把输入框里还未落成 pill 的尾巴一起并入)。
|
||||
*/
|
||||
async function saveResourceClassification(
|
||||
tagsToSave: readonly string[],
|
||||
): Promise<void> {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
setError('编辑素材标签需要在客户端内保存');
|
||||
@@ -150,9 +123,6 @@ export function ResourceClassificationPanel({
|
||||
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
|
||||
throw new Error('项目 revision 无效');
|
||||
}
|
||||
// 输入框里还没按回车 / 逗号落成 pill 的尾巴也要一起保存,
|
||||
// 沿用"整段文本在保存时统一切分"的既有行为,不让用户白输入。
|
||||
const tagsToSave = mergeResourceClassificationTagDraft(tags, tagDraft);
|
||||
const result =
|
||||
await invoke<UpdateLocalProjectResourceClassificationResult>(
|
||||
'update_local_project_resource_classification',
|
||||
@@ -177,66 +147,16 @@ export function ResourceClassificationPanel({
|
||||
}
|
||||
|
||||
/**
|
||||
* 点删除先读引用信息,再打开独立确认面板。素材不可变:只摘掉 manifest 登记,磁盘文件保留。
|
||||
* 底部唯一的「添加」:把输入框里的内容(**含没按回车的尾巴**)落成 pill,然后按同一写入路径保存。
|
||||
*
|
||||
* 落 pill 是即时反馈,保存是本次动作的语义本身 —— 拆成"先落 pill 再等用户去点保存"要求
|
||||
* 用户理解两步,而这里只有一步。
|
||||
*/
|
||||
async function openDeleteResourceDialog() {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
setError('删除资源需要在客户端内执行');
|
||||
return;
|
||||
}
|
||||
setDeleteDialogPreparing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const references = await invoke<ReadLocalProjectAssetReferencesResult>(
|
||||
'read_local_project_asset_references',
|
||||
{ input: { projectPath, assetId: asset.id } },
|
||||
);
|
||||
setReferencedVersions(references.versions);
|
||||
setDeleteReferencedVersions(false);
|
||||
setDeleteDialogOpen(true);
|
||||
} catch (referenceError) {
|
||||
setError(resourceDeleteErrorMessage(referenceError));
|
||||
} finally {
|
||||
setDeleteDialogPreparing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteResource() {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
setError('删除资源需要在客户端内执行');
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
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 invoke<DeleteLocalProjectAssetResult>(
|
||||
'delete_local_project_asset',
|
||||
{
|
||||
input: {
|
||||
projectPath,
|
||||
expectedProjectId: projectId,
|
||||
expectedProjectRevision: status.revision,
|
||||
assetId: asset.id,
|
||||
deleteReferencedVersions,
|
||||
},
|
||||
},
|
||||
);
|
||||
setDeleteDialogOpen(false);
|
||||
onDeleted(result);
|
||||
} catch (deleteError) {
|
||||
setError(resourceDeleteErrorMessage(deleteError));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
async function addTagDraftAndSave() {
|
||||
const tagsToSave = mergeResourceClassificationTagDraft(tags, tagDraft);
|
||||
setTags(tagsToSave);
|
||||
setTagDraft('');
|
||||
await saveResourceClassification(tagsToSave);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -304,41 +224,18 @@ export function ResourceClassificationPanel({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<footer>
|
||||
{/*
|
||||
底部只留一个「添加」:它 = 落 pill + 保存。删除素材挪到资源卡选中工具条
|
||||
(破坏性操作与它要改的对象放在一起),标签自身的删除按钮仍在每个 pill 内部。
|
||||
*/}
|
||||
<footer className="game-resource-classification-footer">
|
||||
<PlatformActionButton
|
||||
tone="danger"
|
||||
onClick={() => void openDeleteResourceDialog()}
|
||||
disabled={saving || deleting || deleteDialogPreparing}
|
||||
aria-label="删除资源"
|
||||
onClick={() => void addTagDraftAndSave()}
|
||||
disabled={saving}
|
||||
>
|
||||
删除
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
onClick={onClose}
|
||||
disabled={saving || deleting}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
onClick={() => void saveResourceClassification()}
|
||||
disabled={saving || deleting}
|
||||
>
|
||||
保存标签
|
||||
添加
|
||||
</PlatformActionButton>
|
||||
</footer>
|
||||
{deleteDialogOpen ? (
|
||||
<ResourceAssetDeleteDialog
|
||||
open
|
||||
localPath={asset.localPath}
|
||||
referencedVersions={referencedVersions}
|
||||
deleteReferencedVersions={deleteReferencedVersions}
|
||||
deleting={deleting}
|
||||
onChangeDeleteReferencedVersions={setDeleteReferencedVersions}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={() => void confirmDeleteResource()}
|
||||
/>
|
||||
) : null}
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Image as ImageIcon, Music2, Video } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
projectResourceCardPreviewKind,
|
||||
type ProjectResourceCardPreviewState,
|
||||
} from './resourceCardPreviewModel';
|
||||
import type { ProjectResource } from './resourceProjectionModel';
|
||||
|
||||
type ResourcePreviewMediaProps = {
|
||||
/**
|
||||
* 该条目对应的资源投影。`null` 表示宿主给不出投影(候选条目理论上都来自资源投影,
|
||||
* 这里只做兜底):此时只渲染类型占位,不注册预览、不请求读取。
|
||||
*/
|
||||
resource: ProjectResource | null;
|
||||
/**
|
||||
* 资源卡预览身份(`useProjectResourceCardPreviews().identityByResourceId` 的取值)。
|
||||
*
|
||||
* 身份缺失时同样退化为类型占位:拿不到身份就无法进入预览队列,硬挂 `<img>` 只会是破图。
|
||||
*/
|
||||
previewIdentity: string | null;
|
||||
/** 与资源卡同源的预览状态快照。 */
|
||||
preview: ProjectResourceCardPreviewState;
|
||||
/**
|
||||
* 把本卡元素登记进资源卡预览管线(`useProjectResourceCardPreviews().observePreview`)。
|
||||
*
|
||||
* 登记即复核:`observePreview` 注册后立刻按几何判一次可见性,所以弹窗里的可见候选
|
||||
* 开箱即进同一条队列;滚动等后续几何变化由宿主的兜底扫描补齐。
|
||||
*/
|
||||
onObservePreview: (
|
||||
element: HTMLElement,
|
||||
resource: ProjectResource,
|
||||
identity: string,
|
||||
) => () => void;
|
||||
onPreviewDecodeError: (identity: string, error: string) => void;
|
||||
/**
|
||||
* 资源投影缺失时的类型占位口径(宿主条目自带的媒体类型,取值域是宿主的 `EditorAsset`)。
|
||||
* 缺省按图片处理,与资源卡对未知类型的占位一致。
|
||||
*/
|
||||
fallbackMediaType?: string;
|
||||
};
|
||||
|
||||
function resourcePreviewPlaceholderIcon(mediaType: string | undefined) {
|
||||
if (mediaType === 'audio') {
|
||||
return <Music2 className="h-5 w-5" aria-hidden="true" />;
|
||||
}
|
||||
if (mediaType === 'video') {
|
||||
return <Video className="h-5 w-5" aria-hidden="true" />;
|
||||
}
|
||||
return <ImageIcon className="h-5 w-5" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗类宿主(portal 里)的资源缩略图。
|
||||
*
|
||||
* 走的是**资源卡同一条预览管线**:身份取自 `identityByResourceId`、状态取自 `previews`、
|
||||
* 可见性通过 `observePreview` 登记进同一张 `observedCards` 表。没有任何第二条读取通路:
|
||||
* 这里只用管线给的 `sourceUrl` 渲染,读不到就退回类型占位(与资源卡的占位语义一致)。
|
||||
*
|
||||
* `data-resource-preview-status` 与资源卡一样暴露"为什么没有图":`idle` / `loading` /
|
||||
* `failed` 在界面上都是占位图标,排障时需要能直接分辨。
|
||||
*/
|
||||
export function ResourcePreviewMedia({
|
||||
resource,
|
||||
previewIdentity,
|
||||
preview,
|
||||
onObservePreview,
|
||||
onPreviewDecodeError,
|
||||
fallbackMediaType,
|
||||
}: ResourcePreviewMediaProps) {
|
||||
const mediaRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = mediaRef.current;
|
||||
if (!element || !resource || !previewIdentity) {
|
||||
return undefined;
|
||||
}
|
||||
return onObservePreview(element, resource, previewIdentity);
|
||||
}, [onObservePreview, previewIdentity, resource]);
|
||||
|
||||
const kind = resource ? projectResourceCardPreviewKind(resource) : null;
|
||||
const sourceUrl =
|
||||
preview.status === 'loaded' ? (preview.preview.sourceUrl ?? null) : null;
|
||||
const visual = (() => {
|
||||
if (sourceUrl && (kind === 'raster-image' || kind === 'media-image')) {
|
||||
return (
|
||||
<img
|
||||
src={sourceUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
decoding="async"
|
||||
onError={() => {
|
||||
if (previewIdentity) {
|
||||
onPreviewDecodeError(
|
||||
previewIdentity,
|
||||
'美术资源无法解码,请重新生成或替换该资源',
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (sourceUrl && kind === 'video') {
|
||||
return (
|
||||
<video
|
||||
src={sourceUrl}
|
||||
className="h-full w-full object-cover"
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
);
|
||||
}
|
||||
return resourcePreviewPlaceholderIcon(
|
||||
kind === 'video'
|
||||
? 'video'
|
||||
: kind === 'audio'
|
||||
? 'audio'
|
||||
: fallbackMediaType,
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={mediaRef}
|
||||
className="game-resource-replacement-media"
|
||||
data-resource-preview-status={preview.status}
|
||||
>
|
||||
{visual}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -41,9 +41,9 @@ import {
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Users,
|
||||
Video,
|
||||
X,
|
||||
ZoomOut,
|
||||
} from 'lucide-react';
|
||||
@@ -148,10 +148,12 @@ import {
|
||||
import { ResourcePromptPolishSlot } from '../../features/resource-canvas/ResourcePromptPolishSlot';
|
||||
import {
|
||||
type LocalProjectVersionReplacementCandidate,
|
||||
projectResourcesByManifestAssetId,
|
||||
resourceReplacementAssetHints,
|
||||
resourceReplacementBlockedReasons,
|
||||
resourceReplacementPickerAssets,
|
||||
resourceVersionReplacementErrorMessage,
|
||||
sortResourceReplacementCandidates,
|
||||
} from '../../features/resource-canvas/resourceVersionReplacementModel';
|
||||
import {
|
||||
readVersionResourceReplacementCandidates,
|
||||
@@ -171,6 +173,7 @@ import {
|
||||
type ResourceFocusIntent,
|
||||
uploadProjectAssetFilesAndReadSnapshot,
|
||||
} from './projectResourceLiveUpdateModel';
|
||||
import { ResourceAssetDeleteDialog } from './ResourceAssetDeleteDialog';
|
||||
import {
|
||||
createResourceBookTransitionController,
|
||||
type ResourceBookTransitionController,
|
||||
@@ -268,6 +271,7 @@ import {
|
||||
ResourceInfoFieldsView,
|
||||
ResourceInfoPanelView,
|
||||
} from './ResourceInfoPanelView';
|
||||
import { ResourcePreviewMedia } from './ResourcePreviewMedia';
|
||||
import {
|
||||
type ProjectAgentResultSummary,
|
||||
type ProjectAttachmentResult,
|
||||
@@ -292,6 +296,7 @@ import {
|
||||
} from './useProjectResourceCanvasLayout';
|
||||
import { useProjectResourceCardPreviews } from './useProjectResourceCardPreviews';
|
||||
import { useProjectResourceSectionHeights } from './useProjectResourceSectionHeights';
|
||||
import { useResourceAssetDeleteFlow } from './useResourceAssetDeleteFlow';
|
||||
|
||||
export type {
|
||||
ProjectAgentResultSummary,
|
||||
@@ -2809,6 +2814,21 @@ export default function ProjectDevelopmentView({
|
||||
},
|
||||
[reloadManifestAfterAssetCommand],
|
||||
);
|
||||
/**
|
||||
* 资源卡选中工具条的「删除素材」。
|
||||
*
|
||||
* 复用「编辑素材标签」面板原来那套删除流程(读引用 → 二次确认 `ResourceAssetDeleteDialog`
|
||||
* → `delete_local_project_asset` 的 `deleteReferencedVersions` 三分支),抽成 hook 后两处
|
||||
* 共用同一份实现;失败原因落到工作台提示条,与资源工作台其它异步失败同一出口。
|
||||
*/
|
||||
const resourceAssetDeleteFlow = useResourceAssetDeleteFlow({
|
||||
projectPath,
|
||||
projectId: manifest.projectId,
|
||||
onError: setResourceWorkbenchNotice,
|
||||
onDeleted: (result) => {
|
||||
void handleResourceClassificationDeleted(result);
|
||||
},
|
||||
});
|
||||
/**
|
||||
* 素材重命名:只改磁盘文件名与 manifest 的 `localPath`,资产 id 不变。
|
||||
* Rust 入参是 `deny_unknown_fields` 的结构体,这里必须只传这三个字段。
|
||||
@@ -4459,9 +4479,10 @@ export default function ProjectDevelopmentView({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const scale = (resourceBookOpensAllResources
|
||||
? resourceBookAllViewportRef.current
|
||||
: resourceCanvasViewportRef.current
|
||||
const scale = (
|
||||
resourceBookOpensAllResources
|
||||
? resourceBookAllViewportRef.current
|
||||
: resourceCanvasViewportRef.current
|
||||
).scale;
|
||||
if (!Number.isFinite(scale) || scale <= 0) {
|
||||
return;
|
||||
@@ -5405,10 +5426,31 @@ export default function ProjectDevelopmentView({
|
||||
resourceReplacementSource,
|
||||
],
|
||||
);
|
||||
/**
|
||||
* 候选排序:可替换的排最前,不可替换的按后端原名次跟随(仍然渲染但禁用)。
|
||||
*
|
||||
* 排序只改展示顺序,不改 `resourceReplacementCandidates` 本身 —— 写入用的 `sourceResourceId`
|
||||
* 与兼容性提示表都按 id 取值,与顺序无关。
|
||||
*/
|
||||
const sortedResourceReplacementCandidates = useMemo(
|
||||
() => sortResourceReplacementCandidates(resourceReplacementCandidates),
|
||||
[resourceReplacementCandidates],
|
||||
);
|
||||
const resourceReplacementPickerEntries = useMemo(
|
||||
() =>
|
||||
resourceReplacementPickerAssets(resources, resourceReplacementCandidates),
|
||||
[resourceReplacementCandidates, resources],
|
||||
resourceReplacementPickerAssets(
|
||||
resources,
|
||||
sortedResourceReplacementCandidates,
|
||||
),
|
||||
[resources, sortedResourceReplacementCandidates],
|
||||
);
|
||||
/**
|
||||
* 候选条目 → 资源投影:候选卡的缩略图要拿资源投影才能进资源卡预览管线(身份、读取口径、
|
||||
* 卡片预览类型都按资源投影算),因此这里与 `resourceReplacementPickerAssets` 共用同一份映射。
|
||||
*/
|
||||
const resourceByManifestAssetId = useMemo(
|
||||
() => projectResourcesByManifestAssetId(resources),
|
||||
[resources],
|
||||
);
|
||||
const resourceReplacementBlockedReasonMap = useMemo(
|
||||
() => resourceReplacementBlockedReasons(resourceReplacementCandidates),
|
||||
@@ -5418,6 +5460,31 @@ export default function ProjectDevelopmentView({
|
||||
() => resourceReplacementAssetHints(resourceReplacementCandidates),
|
||||
[resourceReplacementCandidates],
|
||||
);
|
||||
/**
|
||||
* 「替换素材」候选弹窗里的缩略图必须走资源卡同一条预览管线。
|
||||
*
|
||||
* 弹窗内容挂在 portal(`document.body`)下,**不在资源画本 observer 的 root 子树里**:
|
||||
* observer 永远把它们报成不相交,所以可见性只能靠几何兜底扫描放行。`observePreview` 注册时
|
||||
* 本就会扫一次(覆盖"打开弹窗"),这里再补两个几何变化点:打开后的下一轮宏任务(等弹窗
|
||||
* 真正布局完)与候选区滚动(滚动事件不冒泡,用捕获阶段挂到 document 上)。
|
||||
*/
|
||||
const sweepResourceReplacementPreviews =
|
||||
resourceCardPreviews.sweepVisiblePreviews;
|
||||
useEffect(() => {
|
||||
if (!resourceReplacementOpen) {
|
||||
return undefined;
|
||||
}
|
||||
const sweep = () => {
|
||||
sweepResourceReplacementPreviews();
|
||||
};
|
||||
const timer = setTimeout(sweep, 0);
|
||||
// 滚动事件不冒泡,但捕获阶段的祖先监听器能收到;用它覆盖弹窗自己的滚动容器。
|
||||
document.addEventListener('scroll', sweep, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('scroll', sweep, true);
|
||||
};
|
||||
}, [resourceReplacementOpen, sweepResourceReplacementPreviews]);
|
||||
|
||||
function showRunView() {
|
||||
if (!runAvailable || uiEditorRoute) {
|
||||
@@ -6557,6 +6624,47 @@ export default function ProjectDevelopmentView({
|
||||
<span>替换素材</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{/*
|
||||
破坏性动作排在最后,并用共享工具条同一套分隔线(
|
||||
`image-canvas-editor__floating-toolbar-divider`)把它与前面的
|
||||
非破坏性动作隔开。只删素材登记:磁盘文件保留,确认面板里再问一次
|
||||
「是否连带删除引用它的游戏版本」。
|
||||
*/}
|
||||
{selectedResource?.manifestAssetId ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="删除素材"
|
||||
title="删除素材"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
disabled={
|
||||
resourceAssetDeleteFlow.deleting ||
|
||||
resourceAssetDeleteFlow.preparing
|
||||
}
|
||||
onClick={() => {
|
||||
if (!selectedResource.manifestAssetId) {
|
||||
return;
|
||||
}
|
||||
void resourceAssetDeleteFlow.requestDelete(
|
||||
{
|
||||
assetId:
|
||||
selectedResource.manifestAssetId,
|
||||
// 资源投影的 `path` 就是 manifest 资产的
|
||||
// `localPath`(见 `resourceProjectionModel`),
|
||||
// 与面板曾用的副标题同源。
|
||||
localPath: selectedResource.path,
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span>删除素材</span>
|
||||
</CanvasChromeButton>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
onOpenQuickEditPanel={openResourceQuickEditPanel}
|
||||
@@ -7258,9 +7366,15 @@ export default function ProjectDevelopmentView({
|
||||
asset={resourceClassificationAsset}
|
||||
onClose={() => setResourceClassificationAssetId(null)}
|
||||
onSaved={(result) => void handleResourceClassificationSaved(result)}
|
||||
onDeleted={(result) =>
|
||||
void handleResourceClassificationDeleted(result)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{/*
|
||||
删除素材的二次确认面板:资源卡选中工具条删素材时用,与「编辑素材标签」面板曾用的
|
||||
是同一个 `ResourceAssetDeleteDialog` 与同一条删除流程(见 `useResourceAssetDeleteFlow`)。
|
||||
*/}
|
||||
{resourceAssetDeleteFlow.deleteDialogProps ? (
|
||||
<ResourceAssetDeleteDialog
|
||||
{...resourceAssetDeleteFlow.deleteDialogProps}
|
||||
/>
|
||||
) : null}
|
||||
{resourceRenameAsset ? (
|
||||
@@ -7279,8 +7393,8 @@ export default function ProjectDevelopmentView({
|
||||
) : null}
|
||||
{/*
|
||||
版本级资源替换(直接替换):复用美术画布的参考图弹窗(单选 + 禁用原因 + 提示 + 失败原因)。
|
||||
缩略图走 `renderAssetMedia` 的类型占位:AGC 的素材预览要经带 scope 的原生读取器拿 Blob URL,
|
||||
弹窗里没有同步 `src`,直接给 `<img>` 会挂破图。
|
||||
候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),弹窗只负责把
|
||||
管线给的 `sourceUrl` 画出来;读不到时退回类型占位,与资源卡占位语义一致。
|
||||
*/}
|
||||
<ImageCanvasProjectAssetPickerDialog
|
||||
open={resourceReplacementOpen}
|
||||
@@ -7291,17 +7405,28 @@ export default function ProjectDevelopmentView({
|
||||
assetBlockedReasons={resourceReplacementBlockedReasonMap}
|
||||
assetHints={resourceReplacementHintMap}
|
||||
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>
|
||||
)}
|
||||
renderAssetMedia={(asset) => {
|
||||
const resource = resourceByManifestAssetId.get(asset.id) ?? null;
|
||||
const previewIdentity = resource
|
||||
? (resourceCardPreviews.identityByResourceId.get(resource.id) ??
|
||||
null)
|
||||
: null;
|
||||
return (
|
||||
<ResourcePreviewMedia
|
||||
resource={resource}
|
||||
previewIdentity={previewIdentity}
|
||||
preview={
|
||||
previewIdentity
|
||||
? (resourceCardPreviews.previews.get(previewIdentity) ??
|
||||
resourceCardPreviews.idlePreview)
|
||||
: resourceCardPreviews.idlePreview
|
||||
}
|
||||
onObservePreview={resourceCardPreviews.observePreview}
|
||||
onPreviewDecodeError={handleCardPreviewDecodeError}
|
||||
fallbackMediaType={asset.mediaType}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
onCancel={closeResourceVersionReplacement}
|
||||
onConfirm={(assetIds) =>
|
||||
void confirmResourceVersionReplacement(assetIds)
|
||||
|
||||
+11
@@ -1291,6 +1291,17 @@ export function useProjectResourceCardPreviews(input: {
|
||||
idlePreview: IDLE_PROJECT_RESOURCE_CARD_PREVIEW,
|
||||
observePreview,
|
||||
requestPreview,
|
||||
/**
|
||||
* 可见性兜底扫描(判据见 `viewportBandOfElement`:视口 ± 160px 内的 `idle` 卡补一次
|
||||
* `visible`)。注册(`observePreview`)时本就会扫一次,这里把它暴露出来的唯一原因是
|
||||
* **portal 宿主的几何变化事件 observer 收不到**:
|
||||
*
|
||||
* 弹窗内容挂在 `document.body` 下,不在资源画本 observer 的 `root` 子树里 ⇒ observer
|
||||
* 永远把它们报成"不相交";而扫描用的是**视口几何**,与 observer 的 root 无关,所以这类
|
||||
* 宿主自己注册元素后,只要在「弹窗打开」「候选区滚动」这两个几何变化点上显式扫一次,
|
||||
* 走的仍是同一条队列与同一条判据,不新增第二条加载通路。
|
||||
*/
|
||||
sweepVisiblePreviews,
|
||||
failPreview,
|
||||
protectPreview,
|
||||
/**
|
||||
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { ResourceAssetReferenceVersion } from './ResourceAssetDeleteDialog';
|
||||
|
||||
/**
|
||||
* `delete_local_project_asset` 的返回:素材不可变,删除只摘掉 manifest 登记,磁盘文件保留。
|
||||
*/
|
||||
export type DeleteLocalProjectAssetResult = {
|
||||
assetId: string;
|
||||
localPath: string;
|
||||
committedProjectRevision: number;
|
||||
fileRetained: boolean;
|
||||
};
|
||||
|
||||
type ReadLocalProjectAssetReferencesResult = {
|
||||
assetId: string;
|
||||
versions: ResourceAssetReferenceVersion[];
|
||||
};
|
||||
|
||||
/** 待删除的素材:`assetId` 是 manifest 资产 id,`localPath` 只用于确认面板的副标题。 */
|
||||
export type ResourceAssetDeleteTarget = {
|
||||
assetId: string;
|
||||
localPath: string;
|
||||
};
|
||||
|
||||
type UseResourceAssetDeleteFlowInput = {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
/**
|
||||
* 失败原因出口(读引用失败 / 删除失败 / 不在客户端内)。
|
||||
*
|
||||
* 宿主自己决定显示在哪里:面板内联成 `role="alert"`,资源卡工具条落到工作台提示条。
|
||||
* 删除流程本身不持有错误态,避免出现"两处各显示半句失败原因"。
|
||||
*/
|
||||
onError: (message: string) => void;
|
||||
onDeleted: (result: DeleteLocalProjectAssetResult) => void;
|
||||
};
|
||||
|
||||
export function resourceDeleteErrorMessage(error: unknown) {
|
||||
if (typeof error === 'string' && error.trim()) return error;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return '删除资源失败';
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材删除流程:读引用信息 → 二次确认 → 落盘删除。
|
||||
*
|
||||
* 「编辑素材标签」面板与资源卡选中工具条**共用这一份实现**(同一个原生命令、同一个二次确认
|
||||
* 面板、同一套 `deleteReferencedVersions` 三分支),所以再没有第二份删除实现可以分叉。
|
||||
*/
|
||||
export function useResourceAssetDeleteFlow({
|
||||
projectPath,
|
||||
projectId,
|
||||
onError,
|
||||
onDeleted,
|
||||
}: UseResourceAssetDeleteFlowInput) {
|
||||
const [target, setTarget] = useState<ResourceAssetDeleteTarget | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [preparing, setPreparing] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [referencedVersions, setReferencedVersions] = useState<
|
||||
readonly ResourceAssetReferenceVersion[]
|
||||
>([]);
|
||||
const [deleteReferencedVersions, setDeleteReferencedVersions] =
|
||||
useState(false);
|
||||
|
||||
/**
|
||||
* 点删除先读引用信息,再打开独立确认面板;这一步不写盘。
|
||||
*/
|
||||
const requestDelete = useCallback(
|
||||
async (next: ResourceAssetDeleteTarget) => {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
onError('删除资源需要在客户端内执行');
|
||||
return;
|
||||
}
|
||||
setPreparing(true);
|
||||
setTarget(next);
|
||||
try {
|
||||
const references = await invoke<ReadLocalProjectAssetReferencesResult>(
|
||||
'read_local_project_asset_references',
|
||||
{ input: { projectPath, assetId: next.assetId } },
|
||||
);
|
||||
setReferencedVersions(references.versions);
|
||||
setDeleteReferencedVersions(false);
|
||||
setDialogOpen(true);
|
||||
} catch (referenceError) {
|
||||
setTarget(null);
|
||||
onError(resourceDeleteErrorMessage(referenceError));
|
||||
} finally {
|
||||
setPreparing(false);
|
||||
}
|
||||
},
|
||||
[onError, projectPath],
|
||||
);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
onError('删除资源需要在客户端内执行');
|
||||
return;
|
||||
}
|
||||
if (!target) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
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 invoke<DeleteLocalProjectAssetResult>(
|
||||
'delete_local_project_asset',
|
||||
{
|
||||
input: {
|
||||
projectPath,
|
||||
expectedProjectId: projectId,
|
||||
expectedProjectRevision: status.revision,
|
||||
assetId: target.assetId,
|
||||
deleteReferencedVersions,
|
||||
},
|
||||
},
|
||||
);
|
||||
setDialogOpen(false);
|
||||
setTarget(null);
|
||||
onDeleted(result);
|
||||
} catch (deleteError) {
|
||||
onError(resourceDeleteErrorMessage(deleteError));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [
|
||||
deleteReferencedVersions,
|
||||
onDeleted,
|
||||
onError,
|
||||
projectId,
|
||||
projectPath,
|
||||
target,
|
||||
]);
|
||||
|
||||
const closeDeleteDialog = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
setTarget(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
requestDelete,
|
||||
/** 读引用信息在途:入口按钮据此禁用,避免连点发出两次确认面板。 */
|
||||
preparing,
|
||||
deleting,
|
||||
/**
|
||||
* 现有确认面板的现成入参。没有待删除目标时为 `null`,宿主直接不渲染 `ResourceAssetDeleteDialog`。
|
||||
*/
|
||||
deleteDialogProps: target
|
||||
? {
|
||||
open: dialogOpen,
|
||||
localPath: target.localPath,
|
||||
referencedVersions,
|
||||
deleteReferencedVersions,
|
||||
deleting,
|
||||
onChangeDeleteReferencedVersions: setDeleteReferencedVersions,
|
||||
onClose: closeDeleteDialog,
|
||||
onConfirm: () => void confirmDelete(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user