合并资源工作台分支:PR review 的 Rust 批次并入共享契约与滚轮修复
- 把远端最新 1cc258d2a 合入本分支(PR #316 review 的 Rust 三笔:3a9e45c80、daef9f6cc、64b0d2f76)
- 覆盖:素材导出补敏感文件/控制面拒绝与自我覆盖保护、删除幂等、重命名 CAS、manifest 缓存 TOCTOU、标签上限、注入资源 ID 上限与去重、锁诊断与提权口径
- 冲突零:自动合并成功
- 注意:rename_local_project_asset 现要求 expectedProjectId/expectedProjectRevision,前端调用点由前端批次同批补齐
This commit is contained in:
@@ -38,6 +38,37 @@ export function isResourceCanvasInteractionTarget(
|
|||||||
return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR));
|
return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。
|
||||||
|
*
|
||||||
|
* 词表与 `RESOURCE_CANVAS_INTERACTION_SELECTOR` 的浮层成员同一套类名,只去掉按钮 / 输入 /
|
||||||
|
* 资源卡这类本身不该吞滚轮的交互控件(在卡片上滚轮照旧平移 / 缩放画布):
|
||||||
|
* - `.image-canvas-editor__generation-composer`:快速编辑 / 角色动画浮层(自带提示词滚动区);
|
||||||
|
* - `.game-resource-info-panel`:资源信息浮层(`overflow: auto`);
|
||||||
|
* - `.game-resource-filter-panel`:筛选面板(卡片区 `overflow-y: auto`)。
|
||||||
|
*/
|
||||||
|
export const RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR = [
|
||||||
|
'.image-canvas-editor__generation-composer',
|
||||||
|
'.game-resource-info-panel',
|
||||||
|
'.game-resource-filter-panel',
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这一次滚轮是不是落在「自己吃滚轮的画布浮层」里。
|
||||||
|
*
|
||||||
|
* portal 到 `document.body` 的浮层(`@` 资源选择器与候选菜单、共享模型 / 比例弹出层…)由
|
||||||
|
* `isResourceReferenceOverlayTarget` 与共享 hook 的 portal 弹层判据负责,这里只管留在画布
|
||||||
|
* DOM 里的浮层。
|
||||||
|
*/
|
||||||
|
export function isResourceCanvasWheelOverlayTarget(
|
||||||
|
target: EventTarget | null,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
target instanceof Element &&
|
||||||
|
target.closest(RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR) !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 画布浮层是否可以被清焦点顺手关掉。
|
* 画布浮层是否可以被清焦点顺手关掉。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5768,31 +5768,45 @@ iframe.preview-frame {
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 缩略卡悬停 / 聚焦时高亮它所在栏目(那一摞)的标题条。
|
||||||
|
取值必须与 `PROJECT_RESOURCE_CANVAS_SECTIONS`(7 个现行分区)逐一对齐:
|
||||||
|
`code` / `art` 是旧四、五栏目的历史值,总览里不会渲染,写它们等于空规则;
|
||||||
|
少写一个现行分区,对应栏目就完全没有悬停高亮。 */
|
||||||
.game-resource-book-manager--main:has(
|
.game-resource-book-manager--main:has(
|
||||||
.game-resource-book-thumbnail[data-resource-book-category='code']:hover,
|
.game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:hover,
|
||||||
.game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible
|
.game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:focus-visible
|
||||||
)
|
)
|
||||||
.game-resource-book-scene-titlebar[data-resource-book-category='code'],
|
.game-resource-book-scene-titlebar[data-resource-book-category='ui-interaction'],
|
||||||
|
.game-resource-book-manager--main:has(
|
||||||
|
.game-resource-book-thumbnail[data-resource-book-category='character']:hover,
|
||||||
|
.game-resource-book-thumbnail[data-resource-book-category='character']:focus-visible
|
||||||
|
)
|
||||||
|
.game-resource-book-scene-titlebar[data-resource-book-category='character'],
|
||||||
|
.game-resource-book-manager--main:has(
|
||||||
|
.game-resource-book-thumbnail[data-resource-book-category='scene']:hover,
|
||||||
|
.game-resource-book-thumbnail[data-resource-book-category='scene']:focus-visible
|
||||||
|
)
|
||||||
|
.game-resource-book-scene-titlebar[data-resource-book-category='scene'],
|
||||||
|
.game-resource-book-manager--main:has(
|
||||||
|
.game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
|
||||||
|
.game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
|
||||||
|
)
|
||||||
|
.game-resource-book-scene-titlebar[data-resource-book-category='audio'],
|
||||||
.game-resource-book-manager--main:has(
|
.game-resource-book-manager--main:has(
|
||||||
.game-resource-book-thumbnail[data-resource-book-category='document']:hover,
|
.game-resource-book-thumbnail[data-resource-book-category='document']:hover,
|
||||||
.game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
|
.game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
|
||||||
)
|
)
|
||||||
.game-resource-book-scene-titlebar[data-resource-book-category='document'],
|
.game-resource-book-scene-titlebar[data-resource-book-category='document'],
|
||||||
|
.game-resource-book-manager--main:has(
|
||||||
|
.game-resource-book-thumbnail[data-resource-book-category='unclassified']:hover,
|
||||||
|
.game-resource-book-thumbnail[data-resource-book-category='unclassified']:focus-visible
|
||||||
|
)
|
||||||
|
.game-resource-book-scene-titlebar[data-resource-book-category='unclassified'],
|
||||||
.game-resource-book-manager--main:has(
|
.game-resource-book-manager--main:has(
|
||||||
.game-resource-book-thumbnail[data-resource-book-category='version']:hover,
|
.game-resource-book-thumbnail[data-resource-book-category='version']:hover,
|
||||||
.game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
|
.game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
|
||||||
)
|
)
|
||||||
.game-resource-book-scene-titlebar[data-resource-book-category='version'],
|
.game-resource-book-scene-titlebar[data-resource-book-category='version'] {
|
||||||
.game-resource-book-manager--main:has(
|
|
||||||
.game-resource-book-thumbnail[data-resource-book-category='art']:hover,
|
|
||||||
.game-resource-book-thumbnail[data-resource-book-category='art']:focus-visible
|
|
||||||
)
|
|
||||||
.game-resource-book-scene-titlebar[data-resource-book-category='art'],
|
|
||||||
.game-resource-book-manager--main:has(
|
|
||||||
.game-resource-book-thumbnail[data-resource-book-category='audio']:hover,
|
|
||||||
.game-resource-book-thumbnail[data-resource-book-category='audio']:focus-visible
|
|
||||||
)
|
|
||||||
.game-resource-book-scene-titlebar[data-resource-book-category='audio'] {
|
|
||||||
border-top-color: #cc8060;
|
border-top-color: #cc8060;
|
||||||
border-right-color: #cc8060;
|
border-right-color: #cc8060;
|
||||||
border-bottom-color: #ebd9cf;
|
border-bottom-color: #ebd9cf;
|
||||||
@@ -6902,10 +6916,13 @@ iframe.preview-frame {
|
|||||||
0 0 0 2px rgb(216 115 66 / 24%);
|
0 0 0 2px rgb(216 115 66 / 24%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 卡片本体是 `border: 0`(下面那条基规则)。`border-color` 单独写没有意义——0 宽的边框
|
||||||
|
画不出颜色,所以选中 / 悬停 / 聚焦都必须写成完整的 `border`,否则这三个状态在视觉上
|
||||||
|
完全看不出来。宽度与圆角保持 1px / 12px,`box-sizing: border-box` 下不会改变卡片尺寸。 */
|
||||||
.game-resource-card:hover,
|
.game-resource-card:hover,
|
||||||
.game-resource-card:focus-within,
|
.game-resource-card:focus-within,
|
||||||
.game-resource-card.is-selected {
|
.game-resource-card.is-selected {
|
||||||
border-color: #d57b51;
|
border: 1px solid #d57b51;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
box-shadow: 0 8px 22px rgb(195 105 62 / 15%);
|
box-shadow: 0 8px 22px rgb(195 105 62 / 15%);
|
||||||
}
|
}
|
||||||
@@ -10418,13 +10435,18 @@ button.design-workspace-tree__entry:hover,
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* direct-codex 输入区去默认灰描边:焦点可见性交给下面那条
|
||||||
|
`.project-supervisor-composer .resource-reference-input:focus-within`
|
||||||
|
(`--platform-input-focus-ring` 光环)。这条规则比它多一个 `is-direct-codex` 类,
|
||||||
|
优先级更高,所以**不能写 `box-shadow: none`**——写回去会把那条光环整条盖掉,
|
||||||
|
而 base `.resource-reference-input-editor` 又是 `outline: 0`,输入区就完全没有
|
||||||
|
可见焦点了(无障碍回归)。 */
|
||||||
.game-workbench-chat
|
.game-workbench-chat
|
||||||
.project-supervisor-surface.is-direct-codex
|
.project-supervisor-surface.is-direct-codex
|
||||||
.project-supervisor-composer.is-direct-codex
|
.project-supervisor-composer.is-direct-codex
|
||||||
.resource-reference-input:focus-within {
|
.resource-reference-input:focus-within {
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-workbench-chat
|
.game-workbench-chat
|
||||||
@@ -10704,18 +10726,19 @@ button.design-workspace-tree__entry:hover,
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 陶泥儿输入区的操作排(`@` / AI 润色 / 恢复原文)回到文档流:`grid-column: 1 / -1`
|
/* 陶泥儿输入区的操作排(`AI 润色` / `恢复原文`)回到文档流:网格第二行右侧那一列,
|
||||||
让它独占编辑器下面的一行,`align-items: end` + `justify-content: flex-end` 把它
|
`justify-content: flex-end` 贴住输入框右下角。之前这里用
|
||||||
贴到输入框右下角。之前这里用 `position: absolute; right: 48px; bottom: 10px`
|
`position: absolute; right: 48px; bottom: 10px` 把整排从网格里摘出来浮在输入框中间,
|
||||||
把整排从网格里摘出来浮在输入框中间,AI 润色因此看起来压在文本区里、和下面那排
|
AI 润色因此看起来压在文本区里、和下面那排主操作(`.project-supervisor-composer-controls`
|
||||||
主操作(`.project-supervisor-composer-controls` 的 `@` / 快速 / 发送)脱节。
|
的 `@` / 快速 / 发送)脱节。
|
||||||
不设 `min-height`:这一行的高度由 28px 方钮自己撑开,行高一旦被顶起来会连带把
|
不设 `min-height`:这一行的高度由 28px 方钮自己撑开,行高一旦被顶起来会连带把
|
||||||
编辑器的 `min-height: 96px` 改掉。 */
|
编辑器的 `min-height: 96px` 改掉。 */
|
||||||
.game-workbench-chat
|
.game-workbench-chat
|
||||||
.project-supervisor-surface.is-direct-codex
|
.project-supervisor-surface.is-direct-codex
|
||||||
.project-supervisor-composer
|
.project-supervisor-composer
|
||||||
.resource-reference-input-actions {
|
.resource-reference-input-actions {
|
||||||
grid-column: 1 / -1;
|
grid-row: 2;
|
||||||
|
grid-column: 2;
|
||||||
position: static;
|
position: static;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
@@ -10724,46 +10747,35 @@ button.design-workspace-tree__entry:hover,
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 「润色中… / 润色失败」和应用排同一行:状态文字靠左占剩余宽度,操作排靠右。
|
/* 「润色中… / 润色失败」和应用排**同一行**:状态文字在第二行左侧那一列(占剩余宽度),
|
||||||
单独占一行会把输入框整体顶高 22.8px,把操作排和下面那排主操作推开一个状态行的
|
操作排在第二行右侧那一列。单独占一行会把输入框整体顶高一个状态行的距离;而给它
|
||||||
距离;共行则状态出现/消失都不改变输入框高度。 */
|
`grid-column: 1 / -1` 又会和操作排落进同一个网格单元互相重叠(状态文字长时压到按钮下面)。
|
||||||
|
共行分列则状态出现/消失都不改变输入框高度,也不会互相压。 */
|
||||||
.game-workbench-chat
|
.game-workbench-chat
|
||||||
.project-supervisor-surface.is-direct-codex
|
.project-supervisor-surface.is-direct-codex
|
||||||
.project-supervisor-composer
|
.project-supervisor-composer
|
||||||
.resource-reference-input-status {
|
.resource-reference-input-status {
|
||||||
grid-column: 1 / -1;
|
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
|
grid-column: 1;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-workbench-chat
|
/* direct-codex 里 `ResourceReferenceInput` 收到的是 `showTriggerButton={!directCodex}`,
|
||||||
.project-supervisor-surface.is-direct-codex
|
即输入框内的 `.resource-reference-input-at` 根本不会渲染(`@` 触发钮是下面控制排里的
|
||||||
.project-supervisor-composer
|
`.project-supervisor-reference-trigger`)。这里原本有一条针对它的
|
||||||
.resource-reference-input-at {
|
`position: static !important` / 30px 主色方块规则,永远匹配不到,已删除;
|
||||||
position: static !important;
|
要改 direct-codex 的 `@` 触发钮外观请改 `.project-supervisor-reference-trigger`。 */
|
||||||
right: auto !important;
|
|
||||||
bottom: auto !important;
|
|
||||||
display: grid;
|
|
||||||
width: 30px;
|
|
||||||
min-width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
min-height: 30px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: var(--platform-button-primary-fill);
|
|
||||||
color: var(--platform-button-primary-text);
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.design-agent-reasoning {
|
.design-agent-reasoning {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.82em;
|
font-size: 0.82em;
|
||||||
}
|
}
|
||||||
.design-agent-reasoning summary { cursor: pointer; }
|
.design-agent-reasoning summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.design-agent-reasoning pre {
|
.design-agent-reasoning pre {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ import {
|
|||||||
import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
|
import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
|
||||||
import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
|
import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
|
||||||
import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView';
|
import { ImageCanvasSelectedLayerToolbarView } from '../../../../../src/components/image-editor/ImageCanvasSelectedLayerToolbarView';
|
||||||
import { useImageCanvasFloatingOptionDismiss } from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
|
import {
|
||||||
|
isFloatingOverlayWheelEvent,
|
||||||
|
useImageCanvasFloatingOptionDismiss,
|
||||||
|
} from '../../../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
|
||||||
import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel';
|
import { DesignWorkspacePanel } from '../../features/project-workspace/DesignWorkspacePanel';
|
||||||
import {
|
import {
|
||||||
LocalGamePreviewFrame,
|
LocalGamePreviewFrame,
|
||||||
@@ -115,6 +118,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
canDismissResourceCanvasQuickEdit,
|
canDismissResourceCanvasQuickEdit,
|
||||||
isResourceCanvasInteractionTarget,
|
isResourceCanvasInteractionTarget,
|
||||||
|
isResourceCanvasWheelOverlayTarget,
|
||||||
resolveResourceCanvasFloatingPanelDismissOpen,
|
resolveResourceCanvasFloatingPanelDismissOpen,
|
||||||
resolveResourceCanvasFocusEscapeActive,
|
resolveResourceCanvasFocusEscapeActive,
|
||||||
} from '../../features/resource-canvas/resourceCanvasFocusModel';
|
} from '../../features/resource-canvas/resourceCanvasFocusModel';
|
||||||
@@ -1120,6 +1124,23 @@ function ResourceBookTitleBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 画布滚轮归属的「已登记浮层」总表,与「点外部关闭」用的是同一套浮层口径:
|
||||||
|
* - `@` 资源选择器与候选菜单 portal 到 `document.body`;
|
||||||
|
* - 留在画布 DOM 里、自带滚动区的画布浮层(快速编辑 / 信息 / 筛选,见
|
||||||
|
* `isResourceCanvasWheelOverlayTarget`)。
|
||||||
|
*
|
||||||
|
* 落在它们里面的滚轮归浮层自己,画布不消费(不 preventDefault、不动视口)。
|
||||||
|
*/
|
||||||
|
function isResourceCanvasFloatingOverlayWheelTarget(
|
||||||
|
target: EventTarget | null,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
isResourceReferenceOverlayTarget(target) ||
|
||||||
|
isResourceCanvasWheelOverlayTarget(target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ResourceBookScene({
|
function ResourceBookScene({
|
||||||
plan,
|
plan,
|
||||||
resourceBookState,
|
resourceBookState,
|
||||||
@@ -4406,11 +4427,28 @@ export default function ProjectDevelopmentView({
|
|||||||
|
|
||||||
const handleResourceBookWheel = useCallback(
|
const handleResourceBookWheel = useCallback(
|
||||||
(event: ReactWheelEvent<HTMLDivElement> | WheelEvent) => {
|
(event: ReactWheelEvent<HTMLDivElement> | WheelEvent) => {
|
||||||
resourceBookTransitionControllerRef.current.settle();
|
|
||||||
const sceneElement = resourceBookManagerRef.current;
|
const sceneElement = resourceBookManagerRef.current;
|
||||||
if (!sceneElement) {
|
if (!sceneElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
浮层优先:滚轮只在「这一下确实落在资源画布管理区里」时才归画布。
|
||||||
|
|
||||||
|
React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器
|
||||||
|
`document.body` 上),所以 `@` 选择素材浮层里的滚轮也会走到场景根的 `onWheel`
|
||||||
|
这里;而它在 DOM 上根本不在管理区里。判据与上面「点外部关闭」同一份
|
||||||
|
(`isFloatingOverlayWheelEvent` = 浮层边界 + 已登记浮层):用户在选择器列表上
|
||||||
|
滚轮时,列表自己滚,背后的画布不再跟着平移 / 缩放。
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
isFloatingOverlayWheelEvent(event.target, {
|
||||||
|
boundaryRefs: [resourceBookManagerRef],
|
||||||
|
isInsideExtraOverlay: isResourceCanvasFloatingOverlayWheelTarget,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resourceBookTransitionControllerRef.current.settle();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const rect = sceneElement.getBoundingClientRect();
|
const rect = sceneElement.getBoundingClientRect();
|
||||||
const screenPoint = {
|
const screenPoint = {
|
||||||
|
|||||||
@@ -893,6 +893,93 @@ describe('project resource live canvas integration', () => {
|
|||||||
expect(deriveCalls[0]?.prompt).toBe(expectedText);
|
expect(deriveCalls[0]?.prompt).toBe(expectedText);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户报的原始现象:在「快速编辑 → 插入素材引用」开出的选择器列表上滚鼠标滚轮,
|
||||||
|
* 滚的不是列表,而是背后的资源画布(画布跟着缩放 / 平移)。
|
||||||
|
*
|
||||||
|
* 链路:选择器 portal 到 `document.body`,而 React 的 portal 事件沿 **React 树** 冒泡
|
||||||
|
* (React 把委托监听挂在 portal 容器上),所以它的 wheel 照样走到画布场景根的
|
||||||
|
* `onWheel`;修复前那一下会被画布消费掉。这里用真实事件序列钉住「浮层里的滚轮归浮层、
|
||||||
|
* 画布视口一格不动」,同时用对照用例钉住「画布本体的滚轮照旧」。
|
||||||
|
*/
|
||||||
|
it('在选择素材浮层里滚轮:浮层自己收到、画布视口不动,画布本体滚轮照旧', async () => {
|
||||||
|
installTauri();
|
||||||
|
render(<DerivedWorkbench includeArt />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
|
||||||
|
fireEvent.click(await findResourceSelectButton('source-art.png'));
|
||||||
|
const toolbar = await screen.findByRole('toolbar', {
|
||||||
|
name: '图片工具栏',
|
||||||
|
});
|
||||||
|
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
|
||||||
|
const panel = await screen.findByRole('dialog', {
|
||||||
|
name: '快速编辑图片',
|
||||||
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
within(panel).getByRole('button', { name: '插入素材引用' }),
|
||||||
|
);
|
||||||
|
const picker = await screen.findByRole('dialog', { name: '选择素材' });
|
||||||
|
|
||||||
|
// 前提自检:选择器 DOM 上确实不在资源画本里(这正是 React 事件仍会冒泡到画布的原因)。
|
||||||
|
const manager = document.querySelector('.game-resource-book-manager');
|
||||||
|
expect(manager?.contains(picker)).toBe(false);
|
||||||
|
expect(picker.parentElement).toBe(document.body);
|
||||||
|
|
||||||
|
const list = picker.querySelector('.resource-reference-picker-list');
|
||||||
|
if (!list) throw new Error('missing picker list');
|
||||||
|
const overlayWheelCalls = vi.fn();
|
||||||
|
list.addEventListener('wheel', overlayWheelCalls);
|
||||||
|
|
||||||
|
const readViewport = () =>
|
||||||
|
document
|
||||||
|
.querySelector('[data-resource-viewport]')
|
||||||
|
?.getAttribute('data-resource-viewport');
|
||||||
|
const readSceneWorldTransform = () =>
|
||||||
|
document
|
||||||
|
.querySelector('.game-resource-book-scene-world')
|
||||||
|
?.getAttribute('style');
|
||||||
|
const viewportBefore = readViewport();
|
||||||
|
const sceneWorldBefore = readSceneWorldTransform();
|
||||||
|
expect(viewportBefore).toBeTruthy();
|
||||||
|
|
||||||
|
// 真实事件序列:从浮层内部元素派发滚轮(等同用户在选择器列表上滚)。
|
||||||
|
const overlayWheel = new WheelEvent('wheel', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
deltaY: 240,
|
||||||
|
clientX: 80,
|
||||||
|
clientY: 60,
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
list.dispatchEvent(overlayWheel);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 浮层自己收到该事件、且没被画布消费(列表按原生行为滚动)。
|
||||||
|
expect(overlayWheelCalls).toHaveBeenCalledTimes(1);
|
||||||
|
expect(overlayWheel.defaultPrevented).toBe(false);
|
||||||
|
// 画布视口一格不动。
|
||||||
|
expect(readViewport()).toBe(viewportBefore);
|
||||||
|
expect(readSceneWorldTransform()).toBe(sceneWorldBefore);
|
||||||
|
|
||||||
|
list.removeEventListener('wheel', overlayWheelCalls);
|
||||||
|
|
||||||
|
// 对照用例:画布本体(场景根)上的滚轮必须照旧平移视口——修复没把画布交互一起关掉。
|
||||||
|
const sceneRoot = document.querySelector('.game-resource-book-scene');
|
||||||
|
if (!sceneRoot) throw new Error('missing scene root');
|
||||||
|
const canvasWheel = new WheelEvent('wheel', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
deltaY: 120,
|
||||||
|
clientX: 90,
|
||||||
|
clientY: 70,
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
sceneRoot.dispatchEvent(canvasWheel);
|
||||||
|
});
|
||||||
|
expect(canvasWheel.defaultPrevented).toBe(true);
|
||||||
|
expect(readViewport()).not.toBe(viewportBefore);
|
||||||
|
});
|
||||||
|
|
||||||
it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => {
|
it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => {
|
||||||
const { deriveCalls } = installTauri({ failFirstDerive: true });
|
const { deriveCalls } = installTauri({ failFirstDerive: true });
|
||||||
render(<DerivedWorkbench />);
|
render(<DerivedWorkbench />);
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
|
|
||||||
import type { QuickEditPanelState } from '../../../src/components/image-editor/ImageCanvasEditorTypes';
|
import type { QuickEditPanelState } from '../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||||
import { ImageCanvasQuickEditPanelView } from '../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
|
import { ImageCanvasQuickEditPanelView } from '../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
|
||||||
import { useImageCanvasFloatingOptionDismiss } from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
|
import {
|
||||||
|
isFloatingOverlayWheelEvent,
|
||||||
|
useImageCanvasFloatingOptionDismiss,
|
||||||
|
} from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss';
|
||||||
import {
|
import {
|
||||||
canDismissResourceCanvasQuickEdit,
|
canDismissResourceCanvasQuickEdit,
|
||||||
isResourceCanvasHostOverlayOpen,
|
isResourceCanvasHostOverlayOpen,
|
||||||
isResourceCanvasInteractionTarget,
|
isResourceCanvasInteractionTarget,
|
||||||
|
isResourceCanvasWheelOverlayTarget,
|
||||||
resolveResourceCanvasFloatingPanelDismissOpen,
|
resolveResourceCanvasFloatingPanelDismissOpen,
|
||||||
resolveResourceCanvasFocusEscapeActive,
|
resolveResourceCanvasFocusEscapeActive,
|
||||||
} from '../src/features/resource-canvas/resourceCanvasFocusModel';
|
} from '../src/features/resource-canvas/resourceCanvasFocusModel';
|
||||||
@@ -315,6 +319,69 @@ describe('资源画布浮层关闭时机', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('画布滚轮归属判据', () => {
|
||||||
|
/**
|
||||||
|
* 与「点外部关闭」同一份口径:portal 出去的东西 DOM 上不在边界里,但 React 的事件会沿
|
||||||
|
* React 树冒泡到画布宿主的 `onWheel`。滚轮这类事件必须按 DOM 判归属,否则用户在
|
||||||
|
* `@` 选择器列表上滚动时,背后的画布会跟着平移 / 缩放。
|
||||||
|
*/
|
||||||
|
test('portal 出去的浮层与已登记浮层归浮层,边界里的画布元素归画布', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="owner">
|
||||||
|
<div id="card-blank">画布空白</div>
|
||||||
|
<div class="image-canvas-editor__portal-menu"><span id="menu-item">共享弹出层选项</span></div>
|
||||||
|
<div class="resource-reference-picker"><span id="picker-item">选择器条目</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="portal"><span id="portal-item">portal 到 body 的候选项</span></div>
|
||||||
|
`;
|
||||||
|
const ownerRef = { current: document.querySelector<HTMLElement>('#owner') };
|
||||||
|
const options = {
|
||||||
|
boundaryRefs: [ownerRef],
|
||||||
|
isInsideExtraOverlay: (target: EventTarget | null) =>
|
||||||
|
target instanceof Element &&
|
||||||
|
target.closest('.resource-reference-picker') !== null,
|
||||||
|
};
|
||||||
|
const at = (selector: string) =>
|
||||||
|
document.querySelector<HTMLElement>(selector);
|
||||||
|
|
||||||
|
// 画布自己的:DOM 在边界里,也不是浮层。
|
||||||
|
expect(isFloatingOverlayWheelEvent(at('#card-blank'), options)).toBe(false);
|
||||||
|
// portal 到 body 的浮层:DOM 根本不在边界里 ⇒ 归浮层。
|
||||||
|
expect(isFloatingOverlayWheelEvent(at('#portal-item'), options)).toBe(true);
|
||||||
|
// 边界里但落在共享弹出层上:与「点外部关闭」同口径 ⇒ 归浮层。
|
||||||
|
expect(isFloatingOverlayWheelEvent(at('#menu-item'), options)).toBe(true);
|
||||||
|
// 边界里但由宿主登记成浮层(`@` 选择器 / 候选菜单)⇒ 归浮层。
|
||||||
|
expect(isFloatingOverlayWheelEvent(at('#picker-item'), options)).toBe(true);
|
||||||
|
// 判不出归属(没有事件目标)时不抢:归浮层。
|
||||||
|
expect(isFloatingOverlayWheelEvent(null, options)).toBe(true);
|
||||||
|
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自带滚动区的画布浮层归浮层,资源卡与画布空白仍归画布', () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="game-resource-book-manager">
|
||||||
|
<div class="game-resource-card"><span id="card">卡片</span></div>
|
||||||
|
<div id="blank">画布空白</div>
|
||||||
|
<div class="image-canvas-editor__generation-composer"><span id="prompt">快速编辑提示词</span></div>
|
||||||
|
<div class="game-resource-info-panel"><span id="info">信息浮层</span></div>
|
||||||
|
<div class="game-resource-filter-panel"><span id="filter">筛选面板</span></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const at = (selector: string) =>
|
||||||
|
document.querySelector<HTMLElement>(selector);
|
||||||
|
|
||||||
|
expect(isResourceCanvasWheelOverlayTarget(at('#card'))).toBe(false);
|
||||||
|
expect(isResourceCanvasWheelOverlayTarget(at('#blank'))).toBe(false);
|
||||||
|
expect(isResourceCanvasWheelOverlayTarget(at('#prompt'))).toBe(true);
|
||||||
|
expect(isResourceCanvasWheelOverlayTarget(at('#info'))).toBe(true);
|
||||||
|
expect(isResourceCanvasWheelOverlayTarget(at('#filter'))).toBe(true);
|
||||||
|
expect(isResourceCanvasWheelOverlayTarget(null)).toBe(false);
|
||||||
|
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/** 与 AGC 一样只给必要 props:下拉弹层就地渲染,不走 portal。 */
|
/** 与 AGC 一样只给必要 props:下拉弹层就地渲染,不走 portal。 */
|
||||||
function QuickEditPanelHarness({
|
function QuickEditPanelHarness({
|
||||||
initialPanel,
|
initialPanel,
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
> 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。
|
> 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。
|
||||||
|
|
||||||
|
## 2026-09-12 画布滚轮要按 DOM 归属判定,portal 出去的浮层不能把滚轮让给画布
|
||||||
|
|
||||||
|
- **现象**:资源卡「快速编辑」里用 `@` 开出「选择素材」浮层后,在选择器列表上滚鼠标滚轮,列表自己在滚,背后的资源画布也一起平移 / 缩放(用户口语:「滚轮还是回滚到画布上」)。
|
||||||
|
- **原因**:选择器与输入区候选菜单 `createPortal(..., document.body)`,DOM 上不在画布管理区里;但 React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器上),所以它们的 `wheel` 照样走到画布场景根的 `onWheel`,被当成画布手势消费。React 的 `wheel` 委托监听是 passive 的:`preventDefault()` 是空操作(只报 warning),真正出问题的是视口状态被改写——所以「事件没被 preventDefault」不能作为「画布没吃这一下」的判据。
|
||||||
|
- **处理**:滚轮归属与「点外部关闭」共用同一份浮层口径(`src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts`:`isEventInsideFloatingOverlay` + `isFloatingOverlayWheelEvent`)——DOM 不在宿主边界里的(portal 出去的一律算浮层)与已登记为浮层内部的都归浮层。资源画布只保留 `handleResourceBookWheel` 一处守卫,留在画布 DOM 里自带滚动区的浮层(快速编辑 / 信息 / 筛选,见 `RESOURCE_CANVAS_WHEEL_OVERLAY_SELECTOR`)按同一入口登记,不要再逐浮层加 `stopPropagation`。判据判不出归属时(没有元素目标)不抢滚轮。
|
||||||
|
- **排查顺序**:先确认浮层是不是 portal 出去的;是的话不要先怀疑 CSS `overflow`、`overscroll-behavior` 或事件被 `preventDefault`,直接查画布宿主上的 `onWheel` / `onPointerDown` 有没有做 DOM 归属判断。
|
||||||
|
- **验证**:`npm run test -- apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx`(判据单测,含 portal / 共享弹出层 / 已登记浮层三种来源与「判不出归属不抢」)与 `npm run test -- apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx`(真实事件序列:选择器列表里派发 `wheel` → 画布 `data-resource-viewport` 不变、浮层自己收到该事件;对照组:场景根上派发 `wheel` → 视口照旧变化)。
|
||||||
|
- **关联**:`src/components/image-editor/useImageCanvasFloatingOptionDismiss.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`(`handleResourceBookWheel`)、`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts`
|
||||||
|
|
||||||
## 2026-09-12 策划项目重开前必须恢复运行模式
|
## 2026-09-12 策划项目重开前必须恢复运行模式
|
||||||
|
|
||||||
- 工作台不能只依赖创建时的内存 `startMode`:重开时丢失该值会挂载游戏资源画布,而对话恢复后又进入策划状态,造成左右区域不一致。
|
- 工作台不能只依赖创建时的内存 `startMode`:重开时丢失该值会挂载游戏资源画布,而对话恢复后又进入策划状态,造成左右区域不一致。
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { resolve } from 'node:path';
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { PlatformResourceFilterBar } from './PlatformResourceFilterBar';
|
import {
|
||||||
|
PlatformResourceFilterBar,
|
||||||
|
type PlatformResourceFilterBarProps,
|
||||||
|
} from './PlatformResourceFilterBar';
|
||||||
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
|
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
|
||||||
|
|
||||||
const CATEGORY_ITEMS = [
|
const CATEGORY_ITEMS = [
|
||||||
@@ -15,9 +18,10 @@ const CATEGORY_ITEMS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分段页签的子项宽度下限是「不叠字」的唯一判据:grid 的轨道按容器等分、scroll 的
|
* 分段页签子项的宽度下限只属于 `layout="scroll"`:那一条有 `overflow-x-auto` 做兜底,
|
||||||
* flex 子项默认可收缩,两者都会把中文标签压到内容宽度以下,标签虽然 nowrap,
|
* 宽度下限换来的是「不叠字」。grid 布局没有溢出容器、轨道是 `minmax(0, 1fr)`,同样的
|
||||||
* 文字仍会画出自己的盒子压到相邻项上。这条规则一旦被删掉就要失败。
|
* `min-width: max-content` 只会把内容画到相邻格上。这两条声明级断言(jsdom 不计算外部
|
||||||
|
* 样式表,可见性只能钉在声明与类名上)钉住「下限只挂在 --scroll 修饰类」。
|
||||||
*/
|
*/
|
||||||
function segmentedTabsStylesheet() {
|
function segmentedTabsStylesheet() {
|
||||||
return readFileSync(
|
return readFileSync(
|
||||||
@@ -110,6 +114,50 @@ describe('PlatformResourceFilterBar', () => {
|
|||||||
expect(screen.getByRole('button', { name: '全部' })).toBeTruthy();
|
expect(screen.getByRole('button', { name: '全部' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('标签行是横向滚动容器且不画滚动条', () => {
|
||||||
|
render(
|
||||||
|
<PlatformResourceFilterBar
|
||||||
|
ariaLabel="素材筛选"
|
||||||
|
categoryItems={CATEGORY_ITEMS}
|
||||||
|
activeCategoryId="all"
|
||||||
|
onCategoryChange={() => {}}
|
||||||
|
tagItems={[{ tag: '像素风', assetCount: 1 }]}
|
||||||
|
onToggleTag={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = screen.getByRole('group', { name: '素材筛选标签' });
|
||||||
|
expect(row.className).toContain('platform-category-chip-scroll');
|
||||||
|
// `platform-category-chip-scroll` 只提供 `overflow-x: auto`;不配 `scrollbar-hide`
|
||||||
|
// 它就会成为唯一的「看得见滚动条」的横向滚动容器。
|
||||||
|
expect(row.className).toContain('scrollbar-hide');
|
||||||
|
expect(segmentedTabsStylesheet()).toMatch(
|
||||||
|
/\.scrollbar-hide\s*\{[^}]*scrollbar-width:\s*none/s,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有 onToggleTag 时标签 chip 不得渲染成可点按钮', () => {
|
||||||
|
// 类型上 `tagItems` 必须带 `onToggleTag`(联合类型让这个非法状态不可表达);
|
||||||
|
// 这里刻意 `as unknown as` 绕过类型检查,模拟不带类型检查的调用方,
|
||||||
|
// 断言运行时兜底是「禁用」而不是「看起来可点、点了没反应」。
|
||||||
|
const props = {
|
||||||
|
ariaLabel: '素材筛选',
|
||||||
|
categoryItems: CATEGORY_ITEMS,
|
||||||
|
activeCategoryId: 'all',
|
||||||
|
onCategoryChange: () => {},
|
||||||
|
tagItems: [{ tag: '像素风', assetCount: 1 }],
|
||||||
|
activeTags: [],
|
||||||
|
} as unknown as PlatformResourceFilterBarProps<'all' | 'character'>;
|
||||||
|
|
||||||
|
render(<PlatformResourceFilterBar {...props} />);
|
||||||
|
|
||||||
|
const chip = screen.getByRole('button', {
|
||||||
|
name: /像素风/,
|
||||||
|
}) as HTMLButtonElement;
|
||||||
|
expect(chip.disabled).toBe(true);
|
||||||
|
expect(chip.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
test('never lets a category chip shrink below its label width', () => {
|
test('never lets a category chip shrink below its label width', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<PlatformResourceFilterBar
|
<PlatformResourceFilterBar
|
||||||
@@ -123,6 +171,7 @@ describe('PlatformResourceFilterBar', () => {
|
|||||||
// 分类条是 scroll 布局:容器横向可滚,标签不换行。
|
// 分类条是 scroll 布局:容器横向可滚,标签不换行。
|
||||||
const row = container.querySelector('.platform-segmented-tabs');
|
const row = container.querySelector('.platform-segmented-tabs');
|
||||||
expect(row).not.toBeNull();
|
expect(row).not.toBeNull();
|
||||||
|
expect(row?.className).toContain('platform-segmented-tabs--scroll');
|
||||||
expect(row?.className).toContain('overflow-x-auto');
|
expect(row?.className).toContain('overflow-x-auto');
|
||||||
for (const button of row?.querySelectorAll('button') ?? []) {
|
for (const button of row?.querySelectorAll('button') ?? []) {
|
||||||
expect(button.className).toContain('whitespace-nowrap');
|
expect(button.className).toContain('whitespace-nowrap');
|
||||||
@@ -130,11 +179,15 @@ describe('PlatformResourceFilterBar', () => {
|
|||||||
|
|
||||||
// 单靠 nowrap 不够:子项必须有不低于内容宽度的下限,否则文字会画出盒子压到相邻项。
|
// 单靠 nowrap 不够:子项必须有不低于内容宽度的下限,否则文字会画出盒子压到相邻项。
|
||||||
expect(segmentedTabsStylesheet()).toMatch(
|
expect(segmentedTabsStylesheet()).toMatch(
|
||||||
|
/\.platform-segmented-tabs--scroll\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s,
|
||||||
|
);
|
||||||
|
// 反过来,没有溢出容器的 grid 布局不得吃这条下限(否则内容会压到相邻格上)。
|
||||||
|
expect(segmentedTabsStylesheet()).not.toMatch(
|
||||||
/\.platform-segmented-tabs\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s,
|
/\.platform-segmented-tabs\s*>\s*button\s*\{[^}]*min-width:\s*max-content/s,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('grid 布局的分段页签同样带宽度下限钩子', () => {
|
test('grid 布局的分段页签不带 scroll 修饰类,靠轨道等分收口', () => {
|
||||||
render(
|
render(
|
||||||
<PlatformSegmentedTabs
|
<PlatformSegmentedTabs
|
||||||
items={CATEGORY_ITEMS}
|
items={CATEGORY_ITEMS}
|
||||||
@@ -149,5 +202,8 @@ describe('PlatformResourceFilterBar', () => {
|
|||||||
.closest('div');
|
.closest('div');
|
||||||
expect(row?.className).toContain('platform-segmented-tabs');
|
expect(row?.className).toContain('platform-segmented-tabs');
|
||||||
expect(row?.className).toContain('grid-cols-2');
|
expect(row?.className).toContain('grid-cols-2');
|
||||||
|
// grid 分支没有溢出容器,宽度下限一旦回到这里就会压到相邻格(本轮 review 的回归点)。
|
||||||
|
expect(row?.className).not.toContain('platform-segmented-tabs--scroll');
|
||||||
|
expect(row?.className).not.toContain('overflow-x-auto');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type PlatformResourceFilterSearch = {
|
|||||||
inputRef?: Ref<HTMLInputElement>;
|
inputRef?: Ref<HTMLInputElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformResourceFilterBarProps<TId extends string = string> = {
|
type PlatformResourceFilterBarBaseProps<TId extends string = string> = {
|
||||||
/** 控件组无障碍名称,例如「资源筛选」。 */
|
/** 控件组无障碍名称,例如「资源筛选」。 */
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
/** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */
|
/** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */
|
||||||
@@ -30,14 +30,29 @@ export type PlatformResourceFilterBarProps<TId extends string = string> = {
|
|||||||
categoryItems: readonly PlatformResourceFilterOption<TId>[];
|
categoryItems: readonly PlatformResourceFilterOption<TId>[];
|
||||||
activeCategoryId: TId;
|
activeCategoryId: TId;
|
||||||
onCategoryChange: (id: TId) => void;
|
onCategoryChange: (id: TId) => void;
|
||||||
/** 派生标签库;为空时不渲染标签行。 */
|
|
||||||
tagItems?: readonly PlatformResourceTagOption[];
|
|
||||||
/** 已选标签(多选叠加)。 */
|
|
||||||
activeTags?: readonly string[];
|
|
||||||
onToggleTag?: (tag: string) => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `tagItems` 与 `onToggleTag` 是**成对的**:标签 chip 是带 `aria-pressed` 的按钮,
|
||||||
|
* 只给标签、不给处理函数就会渲染出一排「看起来可点、点了没反应」的控件。
|
||||||
|
* 这里用联合类型让这个非法状态在编译期就不可表达;`Component` 内部再兜一层
|
||||||
|
* `disabled`,供不带类型检查的调用方(JS / 动态构造的 props)也拿到诚实的行为。
|
||||||
|
*/
|
||||||
|
export type PlatformResourceFilterBarProps<TId extends string = string> =
|
||||||
|
| (PlatformResourceFilterBarBaseProps<TId> & {
|
||||||
|
tagItems?: undefined;
|
||||||
|
activeTags?: readonly string[];
|
||||||
|
onToggleTag?: undefined;
|
||||||
|
})
|
||||||
|
| (PlatformResourceFilterBarBaseProps<TId> & {
|
||||||
|
/** 派生标签库;为空数组时不渲染标签行。 */
|
||||||
|
tagItems: readonly PlatformResourceTagOption[];
|
||||||
|
/** 已选标签(多选叠加)。 */
|
||||||
|
activeTags?: readonly string[];
|
||||||
|
onToggleTag: (tag: string) => void;
|
||||||
|
});
|
||||||
|
|
||||||
function tagChipClassName(active: boolean) {
|
function tagChipClassName(active: boolean) {
|
||||||
return [
|
return [
|
||||||
'platform-category-chip gap-1.5 px-2.5 text-xs font-bold',
|
'platform-category-chip gap-1.5 px-2.5 text-xs font-bold',
|
||||||
@@ -104,7 +119,7 @@ export function PlatformResourceFilterBar<TId extends string = string>({
|
|||||||
/>
|
/>
|
||||||
{tagOptions.length > 0 ? (
|
{tagOptions.length > 0 ? (
|
||||||
<div
|
<div
|
||||||
className="platform-category-chip-scroll min-w-0 flex-1"
|
className="platform-category-chip-scroll scrollbar-hide min-w-0 flex-1"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`${ariaLabel}标签`}
|
aria-label={`${ariaLabel}标签`}
|
||||||
>
|
>
|
||||||
@@ -116,6 +131,9 @@ export function PlatformResourceFilterBar<TId extends string = string>({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
className={tagChipClassName(active)}
|
className={tagChipClassName(active)}
|
||||||
|
// 类型上 `tagItems` 必然带 `onToggleTag`;这一层兜底是给不带类型检查的
|
||||||
|
// 调用方(JS / 动态构造的 props):没有处理函数就不该渲染成可点按钮。
|
||||||
|
disabled={!onToggleTag}
|
||||||
onClick={() => onToggleTag?.(option.tag)}
|
onClick={() => onToggleTag?.(option.tag)}
|
||||||
>
|
>
|
||||||
<Tag className="h-3 w-3" aria-hidden="true" />
|
<Tag className="h-3 w-3" aria-hidden="true" />
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export function PlatformSegmentedTabs<TId extends string = string>({
|
|||||||
className={[
|
className={[
|
||||||
'platform-segmented-tabs',
|
'platform-segmented-tabs',
|
||||||
layout === 'scroll'
|
layout === 'scroll'
|
||||||
? 'flex min-w-0 items-center overflow-x-auto scrollbar-hide'
|
? 'platform-segmented-tabs--scroll flex min-w-0 items-center overflow-x-auto scrollbar-hide'
|
||||||
: 'grid',
|
: 'grid',
|
||||||
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
|
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
|
||||||
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
|
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
|
||||||
|
|||||||
@@ -519,13 +519,16 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 分段页签的子项不得被压到内容宽度以下。中文标签(例如「角色与对象」需要一个
|
/* 分段页签的**横滚**子项不得被压到内容宽度以下。中文标签(例如「角色与对象」需要一个
|
||||||
`1.5rem 内边距 + 4 个字`的宽度)在窄容器里一旦被压扁,标签虽然 `nowrap`,文字仍会
|
`1.5rem 内边距 + 4 个字`的宽度)在窄容器里一旦被压扁,标签虽然 `nowrap`,文字仍会
|
||||||
画出自己的盒子压到相邻项上——现场表现就是「后几项挤成一团、文字互相重叠」。
|
画出自己的盒子压到相邻项上——现场表现就是「后几项挤成一团、文字互相重叠」。
|
||||||
`min-width: max-content` 给每个子项一个「内容宽度」下限:放得下就照原样排,放不下
|
`min-width: max-content` 给每个子项一个「内容宽度」下限:放得下就照原样排,放不下
|
||||||
就由容器横向滚动兜底,任何情况下都不叠字。grid 与 scroll 两条 layout 都需要它:
|
就由容器横向滚动兜底(`layout="scroll"` 的容器有 `overflow-x-auto`),任何情况下都不叠字。
|
||||||
grid 的轨道按容器等分,scroll 的 flex 子项默认可收缩,两者都会压到内容宽度以下。 */
|
|
||||||
.platform-segmented-tabs > button {
|
这里**只作用于 scroll 布局**:grid 布局没有溢出容器,轨道是 `minmax(0, 1fr)`,
|
||||||
|
给子项加 `min-width: max-content` 只会让盒子宽过自己那条轨道、直接画到相邻格上,
|
||||||
|
正是本规则要防的重叠。grid 侧靠轨道等分 + 宿主的 `truncateLabels` 收口。 */
|
||||||
|
.platform-segmented-tabs--scroll > button {
|
||||||
min-width: max-content;
|
min-width: max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +26,7 @@ import {
|
|||||||
type GameCreationAppManifest,
|
type GameCreationAppManifest,
|
||||||
normalizeGameCreationAppAssetCategory,
|
normalizeGameCreationAppAssetCategory,
|
||||||
normalizeGameCreationAppAssetTags,
|
normalizeGameCreationAppAssetTags,
|
||||||
|
PROJECT_RESOURCE_CANVAS_SECTIONS,
|
||||||
selectGameCreationAppReadyTasks,
|
selectGameCreationAppReadyTasks,
|
||||||
} from './gameCreationApp';
|
} from './gameCreationApp';
|
||||||
|
|
||||||
@@ -933,7 +937,7 @@ describe('AI 游戏创作 App 共享契约', () => {
|
|||||||
* 「读显示」与「写回」是两个口径,只有写回口径等于落盘值。
|
* 「读显示」与「写回」是两个口径,只有写回口径等于落盘值。
|
||||||
*
|
*
|
||||||
* `gameCreationAppAssetCategory` 会把落盘 `unclassified` 自愈成 kind 派生值(读显示要正确),
|
* `gameCreationAppAssetCategory` 会把落盘 `unclassified` 自愈成 kind 派生值(读显示要正确),
|
||||||
* `gameCreationAppAssetPersistedCategory` 只做缺失 / 非法兜底(写回必须原样)。
|
* `gameCreationAppAssetPersistedCategory` 只做兜底、不套自愈(写回必须原样)。
|
||||||
* 「编辑标签」面板一旦用读显示口径回写,用户只改标签就会静默改分类。
|
* 「编辑标签」面板一旦用读显示口径回写,用户只改标签就会静默改分类。
|
||||||
*/
|
*/
|
||||||
it('写回口径保留落盘 unclassified,读显示口径才自愈', () => {
|
it('写回口径保留落盘 unclassified,读显示口径才自愈', () => {
|
||||||
@@ -941,23 +945,63 @@ describe('AI 游戏创作 App 共享契约', () => {
|
|||||||
expect(gameCreationAppAssetCategory(uiAsset)).toBe('ui-interaction');
|
expect(gameCreationAppAssetCategory(uiAsset)).toBe('ui-interaction');
|
||||||
expect(gameCreationAppAssetPersistedCategory(uiAsset)).toBe('unclassified');
|
expect(gameCreationAppAssetPersistedCategory(uiAsset)).toBe('unclassified');
|
||||||
|
|
||||||
// 落盘值非法或缺失时,两个口径都按 kind 派生(与 Rust 反序列化兜底一致)。
|
// 字段缺失时两个口径都按 kind 派生(与 Rust 反序列化的缺字段兜底一致)。
|
||||||
expect(
|
|
||||||
gameCreationAppAssetPersistedCategory({
|
|
||||||
kind: 'character',
|
|
||||||
category: 'future-category' as never,
|
|
||||||
}),
|
|
||||||
).toBe('character');
|
|
||||||
expect(
|
expect(
|
||||||
gameCreationAppAssetPersistedCategory({
|
gameCreationAppAssetPersistedCategory({
|
||||||
kind: 'character',
|
kind: 'character',
|
||||||
category: undefined,
|
category: undefined,
|
||||||
}),
|
}),
|
||||||
).toBe('character');
|
).toBe('character');
|
||||||
|
// 非法值这一支只服务内存对象:Rust 读侧对未知 category 失败关闭,
|
||||||
|
// 比本客户端新的值根本读不出 manifest,不会以「派生值」的形式出现在 UI 里。
|
||||||
|
expect(
|
||||||
|
gameCreationAppAssetPersistedCategory({
|
||||||
|
kind: 'character',
|
||||||
|
category: 'future-category' as never,
|
||||||
|
}),
|
||||||
|
).toBe('character');
|
||||||
|
|
||||||
// 落盘值合法且非 unclassified 时两个口径一致。
|
// 落盘值合法且非 unclassified 时两个口径一致。
|
||||||
expect(
|
expect(
|
||||||
gameCreationAppAssetPersistedCategory({ kind: 'ui', category: 'audio' }),
|
gameCreationAppAssetPersistedCategory({ kind: 'ui', category: 'audio' }),
|
||||||
).toBe('audio');
|
).toBe('audio');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分区顺序必须跨语言一致:Rust 侧 doc 明写 `PROJECT_RESOURCE_CANVAS_SECTIONS`
|
||||||
|
* 「必须与前端 `PROJECT_RESOURCE_CANVAS_SECTIONS` 保持一致」,但 Rust 单测只钉住自己那份的
|
||||||
|
* 序列化顺序,谁也发现不了另一侧改了顺序。这里解析 Rust 源码里的常量再与 TS 常量对齐,
|
||||||
|
* 由 `apps/ai-game-creator-shell/tests/assetKindCanonicalMapping.test.ts` 解析 Rust
|
||||||
|
* `EFFECTIVE_CATEGORY_CONTRACT` 的同一手法:两侧各写一份就一定会分叉。
|
||||||
|
*/
|
||||||
|
it('资源画布分区顺序与 Rust PROJECT_RESOURCE_CANVAS_SECTIONS 对齐', () => {
|
||||||
|
const rustSource = readFileSync(
|
||||||
|
resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'server-rs/crates/shared-contracts/src/game_creation_app.rs',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const start = rustSource.indexOf(
|
||||||
|
'pub const PROJECT_RESOURCE_CANVAS_SECTIONS',
|
||||||
|
);
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const body = rustSource.slice(
|
||||||
|
start,
|
||||||
|
rustSource.indexOf('];', start) + '];'.length,
|
||||||
|
);
|
||||||
|
const rustSections = Array.from(
|
||||||
|
body.matchAll(/ProjectResourceCanvasSection::(\w+)/gu),
|
||||||
|
).map((match) =>
|
||||||
|
// Rust 变体名 → kebab-case 线上值(与 `#[serde(rename_all = "kebab-case")]` 同口径)。
|
||||||
|
match[1]!.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rustSections).toEqual([...PROJECT_RESOURCE_CANVAS_SECTIONS]);
|
||||||
|
// 顺带钉住「资产分类轴 + 末尾独立 version」这条分区口径本身没有被改掉。
|
||||||
|
expect(PROJECT_RESOURCE_CANVAS_SECTIONS).toEqual([
|
||||||
|
...GAME_CREATION_APP_ASSET_CATEGORIES,
|
||||||
|
'version',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -626,7 +626,10 @@ export function gameCreationAppAssetCategoryForKind(
|
|||||||
* manifest 资产的最终功能分类。
|
* manifest 资产的最终功能分类。
|
||||||
*
|
*
|
||||||
* 优先级:落盘 `category`(用户可在「分类与标签」面板手动设置,是权威值)→ 按 `kind` 派生。
|
* 优先级:落盘 `category`(用户可在「分类与标签」面板手动设置,是权威值)→ 按 `kind` 派生。
|
||||||
* 历史 manifest 缺少 category、或 category 非法时都退回 kind 派生。
|
* 历史 manifest 缺少 category、或(内存对象上的)category 非法时都退回 kind 派生;
|
||||||
|
* 注意「落盘值非法」这一支已经不可达:Rust 读侧对未知 `category` 失败关闭(见
|
||||||
|
* `server-rs/crates/shared-contracts/src/game_creation_app.rs` 的
|
||||||
|
* `GameCreationAppAssetManifestEntryWire`),比本客户端新的值会让整份 manifest 直接读不出来。
|
||||||
*
|
*
|
||||||
* **例外(唯一的覆盖窗口)**:落盘值是 `unclassified`,而该资产 `kind` 能派生出明确的
|
* **例外(唯一的覆盖窗口)**:落盘值是 `unclassified`,而该资产 `kind` 能派生出明确的
|
||||||
* 非 `unclassified` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成
|
* 非 `unclassified` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成
|
||||||
@@ -667,7 +670,9 @@ export function gameCreationAppAssetCategory(
|
|||||||
* 静默改分类(真机已发生:同一条 `kind:"ui"` 资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。
|
* 静默改分类(真机已发生:同一条 `kind:"ui"` 资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。
|
||||||
*
|
*
|
||||||
* Rust 侧 manifest 反序列化(`game_creation_app.rs` 的 `GameCreationAppAssetManifestEntry`
|
* Rust 侧 manifest 反序列化(`game_creation_app.rs` 的 `GameCreationAppAssetManifestEntry`
|
||||||
* `Deserialize`)用的是同一口径,因此本函数结果必然等于该资产当前的落盘 `category`。
|
* `Deserialize`)在字段缺失时用同一套 `kind` 派生;未知分类值则在读侧失败关闭(不再退化成
|
||||||
|
* 派生值),所以从 manifest 读进来的资产本函数结果必然等于它的落盘 `category`。
|
||||||
|
* 剩下这一支「非法值 → 派生」只服务内存对象(手工构造的资产),不是落盘口径的兜底。
|
||||||
*/
|
*/
|
||||||
export function gameCreationAppAssetPersistedCategory(
|
export function gameCreationAppAssetPersistedCategory(
|
||||||
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
|
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
|
||||||
|
|||||||
@@ -49,6 +49,25 @@ describe('AI 游戏创作 App 标签库派生', () => {
|
|||||||
expect(buildGameCreationAppAssetTagLibrary([])).toEqual([]);
|
expect(buildGameCreationAppAssetTagLibrary([])).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sorts asset ids deterministically without a locale-dependent compare', () => {
|
||||||
|
// `localeCompare` 用运行环境默认 locale:同一份 manifest 在不同机器上可能排出不同顺序,
|
||||||
|
// 而这里承诺的是「与输入顺序无关的稳定升序」。素材 id 是 ASCII,code-unit 顺序即可。
|
||||||
|
const library = buildGameCreationAppAssetTagLibrary([
|
||||||
|
{ id: 'asset-10', tags: ['像素风'] },
|
||||||
|
{ id: 'asset-9', tags: ['像素风'] },
|
||||||
|
{ id: 'asset-100', tags: ['像素风'] },
|
||||||
|
{ id: 'Asset-2', tags: ['像素风'] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(library).toEqual([
|
||||||
|
{
|
||||||
|
tag: '像素风',
|
||||||
|
assetCount: 4,
|
||||||
|
assetIds: ['Asset-2', 'asset-10', 'asset-100', 'asset-9'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('requires every selected tag to match and treats an empty selection as no filter', () => {
|
it('requires every selected tag to match and treats an empty selection as no filter', () => {
|
||||||
const tags = ['像素风', '主角'];
|
const tags = ['像素风', '主角'];
|
||||||
|
|
||||||
@@ -62,4 +81,18 @@ describe('AI 游戏创作 App 标签库派生', () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes the selected tags the same way as the asset tags', () => {
|
||||||
|
// 对称归一化:素材侧 tags 会去空白,已选标签若原样比较,`' 像素风 '` 永远匹配不上。
|
||||||
|
const tags = [' 像素风 ', '主角'];
|
||||||
|
|
||||||
|
expect(assetTagsMatchSelection(tags, [' 像素风 '])).toBe(true);
|
||||||
|
expect(assetTagsMatchSelection(tags, ['像素风', ' 主角 '])).toBe(true);
|
||||||
|
// 归一化后为空的选中项等于「没有选中任何标签」→ 不过滤,与空选择同一条语义。
|
||||||
|
expect(assetTagsMatchSelection(tags, [' '])).toBe(true);
|
||||||
|
// 归一化不会把「去空白后仍然不同的标签」误判为命中。
|
||||||
|
expect(assetTagsMatchSelection(tags, ['像素风 '])).toBe(true);
|
||||||
|
expect(assetTagsMatchSelection(tags, [' 场景 '])).toBe(false);
|
||||||
|
expect(gameCreationAppAssetMatchesTags({ tags }, [' 像素风 '])).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export function buildGameCreationAppAssetTagLibrary(
|
|||||||
return Array.from(byTag, ([tag, assetIds]) => ({
|
return Array.from(byTag, ([tag, assetIds]) => ({
|
||||||
tag,
|
tag,
|
||||||
assetCount: assetIds.length,
|
assetCount: assetIds.length,
|
||||||
assetIds: [...assetIds].sort((left, right) => left.localeCompare(right)),
|
// 确定性 code-unit 升序:`localeCompare` 的默认 locale 随环境变化,会让同一份 manifest
|
||||||
|
// 在不同机器上排出不同的 `assetIds`。素材 id 是 ASCII(UUID / 数字串 / `asset-1` 之类),
|
||||||
|
// code-unit 顺序就是稳定升序;标签那一层另有 `zh-CN` 显式口径,两者不要混用。
|
||||||
|
assetIds: [...assetIds].sort(),
|
||||||
})).sort(
|
})).sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
right.assetCount - left.assetCount ||
|
right.assetCount - left.assetCount ||
|
||||||
@@ -59,6 +62,9 @@ export function buildGameCreationAppAssetTagLibrary(
|
|||||||
/**
|
/**
|
||||||
* 标签筛选判据:已选标签必须全部命中(AND),空选择视为不过滤。
|
* 标签筛选判据:已选标签必须全部命中(AND),空选择视为不过滤。
|
||||||
* 与 `category` 筛选共存时由调用方叠加,本函数只看标签。
|
* 与 `category` 筛选共存时由调用方叠加,本函数只看标签。
|
||||||
|
*
|
||||||
|
* 两侧都过 `normalizeGameCreationAppAssetTags`:素材侧 tags 会去空白,已选标签如果原样比较,
|
||||||
|
* `' 像素风 '` 这种带空白的选中项会永远匹配不上素材上已归一化的 `'像素风'`。
|
||||||
*/
|
*/
|
||||||
export function assetTagsMatchSelection(
|
export function assetTagsMatchSelection(
|
||||||
tags: readonly string[] | undefined,
|
tags: readonly string[] | undefined,
|
||||||
@@ -66,7 +72,8 @@ export function assetTagsMatchSelection(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (selectedTags.length === 0) return true;
|
if (selectedTags.length === 0) return true;
|
||||||
const normalized = new Set(normalizeGameCreationAppAssetTags(tags ?? []));
|
const normalized = new Set(normalizeGameCreationAppAssetTags(tags ?? []));
|
||||||
return selectedTags.every((tag) => normalized.has(tag));
|
const selected = normalizeGameCreationAppAssetTags(selectedTags);
|
||||||
|
return selected.every((tag) => normalized.has(tag));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** manifest 资产条目上的标签筛选,口径与 `assetTagsMatchSelection` 一致。 */
|
/** manifest 资产条目上的标签筛选,口径与 `assetTagsMatchSelection` 一致。 */
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* pre-commit 的 Rust 格式检查只按 Cargo workspace 粒度执行:`cargo fmt` 不接受「只格式化
|
||||||
|
* 某个文件」(按文件跑 rustfmt 又会丢掉 manifest 的 edition 口径),所以 hook 收到的是
|
||||||
|
* lint-staged 追加的暂存 `.rs` 路径,而真正执行的是 `cargo fmt --all --manifest-path ...`。
|
||||||
|
*
|
||||||
|
* 这段映射逻辑单独成模块,是为了能脱离 cargo 单测:`--all` 会连**未暂存**的在改文件一起
|
||||||
|
* 检查,如果无脑跑完所有 workspace,一个只暂存了 server-rs 干净文件的提交会被「别人正在改的
|
||||||
|
* src-tauri 文件没格式化」挡下来。按暂存路径筛选 workspace 才是「只查暂存的」的正确粒度。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 受检查的 workspace:`prefix` 是仓库相对路径前缀,`manifestPath` 是它的 Cargo manifest。 */
|
||||||
|
export const RUSTFMT_WORKSPACES = [
|
||||||
|
{ prefix: 'server-rs/', manifestPath: 'server-rs/Cargo.toml' },
|
||||||
|
{
|
||||||
|
prefix: 'apps/ai-game-creator-shell/src-tauri/',
|
||||||
|
manifestPath: 'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 有意排除的第三个 workspace:`apps/desktop-shell/src-tauri`。
|
||||||
|
*
|
||||||
|
* `package.json` 的 `*.rs` glob 会命中它的文件,但仓库当前**整体**不过
|
||||||
|
* `cargo fmt --check`(2026-09 实测 `apps/desktop-shell/src-tauri/src/host_bridge/mod.rs`
|
||||||
|
* 等仍待格式化)。直接纳入会让任何一次只改了别处的 Rust 提交因为历史未格式化文件而失败,
|
||||||
|
* 所以这里显式记录「不查」,而不是让它静默通过:CLI 会对命中该前缀的暂存文件打印提示。
|
||||||
|
* 纳入前先单独跑一次
|
||||||
|
* `cargo fmt --all --manifest-path apps/desktop-shell/src-tauri/Cargo.toml` 并把它加进
|
||||||
|
* `RUSTFMT_WORKSPACES`(同时补 `package.json` 的 `check:rustfmt` / `format:rust`)。
|
||||||
|
*/
|
||||||
|
export const RUSTFMT_EXCLUDED_WORKSPACES = [
|
||||||
|
{ prefix: 'apps/desktop-shell/src-tauri/' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 把 lint-staged 传来的路径统一成「仓库相对 + 正斜杠」形式,便于前缀比较。 */
|
||||||
|
function normalizeStagedPath(filePath, repoRoot) {
|
||||||
|
if (typeof filePath !== 'string') return null;
|
||||||
|
const normalized = filePath.replaceAll('\\', '/').replace(/^\.\//u, '');
|
||||||
|
if (normalized.length === 0) return null;
|
||||||
|
|
||||||
|
const normalizedRoot = repoRoot?.replaceAll('\\', '/').replace(/\/+$/u, '');
|
||||||
|
if (!normalizedRoot) return normalized;
|
||||||
|
|
||||||
|
const isAbsolute =
|
||||||
|
/^[A-Za-z]:\//u.test(normalized) || normalized.startsWith('/');
|
||||||
|
if (!isAbsolute) return normalized;
|
||||||
|
|
||||||
|
const windowsRoot = normalizedRoot.startsWith('/')
|
||||||
|
? normalizedRoot
|
||||||
|
: `/${normalizedRoot}`;
|
||||||
|
const absolutePrefix = `${normalizedRoot}/`;
|
||||||
|
const absolutePrefixWindows = `${windowsRoot}/`;
|
||||||
|
const lowerPath = normalized.toLowerCase();
|
||||||
|
for (const prefix of [absolutePrefix, absolutePrefixWindows]) {
|
||||||
|
if (lowerPath.startsWith(prefix.toLowerCase())) {
|
||||||
|
return normalized.slice(prefix.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由暂存 `.rs` 路径筛出要跑 `cargo fmt --check` 的 workspace。
|
||||||
|
*
|
||||||
|
* - 没有暂存文件列表(手工执行)时保持旧的「全量检查」语义;
|
||||||
|
* - 只检查真的被暂存文件命中的 workspace;
|
||||||
|
* - 命中排除前缀 / 任何已知 workspace 之外的暂存文件单独回报,由调用方提示,
|
||||||
|
* 避免「glob 命中了但谁都没查」这种静默通过。
|
||||||
|
*/
|
||||||
|
export function selectRustfmtWorkspaces({ stagedFiles, repoRoot }) {
|
||||||
|
const normalized = (stagedFiles ?? [])
|
||||||
|
.map((filePath) => normalizeStagedPath(filePath, repoRoot))
|
||||||
|
.filter((filePath) => filePath !== null);
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return {
|
||||||
|
workspaces: RUSTFMT_WORKSPACES,
|
||||||
|
excluded: [],
|
||||||
|
unmanaged: [],
|
||||||
|
usedStagedList: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hitsPrefix = (filePath, prefix) => filePath.startsWith(prefix);
|
||||||
|
const hitsAnyPrefix = (filePath, entries) =>
|
||||||
|
entries.some(({ prefix }) => hitsPrefix(filePath, prefix));
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaces: RUSTFMT_WORKSPACES.filter((workspace) =>
|
||||||
|
normalized.some((filePath) => hitsPrefix(filePath, workspace.prefix)),
|
||||||
|
),
|
||||||
|
excluded: normalized.filter((filePath) =>
|
||||||
|
hitsAnyPrefix(filePath, RUSTFMT_EXCLUDED_WORKSPACES),
|
||||||
|
),
|
||||||
|
unmanaged: normalized.filter(
|
||||||
|
(filePath) =>
|
||||||
|
!hitsAnyPrefix(filePath, RUSTFMT_WORKSPACES) &&
|
||||||
|
!hitsAnyPrefix(filePath, RUSTFMT_EXCLUDED_WORKSPACES),
|
||||||
|
),
|
||||||
|
usedStagedList: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,15 +1,43 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { selectRustfmtWorkspaces } from './lint-staged-rustfmt-workspaces.mjs';
|
||||||
|
|
||||||
// lint-staged 会把命中的暂存文件路径追加到命令末尾,而 `cargo fmt` 只按 workspace 粒度格式化、
|
// lint-staged 会把命中的暂存文件路径追加到命令末尾,而 `cargo fmt` 只按 workspace 粒度格式化、
|
||||||
// 不接受文件参数(也做不到「只格式化某个文件」),所以这里忽略 argv,直接对两个 workspace 跑
|
// 不接受文件参数(也做不到「只格式化某个文件」),所以这里用暂存路径筛出**需要检查的
|
||||||
// `--check`。只查不改:pre-commit 不应该自动改写别人正在改的 Rust 文件。
|
// workspace**,再对它们跑 `--check`。只查不改:pre-commit 不应该自动改写别人正在改的 Rust 文件。
|
||||||
const workspaces = [
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
'server-rs/Cargo.toml',
|
const { workspaces, excluded, unmanaged, usedStagedList } =
|
||||||
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
selectRustfmtWorkspaces({
|
||||||
];
|
stagedFiles: process.argv.slice(2),
|
||||||
|
repoRoot,
|
||||||
|
});
|
||||||
|
|
||||||
for (const manifestPath of workspaces) {
|
// 静默通过是这里最贵的失败模式:glob 命中了文件、但没有任何 workspace 检查到它。
|
||||||
|
if (excluded.length > 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Rust 格式检查跳过(有意排除的 workspace):${excluded.join(', ')}\n` +
|
||||||
|
`该 workspace 当前整体未过 \`cargo fmt --check\`,详见 scripts/lint-staged-rustfmt-workspaces.mjs。\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (unmanaged.length > 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Rust 格式检查未覆盖以下暂存文件(不在任何已登记 workspace 内):${unmanaged.join(', ')}\n` +
|
||||||
|
`如果是新 workspace,请登记到 scripts/lint-staged-rustfmt-workspaces.mjs。\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一条都没筛出来又不曾拿到暂存列表,说明调用方式变了(例如 lint-staged 不再追加参数):
|
||||||
|
// 宁可回退到全量检查,也不要假装检查过了。
|
||||||
|
if (!usedStagedList) {
|
||||||
|
process.stderr.write(
|
||||||
|
'Rust 格式检查未收到 lint-staged 的暂存文件列表,回退为全量检查所有 workspace。\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { manifestPath } of workspaces) {
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
'cargo',
|
'cargo',
|
||||||
['fmt', '--all', '--manifest-path', manifestPath, '--', '--check'],
|
['fmt', '--all', '--manifest-path', manifestPath, '--', '--check'],
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
RUSTFMT_EXCLUDED_WORKSPACES,
|
||||||
|
RUSTFMT_WORKSPACES,
|
||||||
|
selectRustfmtWorkspaces,
|
||||||
|
} from './lint-staged-rustfmt-workspaces.mjs';
|
||||||
|
|
||||||
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
function selectedManifests(stagedFiles: string[], root = repoRoot) {
|
||||||
|
return selectRustfmtWorkspaces({
|
||||||
|
stagedFiles,
|
||||||
|
repoRoot: root,
|
||||||
|
}).workspaces.map((workspace) => workspace.manifestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('lint-staged Rust 格式检查的 workspace 选择', () => {
|
||||||
|
test('只暂存 server-rs 时不再连带检查 src-tauri', () => {
|
||||||
|
// 回归判据:`cargo fmt --all` 会连未暂存的在改文件一起查,全量跑两个 workspace
|
||||||
|
// 会让「只暂存了 server-rs 干净文件」的提交被别人的 src-tauri 改动挡下来。
|
||||||
|
expect(
|
||||||
|
selectedManifests([
|
||||||
|
'server-rs/crates/api-server/src/editor_project.rs',
|
||||||
|
'server-rs/crates/shared-contracts/src/game_creation_app.rs',
|
||||||
|
]),
|
||||||
|
).toEqual(['server-rs/Cargo.toml']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('只暂存 src-tauri 时不再连带检查 server-rs', () => {
|
||||||
|
expect(
|
||||||
|
selectedManifests(['apps/ai-game-creator-shell/src-tauri/src/assets.rs']),
|
||||||
|
).toEqual(['apps/ai-game-creator-shell/src-tauri/Cargo.toml']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('两个 workspace 都有暂存文件时两个都查', () => {
|
||||||
|
expect(
|
||||||
|
selectedManifests([
|
||||||
|
'server-rs/crates/api-server/src/editor_project.rs',
|
||||||
|
'apps/ai-game-creator-shell/src-tauri/src/assets.rs',
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
'server-rs/Cargo.toml',
|
||||||
|
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('手工执行(没有暂存列表)时保持全量检查语义', () => {
|
||||||
|
const selection = selectRustfmtWorkspaces({ stagedFiles: [], repoRoot });
|
||||||
|
expect(selection.usedStagedList).toBe(false);
|
||||||
|
expect(selection.workspaces).toEqual(RUSTFMT_WORKSPACES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('排除的 workspace 与未登记路径都会被显式报出,不静默通过', () => {
|
||||||
|
const selection = selectRustfmtWorkspaces({
|
||||||
|
stagedFiles: [
|
||||||
|
'apps/desktop-shell/src-tauri/src/host_bridge/mod.rs',
|
||||||
|
'somewhere-new/src/lib.rs',
|
||||||
|
],
|
||||||
|
repoRoot,
|
||||||
|
});
|
||||||
|
// desktop-shell 当前整体未过 `cargo fmt --check`,所以它不参与检查……
|
||||||
|
expect(selection.workspaces).toEqual([]);
|
||||||
|
// ……但必须被点名,否则「glob 命中了、谁都没查」就是静默通过。
|
||||||
|
expect(selection.excluded).toEqual([
|
||||||
|
'apps/desktop-shell/src-tauri/src/host_bridge/mod.rs',
|
||||||
|
]);
|
||||||
|
expect(selection.unmanaged).toEqual(['somewhere-new/src/lib.rs']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows 反斜杠与绝对路径都归一化成仓库相对路径', () => {
|
||||||
|
const root = 'C:\\repo\\genarrative';
|
||||||
|
expect(
|
||||||
|
selectedManifests(
|
||||||
|
[
|
||||||
|
'server-rs\\crates\\api-server\\src\\editor_project.rs',
|
||||||
|
`${root}\\apps\\ai-game-creator-shell\\src-tauri\\src\\assets.rs`,
|
||||||
|
],
|
||||||
|
root,
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
'server-rs/Cargo.toml',
|
||||||
|
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('检查清单与 package.json 的 check:rustfmt 保持同一份 workspace 表', () => {
|
||||||
|
// 两处各写一份 workspace 列表就会漂移:hook 查的与 `npm run check:rustfmt` 查的必须同集。
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
readFileSync(resolve(repoRoot, 'package.json'), 'utf8'),
|
||||||
|
) as { scripts: Record<string, string> };
|
||||||
|
const manifests = Array.from(
|
||||||
|
packageJson.scripts['check:rustfmt']!.matchAll(
|
||||||
|
/--manifest-path\s+(\S+)\s+--\s+--check/gu,
|
||||||
|
),
|
||||||
|
).map((match) => match[1]!);
|
||||||
|
expect(manifests).toEqual(
|
||||||
|
RUSTFMT_WORKSPACES.map((workspace) => workspace.manifestPath),
|
||||||
|
);
|
||||||
|
// 排除项必须是「检查清单之外」的,否则这段注释与实际行为不符。
|
||||||
|
for (const excluded of RUSTFMT_EXCLUDED_WORKSPACES) {
|
||||||
|
expect(
|
||||||
|
RUSTFMT_WORKSPACES.some((workspace) =>
|
||||||
|
workspace.prefix.startsWith(excluded.prefix),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4117,7 +4117,7 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 {
|
|||||||
///
|
///
|
||||||
/// 必须与 `media_type == image` 一起构成 AND 门:视频、音频、序列帧等非静态媒体即使挂着
|
/// 必须与 `media_type == image` 一起构成 AND 门:视频、音频、序列帧等非静态媒体即使挂着
|
||||||
/// 图片类 assetKind 也照旧拒绝。
|
/// 图片类 assetKind 也照旧拒绝。
|
||||||
pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 17] = [
|
pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 18] = [
|
||||||
// canonical 静态图类型
|
// canonical 静态图类型
|
||||||
"image",
|
"image",
|
||||||
"scene",
|
"scene",
|
||||||
@@ -4138,6 +4138,11 @@ pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 17] = [
|
|||||||
"ui-prototype",
|
"ui-prototype",
|
||||||
// AGC 本地 manifest 等在用的等价静态图类型
|
// AGC 本地 manifest 等在用的等价静态图类型
|
||||||
"ui",
|
"ui",
|
||||||
|
// `register_local_asset_entry`(assets.rs)空 kind 兜底写的是 `"asset"`:
|
||||||
|
// 未归类上传的 PNG 会以 `kind: "asset"` 登记,而共享契约把 `"asset"` canonical 化成
|
||||||
|
// `"image"`,漏掉它就等于把「未归类静态图」挡在快速编辑之外(400)。
|
||||||
|
// 白名单必须在 canonical 静态图别名下封闭。
|
||||||
|
"asset",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub(crate) fn ensure_editor_image_edit_source_kind_allowed(
|
pub(crate) fn ensure_editor_image_edit_source_kind_allowed(
|
||||||
@@ -20738,13 +20743,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 实测 400×5 的回归用例:AGC「图片快速编辑」发出的请求,来源资源的 assetKind 是本地
|
/// 实测 400×5 的回归用例:AGC「图片快速编辑」发出的请求,来源资源的 assetKind 是本地
|
||||||
/// manifest 原始类型 `art-spritesheet` / `ui`(mediaType=image)。这条路径必须放行;
|
/// manifest 原始类型 `art-spritesheet` / `ui` / `asset`(mediaType=image)。这条路径必须
|
||||||
/// 非静态媒体(video/audio/image-sequence)必须继续拒绝。
|
/// 放行;非静态媒体(video/audio/image-sequence)必须继续拒绝。
|
||||||
|
///
|
||||||
|
/// 夹具必须真的走进被测路径,不能是自洽空断言:请求体先反序列化成
|
||||||
|
/// `EditorImageEditRequest`,再从**请求体自己的** `generationInputs.assetKind` 取类型喂给
|
||||||
|
/// 放行判据;并用源码级闸门断言钉住
|
||||||
|
/// `resolve_editor_image_edit_source` / `ensure_editor_image_edit_target_matches_source`
|
||||||
|
/// 两个函数体内不得再出现 `generation_inputs`(历史上 `generationInputs.source` 参与过
|
||||||
|
/// gating,把 AGC 请求整体挡成 400;解析与对账需要 DB/OSS,源码级断言是无需基础设施
|
||||||
|
/// 也能钉住这条回归的唯一位置)。
|
||||||
#[test]
|
#[test]
|
||||||
fn game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds() {
|
fn game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds() {
|
||||||
// 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形;放行判据只看来源资源的
|
// 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形。
|
||||||
// 权威 assetKind 与 mediaType,请求体里的 `generationInputs.source` 不参与。
|
let agc_payload: EditorImageEditRequest = serde_json::from_str(
|
||||||
let agc_payload: Value = serde_json::from_str(
|
|
||||||
r#"{
|
r#"{
|
||||||
"prompt": "把这张图改成夜间配色",
|
"prompt": "把这张图改成夜间配色",
|
||||||
"sourceReferenceId": "resource-art-spritesheet",
|
"sourceReferenceId": "resource-art-spritesheet",
|
||||||
@@ -20754,18 +20766,55 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}"#,
|
}"#,
|
||||||
)
|
)
|
||||||
.expect("AGC quick edit payload should be JSON");
|
.expect("AGC quick edit payload should deserialize into the request DTO");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
agc_payload
|
agc_payload.source_reference_id, "resource-art-spritesheet",
|
||||||
.pointer("/generationInputs/assetKind")
|
"回归夹具必须保持 AGC 客户端实际发送的请求形状"
|
||||||
.and_then(Value::as_str),
|
);
|
||||||
|
assert_eq!(
|
||||||
|
agc_payload.target_layer_id, None,
|
||||||
|
"AGC 快速编辑不带 targetLayerId,走的是「主来源即目标」分支"
|
||||||
|
);
|
||||||
|
// 关键:放行判据只吃来源资源的权威 assetKind 与 mediaType。这里用请求体自己声明的
|
||||||
|
// assetKind 驱动判据——请求体里同时带着 `generationInputs.source`,它不得参与 gating。
|
||||||
|
let requested_kind = agc_payload
|
||||||
|
.generation_inputs
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|inputs| inputs.pointer("/assetKind"))
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
assert_eq!(
|
||||||
|
requested_kind,
|
||||||
Some("art-spritesheet"),
|
Some("art-spritesheet"),
|
||||||
"回归夹具必须保持 AGC 客户端实际发送的请求形状"
|
"回归夹具必须保持 AGC 客户端实际发送的请求形状"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
ensure_editor_image_edit_source_kind_allowed(
|
||||||
|
normalize_editor_image_edit_resolved_source_kind(requested_kind).as_deref(),
|
||||||
|
Some("image"),
|
||||||
|
)
|
||||||
|
.is_ok(),
|
||||||
|
"AGC 快速编辑请求不得因为 generationInputs.source 被拒"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 源码级闸门:请求解析与目标对账都不得重新引入 generationInputs 门槛。
|
||||||
|
let source = include_str!("editor_project.rs");
|
||||||
|
assert_function_not_contains(
|
||||||
|
source,
|
||||||
|
"async fn resolve_editor_image_edit_source(",
|
||||||
|
"fn ensure_editor_image_edit_source_snapshot_matches(",
|
||||||
|
&["generation_inputs", "generationInputs"],
|
||||||
|
);
|
||||||
|
assert_function_not_contains(
|
||||||
|
source,
|
||||||
|
"fn ensure_editor_image_edit_target_matches_source(",
|
||||||
|
"async fn resolve_editor_image_edit_source(",
|
||||||
|
&["generation_inputs", "generationInputs"],
|
||||||
|
);
|
||||||
|
|
||||||
for (asset_kind, media_type) in [
|
for (asset_kind, media_type) in [
|
||||||
("art-spritesheet", "image"),
|
("art-spritesheet", "image"),
|
||||||
("ui", "image"),
|
("ui", "image"),
|
||||||
|
("asset", "image"),
|
||||||
("ui-prototype", "image"),
|
("ui-prototype", "image"),
|
||||||
("game-art", "image"),
|
("game-art", "image"),
|
||||||
("game-background", "image"),
|
("game-background", "image"),
|
||||||
@@ -20781,6 +20830,7 @@ mod tests {
|
|||||||
for (asset_kind, media_type) in [
|
for (asset_kind, media_type) in [
|
||||||
("art-spritesheet", "video"),
|
("art-spritesheet", "video"),
|
||||||
("ui", "audio"),
|
("ui", "audio"),
|
||||||
|
("asset", "video"),
|
||||||
("video", "video"),
|
("video", "video"),
|
||||||
("sound-effect", "audio"),
|
("sound-effect", "audio"),
|
||||||
("background-music", "audio"),
|
("background-music", "audio"),
|
||||||
|
|||||||
@@ -502,13 +502,18 @@ pub struct GameCreationAppAssetManifestEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值;
|
/// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值;
|
||||||
/// 显式写入的合法分类必须原样保留,未知分类值按前向兼容退回 `kind` 派生。
|
/// 显式写入的合法分类必须原样保留,未知分类值**失败关闭**(见下方 `Deserialize` 实现)。
|
||||||
///
|
///
|
||||||
/// 这里**刻意不做读时自愈**(落盘 `unclassified` 而 kind 能派生明确分类时改用派生值):
|
/// 这里**刻意不做读时自愈**(落盘 `unclassified` 而 kind 能派生明确分类时改用派生值):
|
||||||
/// 反序列化结果就是落盘原值,「编辑标签」面板要靠它把落盘分类原样回写,否则用户只改标签
|
/// 反序列化结果就是落盘原值,「编辑标签」面板要靠它把落盘分类原样回写,否则用户只改标签
|
||||||
/// 也会静默改分类。自愈只属于读显示口径,见 `game_creation_app_asset_effective_category`。
|
/// 也会静默改分类。自愈只属于读显示口径,见 `game_creation_app_asset_effective_category`。
|
||||||
|
///
|
||||||
|
/// `deny_unknown_fields` 与顶层 `GameCreationAppManifest` 同一取向:AGC 客户端是
|
||||||
|
/// 「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,资产级未知字段(例如未来版本的
|
||||||
|
/// `rotation` / `animations`)一旦被静默丢弃,就会在任意一次写入里被抹掉。顶层有这道门、
|
||||||
|
/// 资产级没有的话,等于只挡住了一半的静默数据丢失。
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||||
struct GameCreationAppAssetManifestEntryWire {
|
struct GameCreationAppAssetManifestEntryWire {
|
||||||
id: String,
|
id: String,
|
||||||
kind: String,
|
kind: String,
|
||||||
@@ -531,11 +536,19 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry {
|
|||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
{
|
{
|
||||||
let wire = GameCreationAppAssetManifestEntryWire::deserialize(deserializer)?;
|
let wire = GameCreationAppAssetManifestEntryWire::deserialize(deserializer)?;
|
||||||
let category = wire
|
// 未知 `category` 必须失败关闭,不能退回 `kind` 派生值:反序列化结果就是「编辑标签」
|
||||||
.category
|
// 面板要原样回写的落盘原值,静默替换成派生值会让「只改标签」变成静默改分类
|
||||||
.as_deref()
|
// (写侧整结构体覆盖落盘,替换值会真的写回文件)。派生只属于缺字段的读兼容,
|
||||||
.and_then(game_creation_app_asset_category_from_str)
|
// 以及读显示口径 `game_creation_app_asset_effective_category`。
|
||||||
.unwrap_or_else(|| game_creation_app_asset_category_for_kind(&wire.kind));
|
let category = match wire.category.as_deref() {
|
||||||
|
None => game_creation_app_asset_category_for_kind(&wire.kind),
|
||||||
|
Some(raw) => game_creation_app_asset_category_from_str(raw).ok_or_else(|| {
|
||||||
|
<D::Error as serde::de::Error>::custom(format!(
|
||||||
|
"未知的素材分类 category = {raw}:本客户端会整结构体重写 manifest,\
|
||||||
|
无法原样回写该值,因此失败关闭(请升级客户端)"
|
||||||
|
))
|
||||||
|
})?,
|
||||||
|
};
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: wire.id,
|
id: wire.id,
|
||||||
kind: wire.kind,
|
kind: wire.kind,
|
||||||
@@ -2292,7 +2305,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn asset_manifest_entry_keeps_explicit_category_and_derives_unknown_category_from_kind() {
|
fn asset_manifest_entry_keeps_explicit_category_and_rejects_unknown_category() {
|
||||||
let mut explicit_unclassified = asset_entry_json("character");
|
let mut explicit_unclassified = asset_entry_json("character");
|
||||||
explicit_unclassified["category"] = json!("unclassified");
|
explicit_unclassified["category"] = json!("unclassified");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2307,18 +2320,56 @@ mod tests {
|
|||||||
GameCreationAppAssetCategory::Audio
|
GameCreationAppAssetCategory::Audio
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut unknown_category = asset_entry_json("character");
|
// 未知分类失败关闭:不能退回 `kind` 派生值。否则「读一次 + 任意一次写」会把
|
||||||
unknown_category["category"] = json!("future-category");
|
// 未来客户端写下的 `category` 静默改成本机派生值,等于无迁移地改落盘数据。
|
||||||
assert_eq!(
|
for kind in ["character", "image"] {
|
||||||
asset_entry_from_json(unknown_category).category,
|
let mut unknown_category = asset_entry_json(kind);
|
||||||
GameCreationAppAssetCategory::Character
|
unknown_category["category"] = json!("future-category");
|
||||||
|
let error =
|
||||||
|
serde_json::from_value::<GameCreationAppAssetManifestEntry>(unknown_category)
|
||||||
|
.expect_err("an unknown asset category must fail closed");
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("future-category"),
|
||||||
|
"unexpected error for kind={kind}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 资产级未知字段与顶层同一取向:必须失败关闭,而不是被 serde 静默丢弃。
|
||||||
|
///
|
||||||
|
/// AGC 的写路径是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,静默丢弃等于
|
||||||
|
/// 「读一次 + 任意一次写」抹掉未来版本新增的资产字段(例如 `rotation` / `animations`)。
|
||||||
|
#[test]
|
||||||
|
fn asset_manifest_entry_rejects_unknown_asset_fields() {
|
||||||
|
let mut payload = asset_entry_json("character");
|
||||||
|
payload["rotation"] = json!(90);
|
||||||
|
|
||||||
|
let error = serde_json::from_value::<GameCreationAppAssetManifestEntry>(payload)
|
||||||
|
.expect_err("an unknown asset-level field must fail closed");
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("rotation"),
|
||||||
|
"unexpected error: {error}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut unknown_category_without_kind_semantics = asset_entry_json("image");
|
// 已知字段(含可选字段)必须照旧往返:拒绝未知字段不得顺手把已知字段一起拒掉。
|
||||||
unknown_category_without_kind_semantics["category"] = json!("future-category");
|
let entry = asset_entry_from_json(asset_entry_json("character"));
|
||||||
assert_eq!(
|
let round_tripped: GameCreationAppAssetManifestEntry =
|
||||||
asset_entry_from_json(unknown_category_without_kind_semantics).category,
|
serde_json::from_value(serde_json::to_value(&entry).expect("asset entry serializes"))
|
||||||
GameCreationAppAssetCategory::Unclassified
|
.expect("known asset fields must still deserialize");
|
||||||
|
assert_eq!(round_tripped, entry);
|
||||||
|
|
||||||
|
// 顶层 manifest 也走同一条资产反序列化路径,未知资产字段不得只在裸条目上失败。
|
||||||
|
let mut manifest =
|
||||||
|
serde_json::to_value(new_game_creation_app_manifest("project-1", "像素动作原型"))
|
||||||
|
.expect("manifest should serialize");
|
||||||
|
let mut asset = asset_entry_json("character");
|
||||||
|
asset["animations"] = json!([{ "name": "idle" }]);
|
||||||
|
manifest["assets"] = json!([asset]);
|
||||||
|
let error = serde_json::from_value::<GameCreationAppManifest>(manifest)
|
||||||
|
.expect_err("an unknown asset field must fail the whole manifest read");
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("animations"),
|
||||||
|
"unexpected error: {error}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ type FloatingOptionBoundaryRef = {
|
|||||||
readonly current: HTMLElement | null;
|
readonly current: HTMLElement | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 浮层边界登记:谁算「浮层内部」,关闭判定与滚轮归属共用同一份。 */
|
||||||
|
type FloatingOverlayBoundaryOptions = {
|
||||||
|
boundaryRefs?: Array<FloatingOptionBoundaryRef | null | undefined>;
|
||||||
|
isInsideExtraOverlay?: (target: EventTarget | null) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type UseImageCanvasFloatingOptionDismissOptions = {
|
type UseImageCanvasFloatingOptionDismissOptions = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
boundaryRefs: Array<FloatingOptionBoundaryRef | null | undefined>;
|
boundaryRefs: Array<FloatingOptionBoundaryRef | null | undefined>;
|
||||||
@@ -38,10 +44,62 @@ function isEventInsideBoundary(
|
|||||||
function isEventInsideFloatingMenu(target: EventTarget | null) {
|
function isEventInsideFloatingMenu(target: EventTarget | null) {
|
||||||
return (
|
return (
|
||||||
target instanceof Element &&
|
target instanceof Element &&
|
||||||
target.closest('.image-canvas-editor__portal-menu')
|
target.closest('.image-canvas-editor__portal-menu') !== null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事件是否落在「已登记为浮层」的那几块 DOM 里。
|
||||||
|
*
|
||||||
|
* 这些浮层 portal 到 `document.body`(共享弹出层)或由宿主额外登记(`isInsideExtraOverlay`),
|
||||||
|
* 关闭判定与滚轮归属共用这一份口径,避免两处各写一套白名单。
|
||||||
|
*/
|
||||||
|
function isEventInsideRegisteredOverlay(
|
||||||
|
target: EventTarget | null,
|
||||||
|
isInsideExtraOverlay?: (target: EventTarget | null) => boolean,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
isEventInsideFloatingMenu(target) || isInsideExtraOverlay?.(target) === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「点外部关闭」的边界判据:DOM 在边界里(含 portal 出去的浮层登记)就算内部。
|
||||||
|
*/
|
||||||
|
export function isEventInsideFloatingOverlay(
|
||||||
|
target: EventTarget | null,
|
||||||
|
{ boundaryRefs = [], isInsideExtraOverlay }: FloatingOverlayBoundaryOptions,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
isEventInsideBoundary(target, boundaryRefs) ||
|
||||||
|
isEventInsideRegisteredOverlay(target, isInsideExtraOverlay)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「这一下滚轮归浮层还是归画布」的判据,与上面的关闭判定同源。
|
||||||
|
*
|
||||||
|
* React 的 portal 事件沿 **React 树** 冒泡(React 把委托监听挂在 portal 容器 `document.body`
|
||||||
|
* 上),所以 portal 出去的浮层里的滚轮照样会走到宿主的 `onWheel`。DOM 上根本不在宿主里的
|
||||||
|
* 那一下,只可能是浮层派出来的:必须原样放过,否则用户在 `@` 选择器列表上滚轮时,列表
|
||||||
|
* 在滚、背后的画布也一起平移 / 缩放。
|
||||||
|
*
|
||||||
|
* 两条判据:
|
||||||
|
* 1. DOM 不在边界里 —— portal 出去的浮层(`@` 选择器、候选菜单、共享弹出层……),一律归浮层;
|
||||||
|
* 2. DOM 仍在边界里、但已登记为浮层内部的(`isInsideExtraOverlay` 与共享弹出层),同样归浮层。
|
||||||
|
*
|
||||||
|
* 返回 `true` 表示这次滚轮归浮层,宿主不得消费(不 preventDefault、不动视口)。
|
||||||
|
*/
|
||||||
|
export function isFloatingOverlayWheelEvent(
|
||||||
|
target: EventTarget | null,
|
||||||
|
{ boundaryRefs = [], isInsideExtraOverlay }: FloatingOverlayBoundaryOptions,
|
||||||
|
) {
|
||||||
|
if (!isEventInsideBoundary(target, boundaryRefs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isEventInsideRegisteredOverlay(target, isInsideExtraOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
export function useImageCanvasFloatingOptionDismiss({
|
export function useImageCanvasFloatingOptionDismiss({
|
||||||
isOpen,
|
isOpen,
|
||||||
boundaryRefs,
|
boundaryRefs,
|
||||||
@@ -57,9 +115,10 @@ export function useImageCanvasFloatingOptionDismiss({
|
|||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
|
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
|
||||||
if (
|
if (
|
||||||
isEventInsideBoundary(event.target, boundaryRefs) ||
|
isEventInsideFloatingOverlay(event.target, {
|
||||||
isEventInsideFloatingMenu(event.target) ||
|
boundaryRefs,
|
||||||
isInsideExtraOverlay?.(event.target) === true
|
isInsideExtraOverlay,
|
||||||
|
})
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user