修复 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:
@@ -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` 子树里。所以它必须一起登记成「点这里不算外部」的边界:
|
||||
|
||||
+3
@@ -91,6 +91,9 @@ export function ResourceCanvasGenerationPanelView({
|
||||
});
|
||||
} catch (submitError) {
|
||||
setError(resourceGenerationErrorMessage(submitError));
|
||||
} finally {
|
||||
// 成功路径也要收回在飞标记:宿主现在还靠卸载面板兜底,但组件本身不该
|
||||
// 在 `onSubmit` 正常 resolve 后永久停在「生成中…」并把关闭路径全部锁住。
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-17
@@ -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;
|
||||
|
||||
+24
-2
@@ -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 关闭」。
|
||||
*
|
||||
|
||||
+9
-2
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
+2
-8
@@ -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>
|
||||
|
||||
+14
-4
@@ -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
|
||||
|
||||
+33
@@ -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;
|
||||
}
|
||||
+4
@@ -234,6 +234,10 @@ export type ProjectManifestRereadInput = {
|
||||
*
|
||||
* 两次读 revision 夹一次读清单:中间有写盘落地就说明读到的是撕裂的一对,
|
||||
* 直接放弃本次恢复(下一次快照会带上新的 revision),不猜、不凑。
|
||||
*
|
||||
* 返回 `null` **只表示读到了、但不能采用**(revision 非法、projectId 不一致、
|
||||
* 两次 revision 对不上)。IO / IPC 失败会照旧抛出:调用方要能把"读失败"与
|
||||
* "读到但不可采用"分开呈现(例如上传成功后配对读失败必须报错,而不是静默不同步)。
|
||||
*/
|
||||
export async function rereadAuthoritativeProjectManifestSnapshot(
|
||||
input: ProjectManifestRereadInput,
|
||||
|
||||
-15
@@ -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(
|
||||
|
||||
+3
-11
@@ -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,
|
||||
|
||||
+6
-3
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 用户实测「切换版本点了选项没反应」的直接原因(与事件订阅报错互相独立)。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user