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}
-