AGC 生成任务侧栏:收起也补上退场动画,退场期间内容不跳
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m52s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m58s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m59s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 3m2s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 1m40s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / Native shell tests (pull_request) Failing after 6m29s
Project CI / Repository checks (pull_request) Failing after 4m3s
Project CI / Backend tests (pull_request) Successful in 9m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m13s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m52s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m58s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m59s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 3m2s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 1m40s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / Native shell tests (pull_request) Failing after 6m29s
Project CI / Repository checks (pull_request) Failing after 4m3s
Project CI / Backend tests (pull_request) Successful in 9m6s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m13s
Project CI / Frontend tests (pull_request) Has been cancelled
- 进场本来就有动画(`…-enter`),收起却是瞬间卸载,观感上"闪一下没了"。现在收起先播退场再卸载:`open` 变 false 后进入 `leaving`,根节点挂 `is-leaving` 播 `…-leave`(与进场同向反向,160ms,`pointer-events: none`),播完才卸载 - 时长在组件与 CSS 两处各写一次,组件导出 `RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS` 并由用例钉住两处一致 - 减动效偏好下 CSS 已关掉动画,组件那边同步按 0ms 处理,收起立即卸载,不白等 160ms - 退场期间沿用收起前那一份列表(`lastRenderedRef`):宿主会在同一帧里收起侧栏并把在途任务收口,直接吃新 props 会让退场动画里的内容跳一下;滚动区的空态分支也改读这份冻结快照,不能读实时 `ordered`(这是本批调试时用假时钟用例抓到的真问题) - 用例:面板用例新增「收起时先播完收起动画再卸载,动画期间列表内容不跳」;样式用例新增「is-leaving 挂上收起动画、pointer-events: none、减动效同样关掉」;既有生成任务相关性用例改为等退场动画走完再断言(新增 `waitForTasksSidebarLeaveAnimationToFinish`) - 文档:AGC 入口矩阵与 decision-log 同步退场动画口径 验证:tsc --noEmit 通过;appSurface 全量 441 条通过;侧栏面板 9 条、样式 10 条、开合与入口 3 条通过;变异验证:把早返回改回「立即卸载」与把 CSS 的 `is-leaving` 选择器写错,对应断言分别变红
This commit is contained in:
+77
-16
@@ -1,7 +1,7 @@
|
||||
import './resourceCanvasAssetGenerationTasksSidebar.css';
|
||||
|
||||
import { ListChecks, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* 收起动画时长,必须与 `resourceCanvasAssetGenerationTasksSidebar.css` 里的
|
||||
* `game-resource-generation-tasks-leave` 一致:收起时侧栏要先播完这一段再卸载。
|
||||
*/
|
||||
export const RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS = 160;
|
||||
|
||||
export type ResourceCanvasAssetGenerationTasksPanelViewProps = {
|
||||
tasks: readonly ResourceCanvasAssetGenerationTask[];
|
||||
/** 侧栏是否展开;折叠时只留贴边把手。 */
|
||||
@@ -102,6 +108,13 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
onFocusTask,
|
||||
}: ResourceCanvasAssetGenerationTasksPanelViewProps) {
|
||||
const [nowMillis, setNowMillis] = useState(() => Date.now());
|
||||
/**
|
||||
* 收起动画期:`open` 已经变 false,但侧栏还要留在 DOM 里把 `…-leave` 播完
|
||||
* (`entering` 是刚打开时给根节点挂进场动画的那一档)。
|
||||
*/
|
||||
const [phase, setPhase] = useState<'idle' | 'entering' | 'leaving'>(
|
||||
open ? 'entering' : 'idle',
|
||||
);
|
||||
const inFlightCount = tasks.filter(
|
||||
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
|
||||
).length;
|
||||
@@ -120,6 +133,29 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
0,
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
|
||||
);
|
||||
/**
|
||||
* 收起动画期间必须沿用最后一份列表:宿主会在同一帧里收起侧栏并把「在途」收口成
|
||||
* 已完成,直接吃新 props 会让退出动画里的内容跳一下。
|
||||
*/
|
||||
const lastRenderedRef = useRef({
|
||||
inFlightCount,
|
||||
ordered,
|
||||
active,
|
||||
visibleDone,
|
||||
done,
|
||||
});
|
||||
if (open) {
|
||||
lastRenderedRef.current = {
|
||||
inFlightCount,
|
||||
ordered,
|
||||
active,
|
||||
visibleDone,
|
||||
done,
|
||||
};
|
||||
}
|
||||
const rendered = open
|
||||
? { inFlightCount, ordered, active, visibleDone, done }
|
||||
: lastRenderedRef.current;
|
||||
|
||||
// 已耗时是前端计时(后端只给时间戳):只在还有未终态任务时走秒表,全部收口后停掉。
|
||||
useEffect(() => {
|
||||
@@ -130,18 +166,41 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
return () => clearInterval(timer);
|
||||
}, [hasLiveTask]);
|
||||
|
||||
/**
|
||||
* open → 进场,close → 先留一帧播 `…-leave` 再卸载。
|
||||
*
|
||||
* 减少动效偏好下这一段动画在 CSS 里被关掉,所以那次收起要**立即**卸载,不能白等 160ms。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPhase('entering');
|
||||
return undefined;
|
||||
}
|
||||
setPhase((current) => (current === 'idle' ? 'idle' : 'leaving'));
|
||||
const reducedMotion =
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const timer = window.setTimeout(
|
||||
() => setPhase('idle'),
|
||||
reducedMotion ? 0 : RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
// 折叠态不再在画布左侧留贴边把手:开合口只剩工具条上那一枚「生成任务」按钮
|
||||
// (带在途计数),收起时侧栏完全让出画布。
|
||||
if (!open) {
|
||||
// (带在途计数),收起时侧栏完全让出画布(播完收起动画之后)。
|
||||
if (!open && phase === 'idle') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="platform-theme platform-theme--light game-resource-generation-tasks-sidebar"
|
||||
className={`platform-theme platform-theme--light game-resource-generation-tasks-sidebar${
|
||||
open ? '' : ' is-leaving'
|
||||
}`}
|
||||
role="region"
|
||||
aria-label="生成任务"
|
||||
data-resource-generation-task-count={inFlightCount}
|
||||
data-resource-generation-task-count={rendered.inFlightCount}
|
||||
>
|
||||
<header className="game-resource-generation-tasks-sidebar-header">
|
||||
<h2 className="game-resource-generation-tasks-sidebar-title">
|
||||
@@ -149,9 +208,9 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
生成任务
|
||||
<span
|
||||
className="game-resource-generation-tasks-sidebar-count"
|
||||
aria-label={`在途生成任务 ${inFlightCount}`}
|
||||
aria-label={`在途生成任务 ${rendered.inFlightCount}`}
|
||||
>
|
||||
{inFlightCount}
|
||||
{rendered.inFlightCount}
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
@@ -167,7 +226,7 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
className="game-resource-generation-tasks-scroll"
|
||||
data-resource-generation-task-scroll=""
|
||||
>
|
||||
{ordered.length === 0 ? (
|
||||
{rendered.ordered.length === 0 ? (
|
||||
<p className="game-resource-generation-tasks-empty" role="status">
|
||||
还没有生成任务
|
||||
</p>
|
||||
@@ -180,16 +239,18 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
<h3 className="game-resource-generation-tasks-section-title">
|
||||
<span>排队/生成中</span>
|
||||
<span className="game-resource-generation-tasks-section-title-count">
|
||||
{active.length}
|
||||
{rendered.active.length}
|
||||
</span>
|
||||
</h3>
|
||||
{active.length === 0 ? (
|
||||
{rendered.active.length === 0 ? (
|
||||
<p className="game-resource-generation-tasks-empty">
|
||||
没有进行中的任务
|
||||
</p>
|
||||
) : (
|
||||
<ul className="game-resource-generation-tasks-list">
|
||||
{active.map((task) => taskRow(task, nowMillis, onFocusTask))}
|
||||
{rendered.active.map((task) =>
|
||||
taskRow(task, nowMillis, onFocusTask),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
@@ -200,24 +261,24 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
|
||||
<h3 className="game-resource-generation-tasks-section-title">
|
||||
<span>已完成</span>
|
||||
<span className="game-resource-generation-tasks-section-title-count">
|
||||
{done.length}
|
||||
{rendered.done.length}
|
||||
</span>
|
||||
</h3>
|
||||
{done.length === 0 ? (
|
||||
{rendered.done.length === 0 ? (
|
||||
<p className="game-resource-generation-tasks-empty">
|
||||
还没有完成的任务
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="game-resource-generation-tasks-list">
|
||||
{visibleDone.map((task) =>
|
||||
{rendered.visibleDone.map((task) =>
|
||||
taskRow(task, nowMillis, onFocusTask),
|
||||
)}
|
||||
</ul>
|
||||
{done.length > visibleDone.length ? (
|
||||
{rendered.done.length > rendered.visibleDone.length ? (
|
||||
<p className="game-resource-generation-tasks-empty">
|
||||
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
|
||||
done.length - visibleDone.length
|
||||
rendered.done.length - rendered.visibleDone.length
|
||||
} 条较早记录`}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
+23
-2
@@ -40,6 +40,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 收起:与进场同向反向播一遍,播完由组件卸载(时长见组件里的 `…_LEAVE_MILLIS`,两处必须一致)。 */
|
||||
.game-resource-generation-tasks-sidebar.is-leaving {
|
||||
animation: game-resource-generation-tasks-leave 160ms ease-in forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes game-resource-generation-tasks-leave {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
.game-resource-generation-tasks-sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -336,9 +354,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 降低动效偏好:进场动画、悬停位移与呼吸全部关掉。 */
|
||||
/* 降低动效偏好:进场 / 收起动画、悬停位移与呼吸全部关掉。 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.game-resource-generation-tasks-sidebar {
|
||||
.game-resource-generation-tasks-sidebar,
|
||||
.game-resource-generation-tasks-sidebar.is-leaving {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -354,3 +373,5 @@
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -11114,6 +11114,20 @@ export function registerProjectAgentStatusTests() {
|
||||
return screen.findByRole('region', { name: '生成任务' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 收起「生成任务」侧栏后等它真的走完:收起要先播退场动画(`is-leaving`)再卸载,
|
||||
* 所以点完关闭按钮不能直接断言 DOM 里已经没有它。
|
||||
*/
|
||||
async function waitForTasksSidebarLeaveAnimationToFinish() {
|
||||
expect(
|
||||
document.querySelector('.game-resource-generation-tasks-sidebar')
|
||||
?.className,
|
||||
).toContain('is-leaving');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(),
|
||||
);
|
||||
}
|
||||
|
||||
function completedGenerationRecord(input: {
|
||||
taskId: string;
|
||||
assetId: string | null;
|
||||
@@ -11597,7 +11611,7 @@ export function registerProjectAgentStatusTests() {
|
||||
fireEvent.click(
|
||||
within(sidebar).getByRole('button', { name: '关闭生成任务' }),
|
||||
);
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull();
|
||||
await waitForTasksSidebarLeaveAnimationToFinish();
|
||||
// 画布上不留折叠把手:开合口只剩工具条那一枚「生成任务」按钮。
|
||||
expect(
|
||||
document.querySelector('.game-resource-generation-tasks-handle'),
|
||||
@@ -11635,7 +11649,7 @@ export function registerProjectAgentStatusTests() {
|
||||
fireEvent.click(
|
||||
within(reopened).getByRole('button', { name: '关闭生成任务' }),
|
||||
);
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull();
|
||||
await waitForTasksSidebarLeaveAnimationToFinish();
|
||||
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
|
||||
// 先钉住真的切到了运行页签,否则下面那条断言只是在资源态自证。
|
||||
expect(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { act } from 'react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel';
|
||||
import {
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT,
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS,
|
||||
ResourceCanvasAssetGenerationTasksPanelView,
|
||||
} from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView';
|
||||
import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel';
|
||||
@@ -189,6 +191,51 @@ describe('「生成任务」侧栏', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('收起时先播完收起动画再卸载,动画期间列表内容不跳', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const runningTask = task({ taskId: 't1', assetName: '主界面设计图' });
|
||||
const { rerender } = render(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[runningTask]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole('region', { name: '生成任务' })).not.toBeNull();
|
||||
|
||||
// 收起的同时把任务收口成已完成:退出动画必须沿用收起前那一份列表,内容不能跳。
|
||||
rerender(
|
||||
<ResourceCanvasAssetGenerationTasksPanelView
|
||||
tasks={[]}
|
||||
open={false}
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const leaving = document.querySelector<HTMLElement>(
|
||||
'.game-resource-generation-tasks-sidebar',
|
||||
);
|
||||
expect(leaving).not.toBeNull();
|
||||
expect(leaving?.className).toContain('is-leaving');
|
||||
expect(within(leaving!).getByText('主界面设计图')).not.toBeNull();
|
||||
|
||||
// 动画时长走完就卸载,画布上不留东西。
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(
|
||||
RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
document.querySelector('.game-resource-generation-tasks-sidebar'),
|
||||
).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('只有已完成且拿到资源 id 的任务能定位到素材卡', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onFocusTask } = renderSidebar([
|
||||
|
||||
+32
@@ -143,6 +143,38 @@ describe('「生成任务」侧栏样式', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('收起与进场同源反向:is-leaving 挂上收起动画,减动效下同样关掉', () => {
|
||||
const leaving = resolved([
|
||||
'.game-resource-generation-tasks-sidebar',
|
||||
'.game-resource-generation-tasks-sidebar.is-leaving',
|
||||
]);
|
||||
const animation = declaration(leaving, 'animation');
|
||||
expect(animation).toContain('game-resource-generation-tasks-leave');
|
||||
expect(animation).toContain('160ms');
|
||||
// 动画期间不该还能点到里面(此刻它在播退场,点它只会在卸载前留下半截状态)。
|
||||
expect(declaration(leaving, 'pointer-events')).toBe('none');
|
||||
expect(
|
||||
rules.some((rule) =>
|
||||
rule.selectors.includes(
|
||||
'.game-resource-generation-tasks-sidebar.is-leaving',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// 减少动效:收起动画同样关掉,组件那边会立即卸载,不白等 160ms。
|
||||
const reduced = rules.filter((rule) =>
|
||||
(rule.media ?? '').includes('prefers-reduced-motion'),
|
||||
);
|
||||
expect(
|
||||
reduced.some(
|
||||
(rule) =>
|
||||
rule.selectors.includes(
|
||||
'.game-resource-generation-tasks-sidebar.is-leaving',
|
||||
) && rule.declarations.get('animation') === 'none',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('列表滚动条是细样式,滚动区域有界', () => {
|
||||
const scroll = resolved(['.game-resource-generation-tasks-scroll']);
|
||||
expect(declaration(scroll, 'overflow-y')).toBe('auto');
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- 决策(提交面板):**点「生成」即同步关闭面板**——不等 IPC、不等排队、不等生成;面板内**不出现**任何阶段文案(主按钮文案恒为动作名)。**只有「点击瞬间就失败」**(后端校验 / 权限拒绝 / start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在任务侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消(请求挂在任务与项目内账本上,不挂在面板生命周期上)。
|
||||
- 决策(进度面形态):改为**常驻画布的可折叠任务侧栏**(对齐网页端 `ImageCanvasTaskSidebarView`)——展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 提示「仅显示最近 N 条」),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其 border-top 分割线已删除);折叠即整块让出画布、**不留贴边把手**;每项显示状态徽标 / 后端阶段文案 / 已耗时(前端 1s 计时)/ 素材名 + 「定位到素材」;侧栏非模态(不铺遮罩、不做焦点陷阱、不进模态遮挡判据),工具栏入口按钮是**唯一**开合口、常驻(资源管理 / 运行两个页签都在)并显示 `生成任务 · N`,提交受理后自动展开。**原先的非模态浮层形态已删除,不留平行入口。**
|
||||
- 决策(侧栏失去焦点即收起):展开时挂 document 级 `pointerdown` 捕获监听,点在侧栏内部与工具条那枚开合按钮以外的地方即收起。两处必须排除——**侧栏内部**(点任务卡、点「定位到素材」不能收起侧栏)与**开合按钮本身**(它自己负责 toggle,若也被判成「点外部」就会先收起再被 toggle 打开,表现为按钮失灵;按钮带 `data-resource-generation-task-toggle` 标记供排除)。用 `pointerdown` 而不是 `click`:画布空白处的左键 pointerdown 会 `preventDefault()`,document 上的 click 收不到那一次点击。
|
||||
- 决策(收起动画):收起不能瞬间卸载——进场有动画而消失没有,观感上是"闪一下没了"。做法是**组件自己留一帧播退场**:`open` 变 false 后进入 `leaving`,根节点挂 `is-leaving` 播 `…-leave`(与进场同向反向,160ms,`pointer-events: none`),播完(或减动效偏好的 0ms 定时)才 `setPhase('idle')` 卸载。时长在组件与 CSS 两处各写一次,**必须一致**(组件导出 `RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS` 并在用例里钉住)。退场期间**沿用收起前那一份列表**(`lastRenderedRef`):宿主会在同一帧里收起侧栏并把在途任务收口成已完成,直接吃新 props 会让退场动画里的内容跳一下;空态分支也必须读这份冻结快照,不能读实时 `ordered`。
|
||||
- 决策(定位必须让素材真的可见):`handleResourceSelect` 只改选中、不会移动画布,而这张画布是 **transform 平移**的、资源卡不在任何滚动容器里——`card.scrollIntoView()` 碰不到滚动祖先,卡在视口外时"定位过去了但依然见不到素材"。所以聚焦链在选中之后必须**显式把画布视口居中到该卡**(`centerResourceCanvasOnResource`:读该卡在当前位置表里的 `x/y` 与 `resourceCardSizeByResourceId` 的尺寸,按 `canvasSize / 2 − 卡中心 × scale` 求平移量,**缩放保持不变**,与 `ensureResourceBookContentVisible` 的既有口径一致:定位不改用户的缩放预期)。`scrollIntoView` 一并保留(栏目页仍有带滚动条的祖先)。
|
||||
- 决策(侧栏位置):挂在画布**左侧、标题栏之下、工具栏之上**,宽 300px、**覆盖式**(不 reflow 挤窄画布视口)。理由:右侧已被「智能创作」对话面板占用、顶部是栏目标题栏、底部是栏目工具栏;覆盖式不触碰画布视口数学与资源卡排布,收起即完全让出画布。若产品要求「画布被挤窄」的 flex 兄弟列形态(网页端是那种),需要改 `game-resource-book-manager` 那段布局并单独排期。
|
||||
- 决策(定位终局化):根因是聚焦 effect 的依赖全是画布自身状态,**手动点定位不改其中任何一项** → effect 不重跑、`pendingResourceFocusRef` 无人消费、提示条永久停在中转文案。修法:新增聚焦请求序号并加入 effect 依赖;handler 重写为「能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给『素材已不在项目里 / 已登记但尚未同步』的结论;挂 intent 后推进序号 + **3 秒有界兜底**;intent 被判 invalid 时也给『定位请求已失效』」,并修掉「已聚焦过」提前返回分支不清提示的同类问题(自动落卡那条链同源)。
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
- **本地排队**:AGC 本地 durable 输出槽按**精确动作指纹**分槽(`run_id = slot-<sha256(动作材料)>`,材料为 prompt / outputPath / 比例 / 尺寸 / assetKind / assetLabel / replaceExisting / requireSlices;不同 prompt 或素材名各自独立成槽,不再按 `outputPath` 共用;旧 `{outputPath,requireSlices}` 槽账本由同一精确动作懒迁移)。因此后端已具备并行能力,但**本批前端仍按「同一时刻只派发一条」排队**——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批。所以第二条提交停在**前端本地队列**里(不调用提交 IPC,阶段显示本地排队的「排队中。」),第一条终态后由同一条循环自动补发;判据是「存在 `dispatched && 未终态` 的任务时不派发下一条」。
|
||||
- **阶段文案只来自后端,且只出现在任务侧栏**:派发之后由任务侧栏渲染后端账本给的 `phaseDetail`(「排队中。」/「正在生成。」/「生成已完成。」/失败原因),提交面板已关闭、不渲染任何阶段文案;前端不拼阶段、不做百分比。
|
||||
- **「生成任务」是常驻画布的可折叠侧栏**(形态对齐网页端美术画布的任务侧栏):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除);折叠即整块让出画布、**不留贴边把手**;**失去焦点(点侧栏外部,不含那枚开合按钮本身)即自动收起**;位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口)。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、**不参与** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的遮挡判据),提交受理后自动展开;工具栏入口按钮是**唯一**开合口、**两个页签(资源管理 / 运行)下都常驻**、排在「依赖 / 类型」排列方式**之前**(动作在前、排列方式收行尾),并显示 `生成任务 · N`。每项的「定位到素材」**每次点击都终局化**(含跨栏目先切栏目、不在投影里给结论、3 秒有界兜底),不允许提示条永久停在「正在定位生成的素材…」;定位成功后必须**把画布视口居中到该卡**——这张画布是 transform 平移的,`scrollIntoView()` 碰不到滚动祖先,只改选中会让用户"定位过去了但依然见不到素材"。
|
||||
- **「生成任务」是常驻画布的可折叠侧栏**(形态对齐网页端美术画布的任务侧栏):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除);折叠即整块让出画布、**不留贴边把手**;**收起与进场同源反向播一遍动画**(`…-leave`,160ms,播完才卸载;减动效下立即卸载);**失去焦点(点侧栏外部,不含那枚开合按钮本身)即自动收起**;位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口)。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、**不参与** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的遮挡判据),提交受理后自动展开;工具栏入口按钮是**唯一**开合口、**两个页签(资源管理 / 运行)下都常驻**、排在「依赖 / 类型」排列方式**之前**(动作在前、排列方式收行尾),并显示 `生成任务 · N`。每项的「定位到素材」**每次点击都终局化**(含跨栏目先切栏目、不在投影里给结论、3 秒有界兜底),不允许提示条永久停在「正在定位生成的素材…」;定位成功后必须**把画布视口居中到该卡**——这张画布是 transform 平移的,`scrollIntoView()` 碰不到滚动祖先,只改选中会让用户"定位过去了但依然见不到素材"。
|
||||
|
||||
## 5. 参数口径与复用程度
|
||||
|
||||
|
||||
Reference in New Issue
Block a user