Compare commits

..
Author SHA1 Message Date
总工 35aad34c7c HEL-689: deploy S4 three-band desk console (feat/hel-683-desk-console @ bef28fa) 2026-10-11 13:52:49 +08:00
施工员andmultica-agent bef28facee feat(desk): 三段式图片/视频生成台——任务模式栏+工作流挑选台+摘要带 (HEL-683 S4)
- 新增 components/desk/:StudioDesk 取代 StudioGenerate 成为 /image、/video
  共用页面;TaskModeRail(真实计数、未覆盖置灰、收藏/最近)、WorkflowBay
  (搜索、本机/云端分段、模型家族分组、待确认入口、增量渲染)、
  WorkflowPreview(输入要求预览)、LibraryRow/LibraryFilters/
  InputRequirementBadge
- 挑选时中栏 grid 列宽展开、右侧显示预览;选定后收成摘要带;
  data-reduce 直接跳变;≤1179px 挑选台为全屏浮层,≤760px 任务模式横条
- 挑选侧全部走 view=meta 轻量元数据,完整记录按 id 惰性加载;
  云端来源诚实标注未接入;?wf/?mode 深链;五阶段上下文与返回路径不变
- Composer 工作流芯片改为展开挑选台,删除 WorkflowPicker;
  i18n 29 键 ×7 语言;changelog
- e2e/shoot.mjs 适配挑选台键盘链,并修正 7 处 S1 灰阶化后失效的
  旧色值/文案断言(定影 + theme-1 灰阶信号色)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-11 13:19:30 +08:00
20 changed files with 2149 additions and 405 deletions
@@ -0,0 +1,79 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { WorkflowMediaIO } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
/**
* 输入要求徽标 (HEL-683):一个工作流真实声明 的进出媒介,
* 来自 S2 元数据——图标+文字双编码,只报真实接线,不虚构输入。
*/
interface Props {
io: WorkflowMediaIO | null | undefined;
/** Compact form for row summaries (no out side when empty-in). */
compact?: boolean;
}
let { io, compact = false }: Props = $props();
type Medium = 'image' | 'video' | 'audio';
const LABELS: Record<Medium, string> = {
image: 'wl.mediaImage',
video: 'wl.mediaVideo',
audio: 'wl.mediaAudio',
};
const inList = $derived(((io?.in ?? []) as Medium[]).filter((m) => m in LABELS));
const outList = $derived(((io?.out ?? []) as Medium[]).filter((m) => m in LABELS));
</script>
{#if inList.length || outList.length}
<span class="req" aria-label={t('desk.bayReqAria')}>
{#each inList as m (m)}
<span class="pill in" title="{t(LABELS[m])} · {t('wl.mediaIn')}">
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
</span>
{/each}
{#if inList.length && outList.length}<span class="arrow">→</span>{/if}
{#each outList as m (m)}
<span class="pill out" title="{t(LABELS[m])} · {t('wl.mediaOut')}">
<Icon name={m === 'image' ? 'image' : m === 'video' ? 'film' : 'music'} size={12} />
{#if !compact}<span class="lbl">{t(LABELS[m])}</span>{/if}
</span>
{/each}
</span>
{:else if !compact}
<span class="pill none" title={t('desk.promptOnly')}><span class="lbl">{t('desk.promptOnly')}</span></span>
{/if}
<style>
.req {
display: inline-flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
}
.pill {
display: inline-flex;
align-items: center;
gap: 3px;
height: 18px;
padding: 0 5px;
border-radius: var(--r-sm);
background: var(--bg-4);
color: var(--text-2);
font-size: var(--fs-mini);
white-space: nowrap;
}
.pill.in {
color: var(--text);
}
.pill.none .lbl {
color: var(--text-3);
}
.arrow {
color: var(--text-3);
font-size: var(--fs-mini);
}
</style>
@@ -0,0 +1,124 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { t } from '$lib/i18n.svelte';
/**
* 挑选台筛选行 (HEL-683):搜索框 + 本机/云端来源分段 + 仅收藏开关。
* 云端分段是诚实的占位——云端生成能力尚未接入,切过去只看到说明。
*/
interface Props {
search: string;
source: 'local' | 'cloud';
favOnly: boolean;
resultHint: string;
searchEl?: HTMLInputElement | null;
onSetSearch: (v: string) => void;
onSetSource: (s: 'local' | 'cloud') => void;
onToggleFav: () => void;
}
let {
search,
source,
favOnly,
resultHint,
searchEl = $bindable(null),
onSetSearch,
onSetSource,
onToggleFav,
}: Props = $props();
</script>
<div class="filters">
<div class="search">
<Icon name="search" size={14} />
<input
bind:this={searchEl}
class="search-input"
type="search"
value={search}
placeholder={t('desk.baySearchPh')}
aria-label={t('desk.baySearchPh')}
aria-keyshortcuts="/"
oninput={(e) => onSetSearch(e.currentTarget.value)}
/>
</div>
<div class="a-seg source" role="group" aria-label={t('desk.baySourceLabel')}>
<button type="button" aria-pressed={source === 'local'} onclick={() => onSetSource('local')}>
{t('desk.baySourceLocal')}
</button>
<button type="button" aria-pressed={source === 'cloud'} onclick={() => onSetSource('cloud')}>
{t('desk.baySourceCloud')}
</button>
</div>
<button
type="button"
class="a-chip fav"
class:on={favOnly}
aria-pressed={favOnly}
title={t('desk.bayFavOnly')}
onclick={onToggleFav}
>
<Icon name="star" size={13} />
<span class="lbl">{t('desk.bayFavOnly')}</span>
</button>
<span class="hint">{resultHint}</span>
</div>
<style>
.filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.search {
display: flex;
align-items: center;
gap: var(--sp-3);
flex: 1 1 180px;
min-width: 0;
height: 32px;
box-sizing: border-box;
padding: 0 var(--sp-3);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
color: var(--text-3);
transition: border-color var(--d-state) ease;
}
.search:focus-within {
border-color: var(--accent-line);
}
.search-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--fs-section);
}
.search-input::placeholder {
color: var(--text-3);
}
.source {
flex-shrink: 0;
}
.fav.on {
color: var(--accent);
background: var(--accent-soft);
}
.hint {
margin-left: auto;
color: var(--text-3);
font-size: var(--fs-mini);
white-space: nowrap;
}
@media (max-width: 760px) {
.hint {
display: none;
}
}
</style>
@@ -0,0 +1,170 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
import InputRequirementBadge from './InputRequirementBadge.svelte';
/**
* 挑选台一行 (HEL-683):显示名 + 输入要求徽标 + 用途标签 + 端点/收藏
* 标注。当前工作流行带取景框角线;键盘/指针悬停行只是背景高亮。
*/
interface Props {
meta: WorkflowMeta;
current: boolean;
active: boolean;
endpoints: Array<{ id: string; name: string }>;
onPick: () => void;
onActive: () => void;
}
let { meta, current, active, endpoints, onPick, onActive }: Props = $props();
const endpointName = $derived(
meta.endpoint_id
? (endpoints.find((e) => e.id === meta.endpoint_id)?.name ?? t('desk.wfDefaultConn'))
: t('desk.wfDefaultConn'),
);
</script>
<button
type="button"
class="row"
class:active
class:current
role="option"
aria-selected={active ? 'true' : undefined}
aria-current={current ? 'true' : undefined}
onclick={onPick}
onpointermove={onActive}
onfocus={onActive}
>
<span class="main">
<span class="name">
{#if meta.favorite}<span class="fav"><Icon name="star" size={11} /></span>{/if}
<span class="name-txt">{meta.name}</span>
{#if current}<span class="cur-mark">{t('desk.wfCurrent')}</span>{/if}
</span>
<span class="sub">
<span class="a-tag">{usageLabel(meta.usage!)}</span>
<InputRequirementBadge io={meta.media_io} compact />
</span>
</span>
<span class="side">
<span class="conn" title="{t('studio.connLabel')}:{endpointName}">{endpointName}</span>
</span>
</button>
<style>
.row {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
box-sizing: border-box;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: none;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
/* 长列表性能:视口外的行不参与布局/绘制。 */
content-visibility: auto;
contain-intrinsic-size: auto 52px;
transition: background-color var(--d-state) ease;
}
.row:hover,
.row.active {
background: var(--bg-3);
}
.row:focus-visible {
outline: none;
background: var(--bg-3);
box-shadow: 0 0 0 1px var(--accent-line);
}
.main {
display: grid;
gap: 3px;
flex: 1;
min-width: 0;
}
.name {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.name-txt {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.fav {
display: inline-flex;
flex-shrink: 0;
color: var(--accent);
}
.cur-mark {
flex-shrink: 0;
color: var(--text-3);
font-size: var(--fs-mini);
}
.sub {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-1);
}
.side {
flex-shrink: 0;
}
.conn {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-3);
font-size: var(--fs-mini);
}
/* 当前工作流:取景框角线(HEL-680 的内容级选中语言)。 */
.row.current {
position: relative;
}
.row.current::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px);
background-repeat: no-repeat;
}
</style>
@@ -2,7 +2,7 @@
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { tick, untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { page } from '$app/state';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
@@ -20,6 +20,7 @@
listPaths,
missingMedia,
schemaDefaults,
usageLabel,
validateForm,
type BlockReason,
type DeskKind,
@@ -27,8 +28,18 @@
type FormValues,
} from '$lib/components/generate/schema';
import { matchesFilter, type FeedFilter } from '$lib/components/generate/takes';
import InputRequirementBadge from './InputRequirementBadge.svelte';
import TaskModeRail from './TaskModeRail.svelte';
import WorkflowBay from './WorkflowBay.svelte';
import WorkflowPreview from './WorkflowPreview.svelte';
import { apiErrorMessage, assetUrl, comfy, jobsApi, playgroundApi, projects, settings, workflows } from '$lib/api';
import { USAGE_BY_KIND, type Job, type Workflow } from '$lib/comfy/types';
import {
USAGE_BY_KIND,
type Job,
type Workflow,
type WorkflowMeta,
type WorkflowUsage,
} from '$lib/comfy/types';
import { compactInputValues } from '$lib/comfy/promptInput';
import { connectEvents } from '$lib/events';
import { handleJobEvent } from '$lib/jobProgress';
@@ -38,11 +49,11 @@
import { t } from '$lib/i18n.svelte';
/**
* 生成台 (spec-v2 §3.3, HEL-650 C 批): one desk for images and video — the
* take feed with the composer below and the inspector on the right.
* `/image` and `/video` only preselect: the default workflow comes from
* that kind's usages and the feed opens filtered to it. Slots and params
* follow the selected workflow's JSON (see generate/schema.ts).
* 三段式生成台 (HEL-683, HEL-674 §二 / HEL-679 融合样稿): 左栏任务
* 模式、中部工作流挑选台(挑选时展开,选定后收成摘要带)、右侧沿用
* 参数与结果组件。`/image` 与 `/video` 共用本台,`kind` 决定任务模式,
* `?wf=<id>` 深链指定工作流、`?mode=<usage>` 预选任务模式。项目五阶段
* 的上下文条与返回路径保持不变(回流属 S5,不在本卡)。
*/
interface Props {
kind: DeskKind;
@@ -56,7 +67,10 @@
// Data
// ------------------------------------------------------------------
const settingsQuery = createQuery({ queryKey: ['settings'], queryFn: settings.get });
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.list });
// HEL-683: the desk browses S2's lightweight metadata (view=meta, no
// workflow_json). Full records are fetched per id on demand — the whole
// list (~7.6MB at 150+ rows) never loads here.
const metaQuery = createQuery({ queryKey: ['workflows-meta'], queryFn: workflows.listMeta });
const jobsQuery = createQuery({
queryKey: ['playground-jobs'],
queryFn: playgroundApi.jobs,
@@ -77,13 +91,75 @@
const activeId = $derived($settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? '');
const dryRun = $derived($settingsQuery.data?.dry_run === true);
// HEL-636/638: only confirmed usages with a settled output mapping.
const deskWorkflows = $derived(
($workflowsQuery.data ?? []).filter((w) => w.is_enabled && (w.usage ?? null) !== null && !w.needs_mapping),
// Picker-side metadata: runnable rows of this kind + honest pending count.
const allMetas = $derived($metaQuery.data ?? []);
const metaById = $derived(new Map(allMetas.map((m) => [m.id, m])));
const deskMetas = $derived(
allMetas.filter((m) => m.is_enabled && m.usage !== null && !m.needs_mapping && m.kind === kind),
);
const workflowById = $derived(new Map(($workflowsQuery.data ?? []).map((w) => [w.id, w])));
const workflowOf = (job: Job): Workflow | undefined =>
job.workflow_id != null ? workflowById.get(job.workflow_id) : undefined;
const pendingCount = $derived(allMetas.filter((m) => m.needs_mapping && m.kind === kind).length);
// Full records, lazily fetched per id (selected workflow, feed jobs'
// workflows, the bay's preview row) and cached until invalidated.
const details = new SvelteMap<number, Workflow>();
const inflight = new Set<number>();
async function ensureDetail(id: number): Promise<Workflow | null> {
const hit = details.get(id);
if (hit) return hit;
if (inflight.has(id)) return null;
inflight.add(id);
try {
const wf = await workflows.get(id);
details.set(id, wf);
return wf;
} catch {
return null;
} finally {
inflight.delete(id);
}
}
const neededIds = $derived.by(() => {
const ids = new Set<number>();
if (workflowId != null) ids.add(workflowId);
if (bayOpen && bayActiveId != null) ids.add(bayActiveId);
for (const j of allJobs) if (j.workflow_id != null) ids.add(j.workflow_id);
return ids;
});
$effect(() => {
for (const id of neededIds) {
if (!details.has(id)) void ensureDetail(id);
}
});
/**
* A job's workflow: the full record when loaded, else a metadata stub
* (name/kind/usage render instantly; input-dependent views stay empty
* until the record lands). Undefined only when the row is truly gone —
* the 工作流已删除 tag stays honest.
*/
function stubFromMeta(m: WorkflowMeta): Workflow {
return {
id: m.id,
name: m.name,
kind: m.kind,
usage: m.usage,
is_enabled: m.is_enabled,
input_schema: [],
workflow_json: {},
description: m.description,
prompt_profile: m.prompt_profile,
} as unknown as Workflow;
}
const workflowOf = (job: Job): Workflow | undefined => {
if (job.workflow_id == null) return undefined;
const full = details.get(job.workflow_id);
if (full) return full;
const m = metaById.get(job.workflow_id);
return m ? stubFromMeta(m) : undefined;
};
const allJobs = $derived($jobsQuery.data ?? []);
@@ -136,6 +212,61 @@
return off;
});
// ------------------------------------------------------------------
// Bay (挑选台) state — mode, deep links, open/collapse
// ------------------------------------------------------------------
type TaskMode = WorkflowUsage | 'all';
let mode = $state<TaskMode>(untrack(initMode));
function initMode(): TaskMode {
const q = page.url.searchParams.get('mode');
return q && USAGE_BY_KIND[kind].includes(q as WorkflowUsage) ? (q as WorkflowUsage) : 'all';
}
let bayOpen = $state(false);
let bayActiveId = $state<number | null>(null);
let bay = $state<WorkflowBay | null>(null);
const modeMetas = $derived(mode === 'all' ? deskMetas : deskMetas.filter((m) => m.usage === mode));
function openBay() {
bayActiveId = workflowId;
bayOpen = true;
}
function closeBay() {
bayOpen = false;
}
function pickWorkflow(id: number) {
bayOpen = false;
if (id !== workflowId) workflowId = id;
void tick().then(() => promptEl?.focus());
}
// ?wf=<id> deep link: preselect once the metadata is in (invalid → ignored).
let wfDeepLinkDone = false;
$effect(() => {
if (wfDeepLinkDone) return;
const raw = Number(page.url.searchParams.get('wf'));
if (!Number.isInteger(raw) || raw <= 0) {
wfDeepLinkDone = true;
return;
}
if ($metaQuery.isLoading) return;
wfDeepLinkDone = true;
if (deskMetas.some((m) => m.id === raw)) workflowId = raw;
});
async function toggleFavorite(id: number, fav: boolean) {
try {
await workflows.update(id, { favorite: fav });
await client.invalidateQueries({ queryKey: ['workflows-meta'] });
await client.invalidateQueries({ queryKey: ['workflows'] });
} catch (err) {
toast.error(apiErrorMessage(err));
}
}
// ------------------------------------------------------------------
// Form state
// ------------------------------------------------------------------
@@ -150,7 +281,7 @@
let promptEl = $state<HTMLTextAreaElement | null>(null);
let pickerFor = $state<string | null>(null);
const currentWorkflow = $derived(deskWorkflows.find((w) => w.id === workflowId) ?? null);
const currentWorkflow = $derived(workflowId != null ? (details.get(workflowId) ?? null) : null);
const schema = $derived(currentWorkflow?.input_schema ?? []);
const ds = $derived(classifySchema(schema, currentWorkflow?.kind ?? kind));
@@ -161,16 +292,13 @@
endpointId = currentWorkflow?.endpoint_id ?? activeId ?? '';
});
// Default workflow: this entry's usages in priority order, runnable ones first.
// Default workflow: this entry's usages in priority order (metadata-side;
// error-level warnings surface through the block reason once loaded).
$effect(() => {
if (workflowId != null && deskWorkflows.some((w) => w.id === workflowId)) return;
if (workflowId != null && (details.has(workflowId) || deskMetas.some((m) => m.id === workflowId))) return;
const usages = USAGE_BY_KIND[kind];
const runnable = deskWorkflows.filter((w) => !(w.warnings ?? []).some((x) => x.level === 'error'));
const pick =
usages.map((u) => runnable.find((w) => w.usage === u)).find(Boolean) ??
deskWorkflows.find((w) => usages.includes(w.usage!)) ??
runnable[0] ??
deskWorkflows[0];
const candidates = deskMetas.filter((m) => m.usage != null && usages.includes(m.usage));
const pick = usages.map((u) => candidates.find((m) => m.usage === u)).find(Boolean) ?? candidates[0] ?? null;
workflowId = pick?.id ?? null;
});
@@ -328,6 +456,10 @@
openParams();
return;
}
if (b.target === 'desk-wf') {
openBay();
return;
}
await tick();
const el = b.target ? document.getElementById(b.target) : null;
if (el && !el.getClientRects().length) {
@@ -372,6 +504,8 @@
else if (!narrow) inspOpen = true;
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
// 最近使用由后端在入队时记录 (queue/manager);这里只刷新轻量列表。
void client.invalidateQueries({ queryKey: ['workflows-meta'] });
} catch (err) {
toast.error(t('studio.submitFailedToast', { detail: apiErrorMessage(err) }));
} finally {
@@ -494,9 +628,14 @@
}
/** Fill the composer back with a past take's values (on its own workflow). */
function reuse(job: Job) {
const wf = deskWorkflows.find((w) => w.id === job.workflow_id);
if (!wf) {
async function reuse(job: Job) {
const id = job.workflow_id;
if (id == null) {
toast.error(t('studio.reuseMissingWf'));
return;
}
const wf = (details.get(id) ?? (await ensureDetail(id))) as Workflow | null;
if (!wf || !(wf.input_schema ?? []).length) {
toast.error(t('studio.reuseMissingWf'));
return;
}
@@ -594,8 +733,9 @@
}
// ------------------------------------------------------------------
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦提示词 · ↑↓/J/K 切换记录
// (即时无动画)· 空格播放/暂停 · Delete 删选中 · ] 检视区 · Esc 关浮层
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦搜索/提示词 · ↑↓/J/K 切换
// (挑选台打开时游走挑选行,否则切换记录)· 空格播放/暂停 · Delete 删
// 选中 · ] 检视区 · Esc 关挑选台/浮层
// ------------------------------------------------------------------
function typing(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
@@ -615,6 +755,19 @@
function onKeydown(e: KeyboardEvent) {
if (e.defaultPrevented || pickerFor || inLayer(e.target)) return;
if (e.key === 'Escape' && bayOpen) {
e.preventDefault();
closeBay();
return;
}
// 挑选台开着时,键盘归挑选台(其容器自己处理 ↑↓/Enter/Esc)。
if (bayOpen) {
if (e.key === '/' && !typing(e.target)) {
e.preventDefault();
bay?.focusSearch();
}
return;
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void submit();
@@ -652,6 +805,14 @@
inspOpen = false;
}
}
// ------------------------------------------------------------------
// Band (选定后的摘要带) helpers
// ------------------------------------------------------------------
const currentMeta = $derived(allMetas.find((m) => m.id === workflowId) ?? null);
const previewMeta = $derived(allMetas.find((m) => m.id === bayActiveId) ?? null);
const previewFull = $derived(bayActiveId != null ? (details.get(bayActiveId) ?? null) : null);
const picking = $derived(bayOpen && wide);
</script>
<svelte:window onkeydown={onKeydown} />
@@ -685,82 +846,131 @@
{/snippet}
<AppShell active={kind} title={t('desk.title')}>
<div class="desk" class:insp-off={inspOff}>
<section class="center" aria-label={t('desk.title')}>
<TakeFeed
bind:this={feed}
jobs={allJobs}
{workflowOf}
bind:filter
selectedId={selectedJob?.id ?? null}
{nowMs}
{busyId}
loading={$jobsQuery.isLoading}
{pendingIds}
{flashIds}
{shakeIds}
bind:orderedIds
banner={ctxBanner}
{inspectorToggle}
onSelect={(job) => selectTake(job, 'pointer')}
onReuse={reuse}
onRetry={retry}
onCancel={cancel}
onDelete={requestDelete}
<div class="studio-desk" class:picking>
<TaskModeRail {kind} metas={deskMetas} active={mode} onSelect={(m) => (mode = m)} onPickWorkflow={pickWorkflow} />
<div class="bay-col">
<WorkflowBay
bind:this={bay}
open={bayOpen}
metas={modeMetas}
{pendingCount}
selectedId={workflowId}
bind:activeId={bayActiveId}
{endpoints}
onPick={pickWorkflow}
onClose={closeBay}
/>
{#if !$workflowsQuery.isLoading && !deskWorkflows.length}
<div class="desk-empty" role="note">
<Icon name="playground" size={16} />
<span>{t('desk.noWorkflows')}</span>
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
</div>
<section class="main-col">
{#if !bayOpen}
<div class="bay-band">
<button type="button" class="band-hit" onclick={openBay} title={t('desk.bandChange')}>
<Icon name="playground" size={14} />
<span class="band-mode">{mode === 'all' ? t('desk.modeAll') : usageLabel(mode)}</span>
<span class="band-sep" aria-hidden="true">·</span>
<span class="band-name">
{currentWorkflow?.name ?? currentMeta?.name ?? t('desk.pickWorkflow')}
</span>
{#if currentMeta?.usage}<span class="a-tag">{usageLabel(currentMeta.usage)}</span>{/if}
<InputRequirementBadge io={currentMeta?.media_io} compact />
<span class="band-change">
<span class="band-change-txt">{t('desk.bandChange')}</span>
<Icon name="chevron-right" size={13} />
</span>
</button>
</div>
{/if}
<Composer
{ds}
{values}
workflows={deskWorkflows}
current={currentWorkflow}
{endpoints}
{endpointId}
conn={connState}
{errors}
{showErrors}
{block}
{changedCount}
{submitting}
{sent}
bind:slotBusy
bind:promptEl
onSet={setVal}
onPickWorkflow={(wf) => (workflowId = wf.id)}
onPickEndpoint={(id) => {
endpointTouched = true;
endpointId = id;
}}
onSubmit={submit}
onReveal={reveal}
onOpenParams={openParams}
onFromResults={(nodeId) => (pickerFor = nodeId)}
/>
{#if picking}
<WorkflowPreview
meta={previewMeta}
full={previewFull}
currentId={workflowId}
{endpoints}
onUse={() => pickWorkflow(bayActiveId ?? workflowId ?? 0)}
onToggleFav={(fav) => previewMeta && toggleFavorite(previewMeta.id, fav)}
/>
{/if}
<div class="desk" class:insp-off={inspOff} class:off={picking}>
<section class="center" aria-label={t('desk.title')}>
<TakeFeed
bind:this={feed}
jobs={allJobs}
{workflowOf}
bind:filter
selectedId={selectedJob?.id ?? null}
{nowMs}
{busyId}
loading={$jobsQuery.isLoading}
{pendingIds}
{flashIds}
{shakeIds}
bind:orderedIds
banner={ctxBanner}
{inspectorToggle}
onSelect={(job) => selectTake(job, 'pointer')}
onReuse={reuse}
onRetry={retry}
onCancel={cancel}
onDelete={requestDelete}
/>
{#if !$metaQuery.isLoading && !deskMetas.length}
<div class="desk-empty" role="note">
<Icon name="playground" size={16} />
<span>{t('desk.noWorkflows')}</span>
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
</div>
{/if}
<Composer
{ds}
{values}
current={currentWorkflow}
{endpoints}
{endpointId}
conn={connState}
{errors}
{showErrors}
{block}
{changedCount}
{submitting}
{sent}
bind:slotBusy
bind:promptEl
onSet={setVal}
onOpenBay={openBay}
onPickEndpoint={(id) => {
endpointTouched = true;
endpointId = id;
}}
onSubmit={submit}
onReveal={reveal}
onOpenParams={openParams}
onFromResults={(nodeId) => (pickerFor = nodeId)}
/>
</section>
<Inspector
bind:tab
job={selectedJob}
wf={selectedJob ? workflowOf(selectedJob) : undefined}
{nowMs}
open={inspOpen}
inactive={wide ? inspOff : !inspOpen}
canSendToSlot={!!imageSlotTarget || !!imageList}
{crossfade}
{playSignal}
bind:videoEl
params={paramsTab}
onClose={closeInspector}
onReuse={reuse}
onRetry={retry}
onSendToSlot={sendToSlot}
onDelete={requestDelete}
/>
</div>
</section>
<Inspector
bind:tab
job={selectedJob}
wf={selectedJob ? workflowOf(selectedJob) : undefined}
{nowMs}
open={inspOpen}
inactive={wide ? inspOff : !inspOpen}
canSendToSlot={!!imageSlotTarget || !!imageList}
{crossfade}
{playSignal}
bind:videoEl
params={paramsTab}
onClose={closeInspector}
onReuse={reuse}
onRetry={retry}
onSendToSlot={sendToSlot}
onDelete={requestDelete}
/>
</div>
</AppShell>
@@ -779,17 +989,48 @@
</Modal>
<style>
.studio-desk {
display: grid;
grid-template-columns: 204px 0px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
}
.studio-desk.picking {
grid-template-columns: 204px clamp(340px, 28vw, 440px) minmax(0, 1fr);
}
:global(:root[data-reduce='1']) .studio-desk {
transition: none;
}
.bay-col {
min-width: 0;
min-height: 0;
overflow: hidden;
border-right: 1px solid var(--line);
}
.main-col {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.desk {
display: grid;
grid-template-columns: minmax(0, 1fr) 420px;
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
flex: 1;
transition: grid-template-columns var(--d-drawer) var(--ease-drawer);
}
.desk.insp-off {
grid-template-columns: minmax(0, 1fr) 0px;
}
.desk.off {
display: none;
}
.center {
display: flex;
flex-direction: column;
@@ -808,6 +1049,65 @@
color: var(--warn);
font-size: var(--fs-aux);
}
/* 摘要带(选定后):整条可点,展开挑选台。 */
.bay-band {
flex-shrink: 0;
padding: var(--sp-2) var(--sp-7);
}
.band-hit {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
box-sizing: border-box;
min-width: 0;
padding: var(--sp-2) var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
color: var(--text-2);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
cursor: pointer;
transition:
border-color var(--d-state) ease,
color var(--d-state) ease;
}
.band-hit:hover {
border-color: var(--line-2);
color: var(--text);
}
.band-hit:focus-visible {
outline: none;
border-color: var(--accent-line);
}
.band-mode {
flex-shrink: 0;
color: var(--text-3);
}
.band-sep {
color: var(--text-3);
}
.band-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-weight: 500;
}
.band-change {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
color: var(--text-3);
}
.band-change-txt {
font-size: var(--fs-mini);
}
.picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
@@ -838,7 +1138,12 @@
color: var(--text-3);
font-size: var(--fs-aux);
}
/* 中屏:挑选台为全屏浮层(组件内处理),列宽归零。 */
@media (max-width: 1179px) {
.studio-desk,
.studio-desk.picking {
grid-template-columns: 204px 0px minmax(0, 1fr);
}
.desk,
.desk.insp-off {
grid-template-columns: minmax(0, 1fr);
@@ -846,10 +1151,33 @@
.desk-empty {
margin: 0 var(--sp-6) var(--sp-3);
}
.bay-band {
padding: var(--sp-2) var(--sp-6);
}
}
/* 窄屏:任务模式变横条,摘要带仍可用。 */
@media (max-width: 760px) {
.studio-desk,
.studio-desk.picking {
grid-template-columns: minmax(0, 1fr) 0px;
grid-template-rows: auto minmax(0, 1fr);
}
.bay-col {
grid-column: 2;
grid-row: 1 / span 2;
}
.main-col {
grid-column: 1;
grid-row: 2;
}
.desk-empty {
margin: 0 var(--sp-5) var(--sp-2);
}
.bay-band {
padding: var(--sp-1) var(--sp-4) var(--sp-2);
}
.band-change-txt {
display: none;
}
}
</style>
@@ -0,0 +1,310 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { USAGE_BY_KIND, type WorkflowMeta, type WorkflowUsage } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
/**
* 左栏任务模式 (HEL-683):该入口可用的任务模式(按真实工作流统计),
* 未覆盖的模式置灰;底部为收藏与最近使用。窄屏退化为横向模式条。
*/
interface Props {
kind: 'image' | 'video';
metas: WorkflowMeta[];
active: WorkflowUsage | 'all';
onSelect: (mode: WorkflowUsage | 'all') => void;
onPickWorkflow: (id: number) => void;
}
let { kind, metas, active, onSelect, onPickWorkflow }: Props = $props();
interface ModeEntry {
usage: WorkflowUsage;
count: number;
}
const modes = $derived.by(() => {
const counts = new Map<WorkflowUsage, number>();
for (const m of metas) {
if (!m.usage) continue;
counts.set(m.usage, (counts.get(m.usage) ?? 0) + 1);
}
return USAGE_BY_KIND[kind].map((u) => ({ usage: u, count: counts.get(u) ?? 0 }));
});
const favorites = $derived(
metas
.filter((m) => m.favorite)
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
.slice(0, 5),
);
const recents = $derived(
metas
.filter((m) => m.last_used_at && !m.favorite)
.sort((a, b) => (b.last_used_at ?? '').localeCompare(a.last_used_at ?? ''))
.slice(0, 5),
);
</script>
<nav class="rail" aria-label={t('desk.modeRailAria')}>
<p class="rail-label">{t('desk.modeRailTitle')}</p>
<div class="modes" role="group" aria-label={t('desk.modeRailTitle')}>
<button
type="button"
class="mode"
class:on={active === 'all'}
aria-pressed={active === 'all'}
onclick={() => onSelect('all')}
>
<span class="mode-lbl">{t('desk.modeAll')}</span>
<span class="mode-n">{metas.length}</span>
</button>
{#each modes as m (m.usage)}
<button
type="button"
class="mode"
class:on={active === m.usage}
class:off={m.count === 0}
disabled={m.count === 0}
aria-pressed={active === m.usage}
title={m.count === 0 ? t('desk.modeEmpty') : usageLabel(m.usage)}
onclick={() => onSelect(m.usage)}
>
<span class="mode-lbl">{usageLabel(m.usage)}</span>
<span class="mode-n">{m.count}</span>
</button>
{/each}
</div>
<div class="lists">
{#if recents.length}
<section class="rail-sec">
<p class="rail-label">
<Icon name="clock" size={12} />
{t('desk.railRecent')}
</p>
<ul class="pick-list">
{#each recents as m (m.id)}
<li>
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
<span class="pick-name">{m.name}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if favorites.length}
<section class="rail-sec">
<p class="rail-label">
<Icon name="star" size={12} />
{t('desk.railFav')}
</p>
<ul class="pick-list">
{#each favorites as m (m.id)}
<li>
<button type="button" class="pick" title={m.name} onclick={() => onPickWorkflow(m.id)}>
<span class="pick-name">{m.name}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
</nav>
<style>
.rail {
display: flex;
flex-direction: column;
min-height: 0;
box-sizing: border-box;
padding: var(--sp-5) var(--sp-3);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.rail-label {
display: flex;
align-items: center;
gap: var(--sp-1);
margin: 0 0 var(--sp-2);
padding: 0 var(--sp-3);
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.05em;
white-space: nowrap;
}
.modes {
display: flex;
flex-direction: column;
gap: 2px;
}
.mode {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
box-sizing: border-box;
width: 100%;
padding: var(--sp-3) var(--sp-4);
border: 0;
border-radius: var(--r-md);
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-body);
text-align: left;
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.mode:hover:not(:disabled) {
background: var(--bg-3);
color: var(--text);
}
.mode:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.mode.on {
background: var(--bg-3);
color: var(--text);
}
/* 取景框角线只标当前模式(HEL-680 内容级选中语言)。 */
.mode.on {
position: relative;
}
.mode.on::after {
content: '';
position: absolute;
inset: 2px;
pointer-events: none;
background-image:
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0),
linear-gradient(var(--accent) 0 0);
background-position:
left top,
left top,
right top,
right top,
left bottom,
left bottom,
right bottom,
right bottom;
background-size:
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px),
var(--frame-arm, 12px) var(--frame-w, 2px),
var(--frame-w, 2px) var(--frame-arm, 12px);
background-repeat: no-repeat;
}
.mode.off {
color: var(--text-3);
opacity: 0.55;
cursor: not-allowed;
}
.mode-n {
color: var(--text-3);
font-size: var(--fs-mini);
font-variant-numeric: tabular-nums;
}
.lists {
display: flex;
flex-direction: column;
gap: var(--sp-4);
margin-top: var(--sp-5);
padding-top: var(--sp-4);
border-top: 1px solid var(--line);
}
.rail-sec {
min-width: 0;
}
.pick-list {
display: flex;
flex-direction: column;
gap: 1px;
margin: 0;
padding: 0;
list-style: none;
}
.pick {
display: block;
width: 100%;
box-sizing: border-box;
padding: var(--sp-2) var(--sp-4);
border: 0;
border-radius: var(--r-sm);
background: none;
color: var(--text-2);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.pick:hover {
background: var(--bg-3);
color: var(--text);
}
.pick:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.pick-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 窄屏:任务模式退化为横向条,收藏/最近隐藏(挑选台里有同样入口)。 */
@media (max-width: 760px) {
.rail {
flex-direction: row;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
border-right: 0;
border-bottom: 1px solid var(--line);
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
}
.rail-label {
margin: 0;
padding: 0;
flex-shrink: 0;
}
.modes {
flex-direction: row;
gap: var(--sp-1);
}
.mode {
width: auto;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--line);
font-size: var(--fs-aux);
white-space: nowrap;
}
.mode.on::after {
inset: 1px;
}
.lists {
display: none;
}
}
</style>
@@ -0,0 +1,488 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import { tick } from 'svelte';
import type { WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from '$lib/components/generate/schema';
import LibraryFilters from './LibraryFilters.svelte';
import LibraryRow from './LibraryRow.svelte';
/**
* 工作流挑选台 (HEL-683, HEL-674 §二):中部可搜索筛选、按模型家族
* 分组的挑选列。数据来自 S2 轻量元数据(view=meta,不含完整 JSON);
* 云端来源只做诚实占位(未接入,不可点击使用)。键盘:↑↓/J/K 游走、
* Enter 使用、Esc 关闭。长列表增量渲染 + content-visibility。
*/
interface Props {
open: boolean;
metas: WorkflowMeta[];
pendingCount: number;
selectedId: number | null;
activeId: number | null;
endpoints: Array<{ id: string; name: string }>;
onPick: (id: number) => void;
onClose: () => void;
}
let { open, metas, pendingCount, selectedId, activeId = $bindable(null), endpoints, onPick, onClose }: Props =
$props();
let search = $state('');
let source = $state<'local' | 'cloud'>('local');
let favOnly = $state(false);
let visibleCount = $state(48);
let searchEl = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLElement | null>(null);
const CATEGORY_LABELS: Record<string, string> = {
basic: 'wfCat.basic',
audio_tts: 'wfCat.audioTts',
music: 'wfCat.music',
image: 'wfCat.image',
video: 'wfCat.video',
digital_human: 'wfCat.digitalHuman',
motion_transfer: 'wfCat.motionTransfer',
};
function categoryLabel(key: string | null | undefined): string {
if (!key) return '';
const k = CATEGORY_LABELS[key];
return k ? t(k) : key;
}
const filtered = $derived.by(() => {
const q = search.trim().toLowerCase();
return metas.filter((m) => {
if (favOnly && !m.favorite) return false;
if (!q) return true;
const hay = [
m.name,
m.description ?? '',
(m.tags ?? []).join(' '),
m.model_family ?? '',
m.usage ? usageLabel(m.usage) : '',
categoryLabel(m.category),
]
.join(' ')
.toLowerCase();
return hay.includes(q);
});
});
interface Group {
key: string;
label: string;
rows: WorkflowMeta[];
}
const groups = $derived.by(() => {
const byFamily = new Map<string, WorkflowMeta[]>();
for (const m of filtered) {
const key = m.model_family ?? '';
const list = byFamily.get(key) ?? [];
list.push(m);
byFamily.set(key, list);
}
const out: Group[] = [];
for (const [key, rows] of byFamily) {
rows.sort((a, b) => {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
const la = a.last_used_at ?? '';
const lb = b.last_used_at ?? '';
if (la !== lb) return lb.localeCompare(la);
return a.name.localeCompare(b.name, 'zh');
});
out.push({ key, label: key || t('desk.bayNoFamily'), rows });
}
out.sort((a, b) =>
a.key === '' ? 1 : b.key === '' ? -1 : a.label.localeCompare(b.label, 'zh'),
);
return out;
});
const flat = $derived(groups.flatMap((g) => g.rows));
/** Flattened rows cut at the incremental window, per group. */
const windowed = $derived.by(() => {
let left = visibleCount;
return groups.map((g) => {
const take = Math.max(0, Math.min(g.rows.length, left));
left -= take;
return { ...g, visible: g.rows.slice(0, take) };
});
});
// Any filter change restarts the incremental window.
$effect(() => {
void search;
void favOnly;
visibleCount = 48;
listEl?.scrollTo({ top: 0 });
});
// Keep the active row real: follow the selection, clamp on filter changes.
$effect(() => {
void flat;
if (activeId != null && flat.some((m) => m.id === activeId)) return;
activeId = flat.find((m) => m.id === selectedId)?.id ?? flat[0]?.id ?? null;
});
// Opening the bay: the selected workflow's row must be inside the
// incremental window and in view — otherwise the highlight is invisible.
$effect(() => {
if (!open) return;
const target = activeId ?? selectedId;
const idx = flat.findIndex((m) => m.id === target);
if (idx < 0) return;
if (idx >= visibleCount) visibleCount = idx + 48;
const id = setTimeout(() => {
document
.querySelector<HTMLElement>(`[data-wf-row="${target}"]`)
?.scrollIntoView({ block: 'center' });
}, 0);
return () => clearTimeout(id);
});
const activeMeta = $derived(metas.find((m) => m.id === activeId) ?? null);
const resultHint = $derived(
source === 'cloud' ? '' : t('desk.bayCount', { n: filtered.length, total: metas.length }),
);
// Autofocus the search whenever the bay opens.
$effect(() => {
if (!open) return;
const id = setTimeout(() => searchEl?.focus(), 0);
return () => clearTimeout(id);
});
function moveActive(delta: number) {
if (!flat.length) return;
let i = flat.findIndex((m) => m.id === activeId);
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
i = Math.max(0, Math.min(flat.length - 1, i));
const next = flat[i];
activeId = next.id;
// Keyboard roving may step past the incremental window — extend it so
// the row exists, then reveal it.
if (i >= visibleCount) visibleCount = i + 24;
void tick().then(() => {
document
.querySelector<HTMLElement>(`[data-wf-row="${next.id}"]`)
?.scrollIntoView({ block: 'nearest' });
});
}
function pickActive() {
if (activeMeta) onPick(activeMeta.id);
}
export function focusSearch() {
searchEl?.focus();
}
function onKeydown(e: KeyboardEvent) {
if (e.defaultPrevented) return;
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
const inSearch = e.target === searchEl;
if (e.key === 'ArrowDown' || (!inSearch && e.key === 'j')) {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp' || (!inSearch && e.key === 'k')) {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
pickActive();
}
}
function onScroll() {
const el = listEl;
if (!el) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 240) {
if (visibleCount < flat.length) visibleCount += 48;
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bay-backdrop" onclick={onClose} aria-hidden="true"></div>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<section
class="bay"
class:open
aria-label={t('desk.pickWorkflow')}
inert={!open}
onkeydown={onKeydown}
>
<header class="bay-head">
<h2 class="bay-title">
<Icon name="playground" size={15} />
<span>{t('desk.pickWorkflow')}</span>
</h2>
<button type="button" class="a-btn ghost sm close" onclick={onClose} title={t('desk.keyClose')}>
<Icon name="close" size={14} />
<span class="lbl">{t('desk.keyClose')}</span>
</button>
</header>
<div class="bay-filters">
<LibraryFilters
bind:searchEl
{search}
{source}
{favOnly}
{resultHint}
onSetSearch={(v) => (search = v)}
onSetSource={(s) => (source = s)}
onToggleFav={() => (favOnly = !favOnly)}
/>
</div>
{#if source === 'cloud'}
<div class="cloud-note" role="note">
<Icon name="alert" size={16} />
<p class="cloud-title">{t('desk.bayCloudTitle')}</p>
<p class="cloud-body">{t('desk.bayCloudBody')}</p>
</div>
{:else if !metas.length}
<div class="bay-empty">
<p>{t('desk.noWorkflows')}</p>
<a class="a-btn ghost sm" href="/connections">{t('studio.wfEmptyGo')}</a>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bay-list" bind:this={listEl} role="listbox" aria-label={t('desk.pickWorkflow')} onscroll={onScroll}>
{#each windowed as g (g.key)}
{#if g.visible.length}
<div class="group-label" aria-hidden="true">{g.label} · {g.rows.length}</div>
{#each g.visible as m (m.id)}
<div data-wf-row={m.id}>
<LibraryRow
meta={m}
current={m.id === selectedId}
active={m.id === activeId}
{endpoints}
onPick={() => onPick(m.id)}
onActive={() => (activeId = m.id)}
/>
</div>
{/each}
{/if}
{:else}
<p class="bay-none">{t('desk.wfNoMatch')}</p>
{/each}
{#if visibleCount < flat.length}
<button type="button" class="more a-btn ghost sm" onclick={() => (visibleCount += 48)}>
{t('desk.bayMore', { n: Math.min(48, flat.length - visibleCount) })}
</button>
{/if}
</div>
<footer class="bay-foot">
{#if pendingCount > 0}
<a class="pending-link" href="/connections">
{t('desk.bayPending', { n: pendingCount })}
<Icon name="chevron-right" size={12} />
</a>
{/if}
{#if activeMeta}
<span class="active-name" title={activeMeta.name}>{activeMeta.name}</span>
{/if}
<span class="keys">
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
</span>
</footer>
{/if}
</section>
<style>
.bay {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
box-sizing: border-box;
padding: var(--sp-5) var(--sp-4) var(--sp-3);
overflow: hidden;
}
.bay-head {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-2) var(--sp-4);
}
.bay-title {
display: flex;
align-items: center;
gap: var(--sp-2);
margin: 0;
color: var(--text);
font-size: var(--fs-title);
font-weight: 600;
}
.bay-title :global(svg) {
color: var(--text-3);
}
.close {
margin-left: auto;
}
.close .lbl {
display: none;
}
.bay-filters {
padding: 0 var(--sp-1) var(--sp-3);
}
.bay-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--sp-1);
}
.group-label {
position: sticky;
top: 0;
z-index: 2;
padding: var(--sp-3) var(--sp-3) var(--sp-1);
background: linear-gradient(var(--bg-2) 82%, transparent);
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.04em;
}
.more {
align-self: center;
margin: var(--sp-3) 0;
}
.bay-none,
.bay-empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
color: var(--text-3);
font-size: var(--fs-aux);
text-align: center;
}
.bay-empty {
display: grid;
justify-content: center;
gap: var(--sp-3);
}
.bay-foot {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-2) 0;
border-top: 1px solid var(--line);
font-size: var(--fs-mini);
color: var(--text-3);
}
.pending-link {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--text-2);
text-decoration: none;
white-space: nowrap;
}
.pending-link:hover {
color: var(--text);
}
.active-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-2);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
white-space: nowrap;
}
.keys .a-kbd:not(:first-child) {
margin-left: var(--sp-2);
}
.cloud-note {
display: grid;
justify-items: start;
gap: var(--sp-3);
margin: var(--sp-4) var(--sp-2);
padding: var(--sp-6);
border: 1px dashed var(--line-2);
border-radius: var(--r-lg);
color: var(--text-2);
}
.cloud-title {
margin: 0;
color: var(--text);
font-weight: 600;
}
.cloud-body {
margin: 0;
font-size: var(--fs-aux);
line-height: 1.6;
}
/* 窄/中屏:挑选台改为全屏浮层(计划中的最简可用降级)。 */
.bay-backdrop {
display: none;
}
@media (max-width: 1179px) {
.bay {
position: fixed;
left: 0;
right: 0;
top: var(--topbar-h);
bottom: 0;
z-index: 60;
background: var(--bg);
box-shadow: var(--shadow-float);
transform: translateY(12px);
opacity: 0;
pointer-events: none;
transition:
transform var(--d-drawer) var(--ease-drawer),
opacity var(--d-state) ease;
}
.bay.open {
transform: none;
opacity: 1;
pointer-events: auto;
}
.bay-backdrop {
display: block;
position: fixed;
inset: var(--topbar-h) 0 0 0;
z-index: 59;
background: var(--overlay);
}
.close .lbl {
display: inline;
}
.keys {
display: none;
}
:global(:root[data-reduce='1']) .bay {
transition: none;
}
}
@media (max-width: 760px) {
.bay {
top: 0;
padding: var(--sp-4) var(--sp-4) calc(var(--tabbar-h) + env(safe-area-inset-bottom));
}
.bay-backdrop {
inset: 0;
}
}
</style>
@@ -0,0 +1,247 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import type { Workflow, WorkflowMeta } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { usageLabel, slotSummary } from '$lib/components/generate/schema';
import InputRequirementBadge from './InputRequirementBadge.svelte';
/**
* 输入要求预览 (HEL-683):挑选台打开时右侧的详情面板——展示键盘/指针
* 停留的工作流的名称、家族、说明与真实输入要求(S2 元数据 + 已加载的
* 完整记录槽位摘要),确认后进入创作区。
*/
interface Props {
meta: WorkflowMeta | null;
full: Workflow | null;
currentId: number | null;
endpoints: Array<{ id: string; name: string }>;
onUse: () => void;
onToggleFav: (fav: boolean) => void;
}
let { meta, full, currentId, endpoints, onUse, onToggleFav }: Props = $props();
const isCurrent = $derived(meta != null && meta.id === currentId);
const broken = $derived((full?.warnings ?? []).some((w) => w.level === 'error'));
const endpointName = $derived.by(() => {
const id = meta?.endpoint_id;
if (!id) return t('desk.wfDefaultConn');
return endpoints.find((e) => e.id === id)?.name ?? t('desk.wfDefaultConn');
});
const CATEGORY_LABELS: Record<string, string> = {
basic: 'wfCat.basic',
audio_tts: 'wfCat.audioTts',
music: 'wfCat.music',
image: 'wfCat.image',
video: 'wfCat.video',
digital_human: 'wfCat.digitalHuman',
motion_transfer: 'wfCat.motionTransfer',
};
</script>
<div class="preview">
{#if meta}
<div class="head">
<h3 class="name" title={meta.name}>{meta.name}</h3>
<button
type="button"
class="fav"
class:on={meta.favorite}
aria-pressed={meta.favorite}
aria-label={t('desk.bayFavOnly')}
title={meta.favorite ? t('desk.pvUnfav') : t('desk.pvFav')}
onclick={() => onToggleFav(!meta.favorite)}
>
<Icon name="star" size={16} />
</button>
</div>
<div class="tags">
{#if meta.usage}<span class="a-tag accent">{usageLabel(meta.usage)}</span>{/if}
{#if meta.model_family}<span class="a-tag">{meta.model_family}</span>{/if}
{#if meta.category && CATEGORY_LABELS[meta.category]}
<span class="a-tag">{t(CATEGORY_LABELS[meta.category])}</span>
{/if}
{#if isCurrent}<span class="a-tag ok">{t('desk.pvCurrent')}</span>{/if}
{#if broken}<span class="a-tag err">{t('desk.wfBroken')}</span>{/if}
</div>
{#if meta.description}
<p class="desc">{meta.description}</p>
{/if}
<section class="sec">
<h4 class="sec-title">{t('desk.bayReqTitle')}</h4>
<div class="req-line">
<InputRequirementBadge io={meta.media_io} />
</div>
{#if full}
<p class="slot-summary">{slotSummary(full)}</p>
{:else}
<p class="slot-summary dim">{t('desk.pvSlotsLoading')}</p>
{/if}
</section>
<dl class="kv">
<div class="kv-row">
<dt>{t('studio.connLabel')}</dt>
<dd>{endpointName}</dd>
</div>
{#if meta.last_used_at}
<div class="kv-row">
<dt>{t('desk.pvLastUsed')}</dt>
<dd>{new Date(meta.last_used_at).toLocaleString()}</dd>
</div>
{/if}
{#if (full?.input_schema ?? []).length}
<div class="kv-row">
<dt>{t('desk.pvParamCount')}</dt>
<dd>{(full?.input_schema ?? []).length}</dd>
</div>
{/if}
</dl>
<div class="use">
<button type="button" class="a-btn primary" disabled={broken} onclick={onUse}>
<Icon name="check" size={13} />
{isCurrent ? t('desk.pvBackCreate') : t('desk.pvUse')}
</button>
{#if broken}<p class="use-hint">{t('studio.errWfBroken')}</p>{/if}
</div>
{:else}
<div class="preview-empty">
<Icon name="search" size={20} />
<p>{t('desk.pvEmpty')}</p>
</div>
{/if}
</div>
<style>
.preview {
display: flex;
flex-direction: column;
gap: var(--sp-4);
flex: 1;
min-height: 0;
box-sizing: border-box;
height: 100%;
padding: var(--sp-7);
overflow-y: auto;
}
.head {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
}
.name {
flex: 1;
min-width: 0;
margin: 0;
color: var(--text);
font-size: var(--fs-title);
font-weight: 600;
line-height: 1.4;
overflow-wrap: anywhere;
}
.fav {
flex-shrink: 0;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: none;
color: var(--text-3);
cursor: pointer;
transition:
color var(--d-state) ease,
background-color var(--d-state) ease;
}
.fav.on {
color: var(--accent);
background: var(--accent-soft);
}
.fav:focus-visible {
outline: none;
box-shadow: 0 0 0 1px var(--accent-line);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
.desc {
margin: 0;
color: var(--text-2);
font-size: var(--fs-aux);
line-height: 1.65;
white-space: pre-line;
}
.sec {
display: grid;
gap: var(--sp-2);
padding: var(--sp-4);
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--bg-2);
}
.sec-title {
margin: 0;
color: var(--text-3);
font-size: var(--fs-mini);
letter-spacing: 0.05em;
}
.req-line {
display: flex;
}
.slot-summary {
margin: 0;
color: var(--text-2);
font-size: var(--fs-aux);
}
.slot-summary.dim {
color: var(--text-3);
}
.kv {
display: grid;
gap: var(--sp-1);
margin: 0;
}
.kv-row {
display: flex;
gap: var(--sp-3);
font-size: var(--fs-aux);
}
.kv-row dt {
flex-shrink: 0;
width: 72px;
color: var(--text-3);
}
.kv-row dd {
margin: 0;
color: var(--text-2);
min-width: 0;
overflow-wrap: anywhere;
}
.use {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: auto;
padding-top: var(--sp-4);
}
.use-hint {
margin: 0;
color: var(--err);
font-size: var(--fs-mini);
}
.preview-empty {
display: grid;
place-content: center;
justify-items: center;
gap: var(--sp-3);
height: 100%;
color: var(--text-3);
font-size: var(--fs-aux);
}
</style>
@@ -5,7 +5,6 @@
import { t } from '$lib/i18n.svelte';
import ParamChip from './ParamChip.svelte';
import SlotTray from './SlotTray.svelte';
import WorkflowPicker from './WorkflowPicker.svelte';
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
/**
@@ -13,11 +12,12 @@
* row of chips — workflow, connection, common params, 参数 — ending in
* 生成 with its reason line. The whole composer takes dropped files.
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
* HEL-683: the workflow chip no longer opens a dropdown — it expands the
* desk's middle picker bay (StudioDesk WorkflowBay), the single picker.
*/
interface Props {
ds: DeskSchema;
values: FormValues;
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
endpointId: string;
@@ -32,7 +32,7 @@
slotBusy?: boolean;
promptEl?: HTMLTextAreaElement | null;
onSet: (nodeId: string, value: FormValue) => void;
onPickWorkflow: (wf: Workflow) => void;
onOpenBay: () => void;
onPickEndpoint: (id: string) => void;
onSubmit: () => void;
onReveal: () => void;
@@ -43,7 +43,6 @@
let {
ds,
values,
workflows,
current,
endpoints,
endpointId,
@@ -57,7 +56,7 @@
slotBusy = $bindable(false),
promptEl = $bindable(null),
onSet,
onPickWorkflow,
onOpenBay,
onPickEndpoint,
onSubmit,
onReveal,
@@ -165,7 +164,18 @@
{/if}
<div class="foot">
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
<div class="picker">
<button
type="button"
id="desk-wf"
class="a-chip wf"
title={current?.name ?? t('desk.pickWorkflow')}
onclick={onOpenBay}
>
<Icon name="playground" size={14} />
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
</button>
</div>
<div class="conn opt">
<button
type="button"
@@ -312,6 +322,10 @@
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3) var(--sp-3);
}
.picker {
position: relative;
min-width: 0;
}
.conn {
position: relative;
}
@@ -1,277 +0,0 @@
<script lang="ts">
import Icon from '$lib/components/ui/Icon.svelte';
import ShellPop from '$lib/components/shell/ShellPop.svelte';
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
import { t } from '$lib/i18n.svelte';
import { slotSummary, usageLabel } from './schema';
/**
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
* pop grouped by usage with search; each row shows what the workflow
* really takes (slot summary from its JSON), its connection and whether
* it last ran. Picking keeps the prompt and carried-over images.
* D 批 (HEL-654): the footer's「↑↓ 选择」is now literally true — an
* active row follows ↑↓ (and the pointer), Enter uses it, and the
* search's Enter picks the active row instead of always the first.
*/
interface Props {
workflows: Workflow[];
current: Workflow | null;
endpoints: Array<{ id: string; name: string }>;
onPick: (wf: Workflow) => void;
}
let { workflows, current, endpoints, onPick }: Props = $props();
let open = $state(false);
let query = $state('');
let activeId = $state<number | null>(null);
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
function statusOf(wf: Workflow): Status {
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
const lt = wf.last_test;
if (!lt) return { tone: '', label: t('desk.wfUntested') };
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
return { tone: '', label: t('desk.wfTesting') };
}
function endpointLabel(wf: Workflow): string {
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
return ep ? ep.name : t('desk.wfDefaultConn');
}
const rows = $derived(
workflows.map((wf) => ({
wf,
summary: slotSummary(wf),
conn: endpointLabel(wf),
status: statusOf(wf),
usage: wf.usage ? usageLabel(wf.usage) : '',
})),
);
const groups = $derived.by(() => {
const q = query.trim().toLowerCase();
const hit = (r: (typeof rows)[number]) =>
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
return WORKFLOW_USAGES.map((u) => ({
usage: u,
items: rows.filter((r) => r.wf.usage === u && hit(r)),
})).filter((g) => g.items.length);
});
/** Flattened rows in display order — the roving selection walks these. */
const flat = $derived(groups.flatMap((g) => g.items));
// Keep the active row real: default to the current workflow, clamp on
// filter changes, never point at a row that is no longer listed.
$effect(() => {
if (!open) return;
if (activeId != null && flat.some((r) => r.wf.id === activeId)) return;
activeId = flat.find((r) => r.wf.id === current?.id)?.wf.id ?? flat[0]?.wf.id ?? null;
});
function moveActive(delta: number) {
if (!flat.length) return;
let i = flat.findIndex((r) => r.wf.id === activeId);
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
activeId = flat[Math.max(0, Math.min(flat.length - 1, i))].wf.id;
}
function pickActive() {
const row = flat.find((r) => r.wf.id === activeId) ?? flat[0];
if (row) pick(row.wf);
}
function pick(wf: Workflow) {
open = false;
query = '';
if (wf.id !== current?.id) onPick(wf);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
moveActive(1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
moveActive(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
pickActive();
}
}
</script>
<div class="picker">
<button
type="button"
id="desk-wf"
class="a-chip wf"
aria-haspopup="dialog"
aria-expanded={open}
title={current?.name ?? t('desk.pickWorkflow')}
onclick={() => (open = !open)}
>
<Icon name="playground" size={14} />
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
</button>
<ShellPop
bind:open
label={t('desk.pickWorkflow')}
placement="top"
width="520px"
arrowRoving={false}
onclose={() => (query = '')}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pop-body" onkeydown={onKeydown}>
<div class="search">
<Icon name="search" size={14} />
<input
class="search-input"
type="search"
bind:value={query}
placeholder={t('desk.wfSearchPh')}
aria-label={t('desk.wfSearchPh')}
/>
</div>
<div class="list" role="listbox" aria-label={t('desk.pickWorkflow')}>
{#each groups as g (g.usage)}
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
{#each g.items as r (r.wf.id)}
{@const cur = r.wf.id === current?.id}
<button
type="button"
class="a-menu-item row"
class:off={r.status.tone === 'err'}
class:active={r.wf.id === activeId}
role="option"
aria-current={cur ? 'true' : undefined}
aria-selected={r.wf.id === activeId ? 'true' : undefined}
onclick={() => pick(r.wf)}
onpointermove={() => (activeId = r.wf.id)}
>
<span class="main">
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
<span class="sub">
<span class="a-tag">{r.summary}</span>
<span class="a-tag">{r.conn}</span>
</span>
</span>
<span class="a-tag {r.status.tone}">{r.status.label}</span>
</button>
{/each}
{:else}
<p class="empty">{t('desk.wfNoMatch')}</p>
{/each}
</div>
<div class="foot">
<a class="a-btn ghost sm" href="/connections">
<Icon name="upload" size={13} />
{t('desk.wfImport')}
</a>
<span class="keys">
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
</span>
</div>
</div>
</ShellPop>
</div>
<style>
.picker {
position: relative;
min-width: 0;
}
.pop-body {
display: flex;
flex-direction: column;
}
.search {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4) var(--sp-4);
border-bottom: 1px solid var(--line);
color: var(--text-3);
}
.search-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: var(--fs-section);
}
.search-input::placeholder {
color: var(--text-3);
}
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
.list {
max-height: min(380px, 50vh);
overflow-y: auto;
padding: var(--sp-1) 0;
}
.row {
align-items: center;
}
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
.row.active {
background: var(--bg-4);
}
.main {
display: grid;
gap: 2px;
flex: 1;
min-width: 0;
}
.main .a-menu-label {
font-weight: 500;
}
.row.off .a-menu-label {
color: var(--text-3);
}
.sub {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
}
.empty {
margin: 0;
padding: var(--sp-6) var(--sp-4);
color: var(--text-3);
font-size: var(--fs-aux);
}
.foot {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-1) 0;
border-top: 1px solid var(--line);
}
.keys {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
font-size: var(--fs-mini);
color: var(--text-3);
white-space: nowrap;
}
.keys .a-kbd:not(:first-child) {
margin-left: var(--sp-2);
}
@media (max-width: 760px) {
.keys {
display: none;
}
}
</style>
+30
View File
@@ -2081,6 +2081,36 @@ export const de: Dict = {
'desk.wfUntested': 'Nicht getestet',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Gestern',
// --- Dreispaltige Desk (HEL-683) ---
'desk.bandChange': "Workflow wechseln",
'desk.bayCloudBody': "Cloud-Modelle für Bild / Video (gpt-image2, nano-banana, Jimeng, Kling, …) sind noch nicht angebunden. Nach der Anbindung erscheinen sie mit ihren echten Fähigkeiten — nie als unbenutzbare Einträge.",
'desk.bayCloudTitle': "Cloud-Generierung nicht angebunden",
'desk.bayCount': "{n} / {total} Workflows",
'desk.bayFavOnly': "Favoriten",
'desk.bayMore': "{n} weitere anzeigen",
'desk.bayNoFamily': "Modell unbekannt",
'desk.bayPending': "{n} offen (Zweck / Ausgabe ungeklärt)",
'desk.bayReqAria': "Eingabeanforderungen",
'desk.bayReqTitle': "Eingabeanforderungen",
'desk.baySearchPh': "Name, Beschreibung, Modell, Zweck suchen…",
'desk.baySourceCloud': "Cloud",
'desk.baySourceLabel': "Generierungsquelle",
'desk.baySourceLocal': "Lokal",
'desk.modeAll': "Alle",
'desk.modeEmpty': "Für diesen Modus gibt es noch keinen nutzbaren Workflow",
'desk.modeRailAria': "Aufgabenmodi",
'desk.modeRailTitle': "Aufgabenmodi",
'desk.pvBackCreate': "Zurück zum Erstellen",
'desk.pvCurrent': "Aktueller Workflow",
'desk.pvEmpty': "Links einen Workflow wählen, um die Eingaben zu sehen",
'desk.pvFav': "Zu Favoriten",
'desk.pvLastUsed': "Zuletzt genutzt",
'desk.pvParamCount': "Parameter",
'desk.pvSlotsLoading': "Eingabeslots werden gelesen…",
'desk.pvUnfav': "Aus Favoriten entfernen",
'desk.pvUse': "Diesen Workflow verwenden",
'desk.railFav': "Favoriten",
'desk.railRecent': "Zuletzt",
'wfd.stepParse': 'JSON parsen',
'wfd.stepNodes': 'Knoten erkennen',
'wfd.stepModels': 'Modelle prüfen',
+30
View File
@@ -2080,6 +2080,36 @@ export const en = {
'desk.wfUntested': 'Not tested',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Yesterday',
// --- Three-section desk (HEL-683) ---
'desk.bandChange': "Change workflow",
'desk.bayCloudBody': "Cloud image / video models (gpt-image2, nano-banana, Jimeng, Kling, …) are not connected to this product yet. Once integrated they will be listed with their real capabilities — never as unusable entries.",
'desk.bayCloudTitle': "Cloud generation not connected",
'desk.bayCount': "{n} / {total} workflows",
'desk.bayFavOnly': "Favorites",
'desk.bayMore': "Show {n} more",
'desk.bayNoFamily': "Unrecognized model",
'desk.bayPending': "{n} pending confirmation (usage / output unsettled)",
'desk.bayReqAria': "Input requirements",
'desk.bayReqTitle': "Input requirements",
'desk.baySearchPh': "Search name, description, model, usage…",
'desk.baySourceCloud': "Cloud",
'desk.baySourceLabel': "Generation source",
'desk.baySourceLocal': "Local",
'desk.modeAll': "All",
'desk.modeEmpty': "No runnable workflow for this mode yet",
'desk.modeRailAria': "Task modes",
'desk.modeRailTitle': "Task modes",
'desk.pvBackCreate': "Back to creating",
'desk.pvCurrent': "Current workflow",
'desk.pvEmpty': "Pick a workflow on the left to see its input requirements",
'desk.pvFav': "Add to favorites",
'desk.pvLastUsed': "Last used",
'desk.pvParamCount': "Parameters",
'desk.pvSlotsLoading': "Loading input slots…",
'desk.pvUnfav': "Remove from favorites",
'desk.pvUse': "Use this workflow",
'desk.railFav': "Favorites",
'desk.railRecent': "Recent",
'wfd.stepParse': 'Parse JSON',
'wfd.stepNodes': 'Identify nodes',
'wfd.stepModels': 'Verify models',
+30
View File
@@ -2081,6 +2081,36 @@ export const es: Dict = {
'desk.wfUntested': 'Sin probar',
'desk.workflow': 'Flujo',
'desk.yesterday': 'Ayer',
// --- Escritorio de tres secciones (HEL-683) ---
'desk.bandChange': "Cambiar flujo",
'desk.bayCloudBody': "Los modelos de nube para imagen / vídeo (gpt-image2, nano-banana, Jimeng, Kling, …) aún no están conectados a este producto. Al integrarse se listarán con sus capacidades reales, nunca como entradas inutilizables.",
'desk.bayCloudTitle': "Generación en la nube no conectada",
'desk.bayCount': "{n} / {total} flujos",
'desk.bayFavOnly': "Favoritos",
'desk.bayMore': "Mostrar {n} más",
'desk.bayNoFamily': "Modelo no identificado",
'desk.bayPending': "{n} pendientes de confirmar (uso / salida sin fijar)",
'desk.bayReqAria': "Requisitos de entrada",
'desk.bayReqTitle': "Requisitos de entrada",
'desk.baySearchPh': "Buscar nombre, descripción, modelo, uso…",
'desk.baySourceCloud': "Nube",
'desk.baySourceLabel': "Origen de generación",
'desk.baySourceLocal': "Local",
'desk.modeAll': "Todos",
'desk.modeEmpty': "Aún no hay flujo utilizable para este modo",
'desk.modeRailAria': "Modos de tarea",
'desk.modeRailTitle': "Modos de tarea",
'desk.pvBackCreate': "Volver a crear",
'desk.pvCurrent': "Flujo actual",
'desk.pvEmpty': "Elige un flujo a la izquierda para ver sus requisitos",
'desk.pvFav': "Añadir a favoritos",
'desk.pvLastUsed': "Último uso",
'desk.pvParamCount': "Parámetros",
'desk.pvSlotsLoading': "Leyendo ranuras de entrada…",
'desk.pvUnfav': "Quitar de favoritos",
'desk.pvUse': "Usar este flujo",
'desk.railFav': "Favoritos",
'desk.railRecent': "Recientes",
'wfd.stepParse': 'Analizar JSON',
'wfd.stepNodes': 'Identificar nodos',
'wfd.stepModels': 'Verificar modelos',
+30
View File
@@ -2081,6 +2081,36 @@ export const fr: Dict = {
'desk.wfUntested': 'Non testé',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Hier',
// --- Bureau en trois sections (HEL-683) ---
'desk.bandChange': "Changer de flux",
'desk.bayCloudBody': "Les modèles cloud d'image / vidéo (gpt-image2, nano-banana, Jimeng, Kling, …) ne sont pas encore connectés à ce produit. Une fois intégrés, ils seront listés avec leurs capacités réelles — jamais en entrées inutilisables.",
'desk.bayCloudTitle': "Génération cloud non connectée",
'desk.bayCount': "{n} / {total} flux",
'desk.bayFavOnly': "Favoris",
'desk.bayMore': "Afficher {n} de plus",
'desk.bayNoFamily': "Modèle non identifié",
'desk.bayPending': "{n} à confirmer (usage / sortie non fixés)",
'desk.bayReqAria': "Exigences d'entrée",
'desk.bayReqTitle': "Exigences d'entrée",
'desk.baySearchPh': "Rechercher nom, description, modèle, usage…",
'desk.baySourceCloud': "Cloud",
'desk.baySourceLabel': "Source de génération",
'desk.baySourceLocal': "Local",
'desk.modeAll': "Tous",
'desk.modeEmpty': "Aucun flux utilisable pour ce mode pour l'instant",
'desk.modeRailAria': "Modes de tâche",
'desk.modeRailTitle': "Modes de tâche",
'desk.pvBackCreate': "Retour à la création",
'desk.pvCurrent': "Flux actuel",
'desk.pvEmpty': "Choisissez un flux à gauche pour voir ses exigences d'entrée",
'desk.pvFav': "Ajouter aux favoris",
'desk.pvLastUsed': "Dernière utilisation",
'desk.pvParamCount': "Paramètres",
'desk.pvSlotsLoading': "Lecture des emplacements d’entrée…",
'desk.pvUnfav': "Retirer des favoris",
'desk.pvUse': "Utiliser ce flux",
'desk.railFav': "Favoris",
'desk.railRecent': "Récents",
'wfd.stepParse': 'Analyser le JSON',
'wfd.stepNodes': 'Identifier les nœuds',
'wfd.stepModels': 'Vérifier les modèles',
+30
View File
@@ -2079,6 +2079,36 @@ export const ja: Dict = {
'desk.wfUntested': '未テスト',
'desk.workflow': 'ワークフロー',
'desk.yesterday': '昨日',
// --- 三段構成デスク (HEL-683) ---
'desk.bandChange': "ワークフローを変更",
'desk.bayCloudBody': "クラウドの画像/動画モデル(gpt-image2、nano-banana、即夢、Kling など)はまだ本製品に接続されていません。接続後は実際の機能とともに一覧表示され、使えない項目が並ぶことはありません。",
'desk.bayCloudTitle': "クラウド生成は未接続",
'desk.bayCount': "{n} / {total} 件のワークフロー",
'desk.bayFavOnly': "お気に入り",
'desk.bayMore': "さらに {n} 件表示",
'desk.bayNoFamily': "モデル未識別",
'desk.bayPending': "確認待ち {n} 件(用途/出力未確定)",
'desk.bayReqAria': "入力要件",
'desk.bayReqTitle': "入力要件",
'desk.baySearchPh': "名前・説明・モデル・用途を検索…",
'desk.baySourceCloud': "クラウド",
'desk.baySourceLabel': "生成ソース",
'desk.baySourceLocal': "ローカル",
'desk.modeAll': "すべて",
'desk.modeEmpty': "このモードで利用できるワークフローはまだありません",
'desk.modeRailAria': "タスクモード",
'desk.modeRailTitle': "タスクモード",
'desk.pvBackCreate': "作成に戻る",
'desk.pvCurrent': "現在のワークフロー",
'desk.pvEmpty': "左でワークフローを選ぶと入力要件が表示されます",
'desk.pvFav': "お気に入りに追加",
'desk.pvLastUsed': "最終使用",
'desk.pvParamCount': "パラメータ数",
'desk.pvSlotsLoading': "入力スロットを読み込み中…",
'desk.pvUnfav': "お気に入りから外す",
'desk.pvUse': "このワークフローを使う",
'desk.railFav': "お気に入り",
'desk.railRecent': "最近の使用",
'wfd.stepParse': 'JSON を解析',
'wfd.stepNodes': 'ノードを識別',
'wfd.stepModels': 'モデルを確認',
+30
View File
@@ -2079,6 +2079,36 @@ export const ko: Dict = {
'desk.wfUntested': '테스트 안 함',
'desk.workflow': '워크플로',
'desk.yesterday': '어제',
// --- 3단 구성 데스크 (HEL-683) ---
'desk.bandChange': "워크플로 변경",
'desk.bayCloudBody': "클라우드 이미지 / 동영상 모델(gpt-image2, nano-banana, 지멍, 클링 등)은 아직 이 제품에 연결되지 않았습니다. 연결 후 실제 기능과 함께 표시되며, 사용할 수 없는 항목은 나타나지 않습니다.",
'desk.bayCloudTitle': "클라우드 생성 미연결",
'desk.bayCount': "{n} / {total}개 워크플로",
'desk.bayFavOnly': "즐겨찾기",
'desk.bayMore': "{n}개 더 보기",
'desk.bayNoFamily': "미식별 모델",
'desk.bayPending': "확인 대기 {n}개(용도 / 출력 미확정)",
'desk.bayReqAria': "입력 요구 사항",
'desk.bayReqTitle': "입력 요구 사항",
'desk.baySearchPh': "이름, 설명, 모델, 용도 검색…",
'desk.baySourceCloud': "클라우드",
'desk.baySourceLabel': "생성 소스",
'desk.baySourceLocal': "로컬",
'desk.modeAll': "전체",
'desk.modeEmpty': "이 모드에서 사용 가능한 워크플로가 아직 없습니다",
'desk.modeRailAria': "작업 모드",
'desk.modeRailTitle': "작업 모드",
'desk.pvBackCreate': "만들기로 돌아가기",
'desk.pvCurrent': "현재 워크플로",
'desk.pvEmpty': "왼쪽에서 워크플로를 선택하면 입력 요구 사항이 표시됩니다",
'desk.pvFav': "즐겨찾기에 추가",
'desk.pvLastUsed': "최근 사용",
'desk.pvParamCount': "매개변수 수",
'desk.pvSlotsLoading': "입력 슬롯 읽는 중…",
'desk.pvUnfav': "즐겨찾기에서 제거",
'desk.pvUse': "이 워크플로 사용",
'desk.railFav': "즐겨찾기",
'desk.railRecent': "최근 사용",
'wfd.stepParse': 'JSON 파싱',
'wfd.stepNodes': '노드 식별',
'wfd.stepModels': '모델 확인',
+30
View File
@@ -2079,6 +2079,36 @@ export const zh: Dict = {
'desk.wfUntested': '未试运行',
'desk.workflow': '工作流',
'desk.yesterday': '昨天',
// --- 三段式生成台 (HEL-683) ---
'desk.bandChange': "更换工作流",
'desk.bayCloudBody': "云端生图 / 生视频模型(gpt-image2、nano-banana、即梦、可灵等)尚未接入本产品。接入后这里会按真实能力列出,不会展示无法使用的条目。",
'desk.bayCloudTitle': "云端生成未接入",
'desk.bayCount': "{n} / {total} 个工作流",
'desk.bayFavOnly': "收藏",
'desk.bayMore': "再显示 {n} 个",
'desk.bayNoFamily': "未识别模型",
'desk.bayPending': "{n} 个待确认(用途 / 输出未定)",
'desk.bayReqAria': "输入要求",
'desk.bayReqTitle': "输入要求",
'desk.baySearchPh': "搜索名称、说明、模型、用途…",
'desk.baySourceCloud': "云端",
'desk.baySourceLabel': "生成来源",
'desk.baySourceLocal': "本机",
'desk.modeAll': "全部",
'desk.modeEmpty': "该模式暂无可用工作流",
'desk.modeRailAria': "任务模式",
'desk.modeRailTitle': "任务模式",
'desk.pvBackCreate': "返回创作",
'desk.pvCurrent': "当前工作流",
'desk.pvEmpty': "在左侧选择一个工作流查看输入要求",
'desk.pvFav': "加入收藏",
'desk.pvLastUsed': "最近使用",
'desk.pvParamCount': "参数项",
'desk.pvSlotsLoading': "正在读取输入槽位…",
'desk.pvUnfav': "取消收藏",
'desk.pvUse': "使用此工作流",
'desk.railFav': "收藏",
'desk.railRecent': "最近使用",
'wfd.stepParse': '解析 JSON',
'wfd.stepNodes': '识别节点',
'wfd.stepModels': '核对模型',
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
</script>
<StudioGenerate kind="image" />
<StudioDesk kind="image" />
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import StudioGenerate from '$lib/components/studio/StudioGenerate.svelte';
import StudioDesk from '$lib/components/desk/StudioDesk.svelte';
</script>
<StudioGenerate kind="video" />
<StudioDesk kind="video" />
@@ -0,0 +1,46 @@
# S4:三段式图片 / 视频生成台(HEL-683)
按 HEL-679 已确认的三张融合样稿,把 `/image` 与 `/video` 两个入口接入同一
个三段式生成台。本批只动 `calliope-web`,生成后端与项目回流(S5)零改动。
## 三段式布局(新增 `components/desk/`)
- `StudioDesk.svelte` 取代 `StudioGenerate.svelte` 成为 `/image`、`/video`
的共用页面(薄壳不变,`kind` 决定任务模式);右栏沿用既有
`Composer / SlotTray / Inspector / TakeFeed / ContextBar`,仅重排布局。
- 左栏 `TaskModeRail.svelte`:任务模式按真实工作流统计计数,未覆盖模式置灰;
底部收藏 / 最近使用直达;≤760px 退化为横向模式条。
- 中栏挑选台 `WorkflowBay.svelte`(含 `LibraryFilters / LibraryRow /
InputRequirementBadge`):搜索、本机 / 云端来源分段、按模型家族分组、
收藏过滤、待确认计数与入口;长列表增量渲染(48/批)+ `content-visibility`,
键盘 ↑↓/J/K 游走、Enter 使用、Esc 关闭。
- 挑选时中栏展开(grid 列宽动画,`data-reduce` 下直接跳变),右侧显示
`WorkflowPreview.svelte` 输入要求预览(S2 元数据 + 按需加载的槽位摘要);
选定后收成摘要带(band),一键重新展开。≤1179px 挑选台为全屏浮层。
- 云端来源是诚实占位:明确标注「云端生成未接入」,不造可点击的虚假能力;
数据结构按 `source: 'comfy' | 'cloud'` 预留。
## 数据与路由
- 挑选台 / 任务模式 / 摘要带全部走 `GET /api/workflows?view=meta` 轻量元数据
(150+ 条不下载 workflow_json);完整记录改为按 id 惰性加载(当前工作流、
记录流里出现的工作流、预览行),feed 名称先用元数据桩、schema 到位后补全,
「工作流已删除」判定以元数据为准,不在加载期误报。
- 新增查询参数:`?wf=<id>` 深链指定工作流、`?mode=<task-mode>` 预选任务模式;
五阶段 `deskHref` 的 `from=workbench` 上下文条与返回路径保持不变。
- Composer 的工作流芯片从 380px 下拉弹层改为展开挑选台(`#desk-wf` 语义
不变,仍是缺工作流时的定位目标);`WorkflowPicker.svelte` 删除。
- 最近使用沿用后端入队时写入的 `last_used_at`(queue/manager 原有逻辑),
提交后刷新轻量列表即可。
## 验证
- `npm run check`(0 错误,2 条基线既有警告)、`npm run test`、`npm run build`
通过;prettier 对本批文件通过(仓库 lint 基线因 prettier 3.9 全量重排 +
无 eslint 配置本就不可用,未扩面修复)。
- `e2e/shoot.mjs` 全绿:挑选台键盘链(展开→搜索→↑↓→Enter→调参→提交)、
SSE 状态语义、删除撤销、任务页、窄屏无溢出、减动效;同时把 7 处 S1 灰阶
化后未更新的旧色值断言修正为 theme-1 实际值(定影文案 + 灰阶信号色)。
- `e2e/lastframe.mjs` 全绿(视频检视、尾帧导出、手机弹层无回归)。
- 手工走查:`?wf` / `?mode` / `from=workbench` 上下文预填与返回、1024/390
无横向溢出,截图见 `e2e/shots/hel683-*.png`。
+23 -18
View File
@@ -69,14 +69,15 @@ const run = async () => {
const page = await ctx.newPage();
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message));
// ---------- 工作流选择器:↑↓ 高亮 + Enter 使用 ----------
// ---------- 挑选台(HEL-683 三段式):摘要带展开 → ↑↓ 高亮 + Enter 使用 ----------
await page.goto('http://127.0.0.1:5173/image');
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
await page.click('#desk-wf');
await page.waitForSelector('.shell-pop');
const activeRows = page.locator('.shell-pop .row.active');
await page.click('.bay-band .band-hit');
await page.waitForSelector('.bay .row.active', { timeout: 10000 });
await page.waitForSelector('.bay .search-input');
const activeRows = page.locator('.bay .row.active');
const nActive = await activeRows.count();
ok('工作流面板有默认高亮行', nActive === 1);
ok('挑选台有默认高亮行', nActive === 1);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
const label1 = await activeRows.first().textContent();
@@ -86,15 +87,19 @@ const run = async () => {
const chipBefore = await page.locator('#desk-wf .lbl').textContent();
await page.keyboard.press('Enter');
await page.waitForTimeout(400);
ok('Enter 使用高亮的工作流(挑选台收起)', (await page.locator('.bay.open').count()) === 0);
const chipAfter = await page.locator('#desk-wf .lbl').textContent();
ok('Enter 使用高亮的工作流', chipBefore !== chipAfter || (await activeRows.count()) === 0, `${chipBefore} → ${chipAfter}`);
await page.screenshot({ path: `${SHOTS}/01-picker-roving.png` });
ok('输入台芯片跟随选中工作流', chipBefore !== chipAfter, `${chipBefore} → ${chipAfter}`);
await page.screenshot({ path: `${SHOTS}/01-bay-roving.png` });
// 回到文生图工作流
await page.click('#desk-wf');
await page.fill('.shell-pop .search-input', '文生图');
// 回到文生图工作流(搜索唯一命中「回归」)
await page.click('.bay-band .band-hit');
await page.waitForSelector('.bay .search-input');
await page.fill('.bay .search-input', '回归');
await page.waitForSelector('.bay .row.active');
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
ok('搜索后 Enter 选中文生图工作流', (await page.locator('#desk-wf .lbl').textContent()).includes('文生图'));
// ---------- 提交 → 完成(绿标签 + 自动切结果页) ----------
setMode({ mode: 'ok', delay_sec: 0 });
@@ -116,9 +121,9 @@ const run = async () => {
// 绿标签
const doneCard = page.locator('.take', { hasText: '柴犬' }).first();
const doneTag = await doneCard.locator('.state .a-tag').first().textContent();
ok('完成态为绿色「完成」标签(非金色)', (doneTag || '').includes('完成'), doneTag);
ok('完成态为「定影」标签(HEL-680 灰阶语义)', (doneTag || '').includes('定影'), doneTag);
const tagColor = await doneCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
ok('完成标签用信号绿 rgb(73,203,144)', tagColor === 'rgb(73, 203, 144)', tagColor);
ok('完成标签用灰阶信号色 rgb(221,221,215)', tagColor === 'rgb(221, 221, 215)', tagColor);
await page.screenshot({ path: `${SHOTS}/02-desk-done.png` });
// ---------- 慢任务:排队灰 + 运行青 + 进度条 ----------
@@ -130,9 +135,9 @@ const run = async () => {
await page.click('.a-btn.primary'); // 第二个:queued 灰
await page.waitForTimeout(4000);
const runTag = await stateTag(page, '观察进度').textContent();
ok('运行中为青色「生成中」', /生成中|执行/.test(runTag || ''), runTag);
ok('运行中为「生成中/执行中」', /生成中|执行/.test(runTag || ''), runTag);
const runTagColor = await stateTag(page, '观察进度').evaluate((el) => getComputedStyle(el).color);
ok('运行标签用信号青 rgb(84,223,210)', runTagColor === 'rgb(84, 223, 210)', runTagColor);
ok('运行标签用灰阶信号色 rgb(184,182,172)', runTagColor === 'rgb(184, 182, 172)', runTagColor);
const hasBar = (await page.locator('.take', { hasText: '观察进度' }).locator('.a-progress').count()) > 0;
ok('运行中卡片有进度条', hasBar);
const queueTag = await stateTag(page, '第二个任务').textContent();
@@ -175,7 +180,7 @@ const run = async () => {
const failTag = await failCard.locator('.state .a-tag').first().textContent();
ok('失败为红色「失败」标签', (failTag || '').includes('失败'), failTag);
const failColor = await failCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
ok('失败标签用信号红 rgb(240,100,90)', failColor === 'rgb(240, 100, 90)', failColor);
ok('失败标签用信号红 rgb(208,122,107)(灰阶底上的红)', failColor === 'rgb(208, 122, 107)', failColor);
const errLine = failCard.locator('.err-txt');
ok('失败自动展开一行中文结论', (await errLine.count()) === 1 && (await errLine.textContent()).length > 4);
await errLine.click();
@@ -199,7 +204,7 @@ const run = async () => {
const undoBtn = page.locator('.toast .action', { hasText: '撤销' });
ok('出现带「撤销」的 Toast', (await undoBtn.count()) === 1);
const undoColor = await undoBtn.evaluate((el) => getComputedStyle(el).color);
ok('撤销动作按钮为金色文字 rgb(242,199,132)', undoColor === 'rgb(242, 199, 132)', undoColor);
ok('撤销动作按钮为强调白文字 rgb(245,244,238)', undoColor === 'rgb(245, 244, 238)', undoColor);
await page.screenshot({ path: `${SHOTS}/05-undo-toast.png` });
const countDeleted = await cardCount(page);
ok('删除后行立即收起', countDeleted === countBefore - 1, `${countBefore} → ${countDeleted}`);
@@ -237,7 +242,7 @@ const run = async () => {
const goneTag = await goneCard.locator('.state .a-tag').first().textContent();
ok('文件缺失为琥珀「文件缺失」', (goneTag || '').includes('文件缺失'), goneTag);
const goneColor = await goneCard.locator('.state .a-tag').first().evaluate((el) => getComputedStyle(el).color);
ok('缺失标签用琥珀 rgb(240,180,92)', goneColor === 'rgb(240, 180, 92)', goneColor);
ok('缺失标签用信号琥珀 rgb(163,145,95)', goneColor === 'rgb(163, 145, 95)', goneColor);
ok('缺失态给「用相同参数重做」', (await goneCard.getByRole('button', { name: '用相同参数重做' }).count()) === 1);
ok('缺失态给「移除记录」', (await goneCard.getByRole('button', { name: '移除记录' }).count()) === 1);
await goneCard.getByRole('button', { name: '用相同参数重做' }).click();
@@ -284,7 +289,7 @@ const run = async () => {
ok('任务页筛选芯片组(含失败/进行中)', (await chips.count()) === 5);
// 默认「全部」选中即金色文字(accent-soft 底)
const selChipColor = await chips.first().evaluate((el) => getComputedStyle(el).color);
ok('选中筛选芯片为金色文字', selChipColor === 'rgb(242, 199, 132)', selChipColor);
ok('选中筛选芯片为强调白文字', selChipColor === 'rgb(245, 244, 238)', selChipColor);
const failedChipColor = await chips.nth(3).evaluate((el) => getComputedStyle(el).color);
await chips.nth(3).click(); // 失败
await page.waitForTimeout(300);