修复 AGC 画布四项验收问题:锚点让位标题栏、浮层不再撞画布边缘、拖动不再触发点击、图标规范参考口径对齐

- 生成任务锚点新增「资源总览」档:资源总览页仍贴画布顶边内缩 0.85rem,资源栏目画布页让开顶部钉住的整宽栏目标题栏(42px + 0.5rem),两档切换走 margin-top 过渡(减动效下关掉)。
- 生成浮层的顶边与可用高度改为按占位卡的真实屏幕位置算(卡下沿到安全底边),不再假设卡片贴在安全带上沿——占位落到下半屏时浮层底边不再越过画布下沿被切掉。
- 生成浮层新增水平收口:按「浮层左右各留 12px」把锚点收进画布可视范围,卡片靠边时浮层不再有一半落到画布之外;宽度口径与面板 CSS 同源(min(560px, 100% - 24px))。
- 新增画布卡片手势的共享判据 resourceCanvasGestureExceededDragThreshold(阈值仍取 RESOURCE_CANVAS_DRAG_THRESHOLD):资源卡与生成占位卡共用同一份「拖动还是点击」判断。
- 生成占位卡修掉拖动收尾误触点击:越过阈值才算拖动、登记一次性点击抑制(consumeDragClick)由宿主消费,「拖动中」样式也从越过阈值那一刻起才亮。
- 生成占位卡的删除按钮起手手势不再被当成「点卡片」:按起手点判定,松手落回卡片时不触发开合生成浮层。
- 图标规范面板的参考选择注释按现行判据改写(只有图集 icon-spritesheet 不接受用户参考),入口矩阵文档同步补「参考图(@ 引用)」口径并修掉「本地 IPC 没有参考图入参」的旧说法。
- 补齐回归用例:手势模型判据、占位卡拖动/点击与阈值、宿主级「拖完不弹浮层且普通点击仍开合」、浮层顶边与水平收口、锚点分档与跨文件标题栏高度守卫、逐条生成入口的 @ 与判据一致、图标规范面板的 @ 与提交带引用。
- 共享记忆 pitfalls.md 记录两条排障口径:卡类手势必须只有一份阈值与点击抑制判据;画布浮层几何只能按真实屏幕位置算、分页锚点必须让开钉死的标题栏。
This commit is contained in:
2026-09-21 11:58:13 +08:00
parent 959d367ed4
commit beebcca4de
20 changed files with 1001 additions and 70 deletions
@@ -235,8 +235,14 @@ export function ResourceCanvasAssetGenerationPanelView({
/**
* 参考选择只对有真实参考能力的入口呈现。
*
* 图集只接受单张规范引用、图标规范本身就是权威规范图产出方:这两类入口不给选择器
* 原生侧同样拒绝额外参考(不是静默丢弃)。
* `@` 引用输入区是**共享组件**`ResourceReferenceInput`,聊天 / 快速编辑 / 这里同一份)
* 这一处只是「要不要挂它」的唯一分支:判据在 `resourceCanvasAssetGenerationAcceptsReferences`
* 不在这里另写一套 kind 清单。
*
* 拒绝用户参考的只有**图集**`icon-spritesheet`):按合同它只接受单张权威规范引用,
* 原生侧对多余参考是**显式拒绝**(不是静默丢弃)。图标规范(`icon-spec`)虽然产出权威规范图,
* 但生成时同样可以带参考图,上限与普通生成一致——它**有** `@` 选择器。
* 两端必须同一个词:Rust 侧是 `platform_art_asset_kind_accepts_user_reference_assets`。
*/
const referenceEnabled =
resourceCanvasAssetGenerationAcceptsReferences(action);
@@ -44,12 +44,18 @@ export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
/**
* 锚点落在哪个工作面:资源栏目画布 / 运行表现层 / UI 编辑器。
* 锚点落在哪个工作面:资源总览页 / 资源栏目画布 / 运行表现层 / UI 编辑器。
*
* 三处右上角的既有 chrome 不同(运行表现层右上角是版本入口、UI 编辑器是整页编辑器壳),
* 所以坐标由这一档在样式里分档给出;缺省按资源栏目画布算。
* 四处的既有 chrome 不同,所以坐标由这一档在样式里分档给出;缺省按资源栏目画布算:
* - `canvas-overview`(资源总览 / 画本):顶部没有钉死的栏目标题栏,锚点贴画布顶边内缩;
* - `canvas`(资源栏目画布):顶部钉着**整宽**的栏目标题栏(含「返回资源总览」入口),
* 锚点必须让开它,否则会压在那条标题栏上(验收现场那条「生成任务和标题栏重叠」);
* - `run`:右上角被版本入口占着;
* - `editor`UI 编辑器是整页编辑器壳。
*
* 两页之间切换时锚点高度走样式里的 transition,不瞬移。
*/
placement?: 'canvas' | 'run' | 'editor';
placement?: 'canvas-overview' | 'canvas' | 'run' | 'editor';
};
/**
@@ -2,6 +2,7 @@ import './resourceCanvasGenerationPanel.css';
import { Sparkles, X } from 'lucide-react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import { useRef } from 'react';
import {
RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS,
@@ -44,6 +45,14 @@ export function ResourceCanvasGenerationPlaceholderCardView({
onTogglePanel,
onRemove,
}: ResourceCanvasGenerationPlaceholderCardViewProps) {
/**
* 这一次点击手势是从哪儿起手的。
*
* 删除按钮自己会 `stopPropagation`,所以「按在删除按钮上、松手落回卡片」这种手势里,
* 浏览器把 `click` 派给两者的共同祖先(也就是这张卡)——删除按钮的删除没发生,卡片的
* 「开合生成浮层」却执行了。判据不看移动距离:**从删除按钮起手的手势永远不是「点卡片」**。
*/
const gestureOriginRef = useRef<'card' | 'remove'>('card');
return (
<div
className={`game-resource-generation-placeholder${active ? ' is-active' : ''}${dragging ? ' is-dragging' : ''}`}
@@ -58,13 +67,21 @@ export function ResourceCanvasGenerationPlaceholderCardView({
role="button"
tabIndex={0}
aria-label={`${placeholder.assetName}${RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS[placeholder.status]}`}
onPointerDown={onPointerDown}
onPointerDown={(event) => {
gestureOriginRef.current = 'card';
onPointerDown(event);
}}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onLostPointerCapture={onPointerCancel}
onClick={(event) => {
event.stopPropagation();
const origin = gestureOriginRef.current;
gestureOriginRef.current = 'card';
if (origin === 'remove') {
return;
}
onTogglePanel();
}}
onKeyDown={(event) => {
@@ -87,7 +104,12 @@ export function ResourceCanvasGenerationPlaceholderCardView({
<button
type="button"
aria-label={`删除占位 ${placeholder.assetName}`}
onPointerDown={(event) => event.stopPropagation()}
onPointerDown={(event) => {
// 卡片不接管这一次手势(不拖动),但把起手点记下来:松手落回卡片时那次 click
// 不该被当成「点卡片开浮层」。
gestureOriginRef.current = 'remove';
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onRemove();
@@ -37,6 +37,34 @@
align-items: flex-start;
gap: 0.45rem;
pointer-events: auto;
/*
* 资源总览 ↔ 资源栏目画布之间切换时,锚点要往下挪开那条钉死的标题栏:高度差走一段过渡,
* 不瞬移(两页的坐标档见下面的 `[data-generation-tasks-placement]` 规则)。
*
* 只过渡 `margin-top`:锚点是 stage 网格里 `align-self: start` 的覆盖式条目,改它的外边距
* 不会让工作面那一行 reflow,画布与资源卡排布一动不动。
*/
transition: margin-top 180ms ease-out;
}
/*
* 资源总览(画本)页:顶部没有钉死的标题栏——栏目名与计数都活在画本世界里跟着视口平移,
* 所以锚点仍按「画布顶边内缩一小段」定位。这一档显式写出来,是为了让「两页高度不同」这件事
* 在样式里看得见,而不是靠缺省值隐式成立。
*/
.game-resource-generation-tasks-anchor[data-generation-tasks-placement='canvas-overview'] {
margin-top: 0.85rem;
}
/*
* 资源栏目画布页:顶部钉着**整宽**的栏目标题栏(`.game-resource-book-scene-titlebar`
* 最小高 42px`styles.css` 里定义),右端就是「返回资源总览」入口。锚点必须让开这一条,
* 否则那枚开关会直接压在它上面(验收现场那条「生成任务和标题栏重叠」就是这么来的)。
*
* 2.625rem 就是那条标题栏的 42px:两处必须一起改(有声明级守卫用例钉住这个关系)。
*/
.game-resource-generation-tasks-anchor[data-generation-tasks-placement='canvas'] {
margin-top: calc(2.625rem + 0.5rem);
}
.game-resource-generation-tasks-anchor[data-generation-tasks-placement='run'] {
@@ -439,6 +467,11 @@
/* 降低动效偏好:进场 / 收起动画、悬停位移与呼吸全部关掉。 */
@media (prefers-reduced-motion: reduce) {
/* 锚点高度过渡也关掉:两页切换时锚点直接落到目标高度。 */
.game-resource-generation-tasks-anchor {
transition: none;
}
.game-resource-generation-tasks-sidebar,
.game-resource-generation-tasks-sidebar.is-leaving {
animation: none;
@@ -0,0 +1,46 @@
import { RESOURCE_CANVAS_DRAG_THRESHOLD } from '../../view/project-development/resourceCanvasLayoutModel';
/**
* 画布上「这一次手势是拖动还是点击」的唯一判据。
*
* 画布上的卡片既是可点的控件、又是可拖的对象,而浏览器在 pointerdown 与 pointerup 落在同一个
* 节点上时**一定会补一次 `click`**——中间移动了多少都不管。所以:
*
* 1. 「开始拖动」这件事只能由**移动距离**判,不能由「收到过 pointermove」判:按下时浏览器
* 就可能补一次零位移的 move,用后者会让一次普通点击直接变成一次拖动;
* 2. 拖动结束后紧跟的那次 `click` 是这次拖动的副产物,必须被**一次性吃掉**,否则拖完卡片会
* 顺手触发卡片自己的点击语义(资源卡是选中、生成占位卡是开合生成浮层)。
*
* 资源卡(`view/project-development/index.tsx`)与生成占位卡
* `useResourceCanvasGenerationPlaceholders`)共用这里的阈值与判据:两处各写一份的结果就是
* 一边修好、另一边继续漏(占位卡拖一下就弹生成浮层,就是这么来的)。
*/
export function resourceCanvasGestureExceededDragThreshold({
startClientX,
startClientY,
clientX,
clientY,
threshold = RESOURCE_CANVAS_DRAG_THRESHOLD,
}: {
/** 按下时的屏幕坐标。 */
startClientX: number;
startClientY: number;
/** 当前屏幕坐标。 */
clientX: number;
clientY: number;
/** 阈值(屏幕 px);缺省用画布统一口径 `RESOURCE_CANVAS_DRAG_THRESHOLD`。 */
threshold?: number;
}): boolean {
if (
![startClientX, startClientY, clientX, clientY, threshold].every((value) =>
Number.isFinite(value),
)
) {
// 几何不成立时按「没有拖动」算:宁可把一次拖动当点击(用户再点一下即可),
// 也不能把一次点击误判成拖动而吃掉它。
return false;
}
return (
Math.hypot(clientX - startClientX, clientY - startClientY) >= threshold
);
}
@@ -78,8 +78,16 @@
.game-approval-dialog.resource-canvas-generation-floating-panel {
position: absolute;
z-index: 70;
/* 锚点给的是占位卡中心:不居中就会整体右偏半个面板宽(真实浏览器 x287 vs 卡中心 286 复现过)。 */
/*
* 锚点给的是占位卡中心(宿主会把它收进「浮层左右两边各留 12px」的区间,卡片靠边时浮层仍
* 完整落在画布内):不居中就会整体右偏半个面板宽(真实浏览器 x287 vs 卡中心 286 复现过)。
*/
transform: translateX(-50%);
/*
* 宽度口径:`min(560px, 100% - 24px)`。这两个数在宿主侧算水平收口时要用同一份
* `resourceCanvasGenerationVisibilityModel` 的 `RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH`
* 与 `..._VIEWPORT_INSET`),改一处必须改另一处,有声明级守卫用例钉住这个关系。
*/
width: min(560px, calc(100% - 24px));
/* 外形走基类 token(= 网页端画布面板那一套),三块浮层因此长得一样。 */
border: 1px solid var(--game-canvas-card-border, #e4c8ba);
@@ -90,16 +90,86 @@ export function revealResourceCanvasGenerationContent({
}
/**
* 浮层该占多高、以及它挂在占位卡下面还是直接盖住占位卡
* 生成浮层的宽度口径:最大宽度,以及与画布可视边之间必须留出的内缩
*
* 与 `resourceCanvasGenerationPanel.css` 里的 `width: min(560px, calc(100% - 24px))` 同口径
* 24px = 两侧各 12px)。宿主按这对值算**水平收口**:浮层贴着占位卡,但恒定留在画布可视
* 范围内,不被画布边界切掉。两处一起改(有声明级守卫用例钉住这个关系)。
*/
export const RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH = 560;
export const RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET = 12;
/** 浮层在这个画布宽下的实际宽度(CSS px)。画布未就绪时退回最大宽度。 */
export function resourceCanvasGenerationPanelWidth(
canvasWidth: number,
): number {
if (!Number.isFinite(canvasWidth) || canvasWidth <= 0) {
return RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH;
}
return Math.max(
0,
Math.min(
RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH,
canvasWidth - RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET * 2,
),
);
}
/**
* 浮层锚点(贴着占位卡下沿居中那条水平中线)的收口。
*
* 生成浮层比资源卡宽得多(560 vs 180),卡片靠近画布左右边缘时,居中展开的浮层会有一半落到
* 画布之外被 `overflow: hidden` 切掉——用户看到的就是「生成窗口和画布边缘撞在一起」。这里把
* 锚点收在「浮层左右两边各留 `inset`」的区间里:浮层仍然贴着卡片,但**恒定完整落在画布内**,
* 平移、缩放、拖动卡片都不需要另做补偿。
*
* 画布比浮层还窄时不硬撑:居中交给 CSS 的宽度收口(`calc(100% - 24px)`)。
*/
export function clampResourceCanvasGenerationPanelAnchorX({
centerX,
canvasWidth,
panelWidth,
inset = RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET,
}: {
/** 未收口锚点:占位卡中心的屏幕 X(画布内坐标)。 */
centerX: number;
canvasWidth: number;
panelWidth: number;
inset?: number;
}): number {
if (
![centerX, canvasWidth, panelWidth, inset].every((value) =>
Number.isFinite(value),
) ||
canvasWidth <= 0 ||
panelWidth <= 0
) {
return centerX;
}
const min = Math.max(0, inset) + panelWidth / 2;
const max = canvasWidth - Math.max(0, inset) - panelWidth / 2;
if (max <= min) {
return canvasWidth / 2;
}
return Math.min(max, Math.max(min, centerX));
}
/**
* 浮层该占多高、顶边落在哪、以及它挂在占位卡下面还是直接盖住占位卡。
*
* 三版都踩过的坑,这里一次钉住:
* 1. 用 `window.innerHeight` 当上界 → 面板垂到底栏下面,提交按钮点不到;
* 2. 用「当前顶边到安全底边」当上界 → 打开瞬间只剩一百多像素,只见标题与提交行;
* 3. 用固定最小高度硬顶 → 底边反过来被顶出画布(`overflow: hidden` 直接切掉)。
*
* 正确口径:按**画布安全带**(画布高 − 顶栏 − 底栏)分配。装得下「占位卡 + 间隙 + 浮层」时
* 浮层挂在卡下面;装不下时(例如缩放 1.5,卡就占 192px)浮层改为**与卡顶边对齐、盖住占位卡**,
* 用整条安全带当编辑高度——占位允许被压到浮层后面,但全局缩放不变、提交按钮永远可见。
* 正确口径:按**占位卡在画布上的真实屏幕位置**分配「卡下沿到安全底边」这一段。装得下可编辑
* 高度时浮层挂在卡下面;装不下时(例如缩放 1.5,卡就占 192px;又或者占位被排到画布下半屏)
* 浮层改为**与卡顶边对齐、盖住占位卡**,用「卡顶边到安全底边」当编辑高度——占位允许被压到
* 浮层后面,但全局缩放不变、提交按钮永远可见。
*
* 第四版修的就是这里:早先只吃「安全带高度 − 卡高」,等于假设占位卡永远贴在安全带上沿。占位卡
* 是追加在栏目内容最下方的,落到下半屏是常态,于是浮层底边越过画布下沿被切掉——验收现场那条
* 「生成窗口和画布边缘碰撞」。现在顶边与可用高度都由真实位置算出来,浮层恒定完整落在画布内。
*/
export const RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT = 280;
export const RESOURCE_CANVAS_GENERATION_PANEL_FLOOR_HEIGHT = 160;
@@ -108,6 +178,8 @@ export const RESOURCE_CANVAS_GENERATION_PANEL_MAX_HEIGHT = 520;
export type ResourceCanvasGenerationPanelPlacement = {
/** 浮层顶边是否与占位卡顶边对齐(盖住占位)而不是挂在卡下面。 */
overlaysAnchor: boolean;
/** 浮层顶边(CSS px,画布内坐标,已收进安全带)。 */
top: number;
/** 浮层可用的高度(CSS px)。 */
availableHeight: number;
};
@@ -116,6 +188,7 @@ export function resolveResourceCanvasGenerationPanelPlacement({
canvasHeight,
topInset,
bottomInset,
anchorTop,
anchorHeight,
gap = 12,
minHeight = RESOURCE_CANVAS_GENERATION_PANEL_MIN_HEIGHT,
@@ -126,6 +199,8 @@ export function resolveResourceCanvasGenerationPanelPlacement({
canvasHeight: number;
topInset: number;
bottomInset: number;
/** 浮层锚点那一块(占位卡)的**屏幕**顶边(画布内坐标)。 */
anchorTop: number;
/** 浮层锚点那一块(占位卡)的**屏幕**高度(世界尺寸 × 当前缩放)。 */
anchorHeight: number;
gap?: number;
@@ -133,28 +208,41 @@ export function resolveResourceCanvasGenerationPanelPlacement({
floor?: number;
maxHeight?: number;
}): ResourceCanvasGenerationPanelPlacement {
const safeTop = Math.max(0, topInset);
const safeBottom = Math.max(
safeTop + 1,
canvasHeight - Math.max(0, bottomInset),
);
if (
!Number.isFinite(canvasHeight) ||
!Number.isFinite(anchorTop) ||
!Number.isFinite(anchorHeight) ||
canvasHeight <= 0
) {
return { overlaysAnchor: false, availableHeight: minHeight };
}
const bandHeight = Math.max(
0,
canvasHeight - Math.max(0, topInset) - Math.max(0, bottomInset),
);
const below = bandHeight - Math.max(0, anchorHeight) - Math.max(0, gap);
if (below >= minHeight) {
return {
overlaysAnchor: false,
availableHeight: Math.min(maxHeight, Math.floor(below)),
top: safeTop,
availableHeight: minHeight,
};
}
// 卡下面塞不下可编辑高度:改用「盖住占位卡」的整条安全带。
const overlayBudget = Math.max(0, bandHeight - Math.max(0, gap));
const belowTop = anchorTop + Math.max(0, anchorHeight) + Math.max(0, gap);
const belowSpace = safeBottom - belowTop;
if (belowSpace >= minHeight) {
return {
overlaysAnchor: false,
top: belowTop,
availableHeight: Math.min(maxHeight, Math.floor(belowSpace)),
};
}
/*
* 卡下面塞不下可编辑高度:改用「盖住占位卡」——顶边取卡顶边,但不能越出安全带
* (卡自己贴着下沿时,至少给出一屏可编辑高度,而不是把浮层顶到画布外面去)。
*/
const overlayTop = Math.max(safeTop, Math.min(anchorTop, safeBottom - floor));
const overlayBudget = Math.max(0, safeBottom - overlayTop);
return {
overlaysAnchor: true,
top: overlayTop,
availableHeight:
overlayBudget >= floor
? Math.min(maxHeight, Math.floor(overlayBudget))
@@ -2,6 +2,7 @@ import type { PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resourceCanvasGestureExceededDragThreshold } from './resourceCanvasCardGestureModel';
import {
bindResourceCanvasGenerationPlaceholderTask,
failResourceCanvasGenerationPlaceholder,
@@ -33,6 +34,7 @@ type PlaceholderDragState = {
startX: number;
startY: number;
scale: number;
/** 这次手势**已经越过拖动阈值**(不是「收到过 pointermove」)。 */
changed: boolean;
};
@@ -70,6 +72,13 @@ export function useResourceCanvasGenerationPlaceholders({
const placeholdersRef = useRef<ResourceCanvasGenerationPlaceholder[]>([]);
placeholdersRef.current = placeholders;
const dragRef = useRef<PlaceholderDragState | null>(null);
/**
* 拖动收尾的那次 `click` 一次性抑制:拖动(down/up 落在同一张卡上)必然引出一次 click,
* 不挡掉它,拖完占位就会顺手开合一次生成浮层。与资源卡那条链路同一口径
* `skipNextResourceCardClickRef` + 下一个宏任务收干净)。
*/
const suppressedClickDraftIdRef = useRef<string | null>(null);
const suppressedClickTimerRef = useRef<number | null>(null);
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
@@ -77,6 +86,11 @@ export function useResourceCanvasGenerationPlaceholders({
useEffect(() => {
dragRef.current = null;
setDraggingDraftId(null);
suppressedClickDraftIdRef.current = null;
if (suppressedClickTimerRef.current !== null) {
window.clearTimeout(suppressedClickTimerRef.current);
suppressedClickTimerRef.current = null;
}
setPlaceholders((current) =>
resourceCanvasGenerationPlaceholdersForProject(current, projectId),
);
@@ -166,6 +180,41 @@ export function useResourceCanvasGenerationPlaceholders({
return drag;
}, []);
/**
* 登记一次性点击抑制,并在**下一个宏任务**里收干净。
*
* `click` 由浏览器紧接着 `pointerup` 补发(同一轮任务内),所以抑制状态只需要活过一个宏任务;
* 定时清掉的好处是「拖完松手落在卡外、浏览器没补 click」时也不会把抑制残留到下一次真点击上。
*/
const suppressClickAfterDrag = useCallback((draftId: string) => {
if (suppressedClickTimerRef.current !== null) {
window.clearTimeout(suppressedClickTimerRef.current);
}
suppressedClickDraftIdRef.current = draftId;
suppressedClickTimerRef.current = window.setTimeout(() => {
suppressedClickDraftIdRef.current = null;
suppressedClickTimerRef.current = null;
}, 0);
}, []);
/**
* 消费一次「这次 click 是拖动收尾的副产物」:宿主在开合浮层前问一次,返回 true 就跳过。
*
* 判定要写在手势那一侧(这里),不能写进卡片组件:拖动状态、阈值与坐标都在这一层,
* 卡片只该拿到「点我一下」这个语义。
*/
const consumeDragClick = useCallback((draftId: string) => {
if (suppressedClickDraftIdRef.current !== draftId) {
return false;
}
suppressedClickDraftIdRef.current = null;
if (suppressedClickTimerRef.current !== null) {
window.clearTimeout(suppressedClickTimerRef.current);
suppressedClickTimerRef.current = null;
}
return true;
}, []);
/**
* 占位卡自己的指针拖动。
*
@@ -200,7 +249,7 @@ export function useResourceCanvasGenerationPlaceholders({
scale: Number.isFinite(scale) && scale > 0 ? scale : 1,
changed: false,
};
setDraggingDraftId(placeholder.draftId);
// 「拖动中」不在这里置位:按下还不一定是拖动(见 onPointerMove 的阈值判据)。
},
onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
@@ -208,11 +257,30 @@ export function useResourceCanvasGenerationPlaceholders({
return;
}
event.stopPropagation();
if (!drag.changed) {
/*
* 只有真的移过阈值才算拖动:按下时浏览器就可能补一次零位移的 pointermove,把「收到过
* move」当拖动信号,会让一次普通点击直接吞掉自己的 click(点占位卡打不开浮层)。
*/
if (
!resourceCanvasGestureExceededDragThreshold({
startClientX: drag.startClientX,
startClientY: drag.startClientY,
clientX: event.clientX,
clientY: event.clientY,
})
) {
return;
}
drag.changed = true;
suppressClickAfterDrag(drag.draftId);
// 拖动样式也从越过阈值这一刻起才亮:点一下不该闪一次「拖动中」。
setDraggingDraftId(drag.draftId);
}
const nextX =
drag.startX + (event.clientX - drag.startClientX) / drag.scale;
const nextY =
drag.startY + (event.clientY - drag.startClientY) / drag.scale;
drag.changed = true;
move(drag.draftId, nextX, nextY);
},
onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => {
@@ -231,7 +299,7 @@ export function useResourceCanvasGenerationPlaceholders({
move(drag.draftId, drag.startX, drag.startY);
},
}),
[clearDrag, move, viewportScale],
[clearDrag, move, suppressClickAfterDrag, viewportScale],
);
const projectPlaceholders = useMemo(
@@ -250,6 +318,7 @@ export function useResourceCanvasGenerationPlaceholders({
remove,
move,
dragHandlersFor,
consumeDragClick,
placeholderByTaskId: (taskId: string) =>
resourceCanvasGenerationPlaceholderByTaskId(
placeholdersRef.current,
@@ -168,6 +168,7 @@ import {
type ResourceCanvasBottomToolAction,
} from '../../features/resource-canvas/resourceCanvasBottomToolbarModel';
import { ResourceCanvasBottomToolbarView } from '../../features/resource-canvas/ResourceCanvasBottomToolbarView';
import { resourceCanvasGestureExceededDragThreshold } from '../../features/resource-canvas/resourceCanvasCardGestureModel';
import {
isResourceCanvasInteractionTarget,
isResourceCanvasPanTarget,
@@ -188,7 +189,9 @@ import {
type ResourceCanvasGenerationPlaceholder,
} from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel';
import {
clampResourceCanvasGenerationPanelAnchorX,
resolveResourceCanvasGenerationPanelPlacement,
resourceCanvasGenerationPanelWidth,
revealResourceCanvasGenerationContent,
} from '../../features/resource-canvas/resourceCanvasGenerationVisibilityModel';
import {
@@ -326,7 +329,6 @@ import {
normalizeInfiniteResourceCanvasViewport,
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_DRAG_THRESHOLD,
RESOURCE_CANVAS_FIT_PADDING,
RESOURCE_CANVAS_INITIAL_FIT_MAX_SCALE,
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
@@ -717,8 +719,6 @@ const RESOURCE_GENERATION_SAFE_INSET_FALLBACK = {
};
/** 面板高度量不到时的兜底(与浮层的 `max-height` 同量级),宁多留不贴栏。 */
const RESOURCE_CANVAS_GENERATION_PANEL_HEIGHT_FALLBACK = 320;
/** 面板宽度量不到时的兜底:与浮层 CSS 的 `min(560px, 100% - 24px)` 同口径。 */
const RESOURCE_CANVAS_GENERATION_PANEL_WIDTH_FALLBACK = 560;
/** 占位卡与它下沿浮层之间的间隙:与浮层锚点用同一个数。 */
const RESOURCE_CANVAS_GENERATION_PANEL_GAP = 12;
function resourceGenerationOverlaySafeInsets(element: HTMLElement | null) {
@@ -5979,7 +5979,14 @@ export default function ProjectDevelopmentView({
const deltaY = event.clientY - drag.startClientY;
if (
!drag.changed &&
Math.hypot(deltaX, deltaY) < RESOURCE_CANVAS_DRAG_THRESHOLD
// 「拖动还是点击」与生成占位卡共用同一条判据(`resourceCanvasCardGestureModel`):
// 卡类手势的阈值只该有一份,两边各写一套的结果是占位卡那条链路漏掉抑制。
!resourceCanvasGestureExceededDragThreshold({
startClientX: drag.startClientX,
startClientY: drag.startClientY,
clientX: event.clientX,
clientY: event.clientY,
})
) {
return;
}
@@ -9194,44 +9201,61 @@ export default function ProjectDevelopmentView({
})
: null;
/**
* ****
*
* `getBoundingClientRect` / `clientHeight`
*
*/
const resourceGenerationPanelCanvasSize = resourceCanvasElementSize(
resourceCanvasRef.current,
);
const resourceGenerationPanelInsets = resourceGenerationOverlaySafeInsets(
resourceCanvasRef.current,
);
/**
* ****沿
*
* `window.innerHeight`
*
* reveal
* 沿
* 沿 `overflow: hidden`
* ****
* reveal
*/
const resourceGenerationPanelPlacement = resourceGenerationPanelStyle
? resolveResourceCanvasGenerationPanelPlacement({
canvasHeight: resourceCanvasElementSize(resourceCanvasRef.current)
.height,
topInset: resourceGenerationOverlaySafeInsets(resourceCanvasRef.current)
.top,
bottomInset: resourceGenerationOverlaySafeInsets(
resourceCanvasRef.current,
).bottom,
canvasHeight: resourceGenerationPanelCanvasSize.height,
topInset: resourceGenerationPanelInsets.top,
bottomInset: resourceGenerationPanelInsets.bottom,
// 占位卡的坐标是**世界坐标**,安全带是 CSS px:按当前缩放与视口位移换算到画布内坐标。
anchorTop:
resourceCanvasSceneViewportRef.current.y +
(resourceGenerationPanelPlaceholder?.y ?? 0) *
(resourceCanvasSceneViewportRef.current.scale || 1),
// 占位卡高度是**世界坐标**,安全带是 CSS px:按当前缩放换算成屏幕高度再扣。
anchorHeight:
(resourceGenerationPanelPlaceholder?.height ?? 0) *
(resourceCanvasSceneViewportRef.current.scale || 1),
// 卡与浮层之间的间隙:与 reveal 算「整块要带进可视区」时用同一个数。
gap: RESOURCE_CANVAS_GENERATION_PANEL_GAP,
})
: null;
/**
* / 沿CSS
* `translateX(-50%)`
* 沿
*
* CSS `translateX(-50%)`线
*/
const resourceGenerationPanelFloatingStyle =
resourceGenerationPanelStyle && resourceGenerationPanelPlacement
? {
...resourceGenerationPanelStyle,
...(resourceGenerationPanelPlacement.overlaysAnchor &&
resourceGenerationPanelPlaceholder
? {
top:
resourceCanvasSceneViewportRef.current.y +
resourceGenerationPanelPlaceholder.y *
(resourceCanvasSceneViewportRef.current.scale || 1),
}
: {}),
left: clampResourceCanvasGenerationPanelAnchorX({
centerX: resourceGenerationPanelStyle.left,
canvasWidth: resourceGenerationPanelCanvasSize.width,
panelWidth: resourceCanvasGenerationPanelWidth(
resourceGenerationPanelCanvasSize.width,
),
}),
top: resourceGenerationPanelPlacement.top,
maxHeight: `${resourceGenerationPanelPlacement.availableHeight}px`,
}
: null;
@@ -9276,7 +9300,8 @@ export default function ProjectDevelopmentView({
const panelWidth = Math.max(
0,
panelElement?.getBoundingClientRect().width ??
RESOURCE_CANVAS_GENERATION_PANEL_WIDTH_FALLBACK,
// 量不到时按同一套宽度口径估:`min(560, 画布宽 - 24)`。
resourceCanvasGenerationPanelWidth(canvasSize.width),
);
const viewport = normalizeResourceBookViewport(
category === RESOURCE_BOOK_ALL_TARGET
@@ -9794,9 +9819,16 @@ export default function ProjectDevelopmentView({
placeholder,
)}
onTogglePanel={() =>
toggleResourceGenerationPlaceholderPanel(
placeholder,
// 拖动收尾那一次 click 是拖动的副产物(down/up 落在同一张卡上
// 浏览器必补一次 click):吃掉它,否则拖一下占位就会顺手把生成
// 浮层弹开。与资源卡拖拽后的抑制同一口径。
resourceGenerationPlaceholders.consumeDragClick(
placeholder.draftId,
)
? undefined
: toggleResourceGenerationPlaceholderPanel(
placeholder,
)
}
onRemove={() =>
removeResourceGenerationPlaceholderCard(
@@ -10875,8 +10907,19 @@ export default function ProjectDevelopmentView({
setResourceAssetGenerationTasksPanelOpen((current) => !current)
}
onFocusTask={focusResourceAssetGenerationTask}
/*
chrome
`[data-generation-tasks-placement]`
*/
placement={
uiEditorRoute ? 'editor' : mode === 'run' ? 'run' : 'canvas'
uiEditorRoute
? 'editor'
: mode === 'run'
? 'run'
: resourceBookView === 'child'
? 'canvas'
: 'canvas-overview'
}
/>
</section>
@@ -23,7 +23,11 @@ import {
resourceCanvasAssetGenerationReferenceIssue,
resourceCanvasAssetGenerationUserReferenceLimit,
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
import {
resolveResourceCanvasBottomTools,
type ResourceCanvasAssetToolAction,
resourceCanvasBottomToolActions,
} from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
afterEach(() => {
cleanup();
@@ -265,6 +269,121 @@ describe('生成面板的参考接线', () => {
});
});
/**
* 图标规范(`icon-spec`)是验收现场被问到的那一条:「生成图标规范时没法 @」。
*
* 面板里那段注释曾写成「图集与图标规范这两类入口不给选择器」,与判据不一致,容易被读成
* 「图标规范被有意排除」。这里把它钉成可执行的事实:图标规范与普通生成一致地呈现 `@`
* 与参考计数,并且提交时真的把引用带出去。
*/
test('图标规范入口呈现参考计数与引用输入区,提交带回引用', async () => {
const user = userEvent.setup();
const onSubmit =
vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>();
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')];
render(
<ResourceCanvasAssetGenerationPanelView
action={iconSpecAction}
assets={[imageAsset]}
projectPath="/tmp/project"
draft={{
prompt: '按这套界面风格出图标规范 @素材-a',
assetName: '图标规范',
aspectRatio: '1:1',
imageSize: '1K',
references,
}}
onSubmit={onSubmit}
onClose={() => undefined}
/>,
);
const panel = screen.getByRole('dialog', { name: '图标规范' });
expect(panel.textContent).toContain('参考图 1/5');
expect(
within(panel).getByRole('button', { name: '插入素材引用' }),
).not.toBeNull();
await user.click(within(panel).getByRole('button', { name: '图标规范' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
kind: 'icon-spec',
references: [expect.objectContaining({ resourceId: 'asset-a' })],
});
});
test('图标规范的 @ 真的能开出素材选择器(不是只画了一枚按钮)', async () => {
const user = userEvent.setup();
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
render(
<ResourceCanvasAssetGenerationPanelView
action={iconSpecAction}
assets={[imageAsset]}
projectPath="/tmp/project"
onSubmit={vi.fn()}
onClose={() => undefined}
/>,
);
await user.click(screen.getByRole('button', { name: '插入素材引用' }));
expect(
await screen.findByRole('dialog', { name: '选择素材' }),
).not.toBeNull();
});
/**
* 反向守卫:`@` 的有无只由**一条**判据决定,不是每条入口各自决定。
*
* 遍历底部工具栏里真实存在的生成入口逐条渲染面板,断言「面板里有没有 @」与
* `resourceCanvasAssetGenerationAcceptsReferences` 完全一致;并钉住唯一的例外是图集。
* 这条用例的价值在增量:以后新增一条入口忘了接 `@`,或者有人把某条入口按类型硬编码成
* 没有选择器,这里立刻会红。
*/
test('底部工具栏每条生成入口的 @ 与判据完全一致,唯一例外是图集', () => {
const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png');
const actions = (
['ui-interaction', 'character', 'scene', 'audio'] as const
).flatMap((category) =>
resolveResourceCanvasBottomTools(category).flatMap((tool) =>
resourceCanvasBottomToolActions(tool),
),
);
const generationActions = actions.filter(
(candidate) => candidate.route === 'asset',
);
expect(generationActions.length).toBeGreaterThan(0);
expect(
generationActions
.filter(
(candidate) =>
!resourceCanvasAssetGenerationAcceptsReferences(candidate),
)
.map((candidate) => candidate.id),
).toEqual(['generate-icon-spritesheet']);
// 图标规范就在这一批入口里:这条断言同时挡住「入口被改名 / 被摘掉」的静默漂移。
expect(generationActions.map((candidate) => candidate.id)).toContain(
'generate-spec-icon',
);
for (const candidate of generationActions) {
const view = render(
<ResourceCanvasAssetGenerationPanelView
action={candidate}
assets={[imageAsset]}
projectPath="/tmp/project"
onSubmit={vi.fn()}
onClose={() => undefined}
/>,
);
expect(
screen.queryByRole('button', { name: '插入素材引用' }) !== null,
`${candidate.id}${candidate.label}`,
).toBe(resourceCanvasAssetGenerationAcceptsReferences(candidate));
view.unmount();
}
});
test('图集入口不呈现参考选择,只提交纯提示词', async () => {
const user = userEvent.setup();
const onSubmit =
@@ -57,7 +57,7 @@ function renderSidebar(
tasks: readonly ResourceCanvasAssetGenerationTask[],
options: {
open?: boolean;
placement?: 'canvas' | 'run' | 'editor';
placement?: 'canvas-overview' | 'canvas' | 'run' | 'editor';
} = {},
) {
const onToggleOpen = vi.fn();
@@ -283,6 +283,18 @@ describe('「生成任务」侧栏', () => {
/>,
);
expect(placementOf()).toBe('canvas');
// 资源总览(画本)页单独一档:那一页顶部没有钉死的栏目标题栏。
rerender(
<ResourceCanvasAssetGenerationTasksPanelView
tasks={[]}
open
onToggleOpen={vi.fn()}
onFocusTask={vi.fn()}
placement="canvas-overview"
/>,
);
expect(placementOf()).toBe('canvas-overview');
});
test('收起时先播完收起动画再卸载,动画期间列表内容不跳', () => {
@@ -22,6 +22,38 @@ const SIDEBAR_CSS_PATH = repoPath(
);
const rules = parseStyleSheet(readFileSync(SIDEBAR_CSS_PATH, 'utf8'));
/**
* 锚点在「资源栏目画布」那一档要让开的、钉死的栏目标题栏(`.game-resource-book-scene-titlebar`)。
* 它的高度定义在全局样式表里,而让开多少写在侧栏样式里——两个文件,必须一起改,
* 所以这里读真实声明把关系钉住。
*/
const GLOBAL_CSS_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
const globalRules = parseStyleSheet(readFileSync(GLOBAL_CSS_PATH, 'utf8'));
// 同一个求值器限制:全局表里也有 `prefers-reduced-motion` 档,求值时先排除掉。
const globalWidthRules = globalRules.filter(
(rule) => !(rule.media ?? '').includes('prefers-reduced-motion'),
);
/** 声明里允许的形状就这两种:单个长度,或「几个长度相加」的 `calc`(本文件不解析别的函数)。 */
function cssLengthToPx(value: string): number {
const raw = value.trim();
const calc = /^calc\((.+)\)$/u.exec(raw);
if (calc) {
return calc[1]
.split('+')
.map((term) => cssLengthToPx(term))
.reduce((total, term) => total + term, 0);
}
const match = raw.match(/^(-?\d+(?:\.\d+)?)(px|rem)$/u);
if (!match) {
throw new Error(`不是可换算的长度:${value}`);
}
const [, amount, unit] = match;
// 根字号取浏览器 / WebView 的默认 16px(全局样式表没有改写 `html` 的字号),
// 这个换算只用在那条「让开多少 ≥ 标题栏多高」的守卫上。
return Number(amount) * (unit === 'rem' ? 16 : 1);
}
/**
* `styleCascade` 的求值器只认识宽度媒体查询,遇到 `prefers-reduced-motion` 会主动报错;
* 那条规则的生效与否单独在下面按声明断言,所以求值时先把它排除掉。
@@ -229,6 +261,40 @@ describe('「生成任务」侧栏样式', () => {
expect(declaration(editorAnchor, 'grid-row')).toBe('2');
expect(declaration(editorAnchor, 'margin')).toBe('0.85rem');
/*
* 资源总览页与资源栏目画布页的**锚点高度不同**:画布页顶部钉着整宽的栏目标题栏
* (右端就是「返回资源总览」入口),锚点必须让开它,否则那枚开关会压在标题栏上
* (验收现场那条「生成任务和标题栏重叠」)。让开的高度与标题栏真实高度是跨文件关系,
* 所以这里读全局样式表里的 min-height 断言「让开得比它高」。
*/
const titlebarMinHeight = cssLengthToPx(
declaration(
resolveDeclarations(globalWidthRules, [
'.game-resource-book-scene-titlebar',
]),
'min-height',
),
);
const canvasAnchor = resolved([
'.game-resource-generation-tasks-anchor',
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='canvas']",
]);
const canvasAnchorTop = cssLengthToPx(
declaration(canvasAnchor, 'margin-top'),
);
expect(canvasAnchorTop).toBeGreaterThan(titlebarMinHeight);
const overviewAnchor = resolved([
'.game-resource-generation-tasks-anchor',
".game-resource-generation-tasks-anchor[data-generation-tasks-placement='canvas-overview']",
]);
const overviewAnchorTop = cssLengthToPx(
declaration(overviewAnchor, 'margin-top'),
);
// 总览页没有那条标题栏:仍贴画布顶边内缩,而且确实比画布页高一档。
expect(overviewAnchorTop).toBe(cssLengthToPx('0.85rem'));
expect(overviewAnchorTop).toBeLessThan(canvasAnchorTop);
// 开关保留原来工具条那一枚的外观(次级胶囊),只是搬到画布上并加一档面板投影;
// 取值仍然全部走平台 token,不引入第二套色板。
const toggle = resolved(['.game-resource-generation-tasks-toggle']);
@@ -286,6 +352,22 @@ describe('「生成任务」侧栏样式', () => {
).toBe(true);
});
test('资源总览 ↔ 资源画布切换时锚点高度有过渡,减动效下关掉', () => {
const anchor = resolved(['.game-resource-generation-tasks-anchor']);
expect(declaration(anchor, 'transition')).toContain('margin-top');
const reduced = rules.filter((rule) =>
(rule.media ?? '').includes('prefers-reduced-motion'),
);
expect(
reduced.some(
(rule) =>
rule.selectors.includes('.game-resource-generation-tasks-anchor') &&
rule.declarations.get('transition') === 'none',
),
).toBe(true);
});
test('焦点环可见(键盘可见焦点用平台 token)', () => {
const focusRule = rules.find((rule) =>
rule.selectors.includes(
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'vitest';
import { resourceCanvasGestureExceededDragThreshold } from '../src/features/resource-canvas/resourceCanvasCardGestureModel';
import { RESOURCE_CANVAS_DRAG_THRESHOLD } from '../src/view/project-development/resourceCanvasLayoutModel';
describe('画布卡片手势:拖动 / 点击的唯一判据', () => {
const gesture = (deltaX: number, deltaY: number) =>
resourceCanvasGestureExceededDragThreshold({
startClientX: 100,
startClientY: 100,
clientX: 100 + deltaX,
clientY: 100 + deltaY,
});
test('正好到阈值算拖动,差一点点仍算点击(与资源卡同一个阈值)', () => {
expect(RESOURCE_CANVAS_DRAG_THRESHOLD).toBe(5);
expect(gesture(RESOURCE_CANVAS_DRAG_THRESHOLD, 0)).toBe(true);
expect(gesture(RESOURCE_CANVAS_DRAG_THRESHOLD - 0.1, 0)).toBe(false);
expect(gesture(0, 0)).toBe(false);
});
test('位移按欧氏距离算:斜向 3/4 就是 5', () => {
expect(gesture(3, 4)).toBe(true);
expect(gesture(2, 3)).toBe(false);
});
test('阈值可覆盖(各档手势需要更紧或更松时只改参数,不改判据)', () => {
expect(
resourceCanvasGestureExceededDragThreshold({
startClientX: 0,
startClientY: 0,
clientX: 1,
clientY: 0,
threshold: 1,
}),
).toBe(true);
});
test('几何非法时按「没有拖动」算:宁可把拖动当点击,也不能把点击误判成拖动', () => {
expect(
resourceCanvasGestureExceededDragThreshold({
startClientX: Number.NaN,
startClientY: 100,
clientX: 200,
clientY: 100,
}),
).toBe(false);
});
});
@@ -8,6 +8,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView';
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView';
import {
RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH,
RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET,
} from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel';
import { repoPath } from './repoPath';
afterEach(cleanup);
@@ -142,4 +146,16 @@ describe('生成浮层样式结构', () => {
/\.game-resource-generation-actions \{[\s\S]*?bottom: 0;[\s\S]*?background:/u,
);
});
/**
* 宿主算「水平收口」用的是模型里那对常量(浮层最大宽 + 左右内缩),CSS 这里是同一对数的另一份
* 写法:两边必须一致,否则收口算的是 560 宽、画出来的却是另一个宽度,卡片靠边时又会露出去。
*/
test('宽度口径与宿主算水平收口的常量同源', () => {
expect(panelCss()).toContain(
`width: min(${RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH}px, calc(100% - ${
RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET * 2
}px));`,
);
});
});
@@ -1012,3 +1012,56 @@ describe('生成浮层按占位隔离(宿主回归)', () => {
expect(tauri.assetGenerationStarts[1]!.prompt).toBe(originalPrompt);
});
});
/**
* 验收现场那条「拖动未生成的资源卡片会触发点击事件」(至少背景音乐与音效会)。
*
* 浏览器在 pointerdown 与 pointerup 落在同一个节点上时**一定会补一次 `click`**——拖完卡片
* 松手,紧跟着的那次 click 会把卡片自己的点击语义再执行一遍。占位卡的点击语义就是开合生成浮层,
* 所以表现为「拖一下就把浮层弹开 / 收起」。这里从真实宿主上钉住两件事:拖动收尾的 click 被吃掉,
* 而一次真正的点击仍然照常开合(抑制只活过一次手势)。
*/
describe('拖动生成占位卡不触发卡片的点击(宿主回归)', () => {
test('拖动收尾的那次 click 被吃掉,普通点击仍然照常开合浮层', async () => {
const tauri = installHostTauri({
assets: [seedBgmAsset('seed-bgm')],
});
render(<HostWorkbench assets={[seedBgmAsset('seed-bgm')]} />);
await settle();
const { draftId } = await openBgmEntry();
// 点入口时浮层是开着的(占位与浮层由同一个动作产生)。
expect(floatingPanel()).not.toBeNull();
const placeholder = placeholderElement(draftId)!;
fireEvent.pointerDown(placeholder, {
pointerId: 7,
button: 0,
isPrimary: true,
clientX: 200,
clientY: 200,
});
fireEvent.pointerMove(placeholder, {
pointerId: 7,
clientX: 260,
clientY: 230,
});
fireEvent.pointerUp(placeholder, {
pointerId: 7,
clientX: 260,
clientY: 230,
});
// 浏览器补的那一次 click:抑制没生效时这里会立刻把浮层关掉。
fireEvent.click(placeholder);
await settle();
expect(floatingPanel()).not.toBeNull();
// 对照:没移动的点击是「点击」,仍然开合浮层(抑制不是把点击永久关掉)。
fireEvent.click(placeholder);
await settle();
expect(floatingPanel()).toBeNull();
// 拖动本身照常生效(拖动只改坐标,不取消这次生成)。
expect(placeholderElement(draftId)).not.toBeNull();
expect(tauri.assetGenerationStarts).toEqual([]);
}, 20_000);
});
@@ -190,6 +190,58 @@ describe('占位卡组件', () => {
// 删除按钮不能把点击透到卡片上(否则删完立刻又开一次浮层)。
expect(onTogglePanel).toHaveBeenCalledTimes(1);
});
/**
* 同一类缺陷的相邻一档:删除按钮自己 `stopPropagation`,「按在删除按钮上、松手落回卡片」
* 那次 click 会被派给共同祖先(卡片)。判据按**起手点**判,不看移动距离——从删除按钮起手的
* 手势永远不是「点卡片」。
*/
test('从删除按钮起手、松手落回卡片时不当作点卡片', () => {
const onTogglePanel = vi.fn();
const onRemove = vi.fn();
render(
<ResourceCanvasGenerationPlaceholderCardView
placeholder={placeholderFixture()}
active={false}
dragging={false}
onPointerDown={() => undefined}
onPointerMove={() => undefined}
onPointerUp={() => undefined}
onPointerCancel={() => undefined}
onTogglePanel={onTogglePanel}
onRemove={onRemove}
/>,
);
const card = screen.getByRole('button', {
name: 'AI 生成图片(待提交)',
});
const remove = screen.getByRole('button', {
name: '删除占位 AI 生成图片',
});
fireEvent.pointerDown(remove, {
pointerId: 3,
button: 0,
isPrimary: true,
clientX: 10,
clientY: 10,
});
// 松手落回卡片:浏览器把 click 派给共同祖先(卡片)。
fireEvent.click(card);
expect(onTogglePanel).not.toHaveBeenCalled();
expect(onRemove).not.toHaveBeenCalled();
// 之后正常点卡片仍然开合浮层(起手标记不残留到下一手势)。
fireEvent.pointerDown(card, {
pointerId: 4,
button: 0,
isPrimary: true,
clientX: 20,
clientY: 20,
});
fireEvent.click(card);
expect(onTogglePanel).toHaveBeenCalledTimes(1);
});
});
describe('占位宿主 Hook', () => {
@@ -262,7 +314,16 @@ describe('占位宿主 Hook', () => {
expect(hook.result.current.placeholders).toEqual([]);
});
test('拖动按视口缩放换算坐标,指针取消回到拖动前位置', () => {
/**
* 把 hook 的返回值也带出来:阈值、拖动中标记与点击抑制都在 hook 这一层,
* 只渲染卡片看不到它们。
*/
function renderDragHarness() {
const hookRef: {
current: ReturnType<
typeof useResourceCanvasGenerationPlaceholders
> | null;
} = { current: null };
function Harness() {
const [created, setCreated] = useState(false);
const placeholders = useResourceCanvasGenerationPlaceholders({
@@ -270,6 +331,7 @@ describe('占位宿主 Hook', () => {
resolvePlacement: () => ({ x: 10, y: 20 }),
viewportScale: () => 2,
});
hookRef.current = placeholders;
useEffect(() => {
if (created) {
return;
@@ -300,6 +362,17 @@ describe('占位宿主 Hook', () => {
}
render(<Harness />);
const hook = () => {
if (!hookRef.current) {
throw new Error('hook 还没挂上');
}
return hookRef.current;
};
return { hook };
}
test('拖动按视口缩放换算坐标,指针取消回到拖动前位置', () => {
renderDragHarness();
const card = screen.getByRole('button', {
name: 'AI 生成图片(待提交)',
});
@@ -341,4 +414,62 @@ describe('占位宿主 Hook', () => {
expect(card.style.left).toBe('30px');
expect(card.style.top).toBe('35px');
});
/**
* 验收现场那条「拖动未生成的资源卡片会触发点击事件」在单元层的钉子:
* 拖动与点击的判据是**移动距离**(与资源卡共用 `RESOURCE_CANVAS_DRAG_THRESHOLD`),
* 而且拖动收尾那一次 click 要被吃掉一次——只有这一次。
*/
test('位移没越过阈值不算拖动:卡片不动、也不吃掉后面的点击', () => {
const { hook } = renderDragHarness();
const card = screen.getByRole('button', {
name: 'AI 生成图片(待提交)',
});
const draftId = hook().placeholders[0]!.draftId;
fireEvent.pointerDown(card, {
pointerId: 11,
button: 0,
isPrimary: true,
clientX: 100,
clientY: 100,
});
// 按下还不算拖动:拖动样式不该在点一下的时候闪。
expect(hook().draggingDraftId).toBeNull();
// 抖动 2px(阈值 5px)不构成拖动。
fireEvent.pointerMove(card, { pointerId: 11, clientX: 102, clientY: 101 });
expect(card.style.left).toBe('10px');
expect(card.style.top).toBe('20px');
expect(hook().draggingDraftId).toBeNull();
fireEvent.pointerUp(card, { pointerId: 11, clientX: 102, clientY: 101 });
// 这一次是点击而不是拖动:后面的 click 必须照常生效。
expect(hook().consumeDragClick(draftId)).toBe(false);
});
test('越过阈值才算拖动:登记一次性点击抑制,下一次点击照常生效', () => {
const { hook } = renderDragHarness();
const card = screen.getByRole('button', {
name: 'AI 生成图片(待提交)',
});
const draftId = hook().placeholders[0]!.draftId;
fireEvent.pointerDown(card, {
pointerId: 12,
button: 0,
isPrimary: true,
clientX: 100,
clientY: 100,
});
// 视口缩放 2:屏幕 6px > 阈值 5px,越过阈值这一刻才算拖动的开始。
fireEvent.pointerMove(card, { pointerId: 12, clientX: 106, clientY: 100 });
expect(hook().draggingDraftId).toBe(draftId);
expect(card.style.left).toBe('13px');
fireEvent.pointerUp(card, { pointerId: 12, clientX: 106, clientY: 100 });
expect(hook().draggingDraftId).toBeNull();
// 拖动收尾那次 click 被吃掉一次,之后恢复正常。
expect(hook().consumeDragClick(draftId)).toBe(true);
expect(hook().consumeDragClick(draftId)).toBe(false);
});
});
@@ -154,8 +154,14 @@ describe('「生成任务」侧栏的开合与入口位置', () => {
'.game-resource-generation-tasks-anchor',
);
expect(anchor).not.toBeNull();
/*
* 这一份夹具停在**资源总览**(没有打开任何栏目页),所以锚点用的是总览那一档:
* 总览页顶部没有钉死的栏目标题栏,锚点贴画布顶边内缩即可;栏目画布页会因为那条整宽的
* 标题栏(右端是「返回资源总览」)而多让开一段高度,两档的坐标与过渡见
* `resourceCanvasAssetGenerationTasksSidebarStyle.test.ts`。
*/
expect(anchor?.getAttribute('data-generation-tasks-placement')).toBe(
'canvas',
'canvas-overview',
);
expect(
document
@@ -1,7 +1,13 @@
import { describe, expect, test } from 'vitest';
import { revealResourceCanvasGenerationContent } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel';
import { resolveResourceCanvasGenerationPanelPlacement } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel';
import {
clampResourceCanvasGenerationPanelAnchorX,
resolveResourceCanvasGenerationPanelPlacement,
RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH,
RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET,
resourceCanvasGenerationPanelWidth,
revealResourceCanvasGenerationContent,
} from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel';
const canvasSize = { width: 800, height: 600 };
const insets = { top: 60, bottom: 90 };
@@ -88,46 +94,84 @@ describe('生成浮层与占位的可见性', () => {
});
});
describe('浮层高度与「盖住占位」判定按画布安全带算', () => {
describe('浮层顶边与高度按占位卡的真实屏幕位置算', () => {
const placement = (input: {
canvasHeight: number;
topInset: number;
bottomInset: number;
anchorTop: number;
anchorHeight: number;
}) => resolveResourceCanvasGenerationPanelPlacement(input);
test('空间够时挂在卡下面,高度 = 安全带 - 卡 - 间隙', () => {
test('空间够时挂在卡下面:顶边贴卡下沿,高度 = 卡下沿到安全底边', () => {
const result = placement({
canvasHeight: 568,
topInset: 54,
bottomInset: 84,
anchorTop: 54,
anchorHeight: 128,
});
expect(result.overlaysAnchor).toBe(false);
// 安全带 430 - 卡 128 - 间隙 12 = 290:落在「至少 260 可编辑」的区间里
// 安全底边 484 - 卡下沿(54 + 128 - 间隙 12 = 290。
expect(result.top).toBe(54 + 128 + 12);
expect(result.availableHeight).toBe(290);
expect(result.top + result.availableHeight).toBe(568 - 84);
});
test('安全带给不出可编辑高度时改为盖住占位,拿整条安全带', () => {
// 缩放 1.5:占位卡视觉 192px,卡下面只剩 224 (< 280) → 盖住占位。
test('安全带给不出可编辑高度时改为盖住占位,拿「卡顶边到安全底边」', () => {
// 缩放 1.5:占位卡视觉 192px,卡下面只剩 234 (< 280) → 盖住占位。
const result = placement({
canvasHeight: 568,
topInset: 58,
bottomInset: 72,
anchorTop: 58,
anchorHeight: 192,
});
expect(result.overlaysAnchor).toBe(true);
// 安全带 438 - 间隙 12 = 426:远大于「一百多像素」的旧表现。
expect(result.availableHeight).toBe(426);
expect(result.top).toBe(58);
// 568 - 72 - 58 = 438:远大于「一百多像素」的旧表现。
expect(result.availableHeight).toBe(438);
expect(result.availableHeight).toBeGreaterThanOrEqual(260);
});
/**
* 验收现场那条「生成窗口和画布边缘碰撞」的回归:占位卡是追加在栏目内容最下方的,
* 落到画布下半屏是常态。旧口径只吃「安全带高度 − 卡高」,等于假设卡永远贴在安全带上沿,
* 于是浮层底边越过画布下沿被 `overflow: hidden` 切掉。
*/
test('占位卡落到下半屏时浮层底边仍收在安全底边以内', () => {
const result = placement({
canvasHeight: 600,
topInset: 60,
bottomInset: 90,
anchorTop: 470,
anchorHeight: 128,
});
expect(result.overlaysAnchor).toBe(true);
// 卡顶边 470 会顶出安全底边(510 - 160 = 350),所以顶边先收到 350,再给一整段可编辑高度。
expect(result.top).toBe(350);
expect(result.top + result.availableHeight).toBeLessThanOrEqual(600 - 90);
// 中间位置同理:顶边贴卡顶,底边贴安全底边。
const middle = placement({
canvasHeight: 600,
topInset: 60,
bottomInset: 90,
anchorTop: 260,
anchorHeight: 128,
});
expect(middle.overlaysAnchor).toBe(true);
expect(middle.top).toBe(260);
expect(middle.top + middle.availableHeight).toBe(600 - 90);
});
test('空间充足时收到上限,几何非法时回退到最小编辑高度', () => {
expect(
placement({
canvasHeight: 1400,
topInset: 54,
bottomInset: 84,
anchorTop: 54,
anchorHeight: 128,
}).availableHeight,
).toBe(520);
@@ -136,17 +180,92 @@ describe('浮层高度与「盖住占位」判定按画布安全带算', () => {
canvasHeight: 0,
topInset: 54,
bottomInset: 84,
anchorTop: 54,
anchorHeight: 128,
}),
).toEqual({ overlaysAnchor: false, availableHeight: 280 });
).toEqual({ overlaysAnchor: false, top: 54, availableHeight: 280 });
// 画布小到连安全带都装不下:退到安全带能给的量,但仍有可编辑高度。
const tiny = placement({
canvasHeight: 240,
topInset: 40,
bottomInset: 40,
anchorTop: 40,
anchorHeight: 128,
});
expect(tiny.overlaysAnchor).toBe(true);
expect(tiny.availableHeight).toBe(160);
expect(tiny.top + tiny.availableHeight).toBeLessThanOrEqual(240 - 40);
});
});
describe('浮层水平收口:贴着卡片,但恒定留在画布内', () => {
const canvasWidth = 900;
const panelWidth = resourceCanvasGenerationPanelWidth(canvasWidth);
test('宽度口径与浮层 CSS 同源:min(560, 画布宽 - 24)', () => {
expect(panelWidth).toBe(RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH);
expect(resourceCanvasGenerationPanelWidth(500)).toBe(
500 - RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET * 2,
);
// 画布未就绪(量不到宽)时退回最大宽度,交给 CSS 收。
expect(resourceCanvasGenerationPanelWidth(0)).toBe(
RESOURCE_CANVAS_GENERATION_PANEL_MAX_WIDTH,
);
});
test('卡片贴左边 / 贴右边时锚点收进来,浮层两边各留 12px', () => {
const left = clampResourceCanvasGenerationPanelAnchorX({
centerX: 20,
canvasWidth,
panelWidth,
});
expect(left - panelWidth / 2).toBe(
RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET,
);
const right = clampResourceCanvasGenerationPanelAnchorX({
centerX: canvasWidth - 5,
canvasWidth,
panelWidth,
});
expect(right + panelWidth / 2).toBe(
canvasWidth - RESOURCE_CANVAS_GENERATION_PANEL_VIEWPORT_INSET,
);
// 卡片在中间时不动:收口只在真的会越界时才生效。
expect(
clampResourceCanvasGenerationPanelAnchorX({
centerX: canvasWidth / 2,
canvasWidth,
panelWidth,
}),
).toBe(canvasWidth / 2);
});
test('画布比浮层还窄时居中(宽度收口交给 CSS),几何非法时原样返回', () => {
const narrowCanvas = 400;
const narrowPanel = resourceCanvasGenerationPanelWidth(narrowCanvas);
expect(
clampResourceCanvasGenerationPanelAnchorX({
centerX: 10,
canvasWidth: narrowCanvas,
panelWidth: narrowPanel,
}),
).toBe(narrowCanvas / 2);
expect(
clampResourceCanvasGenerationPanelAnchorX({
centerX: 33,
canvasWidth: Number.NaN,
panelWidth: 560,
}),
).toBe(33);
expect(
clampResourceCanvasGenerationPanelAnchorX({
centerX: 33,
canvasWidth: 800,
panelWidth: 0,
}),
).toBe(33);
});
});
@@ -11,6 +11,23 @@ Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只
- 处理:客户端门统一到 `PLATFORM_ART_SPRITESHEET_MAX_SLICES = 256`,判据与文案各只留一份(数字由常量插值),并在注释里点名三处同值权威(平台切分常量、工具 schema、公开契约)。
- 复用判据:凡是「平台产出 → 客户端校验后落盘」的链路,客户端门只能表达**安全 / 预算**约束,不得比平台的产品上限更严;两边上限要引同一个常量或同一份文档,改一边时必须同时改另一边,并补一条「上限之内必须能落盘」的回归用例。
## 2026-09-21 画布卡片「拖一下就触发点击」:阈值与点击抑制必须是卡类手势的一份判据
- **现象**:拖动未生成的资源占位卡(背景音乐 / 音效等所有类型)松手后,卡片自己的点击语义被多执行一次——生成浮层被顺手弹开或收起。
- **成因**:浏览器在 `pointerdown``pointerup` 落在同一节点上时**一定会补一次 `click`**,与中间移动了多少无关。生成占位卡那条手势把「收到过 pointermove」当成拖动信号(按下时浏览器就可能补一次零位移的 move),而且没有任何点击抑制;资源卡那条链路早就用 `RESOURCE_CANVAS_DRAG_THRESHOLD`5px+ `skipNextResourceCardClickRef` 处理过这件事,两条链路各写一套,于是只有占位卡漏。
- **处理(现行口径)**:卡类手势只有一份判据 `resourceCanvasGestureExceededDragThreshold``features/resource-canvas/resourceCanvasCardGestureModel.ts`,阈值仍取 `RESOURCE_CANVAS_DRAG_THRESHOLD`);拖动收尾那次 `click` 由手势层登记一次性抑制、宿主在「点卡片」的入口消费(占位卡是 `consumeDragClick`,资源卡是 `skipNextResourceCardClickRef`),抑制活过一个宏任务就清干净。「拖动中」样式也从越过阈值那一刻起才亮。
- **相邻一档**:从删除按钮起手、松手落回卡片的手势,`click` 会被派给共同祖先(卡片)——判据要看**起手点**(`ResourceCanvasGenerationPlaceholderCardView``gestureOriginRef`),不能只看移动距离。
- **易错点**:把「拖动样式」放在 `pointerdown` 上置位,等于承认「按下即拖动」,紧接着的点击又会被自己的抑制吃掉;阈值与抑制必须同时按同一判据走。
## 2026-09-21 画布浮层几何只能按卡片的真实屏幕位置算,分页锚点必须让开钉死的标题栏
- **现象一(浮层与画布边缘碰撞)**:生成浮层底边越过画布下沿被 `overflow: hidden` 切掉,卡片靠左右边时浮层还会有一半落到画布之外。
- **成因**:可用高度只按「安全带高度 − 卡高」算,等于假设占位卡永远贴在安全带上沿;占位卡是追加在栏目内容最下方的,落到下半屏是常态。水平方向则完全没有收口——浮层 560px 宽、按卡片中心居中展开,卡片靠边必然越界。
- **处理(现行口径)**`resolveResourceCanvasGenerationPanelPlacement` 吃卡片的**屏幕顶边**,顶边与可用高度都由真实位置算(卡下面塞不下就改为盖住占位,且顶边收进安全带);水平用 `clampResourceCanvasGenerationPanelAnchorX` 把锚点收进「浮层左右各留 12px」的区间。浮层宽度口径(`min(560px, calc(100% - 24px))`)在 CSS 与模型里各一份,有声明级守卫用例钉住同源。
- **现象二(生成任务开关压在标题栏上)**:资源栏目画布页顶部钉着整宽的栏目标题栏(`.game-resource-book-scene-titlebar`,42px,右端是「返回资源总览」),右上角开关按总览页的坐标摆就会直接压在它上面。
- **处理(现行口径)**:「生成任务」锚点按页分档(`canvas-overview` / `canvas` / `run` / `editor`):画布页让开那条标题栏的高度再加一段间隙,总览页仍贴画布顶边内缩;两档之间的高度差走 `margin-top` 过渡(`prefers-reduced-motion` 下关掉)。让开多少与标题栏多高是**跨文件关系**,守卫用例读全局样式表里那条 `min-height` 断言「让开得比它高」。
- **易错点**:给锚点写死 `top`,或只按某一页的 chrome 算一次坐标,工具条换行、页面切换或标题栏高度调整后都会重新压上去。
## Jenkins Windows 节点的 PATH 白名单决定 Godot 原生扩展能否构建
`Genarrative-Agc-Windows-Build` 在阶段里用 `AGC_WINDOWS_PATH` 整体替换 PATH、不继承节点机器的 PATH,所以 Godot C++ 引导需要的 CMake 与 Python 必须显式写进这份白名单,装在机器 PATH 上并不生效。2026-09-21 的 #97#99 连续失败都停在 `Get-Command cmake.exe`#93#96 是更早的手写 C ABI 在 MSVC C 模式下的对齐问题):节点只有 Visual Studio Build Tools`C:\BuildTools`)自带的 CMake 3.31,缺 Python 3。修复后白名单包含 `C:\BuildTools\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin``C:\Python312``C:\Python312\Scripts`preflight 校验 CMake ≥3.25、Python 3 和 Visual Studio 17 2022 生成器;把 `cmake.exe` 单独复制到别的目录会丢掉 `share/cmake-*/Modules`,不能替代加入安装目录。新节点的 Python 用 `python-3.12.10-amd64.exe /quiet InstallAllUsers=1 TargetDir=C:\Python312 PrependPath=1 Include_launcher=1 InstallLauncherAllUsers=1` 静默安装即可,CMake 不必另装。
@@ -96,6 +113,7 @@ Vite 默认监听应用根下的 Rust `src-tauri/target`,构建产物较多时
- **易错点**:① 把弹层改成 `left: 0` 或往右挪也能让它可见,但那是改变展开方向,弹层会跑到触发钮右边(用户明确否决);② 只放开最外层聊天列不够——surface 与 conversation 各自都会裁,三层必须同时放开;③ 只按宽度比大小会误判:280px 面板里控制排本身也超出(发送钮右侧溢出 22px,被窗口右缘吃掉),那不是本条的原因,别顺手去改控制排布局。
- **验证**`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts``keeps the landscape workbench edge-to-edge with internal chat scrolling` 钉住三条 override 声明在场(删掉任一条即红)。真机几何用 playwright-cli 打开一份只含真实 `styles.css` 与真实 composer DOM 的最小复现页实测(视口 1000×700、面板 280px):弹层 rect 修复前后都是 `[-42, 108]`(位置未动),`elementFromPoint` 的命中区间从修复前的 `[2, 108]` 变成整块;档位文字在截图中完整可见。
- **关联**`apps/ai-game-creator-shell/src/styles.css``面板纵向布局(2026-07 Codex 风格改造)` 区块之后)、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
## 2026-09-17 资源卡「内容跟着边框动」与「模型缩略图被拉伸」是两条不同的几何陷阱
- **内容跟着状态边框位移**:卡片底态是 `border: 0`,悬停 / 选中才加 1px 边框;卡片是 `box-sizing: border-box`,而卡面(`.game-resource-card-visual`)与角标都是 `position: absolute; inset: 0`(包含块 = **padding box**)⇒ 状态一切换,内容盒四边各被吃掉 1px,卡面与角标整体位移并缩小 2px。修法:底态写成 `border: 1px solid transparent;`,状态只点亮 `border-color`;契约用例改成断言「资源卡规则里不得出现非 1px 的 `border` / `border-width`」。
@@ -142,6 +160,7 @@ Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\`
- **处理(现行口径)**:① 渲染 `ProjectDevelopmentView` 的桩统一登记 `list_local_project_asset_generations`(返回**数组**,空账本 `[]`Rust 侧返回 `Vec<AssetGenerationTaskRecord>`,不是 `{ tasks: [] }`);② `unexpectedCommands` 这类门禁**不要放宽**,只登记合法命令;③ 生产代码对 IPC 返回值做形状防御(`Array.isArray` 归一化),IPC 拒绝走既有提示路径,不产生 unhandled rejection`resourceCanvasAssetGenerationTaskModel.ts` / `resourceCanvasAssetGenerationQueue.ts` / `index.tsx` 的恢复 effect)。
- **易错点**:① 桩返回**非数组**时用例可能"看着绿"但同时报 unhandled rejection(实测:把 `[]` 误写成 `{ tasks: [] }` 就是 8 passed + 7 unhandled error),所以判"绿"必须同时看 unhandled 计数;② 新增挂载期 IPC 后要一次性 grep 所有 `ProjectDevelopmentView` 的桩,而不是等 CI 逐个炸;③ 提示条类断言(如「无读时提示」)会把「桩抛错」翻译成「多了一条提示」,排查时先看 unhandled,再看断言。
- **关联**`apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx``apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts``apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts`
## 2026-09-14 UI 编辑器返回后资源画布滚轮平移失效
- **现象**:资源管理打开 UI 编辑器再返回后,资源画布滚轮平移/缩放不再响应;返回前同一手势正常。
@@ -149,6 +168,7 @@ Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\`
- **处理**:将 `uiEditorRoute` 纳入 wheel effect 依赖,使进入/退出 UI 编辑器时先清理旧节点监听,再给返回后的新 manager 绑定同一处理器。
- **验证**`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts -t "restores resource canvas panning"`;回归用例覆盖打开栏目、wheel 平移、进入 UI 编辑器、返回并再次 wheel 平移。
- **关联**`apps/ai-game-creator-shell/src/view/project-development/index.tsx``apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
## 2026-09-14 AGC 就绪等待被 WMI 拖成分钟级:端口归属探测从 Get-NetTCPConnection 换成 netstat
- **现象**`npm run agc``[ai-game-creator-shell] starting backend stack``backend ready` 要等约 80 秒,中途反复出现 `等待配套后端就绪时归属校验未通过(api-server-owner-mismatch: 未知进程)`;而这段时间后端其实已经好了(实测 api-server 12:39:01 已在 8084 监听、`/healthz` 已 20012:40:19 才判 ready)。
@@ -157,6 +177,7 @@ Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\`
- **验证**`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 新增两条——「探测脚本使用 netstat 且不再出现 Get-NetTCPConnection」「命令行按 PID 缓存后随请求下发、TTL 过期即失效」;定向 vitest 55 passed。本机实测:不含 SpacetimeDB 端口的探测 368 ms(原约 22 秒)、含 SpacetimeDB 端口 3.8 秒、命中缓存 368 ms;`npm run agc:serve``starting backend stack``backend ready` 由约 80 秒降到 16.7 秒(其中归属校验只占 4.4 秒,其余是 SpacetimeDB + api-server 的真实启动时间)。
- **残留**:这台机器上首次 WMI 调用本身仍是秒级(曾见 18 秒),所以「新 SpacetimeDB PID 的第一次探测」仍可能多花几秒;命令行在进程存活期内不变,TTL 只用来限制 PID 复用造成的误判窗口。
- **关联**`apps/ai-game-creator-shell/scripts/start-dev-stack.mjs``readWindowsPortOwnerIdentities`)、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts``apps/ai-game-creator-shell/scripts/dev-windows-process.mjs`(退出清理仍走整份 `Win32_Process` 快照,自带 1 秒缓存,不在本次范围)。
## 2026-09-15 AGC JSON API 的响应体也必须有等待上限
- `fetchClientHttp` 的超时只覆盖请求到响应头返回;随后直接等待 `response.text()` 仍可能无限挂起。模型目录共用一个在途 Promise,响应体卡住会使后续刷新复用同一挂起请求、选择器持续忙碌。
@@ -181,6 +202,7 @@ Direct 工具桥会 canonicalize 项目根,事件中的路径可能带 `\\?\`
- **处理**`runNativeShellGate` 收窄为 `(group, gate)`,label 回到各门禁执行体里以字面量打印;分组能力与 `--groups=` 语义不变。脚本内已注明"不要把 label 抽成变量",外部壳的 `check-config` 是它的消费者。
- **验证**`node apps/mobile-shell/scripts/check-config.mjs` exit 0`node apps/desktop-shell/scripts/check-config.mjs` 已越过第 740–777 行的根脚本断言段(本机随后卡在本机不存在的 Tauri 生成产物目录,与本次改动无关);`--groups=contract`、vitest `scripts/project-ci-workflow.test.ts`、eslint 均通过。修复后 run 2097 六个 job 全绿。
- **关联**`scripts/check-native-shells.mjs``runNativeShellGate`)、`apps/desktop-shell/scripts/check-config.mjs``apps/mobile-shell/scripts/check-config.mjs`
## 2026-09-14 AGC 资源画布不要恢复「无条件重派生」,否则新增一张素材就整张重排
- **现象**:用户生成一张新素材后,画布上既有卡片全部移位,刚摆好的位置失效。
@@ -59,8 +59,9 @@
- **工具条动作行的间距只有一套**:行内按钮之间统一 24px(行 `gap: 7px` + 按钮左右 `padding: 11px`;「依赖 / 类型」分段控件内部是 0 间距的连通分段,靠 `margin-left: 17px` 与前一按钮保持同样的 24px)。**布局状态提示(`game-resource-reorder-status`)不进这一行**——它是随保存过程变长的文案(空 →「保存中」→「布局已保存」→ 失败原因),作为 flex 子项会把右侧按钮按文本宽度顶开,同一行不同时刻的按钮间距就会不一样;它改为工具条内的绝对定位,位置固定。
- **复用**:比例 / 尺寸选项与默认值来自网页端美术画布的纯模型 `src/components/image-editor/ImageCanvasGenerationModel.ts``EDITOR_IMAGE_DIMENSION_OPTIONS` / `IMAGE_MODEL_NANOBANANA2`),尺寸标签复用 `resolveEditorImageSizeLabel`;提示词上限复用 AGC 自己的 `resourceEditPromptMaxLength`(图片类默认 32000,与 Rust `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 同口径);「AI 润色」复用 `ResourcePromptPolishSlot``polish_local_project_prompt`);面板外壳复用 AGC 的 `ThemedModal`(与「生成素材」面板同一套宿主 chrome)。
- **参考图(`@` 引用)**:提示词输入区就是聊天 / 快速编辑同一个共享组件 `ResourceReferenceInput``@` 按钮的文案是「插入素材引用」,候选集只放当前项目已登记的位图,SVG 与未落盘条目排除)。是否挂它只由**一条**判据决定:`resourceCanvasAssetGenerationAcceptsReferences`(前端)与 `platform_art_asset_kind_accepts_user_reference_assets`Rust)——**唯一例外是图集 `icon-spritesheet`**(合同只接受单张权威规范引用,原生对多余参考是显式拒绝)。图标规范 `icon-spec` 虽然产出权威规范图,但生成时同样可以带参考,上限与普通生成一致(5 张;带规范前置的入口是「权威规范图 1 张 + 用户参考 4 张」)。面板里那句「图标规范不给选择器」是 kind 词汇收敛前的残留注释,已按现行判据改写,并补了「逐条入口的 `@` 与判据一致」的用例防止再漂。
- **收窄**:本地 IPC 的比例白名单是 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`,网页端还有 `4:3`。前端做的是「主站纯模型 ∩ 本地白名单」,不是另抄一份选项表(测试钉住两者包含关系与 `4:3` 缺席)。
- **没有复用到的**:网页端的生成 composer 子视图(`ImageCanvasBasicGenerationComposerView` / `ImageCanvasCharacterGenerationComposerView` / `ImageCanvasIconSpritesheetComposerView` / `ImageCanvasSpecGenerationPanelView` / `ImageCanvasGenerationImageOptionsView`)。原因有二:① 它们的比例选项含本地通道拒绝的 `4:3`;② 它们自带模型选择器、参考图槽位、BFF 直连(`ImageCanvasSpecGenerationPanelView` 直连 `/api/editor/llm/icon-specs/*`AGC 无该通道),本地 IPC 没有 `model` 也没有参考图入参,照搬会渲染一批改不了请求的假控件。因此面板是 AGC 自己的薄壳,只把**纯模型**接进来。
- **没有复用到的**:网页端的生成 composer 子视图(`ImageCanvasBasicGenerationComposerView` / `ImageCanvasCharacterGenerationComposerView` / `ImageCanvasIconSpritesheetComposerView` / `ImageCanvasSpecGenerationPanelView` / `ImageCanvasGenerationImageOptionsView`)。原因有二:① 它们的比例选项含本地通道拒绝的 `4:3`;② 它们自带模型选择器BFF 直连(`ImageCanvasSpecGenerationPanelView` 直连 `/api/editor/llm/icon-specs/*`,AGC 无该通道),本地 IPC 没有 `model` 入参,照搬会渲染一批改不了请求的假控件。参考图入参**不属于**这类缺口:AGC 侧由 `referenceAssetIds` 交原生按当前账号重新绑定(见上一条),面板照常呈现 `@`因此面板是 AGC 自己的薄壳,只把**纯模型**接进来。
- **不做的**:不改 Rust、不改 external v1 / OpenAPI、不改 `packages/`(共享包只被消费)、不复制主站 `src/components/image-editor/` 目录。
## 6. 前置规范图与不可用原因