Merge branch 'feat/agc-generation-task-progress' into fix/agc-canvas-acceptance-batch

This commit is contained in:
2026-09-14 19:16:16 +08:00
5 changed files with 786 additions and 51 deletions
@@ -1,3 +1,5 @@
import './resourceCanvasAssetGenerationTasksSidebar.css';
import { ChevronLeft, ChevronRight, ListChecks, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
@@ -7,6 +9,7 @@ import {
type ResourceCanvasAssetGenerationTask,
resourceCanvasAssetGenerationTaskElapsedMillis,
resourceCanvasAssetGenerationTaskIsTerminal,
resourceCanvasAssetGenerationTaskTone,
sortResourceCanvasAssetGenerationTasks,
} from './resourceCanvasAssetGenerationTaskModel';
@@ -31,33 +34,42 @@ function taskRow(
return (
<li
key={task.taskId}
className="rounded-xl px-2 py-2"
style={{ background: 'var(--platform-panel-fill)' }}
className="game-resource-generation-task-card"
data-task-status={task.status}
>
<div className="flex items-start justify-between gap-2">
<strong className="text-xs font-bold">{task.assetName}</strong>
<span className="text-[11px] opacity-70">{task.actionLabel}</span>
<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="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px]">
<span className="rounded-full border border-black/10 px-2 py-0.5">
<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>{task.phaseDetail}</span>
<span className="opacity-70">
<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="mt-1 text-[11px]" role="alert">
<p className="game-resource-generation-task-card-error" role="alert">
{task.error}
</p>
) : null}
{focusable ? (
<button
type="button"
className="mt-1 text-[11px] underline"
className="game-resource-generation-task-locate"
aria-label={`定位素材 ${task.assetName}`}
onClick={() => onFocusTask(task)}
>
@@ -80,8 +92,8 @@ function taskRow(
* 侧栏挂在**左侧、标题栏之下、工具栏之上**,并且是**覆盖式**而不是把画布挤窄(画布的视口数学与
* 资源卡排布都不被 reflow 动到,收起即完全让出画布)。宽度 300px 落在 280–340px 区间内。
*
* 两个分栏(「排队/生成中」与「已完成」)只做客户端分组,数据源仍是同一份任务列表;「已完成」
* 条数封顶且列表自身滚动,侧栏高度有界
* 分栏、条数、封顶、折叠语义都在这里;颜色与排版在同目录的 CSS 文件里,取值全部走
* `--platform-*` 设计 token
*/
export function ResourceCanvasAssetGenerationTasksPanelView({
tasks,
@@ -122,23 +134,19 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
return (
<button
type="button"
className="platform-theme platform-theme--light fixed top-1/2 left-0 z-40 flex -translate-y-1/2 flex-col items-center gap-1 rounded-r-xl border border-l-0 border-black/10 px-1.5 py-3 shadow-lg"
style={{
background: 'var(--platform-subpanel-fill)',
color: 'var(--platform-text-strong)',
}}
className="platform-theme platform-theme--light game-resource-generation-tasks-handle"
aria-label="展开生成任务"
aria-expanded={false}
data-resource-generation-task-count={inFlightCount}
onClick={onToggleOpen}
>
<ListChecks size={15} aria-hidden="true" />
<span className="text-[11px] font-bold [writing-mode:vertical-rl]">
<span className="game-resource-generation-tasks-handle-label">
</span>
{inFlightCount > 0 ? (
<span
className="rounded-full border border-black/10 px-1 text-[11px] font-black"
className="game-resource-generation-tasks-handle-badge"
aria-label={`在途生成任务 ${inFlightCount}`}
>
{inFlightCount}
@@ -151,23 +159,25 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
return (
<aside
className="platform-theme platform-theme--light fixed top-16 bottom-24 left-3 z-40 flex w-[300px] max-w-[80vw] flex-col rounded-2xl border border-black/10 shadow-lg"
style={{
background: 'var(--platform-subpanel-fill)',
color: 'var(--platform-text-strong)',
}}
className="platform-theme platform-theme--light game-resource-generation-tasks-sidebar"
role="region"
aria-label="生成任务"
data-resource-generation-task-count={inFlightCount}
>
<header className="flex items-center justify-between gap-2 border-b border-black/10 px-3 py-2">
<h2 className="flex items-center gap-1 text-sm font-black">
<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={`在途生成任务 ${inFlightCount}`}
>
{inFlightCount}
</span>
</h2>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded-full"
className="game-resource-generation-tasks-sidebar-icon-button"
aria-label="收起生成任务"
onClick={onToggleOpen}
>
@@ -175,44 +185,58 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
</button>
</header>
<div
className="min-h-0 flex-1 overflow-y-auto px-3 py-2"
className="game-resource-generation-tasks-scroll"
data-resource-generation-task-scroll=""
>
{ordered.length === 0 ? (
<p className="text-xs" role="status">
<p className="game-resource-generation-tasks-empty" role="status">
</p>
) : (
<>
<section aria-label="排队与生成中">
<h3 className="flex items-center justify-between text-xs font-bold">
<section
className="game-resource-generation-tasks-section"
aria-label="排队与生成中"
>
<h3 className="game-resource-generation-tasks-section-title">
<span>/</span>
<span>{active.length}</span>
<span className="game-resource-generation-tasks-section-title-count">
{active.length}
</span>
</h3>
{active.length === 0 ? (
<p className="mt-1 text-[11px] opacity-70"></p>
<p className="game-resource-generation-tasks-empty">
</p>
) : (
<ul className="mt-1 flex flex-col gap-2">
<ul className="game-resource-generation-tasks-list">
{active.map((task) => taskRow(task, nowMillis, onFocusTask))}
</ul>
)}
</section>
<section aria-label="已完成" className="mt-3">
<h3 className="flex items-center justify-between text-xs font-bold">
<section
className="game-resource-generation-tasks-section"
aria-label="已完成"
>
<h3 className="game-resource-generation-tasks-section-title">
<span></span>
<span>{done.length}</span>
<span className="game-resource-generation-tasks-section-title-count">
{done.length}
</span>
</h3>
{done.length === 0 ? (
<p className="mt-1 text-[11px] opacity-70"></p>
<p className="game-resource-generation-tasks-empty">
</p>
) : (
<>
<ul className="mt-1 flex flex-col gap-2">
<ul className="game-resource-generation-tasks-list">
{visibleDone.map((task) =>
taskRow(task, nowMillis, onFocusTask),
)}
</ul>
{done.length > visibleDone.length ? (
<p className="mt-1 text-[11px] opacity-70">
<p className="game-resource-generation-tasks-empty">
{`仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 ${
done.length - visibleDone.length
} 条较早记录`}
@@ -224,10 +248,10 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
</>
)}
</div>
<footer className="flex items-center justify-end border-t border-black/10 px-3 py-2">
<footer className="game-resource-generation-tasks-sidebar-footer">
<button
type="button"
className="grid h-7 w-7 place-items-center rounded-full"
className="game-resource-generation-tasks-sidebar-icon-button"
aria-label="关闭生成任务"
onClick={onToggleOpen}
>
@@ -66,6 +66,25 @@ export const RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS: Record<
failed: '失败',
};
/**
* 状态 → 视觉 tone(中性 / 品牌 / 成功 / 危险)。
*
* 放在模型层而不是组件层有两个原因:① 它是纯映射,组件不该自己发明颜色语义;
* ② 组件文件只导出组件与常量(`react-refresh/only-export-components`),导出函数会破坏热更新边界。
* 具体样式在 `resourceCanvasAssetGenerationTasksSidebar.css` 里按 `[data-tone=…]` 落地,映射由测试钉住。
*/
export type ResourceCanvasAssetGenerationTaskTone =
| 'queued'
| 'running'
| 'completed'
| 'failed';
export function resourceCanvasAssetGenerationTaskTone(
status: ResourceCanvasAssetGenerationTaskStatus,
): ResourceCanvasAssetGenerationTaskTone {
return status;
}
/**
* 本地排队的阶段文案。
*
@@ -0,0 +1,435 @@
/* 「生成任务」侧栏的样式。
*
* 分层与网页端美术画布的任务侧栏(`ImageCanvasTaskSidebarView.tsx` + `src/index.css:6084+`)一一对应:
* 容器 → 头部(标题 + 在途计数)→ 分栏标题(各带条数)→ 条目卡片(状态徽标 / 阶段 / 耗时 / 定位)。
* 差别只有一处:**颜色全部换成 AGC 的平台设计 token`--platform-*`**,不再照抄网页端的固定色值,
* 这样亮/暗主题都跟着 `platform-theme` 走,也不引入第二套色板。
*
* 行为(分栏、条数、已完成封顶、折叠不清列表、提交后自动展开)都不在这里,样式只负责表现。
*/
.game-resource-generation-tasks-sidebar {
position: fixed;
top: 4rem;
bottom: 6rem;
left: 0.75rem;
z-index: 40;
display: flex;
width: min(300px, 80vw);
flex-direction: column;
overflow: hidden;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.75rem;
background: var(--platform-subpanel-fill);
color: var(--platform-text-strong);
box-shadow: var(--platform-panel-shadow);
backdrop-filter: blur(10px);
animation: game-resource-generation-tasks-enter 160ms ease-out;
}
/* 提交后面板会重新挂载,动画只负责「进场」这一下;不做宽度 reflow,避免画布抖动。 */
@keyframes game-resource-generation-tasks-enter {
from {
opacity: 0;
transform: translateX(-0.5rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.game-resource-generation-tasks-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 1px solid var(--platform-line-soft);
padding: 0.68rem 0.78rem;
}
.game-resource-generation-tasks-sidebar-title {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
font-weight: 850;
}
.game-resource-generation-tasks-sidebar-count {
display: inline-flex;
min-width: 1.35rem;
height: 1.35rem;
align-items: center;
justify-content: center;
border: 1px solid var(--platform-neutral-border);
border-radius: 999px;
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
font-weight: 850;
}
.game-resource-generation-tasks-sidebar-icon-button {
display: grid;
width: 1.75rem;
height: 1.75rem;
place-items: center;
border: 1px solid transparent;
border-radius: 999px;
background: transparent;
color: var(--platform-button-ghost-text);
transition:
background 120ms ease,
color 120ms ease,
border-color 120ms ease;
}
.game-resource-generation-tasks-sidebar-icon-button:hover {
border-color: var(--platform-subpanel-border);
background: var(--platform-nav-item-hover-fill);
color: var(--platform-text-strong);
}
.game-resource-generation-tasks-sidebar-icon-button:focus-visible,
.game-resource-generation-tasks-handle:focus-visible,
.game-resource-generation-task-locate:focus-visible {
outline: 2px solid var(--platform-accent);
outline-offset: 2px;
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.game-resource-generation-tasks-scroll {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.5rem 0.6rem;
scrollbar-color: var(--platform-line-soft) transparent;
scrollbar-width: thin;
}
.game-resource-generation-tasks-scroll::-webkit-scrollbar {
width: 6px;
}
.game-resource-generation-tasks-scroll::-webkit-scrollbar-thumb {
border-radius: 999px;
background: var(--platform-line-soft);
}
.game-resource-generation-tasks-scroll::-webkit-scrollbar-track {
background: transparent;
}
.game-resource-generation-tasks-empty {
padding: 1rem 0.5rem;
color: var(--platform-text-muted);
font-size: 0.74rem;
font-weight: 750;
text-align: center;
}
.game-resource-generation-tasks-section
+ .game-resource-generation-tasks-section {
margin-top: 0.65rem;
border-top: 1px solid var(--platform-line-soft);
padding-top: 0.6rem;
}
.game-resource-generation-tasks-section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
color: var(--platform-text-muted);
font-size: 0.72rem;
font-weight: 850;
letter-spacing: 0.02em;
}
.game-resource-generation-tasks-section-title-count {
display: inline-flex;
min-width: 1.05rem;
height: 1.05rem;
align-items: center;
justify-content: center;
border-radius: 999px;
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
}
.game-resource-generation-tasks-list {
display: grid;
gap: 0.38rem;
margin-top: 0.4rem;
}
.game-resource-generation-task-card {
display: grid;
gap: 0.3rem;
width: 100%;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.5rem;
background: var(--platform-panel-fill);
padding: 0.5rem 0.55rem;
transition:
border-color 120ms ease,
box-shadow 120ms ease,
transform 120ms ease;
}
.game-resource-generation-task-card:hover {
border-color: var(
--platform-surface-hover-border,
var(--platform-subpanel-border)
);
box-shadow: var(--platform-desktop-hover-shadow);
transform: translateY(-1px);
}
.game-resource-generation-task-card-title-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.45rem;
}
.game-resource-generation-task-card-name {
overflow: hidden;
color: var(--platform-text-strong);
font-size: 0.78rem;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-card-action {
overflow: hidden;
color: var(--platform-text-muted);
font-size: 0.68rem;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-card-meta {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.4rem;
}
.game-resource-generation-task-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--platform-neutral-border);
border-radius: 999px;
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
padding: 0.1rem 0.45rem;
font-size: 0.68rem;
font-weight: 800;
white-space: nowrap;
}
.game-resource-generation-task-badge::before {
content: '';
width: 0.375rem;
height: 0.375rem;
border-radius: 999px;
background: currentcolor;
}
/* 四种状态的 tone 映射:中性 / 品牌 / 成功 / 危险,全部走平台 token,不硬编码颜色。 */
.game-resource-generation-task-badge[data-tone='queued'] {
border-color: var(--platform-neutral-border);
background: var(--platform-neutral-bg);
color: var(--platform-neutral-text);
}
.game-resource-generation-task-badge[data-tone='running'] {
border-color: var(--platform-accent);
background: transparent;
color: var(--platform-accent);
}
.game-resource-generation-task-badge[data-tone='completed'] {
border-color: var(--platform-success-border);
background: var(--platform-success-bg);
color: var(--platform-success-text);
}
.game-resource-generation-task-badge[data-tone='failed'] {
border-color: var(--platform-button-danger-border);
background: var(--platform-button-danger-fill);
color: var(--platform-button-danger-text);
}
/* 生成中只给「有在动」的呼吸感,不做百分比 —— 后端没有可播报的百分比。 */
.game-resource-generation-task-badge[data-tone='running']::before {
animation: game-resource-generation-task-pulse 1.2s ease-in-out infinite;
}
@keyframes game-resource-generation-task-pulse {
50% {
opacity: 0.25;
}
}
.game-resource-generation-task-card-phase {
overflow: hidden;
color: var(--platform-text-muted);
font-size: 0.7rem;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-generation-task-card-elapsed {
color: var(--platform-text-muted);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
font-weight: 750;
text-align: right;
white-space: nowrap;
}
.game-resource-generation-task-card-error {
color: var(--platform-button-danger-text);
font-size: 0.7rem;
line-height: 1.35;
overflow-wrap: anywhere;
white-space: normal;
}
.game-resource-generation-task-locate {
justify-self: start;
border: 0;
background: transparent;
color: var(--platform-accent);
font-size: 0.7rem;
font-weight: 800;
padding: 0;
text-decoration: underline;
text-underline-offset: 0.16rem;
transition: color 120ms ease;
}
.game-resource-generation-task-locate:hover {
color: var(--platform-text-strong);
}
.game-resource-generation-tasks-sidebar-footer {
display: flex;
align-items: center;
justify-content: flex-end;
border-top: 1px solid var(--platform-line-soft);
padding: 0.4rem 0.6rem;
}
/* 折叠态:贴边竖向把手 + 在途数量角标。 */
.game-resource-generation-tasks-handle {
position: fixed;
top: 50%;
left: 0;
z-index: 40;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--platform-subpanel-border);
border-left: 0;
border-radius: 0 0.75rem 0.75rem 0;
background: var(--platform-subpanel-fill);
color: var(--platform-text-strong);
padding: 0.6rem 0.3rem;
box-shadow: var(--platform-panel-shadow);
transform: translateY(-50%);
transition:
background 120ms ease,
box-shadow 120ms ease,
transform 120ms ease;
animation: game-resource-generation-tasks-handle-enter 160ms ease-out;
}
.game-resource-generation-tasks-handle:hover {
background: var(--platform-nav-item-hover-fill);
box-shadow: var(--platform-desktop-hover-shadow);
transform: translateY(-50%) translateX(0.1rem);
}
@keyframes game-resource-generation-tasks-handle-enter {
from {
opacity: 0;
transform: translateY(-50%) translateX(-0.5rem);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
.game-resource-generation-tasks-handle-label {
font-size: 0.7rem;
font-weight: 850;
writing-mode: vertical-rl;
}
.game-resource-generation-tasks-handle-badge {
display: inline-flex;
min-width: 1.1rem;
height: 1.1rem;
align-items: center;
justify-content: center;
border: 1px solid var(--platform-accent);
border-radius: 999px;
color: var(--platform-accent);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
font-weight: 850;
}
/* 窄屏(含 360px):侧栏占满可用宽度,把手只占一条窄边,不挡画布操作。 */
@media (max-width: 480px) {
.game-resource-generation-tasks-sidebar {
top: 3.5rem;
right: 0.5rem;
bottom: 5.5rem;
left: 0.5rem;
width: auto;
}
.game-resource-generation-tasks-handle {
padding: 0.5rem 0.2rem;
}
}
/* 降低动效偏好:进场动画、悬停位移与呼吸全部关掉。 */
@media (prefers-reduced-motion: reduce) {
.game-resource-generation-tasks-sidebar,
.game-resource-generation-tasks-handle {
animation: none;
}
.game-resource-generation-task-card,
.game-resource-generation-tasks-handle,
.game-resource-generation-tasks-sidebar-icon-button,
.game-resource-generation-task-badge::before,
.game-resource-generation-task-locate {
transition: none;
animation: none;
}
.game-resource-generation-task-card:hover,
.game-resource-generation-tasks-handle:hover {
transform: none;
}
}
@@ -149,14 +149,13 @@ describe('「生成任务」侧栏', () => {
const { container } = renderSidebar(doneTasks);
const aside = container.querySelector('aside') as HTMLElement;
// 有界高度:上下都给死了,不靠内容撑高。
expect(aside.className).toContain('top-16');
expect(aside.className).toContain('bottom-24');
const scroller = container.querySelector(
'[data-resource-generation-task-scroll]',
) as HTMLElement;
expect(scroller.className).toContain('overflow-y-auto');
expect(scroller.className).toContain('min-h-0');
// 高度有界与「列表自己滚动」在样式声明里钉(见 SidebarStyle 用例);这里只确认
// 容器与滚动区都挂上了语义类,样式不会落空。
expect(aside.className).toContain('game-resource-generation-tasks-sidebar');
expect(
container.querySelector('[data-resource-generation-task-scroll]')
?.className,
).toContain('game-resource-generation-tasks-scroll');
expect(
within(screen.getByRole('region', { name: '已完成' })).getAllByRole(
@@ -234,4 +233,44 @@ describe('「生成任务」侧栏', () => {
renderSidebar([]);
expect(screen.getByRole('status').textContent).toBe('还没有生成任务');
});
test('四种状态各自输出对应的 tone,样式按档落地', () => {
renderSidebar([
task({ taskId: 't-queued' }),
task({
taskId: 't-running',
dispatched: true,
status: 'running',
phaseDetail: '正在生成。',
}),
task({
taskId: 't-completed',
dispatched: true,
status: 'completed',
phaseDetail: '生成已完成。',
assetId: 'asset-tone',
finishedAtMillis: 3_000,
}),
task({
taskId: 't-failed',
dispatched: true,
status: 'failed',
phaseDetail: '生成失败:远端拒绝',
error: '远端拒绝',
}),
]);
const tones = ['queued', 'running', 'completed', 'failed'] as const;
for (const tone of tones) {
expect(
document.querySelector(
`.game-resource-generation-task-badge[data-tone='${tone}']`,
),
).not.toBeNull();
}
// 每条任务一个徽标:tone 不重复、不漏档。
expect(
document.querySelectorAll('.game-resource-generation-task-badge'),
).toHaveLength(4);
});
});
@@ -0,0 +1,218 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, test } from 'vitest';
import {
declaration,
parseStyleSheet,
resolveDeclarations,
type StyleRule,
} from './styleCascade';
/**
* 侧栏样式的声明级断言。
*
* jsdom 不加载这个 CSS 文件,所以这里按仓库既有做法(`styleCascade`)直接解析**真实生效的声明**:
* 「状态 tone 映射」「等宽数字」「圆角 / 悬停」「过渡」「reduced-motion 关动效」都用声明钉住。
*/
const SIDEBAR_CSS_PATH = resolve(
process.cwd(),
'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css',
);
const rules = parseStyleSheet(readFileSync(SIDEBAR_CSS_PATH, 'utf8'));
/**
* `styleCascade` 的求值器只认识宽度媒体查询,遇到 `prefers-reduced-motion` 会主动报错;
* 那条规则的生效与否单独在下面按声明断言,所以求值时先把它排除掉。
*/
const widthRules = rules.filter(
(rule) => !(rule.media ?? '').includes('prefers-reduced-motion'),
);
function resolved(
selectors: readonly string[],
viewportWidth = 1280,
): Map<string, string> {
return resolveDeclarations(widthRules, selectors, viewportWidth);
}
function toneRule(tone: string): StyleRule | undefined {
return rules.find((rule) =>
rule.selectors.includes(
`.game-resource-generation-task-badge[data-tone='${tone}']`,
),
);
}
describe('「生成任务」侧栏样式', () => {
test('四种状态各有一档 tone,取值全部来自平台 token 而不硬编码颜色', () => {
const tones = ['queued', 'running', 'completed', 'failed'] as const;
const resolvedTones = tones.map((tone) =>
resolved([
'.game-resource-generation-task-badge',
`.game-resource-generation-task-badge[data-tone='${tone}']`,
]),
);
const signature = (map: Map<string, string>) =>
['border-color', 'background', 'color']
.map((property) => declaration(map, property))
.join(' | ');
const signatures = resolvedTones.map(signature);
// 四档必须互不相同,否则「状态徽标成体系」这条就退化成同一个样子。
expect(new Set(signatures).size).toBe(4);
expect(signatures[0]).toContain('--platform-neutral');
expect(signatures[1]).toContain('--platform-accent');
expect(signatures[2]).toContain('--platform-success');
expect(signatures[3]).toContain('--platform-button-danger');
for (const one of signatures) {
expect(one).not.toMatch(/#[0-9a-f]{3,8}|rgba?\(|hsla?\(/iu);
}
// 四档 tone 必须在 CSS 里真实存在(少一档这条断言就红)。
for (const tone of tones) {
expect(toneRule(tone)).toBeDefined();
}
});
test('生成中只有「在动」的呼吸感,没有伪造百分比', () => {
const running = resolved([
'.game-resource-generation-task-badge',
".game-resource-generation-task-badge[data-tone='running']",
]);
// 徽标本身不承载进度数值:文案由组件按后端状态给,样式里不出现百分比工具。
expect(declaration(running, 'color')).toBe('var(--platform-accent)');
const pulse = rules.find((rule) =>
rule.selectors.includes(
".game-resource-generation-task-badge[data-tone='running']::before",
),
);
expect(pulse?.declarations.get('animation')).toContain('pulse');
});
test('阶段是次要信息、已耗时等宽数字右对齐', () => {
const phase = resolved(['.game-resource-generation-task-card-phase']);
expect(declaration(phase, 'color')).toBe('var(--platform-text-muted)');
expect(declaration(phase, 'font-size')).toBe('0.7rem');
const elapsed = resolved(['.game-resource-generation-task-card-elapsed']);
expect(declaration(elapsed, 'font-variant-numeric')).toBe('tabular-nums');
expect(declaration(elapsed, 'text-align')).toBe('right');
});
test('条目卡片有圆角与悬停反馈,失败原因可折行不截断', () => {
const card = resolved(['.game-resource-generation-task-card']);
expect(declaration(card, 'border-radius')).toBe('0.5rem');
const hover = resolved([
'.game-resource-generation-task-card',
'.game-resource-generation-task-card:hover',
]);
expect(declaration(hover, 'box-shadow')).toBe(
'var(--platform-desktop-hover-shadow)',
);
expect(declaration(hover, 'transform')).toBe('translateY(-1px)');
const error = resolved(['.game-resource-generation-task-card-error']);
expect(declaration(error, 'white-space')).toBe('normal');
expect(declaration(error, 'overflow-wrap')).toBe('anywhere');
const locate = resolved(['.game-resource-generation-task-locate']);
expect(declaration(locate, 'text-decoration')).toContain('underline');
});
test('展开 / 折叠都有过渡,折叠把手是竖向标签 + 在途角标', () => {
const sidebar = resolved(['.game-resource-generation-tasks-sidebar']);
expect(declaration(sidebar, 'animation')).toContain(
'game-resource-generation-tasks-enter',
);
expect(declaration(sidebar, 'border-radius')).toBe('0.75rem');
expect(declaration(sidebar, 'backdrop-filter')).toBe('blur(10px)');
const handle = resolved([
'.game-resource-generation-tasks-handle',
'.game-resource-generation-tasks-handle:hover',
]);
expect(declaration(handle, 'transition')).toContain('transform');
expect(declaration(handle, 'transform')).toBe(
'translateY(-50%) translateX(0.1rem)',
);
const handleLabel = resolved([
'.game-resource-generation-tasks-handle-label',
]);
expect(declaration(handleLabel, 'writing-mode')).toBe('vertical-rl');
const handleBadge = resolved([
'.game-resource-generation-tasks-handle-badge',
]);
expect(declaration(handleBadge, 'font-variant-numeric')).toBe(
'tabular-nums',
);
});
test('列表滚动条是细样式,滚动区域有界', () => {
const scroll = resolved(['.game-resource-generation-tasks-scroll']);
expect(declaration(scroll, 'overflow-y')).toBe('auto');
expect(declaration(scroll, 'scrollbar-width')).toBe('thin');
expect(declaration(scroll, 'overscroll-behavior')).toBe('contain');
const thumb = rules.find((rule) =>
rule.selectors.includes(
'.game-resource-generation-tasks-scroll::-webkit-scrollbar-thumb',
),
);
expect(thumb?.declarations.get('background')).toBe(
'var(--platform-line-soft)',
);
// 侧栏高度有界:上下边界都给死,不靠内容撑高(内容再多也只滚列表)。
const sidebar = resolved(['.game-resource-generation-tasks-sidebar']);
expect(declaration(sidebar, 'top')).toBe('4rem');
expect(declaration(sidebar, 'bottom')).toBe('6rem');
expect(declaration(sidebar, 'width')).toBe('min(300px, 80vw)');
});
test('窄屏(360px)下侧栏不溢出不挡把手', () => {
const narrow = resolved(['.game-resource-generation-tasks-sidebar'], 360);
expect(declaration(narrow, 'width')).toBe('auto');
expect(declaration(narrow, 'left')).toBe('0.5rem');
expect(declaration(narrow, 'right')).toBe('0.5rem');
});
test('prefers-reduced-motion 下进场动画与过渡都被关掉', () => {
const reduced = rules.filter((rule) =>
(rule.media ?? '').includes('prefers-reduced-motion'),
);
expect(reduced.length).toBeGreaterThan(0);
const motionless = (selector: string, property: string) =>
reduced.some(
(rule) =>
rule.selectors.includes(selector) &&
rule.declarations.get(property) === 'none',
);
expect(
motionless('.game-resource-generation-tasks-sidebar', 'animation'),
).toBe(true);
expect(
motionless('.game-resource-generation-tasks-handle', 'animation'),
).toBe(true);
expect(
motionless('.game-resource-generation-task-card', 'transition'),
).toBe(true);
});
test('焦点环可见(键盘可见焦点用平台 token)', () => {
const focusRule = rules.find((rule) =>
rule.selectors.includes(
'.game-resource-generation-tasks-handle:focus-visible',
),
);
expect(focusRule?.declarations.get('outline')).toBe(
'2px solid var(--platform-accent)',
);
expect(focusRule?.declarations.get('box-shadow')).toBe(
'0 0 0 3px var(--platform-input-focus-ring)',
);
});
});