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

- 进场本来就有动画(`…-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:
2026-09-14 23:01:35 +08:00
parent 96971403ee
commit d0b15dbc54
7 changed files with 197 additions and 21 deletions
@@ -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}
@@ -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([
@@ -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. 参数口径与复用程度