Merge branch 'feat/agc-generation-task-progress' into fix/agc-canvas-acceptance-batch
This commit is contained in:
+67
-43
@@ -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}
|
||||
>
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地排队的阶段文案。
|
||||
*
|
||||
|
||||
+435
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+218
@@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user