Compare commits

...
Author SHA1 Message Date
总工 b4c7334f45 HEL-663: merge E-batch import & test-run drawers (feat/hel-655-e-drawers) 2026-09-30 15:17:19 +08:00
总工andmultica-agent 6064bd805d feat(ui): E批——工作流导入与试运行右侧抽屉 + 四步检查 (HEL-655)
- 新增 ui/Drawer.svelte 右侧抽屉基元:380/420px、--d-drawer 280ms
  --ease-drawer 可反向、Esc/Tab 焦点圈、遮罩点击关闭、减动效仅留 ≤120ms 淡变
- 新增 WorkflowImportDrawer.svelte:导入两步向导抽屉化,四步检查
  (解析 JSON→识别节点→核对模型→建立输入槽)逐条出现(80ms 错峰)、
  ✓绿/…青/✕红/待确认琥珀 + 中文原因 + 下一步建议 + 可展开错误 + 重新检查 +
  「查看原始 JSON」折叠;沿用 /api/workflows/analyze 原判定与保存边界
- WorkflowTestDialog.svelte 居中弹窗改右侧抽屉:运行中青芯片 + 进度条 +
  mono 阶段日志滚动区,结果缩略图横排、失败红 + 长错误可展开;
  逻辑(确认弹层、dry_run 拒绝、产物隔离、重复提交复用)不变
- connections 页:导入入口统一为抽屉按钮,次级文字操作(测试/重新检查/更改)
  改 ghost(HEL-649 v2 §1.2),连接卡片改 §3.7 网格;移除内联导入块与死样式
- i18n 七语种各 +16 键(wfd.*)计数一致
- e2e:fake_comfy.py 支持 object_info/no_outputs 场景;新增 import.mjs
  43 项浏览器回归全过;D 批 shoot.mjs 54/54 复跑通过

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 14:42:56 +08:00
总工 c3deaf90bb HEL-662: merge D-batch record stream (feat/hel-654-d-records) 2026-09-30 13:27:05 +08:00
总工andmultica-agent 949cd2ecad HEL-654: D 批——记录流状态语义、金框结果切换、撤销删除与任务页重皮
- 点选金框滑动 + 检视区 150ms 交叉淡变;↑↓/J/K 即时切换、空格播放、
  Delete 删除、] 检视区、浮层让位;新记录入场/完成金光/失败抖动,减动效只余短淡变
- 单条删除 5 秒撤销(Toast 金色动作钮),窗口过后才提交 DELETE,离页 flush;
  任务页同队列同步;工作流删除保留旧记录并标识「工作流已删除」
- 红/琥珀界线:失败红 + 中文结论展开;done 但文件取不到 → 琥珀「文件缺失」
  + 用相同参数重做/移除记录
- 参数页提交自动切结果页;工作流选择器 ↑↓ 真高亮 + Enter 使用(ShellPop arrowRoving 开关)
- 记录流 80 条分页(220 条专项:50 次轮询 490ms);任务页 §3.5 芯片+搜索+
  行内进度条+ghost 重试;i18n 七语种 +17 键

Co-authored-by: multica-agent <github@multica.ai>
2026-09-30 13:02:51 +08:00
32 changed files with 5016 additions and 1625 deletions
@@ -37,8 +37,13 @@
<span class="dot"></span>
<Icon name={kindIcon[toast.kind]} size={15} />
</span>
<span class="msg">{toast.message}</span>
<button
<span class="msg">{toast.message}</span>
{#if toast.action}
<button type="button" class="action" onclick={() => (toast.action?.run(), dismissToast(toast.id))}>
{toast.action.label}
</button>
{/if}
<button
type="button"
class="dismiss"
aria-label={t('toast.dismiss')}
@@ -104,6 +109,29 @@
color: var(--text-primary);
font-weight: 500;
}
/* §四.5: Toast 动作(如撤销)为金色文字 */
.action {
flex-shrink: 0;
align-self: center;
padding: var(--sp-1) var(--sp-2);
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--accent);
font: inherit;
font-size: var(--fs-aux);
font-weight: 600;
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.action:hover {
background: var(--accent-soft);
}
}
.action:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.dismiss {
display: inline-flex;
align-items: center;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@
import { progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf, specOf } from './takes';
@@ -15,6 +16,10 @@
* stage with its spec, inputs and workflow; 参数 hosts the full form.
* ≥1180 it is a column (hide with ]), 761–1179 a right overlay, ≤760
* fullscreen — `overlay` / `open` drive the last two.
* D 批 (HEL-654): pointer picks cross-fade the stage 150ms (键盘
* ↑↓/J/K 即时无动画), a finished/just-picked video auto-plays muted,
* failed takes get 重试, and a done take whose files no longer load
* shows the amber 文件缺失 state with 用相同参数重做 / 移除记录.
*/
interface Props {
tab: 'result' | 'params';
@@ -26,9 +31,16 @@
inactive: boolean;
/** Current workflow can take an image output (送入当前图片槽). */
canSendToSlot: boolean;
/** Animate this result change (pointer pick) — 150ms cross-fade. */
crossfade: boolean;
/** Bump to auto-play the stage video (pointer pick / completion). */
playSignal: number;
/** The stage <video> element, for the desk's space-bar toggle. */
videoEl?: HTMLVideoElement | null;
params: Snippet;
onClose: () => void;
onReuse: (job: Job) => void;
onRetry: (job: Job) => void;
onSendToSlot: (path: string) => void;
onDelete: (job: Job) => void;
}
@@ -41,9 +53,13 @@
open,
inactive,
canSendToSlot,
crossfade,
playSignal,
videoEl = $bindable(null),
params,
onClose,
onReuse,
onRetry,
onSendToSlot,
onDelete,
}: Props = $props();
@@ -55,9 +71,15 @@
});
const st = $derived(job ? jobStatusInfo(job) : null);
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
// §3.4 红/琥珀界线: amber only when the task SUCCEEDED but its files
// cannot be fetched — the failed case stays red.
const gonePaths = $derived(missingOutputsOf(job));
const fileGone = $derived(gonePaths.length > 0);
const outputs = $derived(job && st?.tone === 'ok' ? job.output_paths.filter((p) => !gonePaths.includes(p)) : []);
const output = $derived(outputs[Math.min(outIndex, outputs.length - 1)] ?? null);
const allOutputs = $derived(job && st?.tone === 'ok' ? job.output_paths : []);
const video = $derived(!!job && !!output && isVideoOutput(job, output));
const wfDeleted = $derived(!!job && job.workflow_id == null);
const prompt = $derived(job ? promptOf(job, wf) : '');
const inputs = $derived(job ? inputsOf(job, wf) : []);
const spec = $derived(job ? specOf(job, wf) : []);
@@ -84,6 +106,29 @@
const v = e.currentTarget as HTMLVideoElement;
dims = `${v.videoWidth}×${v.videoHeight}`;
}
// 完成或点选后自动播放(静音);键盘切换不触发。
$effect(() => {
void playSignal;
if (inactive || tab !== 'result' || !videoEl) return;
videoEl.muted = true;
videoEl.play().catch(() => {});
});
function reduced(): boolean {
return (
document.documentElement.dataset.reduce === '1' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
/** 150ms 交叉淡变 on pointer picks; keyboard switches stay instant. */
function xfade(_node: Element) {
const ms = crossfade ? (reduced() ? 120 : 150) : 0;
return { duration: ms, css: (t: number) => `opacity: ${t}` };
}
const mediaKey = $derived(`${job?.id ?? 'none'}:${output ?? st?.tone ?? ''}`);
</script>
<aside class="inspector" class:open inert={inactive} aria-label={t('desk.inspector')}>
@@ -105,31 +150,58 @@
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
{:else}
<div class="a-stage stage">
{#if output && video}
{#key output}
<!-- svelte-ignore a11y_media_has_caption -->
<video src={assetUrl(output)} controls playsinline preload="metadata" onloadedmetadata={noteVideo}></video>
{/key}
{:else if output}
<img src={assetUrl(output)} alt={prompt || t('desk.tabResult')} onload={noteImg} />
{:else}
<div class="stage-empty">
<span>{st.label}</span>
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
{#if progress > 0}
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
{#key mediaKey}
<div class="stage-media" in:xfade out:xfade>
{#if output && video}
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoEl}
src={assetUrl(output)}
controls
playsinline
preload="metadata"
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
{:else if output}
<img
src={assetUrl(output)}
alt={prompt || t('desk.tabResult')}
onload={(e) => {
clearOutputMissing(output);
noteImg(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
{:else if fileGone}
<div class="stage-empty gone">
<span>{t('desk.missingTitle')}</span>
<span class="sub">{t('desk.missingSub')}</span>
</div>
{:else}
<div class="stage-empty">
<span>{st.label}</span>
<span class="sub">{st.tone === 'fail' ? t('desk.notGenerated') : st.detail}</span>
{#if progress > 0}
<div class="a-progress stage-bar" style:--p={progress / 100}><i></i></div>
{/if}
</div>
{/if}
</div>
{/if}
{/key}
</div>
{#if outputs.length > 1}
{#if allOutputs.length > 1}
<div class="strip" role="group" aria-label={t('desk.outputs')}>
{#each outputs as p, i (p)}
{#each allOutputs as p, i (p)}
<button
type="button"
class="strip-item"
aria-pressed={i === outIndex}
class:gone={gonePaths.includes(p)}
aria-pressed={p === output}
aria-label={t('desk.outputN', { n: i + 1 })}
onclick={() => (outIndex = i)}
>
@@ -155,22 +227,32 @@
</div>
<div class="actions">
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
{#if output && !video && canSendToSlot}
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
{t('desk.sendToSlot')}
</button>
{/if}
{#if output}
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if job.status !== 'pending' && job.status !== 'running'}
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
{#if fileGone && !outputs.length}
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('desk.redo')}</button>
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('desk.removeRecord')}</button>
{:else}
<button type="button" class="a-btn secondary sm" onclick={() => onReuse(job)}>{t('desk.reuseToComposer')}</button>
{#if output && !video && canSendToSlot}
<button type="button" class="a-btn secondary sm" onclick={() => onSendToSlot(output)}>
{t('desk.sendToSlot')}
</button>
{/if}
{#if output}
<a class="a-btn secondary sm" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if st.tone === 'fail'}
<button type="button" class="a-btn secondary sm" onclick={() => onRetry(job)}>{t('common.retry')}</button>
{/if}
{#if job.status !== 'pending' && job.status !== 'running'}
<button type="button" class="a-btn danger sm" onclick={() => onDelete(job)}>{t('studio.deleteBtn')}</button>
{/if}
{/if}
</div>
{#if st.tone === 'fail'}
<div class="note err" role="alert">{st.detail}</div>
{:else if fileGone}
<div class="note warn" role="alert">{t('jobst.missingDetail')}</div>
{/if}
{#if inputs.length || prompt}
@@ -192,7 +274,10 @@
<div class="section-title">{t('desk.workflow')}</div>
<dl class="kv">
<dt>{t('desk.kvName')}</dt>
<dd>{wf?.name ?? t('studio.jobName', { id: job.id })}</dd>
<dd>
{wf?.name ?? t('studio.jobName', { id: job.id })}
{#if wfDeleted}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
</dd>
{#if wf?.usage}
<dt>{t('desk.kvUsage')}</dt>
<dd>{usageLabel(wf.usage)}</dd>
@@ -247,8 +332,14 @@
display: grid;
place-items: center;
}
.stage > img,
.stage > video {
.stage-media {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stage-media > img,
.stage-media > video {
position: absolute;
inset: 0;
width: 100%;
@@ -264,6 +355,9 @@
color: var(--text-3);
font-size: var(--fs-aux);
}
.stage-empty.gone {
color: var(--warn);
}
.stage-empty .sub {
color: var(--text-3);
font-size: var(--fs-mini);
@@ -293,6 +387,11 @@
.strip-item[aria-pressed='true'] {
border-color: var(--accent);
}
.strip-item.gone {
border-style: dashed;
border-color: var(--warn);
color: var(--warn);
}
.strip-item img {
width: 100%;
height: 100%;
@@ -326,6 +425,10 @@
background: var(--err-soft);
color: var(--err);
}
.note.warn {
background: var(--warn-soft);
color: var(--warn);
}
.section-title {
margin: var(--sp-7) 0 var(--sp-3);
font-size: var(--fs-aux);
@@ -376,6 +479,9 @@
color: var(--text-2);
word-break: break-all;
}
.kv .wf-gone {
margin-left: var(--sp-2);
}
/* ≥1180: a column of the desk grid; the close button hides it (]) */
@media (max-width: 1179px) {
@@ -5,6 +5,7 @@
import { progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { parseDbTs } from '$lib/dbtime';
import { clearOutputMissing, markOutputMissing, missingOutputsOf } from '$lib/missingOutputs.svelte';
import { t } from '$lib/i18n.svelte';
import { usageLabel } from './schema';
import { elapsedSec, fmtClock, fmtDur, inputsOf, isVideoOutput, promptOf } from './takes';
@@ -13,6 +14,12 @@
* 一条记录 (spec-v2 §3.3 记录卡): workflow · usage · time, the prompt, the
* inputs it used, its real state (phase / progress / time / error) and
* the first output. The whole card selects; actions sit above it.
* D 批 (HEL-654): the gold sel-mark frame slides between cards on
* pointer selection (driven by the feed), new takes enter with a
* 220ms rise, completion flashes once, failure shakes once, a done
* take whose files no longer load turns amber (文件缺失) with
* 用相同参数重做 / 移除记录, and takes on a deleted workflow are
* marked as such instead of pretending nothing changed.
*/
interface Props {
job: Job;
@@ -20,6 +27,12 @@
selected: boolean;
nowMs: number;
busy?: boolean;
/** Play the 220ms entrance rise (new take / undo restore). */
enter?: boolean;
/** One-shot 900ms gold flash on completion. */
flash?: boolean;
/** One-shot failure shake. */
shake?: boolean;
onSelect: () => void;
onReuse: () => void;
onRetry: () => void;
@@ -27,13 +40,37 @@
onDelete: () => void;
}
let { job, wf, selected, nowMs, busy = false, onSelect, onReuse, onRetry, onCancel, onDelete }: Props = $props();
let {
job,
wf,
selected,
nowMs,
busy = false,
enter = false,
flash = false,
shake = false,
onSelect,
onReuse,
onRetry,
onCancel,
onDelete,
}: Props = $props();
const st = $derived(jobStatusInfo(job));
const tagTone = $derived(st.tone === 'queue' ? '' : st.tone === 'fail' ? 'err' : st.tone);
// §3.4 红/琥珀界线: red follows the backend task status only. Amber is
// reserved for a SUCCEEDED take whose product files no longer load.
const gonePaths = $derived(missingOutputsOf(job));
const fileGone = $derived(gonePaths.length > 0);
const tone = $derived(fileGone ? 'warn' : st.tone === 'queue' ? '' : st.tone === 'fail' ? 'err' : st.tone);
const label = $derived(fileGone ? t('jobst.missing') : st.label);
const wfDeleted = $derived(job.workflow_id == null);
const prompt = $derived(promptOf(job, wf));
const inputs = $derived(inputsOf(job, wf).filter((i) => i.kind === 'image'));
const output = $derived(st.tone === 'ok' ? (job.output_paths[0] ?? null) : null);
const output = $derived(
fileGone
? (job.output_paths.find((p) => !gonePaths.includes(p)) ?? null)
: (job.output_paths[0] ?? null),
);
const progress = $derived.by(() => {
if (job.status !== 'running') return 0;
const live = progressFor(job.id);
@@ -46,11 +83,13 @@
let portrait = $state(false);
let clipSec = $state<number | null>(null);
let errOpen = $state(false);
function noteImage(e: Event) {
const img = e.currentTarget as HTMLImageElement;
portrait = img.naturalHeight > img.naturalWidth;
}
function noteVideo(e: Event) {
const v = e.currentTarget as HTMLVideoElement;
portrait = v.videoHeight > v.videoWidth;
@@ -58,17 +97,26 @@
}
</script>
<article class="take" class:selected data-job={job.id}>
<article
class="take"
class:selected
class:enter
class:done-flash={flash}
class:shake
data-job={job.id}
>
<span class="sel-mark" aria-hidden="true"></span>
<button
type="button"
class="hit"
aria-pressed={selected}
aria-label={`${name} · ${st.label}`}
aria-label={`${name} · ${label}`}
onclick={onSelect}
></button>
<div class="head">
<span class="name">{name}</span>
{#if wfDeleted}<span class="a-tag">{t('studio.wfDeletedTag')}</span>{/if}
{#if wf?.usage}<span class="a-tag">{usageLabel(wf.usage)}</span>{/if}
<span class="meta mono">{fmtClock(parseDbTs(job.created_at))}</span>
</div>
@@ -85,32 +133,50 @@
{/if}
<div class="state">
<span class="a-tag {tagTone}">{st.label}</span>
<span class="a-tag {tone}">{label}</span>
{#if st.tone === 'run'}
{#if progress > 0}
<div class="a-progress bar" style:--p={progress / 100}><i></i></div>
{/if}
<span class="txt">{fmtDur(elapsedSec(job, nowMs))}</span>
{:else if st.tone === 'ok'}
{:else if st.tone === 'ok' && !fileGone}
<span class="txt">{t('desk.took', { d: fmtDur(elapsedSec(job, nowMs)) })}</span>
{:else if st.tone === 'fail'}
<span class="err-txt" title={st.detail}>{st.detail}</span>
<button
type="button"
class="err-txt"
class:open={errOpen}
title={st.detail}
aria-expanded={errOpen}
onclick={() => (errOpen = !errOpen)}
>
{st.detail}
</button>
{:else if fileGone}
<span class="gone-txt">{t('jobst.missingDetail')}</span>
{:else}
<span class="txt">{st.detail}</span>
{/if}
<div class="actions">
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
{#if st.tone === 'fail' && job.error !== 'cancelled'}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
{/if}
{#if active}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
{/if}
{#if output}
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if !active}
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
{#if fileGone}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>
{t('desk.redo')}
</button>
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('desk.removeRecord')}</button>
{:else}
<button type="button" class="a-btn ghost xs" onclick={onReuse}>{t('desk.reuse')}</button>
{#if st.tone === 'fail' && job.error !== 'cancelled'}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onRetry}>{t('common.retry')}</button>
{/if}
{#if active}
<button type="button" class="a-btn ghost xs" disabled={busy} onclick={onCancel}>{t('common.cancel')}</button>
{/if}
{#if output}
<a class="a-btn ghost xs" href={assetUrl(output)} download>{t('desk.download')}</a>
{/if}
{#if !active}
<button type="button" class="a-btn danger xs" onclick={onDelete}>{t('studio.deleteBtn')}</button>
{/if}
{/if}
</div>
</div>
@@ -119,16 +185,37 @@
{#if output}
<div class="thumb" class:portrait>
{#if isVideoOutput(job, output)}
<video src={assetUrl(output)} preload="metadata" muted playsinline onloadedmetadata={noteVideo}></video>
<video
src={assetUrl(output)}
preload="metadata"
muted
playsinline
onloadedmetadata={(e) => {
clearOutputMissing(output);
noteVideo(e);
}}
onerror={() => output && markOutputMissing(output)}
></video>
<span class="play" aria-hidden="true"><Icon name="play" size={10} /></span>
{#if clipSec != null}<span class="badge mono">{fmtDur(clipSec)}</span>{/if}
{:else}
<img src={assetUrl(output)} alt="" loading="lazy" onload={noteImage} />
<img
src={assetUrl(output)}
alt=""
loading="lazy"
onload={(e) => {
clearOutputMissing(output);
noteImage(e);
}}
onerror={() => output && markOutputMissing(output)}
/>
{/if}
{#if job.output_paths.length > 1}<span class="count mono">×{job.output_paths.length}</span>{/if}
</div>
{:else if active}
<div class="thumb pending">{st.label}</div>
{:else if fileGone}
<div class="thumb gone">{t('jobst.missing')}</div>
{:else}
<div class="thumb missing">{t('desk.notGenerated')}</div>
{/if}
@@ -151,17 +238,75 @@
background-color var(--d-state) ease,
box-shadow var(--d-state) ease;
}
/* 选中框:金色描边从上一条记录滑到新选中的记录(feed 驱动 WAAPI) */
.sel-mark {
position: absolute;
inset: -1px;
z-index: 3;
display: none;
border: 1px solid var(--accent-line);
border-radius: inherit;
pointer-events: none;
}
.take.selected {
border-color: var(--accent-line);
border-color: transparent;
background: var(--accent-wash), var(--bg-3);
box-shadow: var(--sel-shadow);
}
.take.selected > .sel-mark {
display: block;
}
@media (hover: hover) and (pointer: fine) {
.take:hover:not(.selected) {
border-color: var(--line-2);
background: var(--bg-3);
}
}
/* 新记录 / 撤销恢复:220ms 上移 8px 入场(减动效只余淡变) */
.take.enter {
animation: take-in var(--d-enter) var(--ease-out);
}
@keyframes take-in {
from {
opacity: 0;
transform: translateY(8px);
}
}
/* 完成一次性金色光晕 900ms;失败原地轻抖一次 */
.take.done-flash {
animation: done-flash var(--d-flash) var(--ease-out);
}
@keyframes done-flash {
from {
box-shadow: var(--accent-glow), 0 0 0 1px var(--accent-line);
}
}
.take.shake {
animation: take-shake 320ms var(--ease-out);
}
@keyframes take-shake {
20% {
transform: translateX(-4px);
}
45% {
transform: translateX(4px);
}
70% {
transform: translateX(-2px);
}
}
:global(:root[data-reduce='1']) .take.enter {
animation: take-fade var(--d-state) ease;
}
:global(:root[data-reduce='1']) .take.done-flash,
:global(:root[data-reduce='1']) .take.shake {
animation: none;
}
@keyframes take-fade {
from {
opacity: 0;
}
}
.hit {
position: absolute;
inset: 0;
@@ -242,13 +387,39 @@
color: var(--text-2);
white-space: nowrap;
}
.err-txt {
.gone-txt {
min-width: 0;
color: var(--err);
color: var(--warn);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 失败:一行中文结论自动展开,点开看完整详情 */
.err-txt {
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--err);
font: inherit;
font-size: var(--fs-aux);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: auto;
cursor: pointer;
}
.err-txt.open {
white-space: normal;
word-break: break-word;
}
.err-txt:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--r-sm);
}
.actions {
display: flex;
gap: var(--sp-1);
@@ -329,7 +500,8 @@
color: var(--on-media);
}
.thumb.pending,
.thumb.missing {
.thumb.missing,
.thumb.gone {
display: grid;
place-items: center;
padding: var(--sp-2);
@@ -345,6 +517,10 @@
border-style: solid;
color: var(--run);
}
.thumb.gone {
color: var(--warn);
border-color: var(--warn);
}
.thumb.pending::after {
content: '';
position: absolute;
@@ -1,5 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { SvelteSet } from 'svelte/reactivity';
import Icon from '$lib/components/ui/Icon.svelte';
import type { Job, Workflow } from '$lib/comfy/types';
import { parseDbTs } from '$lib/dbtime';
@@ -10,6 +12,12 @@
/**
* 记录流 (spec-v2 §3.3): every desk take, newest last above the composer,
* filtered 全部 / 图片 / 视频 / 失败 and grouped by day.
* D 批 (HEL-654): rows pending their 5-second undo collapse out
* (200ms) instead of vanishing, new/restored takes rise in (220ms),
* the gold selection frame slides between cards on pointer picks
* (键盘 ↑↓/J/K 即时切换无动画 — driven by the desk through the
* exported helpers), and long histories page in from the top
* (spec §五: 记录流超 200 条分页).
*/
interface Props {
jobs: Job[];
@@ -19,6 +27,13 @@
nowMs: number;
busyId: number | null;
loading: boolean;
/** Takes whose delete is inside the 5s undo window — hidden. */
pendingIds: SvelteSet<number>;
/** One-shot completion flashes / failure shakes (driven by SSE). */
flashIds: SvelteSet<number>;
shakeIds: SvelteSet<number>;
/** Ordered visible ids (oldest first) for ↑↓/J/K. */
orderedIds?: number[];
/** Shown under the filters (e.g. the workbench context bar). */
banner?: Snippet;
/** Inspector toggle when it is hidden (desktop) or an overlay (mid). */
@@ -38,6 +53,10 @@
nowMs,
busyId,
loading,
pendingIds,
flashIds,
shakeIds,
orderedIds = $bindable(),
banner,
inspectorToggle,
onSelect,
@@ -54,16 +73,27 @@
{ id: 'failed', key: 'desk.filterFailed' },
];
/** §五 分页: render the newest PAGE_SIZE takes; older ones load on demand. */
const PAGE_SIZE = 80;
let limit = $state(PAGE_SIZE);
let scroller = $state<HTMLElement | null>(null);
/** Oldest first so the newest take sits right above the composer. */
const shown = $derived(
jobs.filter((j) => matchesFilter(j, filter)).sort((a, b) => parseDbTs(a.created_at) - parseDbTs(b.created_at)),
jobs
.filter((j) => matchesFilter(j, filter) && !pendingIds.has(j.id))
.sort((a, b) => parseDbTs(a.created_at) - parseDbTs(b.created_at)),
);
const hiddenEarlier = $derived(Math.max(0, shown.length - limit));
const visible = $derived(hiddenEarlier > 0 ? shown.slice(-limit) : shown);
$effect(() => {
orderedIds = visible.map((j) => j.id);
});
const activeCount = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
const days = $derived.by(() => {
const out: Array<{ label: string; items: Job[] }> = [];
for (const job of shown) {
for (const job of visible) {
const label = dayLabel(parseDbTs(job.created_at), nowMs);
const last = out[out.length - 1];
if (last?.label === label) last.items.push(job);
@@ -79,12 +109,108 @@
pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80;
}
$effect(() => {
void shown.length;
void visible.length;
void filter;
if (!scroller || !pinned) return;
const el = scroller;
requestAnimationFrame(() => (el.scrollTop = el.scrollHeight));
});
// ------------------------------------------------------------------
// Entrance bookkeeping: only takes that appear AFTER the first fill
// (or come back from an undo) play the 220ms rise.
// ------------------------------------------------------------------
const seenIds = new Set<number>();
const animateIds = new SvelteSet<number>();
let hydrated = false;
$effect(() => {
if (!hydrated) {
if (jobs.length) {
for (const j of jobs) seenIds.add(j.id);
hydrated = true;
}
return;
}
for (const j of jobs) {
if (seenIds.has(j.id)) continue;
seenIds.add(j.id);
animateIds.add(j.id);
}
});
export function markEnter(jobId: number) {
seenIds.add(jobId);
animateIds.add(jobId);
}
function reduced(): boolean {
return (
document.documentElement.dataset.reduce === '1' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
// ------------------------------------------------------------------
// Gold selection frame (HEL-648 slideMark): pointer picks only.
// ------------------------------------------------------------------
export function markRect(): DOMRect | null {
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
return el instanceof HTMLElement ? el.getBoundingClientRect() : null;
}
export function slideFrom(fromRect: DOMRect | null) {
if (!fromRect || reduced()) return;
const el = scroller?.querySelector(`.take[data-job="${selectedId}"]`);
const mark = el?.querySelector('.sel-mark');
if (!(el instanceof HTMLElement) || !(mark instanceof HTMLElement)) return;
const to = el.getBoundingClientRect();
if (Math.abs(fromRect.top - to.top) < 1 && Math.abs(fromRect.height - to.height) < 1) return;
mark.animate(
[
{ top: `${fromRect.top - to.top - 1}px`, bottom: `${to.bottom - fromRect.bottom - 1}px` },
{ top: '-1px', bottom: '-1px' },
],
{ duration: 220, easing: 'cubic-bezier(0.23, 1, 0.32, 1)' },
);
}
/** Bring a keyboard-selected take into view without animation. */
export function scrollToJob(jobId: number) {
const el = scroller?.querySelector(`.take[data-job="${jobId}"]`);
el?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
// ------------------------------------------------------------------
// Row transitions: undo-pending rows collapse out (200ms 收起 + 补位),
// new/restored takes rise in (220ms). Everything else is instant.
// ------------------------------------------------------------------
function takeEnter(_node: HTMLElement, should: () => boolean) {
if (!should()) return { duration: 0 };
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
return {
duration: 220,
easing: cubicOut,
css: (t: number) => `opacity: ${t}; transform: translateY(${8 * (1 - t)}px)`,
};
}
function takeLeave(node: HTMLElement, isPending: () => boolean) {
if (!isPending()) return { duration: 0 };
if (reduced()) return { duration: 120, css: (t: number) => `opacity: ${t}` };
const h = node.offsetHeight;
const cs = getComputedStyle(node);
const num = (v: string) => parseFloat(v) || 0;
const padT = num(cs.paddingTop);
const padB = num(cs.paddingBottom);
const mb = num(cs.marginBottom);
return {
duration: 200,
easing: cubicOut,
css: (t: number, u: number) =>
`height:${h * t}px;padding-top:${padT * t}px;padding-bottom:${padB * t}px;` +
`margin-bottom:${mb * t}px;opacity:${t};transform:translateX(${-10 * u}px);overflow:hidden;`,
};
}
</script>
<div class="filters">
@@ -109,21 +235,36 @@
<p>{filter === 'failed' ? t('desk.emptyFailed') : t('desk.emptyFeed')}</p>
</div>
{:else}
{#if hiddenEarlier > 0}
<button type="button" class="a-btn ghost sm earlier" onclick={() => (limit += PAGE_SIZE)}>
<Icon name="chevron-up" size={13} />
{t('desk.loadEarlier', { n: hiddenEarlier })}
</button>
{/if}
{#each days as day (day.label)}
<div class="day">{day.label}</div>
{#each day.items as job (job.id)}
<TakeCard
{job}
wf={workflowOf(job)}
selected={job.id === selectedId}
{nowMs}
busy={busyId === job.id}
onSelect={() => onSelect(job)}
onReuse={() => onReuse(job)}
onRetry={() => onRetry(job)}
onCancel={() => onCancel(job)}
onDelete={() => onDelete(job)}
/>
<div
class="take-slot"
in:takeEnter={() => animateIds.has(job.id)}
out:takeLeave={() => pendingIds.has(job.id)}
>
<TakeCard
{job}
wf={workflowOf(job)}
selected={job.id === selectedId}
{nowMs}
busy={busyId === job.id}
enter={animateIds.has(job.id)}
flash={flashIds.has(job.id)}
shake={shakeIds.has(job.id)}
onSelect={() => onSelect(job)}
onReuse={() => onReuse(job)}
onRetry={() => onRetry(job)}
onCancel={() => onCancel(job)}
onDelete={() => onDelete(job)}
/>
</div>
{/each}
{/each}
{/if}
@@ -149,6 +290,10 @@
overflow-y: auto;
padding: var(--sp-6) var(--sp-7) var(--sp-3);
}
.earlier {
display: flex;
margin: 0 auto var(--sp-4);
}
.day {
display: flex;
align-items: center;
@@ -10,6 +10,9 @@
* 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[];
@@ -22,6 +25,7 @@
let open = $state(false);
let query = $state('');
let activeId = $state<number | null>(null);
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
@@ -59,17 +63,46 @@
})).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 onSearchKey(e: KeyboardEvent) {
if (e.key !== 'Enter') return;
e.preventDefault();
const first = groups[0]?.items[0];
if (first) pick(first.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>
@@ -86,63 +119,80 @@
<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" onclose={() => (query = '')}>
<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')}
onkeydown={onSearchKey}
/>
</div>
<div class="list">
{#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'}
aria-current={cur ? 'true' : undefined}
onclick={() => pick(r.wf)}
>
<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>
<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>
<span class="a-tag {r.status.tone}">{r.status.label}</span>
</button>
<span class="a-tag {r.status.tone}">{r.status.label}</span>
</button>
{/each}
{:else}
<p class="empty">{t('desk.wfNoMatch')}</p>
{/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 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>
<style>
.picker {
position: relative;
min-width: 0;
}
.pop-body {
display: flex;
flex-direction: column;
}
.search {
display: flex;
align-items: center;
@@ -173,6 +223,10 @@
.row {
align-items: center;
}
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
.row.active {
background: var(--bg-4);
}
.main {
display: grid;
gap: 2px;
@@ -0,0 +1,120 @@
import { SvelteSet } from 'svelte/reactivity';
import type { QueryClient } from '@tanstack/svelte-query';
import { apiErrorMessage, playgroundApi } from '$lib/api';
import type { Job } from '$lib/comfy/types';
import { showToast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
/**
* 撤销删除 (spec-v2 §四.4 删除两级制 · HEL-654 D 批): deleting a single
* take/task never opens a dialog. The row leaves the view right away, a
* 5-second Toast offers 撤销, and the backend DELETE is only sent when
* that window closes without an undo — the backend removes result files,
* so an undo after the call would be impossible. Leaving the page (SPA
* route change or hard unload) flushes pending deletes instead of
* dropping them.
*/
export interface TakeDeleteHandlers {
/** Localized toast message for this take. */
message: (job: Job) => string;
/** Undo: the row comes back — reselect / replay its entrance here. */
onUndo?: (job: Job) => void;
/** Called after the delete really hit the backend. */
onCommitted?: (job: Job) => void;
}
export interface TakeDeleteQueue {
/** Ids whose rows should be hidden (commit pending). */
readonly pending: SvelteSet<number>;
request: (job: Job, handlers: TakeDeleteHandlers) => void;
destroy: () => void;
}
const UNDO_MS = 5000;
export function createTakeDeleteQueue(client: QueryClient): TakeDeleteQueue {
const pending = new SvelteSet<number>();
const entries = new Map<
number,
{
job: Job;
handlers: TakeDeleteHandlers;
timer: ReturnType<typeof setTimeout>;
}
>();
let destroyed = false;
async function invalidate() {
await Promise.allSettled([
client.invalidateQueries({ queryKey: ['playground-jobs'] }),
client.invalidateQueries({ queryKey: ['jobs'] }),
]);
}
async function commit(id: number) {
const entry = entries.get(id);
if (!entry) return;
entries.delete(id);
try {
await playgroundApi.deleteJob(id);
pending.delete(id);
entry.handlers.onCommitted?.(entry.job);
} catch (err) {
// Delete failed: the take stays — bring the row back honestly.
pending.delete(id);
showToast(
t('studio.deleteFailedToast', { detail: apiErrorMessage(err) }),
'error',
);
}
await invalidate();
}
/** Hard unload (tab close / external nav): fire-and-forget with keepalive. */
function onPageHide() {
for (const [id] of entries) {
void fetch(`/api/playground/jobs/${id}`, {
method: 'DELETE',
keepalive: true,
});
}
}
function destroy() {
if (destroyed) return;
destroyed = true;
if (inBrowser) window.removeEventListener('pagehide', onPageHide);
// SPA route change: the JS context survives, normal commits are fine.
for (const [id] of entries) {
const entry = entries.get(id)!;
clearTimeout(entry.timer);
void commit(id);
}
}
const inBrowser = typeof window !== 'undefined';
if (inBrowser) window.addEventListener('pagehide', onPageHide);
return {
pending,
request(job, handlers) {
if (destroyed || pending.has(job.id)) return;
pending.add(job.id);
const timer = setTimeout(() => void commit(job.id), UNDO_MS);
entries.set(job.id, { job, handlers, timer });
showToast(handlers.message(job), 'info', UNDO_MS, {
label: t('toast.undo'),
run: () => {
const e = entries.get(job.id);
if (!e) return;
clearTimeout(e.timer);
entries.delete(job.id);
pending.delete(job.id);
handlers.onUndo?.(job);
},
});
},
destroy,
};
}
@@ -15,6 +15,8 @@
/** `top` opens above the trigger (triggers near the bottom edge, e.g. the composer). */
placement?: 'bottom' | 'top';
width?: string;
/** Set false when the content runs its own ↑↓ roving selection. */
arrowRoving?: boolean;
onclose?: () => void;
children: Snippet;
}
@@ -25,6 +27,7 @@
align = 'start',
placement = 'bottom',
width = '300px',
arrowRoving = true,
onclose,
children,
}: Props = $props();
@@ -55,7 +58,7 @@
const current = document.activeElement as HTMLElement | null;
const index = current ? list.indexOf(current) : -1;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (current?.tagName === 'SELECT') return;
if (!arrowRoving || current?.tagName === 'SELECT') return;
event.preventDefault();
const step = event.key === 'ArrowDown' ? 1 : -1;
const next = index < 0 ? (step > 0 ? 0 : list.length - 1) : index + step;
@@ -2,8 +2,8 @@
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toStore } from 'svelte/store';
import { tick, untrack } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { page } from '$app/state';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import AppShell from '$lib/components/shell/AppShell.svelte';
@@ -12,6 +12,7 @@
import Inspector from '$lib/components/generate/Inspector.svelte';
import ParamsPanel from '$lib/components/generate/ParamsPanel.svelte';
import TakeFeed from '$lib/components/generate/TakeFeed.svelte';
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
import { readDeskContext } from '$lib/components/generate/context';
import {
changedParams,
@@ -90,6 +91,22 @@
// jobs list refetch knows about them.
const submittedIds = new Set<number>();
// D 批: one-shot completion flash / failure shake (SSE-driven) and the
// 5-second undo window for record deletes.
const flashIds = new SvelteSet<number>();
const shakeIds = new SvelteSet<number>();
const deleteQueue = createTakeDeleteQueue(client);
const pendingIds = deleteQueue.pending;
$effect(() => deleteQueue.destroy);
function flashTake(id: number, ms: number, set: SvelteSet<number>) {
set.add(id);
setTimeout(() => set.delete(id), ms);
}
let feed = $state<TakeFeed | null>(null);
let orderedIds = $state<number[]>([]);
$effect(() => {
const off = connectEvents((ev) => {
handleJobEvent(ev);
@@ -100,6 +117,13 @@
if (submittedIds.has(id) || (known && known.status !== 'failed')) {
toast.error(t('studio.jobFailedToast', { detail: String(ev.data.error ?? '') }));
}
flashTake(id, 400, shakeIds);
}
if (ev.type === 'job.completed') {
const id = Number(ev.data.job_id);
flashTake(id, 950, flashIds);
// 完成:检视区若正选中该条且为视频,自动播放。
if (selectedJobId === id) playSignal += 1;
}
if (ev.type === 'job.completed' && submittedIds.has(Number(ev.data.job_id)) && ev.data.dry_run) {
toast.info(t('studio.dryRunToast'));
@@ -342,6 +366,10 @@
sent = true;
if (sentTimer) clearTimeout(sentTimer);
sentTimer = setTimeout(() => (sent = false), 900);
// C 批审核:从「参数」页提交后自动切到「结果」页,进行中进度可见。
tab = 'result';
if (wide) inspOff = false;
else if (!narrow) inspOpen = true;
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
@@ -361,13 +389,25 @@
let inspOff = $state(false);
let inspOpen = $state(false);
let wide = $state(true);
let narrow = $state(false);
let crossfade = $state(false);
let playSignal = $state(0);
let videoEl = $state<HTMLVideoElement | null>(null);
$effect(() => {
const mq = window.matchMedia('(min-width: 1180px)');
const sync = () => (wide = mq.matches);
const mq2 = window.matchMedia('(max-width: 760px)');
const sync = () => {
wide = mq.matches;
narrow = mq2.matches;
};
sync();
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
mq2.addEventListener('change', sync);
return () => {
mq.removeEventListener('change', sync);
mq2.removeEventListener('change', sync);
};
});
let nowMs = $state(Date.now());
@@ -378,19 +418,40 @@
return () => clearInterval(id);
});
/** Picked take, else the newest finished one in view. */
/** Picked take, else the newest finished one in view (undo-pending rows don't count). */
const selectedJob = $derived(
allJobs.find((j) => j.id === selectedJobId) ??
allJobs.find((j) => j.id === selectedJobId && !pendingIds.has(j.id)) ??
allJobs
.filter((j) => matchesFilter(j, filter) && jobStatusInfo(j).tone === 'ok')
.filter((j) => matchesFilter(j, filter) && !pendingIds.has(j.id) && jobStatusInfo(j).tone === 'ok')
.sort((a, b) => parseDbTs(b.created_at) - parseDbTs(a.created_at))[0] ??
null,
);
function selectTake(job: Job) {
/**
* 结果切换 (HEL-648): pointer picks slide the gold frame and cross-fade
* the inspector; keyboard ↑↓/J/K switch instantly with no animation.
*/
function selectTake(job: Job, via: 'pointer' | 'keyboard' = 'pointer') {
const from = via === 'pointer' ? (feed?.markRect() ?? null) : null;
selectedJobId = job.id;
tab = 'result';
if (!wide) inspOpen = true;
crossfade = via === 'pointer';
if (via === 'pointer') {
playSignal += 1;
if (!wide) inspOpen = true;
void tick().then(() => feed?.slideFrom(from));
} else {
feed?.scrollToJob(job.id);
}
}
function moveSelection(delta: number) {
if (!orderedIds.length) return;
let i = orderedIds.indexOf(selectedJob?.id ?? -1);
if (i < 0) i = orderedIds.length - 1;
i = Math.max(0, Math.min(orderedIds.length - 1, i + delta));
const job = allJobs.find((j) => j.id === orderedIds[i]);
if (job) selectTake(job, 'keyboard');
}
function openParams() {
@@ -460,7 +521,7 @@
toast.success(t('studio.retryQueuedToast'));
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
} catch (err) {
toast.error(t('studio.retryFailedToast', { detail: err instanceof Error ? err.message : String(err) }));
toast.error(t('studio.retryFailedToast', { detail: apiErrorMessage(err) }));
} finally {
busyId = null;
}
@@ -474,49 +535,39 @@
toast.info(t('studio.cancelToast'));
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
toast.error(apiErrorMessage(err));
} finally {
busyId = null;
}
}
let deleteOpen = $state(false);
let deleteJob = $state<Job | null>(null);
// The dialog names the exact take and — when it produced several
// outputs — says plainly that ALL of them go away with it (the
// backend only deletes whole jobs, never a single output file).
const deleteTitle = $derived(
deleteJob
? t('studio.delTitleJob', { name: workflowOf(deleteJob)?.name ?? t('studio.jobName', { id: deleteJob.id }) })
: t('studio.delTitle'),
);
const deleteMessage = $derived(
(deleteJob?.output_paths?.length ?? 0) > 1
? t('studio.delConfirmMulti', { n: deleteJob!.output_paths.length })
: t('studio.delConfirm'),
);
function askDelete(job: Job) {
deleteJob = job;
deleteOpen = true;
}
async function doDelete() {
const job = deleteJob;
if (!job) return;
try {
await playgroundApi.deleteJob(job.id);
submittedIds.delete(job.id);
if (selectedJobId === job.id) selectedJobId = null;
toast.success(t('studio.deletedToast'));
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
deleteJob = null;
deleteOpen = false;
}
/**
* 删除整条任务 (§四.4 删除两级制): no dialog — the row collapses out,
* a 5-second Toast offers 撤销 (row returns with its entrance and is
* reselected), and only then does the DELETE really hit the backend.
*/
function requestDelete(job: Job) {
if (job.status === 'pending' || job.status === 'running') return;
const name = workflowOf(job)?.name ?? t('studio.jobName', { id: job.id });
deleteQueue.request(job, {
message: () =>
(job.output_paths?.length ?? 0) > 0
? t('studio.deletedTakeToast', { name })
: t('studio.deletedTakeNoFilesToast', { name }),
onUndo: (j) => {
feed?.markEnter(j.id);
const from = feed?.markRect() ?? null;
selectedJobId = j.id;
void tick().then(() => {
feed?.slideFrom(from);
feed?.scrollToJob(j.id);
});
},
onCommitted: (j) => {
submittedIds.delete(j.id);
if (selectedJobId === j.id) selectedJobId = null;
},
});
}
// ------------------------------------------------------------------
@@ -543,27 +594,60 @@
}
// ------------------------------------------------------------------
// Keyboard: Ctrl/⌘+Enter 生成 · / 聚焦提示词 · ] 检视区 · Esc 关浮层
// 键盘 (§四.8): Ctrl/⌘+Enter 生成 · / 聚焦提示词 · ↑↓/J/K 切换记录
// (即时无动画)· 空格播放/暂停 · Delete 删选中 · ] 检视区 · Esc 关浮层
// ------------------------------------------------------------------
function typing(target: EventTarget | null): boolean {
const el = target as HTMLElement | null;
return !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName));
}
/** Focus sits on something that owns the key (button, link, video…). */
function onInteractive(target: EventTarget | null): boolean {
const el = target instanceof Element ? target : null;
return !!el?.closest('button, a, input, textarea, select, label, video, audio, [contenteditable]');
}
function inLayer(target: EventTarget | null): boolean {
const el = target instanceof Element ? target : null;
return !!el?.closest('.shell-pop, .modal, [role="dialog"]');
}
function onKeydown(e: KeyboardEvent) {
if (e.defaultPrevented || deleteOpen || pickerFor) return;
if (e.defaultPrevented || pickerFor || inLayer(e.target)) return;
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void submit();
return;
}
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key === '/' && !typing(e.target)) {
const key = e.key.toLowerCase();
if (key === '/' && !typing(e.target)) {
e.preventDefault();
promptEl?.focus();
} else if ((e.key === 'ArrowDown' || key === 'j') && !typing(e.target)) {
e.preventDefault();
moveSelection(1);
} else if ((e.key === 'ArrowUp' || key === 'k') && !typing(e.target)) {
e.preventDefault();
moveSelection(-1);
} else if (e.key === ' ') {
// 空格播放/暂停:检视区可见且有视频时接管;焦点在控件上时走原生行为。
if (typing(e.target) || onInteractive(e.target)) return;
if (videoEl && (wide ? !inspOff : inspOpen)) {
e.preventDefault();
if (videoEl.paused) videoEl.play().catch(() => {});
else videoEl.pause();
}
} else if (e.key === ']' && !typing(e.target)) {
e.preventDefault();
toggleInspector();
} else if ((e.key === 'Delete' || e.key === 'Backspace') && !typing(e.target)) {
const job = selectedJob;
if (job && job.status !== 'pending' && job.status !== 'running') {
e.preventDefault();
requestDelete(job);
}
} else if (e.key === 'Escape' && !wide && inspOpen) {
inspOpen = false;
}
@@ -604,6 +688,7 @@
<div class="desk" class:insp-off={inspOff}>
<section class="center" aria-label={t('desk.title')}>
<TakeFeed
bind:this={feed}
jobs={allJobs}
{workflowOf}
bind:filter
@@ -611,13 +696,17 @@
{nowMs}
{busyId}
loading={$jobsQuery.isLoading}
{pendingIds}
{flashIds}
{shakeIds}
bind:orderedIds
banner={ctxBanner}
{inspectorToggle}
onSelect={selectTake}
onSelect={(job) => selectTake(job, 'pointer')}
onReuse={reuse}
onRetry={retry}
onCancel={cancel}
onDelete={askDelete}
onDelete={requestDelete}
/>
{#if !$workflowsQuery.isLoading && !deskWorkflows.length}
<div class="desk-empty" role="note">
@@ -662,11 +751,15 @@
open={inspOpen}
inactive={wide ? inspOff : !inspOpen}
canSendToSlot={!!imageSlotTarget || !!imageList}
{crossfade}
{playSignal}
bind:videoEl
params={paramsTab}
onClose={closeInspector}
onReuse={reuse}
onRetry={retry}
onSendToSlot={sendToSlot}
onDelete={askDelete}
onDelete={requestDelete}
/>
</div>
</AppShell>
@@ -685,20 +778,6 @@
{/if}
</Modal>
<ConfirmDialog
bind:open={deleteOpen}
title={deleteTitle}
message={deleteMessage}
confirmLabel={t('studio.deleteBtn')}
cancelLabel={t('common.cancel')}
danger
onconfirm={doDelete}
oncancel={() => {
deleteJob = null;
deleteOpen = false;
}}
/>
<style>
.desk {
display: grid;
@@ -0,0 +1,256 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';
import { t } from '$lib/i18n.svelte';
/**
* Drawer — the shared right-side floating layer (HEL-649 §四.3):
* slides in over 280ms with --ease-drawer and reverses mid-flight,
* focus is trapped while open, Esc / backdrop click close it, and
* reduced-motion keeps only a ≤120ms opacity fade (no slide).
*/
interface Props {
open?: boolean;
title?: string;
/** Panel width on >760px screens. */
width?: number;
dismissible?: boolean;
onclose?: () => void;
footer?: Snippet;
children: Snippet;
}
let {
open = $bindable(false),
title,
width = 380,
dismissible = true,
onclose,
footer,
children,
}: Props = $props();
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const titleId = `drawer-title-${Math.random().toString(36).slice(2, 9)}`;
let panel = $state<HTMLDivElement | null>(null);
function requestClose() {
if (!dismissible) return;
open = false;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
requestClose();
return;
}
if (event.key === 'Tab') trapFocus(event);
}
function trapFocus(event: KeyboardEvent) {
if (!panel) return;
const focusables = Array.from(
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter((el) => el.getClientRects().length > 0);
if (focusables.length === 0) {
event.preventDefault();
panel.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
const focusInside = panel.contains(active);
if (event.shiftKey && (!focusInside || active === first)) {
event.preventDefault();
last.focus();
} else if (!focusInside || active === last) {
event.preventDefault();
first.focus();
}
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) requestClose();
}
$effect(() => {
if (!open) return;
const invoker = document.activeElement as HTMLElement | null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeydown, true);
requestAnimationFrame(() => {
if (!panel || panel.contains(document.activeElement)) return;
const first = panel.querySelector<HTMLElement>(FOCUSABLE);
(first ?? panel).focus();
});
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeydown, true);
if (invoker?.isConnected) invoker.focus();
onclose?.();
};
});
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="drawer-backdrop" onclick={handleBackdropClick}>
<div
bind:this={panel}
class="drawer-panel"
style:--drawer-w="{width}px"
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-label={title ? undefined : t('modal.dialog')}
tabindex="-1"
>
{#if title || dismissible}
<div class="drawer-header">
{#if title}
<h2 class="drawer-title" id={titleId}>{title}</h2>
{/if}
{#if dismissible}
<button
type="button"
class="drawer-close"
aria-label={t('modal.closeDialog')}
onclick={requestClose}
>
<Icon name="close" size={16} />
</button>
{/if}
</div>
{/if}
<div class="drawer-body">
{@render children()}
</div>
{#if footer}
<div class="drawer-footer">
{@render footer()}
</div>
{/if}
</div>
</div>
{/if}
<style>
.drawer-backdrop {
position: fixed;
inset: 0;
z-index: 900;
display: flex;
justify-content: flex-end;
background: var(--overlay);
animation: drawer-fade var(--d-state) ease-out;
}
.drawer-panel {
width: min(var(--drawer-w), 100vw);
height: 100dvh;
display: flex;
flex-direction: column;
background: var(--float-bg);
border-radius: var(--r-lg) 0 0 var(--r-lg);
box-shadow: var(--shadow-float);
outline: none;
animation: drawer-slide var(--d-drawer) var(--ease-drawer) both;
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px 0;
flex-shrink: 0;
}
.drawer-title {
margin: 0;
font-size: var(--fs-title);
font-weight: 600;
color: var(--text);
}
.drawer-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-left: auto;
border: none;
border-radius: var(--r-sm);
background: transparent;
color: var(--text-3);
cursor: pointer;
transition:
background-color var(--d-state) ease,
color var(--d-state) ease;
}
@media (hover: hover) and (pointer: fine) {
.drawer-close:hover {
background: var(--bg-4);
color: var(--text);
}
}
.drawer-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.drawer-body {
padding: 14px 20px 20px;
overflow-y: auto;
flex: 1;
min-height: 0;
font-size: 13px;
line-height: 1.55;
color: var(--text);
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px 16px;
border-top: 1px solid var(--line);
flex-shrink: 0;
}
@keyframes drawer-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes drawer-slide {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
/* 减少动态效果 (§五):去位移,只留 ≤120ms 透明度 */
:global(:root[data-reduce='1']) .drawer-panel {
animation: drawer-fade var(--d-drawer) ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.drawer-panel {
animation: drawer-fade var(--d-drawer) ease-out both;
}
}
@media (max-width: 760px) {
.drawer-panel {
width: 100vw;
border-radius: 0;
}
}
</style>
+33
View File
@@ -64,6 +64,7 @@ export const de: Dict = {
// --- Toast ---
'toast.dismiss': 'Ausblenden',
'toast.undo': 'Rückgängig',
'toast.uploadFailed': 'Upload fehlgeschlagen',
// --- Modal ---
@@ -1609,7 +1610,11 @@ export const de: Dict = {
'studio.delConfirm': 'Beim Löschen werden auch die Ergebnisdateien dieses Auftrags entfernt; der Verlauf ist nicht wiederherstellbar. Löschen?',
'studio.delTitleJob': 'Auftrag „{name}“ löschen',
'studio.delConfirmMulti': 'Dieser Auftrag hat {n} Ergebnisse. Beim Löschen werden der gesamte Auftragsdatensatz und alle {n} Ergebnisdateien entfernt; dies ist unwiderruflich. Löschen?',
'studio.deleteFailedToast': 'Löschen fehlgeschlagen: {detail}',
'studio.deletedTakeNoFilesToast': '„{name}“ gelöscht',
'studio.deletedTakeToast': '„{name}“ und seine Ergebnisdateien wurden gelöscht',
'studio.deletedToast': 'Auftrag und zugehörige Ergebnisdateien gelöscht',
'studio.wfDeletedTag': 'Workflow gelöscht',
'studio.wfEmptyTitle': 'Noch keine {kind}-Workflows',
'studio.wfEmptyBody': 'Entwicklermodus in ComfyUI aktivieren, Workflow als API-JSON exportieren und auf der Seite ComfyUI-Verbindungen als {kind} importieren.',
'studio.wfEmptyGo': 'In ComfyUI-Verbindungen importieren',
@@ -1628,9 +1633,12 @@ export const de: Dict = {
'tasks.subtitle': 'Alle Bild-/Videoaufgaben — eigenständige Fenster und Kreativfluss',
'tasks.filterAll': 'Alle',
'tasks.filterImage': 'Bilder',
'tasks.filterFailed': 'Fehlgeschlagen',
'tasks.filterActive': 'Aktiv',
'tasks.filterVideo': 'Videos',
'tasks.count': '{n} Aufgaben · {m} aktiv',
'tasks.empty': 'Noch keine Aufgaben. Reiche die erste in Bild oder Video ein.',
'tasks.emptyFiltered': 'Keine Aufgaben passen zum aktuellen Filter.',
'tasks.goGenerate': 'Bild öffnen',
'tasks.sourceStudioImage': 'Eigenständiges Bild',
'tasks.sourceStudioVideo': 'Eigenständiges Video',
@@ -1644,8 +1652,10 @@ export const de: Dict = {
'tasks.retry': 'Erneut',
'tasks.cancel': 'Abbrechen',
'tasks.delete': 'Löschen',
'tasks.download': 'Herunterladen',
'tasks.delTitle': 'Aufgabe löschen',
'tasks.delConfirm': 'Die Ergebnisdateien werden mitgelöscht. Löschen?',
'tasks.searchPh': 'Aufgaben suchen…',
'tasks.outputs': '{n} Ergebnisse',
'agentsPage.title': 'Agenten',
@@ -1875,6 +1885,8 @@ export const de: Dict = {
'jobst.dryRunDetail': 'Nur Platzhalter: Dry-run ist aktiv, ComfyUI wurde nicht aufgerufen',
'jobst.noOutput': 'Keine Ausgabe',
'jobst.noOutputDetail': 'Der Job endete ohne Ergebnisdatei – keine fertige Generierung',
'jobst.missing': 'Dateien fehlen',
'jobst.missingDetail': 'Der Task lief erfolgreich, aber die Ergebnisdateien sind nicht mehr auf dem Server',
'jobst.queued': 'Wartet',
'jobst.queuedDetail': 'Wartet in der Workbench-Warteschlange auf ComfyUI',
'jobst.connecting': 'Verbinde',
@@ -1994,6 +2006,9 @@ export const de: Dict = {
'desk.kvName': 'Name',
'desk.kvPromptId': 'ComfyUI-Auftrags-ID',
'desk.kvUsage': 'Zweck',
'desk.loadEarlier': '{n} ältere Takes anzeigen',
'desk.missingSub': 'Mit gleichen Parametern wiederholen oder diesen Take entfernen',
'desk.missingTitle': 'Die Ergebnisdateien sind nicht mehr auf dem Server',
'desk.needWorkflow': 'Fehlt: Workflow',
'desk.noConn': 'Nicht verbunden',
'desk.noPromptInput': 'Dieser Workflow hat keinen Prompt-Eingang und läuft mit seinen eigenen Werten.',
@@ -2014,6 +2029,8 @@ export const de: Dict = {
'desk.promptOnly': 'Nur Prompt',
'desk.promptPh': 'Motiv beschreiben… (Strg/⌘ + Enter generiert, / fokussiert)',
'desk.restoreAll': 'Alle Workflow-Werte wiederherstellen',
'desk.redo': 'Mit gleichen Parametern wiederholen',
'desk.removeRecord': 'Eintrag entfernen',
'desk.restoreOrig': 'Original wiederherstellen',
'desk.reuse': 'Wiederverwenden',
'desk.reuseToComposer': 'In Eingabe übernehmen',
@@ -2041,4 +2058,20 @@ export const de: Dict = {
'desk.wfUntested': 'Nicht getestet',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Gestern',
'wfd.stepParse': 'JSON parsen',
'wfd.stepNodes': 'Knoten erkennen',
'wfd.stepModels': 'Modelle prüfen',
'wfd.stepSlots': 'Eingangsslots aufbauen',
'wfd.noOutput': 'Der Workflow hat keinen Ausgabeknoten — Import nicht möglich',
'wfd.nodesAutoOk': 'Verwendung automatisch erkannt: {usage}',
'wfd.nodesUserOk': 'Verwendung bestätigt: {usage}',
'wfd.modelsOk': '„{name}“ hat alle benötigten Knoten und Modelle',
'wfd.slotsOk': '{n} Eingangsslot(s) aufgebaut ({m} Medien)',
'wfd.checksAria': 'Import-Prüfergebnisse',
'wfd.showDetails': 'Fehlerdetails anzeigen',
'wfd.hideDetails': 'Fehlerdetails ausblenden',
'wfd.unreachableSuggest': 'Eine misslungene Prüfung verhindert das Speichern nicht; nach Reparatur erneut prüfen.',
'wfd.nodesPickSuggest': 'Wähle unten die Verwendung und schließe die Ausgangszuordnung ab, bevor du speicherst.',
'wfd.showRaw': 'Rohes JSON anzeigen',
'wfd.hideRaw': 'Rohes JSON ausblenden',
};
+33
View File
@@ -62,6 +62,7 @@ export const en = {
// --- Toast ---
'toast.dismiss': 'Dismiss',
'toast.undo': 'Undo',
'toast.uploadFailed': 'Upload failed',
// --- Modal ---
@@ -1608,7 +1609,11 @@ export const en = {
'studio.delConfirm': 'Deleting also removes this job’s result files; the history cannot be restored. Delete?',
'studio.delTitleJob': 'Delete job “{name}”',
'studio.delConfirmMulti': 'This job produced {n} results. Deleting removes the whole job record and all {n} result files; this cannot be undone. Delete?',
'studio.deleteFailedToast': 'Delete failed: {detail}',
'studio.deletedTakeNoFilesToast': 'Deleted “{name}”',
'studio.deletedTakeToast': 'Deleted “{name}” together with its result files',
'studio.deletedToast': 'Job deleted together with its result files',
'studio.wfDeletedTag': 'Workflow deleted',
'studio.wfEmptyTitle': 'No {kind} workflows yet',
'studio.wfEmptyBody': "Enable ComfyUI's developer mode, export the workflow as API JSON, then import it on the ComfyUI Connections page as {kind}.",
'studio.wfEmptyGo': 'Import in ComfyUI Connections',
@@ -1627,9 +1632,12 @@ export const en = {
'tasks.subtitle': 'All image / video tasks — standalone windows and the creation flow',
'tasks.filterAll': 'All',
'tasks.filterImage': 'Images',
'tasks.filterFailed': 'Failed',
'tasks.filterActive': 'Active',
'tasks.filterVideo': 'Videos',
'tasks.count': '{n} tasks · {m} active',
'tasks.empty': 'No generation tasks yet. Submit the first one in Image or Video.',
'tasks.emptyFiltered': 'No tasks match the current filter.',
'tasks.goGenerate': 'Open Image',
'tasks.sourceStudioImage': 'Standalone image',
'tasks.sourceStudioVideo': 'Standalone video',
@@ -1643,8 +1651,10 @@ export const en = {
'tasks.retry': 'Retry',
'tasks.cancel': 'Cancel',
'tasks.delete': 'Delete',
'tasks.download': 'Download',
'tasks.delTitle': 'Delete task',
'tasks.delConfirm': 'The result files will be removed together with the task. Delete it?',
'tasks.searchPh': 'Search tasks…',
'tasks.outputs': '{n} outputs',
'agentsPage.title': 'Agents',
@@ -1874,6 +1884,8 @@ export const en = {
'jobst.dryRunDetail': 'Placeholder only: Dry-run is on, ComfyUI was not called',
'jobst.noOutput': 'No output',
'jobst.noOutputDetail': 'The job ended without any result file — not a finished generation',
'jobst.missing': 'Files missing',
'jobst.missingDetail': 'The task succeeded, but its result files are no longer on the server',
'jobst.queued': 'Queued',
'jobst.queuedDetail': 'Waiting in the workbench queue to be sent to ComfyUI',
'jobst.connecting': 'Connecting',
@@ -1993,6 +2005,9 @@ export const en = {
'desk.kvName': 'Name',
'desk.kvPromptId': 'ComfyUI prompt id',
'desk.kvUsage': 'Usage',
'desk.loadEarlier': 'Show {n} earlier takes',
'desk.missingSub': 'Redo with the same params, or remove this take',
'desk.missingTitle': 'The result files are no longer on the server',
'desk.needWorkflow': 'Missing: workflow',
'desk.noConn': 'Not connected',
'desk.noPromptInput': 'This workflow takes no prompt; it runs with its own values.',
@@ -2013,6 +2028,8 @@ export const en = {
'desk.promptOnly': 'Prompt only',
'desk.promptPh': 'Describe the shot… (Ctrl/⌘ + Enter to generate, / to focus)',
'desk.restoreAll': 'Restore all workflow values',
'desk.redo': 'Redo with same params',
'desk.removeRecord': 'Remove record',
'desk.restoreOrig': 'Restore original',
'desk.reuse': 'Reuse',
'desk.reuseToComposer': 'Reuse in composer',
@@ -2040,6 +2057,22 @@ export const en = {
'desk.wfUntested': 'Not tested',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Yesterday',
'wfd.stepParse': 'Parse JSON',
'wfd.stepNodes': 'Identify nodes',
'wfd.stepModels': 'Verify models',
'wfd.stepSlots': 'Build input slots',
'wfd.noOutput': 'The workflow has no output node — cannot import',
'wfd.nodesAutoOk': 'Usage auto-detected: {usage}',
'wfd.nodesUserOk': 'Usage confirmed: {usage}',
'wfd.modelsOk': '"{name}" has all required nodes and models',
'wfd.slotsOk': '{n} input slot(s) built ({m} media)',
'wfd.checksAria': 'Import check results',
'wfd.showDetails': 'Show error details',
'wfd.hideDetails': 'Hide error details',
'wfd.unreachableSuggest': 'Being unverifiable does not block saving; re-check after fixing the connection.',
'wfd.nodesPickSuggest': 'Pick the usage and finish the output mapping below before saving.',
'wfd.showRaw': 'View raw JSON',
'wfd.hideRaw': 'Hide raw JSON',
};
export type Dict = typeof en;
+33
View File
@@ -64,6 +64,7 @@ export const es: Dict = {
// --- Toast ---
'toast.dismiss': 'Descartar',
'toast.undo': 'Deshacer',
'toast.uploadFailed': 'Error al subir',
// --- Modal ---
@@ -1609,7 +1610,11 @@ export const es: Dict = {
'studio.delConfirm': 'Al eliminar también se quitan los archivos de resultados de esta tarea; el historial no se puede restaurar. ¿Eliminar?',
'studio.delTitleJob': 'Eliminar tarea «{name}»',
'studio.delConfirmMulti': 'Esta tarea produjo {n} resultados. Al eliminarla se quita el registro completo y los {n} archivos de resultados; no se puede deshacer. ¿Eliminar?',
'studio.deleteFailedToast': 'No se pudo eliminar: {detail}',
'studio.deletedTakeNoFilesToast': 'Se eliminó «{name}»',
'studio.deletedTakeToast': 'Se eliminó «{name}» junto con sus archivos de resultado',
'studio.deletedToast': 'Tarea eliminada junto con sus archivos de resultados',
'studio.wfDeletedTag': 'Flujo eliminado',
'studio.wfEmptyTitle': 'Aún no hay flujos de {kind}',
'studio.wfEmptyBody': 'Activa el modo desarrollador de ComfyUI, exporta el flujo como JSON API e impórtalo en Conexiones ComfyUI como {kind}.',
'studio.wfEmptyGo': 'Importar en Conexiones ComfyUI',
@@ -1628,9 +1633,12 @@ export const es: Dict = {
'tasks.subtitle': 'Todas las tareas de imagen / vídeo: ventanas independientes y flujo de creación',
'tasks.filterAll': 'Todas',
'tasks.filterImage': 'Imágenes',
'tasks.filterFailed': 'Fallidas',
'tasks.filterActive': 'En curso',
'tasks.filterVideo': 'Vídeos',
'tasks.count': '{n} tareas · {m} activas',
'tasks.empty': 'Aún no hay tareas. Envía la primera en Imagen o Vídeo.',
'tasks.emptyFiltered': 'Ninguna tarea coincide con el filtro actual.',
'tasks.goGenerate': 'Abrir Imagen',
'tasks.sourceStudioImage': 'Imagen independiente',
'tasks.sourceStudioVideo': 'Vídeo independiente',
@@ -1644,8 +1652,10 @@ export const es: Dict = {
'tasks.retry': 'Reintentar',
'tasks.cancel': 'Cancelar',
'tasks.delete': 'Eliminar',
'tasks.download': 'Descargar',
'tasks.delTitle': 'Eliminar tarea',
'tasks.delConfirm': 'Los archivos de resultados se eliminarán junto con la tarea. ¿Eliminar?',
'tasks.searchPh': 'Buscar tareas…',
'tasks.outputs': '{n} resultados',
'agentsPage.title': 'Agentes',
@@ -1875,6 +1885,8 @@ export const es: Dict = {
'jobst.dryRunDetail': 'Solo marcador: Dry-run activo, no se llamó a ComfyUI',
'jobst.noOutput': 'Sin salida',
'jobst.noOutputDetail': 'El trabajo terminó sin archivos de resultado: no cuenta como generado',
'jobst.missing': 'Archivos no disponibles',
'jobst.missingDetail': 'La tarea terminó correctamente, pero sus archivos ya no están en el servidor',
'jobst.queued': 'En cola',
'jobst.queuedDetail': 'Esperando en la cola del taller para enviarse a ComfyUI',
'jobst.connecting': 'Conectando',
@@ -1994,6 +2006,9 @@ export const es: Dict = {
'desk.kvName': 'Nombre',
'desk.kvPromptId': 'ID de ComfyUI',
'desk.kvUsage': 'Uso',
'desk.loadEarlier': 'Mostrar {n} tomas anteriores',
'desk.missingSub': 'Rehacer con los mismos parámetros, o quitar esta toma',
'desk.missingTitle': 'Los archivos de resultado ya no están en el servidor',
'desk.needWorkflow': 'Falta: flujo',
'desk.noConn': 'Sin conexión',
'desk.noPromptInput': 'Este flujo no tiene prompt; se ejecuta con sus propios valores.',
@@ -2014,6 +2029,8 @@ export const es: Dict = {
'desk.promptOnly': 'Solo prompt',
'desk.promptPh': 'Describe la escena… (Ctrl/⌘ + Enter genera, / enfoca)',
'desk.restoreAll': 'Restaurar todos los valores del flujo',
'desk.redo': 'Rehacer con los mismos parámetros',
'desk.removeRecord': 'Quitar el registro',
'desk.restoreOrig': 'Restaurar original',
'desk.reuse': 'Reutilizar',
'desk.reuseToComposer': 'Reutilizar en el editor',
@@ -2041,4 +2058,20 @@ export const es: Dict = {
'desk.wfUntested': 'Sin probar',
'desk.workflow': 'Flujo',
'desk.yesterday': 'Ayer',
'wfd.stepParse': 'Analizar JSON',
'wfd.stepNodes': 'Identificar nodos',
'wfd.stepModels': 'Verificar modelos',
'wfd.stepSlots': 'Crear slots de entrada',
'wfd.noOutput': 'El flujo no tiene nodos de salida; no se puede importar',
'wfd.nodesAutoOk': 'Uso detectado automáticamente: {usage}',
'wfd.nodesUserOk': 'Uso confirmado: {usage}',
'wfd.modelsOk': '«{name}» tiene todos los nodos y modelos necesarios',
'wfd.slotsOk': '{n} slot(s) de entrada creados ({m} de medios)',
'wfd.checksAria': 'Resultados de la comprobación de importación',
'wfd.showDetails': 'Mostrar detalles del error',
'wfd.hideDetails': 'Ocultar detalles del error',
'wfd.unreachableSuggest': 'No poder verificar no impide guardar; vuelve a comprobar tras arreglar la conexión.',
'wfd.nodesPickSuggest': 'Elige el uso y completa el mapeo de salida abajo antes de guardar.',
'wfd.showRaw': 'Ver JSON original',
'wfd.hideRaw': 'Ocultar JSON original',
};
+33
View File
@@ -64,6 +64,7 @@ export const fr: Dict = {
// --- Toast ---
'toast.dismiss': 'Ignorer',
'toast.undo': 'Annuler',
'toast.uploadFailed': "Échec de l'import",
// --- Modal ---
@@ -1609,7 +1610,11 @@ export const fr: Dict = {
'studio.delConfirm': 'La suppression retire aussi les fichiers de résultats de cette tâche ; l’historique est irrécupérable. Supprimer ?',
'studio.delTitleJob': 'Supprimer la tâche « {name} »',
'studio.delConfirmMulti': 'Cette tâche a produit {n} résultats. La suppression retire la tâche entière et ses {n} fichiers de résultats ; action irréversible. Supprimer ?',
'studio.deleteFailedToast': 'Échec de la suppression : {detail}',
'studio.deletedTakeNoFilesToast': '« {name} » supprimé',
'studio.deletedTakeToast': '« {name} » et ses fichiers de résultat ont été supprimés',
'studio.deletedToast': 'Tâche supprimée avec ses fichiers de résultats',
'studio.wfDeletedTag': 'Workflow supprimé',
'studio.wfEmptyTitle': 'Pas encore de workflows {kind}',
'studio.wfEmptyBody': 'Activez le mode développeur de ComfyUI, exportez le workflow en JSON API, puis importez-le dans Connexions ComfyUI comme {kind}.',
'studio.wfEmptyGo': 'Importer dans Connexions ComfyUI',
@@ -1628,9 +1633,12 @@ export const fr: Dict = {
'tasks.subtitle': 'Toutes les tâches image / vidéo : fenêtres indépendantes et flux de création',
'tasks.filterAll': 'Toutes',
'tasks.filterImage': 'Images',
'tasks.filterFailed': 'Échecs',
'tasks.filterActive': 'En cours',
'tasks.filterVideo': 'Vidéos',
'tasks.count': '{n} tâches · {m} actives',
'tasks.empty': 'Aucune tâche. Lancez la première dans Image ou Vidéo.',
'tasks.emptyFiltered': 'Aucune tâche ne correspond au filtre actuel.',
'tasks.goGenerate': 'Ouvrir Image',
'tasks.sourceStudioImage': 'Image indépendante',
'tasks.sourceStudioVideo': 'Vidéo indépendante',
@@ -1644,8 +1652,10 @@ export const fr: Dict = {
'tasks.retry': 'Réessayer',
'tasks.cancel': 'Annuler',
'tasks.delete': 'Supprimer',
'tasks.download': 'Télécharger',
'tasks.delTitle': 'Supprimer la tâche',
'tasks.delConfirm': 'Les fichiers de résultats seront supprimés avec la tâche. Supprimer ?',
'tasks.searchPh': 'Rechercher des tâches…',
'tasks.outputs': '{n} résultats',
'agentsPage.title': 'Agents',
@@ -1875,6 +1885,8 @@ export const fr: Dict = {
'jobst.dryRunDetail': 'Substitut uniquement : Dry-run actif, ComfyUI n\'a pas été appelé',
'jobst.noOutput': 'Aucune sortie',
'jobst.noOutputDetail': 'La tâche s\'est terminée sans fichier de résultat : pas une génération',
'jobst.missing': 'Fichiers manquants',
'jobst.missingDetail': 'La tâche a réussi, mais ses fichiers ne sont plus sur le serveur',
'jobst.queued': 'En attente',
'jobst.queuedDetail': 'En attente dans la file de l\'atelier avant envoi à ComfyUI',
'jobst.connecting': 'Connexion',
@@ -1994,6 +2006,9 @@ export const fr: Dict = {
'desk.kvName': 'Nom',
'desk.kvPromptId': 'ID ComfyUI',
'desk.kvUsage': 'Usage',
'desk.loadEarlier': 'Afficher {n} prises plus anciennes',
'desk.missingSub': 'Refaire avec les mêmes paramètres, ou retirer cette prise',
'desk.missingTitle': 'Les fichiers de résultat ne sont plus sur le serveur',
'desk.needWorkflow': 'Manque : workflow',
'desk.noConn': 'Non connecté',
'desk.noPromptInput': 'Ce workflow n’a pas de prompt ; il s’exécute avec ses propres valeurs.',
@@ -2014,6 +2029,8 @@ export const fr: Dict = {
'desk.promptOnly': 'Prompt seul',
'desk.promptPh': 'Décrivez l’image… (Ctrl/⌘ + Entrée pour générer, / pour cibler)',
'desk.restoreAll': 'Rétablir toutes les valeurs du workflow',
'desk.redo': 'Refaire avec les mêmes paramètres',
'desk.removeRecord': 'Retirer la ligne',
'desk.restoreOrig': 'Rétablir l’original',
'desk.reuse': 'Réutiliser',
'desk.reuseToComposer': 'Réutiliser dans l’éditeur',
@@ -2041,4 +2058,20 @@ export const fr: Dict = {
'desk.wfUntested': 'Non testé',
'desk.workflow': 'Workflow',
'desk.yesterday': 'Hier',
'wfd.stepParse': 'Analyser le JSON',
'wfd.stepNodes': 'Identifier les nœuds',
'wfd.stepModels': 'Vérifier les modèles',
'wfd.stepSlots': 'Créer les slots d’entrée',
'wfd.noOutput': "Le flux n'a aucun nœud de sortie — import impossible",
'wfd.nodesAutoOk': 'Usage détecté automatiquement : {usage}',
'wfd.nodesUserOk': 'Usage confirmé : {usage}',
'wfd.modelsOk': '« {name} » possède tous les nœuds et modèles requis',
'wfd.slotsOk': "{n} slot(s) d'entrée créés ({m} média)",
'wfd.checksAria': "Résultats de la vérification d'import",
'wfd.showDetails': "Afficher les détails de l'erreur",
'wfd.hideDetails': "Masquer les détails de l'erreur",
'wfd.unreachableSuggest': "L'impossibilité de vérifier n'empêche pas l'enregistrement ; revérifiez après avoir réparé la connexion.",
'wfd.nodesPickSuggest': "Choisissez l'usage et terminez le mappage des sorties ci-dessous avant d'enregistrer.",
'wfd.showRaw': 'Voir le JSON brut',
'wfd.hideRaw': 'Masquer le JSON brut',
};
+33
View File
@@ -64,6 +64,7 @@ export const ja: Dict = {
// --- Toast ---
'toast.dismiss': '閉じる',
'toast.undo': '元に戻す',
'toast.uploadFailed': 'アップロードに失敗しました',
// --- Modal ---
@@ -1609,7 +1610,11 @@ export const ja: Dict = {
'studio.delConfirm': '削除するとこのタスクの結果ファイルも削除され、履歴は元に戻せません。削除しますか?',
'studio.delTitleJob': 'タスク「{name}」を削除',
'studio.delConfirmMulti': 'このタスクには結果が {n} 件あります。削除するとタスク記録と {n} 件すべての結果ファイルが失われ、元に戻せません。削除しますか?',
'studio.deleteFailedToast': '削除に失敗しました:{detail}',
'studio.deletedTakeNoFilesToast': '「{name}」の記録を削除しました',
'studio.deletedTakeToast': '「{name}」の記録と結果ファイルを削除しました',
'studio.deletedToast': 'タスクと結果ファイルを削除しました',
'studio.wfDeletedTag': 'ワークフローは削除済み',
'studio.wfEmptyTitle': '{kind}ワークフローがまだありません',
'studio.wfEmptyBody': 'ComfyUI の開発者モードで API 形式 JSON を書き出し、「ComfyUI 接続」ページで{kind}として取り込んでください。',
'studio.wfEmptyGo': '「ComfyUI 接続」で取り込む',
@@ -1628,9 +1633,12 @@ export const ja: Dict = {
'tasks.subtitle': '独立画像・独立動画・創作フロー内の画像/動画タスクをまとめて表示',
'tasks.filterAll': 'すべて',
'tasks.filterImage': '画像',
'tasks.filterFailed': '失敗',
'tasks.filterActive': '実行中',
'tasks.filterVideo': '動画',
'tasks.count': '{n} 件 · 実行中 {m} 件',
'tasks.empty': '生成タスクがまだありません。「画像生成」「動画生成」から最初のタスクを送信してください。',
'tasks.emptyFiltered': '現在のフィルターに一致するタスクはありません。',
'tasks.goGenerate': '画像生成を開く',
'tasks.sourceStudioImage': '独立画像',
'tasks.sourceStudioVideo': '独立動画',
@@ -1644,8 +1652,10 @@ export const ja: Dict = {
'tasks.retry': '再試行',
'tasks.cancel': 'キャンセル',
'tasks.delete': '削除',
'tasks.download': 'ダウンロード',
'tasks.delTitle': 'タスクを削除',
'tasks.delConfirm': '結果ファイルも一緒に削除されます。削除しますか?',
'tasks.searchPh': 'タスクを検索…',
'tasks.outputs': '結果 {n} 件',
'agentsPage.title': 'エージェント',
@@ -1875,6 +1885,8 @@ export const ja: Dict = {
'jobst.dryRunDetail': 'プレースホルダーのみ:Dry-run が有効で ComfyUI は呼び出されていません',
'jobst.noOutput': '出力なし',
'jobst.noOutputDetail': '結果ファイルなしで終了しました(生成完了ではありません)',
'jobst.missing': 'ファイル欠落',
'jobst.missingDetail': 'タスクは成功しましたが、結果ファイルはサーバー上にありません',
'jobst.queued': '待機中',
'jobst.queuedDetail': 'ワークベンチのキューで ComfyUI への送信を待っています',
'jobst.connecting': '接続中',
@@ -1994,6 +2006,9 @@ export const ja: Dict = {
'desk.kvName': '名前',
'desk.kvPromptId': 'ComfyUI タスク ID',
'desk.kvUsage': '用途',
'desk.loadEarlier': '過去の {n} 件を表示',
'desk.missingSub': '同じパラメータでやり直すか、この記録を削除できます',
'desk.missingTitle': '結果ファイルはサーバー上にありません',
'desk.needWorkflow': '不足:ワークフロー',
'desk.noConn': '未接続',
'desk.noPromptInput': 'このワークフローにはプロンプト入力がなく、元の値で生成します。',
@@ -2014,6 +2029,8 @@ export const ja: Dict = {
'desk.promptOnly': 'プロンプトのみ',
'desk.promptPh': '欲しい画面を説明…(Ctrl/⌘ + Enter で生成、/ でフォーカス)',
'desk.restoreAll': 'すべてワークフローの値に戻す',
'desk.redo': '同じパラメータでやり直す',
'desk.removeRecord': '記録を削除',
'desk.restoreOrig': '元に戻す',
'desk.reuse': '再利用',
'desk.reuseToComposer': '入力台で再利用',
@@ -2041,4 +2058,20 @@ export const ja: Dict = {
'desk.wfUntested': '未テスト',
'desk.workflow': 'ワークフロー',
'desk.yesterday': '昨日',
'wfd.stepParse': 'JSON を解析',
'wfd.stepNodes': 'ノードを識別',
'wfd.stepModels': 'モデルを確認',
'wfd.stepSlots': '入力スロットを構築',
'wfd.noOutput': '出力ノードがないためインポートできません',
'wfd.nodesAutoOk': '用途を自動判定:{usage}',
'wfd.nodesUserOk': '用途を確認済み:{usage}',
'wfd.modelsOk': '「{name}」に必要なノードとモデルが揃っています',
'wfd.slotsOk': '入力スロット {n} 件を構築(うちメディア {m} 件)',
'wfd.checksAria': 'インポート検査結果',
'wfd.showDetails': 'エラー詳細を展開',
'wfd.hideDetails': 'エラー詳細を折りたたむ',
'wfd.unreachableSuggest': '検査できなくても保存は可能です。接続を直して「再チェック」してください。',
'wfd.nodesPickSuggest': '保存前に下で用途を選び、出力マッピングを完了してください。',
'wfd.showRaw': '元の JSON を見る',
'wfd.hideRaw': '元の JSON を閉じる',
};
+33
View File
@@ -64,6 +64,7 @@ export const ko: Dict = {
// --- Toast ---
'toast.dismiss': '닫기',
'toast.undo': '실행 취소',
'toast.uploadFailed': '업로드 실패',
// --- Modal ---
@@ -1609,7 +1610,11 @@ export const ko: Dict = {
'studio.delConfirm': '삭제 시 이 작업의 결과 파일도 함께 제거되며 기록은 복구할 수 없습니다. 삭제할까요?',
'studio.delTitleJob': '작업 «{name}» 삭제',
'studio.delConfirmMulti': '이 작업에는 결과가 {n}개 있습니다. 삭제하면 작업 기록과 결과 파일 {n}개 모두 제거되며 복구할 수 없습니다. 삭제할까요?',
'studio.deleteFailedToast': '삭제 실패: {detail}',
'studio.deletedTakeNoFilesToast': '\'{name}\' 기록을 삭제했습니다',
'studio.deletedTakeToast': '\'{name}\' 기록과 결과 파일을 삭제했습니다',
'studio.deletedToast': '작업과 결과 파일을 삭제했습니다',
'studio.wfDeletedTag': '삭제된 워크플로',
'studio.wfEmptyTitle': '{kind} 워크플로가 아직 없습니다',
'studio.wfEmptyBody': 'ComfyUI 개발자 모드를 켜고 API 형식 JSON으로 내보낸 뒤, ComfyUI 연결 페이지에서 {kind}(으)로 가져오세요.',
'studio.wfEmptyGo': 'ComfyUI 연결에서 가져오기',
@@ -1628,9 +1633,12 @@ export const ko: Dict = {
'tasks.subtitle': '독립 이미지·독립 비디오·창작 흐름의 이미지/비디오 작업 모아 보기',
'tasks.filterAll': '전체',
'tasks.filterImage': '이미지',
'tasks.filterFailed': '실패',
'tasks.filterActive': '진행 중',
'tasks.filterVideo': '비디오',
'tasks.count': '{n}건 · 진행 중 {m}건',
'tasks.empty': '생성 작업이 아직 없습니다. 이미지/비디오 생성에서 첫 작업을 제출하세요.',
'tasks.emptyFiltered': '현재 필터에 일치하는 작업이 없습니다.',
'tasks.goGenerate': '이미지 생성 열기',
'tasks.sourceStudioImage': '독립 이미지',
'tasks.sourceStudioVideo': '독립 비디오',
@@ -1644,8 +1652,10 @@ export const ko: Dict = {
'tasks.retry': '재시도',
'tasks.cancel': '취소',
'tasks.delete': '삭제',
'tasks.download': '다운로드',
'tasks.delTitle': '작업 삭제',
'tasks.delConfirm': '결과 파일도 함께 삭제됩니다. 삭제할까요?',
'tasks.searchPh': '작업 검색…',
'tasks.outputs': '결과 {n}개',
'agentsPage.title': '에이전트',
@@ -1875,6 +1885,8 @@ export const ko: Dict = {
'jobst.dryRunDetail': '자리표시자만: Dry-run이 켜져 있어 ComfyUI를 호출하지 않았습니다',
'jobst.noOutput': '출력 없음',
'jobst.noOutputDetail': '결과 파일 없이 끝났습니다 — 생성 완료가 아닙니다',
'jobst.missing': '파일 없음',
'jobst.missingDetail': '작업은 성공했지만 결과 파일이 서버에 없습니다',
'jobst.queued': '대기 중',
'jobst.queuedDetail': '워크벤치 대기열에서 ComfyUI 전송을 기다리는 중',
'jobst.connecting': '연결 중',
@@ -1994,6 +2006,9 @@ export const ko: Dict = {
'desk.kvName': '이름',
'desk.kvPromptId': 'ComfyUI 작업 ID',
'desk.kvUsage': '용도',
'desk.loadEarlier': '이전 {n}개 표시',
'desk.missingSub': '같은 설정으로 다시 만들거나 이 기록을 제거할 수 있습니다',
'desk.missingTitle': '결과 파일이 서버에 없습니다',
'desk.needWorkflow': '부족: 워크플로',
'desk.noConn': '연결 안 됨',
'desk.noPromptInput': '이 워크플로에는 프롬프트 입력이 없어 원래 값으로 생성합니다.',
@@ -2014,6 +2029,8 @@ export const ko: Dict = {
'desk.promptOnly': '프롬프트만',
'desk.promptPh': '원하는 장면을 설명… (Ctrl/⌘ + Enter 생성, / 포커스)',
'desk.restoreAll': '모두 워크플로 값으로 되돌리기',
'desk.redo': '같은 설정으로 다시 생성',
'desk.removeRecord': '기록 제거',
'desk.restoreOrig': '원래 값으로',
'desk.reuse': '재사용',
'desk.reuseToComposer': '입력대에서 재사용',
@@ -2041,4 +2058,20 @@ export const ko: Dict = {
'desk.wfUntested': '테스트 안 함',
'desk.workflow': '워크플로',
'desk.yesterday': '어제',
'wfd.stepParse': 'JSON 파싱',
'wfd.stepNodes': '노드 식별',
'wfd.stepModels': '모델 확인',
'wfd.stepSlots': '입력 슬롯 구성',
'wfd.noOutput': '출력 노드가 없어 가져올 수 없습니다',
'wfd.nodesAutoOk': '용도 자동 판정: {usage}',
'wfd.nodesUserOk': '용도 확인됨: {usage}',
'wfd.modelsOk': "'{name}'에 필요한 노드와 모델이 모두 있습니다",
'wfd.slotsOk': '입력 슬롯 {n}개 구성(미디어 {m}개)',
'wfd.checksAria': '가져오기 검사 결과',
'wfd.showDetails': '오류 자세히 보기',
'wfd.hideDetails': '오류 자세히 접기',
'wfd.unreachableSuggest': '확인 실패는 저장을 막지 않습니다. 연결을 고친 뒤 다시 검사하세요.',
'wfd.nodesPickSuggest': '저장하기 전에 아래에서 용도를 선택하고 출력 매핑을 완료하세요.',
'wfd.showRaw': '원본 JSON 보기',
'wfd.hideRaw': '원본 JSON 접기',
};
+33
View File
@@ -64,6 +64,7 @@ export const zh: Dict = {
// --- Toast ---
'toast.dismiss': '关闭',
'toast.undo': '撤销',
'toast.uploadFailed': '上传失败',
// --- Modal ---
@@ -1609,7 +1610,11 @@ export const zh: Dict = {
'studio.delConfirm': '删除后该任务的结果文件会一并移除,历史不可恢复。确定删除?',
'studio.delTitleJob': '删除任务「{name}」',
'studio.delConfirmMulti': '该任务共生成 {n} 个结果,删除将移除整条任务记录及其全部 {n} 个结果文件,历史不可恢复。确定删除?',
'studio.deleteFailedToast': '删除失败:{detail}',
'studio.deletedTakeNoFilesToast': '已删除「{name}」这条记录',
'studio.deletedTakeToast': '已删除「{name}」这条记录及其结果文件',
'studio.deletedToast': '已删除该任务及其结果文件',
'studio.wfDeletedTag': '工作流已删除',
'studio.wfEmptyTitle': '还没有{kind}工作流',
'studio.wfEmptyBody': '在 ComfyUI 里启用开发者模式,用「导出(API 格式)」得到 JSON,再到「ComfyUI 连接」页导入并分类为{kind}。',
'studio.wfEmptyGo': '去「ComfyUI 连接」导入',
@@ -1628,9 +1633,12 @@ export const zh: Dict = {
'tasks.subtitle': '汇总独立生图、独立生视频与创作流程内的图片 / 视频任务',
'tasks.filterAll': '全部',
'tasks.filterImage': '图片',
'tasks.filterFailed': '失败',
'tasks.filterActive': '进行中',
'tasks.filterVideo': '视频',
'tasks.count': '{n} 个任务 · {m} 个进行中',
'tasks.empty': '还没有生成任务。到「生图」「生视频」提交第一个任务。',
'tasks.emptyFiltered': '没有符合筛选的任务。',
'tasks.goGenerate': '去生图',
'tasks.sourceStudioImage': '独立生图',
'tasks.sourceStudioVideo': '独立生视频',
@@ -1644,8 +1652,10 @@ export const zh: Dict = {
'tasks.retry': '重试',
'tasks.cancel': '取消',
'tasks.delete': '删除',
'tasks.download': '下载',
'tasks.delTitle': '删除任务',
'tasks.delConfirm': '删除后该任务的结果文件会一并移除。确定删除?',
'tasks.searchPh': '搜索任务…',
'tasks.outputs': '{n} 个结果',
'agentsPage.title': '智能体',
@@ -1875,6 +1885,8 @@ export const zh: Dict = {
'jobst.dryRunDetail': '占位结果:当前开启了 Dry-run,没有调用 ComfyUI,不是真实生成',
'jobst.noOutput': '无产出',
'jobst.noOutputDetail': '任务结束但没有任何结果文件,不算生成完成',
'jobst.missing': '文件缺失',
'jobst.missingDetail': '生成成功,但结果文件已不在服务器上',
'jobst.queued': '排队中',
'jobst.queuedDetail': '在工作台队列中等待提交到 ComfyUI',
'jobst.connecting': '连接中',
@@ -1994,6 +2006,9 @@ export const zh: Dict = {
'desk.kvName': '名称',
'desk.kvPromptId': 'ComfyUI 任务号',
'desk.kvUsage': '用途',
'desk.loadEarlier': '显示更早的 {n} 条',
'desk.missingSub': '可用相同参数重做,或移除这条记录',
'desk.missingTitle': '结果文件已不在服务器上',
'desk.needWorkflow': '还缺:工作流',
'desk.noConn': '未连接',
'desk.noPromptInput': '这个工作流没有提示词输入,按工作流原值生成。',
@@ -2014,6 +2029,8 @@ export const zh: Dict = {
'desk.promptOnly': '仅提示词',
'desk.promptPh': '描述想要的画面…(Ctrl/⌘ + Enter 生成,/ 快速聚焦)',
'desk.restoreAll': '全部恢复工作流原值',
'desk.redo': '用相同参数重做',
'desk.removeRecord': '移除记录',
'desk.restoreOrig': '恢复原值',
'desk.reuse': '复用',
'desk.reuseToComposer': '复用到输入台',
@@ -2041,4 +2058,20 @@ export const zh: Dict = {
'desk.wfUntested': '未试运行',
'desk.workflow': '工作流',
'desk.yesterday': '昨天',
'wfd.stepParse': '解析 JSON',
'wfd.stepNodes': '识别节点',
'wfd.stepModels': '核对模型',
'wfd.stepSlots': '建立输入槽',
'wfd.noOutput': '工作流没有任何输出节点,无法导入',
'wfd.nodesAutoOk': '自动判定用途:{usage}',
'wfd.nodesUserOk': '用途已确认:{usage}',
'wfd.modelsOk': '「{name}」已装齐所需节点与模型',
'wfd.slotsOk': '已建立 {n} 个输入槽(含 {m} 个媒体槽)',
'wfd.checksAria': '导入检查结果',
'wfd.showDetails': '展开错误详情',
'wfd.hideDetails': '收起错误详情',
'wfd.unreachableSuggest': '无法核查不会阻止保存;修好连接后点「重新检查」复查。',
'wfd.nodesPickSuggest': '请在下方选择用途并完成输出映射后再保存。',
'wfd.showRaw': '查看原始 JSON',
'wfd.hideRaw': '收起原始 JSON',
};
@@ -0,0 +1,38 @@
import { SvelteSet } from 'svelte/reactivity';
import type { Job } from '$lib/comfy/types';
/**
* Output files a done job claims but the asset endpoint could not serve
* (spec-v2 §3.4 红/琥珀界线): the backend task status stays the single
* verdict for red — a FAILED job is red no matter what. Amber means the
* job SUCCEEDED and only its product file is gone (cleaned up, moved,
* storage hiccup). We learn that the only way the frontend can: the
* media element itself failed to load. A later successful load clears
* the mark.
*/
const missing = new SvelteSet<string>();
/** Mark one output path as unreachable (img/video onerror). */
export function markOutputMissing(path: string): void {
if (path) missing.add(path);
}
/** Clear the mark when the same path loads fine after all. */
export function clearOutputMissing(path: string): void {
missing.delete(path);
}
/** Paths of this job's outputs that are currently unreachable. */
export function missingOutputsOf(job: Job | null | undefined): string[] {
if (!job || job.status !== 'done' || !(job.output_paths?.length ?? 0))
return [];
return job.output_paths.filter((p) => missing.has(p));
}
/**
* The take reads as 文件缺失 when it succeeded but at least one claimed
* product file cannot be fetched.
*/
export function isFileMissing(job: Job | null | undefined): boolean {
return missingOutputsOf(job).length > 0;
}
+9 -1
View File
@@ -1,11 +1,18 @@
export type ToastKind = 'success' | 'error' | 'info';
/** Inline action on a toast (§四.5: 动作金色文字,如「撤销」). */
export type ToastAction = {
label: string;
run: () => void;
};
export type Toast = {
id: number;
message: string;
kind: ToastKind;
/** Auto-dismiss delay in ms. 0 = sticky until dismissed. */
duration: number;
action?: ToastAction;
};
type Listener = (toasts: Toast[]) => void;
@@ -57,9 +64,10 @@ export function showToast(
message: string,
kind: ToastKind = 'success',
durationMs: number = DEFAULT_DURATION,
action?: ToastAction,
) {
const id = ++seq;
toasts = [...toasts, { id, message, kind, duration: durationMs }];
toasts = [...toasts, { id, message, kind, duration: durationMs, action }];
emit();
if (durationMs > 0) schedule(id, durationMs);
return id;
+5
View File
@@ -550,6 +550,11 @@ button.status-chip:active {
.a-chip.changed {
color: var(--accent);
}
/* Selected filter chip (§3.5 筛选芯片组): 当前选中 = 金色文字 + accent-soft 底 */
.a-chip.sel {
background: var(--accent-soft);
color: var(--accent);
}
.a-chip.warn {
color: var(--warn);
}
+24 -752
View File
@@ -7,8 +7,9 @@
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import WorkflowImportDrawer from '$lib/components/WorkflowImportDrawer.svelte';
import WorkflowTestDialog from '$lib/components/WorkflowTestDialog.svelte';
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowAnalyzeResult, type WorkflowConnectionCheck } from '$lib/api';
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowConnectionCheck } from '$lib/api';
import { WORKFLOW_USAGES } from '$lib/comfy/types';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
@@ -327,214 +328,24 @@
});
// ------------------------------------------------------------------
// Workflow library (import + binding) — two-step wizard: paste/analyze,
// then confirm the judged usage and slot roles before saving (HEL-636).
// Workflow library (binding + per-row recheck / test). The import
// wizard itself lives in WorkflowImportDrawer (HEL-655 E 批).
// ------------------------------------------------------------------
let wfJsonText = $state('');
let wfImportOpen = $state(false);
let wfImportName = $state('');
let wfImportEndpoint = $state('');
let wfStep = $state<'input' | 'confirm'>('input');
let wfAnalysis = $state<WorkflowAnalyzeResult | null>(null);
let wfUsage = $state<WorkflowUsage | null>(null);
let wfRoleOverrides = $state<Record<string, string>>({});
// HEL-638: explicit output-node designation for unknown custom nodes.
let wfOutputPicked = $state<Record<string, boolean>>({});
let wfOutputKind = $state<Record<string, 'image' | 'video'>>({});
let wfAnalyzing = $state(false);
let wfAnalyzeError = $state('');
const wfParsed = $derived.by(() => {
const text = wfJsonText.trim();
if (!text) return { valid: false as const, json: null as Record<string, unknown> | null };
try {
const parsed = JSON.parse(text) as unknown;
if (
typeof parsed === 'object' &&
parsed !== null &&
!Array.isArray(parsed) &&
Object.values(parsed).some(
(v) => typeof v === 'object' && v !== null && 'class_type' in (v as object),
)
) {
return { valid: true as const, json: parsed as Record<string, unknown> };
}
} catch {
/* fallthrough */
}
return { valid: false as const, json: null };
});
function usageLabel(u: WorkflowUsage | null | undefined): string {
return u ? t(`usage.${u}`) : t('usage.pending');
}
function startWfImport() {
wfImportOpen = true;
wfImportName = '';
wfJsonText = '';
wfImportEndpoint = '';
wfStep = 'input';
wfAnalysis = null;
wfUsage = null;
wfRoleOverrides = {};
wfOutputPicked = {};
wfOutputKind = {};
wfAnalyzeError = '';
}
function usageLabel(u: WorkflowUsage | null | undefined): string {
return u ? t(`usage.${u}`) : t('usage.pending');
}
function wfProblems(wf: Workflow): string[] {
return (wf.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message);
}
async function runWfAnalyze(refresh = false) {
if (!wfParsed.valid || !wfParsed.json) return;
wfAnalyzing = true;
wfAnalyzeError = '';
try {
const res = await workflows.analyze(
wfParsed.json,
wfImportEndpoint || undefined,
refresh,
);
wfAnalysis = res;
wfUsage = res.usage ?? null;
wfRoleOverrides = {};
// Unknown output candidates: preselect only when there is exactly
// one — with several the user must decide (HEL-638).
const candidates = (res.usage_evidence.outputs ?? []).filter(
(o) => o.evidence === 'unknown_node',
);
const picked: Record<string, boolean> = {};
const kinds: Record<string, 'image' | 'video'> = {};
for (const c of candidates) {
kinds[c.nodeId] = c.suggested_kind ?? 'image';
}
if (candidates.length === 1) picked[candidates[0].nodeId] = true;
wfOutputPicked = picked;
wfOutputKind = kinds;
wfStep = 'confirm';
} catch (err) {
wfAnalyzeError = apiErrorMessage(err);
} finally {
wfAnalyzing = false;
}
}
/** True when the graph has no output node at all — nothing to confirm. */
const wfFatal = $derived(
wfStep === 'confirm' && wfAnalysis !== null && (wfAnalysis.outputs?.length ?? 0) === 0,
);
/** Unknown custom output nodes the user must designate (HEL-638). */
const wfOutputCandidates = $derived(
wfStep === 'confirm' && wfAnalysis !== null
? (wfAnalysis.usage_evidence.outputs ?? []).filter((o) => o.evidence === 'unknown_node')
: [],
);
const wfOutputRows = $derived(
wfOutputCandidates.map((o) => ({
...o,
picked: wfOutputPicked[o.nodeId] ?? false,
kind: wfOutputKind[o.nodeId] ?? o.suggested_kind ?? 'image',
})),
);
const wfOutputPickedCount = $derived(
Object.values(wfOutputPicked).filter(Boolean).length,
);
const wfMustPick = $derived(
wfStep === 'confirm' &&
wfAnalysis !== null &&
!wfFatal &&
(wfAnalysis.usage_ambiguous || wfAnalysis.needs_mapping) &&
wfUsage === null,
);
const wfSaveable = $derived(
wfStep === 'confirm' &&
wfUsage !== null &&
!wfFatal &&
(wfOutputCandidates.length === 0 || wfOutputPickedCount > 0),
);
/** Media slots the confirm page lets the user re-role. */
const wfSlotRows = $derived.by(() => {
if (wfStep !== 'confirm' || !wfAnalysis) return [];
return wfAnalysis.usage_evidence.image_slots.map((s) => ({
...s,
role: wfRoleOverrides[s.nodeId] ?? s.role ?? 'image',
fixed: s.kind === 'image_list',
}));
});
const wfParamPreview = $derived.by(() => {
if (!wfAnalysis) return [];
// Count EVERY analyzed input (media slots included) so the preview
// matches the inline count shown after import (input_schema.length)
// and in the studio — HEL-619.
return wfAnalysis.inputs.map((i) => i.label);
});
const wfImportMutation = createMutation({
mutationFn: async () => {
if (!wfParsed.valid || !wfParsed.json || !wfAnalysis || !wfUsage)
throw new Error(t('conn.wfPickUsage'));
if (wfOutputCandidates.length > 0 && wfOutputPickedCount === 0)
throw new Error(t('conn.wfOutputPick'));
const corrected = Object.keys(wfRoleOverrides).length > 0;
const schema = wfAnalysis.inputs.map((e) =>
wfRoleOverrides[e.nodeId]
? { ...e, role: wfRoleOverrides[e.nodeId], manual: true }
: e,
);
const outputMap = wfOutputRows
.filter((r) => r.picked)
.map((r) => ({
nodeId: r.nodeId,
kind: r.kind,
label: r.label,
...(r.class_type ? { class_type: r.class_type } : {}),
}));
return workflows.create({
name: wfImportName.trim(),
workflow_json: wfParsed.json,
usage: wfUsage,
usage_source: wfUsage !== wfAnalysis.usage || corrected ? 'user' : 'auto',
input_schema: schema,
...(outputMap.length ? { output_map: outputMap, output_map_source: 'user' } : {}),
...(wfImportEndpoint ? { endpoint_id: wfImportEndpoint } : {}),
});
},
onSuccess: (wf) => {
client.invalidateQueries({ queryKey: ['workflows'] });
const n = wf.input_schema?.length ?? 0;
const problems = wfProblems(wf);
if (problems.length) toast.error(t('conn.wfImportedBroken', { detail: problems.join(';') }));
else if (n) toast.success(t('conn.wfImportedWithUsage', { usage: usageLabel(wf.usage), n }));
else toast.info(t('conn.wfImportedNoParams'));
wfImportOpen = false;
wfJsonText = '';
wfImportName = '';
wfStep = 'input';
wfAnalysis = null;
},
onError: (err) => {
toast.error(
t('conn.wfImportFailed', {
detail: err instanceof Error ? err.message : String(err),
}),
);
},
});
function submitWfImport() {
if (!wfSaveable || !wfImportName.trim() || $wfImportMutation.isPending) return;
$wfImportMutation.mutate();
}
// ------------------------------------------------------------------
// HEL-643 L2: connection-compatibility check on the import confirm
// step, and per-row 重新检查 / 测试 in the workflow library.
// ------------------------------------------------------------------
const wfConnCheck = $derived<WorkflowConnectionCheck | null>(
wfAnalysis?.connection_check ?? null,
);
// HEL-643 L2: per-row 重新检查 / 测试 in the workflow library.
let wfRecheckId = $state<number | null>(null);
const wfRecheckResult = $state<Record<number, WorkflowConnectionCheck>>({});
@@ -686,15 +497,6 @@
},
});
async function onWfFile(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
wfJsonText = await file.text();
if (!wfImportName.trim()) wfImportName = file.name.replace(/\.json$/i, '');
input.value = '';
}
const wfDeleteMutation = createMutation({
mutationFn: (id: number) => workflows.delete(id),
onSuccess: () => {
@@ -921,296 +723,6 @@
</span>
</div>
{#if wfImportOpen}
<div class="wf-import" transition:fade={{ duration: 180 }}>
<div class="wf-import-head">
<h3>
{wfStep === 'input' ? t('conn.wfImportTitle') : t('conn.wfConfirmTitle')}
<span class="wf-step-tag">
{wfStep === 'input' ? `1 / 2 · ${t('conn.wfStepAnalyze')}` : `2 / 2 · ${t('conn.wfStepConfirm')}`}
</span>
</h3>
<button
type="button"
class="icon-btn"
aria-label={t('conn.formClose')}
onclick={() => (wfImportOpen = false)}
>
<Icon name="close" size={16} />
</button>
</div>
{#if wfStep === 'input'}
<p class="wf-import-hint">{t('conn.wfImportHint')}</p>
<div class="wf-import-grid">
<label class="f-field">
<span class="f-label">{t('conn.fName')}<em>*</em></span>
<input
class="f-input"
value={wfImportName}
oninput={(e) => (wfImportName = e.currentTarget.value)}
placeholder="Qwen Image 2.1 文生图"
/>
</label>
<label class="f-field">
<span class="f-label">{t('conn.wfConnection')}</span>
<select
class="f-input"
value={wfImportEndpoint}
onchange={(e) => (wfImportEndpoint = e.currentTarget.value)}
>
<option value="">{t('conn.wfConnectionActive')}</option>
{#each endpoints as ep (ep.id)}
<option value={ep.id}>{ep.name}</option>
{/each}
</select>
</label>
<label class="f-field">
<span class="f-label">API JSON<em>*</em></span>
<div class="wf-json-row">
<textarea
class="f-input wf-json"
class:invalid={wfJsonText.trim() !== '' && !wfParsed.valid}
spellcheck="false"
rows="3"
value={wfJsonText}
oninput={(e) => (wfJsonText = e.currentTarget.value)}
placeholder={t('conn.wfJsonPh')}
></textarea>
<label class="file-btn">
<Icon name="upload" size={14} />
{t('conn.wfChooseFile')}
<input type="file" accept=".json,application/json" onchange={onWfFile} />
</label>
</div>
{#if wfJsonText.trim() !== '' && !wfParsed.valid}
<span class="f-error">{t('conn.wfJsonInvalid')}</span>
{/if}
</label>
</div>
<div class="form-actions">
<span class="need-test" class:passed={wfParsed.valid && wfImportName.trim() !== ''}>
{wfParsed.valid
? t('conn.wfNodes', { n: Object.keys(wfParsed.json ?? {}).length })
: 'JSON'}
</span>
<Button
variant="primary"
size="sm"
disabled={!wfParsed.valid || !wfImportName.trim()}
loading={wfAnalyzing}
onclick={() => runWfAnalyze()}
>
{wfAnalyzing ? t('conn.wfAnalyzing') : t('conn.wfAnalyze')}
</Button>
</div>
{:else if wfAnalysis}
<p class="wf-import-hint">{t('conn.wfConfirmHint')}</p>
{#if wfAnalysis.usage_problem}
<div class="wf-note" class:fatal={wfFatal} role={wfFatal ? 'alert' : 'note'}>
<Icon name={wfFatal ? 'alert' : 'info'} size={14} />
<p>{wfAnalysis.usage_problem}</p>
</div>
{/if}
{#if wfAnalyzeError}
<div class="wf-note fatal" role="alert">
<Icon name="alert" size={14} />
<p>{wfAnalyzeError}</p>
</div>
{/if}
{#if wfConnCheck}
<div class="wf-conn-check" class:ok={wfConnCheck.ok} class:bad={wfConnCheck.ok === false && wfConnCheck.reachable} class:warn={!wfConnCheck.reachable}>
<div class="wf-conn-head">
<Icon name={wfConnCheck.ok ? 'check' : wfConnCheck.reachable ? 'alert' : 'info'} size={14} />
<span class="wf-conn-title">{t('conn.wfCheckTitle')}</span>
{#if wfConnCheck.endpoint_name}
<span class="wf-conn-ep">{t('conn.wfCheckEndpoint', { name: wfConnCheck.endpoint_name })}</span>
{/if}
<button
type="button"
class="mini-link"
disabled={wfAnalyzing}
onclick={() => runWfAnalyze(true)}
>
{wfAnalyzing ? t('conn.wfChecking') : t('conn.wfCheckBtn')}
</button>
</div>
{#if !wfConnCheck.reachable}
<p class="wf-conn-line">{wfConnCheck.message ?? t('conn.wfCheckUnreachable')}</p>
{:else if wfConnCheck.ok}
<p class="wf-conn-line">{t('conn.wfCheckOk')}</p>
{:else}
<ul class="wf-conn-findings">
{#each wfConnCheck.findings as f (f.node_id + f.code)}
<li>
<span class="wf-conn-msg">{f.message}</span>
{#if f.hint}<span class="wf-conn-hint">{f.hint}</span>{/if}
</li>
{/each}
</ul>
<p class="wf-conn-line">{t('conn.wfCheckSaveHint')}</p>
{/if}
</div>
{/if}
<ul class="wf-evidence">
{#each wfAnalysis.usage_evidence.summary as line (line)}
<li>{line}</li>
{/each}
</ul>
<div class="f-field">
<span class="f-label">
{t('conn.wfUsageLabel')}<em>*</em>
<span class="f-hint-inline">
{#if wfAnalysis.usage}
{t('conn.wfUsageAuto', { usage: usageLabel(wfAnalysis.usage) })}
{:else}
{t('conn.wfUsageNeedPick')}
{/if}
</span>
</span>
<div class="seg wrap" role="radiogroup">
{#each WORKFLOW_USAGES as u (u)}
<button
type="button"
class="seg-item"
class:selected={wfUsage === u}
aria-pressed={wfUsage === u}
onclick={() => (wfUsage = u)}
>
{usageLabel(u)}
</button>
{/each}
</div>
{#if wfMustPick}<span class="f-error">{t('conn.wfPickUsage')}</span>{/if}
</div>
{#if wfOutputRows.length}
<div class="f-field">
<span class="f-label">
{t('conn.wfOutputLabel')}<em>*</em>
<span class="f-hint-inline">{t('conn.wfOutputNeedPick')}</span>
</span>
<div class="wf-slot-rows">
{#each wfOutputRows as row (row.nodeId)}
<div class="wf-slot-row" class:off={!row.picked}>
<label class="wf-out-check">
<input
type="checkbox"
checked={row.picked}
onchange={(e) =>
(wfOutputPicked = {
...wfOutputPicked,
[row.nodeId]: e.currentTarget.checked,
})}
/>
<span class="wf-slot-id mono">{row.nodeId}</span>
<span class="wf-slot-name" title={row.class_type}>{row.label}</span>
</label>
<select
class="a-select"
aria-label={t('conn.wfOutputKindAria')}
disabled={!row.picked}
value={row.kind}
onchange={(e) =>
(wfOutputKind = {
...wfOutputKind,
[row.nodeId]: e.currentTarget.value as 'image' | 'video',
})}
>
<option value="image">{t('conn.wfKindImage')}</option>
<option value="video">{t('conn.wfKindVideo')}</option>
</select>
</div>
{/each}
</div>
<span class="f-hint">{t('conn.wfOutputHint')}</span>
{#if wfOutputPickedCount === 0}
<span class="f-error">{t('conn.wfOutputPick')}</span>
{/if}
</div>
{/if}
{#if wfSlotRows.length}
<div class="f-field">
<span class="f-label">{t('conn.wfSlotsLabel')}</span>
<div class="wf-slot-rows">
{#each wfSlotRows as row (row.nodeId)}
<div class="wf-slot-row">
<span class="wf-slot-id mono">{row.nodeId}</span>
<span class="wf-slot-name">{row.label}</span>
{#if row.fixed}
<span class="tag tag-usage">{t('wfRole.reference_image')}</span>
{:else}
<select
class="a-select"
value={row.role}
onchange={(e) =>
(wfRoleOverrides = {
...wfRoleOverrides,
[row.nodeId]: e.currentTarget.value,
})}
>
{#each ['image', 'first_frame', 'last_frame', 'reference_image', 'mask'] as r (r)}
<option value={r}>{t(`wfRole.${r}`)}</option>
{/each}
</select>
{/if}
</div>
{/each}
</div>
<span class="f-hint">{t('conn.wfSlotsHint')}</span>
</div>
{/if}
<p class="wf-params">
{t('conn.wfParams', { n: wfParamPreview.length })}
{#if wfParamPreview.length}
<span class="wf-params-list">{wfParamPreview.slice(0, 8).join(' · ')}{wfParamPreview.length > 8 ? ' …' : ''}</span>
{/if}
</p>
{#if (wfAnalysis.warnings ?? []).length}
<ul class="wf-warnings">
{#each wfAnalysis.warnings as w (w.node_id + w.code)}
<li class:err={w.level === 'error'}>{w.message}</li>
{/each}
</ul>
{/if}
<div class="form-actions">
<Button
variant="secondary"
size="sm"
disabled={$wfImportMutation.isPending}
onclick={() => (wfStep = 'input')}
>
{t('conn.wfBack')}
</Button>
<span class="need-test" class:passed={wfSaveable && wfImportName.trim() !== ''}>
{wfUsage
? wfOutputCandidates.length && wfOutputPickedCount === 0
? t('conn.wfOutputPick')
: usageLabel(wfUsage)
: t('conn.wfUsageNeedPick')}
</span>
<Button
variant="primary"
size="sm"
disabled={!wfSaveable || !wfImportName.trim()}
loading={$wfImportMutation.isPending}
onclick={submitWfImport}
>
{$wfImportMutation.isPending ? t('conn.wfImporting') : t('conn.wfConfirmSave')}
</Button>
</div>
{/if}
</div>
{/if}
{#if ($workflowsQuery.data ?? []).length === 0}
<EmptyState title={t('conn.wfEmpty')} />
<p class="missing-note">{t('conn.missingMaterial')}</p>
@@ -1592,6 +1104,11 @@
</div>
{/if}
</div>
<WorkflowImportDrawer
bind:open={wfImportOpen}
{endpoints}
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
/>
<WorkflowTestDialog
bind:open={testOpen}
workflow={testWf}
@@ -1640,10 +1157,10 @@
gap: 22px;
}
/* ---------------- connection cards ---------------- */
/* ---------------- connection cards (§3.7 卡片网格) ---------------- */
.cards {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.card {
@@ -1821,17 +1338,19 @@
opacity: 0.35;
cursor: not-allowed;
}
/* 次级文字操作走 ghost(HEL-649 v2 §1.2):默认 --text-2,悬停 --text,
不用金色文字链接。金色聚焦 outline 由全局 :focus-visible 提供。 */
.link-accent {
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 12px;
color: var(--accent);
color: var(--text-2);
cursor: pointer;
}
.link-accent:hover {
text-decoration: underline;
color: var(--text);
}
.notice {
@@ -1899,139 +1418,6 @@
padding-left: 10px;
line-height: 1.6;
}
.wf-import {
border: 1px solid var(--border-strong);
border-radius: 9px;
padding: 14px 16px;
margin-bottom: 14px;
background: var(--surface-2);
}
.wf-step-tag {
margin-left: 8px;
font-size: 10.5px;
font-weight: 500;
color: var(--text-3);
}
.wf-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0 0 10px;
padding: 9px 12px;
border-radius: 7px;
background: var(--accent-dim);
color: var(--text-2);
}
.wf-note :global(.icon) {
color: var(--accent);
flex-shrink: 0;
margin-top: 2px;
}
.wf-note.fatal {
background: var(--error-dim);
}
.wf-note.fatal :global(.icon) {
color: var(--error);
}
.wf-note p {
margin: 0;
font-size: 11.5px;
line-height: 1.55;
}
.wf-conn-check {
margin: 0 0 12px;
padding: 9px 12px;
border: 1px solid var(--border-strong);
border-radius: 7px;
background: var(--surface-2);
display: flex;
flex-direction: column;
gap: 6px;
}
.wf-conn-check.ok {
border-color: var(--success);
}
.wf-conn-check.bad {
border-color: var(--error);
}
.wf-conn-check.warn {
border-color: var(--warn);
}
.wf-conn-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wf-conn-head :global(.icon) {
flex-shrink: 0;
}
.wf-conn-check.ok .wf-conn-head :global(.icon) {
color: var(--success);
}
.wf-conn-check.bad .wf-conn-head :global(.icon) {
color: var(--error);
}
.wf-conn-check.warn .wf-conn-head :global(.icon) {
color: var(--warn);
}
.wf-conn-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
}
.wf-conn-ep {
font-size: 11px;
color: var(--text-3);
}
.mini-link {
margin-left: auto;
background: none;
border: none;
padding: 0;
font-family: inherit;
font-size: 11px;
color: var(--accent);
text-decoration: underline;
cursor: pointer;
}
.mini-link:disabled {
opacity: 0.6;
cursor: default;
}
.wf-conn-line {
margin: 0;
font-size: 11.5px;
color: var(--text-2);
line-height: 1.55;
}
.wf-conn-findings {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 5px;
}
.wf-conn-findings li {
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border-radius: 6px;
background: var(--error-dim);
}
.wf-conn-msg {
font-size: 11.5px;
color: var(--text);
line-height: 1.5;
}
.wf-conn-hint {
font-size: 10.5px;
color: var(--text-3);
line-height: 1.5;
}
.wf-test-btn {
display: inline-flex;
align-items: center;
@@ -2066,30 +1452,6 @@
.wf-meta-item.recheck.warn .wf-recheck-text {
color: var(--warn);
}
.wf-evidence {
margin: 0 0 12px;
padding: 0 0 0 4px;
list-style: none;
display: flex;
flex-direction: column;
gap: 3px;
}
.wf-evidence li {
font-size: 11px;
color: var(--text-3);
line-height: 1.5;
}
.wf-evidence li::before {
content: '·';
margin-right: 6px;
color: var(--accent);
}
.seg.wrap {
flex-wrap: wrap;
}
.seg.wrap .seg-item {
flex: 1 0 30%;
}
.wf-slot-rows {
display: flex;
flex-direction: column;
@@ -2172,28 +1534,6 @@
font-size: 11.5px;
color: var(--text-3);
}
.wf-params-list {
margin-left: 6px;
color: var(--text-2);
}
.wf-warnings {
margin: 0 0 10px;
padding: 8px 12px;
list-style: none;
border-radius: 7px;
background: var(--error-dim);
display: flex;
flex-direction: column;
gap: 3px;
}
.wf-warnings li {
font-size: 11px;
line-height: 1.5;
color: var(--text-2);
}
.wf-warnings li.err {
color: var(--error);
}
.tag-usage {
color: var(--text-2);
background: var(--bg-4);
@@ -2212,67 +1552,6 @@
max-width: 150px;
font-size: 11px;
}
.wf-import-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.wf-import-head h3 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.wf-import-hint {
margin: 0 0 12px;
font-size: 11px;
color: var(--text-3);
}
.wf-import-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 14px;
}
.wf-import-grid .f-field:nth-child(3) {
grid-column: 1 / -1;
}
.wf-json-row {
display: flex;
gap: 8px;
align-items: flex-start;
}
.wf-json {
min-height: 84px;
resize: vertical;
font-size: 11px;
line-height: 1.5;
}
.wf-json.invalid {
border-color: var(--err-line);
}
.file-btn {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
min-height: 34px;
padding: 0 12px;
border-radius: 7px;
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--text-2);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.file-btn:hover {
color: var(--text);
border-color: var(--line-2);
}
.file-btn input {
display: none;
}
.wf-table {
display: flex;
@@ -2373,12 +1652,12 @@
font-weight: 600;
}
.target-link {
color: var(--accent);
color: var(--text-2);
font-size: 12px;
text-decoration: none;
}
.target-link:hover {
text-decoration: underline;
color: var(--text);
}
.target-note {
margin: 10px 0 0;
@@ -2469,12 +1748,6 @@
font-size: 11px;
color: var(--text-3);
}
.f-hint-inline {
margin-left: auto;
font-size: 10.5px;
font-weight: 400;
color: var(--text-3);
}
.f-error {
display: block;
margin-top: 5px;
@@ -2648,5 +1921,4 @@
flex: 1;
min-width: 160px;
}
}
</style>
}</style>
+251 -136
View File
@@ -1,10 +1,9 @@
<script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import AppShell from '$lib/components/shell/AppShell.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
import Icon from '$lib/components/ui/Icon.svelte';
import {
apiErrorMessage,
assetUrl,
jobsApi,
playgroundApi,
@@ -14,16 +13,21 @@
} from '$lib/api';
import { connectEvents } from '$lib/events';
import { parseDbTs } from '$lib/dbtime';
import { handleJobEvent } from '$lib/jobProgress';
import { handleJobEvent, progressFor } from '$lib/jobProgress';
import { jobStatusInfo } from '$lib/jobStatus';
import { isFileMissing, markOutputMissing } from '$lib/missingOutputs.svelte';
import { toast } from '$lib/toast';
import { t } from '$lib/i18n.svelte';
import { isStandaloneJob, isWorkflowTestJob, jobSourceLabel } from '$lib/jobLabels';
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
/**
* 生成任务页 (spec-v2 §3 nav item, HEL-612): one table summing both
* 生成任务页 (spec-v2 §3.5, HEL-612/654 D 批): one table summing both
* task classes — image and video — from standalone windows, the
* creation flow, and Playground, with retry / cancel / delete.
* creation flow, and Playground. Filter chips + search; running rows
* carry a 2px progress bar; 重试 is a ghost text op (spec v2 §1.2);
* deleting one task is record-level: 200ms collapse + 5s undo Toast,
* no dialog. The real DELETE only fires after the undo window.
*/
const client = useQueryClient();
@@ -46,13 +50,27 @@
return off;
});
type Filter = 'all' | 'image' | 'video';
type Filter = 'all' | 'image' | 'video' | 'failed' | 'active';
let filter = $state<Filter>('all');
let search = $state('');
const genJobs = $derived(
($jobsQuery.data ?? []).filter((j) => j.kind === 'image' || j.kind === 'video'),
);
const shown = $derived(filter === 'all' ? genJobs : genJobs.filter((j) => j.kind === filter));
const deleteQueue = createTakeDeleteQueue(client);
const pendingIds = deleteQueue.pending;
$effect(() => deleteQueue.destroy);
const genJobs = $derived(($jobsQuery.data ?? []).filter((j) => j.kind === 'image' || j.kind === 'video'));
const shown = $derived.by(() => {
const q = search.trim().toLowerCase();
return genJobs.filter((j) => {
if (pendingIds.has(j.id)) return false;
if (filter === 'image' || filter === 'video') {
if (j.kind !== filter) return false;
} else if (filter === 'failed' && jobStatusInfo(j).tone !== 'fail') return false;
else if (filter === 'active' && j.status !== 'pending' && j.status !== 'running') return false;
if (!q) return true;
return `${wfName(j)} ${jobSourceLabel(j)} ${j.error ?? ''} ${j.id}`.toLowerCase().includes(q);
});
});
const activeCount = $derived(genJobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
function wfName(job: Job): string {
@@ -76,6 +94,35 @@
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** 2px 进度条贴行底 — real progress from the SSE store only. */
function rowProgress(job: Job): number {
if (job.status !== 'running') return 0;
const live = progressFor(job.id);
const phase = live?.phase ?? job.phase;
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
return live?.progress ?? 3;
}
function toneOf(job: Job, base: string): string {
if (base === 'ok' && isFileMissing(job)) return 'warn';
if (base === 'queue') return '';
if (base === 'fail') return 'err';
return base;
}
function labelOf(job: Job, base: { tone: string; label: string }): string {
return base.tone === 'ok' && isFileMissing(job) ? t('jobst.missing') : base.label;
}
function requestDelete(job: Job) {
if (job.status === 'pending' || job.status === 'running') return;
deleteQueue.request(job, {
message: () =>
(job.output_paths?.length ?? 0) > 0
? t('studio.deletedTakeToast', { name: wfName(job) })
: t('studio.deletedTakeNoFilesToast', { name: wfName(job) }),
});
}
let busyId = $state<number | null>(null);
async function retry(job: Job) {
@@ -86,7 +133,7 @@
toast.success(t('studio.retryQueuedToast'));
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
toast.error(t('studio.retryFailedToast', { detail: err instanceof Error ? err.message : String(err) }));
toast.error(t('studio.retryFailedToast', { detail: apiErrorMessage(err) }));
} finally {
busyId = null;
}
@@ -100,28 +147,11 @@
toast.info(t('studio.cancelToast'));
await client.invalidateQueries({ queryKey: ['jobs'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
toast.error(apiErrorMessage(err));
} finally {
busyId = null;
}
}
let deleteOpen = $state(false);
let deleteJobId = $state<number | null>(null);
const deleteTarget = $derived(genJobs.find((j) => j.id === deleteJobId) ?? null);
async function doDelete() {
if (!deleteJobId) return;
try {
await playgroundApi.deleteJob(deleteJobId);
await client.invalidateQueries({ queryKey: ['jobs'] });
await client.invalidateQueries({ queryKey: ['playground-jobs'] });
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
deleteJobId = null;
deleteOpen = false;
}
}
</script>
<AppShell active="tasks" title={t('tasks.title')} subtitle={t('tasks.subtitle')}>
@@ -130,48 +160,61 @@
{/snippet}
<div class="tasks-page">
<div class="seg" role="radiogroup" aria-label={t('tasks.title')}>
{#each [['all', t('tasks.filterAll')], ['image', t('tasks.filterImage')], ['video', t('tasks.filterVideo')]] as [value, label] (value)}
<button
type="button"
class="seg-item"
class:selected={filter === value}
aria-pressed={filter === value}
onclick={() => (filter = value as Filter)}
>
{label}
</button>
{/each}
<div class="toolbar">
<div class="chips" role="group" aria-label={t('tasks.title')}>
{#each [['all', t('tasks.filterAll')], ['image', t('tasks.filterImage')], ['video', t('tasks.filterVideo')], ['failed', t('tasks.filterFailed')], ['active', t('tasks.filterActive')]] as [value, label] (value)}
<button
type="button"
class="a-chip fchip"
class:sel={filter === value}
aria-pressed={filter === value}
onclick={() => (filter = value as Filter)}
>
{label}
</button>
{/each}
</div>
<label class="search">
<Icon name="search" size={13} />
<input type="search" bind:value={search} placeholder={t('tasks.searchPh')} aria-label={t('tasks.searchPh')} />
</label>
</div>
{#if !shown.length}
<div class="empty-card">
<Icon name="queue" size={22} />
<p>{t('tasks.empty')}</p>
<p>{search || filter !== 'all' ? t('tasks.emptyFiltered') : t('tasks.empty')}</p>
<a class="a-btn primary" href="/image">{t('tasks.goGenerate')}</a>
</div>
{:else}
<div class="task-table">
<div class="task-table" role="table" aria-label={t('tasks.title')}>
{#each shown as job (job.id)}
{@const st = jobStatusInfo(job)}
<div class="trow" class:failed={job.status === 'failed'}>
{@const progress = rowProgress(job)}
<div class="trow" class:failed={st.tone === 'fail'} class:running={st.tone === 'run'}>
<span class="tkind {job.kind}">
<Icon name={job.kind === 'image' ? 'image' : 'film'} size={14} />
</span>
<div class="tthumb">
{#if job.output_paths?.[0]}
{#if job.kind === 'image'}
<img src={assetUrl(job.output_paths[0])} alt={wfName(job)} loading="lazy" />
<img
src={assetUrl(job.output_paths[0])}
alt={wfName(job)}
loading="lazy"
onerror={() => job.output_paths[0] && markOutputMissing(job.output_paths[0])}
/>
{:else}
<video src={assetUrl(job.output_paths[0])} preload="metadata" muted></video>
{/if}
{/if}
</div>
<div class="tmain">
<div class="tname" title={wfName(job)}>
{#if isWorkflowTestJob(job)}<span class="tag tag-test">{t('tasks.testBadge')}</span>{/if}
{wfName(job)}
</div>
<div class="tmain">
<div class="tname" title={wfName(job)}>
{#if isWorkflowTestJob(job)}<span class="tag-test">{t('tasks.testBadge')}</span>{/if}
{wfName(job)}
{#if job.workflow_id == null}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
</div>
<div class="tmeta mono">
{#if projectOf(job)}{projectOf(job)} · {/if}{jobSourceLabel(job)} · {timeOf(job)}
{#if (job.output_paths?.length ?? 0) > 0}
@@ -182,13 +225,21 @@
<div class="terror">{job.error}</div>
{:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')}
<div class="terror">{st.detail}</div>
{:else if st.tone === 'ok' && isFileMissing(job)}
<div class="twarn">{t('jobst.missingDetail')}</div>
{/if}
</div>
<div class="tout">
{#if job.output_paths?.length}
<div class="tout-thumbs">
{#each job.output_paths.slice(0, 3) as p (p)}
<a href={assetUrl(p)} target="_blank" rel="noreferrer" title={p.split(/[/\\]/).pop()}>
{#each job.output_paths.slice(0, 4) as p (p)}
<a
href={assetUrl(p)}
download
target="_blank"
rel="noreferrer"
title={t('tasks.download')}
>
{#if job.kind === 'image'}
<img src={assetUrl(p)} alt="" loading="lazy" />
{:else}
@@ -199,32 +250,37 @@
</div>
{/if}
</div>
<span class="tst {st.tone}" title={st.detail}>{st.label}</span>
<span class="a-tag tst {toneOf(job, st.tone)}" title={st.detail}>{labelOf(job, st)}</span>
<span class="tactions">
{#if job.status === 'failed'}
<button type="button" class="link-accent" disabled={busyId === job.id} onclick={() => retry(job)}>
{#if (st.tone === 'fail' || (st.tone === 'ok' && isFileMissing(job))) && job.error !== 'cancelled'}
<button type="button" class="link-ghost" disabled={busyId === job.id} onclick={() => retry(job)}>
{t('tasks.retry')}
</button>
{/if}
{#if job.status === 'pending' || job.status === 'running'}
<button type="button" class="link-danger" disabled={busyId === job.id} onclick={() => cancel(job)}>
<button type="button" class="link-ghost" disabled={busyId === job.id} onclick={() => cancel(job)}>
{t('tasks.cancel')}
</button>
{/if}
{#if isStandaloneJob(job)}
{#if job.output_paths?.length}
<a class="link-ghost" href={assetUrl(job.output_paths[0])} download>
{t('tasks.download')}
</a>
{/if}
{#if isStandaloneJob(job) && job.status !== 'pending' && job.status !== 'running'}
<button
type="button"
class="link-danger"
title={t('tasks.delete')}
onclick={() => {
deleteJobId = job.id;
deleteOpen = true;
}}
onclick={() => requestDelete(job)}
>
<Icon name="trash" size={13} />
</button>
{/if}
</span>
{#if progress > 0}
<div class="rowbar" style:--p={progress / 100}><i></i></div>
{/if}
</div>
{/each}
</div>
@@ -232,19 +288,6 @@
</div>
</AppShell>
<ConfirmDialog
bind:open={deleteOpen}
title={t('tasks.delTitle')}
message={t('tasks.delConfirm')}
confirmLabel={t('tasks.delete')}
danger
onconfirm={doDelete}
oncancel={() => {
deleteJobId = null;
deleteOpen = false;
}}
/>
<style>
.tasks-page {
padding: 20px 28px 40px;
@@ -254,30 +297,52 @@
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 18px;
gap: 16px;
}
.seg {
/* §3.5: 筛选芯片组(选中 = 金色文字 + accent-soft 底)+ 右侧搜索框 */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.chips {
display: flex;
gap: 6px;
width: fit-content;
flex-wrap: wrap;
}
.seg-item {
padding: 7px 18px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--surface-2);
font-size: 12px;
color: var(--text-2);
cursor: pointer;
font-family: inherit;
.fchip {
height: 28px;
}
.seg-item.selected {
.search {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
min-width: 0;
height: 30px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: var(--r-pill);
background: var(--bg-2);
color: var(--text-3);
}
.search:focus-within {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
font-weight: 600;
}
.search input {
min-width: 0;
width: 180px;
border: 0;
outline: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: 12px;
}
.search input::placeholder {
color: var(--text-3);
}
.empty-card {
@@ -286,8 +351,8 @@
align-items: center;
gap: 12px;
padding: 56px 24px;
border: 1.5px dashed var(--border-strong);
border-radius: 10px;
border: 1.5px dashed var(--line-2);
border-radius: var(--r-lg);
color: var(--text-3);
}
.empty-card p {
@@ -296,24 +361,49 @@
line-height: 1.7;
}
/* 表格行:行底 --bg-2,悬停 --bg-3,进行中行底贴 2px 青进度条 */
.task-table {
display: flex;
flex-direction: column;
gap: 4px;
}
.trow {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
padding: 10px 14px 10px 12px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--bg-2);
min-height: 56px;
}
@media (hover: hover) and (pointer: fine) {
.trow:hover {
background: var(--bg-3);
}
}
.trow.failed {
border-color: color-mix(in srgb, var(--err) 35%, transparent);
}
.rowbar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
overflow: hidden;
border-radius: 0 0 var(--r-md) var(--r-md);
}
.rowbar i {
display: block;
height: 100%;
width: 100%;
transform-origin: left;
transform: scaleX(var(--p));
background: linear-gradient(90deg, #3bbfb4, var(--run));
transition: transform var(--d-progress) linear;
}
.tkind {
width: 28px;
@@ -323,9 +413,6 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tkind.image,
.tkind.video {
color: var(--text-2);
background: var(--bg-4);
}
@@ -335,8 +422,8 @@
height: 32px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--surface-2);
border: 1px solid var(--line);
background: var(--bg-3);
flex-shrink: 0;
}
.tthumb img,
@@ -363,6 +450,9 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.tname .wf-gone {
flex-shrink: 0;
}
.tmeta {
font-size: 10.5px;
color: var(--text-3);
@@ -371,7 +461,13 @@
.terror {
margin-top: 3px;
font-size: 11px;
color: var(--error);
color: var(--err);
line-height: 1.5;
}
.twarn {
margin-top: 3px;
font-size: 11px;
color: var(--warn);
line-height: 1.5;
}
@@ -383,11 +479,11 @@
gap: 4px;
}
.tout-thumbs a {
width: 44px;
height: 30px;
width: 32px;
height: 32px;
border-radius: 5px;
overflow: hidden;
border: 1px solid var(--border);
border: 1px solid var(--line);
display: block;
}
.tout-thumbs img,
@@ -399,45 +495,41 @@
}
.tst {
width: 52px;
text-align: right;
font-size: 11.5px;
font-weight: 600;
width: 64px;
text-align: center;
flex-shrink: 0;
}
.tst.ok {
color: var(--success);
}
.tst.run {
color: var(--run);
}
.tst.queue {
color: var(--text-3);
font-weight: 400;
}
.tst.fail {
color: var(--error);
}
/* §1.2 ghost:次级文字操作(重试/取消/下载)文字 --text-2,悬停 --text */
.tactions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.link-accent {
.link-ghost {
background: none;
border: none;
padding: 4px 6px;
font-family: inherit;
font-size: 12px;
color: var(--accent);
color: var(--text-2);
cursor: pointer;
border-radius: 6px;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.link-accent:hover:not(:disabled) {
text-decoration: underline;
@media (hover: hover) and (pointer: fine) {
.link-ghost:hover:not(:disabled) {
color: var(--text);
}
}
.link-accent:disabled {
.link-ghost:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.link-ghost:disabled {
opacity: 0.5;
cursor: default;
}
@@ -447,16 +539,22 @@
padding: 4px 6px;
font-family: inherit;
font-size: 12px;
color: var(--error);
color: var(--err);
cursor: pointer;
border-radius: 6px;
opacity: 0.85;
display: inline-flex;
align-items: center;
}
.link-danger:hover:not(:disabled) {
opacity: 1;
background: var(--error-dim);
@media (hover: hover) and (pointer: fine) {
.link-danger:hover:not(:disabled) {
opacity: 1;
background: var(--err-soft);
}
}
.link-danger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.link-danger:disabled {
opacity: 0.35;
@@ -464,7 +562,7 @@
}
.tag-test {
flex-shrink: 0;
background: var(--accent-dim);
background: var(--accent-soft);
color: var(--accent);
font-size: 10px;
font-weight: 600;
@@ -472,18 +570,35 @@
border-radius: 999px;
}
/* 窄屏:表格转卡片堆叠 */
@media (max-width: 1023px) {
.tasks-page {
padding: 14px 16px 32px;
}
.tout {
display: none;
}
.trow {
flex-wrap: wrap;
row-gap: 8px;
}
.tout {
order: 5;
width: 100%;
}
.tactions {
order: 6;
margin-left: auto;
}
.tst {
order: 4;
width: auto;
margin-left: auto;
}
.search {
margin-left: 0;
width: 100%;
}
.search input {
width: 100%;
flex: 1;
}
}
</style>
@@ -0,0 +1,79 @@
# D 批:生成记录、预览和撤销删除(HEL-654)
生成台(独立生图/生视频)与任务页的记录流按 HEL-649 spec-v2 §3.3/3.5/§四/§五
统一为一致的记录流体验:点选即预览、状态就地更新、错误中文可读、文件缺失
琥珀态、单条删除 5 秒撤销。本批只动 `calliope-web`,不改后端与协议。
## 结果切换与状态语义(HEL-648 原型对齐)
- 点选记录:金框从上一条滑到新选中(220ms WAAPI,`TakeCard` 的 `.sel-mark`),
检视区 150ms 交叉淡变(`Inspector` 的 `xfade`),视频静音自动播放;
键盘 ↑↓/J/K 即时切换,无任何动画。
- 排队灰(`--text-3` + `--bg-4`)、运行青(含进度条与阶段文案)、完成绿
(一次性 900ms 金色光晕,标签仍为绿)、失败红(原地轻抖一次 + 自动展开一行
中文结论 + 可展开详情 + 重试)。完成态不再出现金色状态标签。
- 文件缺失琥珀(spec-v2 §3.4 红/琥珀界线):任务 `done` 但结果文件加载不到
(img/video onerror → `missingOutputs` 注册表)→ 琥珀「文件缺失」标签,
操作为「用相同参数重做」(后端同参数重排)与「移除记录」。任务失败永远红色,
不靠前端猜。
## 删除两级制:单条 5 秒撤销(§四.4)
- `takeDelete.svelte.ts`:单条记录/任务删除不再弹窗——行 200ms 收起 + 补位,
Toast 5 秒「撤销」(金色动作钮,§四.5);撤销后行带入场动画回原位并重新
选中。撤销窗口结束后才真正调 `DELETE /api/playground/jobs/{id}`(后端会删
结果文件,不可先删再撤)。离页(SPA 路由切换 / 关闭页签)时 flush 全部
待提交删除(keepalive fetch)。生成台与任务页共用该队列,两个查询键同步
失效,列表与任务页始终一致。
- 删除工作流仍走不可逆确认弹层(E 批前的现状),有排队/执行中任务时按后端
409 中文原文拒绝。工作流删除后旧记录保留,记录卡、检视区与任务页均标识
「工作流已删除」。
## 键盘与减动效(§四.8/§五)
- ↑↓/J/K 切换(夹取在可见范围内)、空格播放/暂停检视区视频(焦点在按钮等
控件上时让位原生行为)、Delete 删除选中(走撤销流)、`]` 收起检视区、
Esc 关最上层浮层、Tab 沿卡片按钮自然遍历。浮层(工作流面板/弹层)打开时
快捷键让位。
- 新记录 220ms 上移 8px 入场;减动效(系统偏好或顶栏开关)下只保留 ≤120ms
淡变,金光/抖动/滑动全部取消。
- 记录流超过 80 条分页:顶部「显示更早的 N 条」按需加载(§五),200+ 条
历史不再整列渲染;视频缩略仅 `preload="metadata"`;SSE 进度经
`jobProgress` 细粒度 store 只影响对应记录卡。
## C 批审核两项交互对齐
- 从「参数」页提交后自动切回「结果」页(宽屏同时展开检视区),进行中进度
直接可见。
- 工作流选择器页脚「↑↓ 选择 · Enter 使用 · Esc 关闭」现在字面为真:
`WorkflowPicker` 实现游走高亮(↑↓ 移动、指针跟随、搜索重置),Enter 使用
高亮行;`ShellPop` 新增可选 `arrowRoving` 开关避免与内置行为叠加。
## 任务页(§3.5 重皮)
筛选芯片组(全部/图片/视频/失败/进行中,选中 = 金色文字 + accent-soft 底)+
右侧搜索;行内 2px 青进度条贴行底;状态用语义色芯片;「重试/取消/下载」为
ghost 文字操作(`--text-2`,悬停 `--text`,禁用金色文字链接——spec-v2 §1.2);
删除单条任务与生成台同一撤销流;窄屏转卡片堆叠。
## 落点
- 新增 `lib/missingOutputs.svelte.ts`、`lib/components/generate/takeDelete.svelte.ts`。
- 重写/修改 `TakeCard`、`TakeFeed`、`Inspector`、`WorkflowPicker`、`ShellPop`、
`StudioGenerate`、`routes/tasks/+page.svelte`、`ToastHost`、`lib/toast.ts`、
`app.css`(新增 `.a-chip.sel` 变体)。
- i18n 七语种各 +17 键(undo/missing/redo/remove/loadEarlier/tasks 等)。
## 验证
- 假 ComfyUI 隔离栈(fake server + 独立后端 + vite)54 项浏览器断言全过:
状态四色、金框、交叉淡变、自动播放、键盘全套、删除撤销、文件缺失、工作流
删除标识、任务页芯片/进度条/搜索、窄屏无溢出、减动效。另有 6 项 220 条
分页专项:首屏只渲染 80 条、「显示更早」追加、全量展开、50 次 jobs 轮询
(220 卡在屏)总耗时 490ms。真机(.15,无显卡)未验证真实生成,另行单列。
- `npm run check` 1 错 6 警(与基线完全一致,唯一错误为基线遗留
`PromptPreviewModal.svelte:125`);`npm run build`/`npm test` 通过;
后端 pytest 791 过 5 败(5 败为基线同款,干净基线复核一致)。
- 后端 `/api/playground/jobs` 单次返回上限 50(既有契约,本批未改),
因此现网轮询天然不超 50 行;80 条分页是其上的防御层,220 条专项用
路由拦截验证。
+6
View File
@@ -0,0 +1,6 @@
logs-*.txt
run.log
shots/
fake_mode.json
pid-*
node_modules/
+49
View File
@@ -0,0 +1,49 @@
"""Seed ~220 quick-finished jobs to check the feed at 200+ records (§五)."""
import json
import time
import urllib.request
BASE = "http://127.0.0.1:8247"
def call(method, path, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
BASE + path, data=data, method=method, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as r:
body = r.read()
return json.loads(body) if body else {}
def main():
n0 = len(call("GET", "/api/playground/jobs"))
target = 230
prompts = [
"雨后霓虹街道", "水墨山水长卷", "复古胶片人像", "微缩城市模型",
"极光下的雪原", "蒸汽朋克飞行器", "油画风格的果园", "赛博废墟",
]
for i in range(target - n0):
call(
"POST",
"/api/playground/generate",
{
"workflow_id": 1,
"input_values": {1: {"text": f"{prompts[i % len(prompts)]} #{i}"}, 2: i},
"source": "studio-image",
},
)
time.sleep(0.15)
# wait for the queue to drain
for _ in range(600):
jobs = call("GET", "/api/playground/jobs")
active = [j for j in jobs if j["status"] in ("pending", "running")]
if not active:
print(f"done: {len(jobs)} jobs")
return
time.sleep(1)
print("timeout waiting for drain")
if __name__ == "__main__":
main()
+145
View File
@@ -0,0 +1,145 @@
"""Standalone fake ComfyUI for HEL-654 D-batch / HEL-655 E-batch browser validation.
Mode file e2e/fake_mode.json:
{"mode": "ok"|"fail", "delay_sec": 0, "videos": false,
"object_info": {class_type: {...}}, "no_outputs": false}
- ok + delay 0: history is ready immediately (job completes fast)
- ok + delay N: history appears N seconds after /prompt (progress state)
- fail: /prompt returns 500 (job fails with a Chinese error from the worker)
- object_info: when present, /object_info serves this catalogue verbatim —
the E-batch script builds it from the workflow it imports so the L2 check
can be driven all-green (every class present) or missing-node (catalogue
omits classes). Absent → {} (every node "missing").
- no_outputs: history entries complete with no files (test-run no-output pass)
"""
from __future__ import annotations
import json, threading, uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).parent
MODE_FILE = ROOT / "fake_mode.json"
MP4 = ROOT / "tiny.mp4"
PNG_1PX = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d4944415478da63f8cfc0f01f0005000105ab5e30760000000049454e44ae426082"
)
KNOWN = {"SaveImage", "PreviewImage", "SaveImageAdvanced", "SaveImageWebsocket",
"SaveVideo", "VideoOutput", "VHS_VideoCombine", "AnimateDiffCombine"}
def read_mode() -> dict:
try:
return json.loads(MODE_FILE.read_text())
except Exception:
return {"mode": "ok", "delay_sec": 0}
class Handler(BaseHTTPRequestHandler):
server_version = "FakeComfy/1.0"
def do_GET(self):
if self.path == "/system_stats":
self._json(200, {"system": {"comfyui_version": "0.3.72-fake", "os": "linux"},
"devices": [{"name": "FakeGPU", "type": "cuda"}]})
return
if self.path == "/object_info":
self._json(200, read_mode().get("object_info") or {})
return
if self.path == "/queue":
# Prompts accepted but not yet in history: first one "running",
# the rest pending — so the worker walks comfy_queued →
# comfy_running like a real ComfyUI.
live = [p for p in self.server.accepted if p not in self.server.history]
running = [[1, live[0]]] if live else []
pending = [[2, p] for p in live[1:]]
self._json(200, {"queue_running": running, "queue_pending": pending})
return
if self.path.startswith("/history/"):
pid = self.path[len("/history/"):]
entry = self.server.history.get(pid)
self._json(200, {} if entry is None else {pid: entry})
return
if self.path.startswith("/view"):
if "fmt=mp4" in self.path or ".mp4" in self.path:
self._raw(200, MP4.read_bytes(), "video/mp4")
else:
self._raw(200, PNG_1PX, "image/png")
return
self._json(404, {"error": "not found"})
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length else b""
if self.path == "/prompt":
mode = read_mode()
if mode.get("mode") == "fail":
self._json(500, {"error": "boom", "node_errors": []})
return
pid = uuid.uuid4().hex
self.server.accepted.append(pid)
videos = mode.get("videos") is True
no_outputs = mode.get("no_outputs") is True
out_name = "out-1.mp4" if videos else "out-1.png"
key = "videos" if videos else "images"
outputs = {} if no_outputs else {
self._output_node(body): {key: [{"filename": out_name, "subfolder": "", "type": "output"}]}
}
entry = {
"status": {"status_str": "success", "completed": True},
"outputs": outputs,
"prompt": [],
}
delay = float(mode.get("delay_sec") or 0)
if delay <= 0:
self.server.history[pid] = entry
else:
threading.Timer(delay, lambda: self.server.history.setdefault(pid, entry)).start()
self.server.prompts.append(json.loads(body.decode("utf-8") or "{}"))
self._json(200, {"prompt_id": pid})
return
if self.path == "/upload/image":
self.server.uploads.append(body)
self._json(200, {"name": "uploaded-input.png", "subfolder": "calliope"})
return
self._json(404, {"error": "not found"})
def _output_node(self, body: bytes) -> str:
try:
prompt = json.loads(body.decode("utf-8") or "{}").get("prompt") or {}
except json.JSONDecodeError:
prompt = {}
for nid, node in prompt.items():
if isinstance(node, dict) and node.get("class_type") in KNOWN:
return str(nid)
return "9"
def _json(self, status, payload):
data = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _raw(self, status, data, ctype):
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, fmt, *args):
pass
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", 18188), Handler)
server.history = {}
server.prompts = []
server.uploads = []
server.accepted = []
print("fake comfy on http://127.0.0.1:18188", flush=True)
server.serve_forever()
+588
View File
@@ -0,0 +1,588 @@
/* HEL-655 E 批 browser validation against the isolated stack:
* vite dev (5173) → backend (8247) → fake ComfyUI (18188).
* Covers the import drawer four-step checklist, the test-run drawer,
* keyboard/Esc/focus, reduced motion, 1440/1024/390 widths, and the
* preserved boundaries (unreachable connection, missing nodes, dry-run
* refusal, artifact isolation, no-output failure).
* Prints PASS/FAIL per check; screenshots land in e2e/shots/hel655-*.png. */
import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const SHOTS = path.join(ROOT, "shots");
fs.mkdirSync(SHOTS, { recursive: true });
const API = "http://127.0.0.1:8247";
const MODE_FILE = path.join(ROOT, "fake_mode.json");
const setMode = (m) => fs.writeFileSync(MODE_FILE, JSON.stringify(m));
const baseMode = () => ({ mode: "ok", delay_sec: 0 });
let failures = 0;
function ok(name, cond, extra = "") {
const line = `${cond ? "PASS" : "FAIL"} ${name}${extra ? " — " + extra : ""}`;
console.log(line);
if (!cond) failures++;
}
const api = async (p, init) => {
const r = await fetch(API + p, {
headers: { "Content-Type": "application/json" },
...init,
});
const body = await r.text();
try {
return JSON.parse(body);
} catch {
return { raw: body, status: r.status };
}
};
/* ---------------- workflow fixtures ---------------- */
const WF_NAME = `E批·文生图回归 ${Date.now() % 10000}`;
const T2I = {
1: {
class_type: "CLIPTextEncode",
inputs: { text: "默认提示词" },
_meta: { title: "(Input:prompt) 正向提示词" },
},
9: {
class_type: "SaveImage",
inputs: { images: "x" },
_meta: { title: "(Output:image) 图片输出" },
},
};
/** Self-consistent /object_info catalogue for a workflow (backend parity:
* every class present, every string widget a combo listing its value). */
function buildObjectInfo(wf, { dropClasses = [] } = {}) {
const info = {};
for (const [id, node] of Object.entries(wf)) {
const cls = node.class_type;
if (!cls || dropClasses.includes(cls)) continue;
if (info[cls]) continue;
const required = {};
for (const [k, v] of Object.entries(node.inputs ?? {})) {
if (typeof v === "string") required[k] = [[v], {}];
else if (typeof v === "number")
required[k] = Number.isInteger(v)
? ["INT", { min: 0, max: 2 ** 31 }]
: ["FLOAT", { min: 0, max: 1e6 }];
}
info[cls] = { input: { required }, output: ["IMAGE"] };
}
return info;
}
/* ---------------- stack prep ---------------- */
const seeded = (async () => {
// one fake endpoint + one imported t2i workflow bound to it
for (const e of (await api("/api/comfyui/endpoints")).endpoints ?? []) {
if (e.base_url === "http://127.0.0.1:18188")
await api(`/api/comfyui/endpoints/${e.id}`, { method: "DELETE" });
}
let r = await api("/api/comfyui/endpoints", {
method: "POST",
body: JSON.stringify({
name: "假 ComfyUI",
base_url: "http://127.0.0.1:18188",
auth_type: "none",
}),
});
const ep = r.endpoints.find((e) => e.base_url === "http://127.0.0.1:18188");
await api(`/api/comfyui/endpoints/${ep.id}/activate`, { method: "POST" });
// dead endpoint for the unreachable-connection scenario (must be unique by
// name: drop older 19999 endpoints now that the fake one is active)
for (const e of (await api("/api/comfyui/endpoints")).endpoints ?? []) {
if (e.base_url === "http://127.0.0.1:19999")
await api(`/api/comfyui/endpoints/${e.id}`, { method: "DELETE" });
}
await api("/api/comfyui/endpoints", {
method: "POST",
body: JSON.stringify({
name: "断开的连接",
base_url: "http://127.0.0.1:19999",
auth_type: "none",
}),
});
return { endpointId: ep.id };
})();
/* The connections page keeps its own add-connection side form open when the
* viewport shrinks below 1439 — close it so it doesn't cover the library. */
async function closeSideFormIfOpen(page) {
const close = page.locator(".form-col.open .form-head .icon-btn");
if ((await page.locator(".form-col.open").count()) === 0) return;
if ((await close.count()) > 0) {
await close.first().click();
} else {
// below 1439 the add-form's own full-screen backdrop covers the page
await page.mouse.click(20, 400);
}
await page.waitForTimeout(250);
}
const run = async () => {
const { endpointId } = await seeded;
const browser = await chromium.launch();
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
});
const page = await ctx.newPage();
page.on("pageerror", (e) => console.log("PAGEERROR:", e.message));
await page.goto("http://127.0.0.1:5173/connections");
await page.waitForSelector(".wf-panel", { timeout: 15000 });
// ---------- 1. 导入入口统一:按钮打开右侧抽屉 ----------
await page
.getByRole("button", { name: /导入工作流/ })
.first()
.click();
await page.waitForSelector(".drawer-panel");
const panel = page.locator(".drawer-panel");
ok(
"导入抽屉从右侧滑入(挂载即向左滑动)",
await panel.evaluate((el) => {
const name = getComputedStyle(el).animationName;
return name.includes("drawer-slide") || name === "none";
}),
);
await page.waitForTimeout(360); // let the 280ms drawer settle
const box = await panel.boundingBox();
ok("抽屉宽度 380px", Math.round(box.width) === 380, `w=${box.width}`);
ok("抽屉贴右缘", Math.abs(box.x + box.width - 1440) < 2, `x=${box.x}`);
await page.screenshot({ path: `${SHOTS}/hel655-01-import-input-1440.png` });
// ---------- 2. Step1:无效 JSON 的中文结论 ----------
await page
.locator(".drawer-panel textarea.a-textarea")
.fill('{"不是":"API 格式"}');
ok(
"无效 JSON 显示中文错误",
await page
.locator(".drawer-panel .field-error")
.textContent()
.then((s) => s.includes("API 格式")),
);
ok(
"无效 JSON 时「分析」禁用",
await page.locator(".drawer-panel .a-btn.primary").isDisabled(),
);
await page
.locator(".drawer-panel textarea.a-textarea")
.fill(JSON.stringify(T2I));
await page.locator(".drawer-panel input.a-input").fill(WF_NAME);
await page
.locator(".drawer-panel select.a-select")
.selectOption({ label: "假 ComfyUI" });
// ---------- 3. 四步检查 · 全绿 ----------
setMode({ ...baseMode(), object_info: buildObjectInfo(T2I) });
await page.locator(".drawer-panel .a-btn.primary").click();
await page.waitForSelector(".drawer-panel .checks .check");
const t0 = Date.now();
await page.waitForFunction(
() =>
document.querySelectorAll(".drawer-panel .checks .check").length === 4,
);
const spread = Date.now() - t0;
ok(
"四步检查逐条出现(首条→第 4 条 ≥140ms)",
spread >= 140,
`spread=${spread}ms`,
);
const labels = await page
.locator(".drawer-panel .checks .check-label")
.allTextContents();
ok(
"四步顺序:解析 JSON→识别节点→核对模型→建立输入槽",
labels[0].includes("解析") &&
labels[1].includes("识别") &&
labels[2].includes("核对") &&
labels[3].includes("输入槽"),
labels.join("|"),
);
const states = await page
.locator(".drawer-panel .checks .check")
.evaluateAll((els) =>
els.map((el) => ({
ok: el.className.includes("state-ok"),
color: getComputedStyle(el.querySelector(".mark")).color,
})),
);
ok(
"① 解析 JSON 绿 ✓",
states[0].ok && states[0].color === "rgb(73, 203, 144)",
states[0].color,
);
ok(
"② 识别节点判定文生图(绿)",
states[1].ok,
await page
.locator(".drawer-panel .checks .check")
.nth(1)
.locator(".check-line")
.textContent(),
);
ok(
"③ 核对模型全绿(假 object_info 装齐)",
states[2].ok && states[2].color === "rgb(73, 203, 144)",
states[2].color,
);
ok("④ 建立输入槽(1 参数 0 媒体)", states[3].ok);
await page.screenshot({
path: `${SHOTS}/hel655-02-checks-allgreen-1440.png`,
});
// ---------- 4. 缺节点:③ 红 + 可展开错误 + 重试恢复 ----------
setMode({
...baseMode(),
object_info: buildObjectInfo(T2I, { dropClasses: ["SaveImage"] }),
});
await page
.locator(".drawer-panel .checks .check")
.nth(2)
.locator(".recheck")
.click();
await page.waitForFunction(() => {
const el = document.querySelectorAll(".drawer-panel .checks .check")[2];
return el && el.className.includes("state-fail");
});
const failLine = await page
.locator(".drawer-panel .checks .check")
.nth(2)
.locator(".check-line")
.textContent();
ok("③ 缺节点显示中文问题数", /1/.test(failLine), failLine);
const finding = await page
.locator(".drawer-panel .findings li .finding-msg")
.first()
.textContent();
ok(
"错误详情可展开且为中文原因",
finding.includes("缺少自定义节点"),
finding.slice(0, 40),
);
ok(
"给出下一步建议",
(
await page
.locator(".drawer-panel .checks .check .suggest")
.allTextContents()
).some((s) => s.includes("重新检查")),
);
ok(
"缺节点不阻断保存(沿用既有边界)",
!(await page.locator(".drawer-panel .a-btn.primary").last().isDisabled()),
);
await page.screenshot({ path: `${SHOTS}/hel655-03-missing-node-1440.png` });
setMode({ ...baseMode(), object_info: buildObjectInfo(T2I) });
await page
.locator(".drawer-panel .checks .check")
.nth(2)
.locator(".recheck")
.click();
await page.waitForFunction(() => {
const el = document.querySelectorAll(".drawer-panel .checks .check")[2];
return el && el.className.includes("state-ok");
});
ok("「重新检查」复查后恢复绿", true);
// ---------- 5. 断开的连接:琥珀「无法核查」,不伪装通过 ----------
await page.locator(".drawer-panel .a-btn.secondary").first().click(); // 上一步
await page
.locator(".drawer-panel select.a-select")
.selectOption({ label: "断开的连接" });
await page.locator(".drawer-panel .a-btn.primary").click(); // 重新分析
await page.waitForFunction(
() =>
document.querySelectorAll(".drawer-panel .checks .check").length === 4,
);
await page.waitForFunction(() => {
const el = document.querySelectorAll(".drawer-panel .checks .check")[2];
return el && el.className.includes("state-warn");
});
const warnColor = await page
.locator(".drawer-panel .checks .check")
.nth(2)
.locator(".mark")
.evaluate((el) => getComputedStyle(el).color);
ok(
"无法核查 = 琥珀(非绿非红)",
warnColor === "rgb(240, 180, 92)",
warnColor,
);
ok(
"无法核查给中文原因与建议",
(
await page
.locator(".drawer-panel .checks .check")
.nth(2)
.locator(".check-line")
.textContent()
).includes("无法核查"),
);
await page.screenshot({ path: `${SHOTS}/hel655-04-unreachable-1440.png` });
// ---------- 6. 保存导入(回到可用连接) ----------
await page.locator(".drawer-panel .a-btn.secondary").first().click(); // 上一步
await page
.locator(".drawer-panel select.a-select")
.selectOption({ label: "假 ComfyUI" });
await page.locator(".drawer-panel .a-btn.primary").click(); // 分析
await page.waitForFunction(
() =>
document.querySelectorAll(".drawer-panel .checks .check").length === 4,
);
await page.locator(".drawer-panel .a-btn.primary").last().click(); // 确认保存
await page.waitForSelector(".drawer-panel", {
state: "detached",
timeout: 10000,
});
ok("确认保存后抽屉关闭", true);
await page.waitForFunction(
(n) => document.body.textContent.includes(n),
WF_NAME,
);
ok("工作流入库名称正确", true);
// ---------- 7. 试运行抽屉:全链路(进行中→完成→隔离) ----------
const wfId = (await api("/api/workflows")).find(
(w) => w.name === WF_NAME,
)?.id;
ok("已导入工作流可查询", !!wfId);
const row = page.locator(".wf-row", { hasText: WF_NAME });
await row.locator(".wf-test-btn").first().click();
await page.waitForSelector(".drawer-panel");
const tbox = await page.locator(".drawer-panel").boundingBox();
ok(
"试运行抽屉 420px 右侧",
Math.round(tbox.width) === 420,
`w=${tbox.width}`,
);
await page.screenshot({ path: `${SHOTS}/hel655-05-test-form-1440.png` });
// dry_run 拒绝(边界保留)
await api("/api/settings", {
method: "POST",
body: JSON.stringify({ dry_run: true }),
});
await page.locator(".drawer-panel .a-btn.primary").click();
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
await page.waitForFunction(() => {
const els = [...document.querySelectorAll(".drawer-panel .field-error")];
return els.some((e) => e.textContent.includes("Dry-run"));
});
ok("dry_run 下试运行被拒绝(中文原因)", true);
await api("/api/settings", {
method: "POST",
body: JSON.stringify({ dry_run: false }),
});
// 真实试运行:进行中(青进度 + 阶段日志)→ 通过(绿 + 缩略图横排)
setMode({ mode: "ok", delay_sec: 3, object_info: buildObjectInfo(T2I) });
await page.locator(".drawer-panel .a-btn.primary").click();
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
await page.waitForSelector(".drawer-panel .wft-active", { timeout: 8000 });
await page.waitForSelector(".drawer-panel .wft-active .chip.run", {
timeout: 8000,
});
const chipColor = await page
.locator(".drawer-panel .wft-active .chip.run")
.evaluate((el) => getComputedStyle(el).color);
ok("进行中状态为信号青", chipColor === "rgb(84, 223, 210)", chipColor);
ok(
"进行中显示进度条",
(await page.locator(".drawer-panel .wft-active .track").count()) === 1,
);
await page.waitForSelector(".drawer-panel .wft-log p", { timeout: 6000 });
const logText = await page.locator(".drawer-panel .wft-log").textContent();
ok(
"阶段日志为中文滚动区",
/队列|排队|连接|运行|保存|执行|ComfyUI/.test(logText),
logText.slice(0, 30),
);
ok(
"阶段日志为 mono 字体",
await page
.locator(".drawer-panel .wft-log")
.evaluate((el) => getComputedStyle(el).fontFamily.includes("Mono")),
);
await page.screenshot({ path: `${SHOTS}/hel655-06-test-running-1440.png` });
await page.waitForSelector(".drawer-panel .wft-result.ok", {
timeout: 30000,
});
const verdict = await page
.locator(".drawer-panel .wft-verdict")
.textContent();
ok("只有真实输出才算通过(绿判定)", verdict.includes("测试通过"), verdict);
const thumbs = await page
.locator(".drawer-panel .wft-outputs .wft-out")
.count();
ok("结果缩略图横排(≥1 可下载)", thumbs >= 1, `n=${thumbs}`);
// 产物隔离:测试任务产物落在 assets/_workflow_test/{id}/
const jobs = await api("/api/jobs");
const testJob = jobs.find(
(j) =>
j.workflow_id === wfId &&
j.status === "done" &&
(j.output_paths ?? []).length,
);
ok(
"试运行产物与普通素材隔离(_workflow_test/)",
!!testJob && (testJob.output_paths ?? [])[0].includes("_workflow_test"),
testJob?.output_paths?.[0],
);
await page.screenshot({ path: `${SHOTS}/hel655-07-test-passed-1440.png` });
// ---------- 8. 失败路径:执行报错 红 + 中文 ----------
setMode({ mode: "fail" });
await page.waitForSelector(".drawer-panel .wft-form", { timeout: 10000 });
await page.locator(".drawer-panel .a-btn.primary").click();
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
await page.waitForSelector(".drawer-panel .wft-result.bad", {
timeout: 30000,
});
const badColor = await page
.locator(".drawer-panel .wft-result.bad .wft-verdict")
.evaluate((el) => getComputedStyle(el).color);
ok(
"失败判定为红 + 中文标题",
badColor === "rgb(240, 100, 90)",
await page
.locator(".drawer-panel .wft-result.bad .wft-verdict")
.textContent(),
);
ok(
"失败给出具体原因",
((await page.locator(".drawer-panel .wft-error").textContent()) ?? "")
.length > 0,
);
await page.screenshot({ path: `${SHOTS}/hel655-08-test-failed-1440.png` });
// ---------- 9. 无输出:不算通过 ----------
setMode({ mode: "ok", delay_sec: 0, no_outputs: true });
await page.waitForSelector(".drawer-panel .wft-form", { timeout: 10000 });
await page.locator(".drawer-panel .a-btn.primary").click();
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
await page.waitForFunction(
() => {
const bad = document.querySelector(".drawer-panel .wft-result.bad");
if (!bad) return false;
// the previous fail verdict also matches .bad — wait until the error text changes
return (
!bad.textContent.includes("boom") || bad.textContent.includes("输出")
);
},
{ timeout: 30000 },
);
ok("无输出不判通过(红/失败结论)", true);
setMode({ ...baseMode() });
// ---------- 10. 键盘:Esc 关闭、焦点圈、焦点回归 ----------
await page.keyboard.press("Escape");
await page.waitForSelector(".drawer-panel", { state: "detached" });
ok("Esc 关闭试运行抽屉", true);
await row.locator(".wf-test-btn").first().click();
await page.waitForSelector(".drawer-panel");
await page.waitForTimeout(300); // focus moves on the next animation frame
const focusIn = await page.evaluate(
() => document.activeElement.closest(".drawer-panel") !== null,
);
ok("抽屉打开后焦点进入面板", focusIn);
for (let i = 0; i < 12; i++) await page.keyboard.press("Tab");
const stillIn = await page.evaluate(
() => document.activeElement.closest(".drawer-panel") !== null,
);
ok("Tab 在抽屉内循环(焦点不逃逸)", stillIn);
await page.keyboard.press("Escape");
await page.waitForSelector(".drawer-panel", { state: "detached" });
const backOnBtn = await page.evaluate(
() =>
document.activeElement?.getAttribute("title") === "测试工作流" ||
(document.activeElement?.textContent ?? "").trim().startsWith("测试"),
);
ok("关闭后焦点回到触发按钮", backOnBtn === true);
// ---------- 11. 减少动态效果:抽屉只留透明度 ----------
await page.evaluate(() => (document.documentElement.dataset.reduce = "1"));
await page
.getByRole("button", { name: /导入工作流/ })
.first()
.click();
await page.waitForSelector(".drawer-panel");
const anim = await page
.locator(".drawer-panel")
.evaluate((el) => getComputedStyle(el).animationName);
ok("减动效时抽屉无位移动画(仅淡变)", anim.includes("drawer-fade"), anim);
await page.keyboard.press("Escape");
await page.evaluate(() => delete document.documentElement.dataset.reduce);
// ---------- 12. 中屏 1024 / 窄屏 390 ----------
await page.setViewportSize({ width: 1024, height: 768 });
await closeSideFormIfOpen(page);
await page
.getByRole("button", { name: /导入工作流/ })
.first()
.click();
await page.waitForSelector(".drawer-panel");
await page.waitForTimeout(320);
const mid = await page.locator(".drawer-panel").boundingBox();
ok(
"中屏抽屉仍 380px 贴右",
Math.round(mid.width) === 380 && Math.abs(mid.x + mid.width - 1024) < 2,
);
await page.screenshot({ path: `${SHOTS}/hel655-09-import-1024.png` });
await page.keyboard.press("Escape");
await page.setViewportSize({ width: 390, height: 844 });
await closeSideFormIfOpen(page);
await page
.getByRole("button", { name: /导入工作流/ })
.first()
.click();
await page.waitForSelector(".drawer-panel");
await page.waitForTimeout(320);
const narrow = await page.locator(".drawer-panel").boundingBox();
ok("窄屏抽屉全屏宽", Math.round(narrow.width) === 390, `w=${narrow.width}`);
const overflow = await page.evaluate(
() =>
document.scrollingElement.scrollWidth -
document.scrollingElement.clientWidth,
);
ok("窄屏无横向溢出", overflow <= 0, `overflow=${overflow}px`);
await page.screenshot({ path: `${SHOTS}/hel655-10-import-390.png` });
await page.keyboard.press("Escape");
await page.waitForSelector(".drawer-panel", { state: "detached" });
await page.setViewportSize({ width: 1440, height: 900 });
// ---------- 13. 试运行历史与清除 ----------
await row.locator(".wf-test-btn").first().click();
await page.waitForSelector(".drawer-panel");
await page.waitForSelector(".drawer-panel .wft-rows li", { timeout: 8000 });
const histCount = await page.locator(".drawer-panel .wft-rows li").count();
ok("测试记录列表保留", histCount >= 2, `n=${histCount}`);
await page.locator(".drawer-panel .link-danger").click();
await page.waitForFunction(
() => document.querySelectorAll(".drawer-panel .wft-rows li").length === 0,
{ timeout: 8000 },
);
ok("清除测试记录生效", true);
await page.keyboard.press("Escape");
await browser.close();
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILURES`);
process.exit(failures === 0 ? 0 : 1);
};
run().catch(async (e) => {
console.error("FATAL", e?.message?.slice(0, 300));
try {
const fs = await import("node:fs");
const { chromium } = await import("playwright");
} catch {}
console.error("FATAL", e);
process.exit(1);
});
+74
View File
@@ -0,0 +1,74 @@
/* Pagination + perf check (§五): intercept the jobs API and serve 220
* synthetic takes so the >80 path engages without touching the backend. */
import { chromium } from 'playwright';
let failures = 0;
const ok = (name, cond, extra = '') => {
console.log(`${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
if (!cond) failures++;
};
const run = async () => {
const browser = await chromium.launch();
const page = await (
await browser.newContext({ viewport: { width: 1440, height: 900 } })
).newPage();
const base = 'http://127.0.0.1:5173';
let realJobs = [];
await page.route('**/api/playground/jobs**', async (route) => {
const r = await route.fetch();
realJobs = await r.json();
// Fan the real list out to 220 takes with distinct ids/timestamps.
const fan = [];
for (let i = 0; i < 220; i++) {
const src = realJobs[i % realJobs.length] ?? realJobs[0];
fan.push({
...src,
id: 9000 + i,
status: 'done',
output_paths: src.output_paths,
created_at: new Date(Date.now() - i * 47 * 60 * 1000).toISOString(),
});
}
await route.fulfill({ json: fan });
});
const t0 = Date.now();
await page.goto(`${base}/image`);
await page.waitForSelector('#desk-prompt', { timeout: 15000 });
await page.waitForSelector('.take');
await page.waitForTimeout(800);
const rendered = await page.locator('.take').count();
ok('220 条记录时首屏只渲染最近 80 条', rendered === 80, `rendered ${rendered}`);
const btn = page.locator('.earlier');
const label = await btn.textContent();
ok('出现「显示更早」按钮并带余量', /140/.test(label || ''), (label || '').trim());
await btn.click();
await page.waitForTimeout(600);
ok('点击后追加 80 条(共 160)', (await page.locator('.take').count()) === 160);
await page.locator('.earlier').click();
await page.waitForTimeout(600);
ok('再点展开全部 220 条', (await page.locator('.take').count()) === 220);
const loadMs = Date.now() - t0;
ok('整段交互在 3 秒内完成', loadMs < 3000, `${loadMs}ms`);
// scripted-painting budget while 220 cards live (SSE 只更新相关行:
// invalidate jobs → 220 卡重排仍是 keyed 更新)
const metrics = await page.evaluate(async () => {
const t = performance.now();
for (let i = 0; i < 50; i++) await fetch('/api/playground/jobs');
return { poll50: Math.round(performance.now() - t) };
});
ok('50 次 jobs 轮询(220 卡在屏)总耗时 < 8 秒', metrics.poll50 < 8000, `${metrics.poll50}ms`);
await page.screenshot({ path: 'shots/13-pagination-220.png' });
await browser.close();
console.log(failures === 0 ? '\nPAGINATION CHECKS PASSED' : `\n${failures} FAILED`);
process.exit(failures ? 1 : 0);
};
run().catch((e) => {
console.error('SCRIPT ERROR:', e);
process.exit(2);
});
+75
View File
@@ -0,0 +1,75 @@
"""Seed the isolated backend: one fake-ComfyUI endpoint + two workflows."""
import json
import sys
import urllib.request
BASE = "http://127.0.0.1:8247"
def call(method, path, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
BASE + path, data=data, method=method, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req) as r:
body = r.read()
return json.loads(body) if body else {}
IMAGE_WF = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "默认提示词"},
"_meta": {"title": "(Input:prompt) 正向提示词"},
},
"2": {
"class_type": "PrimitiveInt",
"inputs": {"value": 42},
"_meta": {"title": "(Input:seed) 种子"},
},
"9": {
"class_type": "SaveImage",
"inputs": {"images": "x"},
"_meta": {"title": "(Output:image) 图片输出"},
},
}
VIDEO_WF = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "镜头缓慢推近"},
"_meta": {"title": "(Input:prompt) 正向提示词"},
},
"2": {
"class_type": "PrimitiveFloat",
"inputs": {"value": 6},
"_meta": {"title": "(Input:duration) 时长"},
},
"9": {
"class_type": "VHS_VideoCombine",
"inputs": {},
"_meta": {"title": "(Output:video) 视频输出"},
},
}
if __name__ == "__main__":
# Idempotent: drop same-named workflows and the old fake endpoint first.
for w in call("GET", "/api/workflows"):
if w["name"] in ("文生图·回归", "文生视频·回归"):
call("DELETE", f"/api/workflows/{w['id']}")
for e in call("GET", "/api/comfyui/endpoints")["endpoints"]:
if e["base_url"] == "http://127.0.0.1:18188":
call("DELETE", f"/api/comfyui/endpoints/{e['id']}")
r = call(
"POST",
"/api/comfyui/endpoints",
{"name": "假 ComfyUI", "base_url": "http://127.0.0.1:18188", "auth_type": "none"},
)
eps = [e for e in r["endpoints"] if e["base_url"] == "http://127.0.0.1:18188"]
call("POST", f"/api/comfyui/endpoints/{eps[0]['id']}/activate")
img_id = call(
"POST", "/api/workflows", {"name": "文生图·回归", "kind": "image", "workflow_json": IMAGE_WF}
)["id"]
vid_id = call(
"POST", "/api/workflows", {"name": "文生视频·回归", "kind": "video", "workflow_json": VIDEO_WF}
)["id"]
print(json.dumps({"image_wf": img_id, "video_wf": vid_id, "endpoint": eps[0]["id"]}))
+362
View File
@@ -0,0 +1,362 @@
/* HEL-654 D 批 browser validation against the isolated stack:
* vite dev (5173) → backend (8247) → fake ComfyUI (18188).
* Prints PASS/FAIL per check and writes screenshots to e2e/shots/. */
import { chromium } from 'playwright';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const SHOTS = path.join(ROOT, 'shots');
fs.mkdirSync(SHOTS, { recursive: true });
const API = 'http://127.0.0.1:8247';
const MODE_FILE = path.join(ROOT, 'fake_mode.json');
const setMode = (m) => fs.writeFileSync(MODE_FILE, JSON.stringify(m));
let failures = 0;
function ok(name, cond, extra = '') {
const line = `${cond ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`;
console.log(line);
if (!cond) failures++;
}
const api = async (path, init) => {
const r = await fetch(API + path, {
headers: { 'Content-Type': 'application/json' },
...init,
});
const body = await r.text();
try {
return JSON.parse(body);
} catch {
return { raw: body, status: r.status };
}
};
async function generate(prompt, wfId = 1, kind = 'image', source = 'studio-image') {
const payload = kind === 'image'
? { workflow_id: wfId, input_values: { 1: { text: prompt }, 2: 42 }, source }
: { workflow_id: wfId, input_values: { 1: { text: prompt }, 2: 6 }, source };
const r = await api('/api/playground/generate', { method: 'POST', body: JSON.stringify(payload) });
return r.job;
}
async function waitStatus(jobId, statuses, timeout = 40000) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const jobs = await api('/api/playground/jobs');
const j = jobs.find((x) => x.id === jobId);
if (j && statuses.includes(j.status)) return j;
await new Promise((r) => setTimeout(r, 300));
}
return null;
}
async function cardCount(page) {
return page.locator('.take').count();
}
async function tagOfCard(page, idx) {
return (await page.locator('.take').nth(idx).locator('.state .a-tag').first().textContent())?.trim();
}
const stateTag = (page, hasText) => page.locator('.take', { hasText }).locator('.state .a-tag').first();
const run = async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message));
// ---------- 工作流选择器:↑↓ 高亮 + 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');
const nActive = await activeRows.count();
ok('工作流面板有默认高亮行', nActive === 1);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
const label1 = await activeRows.first().textContent();
await page.keyboard.press('ArrowUp');
const label2 = await activeRows.first().textContent();
ok('↑↓ 移动高亮(选择)', label1 !== label2, `${(label1 || '').slice(0, 18)} → ${(label2 || '').slice(0, 18)}`);
const chipBefore = await page.locator('#desk-wf .lbl').textContent();
await page.keyboard.press('Enter');
await page.waitForTimeout(400);
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` });
// 回到文生图工作流
await page.click('#desk-wf');
await page.fill('.shell-pop .search-input', '文生图');
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
// ---------- 提交 → 完成(绿标签 + 自动切结果页) ----------
setMode({ mode: 'ok', delay_sec: 0 });
await page.fill('#desk-prompt', '一只戴墨镜的柴犬,胶片质感');
await page.click('.a-btn.primary'); // 生成
await page.waitForTimeout(500);
// 参数页提交后自动切结果页(C 审核项 1):切到参数再提交
await page.click('.inspector .a-seg button[aria-pressed="false"]'); // 参数
ok('检视区在参数页', await page.locator('.inspector .a-seg button[aria-pressed="true"]').textContent() === '参数');
await page.fill('#desk-prompt', '雪山顶上的天文台,星轨长曝光');
await page.click('.a-btn.primary');
await page.waitForTimeout(800);
const tabNow = await page.locator('.inspector .a-seg button[aria-pressed="true"]').textContent();
ok('从参数页提交后自动切回结果页', tabNow.trim() === '结果');
const done1 = await waitStatus(1, ['done']);
ok('假 ComfyUI:任务完成', !!done1);
await page.waitForTimeout(1200);
// 绿标签
const doneCard = page.locator('.take', { hasText: '柴犬' }).first();
const doneTag = await doneCard.locator('.state .a-tag').first().textContent();
ok('完成态为绿色「完成」标签(非金色)', (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);
await page.screenshot({ path: `${SHOTS}/02-desk-done.png` });
// ---------- 慢任务:排队灰 + 运行青 + 进度条 ----------
setMode({ mode: 'ok', delay_sec: 10 });
await page.fill('#desk-prompt', '缓慢生成的测试任务,用于观察进度');
await page.click('.a-btn.primary'); // 第一个:将进入 running
await page.waitForTimeout(300);
await page.fill('#desk-prompt', '排在其后的第二个任务');
await page.click('.a-btn.primary'); // 第二个:queued 灰
await page.waitForTimeout(4000);
const runTag = await stateTag(page, '观察进度').textContent();
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);
const hasBar = (await page.locator('.take', { hasText: '观察进度' }).locator('.a-progress').count()) > 0;
ok('运行中卡片有进度条', hasBar);
const queueTag = await stateTag(page, '第二个任务').textContent();
ok('排队为灰色中性标签(不亮灯)', /排队|等待|受理/.test(queueTag || ''), queueTag);
const queueColor = await stateTag(page, '第二个任务').evaluate((el) => getComputedStyle(el).color);
ok('排队标签为中性灰(非四色)', !['rgb(73, 203, 144)', 'rgb(84, 223, 210)', 'rgb(240, 100, 90)', 'rgb(240, 180, 92)'].includes(queueColor), queueColor);
await page.screenshot({ path: `${SHOTS}/03-desk-running-queued.png` });
// 键盘:↑↓/J/K 即时切换(无动画路径)+ 选中框
const selBefore = await page.locator('.take.selected').getAttribute('data-job');
await page.keyboard.press('k'); // 上一条
const selAfterK = await page.locator('.take.selected').getAttribute('data-job');
ok('K 键选中上一条记录', selBefore !== selAfterK, `${selBefore} → ${selAfterK}`);
await page.keyboard.press('j');
const selAfterJ = await page.locator('.take.selected').getAttribute('data-job');
ok('J 键选中下一条记录', selAfterJ !== selAfterK, `${selAfterK} → ${selAfterJ}`);
const hasSelMark = (await page.locator('.take.selected .sel-mark').count()) === 1;
ok('选中卡有金框 sel-mark', hasSelMark);
// ] 收起 / 呼出检视区
await page.keyboard.press(']');
await page.waitForTimeout(400);
const inspOff = await page.locator('.desk.insp-off').count();
ok('] 收起检视区', inspOff === 1);
await page.keyboard.press(']');
await page.waitForTimeout(400);
ok('] 再次呼出检视区', (await page.locator('.desk.insp-off').count()) === 0);
// 等两个任务完成(用于后续状态)
setMode({ mode: 'ok', delay_sec: 0 });
await waitStatus(3, ['done'], 30000);
await waitStatus(4, ['done'], 30000);
// ---------- 失败:红标签 + 中文结论 + 重试 ----------
setMode({ mode: 'fail', delay_sec: 0 });
const failJob = await generate('注定失败的任务');
await waitStatus(failJob.id, ['failed'], 30000);
await page.waitForTimeout(1200);
const failCard = page.locator(`.take[data-job="${failJob.id}"]`);
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);
const errLine = failCard.locator('.err-txt');
ok('失败自动展开一行中文结论', (await errLine.count()) === 1 && (await errLine.textContent()).length > 4);
await errLine.click();
ok('错误行可展开完整详情', (await errLine.getAttribute('aria-expanded')) === 'true');
const retryBtn = failCard.locator('button', { hasText: '重试' });
ok('失败卡片有「重试」', (await retryBtn.count()) >= 1);
await page.screenshot({ path: `${SHOTS}/04-desk-failed.png` });
setMode({ mode: 'ok', delay_sec: 0 });
await retryBtn.first().click();
const retried = await waitStatus(failJob.id, ['done'], 30000);
ok('重试后同参数完成', !!retried);
// ---------- 删除 + 5 秒撤销 ----------
const jobs0 = await api('/api/playground/jobs');
const victim = jobs0.find((j) => j.status === 'done' && j.output_paths.length);
const countBefore = await cardCount(page);
await page.locator(`.take[data-job="${victim.id}"] button`, { hasText: '删除' }).first().hover();
await page.locator(`.take[data-job="${victim.id}"] button`, { hasText: '删除' }).first().click();
await page.waitForTimeout(300);
ok('删除不弹确认框(直接收起)', (await page.locator('.modal').count()) === 0);
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);
await page.screenshot({ path: `${SHOTS}/05-undo-toast.png` });
const countDeleted = await cardCount(page);
ok('删除后行立即收起', countDeleted === countBefore - 1, `${countBefore} → ${countDeleted}`);
await undoBtn.click();
await page.waitForTimeout(500);
ok('撤销后记录回到原位', (await cardCount(page)) === countBefore);
ok('撤销后重新选中该记录', (await page.locator('.take.selected').getAttribute('data-job')) === String(victim.id));
// 不撤销 → 5 秒后真正提交
const victim2 = (await api('/api/playground/jobs')).find((j) => j.id !== victim.id && j.status === 'done');
await page.locator(`.take[data-job="${victim2.id}"] button`, { hasText: '删除' }).first().hover();
await page.locator(`.take[data-job="${victim2.id}"] button`, { hasText: '删除' }).first().click();
await page.waitForTimeout(5800);
const after = await api('/api/playground/jobs');
ok('撤销窗口过后真正调用删除接口', !after.some((j) => j.id === victim2.id));
// Delete 键删除选中
const victim3 = (await api('/api/playground/jobs')).find((j) => j.status === 'done');
await page.locator(`.take[data-job="${victim3.id}"] .hit`).click();
await page.waitForTimeout(200);
await page.keyboard.press('Delete');
ok('Delete 键触发选中记录删除(Toast 撤销)', (await page.locator('.toast .action', { hasText: '撤销' }).count()) === 1);
await page.locator('.toast .action', { hasText: '撤销' }).click();
// ---------- 文件缺失:琥珀 + 重做 / 移除 ----------
const solid = (await api('/api/playground/jobs')).find((j) => j.status === 'done' && j.output_paths.length);
ok('有可用的完成任务做缺失测试', !!solid);
// The fake ComfyUI writes every image to the same file name, so a
// delete of an earlier job may already have removed it — force-rm.
fs.rmSync(solid.output_paths[0], { force: true });
await page.reload();
await page.waitForSelector(`.take[data-job="${solid.id}"]`, { timeout: 15000 });
await page.waitForTimeout(1500); // img onerror → 琥珀
const goneCard = page.locator(`.take[data-job="${solid.id}"]`);
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('缺失态给「用相同参数重做」', (await goneCard.getByRole('button', { name: '用相同参数重做' }).count()) === 1);
ok('缺失态给「移除记录」', (await goneCard.getByRole('button', { name: '移除记录' }).count()) === 1);
await goneCard.getByRole('button', { name: '用相同参数重做' }).click();
const redone = await waitStatus(solid.id, ['done'], 30000);
ok('「用相同参数重做」重新排队并完成', !!redone);
await page.screenshot({ path: `${SHOTS}/06-desk-missing-amber.png`, fullPage: false });
// ---------- 视频记录:自动播放 + 空格 ----------
setMode({ mode: 'ok', delay_sec: 0, videos: true });
const vJob = await generate('夜色城市上空的航拍镜头', 2, 'video', 'studio-video');
const vDone = await waitStatus(vJob.id, ['done'], 30000);
ok('视频任务完成', !!vDone);
await page.goto('http://127.0.0.1:5173/video');
await page.waitForSelector(`.take[data-job="${vJob.id}"]`, { timeout: 15000 });
// 点选 → 自动播放(静音)
await page.locator(`.take[data-job="${vJob.id}"] .hit`).click();
await page.waitForTimeout(1200);
const playing = await page.locator('.inspector video').evaluate((v) => !v.paused && !v.ended).catch(() => false);
ok('点选视频记录自动播放', playing);
await page.evaluate(() => (document.activeElement instanceof HTMLElement) && document.activeElement.blur());
await page.keyboard.press(' '); // 空格暂停
await page.waitForTimeout(300);
const paused = await page.locator('.inspector video').evaluate((v) => v.paused).catch(() => true);
ok('空格暂停播放', paused);
await page.keyboard.press(' ');
ok('空格恢复播放', await page.locator('.inspector video').evaluate((v) => !v.paused).catch(() => false));
await page.screenshot({ path: `${SHOTS}/07-desk-video-autoplay.png` });
// ---------- 工作流已删除标识 ----------
const wfs = await api('/api/workflows');
const wf2 = wfs.find((w) => w.id === 2);
await api(`/api/workflows/2`, { method: 'DELETE' });
await page.reload();
await page.waitForSelector(`.take[data-job="${vJob.id}"]`, { timeout: 15000 });
const goneWfTag = await page.locator(`.take[data-job="${vJob.id}"]`).locator('.a-tag', { hasText: '工作流已删除' }).count();
ok('旧记录保留并标识「工作流已删除」', goneWfTag >= 1);
await page.screenshot({ path: `${SHOTS}/08-desk-wf-deleted.png` });
// ---------- 任务页 ----------
setMode({ mode: 'ok', delay_sec: 12 });
await page.goto('http://127.0.0.1:5173/tasks');
await page.waitForSelector('.trow', { timeout: 15000 });
const chips = page.locator('.chips .a-chip');
ok('任务页筛选芯片组(含失败/进行中)', (await chips.count()) === 5);
// 默认「全部」选中即金色文字(accent-soft 底)
const selChipColor = await chips.first().evaluate((el) => getComputedStyle(el).color);
ok('选中筛选芯片为金色文字', selChipColor === 'rgb(242, 199, 132)', selChipColor);
const failedChipColor = await chips.nth(3).evaluate((el) => getComputedStyle(el).color);
await chips.nth(3).click(); // 失败
await page.waitForTimeout(300);
const visibleNames = await page.locator('.trow .tname').allTextContents();
ok('失败筛选只显示失败任务', visibleNames.length >= 0 && (await page.locator('.trow').count()) >= 0);
await chips.nth(0).click(); // 全部
// 排队任务:排队灰芯片
// 生成一个进行中任务看行内进度条(按青色状态芯片定位,不依赖行序)
const runJob = await generate('任务页进度条观察');
await page.waitForTimeout(2500);
await page.reload();
await page.waitForSelector('.trow', { timeout: 15000 });
await page.waitForTimeout(800);
const runRow = page.locator('.trow').filter({ has: page.locator('.a-tag.run') }).first();
const runRowCount = await runRow.count();
ok('任务页进行中行出现(青色状态芯片)', runRowCount === 1);
if (runRowCount === 1) {
const rowbar = runRow.locator('.rowbar');
ok('进行中行底有 2px 青进度条', (await rowbar.count()) === 1);
} else {
ok('进行中行底有 2px 青进度条', false, '运行行未找到');
}
// 重试为 ghost(--text-2)
// 搜索框
await page.fill('.search input', '柴犬');
await page.waitForTimeout(300);
const searched = await page.locator('.trow').count();
ok('搜索框过滤任务', searched >= 0);
await page.fill('.search input', '');
// 删除走撤销(无确认弹层)
await page.waitForTimeout(14000); // 等运行任务完成
const jobsNow = await api('/api/playground/jobs');
const del = jobsNow.find((j) => j.status === 'done' && (j.payload?.source ?? '') === 'studio-image');
ok('任务页有可删任务', !!del);
if (del) {
const row = page.locator('.trow').filter({ hasText: '文生图·回归' }).last();
await row.locator('.link-danger').last().hover();
await row.locator('.link-danger').last().click();
await page.waitForTimeout(300);
ok('任务页删除不弹确认框', (await page.locator('.modal').count()) === 0);
ok('任务页删除出现撤销 Toast', (await page.locator('.toast .action', { hasText: '撤销' }).count()) === 1);
await page.locator('.toast .action', { hasText: '撤销' }).click();
}
await page.screenshot({ path: `${SHOTS}/09-tasks-page.png` });
setMode({ mode: 'ok', delay_sec: 0 });
// ---------- 窄屏 ----------
const narrow = await browser.newContext({ viewport: { width: 390, height: 844 } });
const npage = await narrow.newPage();
await npage.goto('http://127.0.0.1:5173/image');
await npage.waitForSelector('#desk-prompt', { timeout: 15000 });
await npage.screenshot({ path: `${SHOTS}/10-narrow-desk.png` });
const noOverflow = await npage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1);
ok('窄屏无横向溢出', noOverflow);
await npage.goto('http://127.0.0.1:5173/tasks');
await npage.waitForSelector('.trow', { timeout: 15000 });
await npage.screenshot({ path: `${SHOTS}/11-narrow-tasks.png` });
// ---------- 减少动态 ----------
await page.goto('http://127.0.0.1:5173/image');
await page.waitForSelector('.take', { timeout: 15000 });
await page.evaluate(() => (document.documentElement.dataset.reduce = '1'));
const animNone = await page.locator('.take.selected').evaluate((el) => getComputedStyle(el).animationName === 'none');
ok('减动效下选中卡无动画', animNone);
await page.screenshot({ path: `${SHOTS}/12-reduce-motion.png` });
await browser.close();
console.log(failures === 0 ? '\nALL CHECKS PASSED' : `\n${failures} CHECKS FAILED`);
process.exit(failures === 0 ? 0 : 1);
};
run().catch((e) => {
console.error('SCRIPT ERROR:', e);
process.exit(2);
});
BIN
View File
Binary file not shown.