修复 PR #316 review(前端 TS/TSX):资源工作台与资源画布

- 素材重命名(跨范围契约):`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:<id>`,重试不再静默什么都不做;取不到时给出可见失败
- 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 拒绝文案、版本卡下载门禁、上传中关闭路径、浮层判据、菜单跟随滚动、分区映射
This commit is contained in:
2026-09-12 20:49:03 +08:00
parent d04fe83f10
commit d1581892d7
23 changed files with 668 additions and 104 deletions
@@ -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<HTMLDivElement>(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` 子树里。所以它必须一起登记成「点这里不算外部」的边界:
@@ -91,6 +91,9 @@ export function ResourceCanvasGenerationPanelView({
});
} catch (submitError) {
setError(resourceGenerationErrorMessage(submitError));
} finally {
// 成功路径也要收回在飞标记:宿主现在还靠卸载面板兜底,但组件本身不该
// 在 `onSubmit` 正常 resolve 后永久停在「生成中…」并把关闭路径全部锁住。
setSubmitting(false);
}
}
@@ -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 (
<div
className="game-approval-backdrop"
role="presentation"
onMouseDown={(event) => {
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}
>
<X size={16} aria-hidden="true" />
@@ -123,14 +144,14 @@ export function ResourceCanvasPanelView({
type="button"
className="game-resource-panel-download"
onClick={onDownloadSelection}
disabled={selectedCount === 0 || isUploading}
disabled={selectedDownloadableCount === 0 || isUploading}
>
{isUploading ? (
<Loader2 size={15} className="animate-spin" aria-hidden="true" />
) : (
<Download size={15} aria-hidden="true" />
)}
{selectedCount > 1 ? `下载选中(${selectedCount})` : '下载'}
{/* 上传中的进行态归「上传素材」那一颗(文案已经是「上传中…」),
这里再转一圈会让禁用的下载按钮看起来在下载。 */}
<Download size={15} aria-hidden="true" />
{selectedDownloadableCount > 1
? `下载选中(${selectedDownloadableCount})`
: '下载'}
</button>
</div>
@@ -164,11 +185,7 @@ export function ResourceCanvasPanelView({
<img src={entry.previewSourceUrl} alt="" />
) : (
<span className="game-resource-panel-preview-placeholder">
{entry.previewStatus === 'failed'
? '预览失败'
: entry.previewStatus === 'loading'
? '载入中…'
: entry.typeLabel}
{previewPlaceholder(entry)}
</span>
)}
</button>
@@ -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;
@@ -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 关闭」。
*
@@ -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
);
});
}
@@ -38,10 +38,7 @@ const CANVAS_ASSET_KINDS = new Set<string>([
export function resourceCanvasMediaType(
resource: Pick<ProjectResource, 'mediaType' | 'subtype' | 'path'>,
): 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<ProjectResource, 'mediaType' | 'category' | 'path'>,
) {
return RESOURCE_RASTER_MEDIA_TYPES.has(
canonicalProjectedResourceMediaType({
mediaType: resource.mediaType,
path: resource.path,
} as ProjectResource),
canonicalProjectedResourceMediaType(resource),
);
}
@@ -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"
>
<header>
@@ -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"
>
<header>
@@ -198,7 +203,12 @@ export function ResourceClassificationPanel({
<h2>编辑素材标签</h2>
<p>{resourceAssetDisplayName(asset.localPath)}</p>
</div>
<button type="button" aria-label="关闭编辑素材标签" onClick={onClose}>
<button
type="button"
aria-label="关闭编辑素材标签"
disabled={saving}
onClick={onClose}
>
×
</button>
</header>
@@ -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"
>
<header>
@@ -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<HTMLDivElement>) => void;
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;
onWheel: (event: ReactWheelEvent<HTMLDivElement>) => void;
}) {
const sceneViewport = normalizeResourceBookViewport(
resourceBookState.view === 'main' ? mainViewport : viewport,
@@ -1220,7 +1221,6 @@ function ResourceBookScene({
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onWheel={onWheel}
>
<div
className="game-resource-book-scene-world"
@@ -1686,10 +1686,20 @@ export default function ProjectDevelopmentView({
* 提示词输入区里点一个候选资源,DOM 上确实落在画布管理区之外,不登记就
* 会把正在编辑的快速编辑面板一起收掉。
*/
// 快速编辑、资源信息与「生成动画」都是画布上的浮层:同一个开关口径既决定
// 「点外部 / Esc 关闭」是否接管,也决定 Esc 是否归浮层所有。三处共享这一份,
// 少算一个就会出现「只有一个浮层开着时点外部收不掉、Esc 只当没看见」。
const isResourceCanvasFloatingPanelOpen =
resolveResourceCanvasFloatingPanelOpen({
isQuickEditPanelOpen: quickEditPanel !== null,
isResourceInfoPanelOpen: resourceInfoPanelOpen,
isCharacterAnimationPanelOpen: characterAnimationPanel !== null,
});
useImageCanvasFloatingOptionDismiss({
isOpen: resolveResourceCanvasFloatingPanelDismissOpen({
isCanvasVisible: mode === 'resources' && !uiEditorRoute,
isFloatingPanelOpen: quickEditPanel !== null || resourceInfoPanelOpen,
isFloatingPanelOpen: isResourceCanvasFloatingPanelOpen,
hostOverlay: {
isResourcePanelOpen: resourcePanelOpen,
isGenerationPanelOpen: resourceGenerationOpen,
@@ -1716,7 +1726,7 @@ export default function ProjectDevelopmentView({
!resolveResourceCanvasFocusEscapeActive({
isCanvasVisible: mode === 'resources' && !uiEditorRoute,
hasSelectedResource: selectedResourceIds.length > 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<RenameLocalProjectAssetResult>(
'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 子树里,再给 `<ResourceBookScene>`
// 挂一个 React `onWheel` 就会同一次滚动跑两遍——第二遍读到的是已经被第一遍改过的
// 视口 ref,平移 / 缩放都会被翻倍。
manager.addEventListener('wheel', handleResourceBookWheel, {
passive: false,
});
@@ -5859,6 +5892,35 @@ export default function ProjectDevelopmentView({
[applyResourceQuickEditPrompt],
);
/**
* 快速编辑的源资源。
*
* 先按层 id 找;找不到时回落到上一次已经正规化出来的正式素材:正规化成功后
* `onManifestChange` 会把资源重投影成 `asset:<id>`,而 `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}
/>
<div className="game-resource-book-zoom">
<button
@@ -0,0 +1,33 @@
/**
* 素材命令(重命名 / 删除 / 分类保存)失败原因的统一出口。
*
* Rust 侧的项目身份 / 版本 CAS 拒绝是**结构化错误码**(`project-identity-conflict` /
* `project-revision-conflict`):直接透出就是给用户看一串代码。这里只翻译已知的码,
* 其余错误原样透出 —— 宁可显示后端原文,也不把原因吞掉换成「操作失败」这种无信息文案。
*
* 口径与 `resourceVersionReplacementErrorMessage` 里同样的两条映射逐字一致,
* 三条链路给用户的说法必须一样。
*/
export function projectAssetCommandErrorMessage(
error: unknown,
fallback: string,
): string {
let message = '';
if (typeof error === 'string') {
message = error.trim();
} else if (error instanceof Error) {
message = error.message.trim();
} else if (error !== null && error !== undefined) {
message = String(error).trim();
}
if (!message) {
return fallback;
}
if (message.includes('project-revision-conflict')) {
return '项目已被其它操作改动,请刷新后重试';
}
if (message.includes('project-identity-conflict')) {
return '项目身份不一致,请重新打开项目后再试';
}
return message;
}
@@ -234,6 +234,10 @@ export type ProjectManifestRereadInput = {
*
* 两次读 revision 夹一次读清单:中间有写盘落地就说明读到的是撕裂的一对,
* 直接放弃本次恢复(下一次快照会带上新的 revision),不猜、不凑。
*
* 返回 `null` **只表示读到了、但不能采用**(revision 非法、projectId 不一致、
* 两次 revision 对不上)。IO / IPC 失败会照旧抛出:调用方要能把"读失败"与
* "读到但不可采用"分开呈现(例如上传成功后配对读失败必须报错,而不是静默不同步)。
*/
export async function rereadAuthoritativeProjectManifestSnapshot(
input: ProjectManifestRereadInput,
@@ -25,21 +25,6 @@ export const LEGACY_RESOURCE_CANVAS_SECTION_TARGETS: Record<
code: ['unclassified'],
};
/**
* 无法精确归并时回落到的栏目。当前分区不在目标集合内(例如资产后来被重新分类)时,
* 该坐标按"确实无从归并"处理:不做坐标迁移、不重置其余坐标。
*/
export const LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK: Record<
LegacyProjectResourceCanvasSection,
ProjectResourceCanvasCategory
> = {
document: 'document',
audio: 'audio',
version: 'version',
art: 'unclassified',
code: 'unclassified',
};
/**
* 把磁盘上的 `section` 值归一到现行分区。
*
@@ -94,7 +94,15 @@ function pathExtension(path: string) {
return path.split(/[?#]/u, 1)[0]?.split('.').pop()?.toLowerCase() ?? '';
}
export function canonicalProjectedResourceMediaType(resource: ProjectResource) {
/**
* 规范化的媒体类型:只读 `mediaType` 与 `path`。
*
* 参数按实际读到的字段收窄(而不是收整个 `ProjectResource`),调用方拿完整资源也兼容,
* 但少一层 `as ProjectResource` 强转:以后再读别的字段会当场编译报错,而不是拿到 `undefined`。
*/
export function canonicalProjectedResourceMediaType(
resource: Pick<ProjectResource, 'mediaType' | 'path'>,
) {
const declared = resource.mediaType.trim().toLowerCase();
if (/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/u.test(declared)) {
return declared;
@@ -189,9 +197,19 @@ export function resolveProjectCharacterAnimationCapability(
};
}
/**
* 资源名去掉扩展名后的基名(找不到可用名字时返回空串,由各自的后缀规则补兜底名)。
*
* 派生资源的命名规则共用这一条,避免「-编辑版」与「-角色动画」在去扩展名的口径上分叉。
*/
export function resourceBaseName(
resource: Pick<ProjectResource, 'label'>,
): string {
return resource.label.replace(/\.[^.]+$/u, '').trim();
}
export function defaultDerivedResourceName(resource: ProjectResource) {
const baseName = resource.label.replace(/\.[^.]+$/u, '').trim();
return `${baseName || '资源'}-编辑版`;
return `${resourceBaseName(resource) || '资源'}-编辑版`;
}
/**
@@ -203,8 +221,7 @@ export function defaultDerivedResourceName(resource: ProjectResource) {
export function defaultCharacterAnimationResourceName(
resource: ProjectResource,
) {
const baseName = resource.label.replace(/\.[^.]+$/u, '').trim();
return `${baseName || '资源'}-角色动画`;
return `${resourceBaseName(resource) || '资源'}-角色动画`;
}
export function resourceEditPromptMaxLength(
@@ -87,10 +87,7 @@ type ObservedPreviewCard = {
resource: ProjectResource;
};
function resourceReadKindLabel(
resource: ProjectResource,
kind: ProjectResourceCardPreviewKind,
) {
function resourceReadKindLabel(kind: ProjectResourceCardPreviewKind) {
if (kind === 'document') {
return '文档';
}
@@ -101,12 +98,11 @@ function resourceReadKindLabel(
}
function previewReadErrorMessage(
resource: ProjectResource,
kind: ProjectResourceCardPreviewKind,
error: unknown,
): { error: string; retryable: boolean } {
const message = error instanceof Error ? error.message : String(error);
const kindLabel = resourceReadKindLabel(resource, kind);
const kindLabel = resourceReadKindLabel(kind);
if (message.includes('项目权限策略要求用户确认')) {
return {
error: `当前项目策略要求先确认读取${kindLabel},确认后请关闭详情并重试`,
@@ -591,11 +587,7 @@ export function useProjectResourceCardPreviews(input: {
isCurrentJob(job) &&
!isProjectResourcePreviewCancellation(error)
) {
const failure = previewReadErrorMessage(
job.resource,
jobPreviewKind,
error,
);
const failure = previewReadErrorMessage(jobPreviewKind, error);
publishPreview(job.identity, {
status: 'failed',
...failure,
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage';
import type { ResourceAssetReferenceVersion } from './ResourceAssetDeleteDialog';
/**
@@ -36,10 +37,12 @@ type UseResourceAssetDeleteFlowInput = {
onDeleted: (result: DeleteLocalProjectAssetResult) => void;
};
/**
* 删除失败原因:项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出。
* 与重命名、分类保存共用同一份映射(`projectAssetCommandErrorMessage`)。
*/
export function resourceDeleteErrorMessage(error: unknown) {
if (typeof error === 'string' && error.trim()) return error;
if (error instanceof Error && error.message) return error.message;
return '删除资源失败';
return projectAssetCommandErrorMessage(error, '删除资源失败');
}
/**
@@ -307,7 +307,10 @@ describe('useResourceAssetDeleteFlow 删除素材', () => {
await user.click(screen.getByRole('button', { name: '确认删除资源' }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('project-revision-conflict'),
// 结构化 CAS 错误码翻成用户可读中文:与重命名、分类保存同一份映射。
expect(onError).toHaveBeenCalledWith(
'项目已被其它操作改动,请刷新后重试',
),
);
expect(screen.getByRole('dialog', { name: '确认删除资源' })).not.toBeNull();
expect(onDeleted).not.toHaveBeenCalled();
@@ -0,0 +1,198 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
within,
} from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
isResourceCanvasExportable,
resolveDownloadablePanelEntries,
resolveResourceCanvasPanelEntries,
resolveSelectedPanelEntries,
} from '../src/features/resource-canvas/resourceCanvasAssetTransferModel';
import { ResourceCanvasPanelView } from '../src/features/resource-canvas/ResourceCanvasPanelView';
import type {
ProjectResource,
ProjectVersionResourceSummary,
} from '../src/view/project-development/resourceProjectionModel';
afterEach(cleanup);
function createResource(
overrides: Partial<ProjectResource> = {},
): ProjectResource {
return {
id: 'asset:hero',
category: 'character',
subtype: 'character',
label: '主角立绘',
path: 'assets/hero.png',
mediaType: 'image/png',
sourceLabel: '生成',
taskTitle: null,
manifestAssetId: 'hero',
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
...overrides,
};
}
/** 虚拟版本条目:只有版本摘要与合成展示路径,没有对应文件。 */
function createVersionResource(): ProjectResource {
const version: ProjectVersionResourceSummary = {
versionId: 'version-1',
parentVersionId: null,
projectRevision: 3,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
label: '版本 1',
childVersionIds: [],
};
return createResource({
id: 'version:version-1',
category: 'version',
subtype: 'version',
label: '项目版本 · 版本 1',
path: '',
mediaType: 'application/vnd.genarrative.project-version+json',
manifestAssetId: null,
version,
});
}
function panelEntries() {
return resolveResourceCanvasPanelEntries([
{
resource: createResource(),
categoryLabel: '角色与对象',
typeLabel: '图片',
previewIdentity: null,
previewStatus: 'idle',
previewSourceUrl: null,
previewError: null,
},
{
resource: createVersionResource(),
categoryLabel: '项目版本',
typeLabel: '版本',
previewIdentity: null,
previewStatus: 'idle',
previewSourceUrl: null,
previewError: null,
},
]);
}
describe('资源面板下载口径', () => {
it('虚拟版本条目不可下载:合成路径不许走到落盘链路', () => {
const entries = panelEntries();
const hero = entries[0]!;
const version = entries[1]!;
expect(isResourceCanvasExportable(hero)).toBe(true);
expect(hero.downloadable).toBe(true);
expect(version.downloadable).toBe(false);
// 下载目标只保留真能落盘的条目:版本卡的 path 是合成展示串,
// 传给 `save_local_project_asset_file` 只会失败。
expect(
resolveDownloadablePanelEntries(
resolveSelectedPanelEntries(entries, [
hero.resourceId,
version.resourceId,
]),
).map((entry) => entry.resourceId),
).toEqual([hero.resourceId]);
});
it('只选中不可下载条目时下载按钮保持禁用,不会变成点了没反应', () => {
const onDownloadSelection = vi.fn();
const entries = panelEntries();
const version = entries[1]!;
render(
<ResourceCanvasPanelView
entries={entries}
selectedResourceIds={[version.resourceId]}
onToggleEntry={vi.fn()}
onSelectAll={vi.fn()}
onClearSelection={vi.fn()}
onUploadFiles={vi.fn()}
onDownloadSelection={onDownloadSelection}
isUploading={false}
notice=""
onClose={vi.fn()}
/>,
);
const download = within(
screen.getByRole('dialog', { name: '资源面板' }),
).getByRole('button', { name: '下载' }) as HTMLButtonElement;
expect(download.disabled).toBe(true);
fireEvent.click(download);
expect(onDownloadSelection).not.toHaveBeenCalled();
});
it('可下载条目选中时按钮可用,并且上传中把三条关闭路径一起挡住', () => {
const onDownloadSelection = vi.fn();
const onClose = vi.fn();
const entries = panelEntries();
const hero = entries[0]!;
const { rerender } = render(
<ResourceCanvasPanelView
entries={entries}
selectedResourceIds={[hero.resourceId]}
onToggleEntry={vi.fn()}
onSelectAll={vi.fn()}
onClearSelection={vi.fn()}
onUploadFiles={vi.fn()}
onDownloadSelection={onDownloadSelection}
isUploading={false}
notice=""
onClose={onClose}
/>,
);
const panel = () => screen.getByRole('dialog', { name: '资源面板' });
fireEvent.click(within(panel()).getByRole('button', { name: '下载' }));
expect(onDownloadSelection).toHaveBeenCalledTimes(1);
rerender(
<ResourceCanvasPanelView
entries={entries}
selectedResourceIds={[hero.resourceId]}
onToggleEntry={vi.fn()}
onSelectAll={vi.fn()}
onClearSelection={vi.fn()}
onUploadFiles={vi.fn()}
onDownloadSelection={onDownloadSelection}
isUploading
notice=""
onClose={onClose}
/>,
);
// 上传在飞:× 禁用、Escape 与遮罩都不许把面板关掉(父级上传还在跑)。
expect(
(
within(panel()).getByRole('button', {
name: '关闭资源面板',
}) as HTMLButtonElement
).disabled,
).toBe(true);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
fireEvent.mouseDown(panel().parentElement as HTMLElement);
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -12,6 +12,7 @@ import {
isResourceCanvasHostOverlayOpen,
isResourceCanvasInteractionTarget,
resolveResourceCanvasFloatingPanelDismissOpen,
resolveResourceCanvasFloatingPanelOpen,
resolveResourceCanvasFocusEscapeActive,
} from '../src/features/resource-canvas/resourceCanvasFocusModel';
import { createResourceQuickEditPanelDraft } from '../src/features/resource-canvas/resourceCanvasQuickEditModel';
@@ -118,6 +119,44 @@ describe('resourceCanvasFocusModel', () => {
}
});
test('快速编辑 / 资源信息 / 生成动画任一浮层开着都算画布浮层打开', () => {
// 三种浮层共用一条开关判据:少算「生成动画」时,只有它开着的那一帧
// `resolveResourceCanvasFloatingPanelDismissOpen` 会判 false,点外部就收不掉面板。
expect(
resolveResourceCanvasFloatingPanelOpen({
isQuickEditPanelOpen: false,
isResourceInfoPanelOpen: false,
isCharacterAnimationPanelOpen: false,
}),
).toBe(false);
for (const panel of [
{ isQuickEditPanelOpen: true },
{ isResourceInfoPanelOpen: true },
{ isCharacterAnimationPanelOpen: true },
] as const) {
const isFloatingPanelOpen = resolveResourceCanvasFloatingPanelOpen({
isQuickEditPanelOpen: false,
isResourceInfoPanelOpen: false,
isCharacterAnimationPanelOpen: false,
...panel,
});
expect(isFloatingPanelOpen).toBe(true);
expect(
resolveResourceCanvasFloatingPanelDismissOpen({
isCanvasVisible: true,
isFloatingPanelOpen,
hostOverlay: {
isResourcePanelOpen: false,
isGenerationPanelOpen: false,
isClassificationPanelOpen: false,
isRenameDialogOpen: false,
isRecoveryPanelOpen: false,
},
}),
).toBe(true);
}
});
test('画布可见且画布浮层打开时才接管点外部关闭', () => {
const closed = {
isResourcePanelOpen: false,
@@ -6,7 +6,6 @@ import {
type ProjectResourceCanvasCategory,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK,
LEGACY_RESOURCE_CANVAS_SECTION_TARGETS,
normalizeResourceCanvasPosition,
resolveResourceCanvasSection,
@@ -31,16 +30,13 @@ describe('资源画布分区轴与新旧栏目映射', () => {
]);
});
it('每个旧栏目值都有归并目标,且回落栏目本身属于目标集合', () => {
it('每个旧栏目值都有归并目标,且目标都落在现行分区里', () => {
for (const legacy of LEGACY_PROJECT_RESOURCE_CANVAS_SECTIONS) {
const targets = LEGACY_RESOURCE_CANVAS_SECTION_TARGETS[legacy];
expect(targets.length).toBeGreaterThan(0);
for (const target of targets) {
expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toContain(target);
}
expect(targets).toContain(
LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK[legacy],
);
}
});
@@ -83,7 +79,9 @@ describe('资源画布分区轴与新旧栏目映射', () => {
for (const target of LEGACY_RESOURCE_CANVAS_SECTION_TARGETS.art) {
expect(resolveResourceCanvasSection('art', target)).toBe(target);
}
expect(LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK.art).toBe('unclassified');
// 目标集合之外不猜、也不回落到别的栏目:宁可让这条坐标走自动排布,
// 也不把资源放进它并不属于的栏目。
expect(resolveResourceCanvasSection('art', 'document')).toBeNull();
});
it('旧 code 统一归入待归类', () => {
@@ -113,7 +111,7 @@ describe('资源画布分区轴与新旧栏目映射', () => {
}
for (const legacy of ['art', 'code'] as const) {
const target = LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK[legacy];
const target = LEGACY_RESOURCE_CANVAS_SECTION_TARGETS[legacy][0]!;
const normalized = normalizeResourceCanvasPosition(
{
resourceId: 'resource-1',
@@ -163,6 +163,9 @@ describe('素材重命名前端链路', () => {
installResourceCardIntersectionObserver();
const renamedManifest = createManifestWithHero('assets/hero-v2.png');
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 11, hasCommittedEdit: true };
}
if (command === 'rename_local_project_asset') {
return {
asset: renamedManifest.assets[0],
@@ -193,14 +196,29 @@ describe('素材重命名前端链路', () => {
await waitFor(() => {
expect(onManifestChange).toHaveBeenCalledTimes(1);
});
// 入参是 deny_unknown_fields 结构体:只允许这三个字段,多传会被 Rust 直接拒绝。
// 与删除 / 分类保存同一套 CAS 口径:带上项目身份 + **这一次读到的** revision。
expect(invoke).toHaveBeenCalledWith('rename_local_project_asset', {
input: {
projectPath: PROJECT_PATH,
expectedProjectId: 'workbench-asset-rename',
expectedProjectRevision: 11,
assetId: 'asset-hero',
newFileName: 'hero-v2.png',
},
});
expect(invoke).toHaveBeenCalledWith('get_local_game_project_revision', {
projectPath: PROJECT_PATH,
});
// revision 必须来自那一次读取:先读再改名,顺序反了就等于把过期值贴上去。
expect(
invoke.mock.calls.findIndex(
([command]) => command === 'get_local_game_project_revision',
),
).toBeLessThan(
invoke.mock.calls.findIndex(
([command]) => command === 'rename_local_project_asset',
),
);
expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', {
projectPath: PROJECT_PATH,
commandId: 'asset.list',
@@ -221,9 +239,82 @@ describe('素材重命名前端链路', () => {
});
});
test('reads the project revision before renaming so a stale local value cannot be replayed', async () => {
installResourceCardIntersectionObserver();
// 本地 manifest 的 revision 落后(5)而磁盘上已经是 42:提交的必须是读到的 42,
// 否则后端按「陈旧改写」拒收,用户会看到一次凭空失败的重命名。
const staleManifest = createManifestWithHero('assets/hero.png');
const invoke = installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 42, hasCommittedEdit: true };
}
if (command === 'rename_local_project_asset') {
return {
asset: createManifestWithHero('assets/hero-v2.png').assets[0],
previousLocalPath: 'assets/hero.png',
committedProjectRevision: 43,
};
}
if (command === 'get_local_game_manifest') {
return createManifestWithHero('assets/hero-v2.png');
}
throw new Error(`unexpected command: ${command}`);
});
renderWorkbench(staleManifest);
const toolbar = await openHeroCard();
fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' }));
const field = (await screen.findByLabelText(
'新文件名',
)) as HTMLInputElement;
fireEvent.change(field, { target: { value: 'hero-v2.png' } });
fireEvent.click(screen.getByRole('button', { name: '确认重命名素材' }));
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'rename_local_project_asset',
expect.anything(),
);
});
expect(
invoke.mock.calls.find(
([command]) => command === 'rename_local_project_asset',
)?.[1],
).toMatchObject({ input: { expectedProjectRevision: 42 } });
});
test('surfaces the project CAS rejection as readable copy instead of the raw code', async () => {
installResourceCardIntersectionObserver();
installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7, hasCommittedEdit: true };
}
if (command === 'rename_local_project_asset') {
throw 'project-revision-conflict';
}
throw new Error(`unexpected command: ${command}`);
});
renderWorkbench(createManifestWithHero('assets/hero.png'));
const toolbar = await openHeroCard();
fireEvent.click(within(toolbar).getByRole('button', { name: '重命名' }));
const field = (await screen.findByLabelText(
'新文件名',
)) as HTMLInputElement;
fireEvent.change(field, { target: { value: 'hero-v2.png' } });
fireEvent.click(screen.getByRole('button', { name: '确认重命名素材' }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('项目已被其它操作改动,请刷新后重试');
expect(alert.textContent).not.toContain('project-revision-conflict');
});
test('keeps the panel open and surfaces the native rejection', async () => {
installResourceCardIntersectionObserver();
installInvoke(async (command) => {
if (command === 'get_local_game_project_revision') {
return { revision: 7, hasCommittedEdit: true };
}
if (command === 'rename_local_project_asset') {
throw '新文件名非法:扩展名必须与原文件一致';
}
@@ -295,6 +295,55 @@ describe('C7 运行模块版本切换', () => {
).toBe(false);
});
/**
* 菜单位置按触发按钮的实时 rect 算:运行画面会缩放 / 滚动,`fixed` 定位的菜单
* 不跟着重算就会漂在旧位置上(离触发按钮越来越远,甚至跑出视口)。
*
* 变异验证:把 `scroll` / `resize` 的重算订阅删掉,本用例必须失败。
*/
test('recomputes the portal menu position after the window scrolls', async () => {
const rect = (bottom: number) =>
({
bottom,
right: 200,
top: bottom - 24,
left: 120,
width: 80,
height: 24,
x: 120,
y: bottom - 24,
toJSON: () => ({}),
}) as DOMRect;
const rectSpy = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockReturnValue(rect(100));
try {
render(
React.createElement(GameRunVersionPicker, {
versions: createVersionedManifest().versions,
activeVersionId: null,
onSelectVersion: vi.fn(),
}),
);
fireEvent.click(await screen.findByLabelText(/^当前版本:/));
const menu = await screen.findByRole('listbox', {
name: '切换游戏版本',
});
expect(menu.style.top).toBe('106px');
// 窗口滚动 / 画布缩放后触发按钮位移,菜单必须跟着走。
rectSpy.mockReturnValue(rect(240));
fireEvent.scroll(window);
await waitFor(() => {
expect(menu.style.top).toBe('246px');
});
} finally {
rectSpy.mockRestore();
}
});
/**
* 用户实测「切换版本点了选项没反应」的直接原因(与事件订阅报错互相独立)。
*