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 316d481ea..1d5d6b309 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 @@ -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 { + const resourceByAssetId = new Map(); + 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(); - 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 []; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 6f853e8a5..bba6bc9f3 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -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`, - * 所以候选行只渲染稳定类型图标,不挂 ``、不出现破图。 + * 缩略图由资源卡同一条预览管线(`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; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx index ee2da6936..5350aec11 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx @@ -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(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 { 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( '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( - '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( - '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({

) : null} -