From d1581892d72ae927fb37910b6b5f82721d830f9f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 20:49:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20PR=20#316=20review?= =?UTF-8?q?=EF=BC=88=E5=89=8D=E7=AB=AF=20TS/TSX=EF=BC=89=EF=BC=9A=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E5=B7=A5=E4=BD=9C=E5=8F=B0=E4=B8=8E=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 素材重命名(跨范围契约):`rename_local_project_asset` 现在要求 `expectedProjectId` + `expectedProjectRevision`,前端按删除 / 分类保存同一套 CAS 口径补齐——先 `get_local_game_project_revision` 读当前 revision 再连同项目身份提交,不用可能过期的本地缓存值 - 失败文案统一:新增 `projectAssetCommandErrorMessage`,把 `project-identity-conflict` / `project-revision-conflict` 翻成用户可读中文,重命名 / 删除 / 分类保存三条链路共用一份映射,其余错误原样透出 - index.tsx:资源面板下载按 `resolveDownloadablePanelEntries` 过滤,版本卡这类合成 path 的条目不再进入落盘链路 - index.tsx:`characterAnimationPanel` 纳入画布浮层开关判据(抽 `resolveResourceCanvasFloatingPanelOpen`),「生成动画」面板与快速编辑 / 信息浮层共用同一条「点外部 / Esc 关闭」规则 - index.tsx:去掉 `ResourceBookScene` 上重复的 `onWheel`,滚轮只由 manager 上的原生 `passive:false` 监听处理,平移 / 缩放不再被翻倍 - index.tsx:快速编辑的源资源在层 id 命中不到时回落到已正规化的 `asset:`,重试不再静默什么都不做;取不到时给出可见失败 - ResourceCanvasPanelView.tsx:下载按钮按可下载条数判可用、不再在上传中显示下载转圈;上传中禁掉 × / Esc / 遮罩三条关闭路径;预览占位文案抽 helper - GameRunVersionPicker.tsx:portal 菜单在滚动 / 缩放后重算位置;外部点击判定沿用共享 `useImageCanvasFloatingOptionDismiss` + `menuRef` 边界(review 该条已修,保留现状并补位置用例) - ResourceCanvasGenerationPanelView.tsx:`submitting` 改 `finally` 收回,成功路径不再永久锁住面板 - ResourceAssetDeleteDialog.tsx / ResourceRenameDialog.tsx / ResourceClassificationPanel.tsx:在飞时 `closeOnEscape` / `closeOnBackdrop` 与头部 × 一起挡住 - resourceEditModel.ts / resourceCanvasToolbarModel.ts / useProjectResourceCardPreviews.ts / projectResourceLiveUpdateModel.ts:抽 `resourceBaseName`、`canonicalProjectedResourceMediaType` 参数收窄去掉强转、删未使用参数、补「IO 失败会 reject」的签名说明 - resourceCanvasHistoryModel.ts:`resourceCanvasSnapshotsEqual` 不再比较不可恢复的 `manuallyPlaced`,消除「压进历史但撤销是空操作」 - resourceCanvasSectionMapping.ts:删掉从未接线、且注释与实现相矛盾的 `LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK` - resourceCanvasChrome.css:`.game-resource-panel-grid` 补 `flex: 1 1 auto; min-height: 0`,卡片网格成为真正的滚动区 - 用例:重命名 CAS 载荷与读序、CAS 拒绝文案、版本卡下载门禁、上传中关闭路径、浮层判据、菜单跟随滚动、分区映射 --- .../resource-canvas/GameRunVersionPicker.tsx | 16 +- .../ResourceCanvasGenerationPanelView.tsx | 3 + .../ResourceCanvasPanelView.tsx | 51 +++-- .../resource-canvas/resourceCanvasChrome.css | 19 +- .../resourceCanvasFocusModel.ts | 26 ++- .../resourceCanvasHistoryModel.ts | 11 +- .../resourceCanvasToolbarModel.ts | 10 +- .../ResourceAssetDeleteDialog.tsx | 4 + .../ResourceClassificationPanel.tsx | 18 +- .../ResourceRenameDialog.tsx | 4 + .../src/view/project-development/index.tsx | 112 ++++++++-- .../projectAssetCommandErrorMessage.ts | 33 +++ .../projectResourceLiveUpdateModel.ts | 4 + .../resourceCanvasSectionMapping.ts | 15 -- .../project-development/resourceEditModel.ts | 27 ++- .../useProjectResourceCardPreviews.ts | 14 +- .../useResourceAssetDeleteFlow.ts | 9 +- .../tests/resourceAssetDeleteFlow.test.tsx | 5 +- .../resourceCanvasAssetTransfer.test.tsx | 198 ++++++++++++++++++ .../resourceCanvasFloatingDismiss.test.tsx | 39 ++++ .../resourceCanvasSectionMapping.test.ts | 12 +- .../tests/resourceRename.test.tsx | 93 +++++++- .../tests/resourceVersionSwitch.test.tsx | 49 +++++ 23 files changed, 668 insertions(+), 104 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/projectAssetCommandErrorMessage.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasAssetTransfer.test.tsx diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx index 05758b747..65a739217 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/GameRunVersionPicker.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { GameIterationVersion } from '../../../../../packages/shared/src/contracts/gameCreationApp'; @@ -24,6 +24,20 @@ export function GameRunVersionPicker({ }: GameRunVersionPickerProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); + // 菜单位置每次渲染都按触发按钮的实时 rect 重算;滚动 / 缩放(运行画面本身会缩放画布) + // 之后必须重算,否则 fixed 定位的菜单会漂在旧位置上。用一个 tick 驱动重算, + // 「打开当帧就有菜单」的时序不变。 + const [, setMenuAnchorTick] = useState(0); + useEffect(() => { + if (!open) return undefined; + const refreshMenuAnchor = () => setMenuAnchorTick((tick) => tick + 1); + window.addEventListener('scroll', refreshMenuAnchor, true); + window.addEventListener('resize', refreshMenuAnchor); + return () => { + window.removeEventListener('scroll', refreshMenuAnchor, true); + window.removeEventListener('resize', refreshMenuAnchor); + }; + }, [open]); /** * 菜单 portal 到 `document.body`(运行画面会缩放,挂 body 才不跟着画布跑偏), * DOM 上**不在** `rootRef` 子树里。所以它必须一起登记成「点这里不算外部」的边界: diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx index 5fc3b897e..161dbfee9 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -91,6 +91,9 @@ export function ResourceCanvasGenerationPanelView({ }); } catch (submitError) { setError(resourceGenerationErrorMessage(submitError)); + } finally { + // 成功路径也要收回在飞标记:宿主现在还靠卸载面板兜底,但组件本身不该 + // 在 `onSubmit` 正常 resolve 后永久停在「生成中…」并把关闭路径全部锁住。 setSubmitting(false); } } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx index d9400857d..5ac80692d 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasPanelView.tsx @@ -1,8 +1,19 @@ -import { Download, Loader2, Upload, X } from 'lucide-react'; +import { Download, Upload, X } from 'lucide-react'; import { useEffect, useId, useRef } from 'react'; import type { ResourceCanvasPanelEntry } from './resourceCanvasAssetTransferModel'; +/** 预览位占位文案:预览失败 → 载入中 → 退回素材类型名。 */ +function previewPlaceholder(entry: ResourceCanvasPanelEntry) { + if (entry.previewStatus === 'failed') { + return '预览失败'; + } + if (entry.previewStatus === 'loading') { + return '载入中…'; + } + return entry.typeLabel; +} + export type ResourceCanvasPanelViewProps = { entries: readonly ResourceCanvasPanelEntry[]; selectedResourceIds: readonly string[]; @@ -39,6 +50,11 @@ export function ResourceCanvasPanelView({ const selectedCount = entries.filter((entry) => selected.has(entry.resourceId), ).length; + // 「下载」只对真的能落盘的条目生效(版本卡没有真实文件,path 是合成展示串)。 + // 按钮按可下载条数判可用,避免「选中了、按钮亮着、点了没反应」。 + const selectedDownloadableCount = entries.filter( + (entry) => selected.has(entry.resourceId) && entry.downloadable, + ).length; useEffect(() => { closeButtonRef.current?.focus(); @@ -46,20 +62,24 @@ export function ResourceCanvasPanelView({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - onClose(); + // 上传在飞时不许关面板:关掉只是把这一份 view 卸下来,父级的上传还在跑, + // 进度与结果(含失败提示)都会被藏到下次打开面板才看得到。 + if (event.key !== 'Escape' || isUploading) { + return; } + event.preventDefault(); + onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [onClose]); + }, [isUploading, onClose]); return (
{ + if (isUploading) return; if (event.target === event.currentTarget) { onClose(); } @@ -80,6 +100,7 @@ export function ResourceCanvasPanelView({ ref={closeButtonRef} type="button" aria-label="关闭资源面板" + disabled={isUploading} onClick={onClose} >
@@ -164,11 +185,7 @@ export function ResourceCanvasPanelView({ ) : ( - {entry.previewStatus === 'failed' - ? '预览失败' - : entry.previewStatus === 'loading' - ? '载入中…' - : entry.typeLabel} + {previewPlaceholder(entry)} )} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css index 9ae3f4715..6057b2862 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasChrome.css @@ -38,8 +38,7 @@ * 图标键 `2.25rem` 方块、文字键靠同一高度撑开、分隔线 `1.125rem` 高。 * 早期实现多加了 `max-width` 与 `flex-wrap: wrap`,动作一多就折成两三层, * 与美术画布不一致,已移除。窄屏用横向滚动兜底,仍然只占一行。 */ -.game-resource-canvas-toolbar-host - .image-canvas-editor__floating-toolbar { +.game-resource-canvas-toolbar-host .image-canvas-editor__floating-toolbar { position: absolute; z-index: 60; display: inline-flex; @@ -815,14 +814,14 @@ * * 只覆盖美术画布没有的宿主条件(层级、锚点变换、窄屏宽度),其余全部沿用上面的 * 真实规则;早期那套 `display: flex` + `gap: 0.5rem` + 手写内边距的近似值已删除。 */ -.game-resource-canvas-toolbar-host - .image-canvas-editor__quick-edit-panel { +.game-resource-canvas-toolbar-host .image-canvas-editor__quick-edit-panel { z-index: 70; pointer-events: auto; } /* 信息浮层:与快速编辑浮层同一族(同一个锚点、同一档圆角与阴影), - * 但只读、更窄,所以不复用生成 composer 的栅格。 */.game-resource-canvas-toolbar-host .game-resource-info-panel { + * 但只读、更窄,所以不复用生成 composer 的栅格。 */ +.game-resource-canvas-toolbar-host .game-resource-info-panel { position: absolute; z-index: 70; display: grid; @@ -905,8 +904,7 @@ } @media (max-width: 720px) { - .game-resource-canvas-toolbar-host - .image-canvas-editor__floating-toolbar { + .game-resource-canvas-toolbar-host .image-canvas-editor__floating-toolbar { max-width: 94vw; } } @@ -918,6 +916,8 @@ display: flex; flex-direction: column; gap: 0.6rem; + /* 卡片网格是滚动区,面板自己不再裁剪出第二条滚动条。 */ + overflow: hidden; } .game-resource-panel-actions { @@ -978,6 +978,11 @@ } .game-resource-panel-grid { + /* 面板是 column flex + max-height 的容器:这里必须能收缩(min-height: 0) + 并吃掉剩余高度,否则 flex 项的 min-height: auto 会撑到内容全高, + `overflow-y: auto` 永远不触发,卡片会溢出面板。 */ + flex: 1 1 auto; + min-height: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.6rem; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index 6096de61c..186eae60d 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -5,8 +5,7 @@ * 的 `clearCanvasFocus()` 与 `src/components/image-editor/useImageCanvasKeyboardShortcuts.ts` * 的 Escape 分支),AGC 不再自带第二套口径: * - * - 画布空白处左键 pointerdown → 清焦点(清空选中 + 关闭画布浮层:快速编辑与信息); - * - 点在资源卡、交互控件或画布浮层里 → 不清焦点; + * - 画布空白处左键 pointerdown → 清焦点(清空选中 + 关闭画布浮层:快速编辑与信息); * - 点在资源卡、交互控件或画布浮层里 → 不清焦点; * - 选中变化 / 切项目 / 切模式 → 由宿主既有的重置逻辑负责。 */ @@ -71,6 +70,29 @@ export function isResourceCanvasHostOverlayOpen( ); } +/** + * 画布上的浮层是否开着:快速编辑、资源信息与「生成动画」。 + * + * 三者在宿主里是同一类浮层,必须共用这一条判据(它同时决定「点外部 / Esc 关闭」是否接管、 + * 以及 Esc 是否归浮层所有)。少算一个就会出现「只有一个浮层开着时点外部收不掉、Esc 像没生效」—— + * 「生成动画」此前就是这样被漏掉的。 + */ +export function resolveResourceCanvasFloatingPanelOpen({ + isQuickEditPanelOpen, + isResourceInfoPanelOpen, + isCharacterAnimationPanelOpen, +}: { + isQuickEditPanelOpen: boolean; + isResourceInfoPanelOpen: boolean; + isCharacterAnimationPanelOpen: boolean; +}): boolean { + return ( + isQuickEditPanelOpen || + isResourceInfoPanelOpen || + isCharacterAnimationPanelOpen + ); +} + /** * 画布浮层(快速编辑 / 信息)是否接管「点外部 / Esc 关闭」。 * diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts index 260ed1aa6..9ee3d1ee4 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasHistoryModel.ts @@ -79,6 +79,14 @@ export function captureResourceCanvasSnapshot( return { entries }; } +/** + * 两份快照是否算「同一版布局」。 + * + * 只比 `section / x / y`:撤销写回走 `commitPosition(resourceId, section, x, y)`, + * `manuallyPlaced` 不在写回入参里(`moveResourceCanvasPosition` 自己会置 true), + * 所以它本来就不是可恢复的维度。把它算进相等判定,只会在「只有这个标记不同」时压进一条 + * 历史,而 `resolveResourceCanvasRestoreEntries` 按坐标过滤后返回空表 —— 撤销变成静默空操作。 + */ export function resourceCanvasSnapshotsEqual( left: ResourceCanvasLayoutSnapshot, right: ResourceCanvasLayoutSnapshot, @@ -93,8 +101,7 @@ export function resourceCanvasSnapshotsEqual( entry.resourceId === other.resourceId && entry.section === other.section && entry.x === other.x && - entry.y === other.y && - entry.manuallyPlaced === other.manuallyPlaced + entry.y === other.y ); }); } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts index f5a058fd9..ade2d2d8e 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasToolbarModel.ts @@ -38,10 +38,7 @@ const CANVAS_ASSET_KINDS = new Set([ export function resourceCanvasMediaType( resource: Pick, ): CanvasMediaType | undefined { - const mediaType = canonicalProjectedResourceMediaType({ - mediaType: resource.mediaType, - path: resource.path, - } as ProjectResource); + const mediaType = canonicalProjectedResourceMediaType(resource); if (mediaType.startsWith('image/')) { return 'image'; } @@ -69,10 +66,7 @@ export function isResourceRasterImage( resource: Pick, ) { return RESOURCE_RASTER_MEDIA_TYPES.has( - canonicalProjectedResourceMediaType({ - mediaType: resource.mediaType, - path: resource.path, - } as ProjectResource), + canonicalProjectedResourceMediaType(resource), ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceAssetDeleteDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceAssetDeleteDialog.tsx index 0c86df9a7..9e0b9ff45 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceAssetDeleteDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceAssetDeleteDialog.tsx @@ -47,6 +47,10 @@ export function ResourceAssetDeleteDialog({ open={open} ariaLabel="确认删除资源" onClose={onClose} + // 删除在飞时头部 × / 取消 / 删除都已禁用,Escape 与遮罩也必须一起挡住, + // 否则面板看着像"取消了",后台的删除其实还在跑。 + closeOnBackdrop={!deleting} + closeOnEscape={!deleting} panelClassName="game-approval-dialog game-resource-delete-dialog" >
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 133ad07ff..0f209158c 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 @@ -18,6 +18,7 @@ import { } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { ThemedModal } from '../../components/modal/ThemedModal'; import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences'; +import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage'; type UpdateLocalProjectResourceClassificationResult = { asset: GameCreationAppAssetManifestEntry; @@ -67,9 +68,9 @@ function resourceAssetDisplayName(localPath: string) { } function resourceClassificationErrorMessage(error: unknown) { - if (typeof error === 'string' && error.trim()) return error; - if (error instanceof Error && error.message) return error.message; - return '保存素材标签失败'; + // 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出; + // 与重命名、删除共用同一份映射。 + return projectAssetCommandErrorMessage(error, '保存素材标签失败'); } type ResourceClassificationPanelProps = { @@ -191,6 +192,10 @@ export function ResourceClassificationPanel({ open ariaLabel="编辑素材标签" onClose={onClose} + // 保存在飞时不许用 Escape / 点遮罩把面板关掉:关掉后迟到的 `onSaved` + // 会打到一个已经卸载的面板上。头部 × 同样按 `saving` 禁用。 + closeOnBackdrop={!saving} + closeOnEscape={!saving} panelClassName="game-approval-dialog game-resource-classification-dialog" >
@@ -198,7 +203,12 @@ export function ResourceClassificationPanel({

编辑素材标签

{resourceAssetDisplayName(asset.localPath)}

-
diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceRenameDialog.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceRenameDialog.tsx index 71b54e108..265b745f4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceRenameDialog.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceRenameDialog.tsx @@ -46,6 +46,10 @@ export function ResourceRenameDialog({ open={open} ariaLabel="重命名素材" onClose={onClose} + // 重命名在飞时头部 × / 取消 / 重命名都已禁用,Escape 与遮罩也必须一起挡住, + // 保持同一个"在飞不可关闭"的判据。 + closeOnBackdrop={!renaming} + closeOnEscape={!renaming} panelClassName="game-approval-dialog game-resource-rename-dialog" >
diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index a63058a09..1efd206bc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -109,6 +109,7 @@ import { defaultResourceExportFileName, isResourceCanvasExportable, isUploadableResourceFile, + resolveDownloadablePanelEntries, resolveResourceCanvasPanelEntries, resolveSelectedPanelEntries, } from '../../features/resource-canvas/resourceCanvasAssetTransferModel'; @@ -116,6 +117,7 @@ import { canDismissResourceCanvasQuickEdit, isResourceCanvasInteractionTarget, resolveResourceCanvasFloatingPanelDismissOpen, + resolveResourceCanvasFloatingPanelOpen, resolveResourceCanvasFocusEscapeActive, } from '../../features/resource-canvas/resourceCanvasFocusModel'; import { @@ -172,6 +174,7 @@ import { } from '../../services/platformSession'; import UiEditorPage from '../ui-editor'; import type { UiEditorStepId } from '../ui-editor/model'; +import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage'; import { type ProjectManifestSnapshotMetadata, resolveResourceFocusIntent, @@ -1136,7 +1139,6 @@ function ResourceBookScene({ onPointerMove, onPointerUp, onPointerCancel, - onWheel, }: { plan: ResourceBookSceneCategoryPlan[]; resourceBookState: ResourceBookState; @@ -1166,7 +1168,6 @@ function ResourceBookScene({ onPointerMove: (event: ReactPointerEvent) => void; onPointerUp: (event: ReactPointerEvent) => void; onPointerCancel: (event: ReactPointerEvent) => void; - onWheel: (event: ReactWheelEvent) => void; }) { const sceneViewport = normalizeResourceBookViewport( resourceBookState.view === 'main' ? mainViewport : viewport, @@ -1220,7 +1221,6 @@ function ResourceBookScene({ onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} - onWheel={onWheel} >
0, - isFloatingPanelOpen: quickEditPanel !== null || resourceInfoPanelOpen, + isFloatingPanelOpen: isResourceCanvasFloatingPanelOpen, hostOverlay: { isResourcePanelOpen: resourcePanelOpen, isGenerationPanelOpen: resourceGenerationOpen, @@ -1737,11 +1747,10 @@ export default function ProjectDevelopmentView({ return () => window.removeEventListener('keydown', handleKeyDown); }, [ clearResourceCanvasFocus, + isResourceCanvasFloatingPanelOpen, mode, - quickEditPanel, resourceClassificationAssetId, resourceGenerationOpen, - resourceInfoPanelOpen, resourcePanelOpen, resourceRecoveryPanelOpen, resourceRenameAssetId, @@ -2904,7 +2913,10 @@ export default function ProjectDevelopmentView({ }); /** * 素材重命名:只改磁盘文件名与 manifest 的 `localPath`,资产 id 不变。 - * Rust 入参是 `deny_unknown_fields` 的结构体,这里必须只传这三个字段。 + * + * Rust 入参是 `deny_unknown_fields` 结构体,且与删除 / 分类保存同一套 CAS 口径: + * 先读当前项目 revision 再带着项目身份一起提交。**不能用本地缓存的 revision**: + * 本地值落后时后端会按「陈旧改写」直接拒收。 */ const confirmResourceRename = useCallback( async (newFileName: string) => { @@ -2918,11 +2930,20 @@ export default function ProjectDevelopmentView({ setResourceRenaming(true); setResourceRenameError(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( 'rename_local_project_asset', { input: { projectPath, + expectedProjectId: manifest.projectId, + expectedProjectRevision: status.revision, assetId: asset.id, newFileName, }, @@ -2933,16 +2954,19 @@ export default function ProjectDevelopmentView({ `asset-rename:${asset.id}`, ); } catch (renameError) { - const message = - renameError instanceof Error - ? renameError.message - : String(renameError); - setResourceRenameError(message.trim() || '重命名素材失败'); + setResourceRenameError( + projectAssetCommandErrorMessage(renameError, '重命名素材失败'), + ); } finally { setResourceRenaming(false); } }, - [projectPath, reloadManifestAfterAssetCommand, resourceRenameAsset], + [ + manifest.projectId, + projectPath, + reloadManifestAfterAssetCommand, + resourceRenameAsset, + ], ); const hasRegisteredArtImageAssets = manifest.assets.some( (asset) => @@ -4111,7 +4135,12 @@ export default function ProjectDevelopmentView({ const downloadResourcePanelEntries = useCallback( async (targets: typeof resourcePanelEntries) => { - await saveProjectResourcesToDisk(targets, setResourcePanelNotice); + // 版本卡这类条目的 `path` 是合成展示串,没有真实文件可复制; + // 统一在这里按 `downloadable` 过一遍,别让合成路径走到落盘链路。 + await saveProjectResourcesToDisk( + resolveDownloadablePanelEntries(targets), + setResourcePanelNotice, + ); }, [saveProjectResourcesToDisk], ); @@ -4465,6 +4494,10 @@ export default function ProjectDevelopmentView({ if (!manager) return; // React wheel listeners are passive; canvas gestures must suppress native // scrolling/zooming even when all content has been panned off screen. + // + // 只在这一处登记:画本场景活在这个 manager 子树里,再给 `` + // 挂一个 React `onWheel` 就会同一次滚动跑两遍——第二遍读到的是已经被第一遍改过的 + // 视口 ref,平移 / 缩放都会被翻倍。 manager.addEventListener('wheel', handleResourceBookWheel, { passive: false, }); @@ -5859,6 +5892,35 @@ export default function ProjectDevelopmentView({ [applyResourceQuickEditPrompt], ); + /** + * 快速编辑的源资源。 + * + * 先按层 id 找;找不到时回落到上一次已经正规化出来的正式素材:正规化成功后 + * `onManifestChange` 会把资源重投影成 `asset:`,而 `quickEditSourceLayer.id` + * 仍是正规化前那个 id(`task:…`),重试时按旧 id 查必然落空——早期返回就成了 + * 「提交按钮点了没反应」。 + */ + const resolveQuickEditSourceResource = useCallback( + (layer: CanvasLayer | null) => { + if (!layer) { + return undefined; + } + const byLayerId = canvasResources.find((item) => item.id === layer.id); + if (byLayerId) { + return byLayerId; + } + const normalizedAssetId = + resourceQuickEditRequestRef.current?.normalizedAssetId; + if (!normalizedAssetId) { + return undefined; + } + return canvasResources.find( + (item) => item.id === `asset:${normalizedAssetId}`, + ); + }, + [canvasResources], + ); + /** * 快速编辑:先按后端真实门禁把任务产物正规化成正式素材,再走资源派生产出一张新素材。 * 源素材(文件与 manifest 条目)保持不变。 @@ -5866,9 +5928,7 @@ export default function ProjectDevelopmentView({ const submitResourceQuickEdit = useCallback(async () => { const panel = quickEditPanel; const layer = quickEditSourceLayer; - const resource = layer - ? canvasResources.find((item) => item.id === layer.id) - : undefined; + const resource = resolveQuickEditSourceResource(layer); const invoke = window.__TAURI__?.core?.invoke; const prompt = panel?.prompt.trim() ?? ''; if (!panel || !layer || !resource || !invoke || !prompt) { @@ -5883,6 +5943,19 @@ export default function ProjectDevelopmentView({ : current, ); } + // 源资源已经不在画布上(被删除 / 重投影掉):给出可见失败, + // 而不是让提交按钮静默什么都不做。 + if (panel && layer && !resource && invoke) { + setQuickEditPanel((current) => + current + ? { + ...current, + status: 'failed', + errorMessage: '源素材已不在画布上,请重新打开快速编辑', + } + : current, + ); + } return; } const actionProject = { projectPath, projectId: manifest.projectId }; @@ -5986,12 +6059,12 @@ export default function ProjectDevelopmentView({ ); } }, [ - canvasResources, manifest, onManifestChange, projectPath, quickEditPanel, quickEditSourceLayer, + resolveQuickEditSourceResource, resolveResourceDeriveSource, ]); @@ -6910,7 +6983,6 @@ export default function ProjectDevelopmentView({ onPointerMove={handleResourceCanvasPointerMove} onPointerUp={stopResourceCanvasPan} onPointerCancel={stopResourceCanvasPan} - onWheel={handleResourceBookWheel} />