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
- 进场本来就有动画(`…-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` 选择器写错,对应断言分别变红
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|