Files
Genarrative/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx
T
suzmii d0b15dbc54
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
AGC 生成任务侧栏:收起也补上退场动画,退场期间内容不跳
- 进场本来就有动画(`…-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` 选择器写错,对应断言分别变红
2026-09-14 23:01:35 +08:00

294 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import './resourceCanvasAssetGenerationTasksSidebar.css';
import { ListChecks, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS,
resourceCanvasAssetGenerationElapsedLabel,
type ResourceCanvasAssetGenerationTask,
resourceCanvasAssetGenerationTaskElapsedMillis,
resourceCanvasAssetGenerationTaskIsTerminal,
resourceCanvasAssetGenerationTaskTone,
sortResourceCanvasAssetGenerationTasks,
} from './resourceCanvasAssetGenerationTaskModel';
/** 「已完成」分栏的展示上限:触顶后只提示还有多少条,不无限拉长侧栏。 */
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[];
/** 侧栏是否展开;折叠时只留贴边把手。 */
open: boolean;
onToggleOpen: () => void;
/** 定位到该任务产出的素材卡(宿主复用既有 `pendingResourceFocusRef` 聚焦链)。 */
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void;
};
function taskRow(
task: ResourceCanvasAssetGenerationTask,
nowMillis: number,
onFocusTask: (task: ResourceCanvasAssetGenerationTask) => void,
) {
const focusable = task.status === 'completed' && Boolean(task.assetId);
return (
<li
key={task.taskId}
className="game-resource-generation-task-card"
data-task-status={task.status}
>
<div className="game-resource-generation-task-card-title-row">
<strong className="game-resource-generation-task-card-name">
{task.assetName}
</strong>
<span className="game-resource-generation-task-card-action">
{task.actionLabel}
</span>
</div>
<div className="game-resource-generation-task-card-meta">
<span
className="game-resource-generation-task-badge"
data-tone={resourceCanvasAssetGenerationTaskTone(task.status)}
>
{RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]}
</span>
<span className="game-resource-generation-task-card-phase">
{task.phaseDetail}
</span>
<span className="game-resource-generation-task-card-elapsed">
{`已耗时 ${resourceCanvasAssetGenerationElapsedLabel(
resourceCanvasAssetGenerationTaskElapsedMillis(task, nowMillis),
)}`}
</span>
</div>
{task.error ? (
<p className="game-resource-generation-task-card-error" role="alert">
{task.error}
</p>
) : null}
{focusable ? (
<button
type="button"
className="game-resource-generation-task-locate"
aria-label={`定位素材 ${task.assetName}`}
onClick={() => onFocusTask(task)}
>
定位到素材
</button>
) : null}
</li>
);
}
/**
* 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。
*
* 形态对齐网页端美术画布的任务侧栏:贴边的独立 `<aside>`,展开时是一列任务(画布照常可交互),
* 折叠时只留一个可点的贴边把手(带在途数量角标)。它**不是**模态浮层:不铺全屏遮罩、不做焦点
* 陷阱、不进 `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的
* 遮挡判据;折叠也不影响任务推进——任务活在账本与本地队列里,与这个视图的生命周期无关。
*
* 位置取舍:AGC 栏目画布的右侧是「智能创作」对话面板、底部是栏目工具栏、顶部是栏目标题栏,所以
* 侧栏挂在**左侧、标题栏之下、工具栏之上**,并且是**覆盖式**而不是把画布挤窄(画布的视口数学与
* 资源卡排布都不被 reflow 动到,收起即完全让出画布)。宽度 300px 落在 280–340px 区间内。
*
* 分栏、条数、封顶、折叠语义都在这里;颜色与排版在同目录的 CSS 文件里,取值全部走
* `--platform-*` 设计 token。
*/
export function ResourceCanvasAssetGenerationTasksPanelView({
tasks,
open,
onToggleOpen,
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;
const hasLiveTask = inFlightCount > 0;
const ordered = useMemo(
() => sortResourceCanvasAssetGenerationTasks(tasks),
[tasks],
);
const active = ordered.filter(
(task) => !resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const done = ordered.filter((task) =>
resourceCanvasAssetGenerationTaskIsTerminal(task),
);
const visibleDone = done.slice(
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(() => {
if (!hasLiveTask) {
return undefined;
}
const timer = setInterval(() => setNowMillis(Date.now()), 1_000);
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 && phase === 'idle') {
return null;
}
return (
<aside
className={`platform-theme platform-theme--light game-resource-generation-tasks-sidebar${
open ? '' : ' is-leaving'
}`}
role="region"
aria-label="生成任务"
data-resource-generation-task-count={rendered.inFlightCount}
>
<header className="game-resource-generation-tasks-sidebar-header">
<h2 className="game-resource-generation-tasks-sidebar-title">
<ListChecks size={15} aria-hidden="true" />
生成任务
<span
className="game-resource-generation-tasks-sidebar-count"
aria-label={`在途生成任务 ${rendered.inFlightCount}`}
>
{rendered.inFlightCount}
</span>
</h2>
<button
type="button"
className="game-resource-generation-tasks-sidebar-icon-button"
aria-label="关闭生成任务"
onClick={onToggleOpen}
>
<X size={16} aria-hidden="true" />
</button>
</header>
<div
className="game-resource-generation-tasks-scroll"
data-resource-generation-task-scroll=""
>
{rendered.ordered.length === 0 ? (
<p className="game-resource-generation-tasks-empty" role="status">
还没有生成任务
</p>
) : (
<>
<section
className="game-resource-generation-tasks-section"
aria-label="排队与生成中"
>
<h3 className="game-resource-generation-tasks-section-title">
<span>排队/生成中</span>
<span className="game-resource-generation-tasks-section-title-count">
{rendered.active.length}
</span>
</h3>
{rendered.active.length === 0 ? (
<p className="game-resource-generation-tasks-empty">
没有进行中的任务
</p>
) : (
<ul className="game-resource-generation-tasks-list">
{rendered.active.map((task) =>
taskRow(task, nowMillis, onFocusTask),
)}
</ul>
)}
</section>
<section
className="game-resource-generation-tasks-section"
aria-label="已完成"
>
<h3 className="game-resource-generation-tasks-section-title">
<span>已完成</span>
<span className="game-resource-generation-tasks-section-title-count">
{rendered.done.length}
</span>
</h3>
{rendered.done.length === 0 ? (
<p className="game-resource-generation-tasks-empty">
还没有完成的任务
</p>
) : (
<>
<ul className="game-resource-generation-tasks-list">
{rendered.visibleDone.map((task) =>
taskRow(task, nowMillis, onFocusTask),
)}
</ul>
{rendered.done.length > rendered.visibleDone.length ? (
<p className="game-resource-generation-tasks-empty">
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
rendered.done.length - rendered.visibleDone.length
} 条较早记录`}
</p>
) : null}
</>
)}
</section>
</>
)}
</div>
</aside>
);
}