合并资源工作台分支:PR review 的 Rust 批次并入共享契约与滚轮修复
Project CI / Repository checks (pull_request) Successful in 2m49s
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / Backend tests (pull_request) Successful in 6m58s
Project CI / Native shell tests (pull_request) Successful in 19m0s

- 把远端最新 1cc258d2a 合入本分支(PR #316 review 的 Rust 三笔:3a9e45c80、daef9f6cc、64b0d2f76)
- 覆盖:素材导出补敏感文件/控制面拒绝与自我覆盖保护、删除幂等、重命名 CAS、manifest 缓存 TOCTOU、标签上限、注入资源 ID 上限与去重、锁诊断与提权口径
- 冲突零:自动合并成功
- 注意:rename_local_project_asset 现要求 expectedProjectId/expectedProjectRevision,前端调用点由前端批次同批补齐
This commit is contained in:
2026-09-12 20:24:01 +08:00
20 changed files with 930 additions and 117 deletions
@@ -38,6 +38,37 @@ export function isResourceCanvasInteractionTarget(
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
);
}
/**
* 画布浮层是否可以被清焦点顺手关掉。
*
+58 -46
View File
@@ -5768,31 +5768,45 @@ iframe.preview-frame {
transition: none;
}
/* 缩略卡悬停 / 聚焦时高亮它所在栏目(那一摞)的标题条。
取值必须与 `PROJECT_RESOURCE_CANVAS_SECTIONS`7 个现行分区)逐一对齐:
`code` / `art` 是旧四、五栏目的历史值,总览里不会渲染,写它们等于空规则;
少写一个现行分区,对应栏目就完全没有悬停高亮。 */
.game-resource-book-manager--main:has(
.game-resource-book-thumbnail[data-resource-book-category='code']:hover,
.game-resource-book-thumbnail[data-resource-book-category='code']:focus-visible
.game-resource-book-thumbnail[data-resource-book-category='ui-interaction']:hover,
.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-thumbnail[data-resource-book-category='document']:hover,
.game-resource-book-thumbnail[data-resource-book-category='document']:focus-visible
)
.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-thumbnail[data-resource-book-category='version']:hover,
.game-resource-book-thumbnail[data-resource-book-category='version']:focus-visible
)
.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'] {
.game-resource-book-scene-titlebar[data-resource-book-category='version'] {
border-top-color: #cc8060;
border-right-color: #cc8060;
border-bottom-color: #ebd9cf;
@@ -6902,10 +6916,13 @@ iframe.preview-frame {
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:focus-within,
.game-resource-card.is-selected {
border-color: #d57b51;
border: 1px solid #d57b51;
outline: 0;
box-shadow: 0 8px 22px rgb(195 105 62 / 15%);
}
@@ -10418,13 +10435,18 @@ button.design-workspace-tree__entry:hover,
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
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer.is-direct-codex
.resource-reference-input:focus-within {
border: 0;
outline: none;
box-shadow: none;
}
.game-workbench-chat
@@ -10704,18 +10726,19 @@ button.design-workspace-tree__entry:hover,
line-height: 1.6;
}
/* 陶泥儿输入区的操作排(`@` / AI 润色 / 恢复原文)回到文档流:`grid-column: 1 / -1`
让它独占编辑器下面的一行,`align-items: end` + `justify-content: flex-end` 把它
贴到输入框右下角。之前这里用 `position: absolute; right: 48px; bottom: 10px`
把整排从网格里摘出来浮在输入框中间,AI 润色因此看起来压在文本区里、和下面那排
主操作(`.project-supervisor-composer-controls` 的 `@` / 快速 / 发送)脱节。
/* 陶泥儿输入区的操作排(`AI 润色` / `恢复原文`)回到文档流:网格第二行右侧那一列,
`justify-content: flex-end` 贴住输入框右下角。之前这里用
`position: absolute; right: 48px; bottom: 10px` 把整排从网格里摘出来浮在输入框中间,
AI 润色因此看起来压在文本区里、和下面那排主操作(`.project-supervisor-composer-controls`
的 `@` / 快速 / 发送)脱节。
不设 `min-height`:这一行的高度由 28px 方钮自己撑开,行高一旦被顶起来会连带把
编辑器的 `min-height: 96px` 改掉。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-actions {
grid-column: 1 / -1;
grid-row: 2;
grid-column: 2;
position: static;
display: flex;
align-items: end;
@@ -10724,46 +10747,35 @@ button.design-workspace-tree__entry:hover,
height: auto;
}
/* 「润色中… / 润色失败」和应用排同一行:状态文字靠左占剩余宽度,操作排靠右。
单独占一行会把输入框整体顶高 22.8px,把操作排和下面那排主操作推开一个状态行的
距离;共行则状态出现/消失都不改变输入框高度。 */
/* 「润色中… / 润色失败」和应用排**同一行**:状态文字在第二行左侧那一列(占剩余宽度),
操作排在第二行右侧那一列。单独占一行会把输入框整体顶高一个状态行的距离;而给它
`grid-column: 1 / -1` 又会和操作排落进同一个网格单元互相重叠(状态文字长时压到按钮下面)。
共行分列则状态出现/消失都不改变输入框高度,也不会互相压。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-status {
grid-column: 1 / -1;
grid-row: 2;
grid-column: 1;
align-self: center;
min-width: 0;
margin-top: 0;
}
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.resource-reference-input-at {
position: static !important;
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;
}
/* direct-codex 里 `ResourceReferenceInput` 收到的是 `showTriggerButton={!directCodex}`
即输入框内的 `.resource-reference-input-at` 根本不会渲染(`@` 触发钮是下面控制排里的
`.project-supervisor-reference-trigger`)。这里原本有一条针对它的
`position: static !important` / 30px 主色方块规则,永远匹配不到,已删除;
要改 direct-codex 的 `@` 触发钮外观请改 `.project-supervisor-reference-trigger`。 */
.design-agent-reasoning {
margin: 8px 0;
color: var(--text-muted);
font-size: 0.82em;
}
.design-agent-reasoning summary { cursor: pointer; }
.design-agent-reasoning summary {
cursor: pointer;
}
.design-agent-reasoning pre {
margin: 6px 0 0;
white-space: pre-wrap;
@@ -88,7 +88,10 @@ import {
import { ImageCanvasProjectAssetPickerDialog } from '../../../../../src/components/image-editor/ImageCanvasProjectAssetPickerDialog';
import { ImageCanvasQuickEditPanelView } from '../../../../../src/components/image-editor/ImageCanvasQuickEditPanelView';
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 {
LocalGamePreviewFrame,
@@ -115,6 +118,7 @@ import {
import {
canDismissResourceCanvasQuickEdit,
isResourceCanvasInteractionTarget,
isResourceCanvasWheelOverlayTarget,
resolveResourceCanvasFloatingPanelDismissOpen,
resolveResourceCanvasFocusEscapeActive,
} 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({
plan,
resourceBookState,
@@ -4406,11 +4427,28 @@ export default function ProjectDevelopmentView({
const handleResourceBookWheel = useCallback(
(event: ReactWheelEvent<HTMLDivElement> | WheelEvent) => {
resourceBookTransitionControllerRef.current.settle();
const sceneElement = resourceBookManagerRef.current;
if (!sceneElement) {
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();
const rect = sceneElement.getBoundingClientRect();
const screenPoint = {
@@ -893,6 +893,93 @@ describe('project resource live canvas integration', () => {
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 () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
@@ -6,11 +6,15 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import type { QuickEditPanelState } from '../../../src/components/image-editor/ImageCanvasEditorTypes';
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 {
canDismissResourceCanvasQuickEdit,
isResourceCanvasHostOverlayOpen,
isResourceCanvasInteractionTarget,
isResourceCanvasWheelOverlayTarget,
resolveResourceCanvasFloatingPanelDismissOpen,
resolveResourceCanvasFocusEscapeActive,
} 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。 */
function QuickEditPanelHarness({
initialPanel,
@@ -2,6 +2,15 @@
> 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 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 策划项目重开前必须恢复运行模式
- 工作台不能只依赖创建时的内存 `startMode`:重开时丢失该值会挂载游戏资源画布,而对话恢复后又进入策划状态,造成左右区域不一致。
@@ -6,7 +6,10 @@ import { resolve } from 'node:path';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { PlatformResourceFilterBar } from './PlatformResourceFilterBar';
import {
PlatformResourceFilterBar,
type PlatformResourceFilterBarProps,
} from './PlatformResourceFilterBar';
import { PlatformSegmentedTabs } from './PlatformSegmentedTabs';
const CATEGORY_ITEMS = [
@@ -15,9 +18,10 @@ const CATEGORY_ITEMS = [
];
/**
* 分段页签子项宽度下限是「不叠字」的唯一判据:grid 的轨道按容器等分、scroll 的
* flex 子项默认可收缩,两者都会把中文标签压到内容宽度以下,标签虽然 nowrap,
* 文字仍会画出自己的盒子压到相邻上。这条规则一旦被删掉就要失败。
* 分段页签子项宽度下限只属于 `layout="scroll"`:那一条有 `overflow-x-auto` 做兜底,
* 宽度下限换来的是「不叠字」。grid 布局没有溢出容器、轨道是 `minmax(0, 1fr)`,同样的
* `min-width: max-content` 只会把内容画到相邻上。这两条声明级断言(jsdom 不计算外部
* 样式表,可见性只能钉在声明与类名上)钉住「下限只挂在 --scroll 修饰类」。
*/
function segmentedTabsStylesheet() {
return readFileSync(
@@ -110,6 +114,50 @@ describe('PlatformResourceFilterBar', () => {
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', () => {
const { container } = render(
<PlatformResourceFilterBar
@@ -123,6 +171,7 @@ describe('PlatformResourceFilterBar', () => {
// 分类条是 scroll 布局:容器横向可滚,标签不换行。
const row = container.querySelector('.platform-segmented-tabs');
expect(row).not.toBeNull();
expect(row?.className).toContain('platform-segmented-tabs--scroll');
expect(row?.className).toContain('overflow-x-auto');
for (const button of row?.querySelectorAll('button') ?? []) {
expect(button.className).toContain('whitespace-nowrap');
@@ -130,11 +179,15 @@ describe('PlatformResourceFilterBar', () => {
// 单靠 nowrap 不够:子项必须有不低于内容宽度的下限,否则文字会画出盒子压到相邻项。
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,
);
});
test('grid 布局的分段页签同样带宽度下限钩子', () => {
test('grid 布局的分段页签不带 scroll 修饰类,靠轨道等分收口', () => {
render(
<PlatformSegmentedTabs
items={CATEGORY_ITEMS}
@@ -149,5 +202,8 @@ describe('PlatformResourceFilterBar', () => {
.closest('div');
expect(row?.className).toContain('platform-segmented-tabs');
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>;
};
export type PlatformResourceFilterBarProps<TId extends string = string> = {
type PlatformResourceFilterBarBaseProps<TId extends string = string> = {
/** 控件组无障碍名称,例如「资源筛选」。 */
ariaLabel: string;
/** 搜索行;宿主不需要搜索时省略即可,其余筛选照常渲染。 */
@@ -30,14 +30,29 @@ export type PlatformResourceFilterBarProps<TId extends string = string> = {
categoryItems: readonly PlatformResourceFilterOption<TId>[];
activeCategoryId: TId;
onCategoryChange: (id: TId) => void;
/** 派生标签库;为空时不渲染标签行。 */
tagItems?: readonly PlatformResourceTagOption[];
/** 已选标签(多选叠加)。 */
activeTags?: readonly string[];
onToggleTag?: (tag: string) => void;
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) {
return [
'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 ? (
<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"
aria-label={`${ariaLabel}标签`}
>
@@ -116,6 +131,9 @@ export function PlatformResourceFilterBar<TId extends string = string>({
type="button"
aria-pressed={active}
className={tagChipClassName(active)}
// 类型上 `tagItems` 必然带 `onToggleTag`;这一层兜底是给不带类型检查的
// 调用方(JS / 动态构造的 props):没有处理函数就不该渲染成可点按钮。
disabled={!onToggleTag}
onClick={() => onToggleTag?.(option.tag)}
>
<Tag className="h-3 w-3" aria-hidden="true" />
@@ -193,7 +193,7 @@ export function PlatformSegmentedTabs<TId extends string = string>({
className={[
'platform-segmented-tabs',
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',
PLATFORM_SEGMENTED_TABS_FRAME_CLASS[frame],
PLATFORM_SEGMENTED_TABS_GAP_CLASS[gap],
+7 -4
View File
@@ -519,13 +519,16 @@
display: none;
}
/* 分段页签的子项不得被压到内容宽度以下。中文标签(例如「角色与对象」需要一个
/* 分段页签的**横滚**子项不得被压到内容宽度以下。中文标签(例如「角色与对象」需要一个
`1.5rem 内边距 + 4 个字`的宽度)在窄容器里一旦被压扁,标签虽然 `nowrap`,文字仍会
画出自己的盒子压到相邻项上——现场表现就是「后几项挤成一团、文字互相重叠」。
`min-width: max-content` 给每个子项一个「内容宽度」下限:放得下就照原样排,放不下
就由容器横向滚动兜底,任何情况下都不叠字。grid 与 scroll 两条 layout 都需要它:
grid 的轨道按容器等分,scroll 的 flex 子项默认可收缩,两者都会压到内容宽度以下。 */
.platform-segmented-tabs > button {
就由容器横向滚动兜底`layout="scroll"` 的容器有 `overflow-x-auto`,任何情况下都不叠字。
这里**只作用于 scroll 布局**:grid 布局没有溢出容器,轨道是 `minmax(0, 1fr)`
给子项加 `min-width: max-content` 只会让盒子宽过自己那条轨道、直接画到相邻格上,
正是本规则要防的重叠。grid 侧靠轨道等分 + 宿主的 `truncateLabels` 收口。 */
.platform-segmented-tabs--scroll > button {
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 {
@@ -23,6 +26,7 @@ import {
type GameCreationAppManifest,
normalizeGameCreationAppAssetCategory,
normalizeGameCreationAppAssetTags,
PROJECT_RESOURCE_CANVAS_SECTIONS,
selectGameCreationAppReadyTasks,
} from './gameCreationApp';
@@ -933,7 +937,7 @@ describe('AI 游戏创作 App 共享契约', () => {
* 「读显示」与「写回」是两个口径,只有写回口径等于落盘值。
*
* `gameCreationAppAssetCategory` 会把落盘 `unclassified` 自愈成 kind 派生值(读显示要正确),
* `gameCreationAppAssetPersistedCategory` 只做缺失 / 非法兜底(写回必须原样)。
* `gameCreationAppAssetPersistedCategory` 只做兜底、不套自愈(写回必须原样)。
* 「编辑标签」面板一旦用读显示口径回写,用户只改标签就会静默改分类。
*/
it('写回口径保留落盘 unclassified,读显示口径才自愈', () => {
@@ -941,23 +945,63 @@ describe('AI 游戏创作 App 共享契约', () => {
expect(gameCreationAppAssetCategory(uiAsset)).toBe('ui-interaction');
expect(gameCreationAppAssetPersistedCategory(uiAsset)).toBe('unclassified');
// 落盘值非法或缺失时两个口径都按 kind 派生(与 Rust 反序列化兜底一致)。
expect(
gameCreationAppAssetPersistedCategory({
kind: 'character',
category: 'future-category' as never,
}),
).toBe('character');
// 字段缺失时两个口径都按 kind 派生(与 Rust 反序列化的缺字段兜底一致)。
expect(
gameCreationAppAssetPersistedCategory({
kind: 'character',
category: undefined,
}),
).toBe('character');
// 非法值这一支只服务内存对象:Rust 读侧对未知 category 失败关闭,
// 比本客户端新的值根本读不出 manifest,不会以「派生值」的形式出现在 UI 里。
expect(
gameCreationAppAssetPersistedCategory({
kind: 'character',
category: 'future-category' as never,
}),
).toBe('character');
// 落盘值合法且非 unclassified 时两个口径一致。
expect(
gameCreationAppAssetPersistedCategory({ kind: 'ui', category: '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 资产的最终功能分类。
*
* 优先级:落盘 `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` 分类时,采用派生值。这条规则用于自愈「历史上被系统错误写成
@@ -667,7 +670,9 @@ export function gameCreationAppAssetCategory(
* 静默改分类(真机已发生:同一条 `kind:"ui"` 资产出现 `unclassified` 与 `ui-interaction` 两种落盘值)。
*
* Rust 侧 manifest 反序列化(`game_creation_app.rs` 的 `GameCreationAppAssetManifestEntry`
* `Deserialize`用的是同一口径,因此本函数结果必然等于该资产当前的落盘 `category`。
* `Deserialize`在字段缺失时用同一套 `kind` 派生;未知分类值则在读侧失败关闭(不再退化成
* 派生值),所以从 manifest 读进来的资产本函数结果必然等于它的落盘 `category`。
* 剩下这一支「非法值 → 派生」只服务内存对象(手工构造的资产),不是落盘口径的兜底。
*/
export function gameCreationAppAssetPersistedCategory(
asset: Pick<GameCreationAppAssetManifestEntry, 'kind' | 'category'>,
@@ -49,6 +49,25 @@ describe('AI 游戏创作 App 标签库派生', () => {
expect(buildGameCreationAppAssetTagLibrary([])).toEqual([]);
});
it('sorts asset ids deterministically without a locale-dependent compare', () => {
// `localeCompare` 用运行环境默认 locale:同一份 manifest 在不同机器上可能排出不同顺序,
// 而这里承诺的是「与输入顺序无关的稳定升序」。素材 id 是 ASCIIcode-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', () => {
const tags = ['像素风', '主角'];
@@ -62,4 +81,18 @@ describe('AI 游戏创作 App 标签库派生', () => {
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]) => ({
tag,
assetCount: assetIds.length,
assetIds: [...assetIds].sort((left, right) => left.localeCompare(right)),
// 确定性 code-unit 升序:`localeCompare` 的默认 locale 随环境变化,会让同一份 manifest
// 在不同机器上排出不同的 `assetIds`。素材 id 是 ASCIIUUID / 数字串 / `asset-1` 之类),
// code-unit 顺序就是稳定升序;标签那一层另有 `zh-CN` 显式口径,两者不要混用。
assetIds: [...assetIds].sort(),
})).sort(
(left, right) =>
right.assetCount - left.assetCount ||
@@ -59,6 +62,9 @@ export function buildGameCreationAppAssetTagLibrary(
/**
* 标签筛选判据:已选标签必须全部命中(AND),空选择视为不过滤。
* 与 `category` 筛选共存时由调用方叠加,本函数只看标签。
*
* 两侧都过 `normalizeGameCreationAppAssetTags`:素材侧 tags 会去空白,已选标签如果原样比较,
* `' 像素风 '` 这种带空白的选中项会永远匹配不上素材上已归一化的 `'像素风'`。
*/
export function assetTagsMatchSelection(
tags: readonly string[] | undefined,
@@ -66,7 +72,8 @@ export function assetTagsMatchSelection(
): boolean {
if (selectedTags.length === 0) return true;
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` 一致。 */
+102
View File
@@ -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,
};
}
+35 -7
View File
@@ -1,15 +1,43 @@
import { spawnSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { selectRustfmtWorkspaces } from './lint-staged-rustfmt-workspaces.mjs';
// lint-staged 会把命中的暂存文件路径追加到命令末尾,而 `cargo fmt` 只按 workspace 粒度格式化、
// 不接受文件参数(也做不到「只格式化某个文件」),所以这里忽略 argv,直接对两个 workspace 跑
// `--check`。只查不改:pre-commit 不应该自动改写别人正在改的 Rust 文件。
const workspaces = [
'server-rs/Cargo.toml',
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
];
// 不接受文件参数(也做不到「只格式化某个文件」),所以这里用暂存路径筛出**需要检查的
// workspace**,再对它们跑 `--check`。只查不改:pre-commit 不应该自动改写别人正在改的 Rust 文件。
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const { workspaces, excluded, unmanaged, usedStagedList } =
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(
'cargo',
['fmt', '--all', '--manifest-path', manifestPath, '--', '--check'],
+113
View File
@@ -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 门:视频、音频、序列帧等非静态媒体即使挂着
/// 图片类 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 静态图类型
"image",
"scene",
@@ -4138,6 +4138,11 @@ pub(crate) const EDITOR_IMAGE_EDIT_STATIC_IMAGE_ASSET_KINDS: [&str; 17] = [
"ui-prototype",
// AGC 本地 manifest 等在用的等价静态图类型
"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(
@@ -20738,13 +20743,20 @@ mod tests {
}
/// 实测 400×5 的回归用例:AGC「图片快速编辑」发出的请求,来源资源的 assetKind 是本地
/// manifest 原始类型 `art-spritesheet` / `ui`mediaType=image)。这条路径必须放行;
/// 非静态媒体(video/audio/image-sequence)必须继续拒绝。
/// manifest 原始类型 `art-spritesheet` / `ui` / `asset`mediaType=image)。这条路径必须
/// 放行;非静态媒体(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]
fn game_creator_client_quick_edit_accepts_local_manifest_static_image_kinds() {
// 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形;放行判据只看来源资源的
// 权威 assetKind 与 mediaType,请求体里的 `generationInputs.source` 不参与。
let agc_payload: Value = serde_json::from_str(
// 与客户端 `submit_resource_edit_remote` 发送的 JSON 同形
let agc_payload: EditorImageEditRequest = serde_json::from_str(
r#"{
"prompt": "把这张图改成夜间配色",
"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!(
agc_payload
.pointer("/generationInputs/assetKind")
.and_then(Value::as_str),
agc_payload.source_reference_id, "resource-art-spritesheet",
"回归夹具必须保持 AGC 客户端实际发送的请求形状"
);
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"),
"回归夹具必须保持 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 [
("art-spritesheet", "image"),
("ui", "image"),
("asset", "image"),
("ui-prototype", "image"),
("game-art", "image"),
("game-background", "image"),
@@ -20781,6 +20830,7 @@ mod tests {
for (asset_kind, media_type) in [
("art-spritesheet", "video"),
("ui", "audio"),
("asset", "video"),
("video", "video"),
("sound-effect", "audio"),
("background-music", "audio"),
@@ -502,13 +502,18 @@ pub struct GameCreationAppAssetManifestEntry {
}
/// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值;
/// 显式写入的合法分类必须原样保留,未知分类值按前向兼容退回 `kind` 派生
/// 显式写入的合法分类必须原样保留,未知分类值**失败关闭**(见下方 `Deserialize` 实现)
///
/// 这里**刻意不做读时自愈**(落盘 `unclassified` 而 kind 能派生明确分类时改用派生值):
/// 反序列化结果就是落盘原值,「编辑标签」面板要靠它把落盘分类原样回写,否则用户只改标签
/// 也会静默改分类。自愈只属于读显示口径,见 `game_creation_app_asset_effective_category`。
///
/// `deny_unknown_fields` 与顶层 `GameCreationAppManifest` 同一取向:AGC 客户端是
/// 「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,资产级未知字段(例如未来版本的
/// `rotation` / `animations`)一旦被静默丢弃,就会在任意一次写入里被抹掉。顶层有这道门、
/// 资产级没有的话,等于只挡住了一半的静默数据丢失。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct GameCreationAppAssetManifestEntryWire {
id: String,
kind: String,
@@ -531,11 +536,19 @@ impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry {
D: serde::Deserializer<'de>,
{
let wire = GameCreationAppAssetManifestEntryWire::deserialize(deserializer)?;
let category = wire
.category
.as_deref()
.and_then(game_creation_app_asset_category_from_str)
.unwrap_or_else(|| game_creation_app_asset_category_for_kind(&wire.kind));
// 未知 `category` 必须失败关闭,不能退回 `kind` 派生值:反序列化结果就是「编辑标签」
// 面板要原样回写的落盘原值,静默替换成派生值会让「只改标签」变成静默改分类
// (写侧整结构体覆盖落盘,替换值会真的写回文件)。派生只属于缺字段的读兼容,
// 以及读显示口径 `game_creation_app_asset_effective_category`。
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 {
id: wire.id,
kind: wire.kind,
@@ -2292,7 +2305,7 @@ mod tests {
}
#[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");
explicit_unclassified["category"] = json!("unclassified");
assert_eq!(
@@ -2307,18 +2320,56 @@ mod tests {
GameCreationAppAssetCategory::Audio
);
let mut unknown_category = asset_entry_json("character");
unknown_category["category"] = json!("future-category");
assert_eq!(
asset_entry_from_json(unknown_category).category,
GameCreationAppAssetCategory::Character
// 未知分类失败关闭:不能退回 `kind` 派生值。否则「读一次 + 任意一次写」会把
// 未来客户端写下的 `category` 静默改成本机派生值,等于无迁移地改落盘数据。
for kind in ["character", "image"] {
let mut unknown_category = asset_entry_json(kind);
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");
assert_eq!(
asset_entry_from_json(unknown_category_without_kind_semantics).category,
GameCreationAppAssetCategory::Unclassified
// 已知字段(含可选字段)必须照旧往返:拒绝未知字段不得顺手把已知字段一起拒掉。
let entry = asset_entry_from_json(asset_entry_json("character"));
let round_tripped: GameCreationAppAssetManifestEntry =
serde_json::from_value(serde_json::to_value(&entry).expect("asset entry serializes"))
.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;
};
/** 浮层边界登记:谁算「浮层内部」,关闭判定与滚轮归属共用同一份。 */
type FloatingOverlayBoundaryOptions = {
boundaryRefs?: Array<FloatingOptionBoundaryRef | null | undefined>;
isInsideExtraOverlay?: (target: EventTarget | null) => boolean;
};
type UseImageCanvasFloatingOptionDismissOptions = {
isOpen: boolean;
boundaryRefs: Array<FloatingOptionBoundaryRef | null | undefined>;
@@ -38,10 +44,62 @@ function isEventInsideBoundary(
function isEventInsideFloatingMenu(target: EventTarget | null) {
return (
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({
isOpen,
boundaryRefs,
@@ -57,9 +115,10 @@ export function useImageCanvasFloatingOptionDismiss({
const handleClick = (event: MouseEvent) => {
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
if (
isEventInsideBoundary(event.target, boundaryRefs) ||
isEventInsideFloatingMenu(event.target) ||
isInsideExtraOverlay?.(event.target) === true
isEventInsideFloatingOverlay(event.target, {
boundaryRefs,
isInsideExtraOverlay,
})
) {
return;
}