From beebcca4dec56d94ca719a0b6be9628a1285bd58 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 21 Sep 2026 11:58:13 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E5=9B=9B=E9=A1=B9=E9=AA=8C=E6=94=B6=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=9A=E9=94=9A=E7=82=B9=E8=AE=A9=E4=BD=8D=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E6=A0=8F=E3=80=81=E6=B5=AE=E5=B1=82=E4=B8=8D=E5=86=8D=E6=92=9E?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E8=BE=B9=E7=BC=98=E3=80=81=E6=8B=96=E5=8A=A8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=A7=A6=E5=8F=91=E7=82=B9=E5=87=BB=E3=80=81?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=E8=A7=84=E8=8C=83=E5=8F=82=E8=80=83=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 生成任务锚点新增「资源总览」档:资源总览页仍贴画布顶边内缩 0.85rem,资源栏目画布页让开顶部钉住的整宽栏目标题栏(42px + 0.5rem),两档切换走 margin-top 过渡(减动效下关掉)。 - 生成浮层的顶边与可用高度改为按占位卡的真实屏幕位置算(卡下沿到安全底边),不再假设卡片贴在安全带上沿——占位落到下半屏时浮层底边不再越过画布下沿被切掉。 - 生成浮层新增水平收口:按「浮层左右各留 12px」把锚点收进画布可视范围,卡片靠边时浮层不再有一半落到画布之外;宽度口径与面板 CSS 同源(min(560px, 100% - 24px))。 - 新增画布卡片手势的共享判据 resourceCanvasGestureExceededDragThreshold(阈值仍取 RESOURCE_CANVAS_DRAG_THRESHOLD):资源卡与生成占位卡共用同一份「拖动还是点击」判断。 - 生成占位卡修掉拖动收尾误触点击:越过阈值才算拖动、登记一次性点击抑制(consumeDragClick)由宿主消费,「拖动中」样式也从越过阈值那一刻起才亮。 - 生成占位卡的删除按钮起手手势不再被当成「点卡片」:按起手点判定,松手落回卡片时不触发开合生成浮层。 - 图标规范面板的参考选择注释按现行判据改写(只有图集 icon-spritesheet 不接受用户参考),入口矩阵文档同步补「参考图(@ 引用)」口径并修掉「本地 IPC 没有参考图入参」的旧说法。 - 补齐回归用例:手势模型判据、占位卡拖动/点击与阈值、宿主级「拖完不弹浮层且普通点击仍开合」、浮层顶边与水平收口、锚点分档与跨文件标题栏高度守卫、逐条生成入口的 @ 与判据一致、图标规范面板的 @ 与提交带引用。 - 共享记忆 pitfalls.md 记录两条排障口径:卡类手势必须只有一份阈值与点击抑制判据;画布浮层几何只能按真实屏幕位置算、分页锚点必须让开钉死的标题栏。 --- ...ResourceCanvasAssetGenerationPanelView.tsx | 10 +- ...rceCanvasAssetGenerationTasksPanelView.tsx | 14 +- ...rceCanvasGenerationPlaceholderCardView.tsx | 26 +++- ...ourceCanvasAssetGenerationTasksSidebar.css | 33 +++++ .../resourceCanvasCardGestureModel.ts | 46 ++++++ .../resourceCanvasGenerationPanel.css | 10 +- ...resourceCanvasGenerationVisibilityModel.ts | 118 +++++++++++++-- ...useResourceCanvasGenerationPlaceholders.ts | 75 +++++++++- .../src/view/project-development/index.tsx | 99 +++++++++---- ...ceCanvasAssetGenerationReferences.test.tsx | 121 ++++++++++++++- ...ceCanvasAssetGenerationTasksPanel.test.tsx | 14 +- ...asAssetGenerationTasksSidebarStyle.test.ts | 82 +++++++++++ .../resourceCanvasCardGestureModel.test.ts | 49 ++++++ ...nvasGenerationFloatingPanelChrome.test.tsx | 16 ++ ...urceCanvasGenerationHostLifecycle.test.tsx | 53 +++++++ ...sourceCanvasGenerationPlaceholder.test.tsx | 133 ++++++++++++++++- ...nvasGenerationTasksSidebarDismiss.test.tsx | 8 +- ...resourceCanvasGenerationVisibility.test.ts | 139 ++++++++++++++++-- docs/project-memory/shared-memory/pitfalls.md | 22 +++ ...C】栏目画布底部工具栏入口矩阵-2026-09-13.md | 3 +- 20 files changed, 1001 insertions(+), 70 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasCardGestureModel.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasCardGestureModel.test.ts diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 68886de45..f5b099e27 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -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); diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx index 539131016..216f8800d 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx @@ -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'; }; /** diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx index 2c0305d3b..503a6489a 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx @@ -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 (
{ + 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({
{task.error ? (

@@ -166,14 +211,7 @@ function assetGenerationTaskRow( * 给的待办记录里没有产物 id,编一个指向不明的跳转不如不给;② 提示词单独一行,用户要能认出手上 * 这行是哪一次修改。 */ -function resourceEditTaskRow( - task: ResourceCanvasResourceEditTask, - nowMillis: number, -) { - const elapsedMillis = resourceCanvasResourceEditTaskElapsedMillis( - task, - nowMillis, - ); +function resourceEditTaskRow(task: ResourceCanvasResourceEditTask) { return (

  • {task.phaseDetail} - {elapsedMillis === null ? null : ( - - {`已耗时 ${resourceCanvasResourceEditElapsedLabel(elapsedMillis)}`} - - )} + {task.error ? (

    @@ -224,12 +258,11 @@ function resourceEditTaskRow( function resourceCanvasGenerationTaskRowNode( row: ResourceCanvasGenerationTaskRow, - nowMillis: number, onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void, ) { return row.source === 'asset-generation' - ? assetGenerationTaskRow(row.task, nowMillis, onFocusTask) - : resourceEditTaskRow(row.task, nowMillis); + ? assetGenerationTaskRow(row.task, onFocusTask) + : resourceEditTaskRow(row.task); } /** @@ -257,7 +290,6 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ onFocusTask, placement = 'canvas', }: ResourceCanvasAssetGenerationTasksPanelViewProps) { - const [nowMillis, setNowMillis] = useState(() => Date.now()); /** * 收起动画期:`open` 已经变 false,但侧栏还要留在 DOM 里把 `…-leave` 播完 * (`entering` 是刚打开时给根节点挂进场动画的那一档)。 @@ -291,7 +323,6 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ ); const done = ordered.filter(resourceCanvasGenerationTaskRowIsTerminal); const inFlightCount = active.length; - const hasLiveTask = inFlightCount > 0; const visibleDone = done.slice( 0, RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT, @@ -320,14 +351,12 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ ? { inFlightCount, ordered, active, visibleDone, done } : lastRenderedRef.current; - // 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。 - useEffect(() => { - if (!hasLiveTask) { - return undefined; - } - const timer = setInterval(() => setNowMillis(Date.now()), 1_000); - return () => clearInterval(timer); - }, [hasLiveTask]); + /* + * 已耗时是前端计时(后端只给时间戳):秒表**不在这一层**——面板上通常同时挂着多条任务, + * 在这里订阅 100ms 的 tick 等于每 0.1 秒重建整个侧栏。计时下沉到每一行的 + * `AssetGenerationElapsedLabel` / `ResourceEditElapsedLabel`,只在**还有未终态任务**时 + * 订阅(终态行定格在账本的结束时间上)。 + */ /** * open → 进场,close → 先留一帧播 `…-leave` 再卸载。 @@ -436,11 +465,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ ) : (

      {rendered.active.map((row) => - resourceCanvasGenerationTaskRowNode( - row, - nowMillis, - onFocusTask, - ), + resourceCanvasGenerationTaskRowNode(row, onFocusTask), )}
    )} @@ -463,11 +488,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({ <>
      {rendered.visibleDone.map((row) => - resourceCanvasGenerationTaskRowNode( - row, - nowMillis, - onFocusTask, - ), + resourceCanvasGenerationTaskRowNode(row, onFocusTask), )}
    {rendered.done.length > rendered.visibleDone.length ? ( diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index 3e911ea8f..5d01d2ac8 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -133,6 +133,100 @@ describe('「生成任务」侧栏', () => { ).toBeGreaterThan(0); }); + /** + * 读秒粒度:与对话侧同一份 `useLiveNow`(`LIVE_TIMER_TICK_MS = 100`)。 + * + * 不足一分钟的耗时显示到 0.1 秒,刷新就必须是 100ms——按 1 秒一跳时,用户看到的是一块 + * 「带小数却一格一格跳」的表,像卡住不动(验收现场「生成任务里的读秒更新频率应为 0.1s」)。 + */ + test('读秒按 100ms 刷新:不足一分钟的耗时以 0.1 秒递增', () => { + vi.useFakeTimers(); + try { + // 假时钟与任务的创建时间对齐(夹具的 `nowMillis` 是 1000)。 + vi.setSystemTime(1_000); + renderSidebar([ + task({ + taskId: 't1', + dispatched: true, + status: 'running', + phaseDetail: '正在生成。', + }), + ]); + const elapsed = () => screen.getByText(/^已耗时 /u).textContent; + expect(elapsed()).toBe('已耗时 0.0秒'); + + act(() => { + vi.advanceTimersByTime(100); + }); + expect(elapsed()).toBe('已耗时 0.1秒'); + + // 1 秒一跳的实现会停在上面的 0.1 秒:再走 400ms 必须继续往上加。 + act(() => { + vi.advanceTimersByTime(400); + }); + expect(elapsed()).toBe('已耗时 0.5秒'); + } finally { + vi.useRealTimers(); + } + }); + + test('任务收口后不再走表:已耗时定格在账本给的结束时间上', () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(1_000); + renderSidebar([ + task({ + taskId: 't1', + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'asset-1', + finishedAtMillis: 2_500, + }), + ]); + const elapsed = () => screen.getByText(/^已耗时 /u).textContent; + expect(elapsed()).toBe('已耗时 1.5秒'); + + act(() => { + vi.advanceTimersByTime(5_000); + }); + expect(elapsed()).toBe('已耗时 1.5秒'); + } finally { + vi.useRealTimers(); + } + }); + + /** + * 秒表只订在**运行中的那一行**上:终态行不订阅,面板本身也不订阅。 + * + * 这条是性能约束的可执行版本——100ms 的 tick 若落在整块面板或终态行上,每 0.1 秒都会重建 + * 整个侧栏(`useLiveNow` 的注释写明「调用方必须是叶子节点」)。 + */ + test('秒表只订在运行中的那一行上,终态行与面板本身都不订阅时钟', () => { + const spy = vi.spyOn(globalThis, 'setInterval'); + try { + renderSidebar([ + task({ + taskId: 'live', + dispatched: true, + status: 'running', + phaseDetail: '正在生成。', + }), + task({ + taskId: 'done', + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'asset-done', + finishedAtMillis: 2_000, + }), + ]); + expect(spy).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + }); + test('收起只有一个入口:头部那枚关闭按钮,收起即整块让出画布', async () => { const user = userEvent.setup(); const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })]); -- 2.52.0