Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c7334f45 | ||
|
|
6064bd805d | ||
|
|
c3deaf90bb | ||
|
|
949cd2ecad | ||
|
|
0c620e61fe | ||
|
|
6f4d50f4f0 | ||
|
|
8b03291327 | ||
|
|
225fcb49ea | ||
|
|
70a860ce2a | ||
|
|
e42212a16f |
@@ -17,6 +17,10 @@
|
||||
allowUpload?: boolean;
|
||||
/** Force required-field error rings visible (e.g. after a blocked Generate). */
|
||||
showErrors?: boolean;
|
||||
/** Show each field's workflow value (原值) and mark fields that differ from it. */
|
||||
showOrig?: boolean;
|
||||
/** Extra per-field messages keyed by nodeId (e.g. range checks); shown with showErrors. */
|
||||
errors?: Record<string, string>;
|
||||
/** Emits labels of required-but-empty inputs whenever the set changes. */
|
||||
onValidityChange?: (missing: string[]) => void;
|
||||
onChange?: (values: Record<string, string | number>) => void;
|
||||
@@ -30,10 +34,23 @@
|
||||
assetOptions = [],
|
||||
allowUpload = false,
|
||||
showErrors = false,
|
||||
showOrig = false,
|
||||
errors = {},
|
||||
onValidityChange,
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
function hasOrig(inp: ComfyDynamicInput): boolean {
|
||||
return inp.defaultValue !== undefined && inp.defaultValue !== '';
|
||||
}
|
||||
|
||||
function isChanged(inp: ComfyDynamicInput): boolean {
|
||||
if (!showOrig) return false;
|
||||
const v = values[inp.nodeId];
|
||||
if (isBlank(v)) return hasOrig(inp);
|
||||
return !hasOrig(inp) || String(v) !== String(inp.defaultValue);
|
||||
}
|
||||
|
||||
// Unique per mounted form so label/id pairs never collide across instances.
|
||||
const uid = Math.random().toString(36).slice(2, 8);
|
||||
let touched = $state<Record<string, boolean>>({});
|
||||
@@ -80,6 +97,11 @@
|
||||
return isMissing(inp) && (showErrors || !!touched[inp.nodeId]);
|
||||
}
|
||||
|
||||
function extraError(inp: ComfyDynamicInput): string | null {
|
||||
const msg = errors[inp.nodeId]?.trim();
|
||||
return msg && !isMissing(inp) && (showErrors || !!touched[inp.nodeId]) ? msg : null;
|
||||
}
|
||||
|
||||
function thumbFor(current: string): string | null {
|
||||
if (!current) return null;
|
||||
const opt = assetOptions.find((o) => o.path === current);
|
||||
@@ -224,7 +246,9 @@
|
||||
{#each inputs as inp (inp.nodeId)}
|
||||
{@const current = String(values[inp.nodeId] ?? '')}
|
||||
{@const id = inputId(inp.nodeId)}
|
||||
{@const errored = showError(inp)}
|
||||
{@const extra = extraError(inp)}
|
||||
{@const errored = showError(inp) || !!extra}
|
||||
{@const changed = isChanged(inp)}
|
||||
<div class="field">
|
||||
<label class="label" for={id}>
|
||||
{inp.label}
|
||||
@@ -235,6 +259,9 @@
|
||||
{#if inp.role}<span class="role">{inp.role}</span>{/if}
|
||||
<span class="kind">{inp.kind}</span>
|
||||
{/if}
|
||||
{#if showOrig && hasOrig(inp)}
|
||||
<span class="orig">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{#if readonly}
|
||||
<div class="readonly">{values[inp.nodeId] ?? inp.defaultValue ?? '—'}</div>
|
||||
@@ -243,6 +270,7 @@
|
||||
{id}
|
||||
rows="3"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={current}
|
||||
@@ -254,6 +282,7 @@
|
||||
{id}
|
||||
type="number"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={values[inp.nodeId] ?? ''}
|
||||
@@ -264,6 +293,7 @@
|
||||
<select
|
||||
{id}
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
value={current || String(inp.defaultValue ?? '')}
|
||||
onchange={(e) => setValue(inp.nodeId, e.currentTarget.value)}
|
||||
onblur={() => markTouched(inp.nodeId)}
|
||||
@@ -445,6 +475,7 @@
|
||||
{id}
|
||||
type="text"
|
||||
class:invalid={errored}
|
||||
class:changed
|
||||
aria-required={inp.required || undefined}
|
||||
aria-invalid={errored || undefined}
|
||||
value={current}
|
||||
@@ -453,7 +484,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{#if errored}
|
||||
<span class="error-msg" role="alert">{t('cdf.requiredMsg', { label: inp.label })}</span>
|
||||
<span class="error-msg" role="alert">{extra ?? t('cdf.requiredMsg', { label: inp.label })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -516,11 +547,23 @@
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
input.changed,
|
||||
textarea.changed,
|
||||
select.changed {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
input.invalid,
|
||||
textarea.invalid,
|
||||
select.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.orig {
|
||||
float: right;
|
||||
font-weight: 400;
|
||||
font-size: var(--fs-mini);
|
||||
font-family: var(--mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
input.invalid:focus-visible,
|
||||
textarea.invalid:focus-visible,
|
||||
select.invalid:focus-visible {
|
||||
|
||||
@@ -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')}
|
||||
@@ -52,15 +57,16 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* §四.5 Toast:右下角堆叠,220ms 上移入场;成功绿点 / 失败红点。 */
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 20px;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
max-width: min(400px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
@@ -69,23 +75,10 @@
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
animation: toast-in 0.22s ease-out;
|
||||
}
|
||||
.toast.success {
|
||||
border-color: rgba(34, 197, 94, 0.45);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.toast.error {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
.toast.info {
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--float-bg);
|
||||
box-shadow: var(--shadow-float);
|
||||
animation: toast-in var(--d-enter) var(--ease-out);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
@@ -101,21 +94,44 @@
|
||||
background: currentColor;
|
||||
}
|
||||
.toast.success .badge {
|
||||
color: var(--success);
|
||||
color: var(--ok);
|
||||
}
|
||||
.toast.error .badge {
|
||||
color: var(--error);
|
||||
color: var(--err);
|
||||
}
|
||||
.toast.info .badge {
|
||||
color: var(--info);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.msg {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.4;
|
||||
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;
|
||||
@@ -127,10 +143,12 @@
|
||||
padding: 0 2px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.dismiss:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
.dismiss:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
@@ -139,7 +157,7 @@
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -151,10 +169,12 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
:global(:root[data-reduce='1']) .toast {
|
||||
animation: none;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.toast-host {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
bottom: calc(var(--tabbar-h) + var(--sp-5) + env(safe-area-inset-bottom));
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
max-width: none;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import ParamChip from './ParamChip.svelte';
|
||||
import SlotTray from './SlotTray.svelte';
|
||||
import WorkflowPicker from './WorkflowPicker.svelte';
|
||||
import { chipValue, type BlockReason, type DeskSchema, type FormValue, type FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* 输入台 (spec-v2 §3.3): slots from the workflow JSON, the prompt, and one
|
||||
* row of chips — workflow, connection, common params, 参数 — ending in
|
||||
* 生成 with its reason line. The whole composer takes dropped files.
|
||||
* ≤760px it collapses to one line and rises as a bottom sheet on focus.
|
||||
*/
|
||||
interface Props {
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
endpointId: string;
|
||||
conn: { state: 'ok' | 'fail' | 'untested' | 'dry'; ms?: number | null };
|
||||
errors: Record<string, string>;
|
||||
showErrors: boolean;
|
||||
block: BlockReason | null;
|
||||
changedCount: number;
|
||||
submitting: boolean;
|
||||
/** Shows 已提交 on the button for --d-flash. */
|
||||
sent: boolean;
|
||||
slotBusy?: boolean;
|
||||
promptEl?: HTMLTextAreaElement | null;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onPickWorkflow: (wf: Workflow) => void;
|
||||
onPickEndpoint: (id: string) => void;
|
||||
onSubmit: () => void;
|
||||
onReveal: () => void;
|
||||
onOpenParams: () => void;
|
||||
onFromResults: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
ds,
|
||||
values,
|
||||
workflows,
|
||||
current,
|
||||
endpoints,
|
||||
endpointId,
|
||||
conn,
|
||||
errors,
|
||||
showErrors,
|
||||
block,
|
||||
changedCount,
|
||||
submitting,
|
||||
sent,
|
||||
slotBusy = $bindable(false),
|
||||
promptEl = $bindable(null),
|
||||
onSet,
|
||||
onPickWorkflow,
|
||||
onPickEndpoint,
|
||||
onSubmit,
|
||||
onReveal,
|
||||
onOpenParams,
|
||||
onFromResults,
|
||||
}: Props = $props();
|
||||
|
||||
let tray = $state<ReturnType<typeof SlotTray> | null>(null);
|
||||
let root = $state<HTMLElement | null>(null);
|
||||
let dragDepth = $state(0);
|
||||
let expanded = $state(false);
|
||||
let connOpen = $state(false);
|
||||
|
||||
const hasSlots = $derived(ds.slots.length + ds.lists.length > 0);
|
||||
const promptVal = $derived(ds.prompt ? String(values[ds.prompt.nodeId] ?? '') : '');
|
||||
const promptErr = $derived(showErrors && ds.prompt ? errors[ds.prompt.nodeId] : undefined);
|
||||
const endpointName = $derived(endpoints.find((e) => e.id === endpointId)?.name ?? t('desk.noConn'));
|
||||
|
||||
/** Composer chips: ratio, the width × height pair, resolution, duration, seed. */
|
||||
const chips = $derived.by(() => {
|
||||
const out: Array<{ key: string; inputs: ComfyDynamicInput[]; text: string }> = [];
|
||||
if (ds.ratio) out.push({ key: ds.ratio.nodeId, inputs: [ds.ratio], text: chipValue(ds.ratio, values, ds) });
|
||||
if (ds.width && ds.height) {
|
||||
const w = values[ds.width.nodeId] ?? ds.width.defaultValue ?? '?';
|
||||
const h = values[ds.height.nodeId] ?? ds.height.defaultValue ?? '?';
|
||||
out.push({ key: ds.width.nodeId, inputs: [ds.width, ds.height], text: `${w}×${h}` });
|
||||
} else {
|
||||
for (const inp of [ds.width, ds.height]) {
|
||||
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: `${inp.label} ${chipValue(inp, values, ds)}` });
|
||||
}
|
||||
}
|
||||
for (const inp of [ds.resolution, ds.duration, ds.seed]) {
|
||||
if (inp) out.push({ key: inp.nodeId, inputs: [inp], text: chipValue(inp, values, ds) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragDepth = 0;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) tray?.dropFiles(files);
|
||||
}
|
||||
|
||||
function hasFiles(e: DragEvent): boolean {
|
||||
return Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
||||
}
|
||||
|
||||
function onFocusOut(e: FocusEvent) {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !root?.contains(next)) expanded = false;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && expanded && !e.defaultPrevented) {
|
||||
expanded = false;
|
||||
(document.activeElement as HTMLElement | null)?.blur();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="composer-wrap">
|
||||
<div class="a-sprocket" aria-hidden="true"></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={root}
|
||||
class="composer"
|
||||
class:drag={dragDepth > 0}
|
||||
class:expanded
|
||||
onfocusin={() => (expanded = true)}
|
||||
onfocusout={onFocusOut}
|
||||
onkeydown={onKeydown}
|
||||
ondragenter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => hasFiles(e) && e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if hasSlots}
|
||||
<div class="slots">
|
||||
<SlotTray bind:this={tray} bind:busy={slotBusy} {ds} {values} {showErrors} {errors} {onSet} {onFromResults} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ds.prompt}
|
||||
<textarea
|
||||
bind:this={promptEl}
|
||||
id="desk-prompt"
|
||||
class="prompt"
|
||||
class:invalid={!!promptErr}
|
||||
rows="2"
|
||||
value={promptVal}
|
||||
placeholder={t('desk.promptPh')}
|
||||
aria-label={t('studio.promptLabel')}
|
||||
aria-invalid={!!promptErr || undefined}
|
||||
aria-keyshortcuts="Control+Enter Meta+Enter /"
|
||||
oninput={(e) => onSet(ds.prompt!.nodeId, e.currentTarget.value)}
|
||||
></textarea>
|
||||
{#if promptErr}<p class="a-slot-hint err prompt-err" role="alert">{promptErr}</p>{/if}
|
||||
{:else if current}
|
||||
<p class="no-prompt">{t('desk.noPromptInput')}</p>
|
||||
{/if}
|
||||
|
||||
<div class="foot">
|
||||
<WorkflowPicker {workflows} {current} {endpoints} onPick={onPickWorkflow} />
|
||||
<div class="conn opt">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-conn"
|
||||
class="a-chip"
|
||||
class:err={conn.state === 'fail'}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={connOpen}
|
||||
title={t('studio.connLabel')}
|
||||
onclick={() => (connOpen = !connOpen)}
|
||||
>
|
||||
<span class="status-chip-dot {conn.state === 'ok' ? 'run' : conn.state === 'fail' ? 'fail' : 'idle'}"></span>
|
||||
<span class="lbl">{endpointName}</span>
|
||||
{#if conn.state === 'ok' && conn.ms != null}<span class="mono">· {Math.round(conn.ms)} ms</span>{/if}
|
||||
{#if conn.state === 'dry'}<span>· {t('desk.dryRun')}</span>{/if}
|
||||
</button>
|
||||
<ShellPop bind:open={connOpen} label={t('studio.connLabel')} placement="top" width="280px">
|
||||
<div class="a-menu-group">{t('studio.connLabel')}</div>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item"
|
||||
aria-current={ep.id === endpointId ? 'true' : undefined}
|
||||
onclick={() => {
|
||||
onPickEndpoint(ep.id);
|
||||
connOpen = false;
|
||||
}}
|
||||
>
|
||||
<span class="a-menu-label">{ep.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<a class="a-menu-item" href="/connections">
|
||||
<Icon name="link" size={15} />
|
||||
<span class="a-menu-label">{t('studio.connManage')}</span>
|
||||
</a>
|
||||
</ShellPop>
|
||||
</div>
|
||||
{#each chips as c (c.key)}
|
||||
<div class="opt">
|
||||
<ParamChip
|
||||
inputs={c.inputs}
|
||||
{values}
|
||||
text={c.text}
|
||||
invalid={showErrors && c.inputs.some((i) => !!errors[i.nodeId]?.trim())}
|
||||
{onSet}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if ds.common.length + ds.advanced.length > 0}
|
||||
<button type="button" class="a-chip opt" class:changed={changedCount > 0} onclick={onOpenParams}>
|
||||
<Icon name="settings" size={14} />
|
||||
<span class="lbl">
|
||||
{changedCount > 0 ? t('desk.paramsChanged', { n: changedCount }) : t('desk.paramsAll')}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="submit">
|
||||
{#if block}
|
||||
<button type="button" class="why" class:err={block.tone === 'err'} onclick={onReveal}>
|
||||
{block.text}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="a-btn primary gen"
|
||||
class:sent
|
||||
disabled={!!block || submitting || slotBusy}
|
||||
aria-keyshortcuts="Control+Enter Meta+Enter"
|
||||
title={t('desk.genHint')}
|
||||
onclick={onSubmit}
|
||||
>
|
||||
<span class="l-idle"><Icon name="sparkle" size={13} />{t('desk.generate')}</span>
|
||||
<span class="l-sent" aria-hidden={!sent}><Icon name="check" size={13} />{t('desk.sent')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.composer-wrap {
|
||||
padding: var(--sp-2) var(--sp-7) var(--sp-6);
|
||||
}
|
||||
.a-sprocket {
|
||||
margin: 0 var(--sp-2) var(--sp-3);
|
||||
}
|
||||
.composer {
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--composer-bg);
|
||||
transition:
|
||||
border-color var(--d-state) ease,
|
||||
box-shadow var(--d-state) ease;
|
||||
}
|
||||
.composer:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: var(--composer-focus);
|
||||
}
|
||||
.composer.drag {
|
||||
border-color: var(--accent-line);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.slots {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.prompt {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
max-height: 180px;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-2);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
field-sizing: content;
|
||||
}
|
||||
.prompt:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
.prompt::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.prompt-err,
|
||||
.no-prompt {
|
||||
margin: 0;
|
||||
padding: 0 var(--sp-6);
|
||||
}
|
||||
.no-prompt {
|
||||
padding-top: var(--sp-5);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3) var(--sp-3);
|
||||
}
|
||||
.conn {
|
||||
position: relative;
|
||||
}
|
||||
.submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
margin-left: auto;
|
||||
}
|
||||
.why {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--warn);
|
||||
white-space: nowrap;
|
||||
text-decoration: underline dotted;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.why.err {
|
||||
color: var(--err);
|
||||
}
|
||||
/* 生成:按下即缩,提交后文字换成「已提交」约 --d-flash,期间仍可再次提交 */
|
||||
.gen {
|
||||
display: inline-grid;
|
||||
min-width: 84px;
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.gen > span {
|
||||
grid-area: 1 / 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-1);
|
||||
transition:
|
||||
opacity var(--d-state) ease,
|
||||
transform var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.gen .l-sent {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.gen.sent .l-idle {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
.gen.sent .l-sent {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .gen > span {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1179px) {
|
||||
.composer-wrap {
|
||||
padding: var(--sp-2) var(--sp-6) var(--sp-5);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.composer-wrap {
|
||||
padding: var(--sp-1) var(--sp-4) var(--sp-4);
|
||||
}
|
||||
.composer:not(.expanded) .slots,
|
||||
.composer:not(.expanded) .opt {
|
||||
display: none;
|
||||
}
|
||||
.composer:not(.expanded) .prompt {
|
||||
min-height: 40px;
|
||||
max-height: 40px;
|
||||
padding: var(--sp-4) var(--sp-5) var(--sp-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.composer.expanded {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
|
||||
z-index: 45;
|
||||
border-bottom: 0;
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
box-shadow: var(--shadow-float);
|
||||
max-height: calc(100dvh - var(--topbar-h) - var(--tabbar-h));
|
||||
overflow-y: auto;
|
||||
}
|
||||
.composer.expanded .slots {
|
||||
max-height: none;
|
||||
}
|
||||
.submit {
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { backHref, type DeskContext } from './context';
|
||||
|
||||
/**
|
||||
* 上下文条 (spec-v2 §3.3): the desk was opened from a creation stage.
|
||||
* Says where from, and plainly that takes stay in the desk feed — the
|
||||
* desk's generate call has no shot/asset link to fill back.
|
||||
*/
|
||||
interface Props {
|
||||
ctx: DeskContext;
|
||||
projectTitle: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
let { ctx, projectTitle, onDismiss }: Props = $props();
|
||||
|
||||
const stageName = $derived(t(`workbench.stage.${ctx.stage}`));
|
||||
</script>
|
||||
|
||||
<div class="context-bar" role="status">
|
||||
<Icon name="link" size={14} />
|
||||
<span class="from">
|
||||
<b>{t('desk.ctxFrom', { project: projectTitle })}</b>{#if ctx.label} · {ctx.label}{/if}
|
||||
</span>
|
||||
<span class="note">{t('desk.ctxNoBackfill', { stage: stageName })}</span>
|
||||
<a class="a-btn ghost xs back" href={backHref(ctx)}>{t('desk.ctxBack', { stage: stageName })}</a>
|
||||
<button type="button" class="a-btn ghost xs" aria-label={t('desk.ctxDismiss')} onclick={onDismiss}>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.context-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
margin: var(--sp-4) var(--sp-7) 0;
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--run-soft);
|
||||
color: var(--text);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.context-bar > :global(svg) {
|
||||
color: var(--run);
|
||||
}
|
||||
.from b {
|
||||
color: var(--run);
|
||||
font-weight: 600;
|
||||
}
|
||||
.note {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.back {
|
||||
margin-left: auto;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.context-bar {
|
||||
margin: var(--sp-4) var(--sp-6) 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.context-bar {
|
||||
margin: var(--sp-3) var(--sp-5) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,540 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 检视区 (spec-v2 §3.3): 结果 shows the selected take on the projection
|
||||
* 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';
|
||||
job: Job | null;
|
||||
wf: Workflow | undefined;
|
||||
nowMs: number;
|
||||
open: boolean;
|
||||
/** Collapsed or off-screen — nothing inside may take focus. */
|
||||
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;
|
||||
}
|
||||
|
||||
let {
|
||||
tab = $bindable(),
|
||||
job,
|
||||
wf,
|
||||
nowMs,
|
||||
open,
|
||||
inactive,
|
||||
canSendToSlot,
|
||||
crossfade,
|
||||
playSignal,
|
||||
videoEl = $bindable(null),
|
||||
params,
|
||||
onClose,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onSendToSlot,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
let outIndex = $state(0);
|
||||
$effect.pre(() => {
|
||||
void job?.id;
|
||||
outIndex = 0;
|
||||
});
|
||||
|
||||
const st = $derived(job ? jobStatusInfo(job) : null);
|
||||
// §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) : []);
|
||||
const endpoint = $derived(
|
||||
((job?.payload?.endpoint as Record<string, unknown> | undefined)?.name as string | undefined) ?? '',
|
||||
);
|
||||
const progress = $derived.by(() => {
|
||||
if (!job || 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;
|
||||
});
|
||||
let dims = $state('');
|
||||
$effect.pre(() => {
|
||||
void output;
|
||||
dims = '';
|
||||
});
|
||||
function noteImg(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
dims = `${img.naturalWidth}×${img.naturalHeight}`;
|
||||
}
|
||||
function noteVideo(e: Event) {
|
||||
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')}>
|
||||
<div class="tabs">
|
||||
<div class="a-seg" role="group" aria-label={t('desk.inspector')}>
|
||||
<button type="button" aria-pressed={tab === 'result'} onclick={() => (tab = 'result')}>{t('desk.tabResult')}</button>
|
||||
<button type="button" aria-pressed={tab === 'params'} onclick={() => (tab = 'params')}>{t('desk.tabParams')}</button>
|
||||
</div>
|
||||
<span class="keys"><span class="a-kbd">]</span>{t('desk.keyInspector')}</span>
|
||||
<button type="button" class="a-btn ghost sm close" aria-label={t('desk.closeInspector')} onclick={onClose}>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
{#if tab === 'params'}
|
||||
{@render params()}
|
||||
{:else if !job || !st}
|
||||
<div class="a-stage stage"><div class="stage-empty">{t('desk.pickTake')}</div></div>
|
||||
{:else}
|
||||
<div class="a-stage stage">
|
||||
{#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>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
{#if allOutputs.length > 1}
|
||||
<div class="strip" role="group" aria-label={t('desk.outputs')}>
|
||||
{#each allOutputs as p, i (p)}
|
||||
<button
|
||||
type="button"
|
||||
class="strip-item"
|
||||
class:gone={gonePaths.includes(p)}
|
||||
aria-pressed={p === output}
|
||||
aria-label={t('desk.outputN', { n: i + 1 })}
|
||||
onclick={() => (outIndex = i)}
|
||||
>
|
||||
{#if isVideoOutput(job, p)}
|
||||
<Icon name="film" size={14} />
|
||||
{:else}
|
||||
<img src={assetUrl(p)} alt="" loading="lazy" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="meta">
|
||||
{#if dims}<span class="mono"><b>{dims}</b></span>{/if}
|
||||
{#each spec as s (s.label)}
|
||||
{#if !(dims && s.label === t('desk.specSize'))}
|
||||
<span>{s.label} <b class="mono">{s.value}</b></span>
|
||||
{/if}
|
||||
{/each}
|
||||
<span>{t('desk.elapsed')} <b class="mono">{fmtDur(elapsedSec(job, nowMs))}</b></span>
|
||||
{#if endpoint}<span>{endpoint}</span>{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#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}
|
||||
<div class="section-title">{t('desk.inputs')}</div>
|
||||
{#if inputs.length}
|
||||
<div class="inputs">
|
||||
{#each inputs as inp (inp.label + inp.path)}
|
||||
{#if inp.kind === 'image'}
|
||||
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
|
||||
{:else}
|
||||
<span class="mini glyph" title={inp.label}><Icon name={inp.kind === 'video' ? 'film' : 'music'} size={14} /></span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
{/if}
|
||||
|
||||
<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 })}
|
||||
{#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>
|
||||
{/if}
|
||||
<dt>{t('desk.kvCreated')}</dt>
|
||||
<dd class="mono">{fmtClock(parseDbTs(job.created_at))}</dd>
|
||||
{#if job.comfy_prompt_id}
|
||||
<dt>{t('desk.kvPromptId')}</dt>
|
||||
<dd class="mono">{job.comfy_prompt_id}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.inspector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.close {
|
||||
padding: 0 var(--sp-3);
|
||||
}
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
.stage {
|
||||
aspect-ratio: 16 / 10;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.stage-media {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.stage-media > img,
|
||||
.stage-media > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.stage-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-6);
|
||||
text-align: center;
|
||||
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);
|
||||
}
|
||||
.stage-bar {
|
||||
width: 160px;
|
||||
}
|
||||
.strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.strip-item {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.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%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2) var(--sp-5);
|
||||
margin: var(--sp-5) 0;
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.meta b {
|
||||
color: var(--text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.note {
|
||||
margin-top: var(--sp-5);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--r-md);
|
||||
font-size: var(--fs-aux);
|
||||
word-break: break-word;
|
||||
}
|
||||
.note.err {
|
||||
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);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
.mini {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
object-fit: cover;
|
||||
}
|
||||
.mini.glyph {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
margin: 0;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.kv dt {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
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) {
|
||||
.inspector {
|
||||
position: fixed;
|
||||
top: var(--topbar-h);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
width: min(420px, 100vw);
|
||||
box-shadow: var(--shadow-float);
|
||||
transform: translateX(100%);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform var(--d-drawer) var(--ease-drawer),
|
||||
visibility 0s linear var(--d-drawer);
|
||||
}
|
||||
.inspector.open {
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
transition:
|
||||
transform var(--d-drawer) var(--ease-drawer),
|
||||
visibility 0s;
|
||||
}
|
||||
:global(:root[data-reduce='1']) .inspector {
|
||||
transform: none;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--d-drawer) ease,
|
||||
visibility 0s linear var(--d-drawer);
|
||||
}
|
||||
:global(:root[data-reduce='1']) .inspector.open {
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity var(--d-drawer) ease,
|
||||
visibility 0s;
|
||||
}
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
.close {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.inspector {
|
||||
top: 0;
|
||||
z-index: 70;
|
||||
width: 100vw;
|
||||
border-left: 0;
|
||||
}
|
||||
.body {
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { inputRole, isChanged, type FormValue, type FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* One common parameter as a composer chip (spec-v2 §3.3 参数芯片): the
|
||||
* pop edits it in place — options list for selects, a number field for
|
||||
* the rest (seed adds 随机) — and 恢复原值 returns to the workflow value.
|
||||
* `inputs` holds two entries for the width × height pair.
|
||||
*/
|
||||
interface Props {
|
||||
inputs: ComfyDynamicInput[];
|
||||
values: FormValues;
|
||||
text: string;
|
||||
title?: string;
|
||||
invalid?: boolean;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
}
|
||||
|
||||
let { inputs, values, text, title, invalid = false, onSet }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
const changed = $derived(inputs.some((i) => isChanged(i, values)));
|
||||
const single = $derived(inputs.length === 1 ? inputs[0] : null);
|
||||
const uid = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
function current(inp: ComfyDynamicInput): string {
|
||||
const v = values[inp.nodeId] ?? inp.defaultValue ?? '';
|
||||
return Array.isArray(v) ? v.join(',') : String(v);
|
||||
}
|
||||
|
||||
function setNumber(inp: ComfyDynamicInput, raw: string) {
|
||||
if (raw === '') return onSet(inp.nodeId, '');
|
||||
const n = Number(raw);
|
||||
onSet(inp.nodeId, Number.isNaN(n) ? raw : n);
|
||||
}
|
||||
|
||||
function restore() {
|
||||
for (const inp of inputs) onSet(inp.nodeId, inp.defaultValue ?? '');
|
||||
}
|
||||
|
||||
function randomSeed(inp: ComfyDynamicInput) {
|
||||
const max = Math.min(inp.max ?? 2 ** 32 - 1, Number.MAX_SAFE_INTEGER);
|
||||
const min = Math.max(inp.min ?? 0, 0);
|
||||
onSet(inp.nodeId, min + Math.floor(Math.random() * (max - min + 1)));
|
||||
}
|
||||
|
||||
function pickOption(inp: ComfyDynamicInput, opt: string) {
|
||||
onSet(inp.nodeId, opt);
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="param-chip">
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:changed={changed && !invalid}
|
||||
class:err={invalid}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={title ?? inputs.map((i) => i.label).join(' × ')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="lbl">{text}</span>
|
||||
</button>
|
||||
<ShellPop bind:open label={inputs.map((i) => i.label).join(' × ')} placement="top" width="260px">
|
||||
{#if single?.kind === 'select' && single.options?.length}
|
||||
<div class="a-menu-group">{single.label}</div>
|
||||
{#each single.options as opt (opt)}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item"
|
||||
aria-current={current(single) === opt ? 'true' : undefined}
|
||||
onclick={() => pickOption(single, opt)}
|
||||
>
|
||||
<span class="a-menu-label">{opt}</span>
|
||||
{#if String(single.defaultValue ?? '') === opt}
|
||||
<span class="a-menu-meta">{t('desk.origValue')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="fields">
|
||||
{#each inputs as inp (inp.nodeId)}
|
||||
{@const id = `pc-${uid}-${inp.nodeId.replace(/[^a-zA-Z0-9_-]/g, '')}`}
|
||||
<div class="field-row">
|
||||
<label class="field-label" for={id}>
|
||||
{inp.label}
|
||||
{#if inp.defaultValue !== undefined && inp.defaultValue !== ''}
|
||||
<span class="orig mono">{t('cdf.orig', { v: String(inp.defaultValue) })}</span>
|
||||
{/if}
|
||||
</label>
|
||||
<div class="inline">
|
||||
{#if inp.kind === 'number'}
|
||||
<input
|
||||
{id}
|
||||
class="a-input mono"
|
||||
class:invalid
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
value={current(inp)}
|
||||
oninput={(e) => setNumber(inp, e.currentTarget.value)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (open = false)}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
{id}
|
||||
class="a-input"
|
||||
class:invalid
|
||||
type="text"
|
||||
value={current(inp)}
|
||||
oninput={(e) => onSet(inp.nodeId, e.currentTarget.value)}
|
||||
onkeydown={(e) => e.key === 'Enter' && (open = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if inputRole(inp) === 'seed'}
|
||||
<button type="button" class="a-btn secondary sm" onclick={() => randomSeed(inp)}>
|
||||
{t('desk.seedRandom')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if inp.min != null || inp.max != null}
|
||||
<span class="field-hint mono">{inp.min ?? '−∞'} – {inp.max ?? '∞'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if changed}
|
||||
<div class="foot">
|
||||
<button type="button" class="a-btn ghost sm" onclick={restore}>{t('desk.restoreOrig')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.param-chip {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.fields {
|
||||
display: grid;
|
||||
gap: var(--sp-5);
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
.field-row {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.field-row .field-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin: 0;
|
||||
}
|
||||
.orig {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
.inline {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.field-row .field-hint {
|
||||
margin: 0;
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--sp-2) var(--sp-1) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import ComfyDynamicForm from '$lib/components/ComfyDynamicForm.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import type { ComfyDynamicInput, Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import type { DeskSchema, FormValue, FormValues } from './schema';
|
||||
|
||||
/**
|
||||
* 参数 (spec-v2 §3.3 检视区 · 参数): every non-media input the workflow
|
||||
* declares, each with its 原值 and a gold edge once changed. Common ones
|
||||
* first; the rest fold under 高级参数 — untouched, they keep the
|
||||
* workflow's own values.
|
||||
*/
|
||||
interface Props {
|
||||
workflow: Workflow | null;
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
errors: Record<string, string>;
|
||||
showErrors: boolean;
|
||||
changedCount: number;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onRestoreAll: () => void;
|
||||
}
|
||||
|
||||
let { workflow, ds, values, errors, showErrors, changedCount, onSet, onRestoreAll }: Props = $props();
|
||||
|
||||
let advancedOpen = $state(false);
|
||||
const brokenReasons = $derived(
|
||||
(workflow?.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message),
|
||||
);
|
||||
const advancedErr = $derived(showErrors && ds.advanced.some((i) => !!errors[i.nodeId]?.trim()));
|
||||
|
||||
$effect(() => {
|
||||
if (advancedErr) advancedOpen = true;
|
||||
});
|
||||
|
||||
function patch(inputs: ComfyDynamicInput[], next: Record<string, string | number>) {
|
||||
for (const inp of inputs) {
|
||||
const v = next[inp.nodeId];
|
||||
if (v !== undefined && v !== values[inp.nodeId]) onSet(inp.nodeId, v);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="params">
|
||||
{#if !workflow}
|
||||
<p class="note">{t('desk.pickWorkflow')}</p>
|
||||
{:else}
|
||||
{#if brokenReasons.length}
|
||||
<div class="note err" role="alert">
|
||||
<strong>{t('studio.wfBrokenTitle')}</strong>
|
||||
<ul>
|
||||
{#each brokenReasons as r (r)}<li>{r}</li>{/each}
|
||||
</ul>
|
||||
<span>{t('studio.wfBrokenHint')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !(workflow.input_schema ?? []).length}
|
||||
<p class="note">{t('studio.wfNoParamsNote')}</p>
|
||||
{:else if (workflow.input_schema ?? []).some((i) => i.auto)}
|
||||
<p class="note">{t('studio.wfAutoParamsNote', { n: workflow.input_schema.length })}</p>
|
||||
{/if}
|
||||
|
||||
{#if ds.common.length}
|
||||
<div class="section-title">{t('desk.paramsCommon')}</div>
|
||||
<ComfyDynamicForm
|
||||
inputs={ds.common}
|
||||
values={values as Record<string, string | number>}
|
||||
quiet
|
||||
showOrig
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => patch(ds.common, v)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if ds.advanced.length}
|
||||
<div class="fold">
|
||||
<button
|
||||
type="button"
|
||||
class="fold-head"
|
||||
class:err={advancedErr}
|
||||
aria-expanded={advancedOpen}
|
||||
aria-controls="desk-advanced"
|
||||
onclick={() => (advancedOpen = !advancedOpen)}
|
||||
>
|
||||
<span class="chev" aria-hidden="true"><Icon name="play" size={10} /></span>
|
||||
{t('desk.advanced', { n: ds.advanced.length })}
|
||||
</button>
|
||||
<div id="desk-advanced" class="collapse" class:open={advancedOpen} inert={!advancedOpen}>
|
||||
<div class="collapse-in">
|
||||
<ComfyDynamicForm
|
||||
inputs={ds.advanced}
|
||||
values={values as Record<string, string | number>}
|
||||
quiet
|
||||
showOrig
|
||||
{errors}
|
||||
{showErrors}
|
||||
onChange={(v) => patch(ds.advanced, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if changedCount > 0}
|
||||
<div class="restore">
|
||||
<button type="button" class="a-btn ghost sm" onclick={onRestoreAll}>
|
||||
<Icon name="retry" size={13} />
|
||||
{t('desk.restoreAll')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.params {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.note.err {
|
||||
background: var(--err-soft);
|
||||
color: var(--err);
|
||||
}
|
||||
.note ul {
|
||||
margin: var(--sp-2) 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.fold {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.fold-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
padding: var(--sp-4) 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
font-size: var(--fs-aux);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color var(--d-state) ease;
|
||||
}
|
||||
.fold-head.err {
|
||||
color: var(--err);
|
||||
}
|
||||
.chev {
|
||||
display: inline-grid;
|
||||
transition: transform var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.fold-head[aria-expanded='true'] .chev {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.fold-head:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.collapse {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transition:
|
||||
grid-template-rows var(--d-enter) var(--ease-out),
|
||||
opacity var(--d-state) ease;
|
||||
}
|
||||
.collapse.open {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
.collapse-in {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.collapse.open .collapse-in {
|
||||
padding-bottom: var(--sp-2);
|
||||
}
|
||||
.restore {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import MediaSlot from '$lib/components/studio/MediaSlot.svelte';
|
||||
import RefImageGrid from '$lib/components/studio/RefImageGrid.svelte';
|
||||
import { mediaFileProblem } from '$lib/comfy/imageFile';
|
||||
import { createUploadManager } from '$lib/comfy/useUpload.svelte';
|
||||
import type { ComfyDynamicInput } from '$lib/comfy/types';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { h3Warnings, listKind, listPaths, type DeskSchema, type FormValue, type FormValues, type SlotDef } from './schema';
|
||||
|
||||
/**
|
||||
* 输入槽 (spec-v2 §3.3): exactly the media inputs the workflow JSON wires —
|
||||
* text-to-image shows none, image-to-image one input, image-to-video a
|
||||
* first frame, ref-to-video the 1–N list. Owns uploads and rejects;
|
||||
* values flow back through `onSet`.
|
||||
*/
|
||||
interface Props {
|
||||
ds: DeskSchema;
|
||||
values: FormValues;
|
||||
showErrors: boolean;
|
||||
errors: Record<string, string>;
|
||||
/** True while any upload runs (generate waits for it). */
|
||||
busy?: boolean;
|
||||
onSet: (nodeId: string, value: FormValue) => void;
|
||||
onFromResults: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
let { ds, values, showErrors, errors, busy = $bindable(false), onSet, onFromResults }: Props = $props();
|
||||
|
||||
const uploads = createUploadManager();
|
||||
type Reject = { name: string; message: string };
|
||||
let rejects = $state<Record<string, Reject[]>>({});
|
||||
let fileSizes = $state<Record<string, number>>({});
|
||||
let replacingKey = $state('');
|
||||
let replacingIndex = $state(-1);
|
||||
|
||||
$effect(() => {
|
||||
busy = Object.keys(uploads.uploading).length > 0;
|
||||
});
|
||||
|
||||
// A different workflow starts with no stale rejects.
|
||||
$effect(() => {
|
||||
void ds;
|
||||
rejects = {};
|
||||
});
|
||||
|
||||
const warns = $derived(h3Warnings(ds, values));
|
||||
|
||||
function setRejects(nodeId: string, list: Reject[]) {
|
||||
rejects = { ...rejects, [nodeId]: list };
|
||||
}
|
||||
|
||||
async function upload(nodeId: string, file: File): Promise<string | null> {
|
||||
const path = await uploads.uploadSafe(nodeId, file);
|
||||
if (path) fileSizes = { ...fileSizes, [path]: file.size };
|
||||
return path;
|
||||
}
|
||||
|
||||
async function slotFile(s: SlotDef, file: File) {
|
||||
const problem = mediaFileProblem(s.mediaKind, file);
|
||||
if (problem) {
|
||||
setRejects(s.inp.nodeId, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(s.inp.nodeId, []);
|
||||
const path = await upload(s.inp.nodeId, file);
|
||||
if (path) onSet(s.inp.nodeId, path);
|
||||
}
|
||||
|
||||
/** Add picked/dropped files in order; a batch that would pass `max` is refused whole. */
|
||||
async function groupAdd(entry: ComfyDynamicInput, files: File[]) {
|
||||
const key = entry.nodeId;
|
||||
const mk = listKind(entry);
|
||||
const max = entry.max ?? 9;
|
||||
const ok = files.filter((f) => !mediaFileProblem(mk, f));
|
||||
setRejects(
|
||||
key,
|
||||
files.filter((f) => mediaFileProblem(mk, f)).map((f) => ({ name: f.name, message: mediaFileProblem(mk, f)! })),
|
||||
);
|
||||
const start = listPaths(values, entry);
|
||||
if (start.length + ok.length > max) {
|
||||
toast.error(t('studio.errRefMax', { max, n: start.length, k: ok.length }));
|
||||
return;
|
||||
}
|
||||
for (const f of ok) {
|
||||
const path = await upload(key, f);
|
||||
if (!path) break;
|
||||
onSet(key, [...listPaths(values, entry), path]);
|
||||
}
|
||||
}
|
||||
|
||||
async function groupReplace(entry: ComfyDynamicInput, index: number, file: File) {
|
||||
const key = entry.nodeId;
|
||||
const problem = mediaFileProblem(listKind(entry), file);
|
||||
if (problem) {
|
||||
setRejects(key, [{ name: file.name, message: problem }]);
|
||||
return;
|
||||
}
|
||||
setRejects(key, []);
|
||||
replacingKey = key;
|
||||
replacingIndex = index;
|
||||
try {
|
||||
const path = await upload(key, file);
|
||||
if (path) onSet(key, listPaths(values, entry).map((p, i) => (i === index ? path : p)));
|
||||
} finally {
|
||||
replacingKey = '';
|
||||
replacingIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function fileKind(f: File): 'image' | 'video' | 'audio' {
|
||||
if (f.type.startsWith('video/')) return 'video';
|
||||
if (f.type.startsWith('audio/')) return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
/** Composer-wide drop: each file goes to the first wired slot of its kind with room. */
|
||||
export function dropFiles(files: File[]) {
|
||||
const pending = new Map<string, File[]>();
|
||||
const taken = new Set<string>();
|
||||
let unplaced = 0;
|
||||
for (const f of files) {
|
||||
const mk = fileKind(f);
|
||||
const slot = ds.slots.find(
|
||||
(s) =>
|
||||
s.mediaKind === mk &&
|
||||
!s.mask &&
|
||||
!taken.has(s.inp.nodeId) &&
|
||||
!String(values[s.inp.nodeId] ?? ''),
|
||||
);
|
||||
if (slot) {
|
||||
taken.add(slot.inp.nodeId);
|
||||
void slotFile(slot, f);
|
||||
continue;
|
||||
}
|
||||
const list = ds.lists.find((g) => listKind(g) === mk);
|
||||
if (list) {
|
||||
pending.set(list.nodeId, [...(pending.get(list.nodeId) ?? []), f]);
|
||||
continue;
|
||||
}
|
||||
unplaced += 1;
|
||||
}
|
||||
for (const [nodeId, batch] of pending) {
|
||||
const entry = ds.lists.find((g) => g.nodeId === nodeId);
|
||||
if (entry) void groupAdd(entry, batch);
|
||||
}
|
||||
if (unplaced) toast.error(t('desk.dropNoSlot', { n: unplaced }));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ds.slots.length || ds.lists.length}
|
||||
<div class="tray">
|
||||
{#each ds.slots as s (s.inp.nodeId)}
|
||||
{@const reject = rejects[s.inp.nodeId]?.[0]}
|
||||
<MediaSlot
|
||||
label={s.label}
|
||||
required={s.inp.required}
|
||||
value={String(values[s.inp.nodeId] ?? '')}
|
||||
kind={s.mediaKind}
|
||||
size={fileSizes[String(values[s.inp.nodeId] ?? '')]}
|
||||
uploading={uploads.isUploading(s.inp.nodeId)}
|
||||
uploadName={uploads.uploading[s.inp.nodeId]}
|
||||
need={showErrors && !!errors[s.inp.nodeId]}
|
||||
error={reject?.message ?? (showErrors ? errors[s.inp.nodeId] : undefined)}
|
||||
allowFromResults={s.fromResults}
|
||||
anchorId={`slot-${s.inp.nodeId}`}
|
||||
onFile={(f) => slotFile(s, f)}
|
||||
onFromResults={() => onFromResults(s.inp.nodeId)}
|
||||
onClear={() => {
|
||||
setRejects(s.inp.nodeId, []);
|
||||
onSet(s.inp.nodeId, '');
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
{#each ds.lists as entry (entry.nodeId)}
|
||||
<RefImageGrid
|
||||
label={entry.label}
|
||||
mediaKind={listKind(entry)}
|
||||
paths={listPaths(values, entry)}
|
||||
max={entry.max ?? 9}
|
||||
sizes={fileSizes}
|
||||
uploadingName={uploads.uploading[entry.nodeId]}
|
||||
uploadingIndex={replacingKey === entry.nodeId ? replacingIndex : -1}
|
||||
rejects={rejects[entry.nodeId] ?? []}
|
||||
need={showErrors && !!errors[entry.nodeId]}
|
||||
error={showErrors ? errors[entry.nodeId] : undefined}
|
||||
allowFromResults={entry.kind === 'image_list'}
|
||||
anchorId={`slot-${entry.nodeId}`}
|
||||
onAdd={(files) => groupAdd(entry, files)}
|
||||
onReplace={(i, file) => groupReplace(entry, i, file)}
|
||||
onRemove={(i) => onSet(entry.nodeId, listPaths(values, entry).filter((_, j) => j !== i))}
|
||||
onDismissReject={(i) => setRejects(entry.nodeId, (rejects[entry.nodeId] ?? []).filter((_, j) => j !== i))}
|
||||
onFromResults={entry.kind === 'image_list' ? () => onFromResults(entry.nodeId) : undefined}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if showErrors && errors.firstLast}
|
||||
<p class="a-slot-hint err note">{errors.firstLast}</p>
|
||||
{/if}
|
||||
{#if ds.hasH3Hint}
|
||||
<div class="note" role="note">
|
||||
<p class="a-slot-hint">{t('wfMedia.h3Limits')}</p>
|
||||
{#each warns as w (w)}
|
||||
<p class="a-slot-hint warn">{w}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tray {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3) var(--sp-6);
|
||||
padding: var(--sp-4) var(--sp-5) 0;
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
padding: var(--sp-2) var(--sp-5) 0;
|
||||
}
|
||||
.note p {
|
||||
margin: 0;
|
||||
}
|
||||
.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,568 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 一条记录 (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;
|
||||
wf: Workflow | undefined;
|
||||
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;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
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));
|
||||
// §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(
|
||||
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);
|
||||
const phase = live?.phase ?? job.phase;
|
||||
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
|
||||
return live?.progress ?? 3;
|
||||
});
|
||||
const active = $derived(job.status === 'pending' || job.status === 'running');
|
||||
const name = $derived(wf?.name ?? t('studio.jobName', { id: job.id }));
|
||||
|
||||
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;
|
||||
if (Number.isFinite(v.duration)) clipSec = v.duration;
|
||||
}
|
||||
</script>
|
||||
|
||||
<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} · ${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>
|
||||
|
||||
{#if prompt}<p class="prompt">{prompt}</p>{/if}
|
||||
|
||||
{#if inputs.length}
|
||||
<div class="inputs" aria-label={t('desk.inputs')}>
|
||||
{#each inputs.slice(0, 5) as inp (inp.path + inp.label)}
|
||||
<img class="mini" src={assetUrl(inp.path)} alt={inp.label} title={inp.label} loading="lazy" />
|
||||
{/each}
|
||||
{#if inputs.length > 5}<span class="more">+{inputs.length - 5}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="state">
|
||||
<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' && !fileGone}
|
||||
<span class="txt">{t('desk.took', { d: fmtDur(elapsedSec(job, nowMs)) })}</span>
|
||||
{:else if st.tone === 'fail'}
|
||||
<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">
|
||||
{#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>
|
||||
|
||||
<div class="out">
|
||||
{#if output}
|
||||
<div class="thumb" class:portrait>
|
||||
{#if isVideoOutput(job, output)}
|
||||
<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={(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}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.take {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: var(--sp-4) var(--sp-6);
|
||||
margin-bottom: var(--sp-3);
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--bg-2);
|
||||
transition:
|
||||
border-color var(--d-state) ease,
|
||||
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: 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;
|
||||
z-index: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hit:focus-visible {
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
.head,
|
||||
.prompt,
|
||||
.inputs,
|
||||
.state {
|
||||
grid-column: 1;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.meta {
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
color: var(--text-2);
|
||||
font-size: var(--fs-body-sm);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.mini {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 5px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
.more {
|
||||
padding-left: 2px;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-height: 22px;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.bar {
|
||||
flex: 0 0 140px;
|
||||
}
|
||||
.txt {
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gone-txt {
|
||||
min-width: 0;
|
||||
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);
|
||||
margin-left: auto;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transition: opacity var(--d-state) ease;
|
||||
}
|
||||
.take:focus-within .actions,
|
||||
.take.selected .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.take:hover .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (hover: none) {
|
||||
.actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.out {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 4;
|
||||
align-self: start;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-4);
|
||||
box-shadow: inset 0 0 0 1px var(--on-media-line);
|
||||
}
|
||||
.thumb.portrait {
|
||||
width: 96px;
|
||||
aspect-ratio: 3 / 4;
|
||||
}
|
||||
.thumb img,
|
||||
.thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.badge,
|
||||
.count {
|
||||
position: absolute;
|
||||
bottom: var(--sp-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
font-size: var(--fs-mini);
|
||||
}
|
||||
.badge {
|
||||
right: var(--sp-2);
|
||||
}
|
||||
.count {
|
||||
right: var(--sp-2);
|
||||
top: var(--sp-2);
|
||||
bottom: auto;
|
||||
}
|
||||
.play {
|
||||
position: absolute;
|
||||
left: var(--sp-2);
|
||||
bottom: var(--sp-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--media-scrim);
|
||||
color: var(--on-media);
|
||||
}
|
||||
.thumb.pending,
|
||||
.thumb.missing,
|
||||
.thumb.gone {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-2);
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
border: 1px dashed var(--line-2);
|
||||
background: var(--bg-2);
|
||||
box-shadow: none;
|
||||
}
|
||||
.thumb.pending {
|
||||
border-style: solid;
|
||||
color: var(--run);
|
||||
}
|
||||
.thumb.gone {
|
||||
color: var(--warn);
|
||||
border-color: var(--warn);
|
||||
}
|
||||
.thumb.pending::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(100deg, transparent 30%, var(--run-soft) 50%, transparent 70%);
|
||||
animation: take-sheen 1.4s linear infinite;
|
||||
}
|
||||
@keyframes take-sheen {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .thumb.pending::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.take {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
.head {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 2px;
|
||||
}
|
||||
.out {
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
.state {
|
||||
grid-column: 1 / -1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.thumb {
|
||||
width: 108px;
|
||||
}
|
||||
.thumb.portrait {
|
||||
width: 72px;
|
||||
}
|
||||
.bar {
|
||||
flex-basis: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,344 @@
|
||||
<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';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import TakeCard from './TakeCard.svelte';
|
||||
import { dayLabel, matchesFilter, type FeedFilter } from './takes';
|
||||
|
||||
/**
|
||||
* 记录流 (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[];
|
||||
workflowOf: (job: Job) => Workflow | undefined;
|
||||
filter: FeedFilter;
|
||||
selectedId: number | null;
|
||||
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). */
|
||||
inspectorToggle?: Snippet;
|
||||
onSelect: (job: Job) => void;
|
||||
onReuse: (job: Job) => void;
|
||||
onRetry: (job: Job) => void;
|
||||
onCancel: (job: Job) => void;
|
||||
onDelete: (job: Job) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
jobs,
|
||||
workflowOf,
|
||||
filter = $bindable(),
|
||||
selectedId,
|
||||
nowMs,
|
||||
busyId,
|
||||
loading,
|
||||
pendingIds,
|
||||
flashIds,
|
||||
shakeIds,
|
||||
orderedIds = $bindable(),
|
||||
banner,
|
||||
inspectorToggle,
|
||||
onSelect,
|
||||
onReuse,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
}: Props = $props();
|
||||
|
||||
const FILTERS: Array<{ id: FeedFilter; key: string }> = [
|
||||
{ id: 'all', key: 'desk.filterAll' },
|
||||
{ id: 'image', key: 'desk.filterImage' },
|
||||
{ id: 'video', key: 'desk.filterVideo' },
|
||||
{ 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) && !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 visible) {
|
||||
const label = dayLabel(parseDbTs(job.created_at), nowMs);
|
||||
const last = out[out.length - 1];
|
||||
if (last?.label === label) last.items.push(job);
|
||||
else out.push({ label, items: [job] });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Stick to the bottom as takes arrive, unless the user scrolled up to read.
|
||||
let pinned = true;
|
||||
function onScroll() {
|
||||
if (!scroller) return;
|
||||
pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80;
|
||||
}
|
||||
$effect(() => {
|
||||
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">
|
||||
<div class="a-seg" role="group" aria-label={t('desk.filterAria')}>
|
||||
{#each FILTERS as f (f.id)}
|
||||
<button type="button" aria-pressed={filter === f.id} onclick={() => (filter = f.id)}>{t(f.key)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="count">
|
||||
{t('desk.countTakes', { n: shown.length })}{#if activeCount} · {t('desk.countActive', { n: activeCount })}{/if}
|
||||
</span>
|
||||
{@render inspectorToggle?.()}
|
||||
</div>
|
||||
{@render banner?.()}
|
||||
|
||||
<div class="feed" bind:this={scroller} onscroll={onScroll}>
|
||||
{#if loading && !jobs.length}
|
||||
<p class="empty">{t('common.loading')}</p>
|
||||
{:else if !shown.length}
|
||||
<div class="empty">
|
||||
<Icon name={filter === 'video' ? 'film' : filter === 'failed' ? 'check' : 'image'} size={20} />
|
||||
<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)}
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4) var(--sp-7);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.count {
|
||||
margin-left: auto;
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.feed {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
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;
|
||||
gap: var(--sp-3);
|
||||
margin: var(--sp-4) 0 var(--sp-3);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.day::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin: 0;
|
||||
padding: var(--sp-8) var(--sp-6);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.filters {
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
}
|
||||
.feed {
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3);
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.filters {
|
||||
padding: var(--sp-3) var(--sp-5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.count {
|
||||
display: none;
|
||||
}
|
||||
.feed {
|
||||
padding: var(--sp-5) var(--sp-5) var(--sp-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from '$lib/components/shell/ShellPop.svelte';
|
||||
import { WORKFLOW_USAGES, type Workflow } from '$lib/comfy/types';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { slotSummary, usageLabel } from './schema';
|
||||
|
||||
/**
|
||||
* 工作流选择 (spec-v2 §3.3 工作流面板): the composer's first chip opens a
|
||||
* pop grouped by usage with search; each row shows what the workflow
|
||||
* really takes (slot summary from its JSON), its connection and whether
|
||||
* it last ran. Picking keeps the prompt and carried-over images.
|
||||
* D 批 (HEL-654): the footer's「↑↓ 选择」is now literally true — an
|
||||
* active row follows ↑↓ (and the pointer), Enter uses it, and the
|
||||
* search's Enter picks the active row instead of always the first.
|
||||
*/
|
||||
interface Props {
|
||||
workflows: Workflow[];
|
||||
current: Workflow | null;
|
||||
endpoints: Array<{ id: string; name: string }>;
|
||||
onPick: (wf: Workflow) => void;
|
||||
}
|
||||
|
||||
let { workflows, current, endpoints, onPick }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let query = $state('');
|
||||
let activeId = $state<number | null>(null);
|
||||
|
||||
type Status = { tone: '' | 'ok' | 'warn' | 'err'; label: string };
|
||||
|
||||
function statusOf(wf: Workflow): Status {
|
||||
if ((wf.warnings ?? []).some((w) => w.level === 'error')) return { tone: 'err', label: t('desk.wfBroken') };
|
||||
const lt = wf.last_test;
|
||||
if (!lt) return { tone: '', label: t('desk.wfUntested') };
|
||||
if (lt.status === 'done' && lt.output_count > 0) return { tone: 'ok', label: t('desk.wfOk') };
|
||||
if (lt.status === 'failed' || lt.status === 'done') return { tone: 'warn', label: t('desk.wfLastFailed') };
|
||||
return { tone: '', label: t('desk.wfTesting') };
|
||||
}
|
||||
|
||||
function endpointLabel(wf: Workflow): string {
|
||||
const ep = endpoints.find((e) => e.id === wf.endpoint_id);
|
||||
return ep ? ep.name : t('desk.wfDefaultConn');
|
||||
}
|
||||
|
||||
const rows = $derived(
|
||||
workflows.map((wf) => ({
|
||||
wf,
|
||||
summary: slotSummary(wf),
|
||||
conn: endpointLabel(wf),
|
||||
status: statusOf(wf),
|
||||
usage: wf.usage ? usageLabel(wf.usage) : '',
|
||||
})),
|
||||
);
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const hit = (r: (typeof rows)[number]) =>
|
||||
!q || `${r.wf.name} ${r.usage} ${r.summary}`.toLowerCase().includes(q);
|
||||
return WORKFLOW_USAGES.map((u) => ({
|
||||
usage: u,
|
||||
items: rows.filter((r) => r.wf.usage === u && hit(r)),
|
||||
})).filter((g) => g.items.length);
|
||||
});
|
||||
|
||||
/** Flattened rows in display order — the roving selection walks these. */
|
||||
const flat = $derived(groups.flatMap((g) => g.items));
|
||||
|
||||
// Keep the active row real: default to the current workflow, clamp on
|
||||
// filter changes, never point at a row that is no longer listed.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
if (activeId != null && flat.some((r) => r.wf.id === activeId)) return;
|
||||
activeId = flat.find((r) => r.wf.id === current?.id)?.wf.id ?? flat[0]?.wf.id ?? null;
|
||||
});
|
||||
|
||||
function moveActive(delta: number) {
|
||||
if (!flat.length) return;
|
||||
let i = flat.findIndex((r) => r.wf.id === activeId);
|
||||
i = i < 0 ? (delta > 0 ? 0 : flat.length - 1) : i + delta;
|
||||
activeId = flat[Math.max(0, Math.min(flat.length - 1, i))].wf.id;
|
||||
}
|
||||
|
||||
function pickActive() {
|
||||
const row = flat.find((r) => r.wf.id === activeId) ?? flat[0];
|
||||
if (row) pick(row.wf);
|
||||
}
|
||||
|
||||
function pick(wf: Workflow) {
|
||||
open = false;
|
||||
query = '';
|
||||
if (wf.id !== current?.id) onPick(wf);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
moveActive(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
moveActive(-1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pickActive();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker">
|
||||
<button
|
||||
type="button"
|
||||
id="desk-wf"
|
||||
class="a-chip wf"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={current?.name ?? t('desk.pickWorkflow')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<Icon name="playground" size={14} />
|
||||
<span class="lbl">{current?.name ?? t('desk.pickWorkflow')}</span>
|
||||
</button>
|
||||
<ShellPop
|
||||
bind:open
|
||||
label={t('desk.pickWorkflow')}
|
||||
placement="top"
|
||||
width="520px"
|
||||
arrowRoving={false}
|
||||
onclose={() => (query = '')}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="pop-body" onkeydown={onKeydown}>
|
||||
<div class="search">
|
||||
<Icon name="search" size={14} />
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
bind:value={query}
|
||||
placeholder={t('desk.wfSearchPh')}
|
||||
aria-label={t('desk.wfSearchPh')}
|
||||
/>
|
||||
</div>
|
||||
<div class="list" role="listbox" aria-label={t('desk.pickWorkflow')}>
|
||||
{#each groups as g (g.usage)}
|
||||
<div class="a-menu-group">{usageLabel(g.usage)} · {g.items.length}</div>
|
||||
{#each g.items as r (r.wf.id)}
|
||||
{@const cur = r.wf.id === current?.id}
|
||||
<button
|
||||
type="button"
|
||||
class="a-menu-item row"
|
||||
class:off={r.status.tone === 'err'}
|
||||
class:active={r.wf.id === activeId}
|
||||
role="option"
|
||||
aria-current={cur ? 'true' : undefined}
|
||||
aria-selected={r.wf.id === activeId ? 'true' : undefined}
|
||||
onclick={() => pick(r.wf)}
|
||||
onpointermove={() => (activeId = r.wf.id)}
|
||||
>
|
||||
<span class="main">
|
||||
<span class="a-menu-label">{r.wf.name}{cur ? ` · ${t('desk.wfCurrent')}` : ''}</span>
|
||||
<span class="sub">
|
||||
<span class="a-tag">{r.summary}</span>
|
||||
<span class="a-tag">{r.conn}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="a-tag {r.status.tone}">{r.status.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="empty">{t('desk.wfNoMatch')}</p>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="foot">
|
||||
<a class="a-btn ghost sm" href="/connections">
|
||||
<Icon name="upload" size={13} />
|
||||
{t('desk.wfImport')}
|
||||
</a>
|
||||
<span class="keys">
|
||||
<span class="a-kbd">↑↓</span>{t('desk.keySelect')}
|
||||
<span class="a-kbd">Enter</span>{t('desk.keyUse')}
|
||||
<span class="a-kbd">Esc</span>{t('desk.keyClose')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.pop-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* Search and foot stay put; only the list scrolls inside the pop's max-height. */
|
||||
.list {
|
||||
max-height: min(380px, 50vh);
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-1) 0;
|
||||
}
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
/* 键盘游走选中态(↑↓ 选择 · Enter 使用) */
|
||||
.row.active {
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.main .a-menu-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
.row.off .a-menu-label {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.sub {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
color: var(--text-3);
|
||||
font-size: var(--fs-aux);
|
||||
}
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-3) var(--sp-1) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.keys .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.keys {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { WorkbenchStageKey } from '$lib/api';
|
||||
import type { DeskKind } from './schema';
|
||||
|
||||
/**
|
||||
* 五段创作 → 生成台 handoff, carried in the URL so a reload keeps it.
|
||||
* The desk only prefills from it; the playground generate API has no
|
||||
* project/shot link, so results stay in the desk feed (no backfill).
|
||||
*/
|
||||
export interface DeskContext {
|
||||
kind: DeskKind;
|
||||
projectId: number;
|
||||
stage: WorkbenchStageKey;
|
||||
/** Human label of the source row, e.g. 镜头 S1 / 角色「林夏」. */
|
||||
label: string;
|
||||
prompt?: string;
|
||||
negative?: string;
|
||||
durationSec?: number;
|
||||
}
|
||||
|
||||
const STAGES: WorkbenchStageKey[] = ['script', 'style', 'storyboard', 'assets', 'shot'];
|
||||
|
||||
export function deskHref(ctx: DeskContext): string {
|
||||
const q = new URLSearchParams({
|
||||
from: 'workbench',
|
||||
project: String(ctx.projectId),
|
||||
stage: ctx.stage,
|
||||
label: ctx.label,
|
||||
});
|
||||
if (ctx.prompt) q.set('prompt', ctx.prompt);
|
||||
if (ctx.negative) q.set('negative', ctx.negative);
|
||||
if (ctx.durationSec != null) q.set('duration', String(ctx.durationSec));
|
||||
return `/${ctx.kind}?${q.toString()}`;
|
||||
}
|
||||
|
||||
export function readDeskContext(url: URL, kind: DeskKind): DeskContext | null {
|
||||
const q = url.searchParams;
|
||||
if (q.get('from') !== 'workbench') return null;
|
||||
const projectId = Number(q.get('project'));
|
||||
const stage = q.get('stage') as WorkbenchStageKey | null;
|
||||
if (!Number.isInteger(projectId) || projectId <= 0 || !stage || !STAGES.includes(stage)) return null;
|
||||
const duration = Number(q.get('duration'));
|
||||
return {
|
||||
kind,
|
||||
projectId,
|
||||
stage,
|
||||
label: q.get('label') ?? '',
|
||||
prompt: q.get('prompt') ?? undefined,
|
||||
negative: q.get('negative') ?? undefined,
|
||||
durationSec: Number.isFinite(duration) && duration > 0 ? duration : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function backHref(ctx: DeskContext): string {
|
||||
return `/workbench/${ctx.projectId}?stage=${ctx.stage}`;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import type { ComfyDynamicInput, Workflow, WorkflowUsage } from '$lib/comfy/types';
|
||||
import { normalizeInputRole } from '$lib/comfy/parser';
|
||||
import { ratioShort, resolutionSelectorSize } from '$lib/comfy/resolution';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* 生成台表单识别 (HEL-653, moved verbatim from StudioGenerate): slots and
|
||||
* structured params come from what the workflow JSON actually declares —
|
||||
* never from model names. No wiring → no slot.
|
||||
*/
|
||||
|
||||
export type DeskKind = 'image' | 'video';
|
||||
export type FormValue = string | number | string[];
|
||||
export type FormValues = Record<string, FormValue>;
|
||||
|
||||
export interface SlotDef {
|
||||
inp: ComfyDynamicInput;
|
||||
label: string;
|
||||
mediaKind: 'image' | 'video' | 'audio';
|
||||
fromResults: boolean;
|
||||
/** Required-slot message when left empty (ref-upload-spec §3). */
|
||||
missing: string;
|
||||
firstFrame: boolean;
|
||||
lastFrame: boolean;
|
||||
mask: boolean;
|
||||
}
|
||||
|
||||
/** Why 生成 is disabled — the line next to the button (spec-v2 §1.2). */
|
||||
export interface BlockReason {
|
||||
text: string;
|
||||
tone: 'warn' | 'err';
|
||||
/** Element id to focus when the reason is clicked. */
|
||||
target?: string;
|
||||
/** Open the params tab instead of focusing. */
|
||||
params?: boolean;
|
||||
}
|
||||
|
||||
export interface DeskSchema {
|
||||
prompt: ComfyDynamicInput | null;
|
||||
negative: ComfyDynamicInput | null;
|
||||
seed: ComfyDynamicInput | null;
|
||||
width: ComfyDynamicInput | null;
|
||||
height: ComfyDynamicInput | null;
|
||||
resolution: ComfyDynamicInput | null;
|
||||
duration: ComfyDynamicInput | null;
|
||||
batch: ComfyDynamicInput | null;
|
||||
/** ComfyUI ResolutionSelector: the ratio + megapixels pair decides width/height. */
|
||||
ratio: ComfyDynamicInput | null;
|
||||
megapixels: ComfyDynamicInput | null;
|
||||
/** Single media inputs (input image, first/last frame, mask, ref video/audio). */
|
||||
slots: SlotDef[];
|
||||
/** Autogrow media lists actually wired (image / video / audio). */
|
||||
lists: ComfyDynamicInput[];
|
||||
/** Parameters shown as composer chips (比例 / 尺寸 / 分辨率 / 时长 / 种子). */
|
||||
common: ComfyDynamicInput[];
|
||||
/** Everything else that is not media and not the prompt — folded. */
|
||||
advanced: ComfyDynamicInput[];
|
||||
hasH3Hint: boolean;
|
||||
}
|
||||
|
||||
export function inputRole(inp: ComfyDynamicInput): string | null {
|
||||
return normalizeInputRole(inp.role ?? null);
|
||||
}
|
||||
|
||||
function hay(inp: ComfyDynamicInput): string {
|
||||
return `${inp.role ?? ''} ${inp.label}`.toLowerCase();
|
||||
}
|
||||
|
||||
export function isMedia(inp: ComfyDynamicInput): boolean {
|
||||
return (
|
||||
inp.kind === 'image' ||
|
||||
inp.kind === 'image_url' ||
|
||||
inp.kind === 'video' ||
|
||||
inp.kind === 'audio' ||
|
||||
inp.kind === 'image_list' ||
|
||||
inp.kind === 'video_list' ||
|
||||
inp.kind === 'audio_list'
|
||||
);
|
||||
}
|
||||
|
||||
function isList(inp: ComfyDynamicInput): boolean {
|
||||
return inp.kind === 'image_list' || inp.kind === 'video_list' || inp.kind === 'audio_list';
|
||||
}
|
||||
|
||||
function isMask(inp: ComfyDynamicInput): boolean {
|
||||
return inputRole(inp) === 'mask' || /mask|遮罩/.test(hay(inp));
|
||||
}
|
||||
|
||||
// Auto-detected slots carry their role from the wiring; tagged (Input:…)
|
||||
// workflows still name frames only in the title.
|
||||
export function isFirstFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
|
||||
return inputRole(inp) === 'first_frame' || (!inp.auto && kind === 'video' && /first|start|首帧/.test(hay(inp)));
|
||||
}
|
||||
export function isLastFrame(inp: ComfyDynamicInput, kind: DeskKind): boolean {
|
||||
return inputRole(inp) === 'last_frame' || (!inp.auto && kind === 'video' && /last|end|尾帧/.test(hay(inp)));
|
||||
}
|
||||
|
||||
export function listKind(entry: ComfyDynamicInput): 'image' | 'video' | 'audio' {
|
||||
if (entry.kind === 'video_list') return 'video';
|
||||
if (entry.kind === 'audio_list') return 'audio';
|
||||
return 'image';
|
||||
}
|
||||
|
||||
export function listPaths(values: FormValues, entry: ComfyDynamicInput): string[] {
|
||||
const v = values[entry.nodeId];
|
||||
if (Array.isArray(v)) return v;
|
||||
return typeof v === 'string' && v ? [v] : [];
|
||||
}
|
||||
|
||||
export function classifySchema(schema: ComfyDynamicInput[], kind: DeskKind): DeskSchema {
|
||||
const plain = (pred: (i: ComfyDynamicInput) => boolean) =>
|
||||
schema.find((i) => !isMedia(i) && pred(i)) ?? null;
|
||||
const byRole = (r: string) => plain((i) => inputRole(i) === r);
|
||||
|
||||
const prompt = byRole('prompt');
|
||||
const negative = byRole('negative');
|
||||
const seed = byRole('seed');
|
||||
const width = byRole('width');
|
||||
const height = byRole('height');
|
||||
const resolution = byRole('resolution');
|
||||
const duration = byRole('duration');
|
||||
const batch = plain((i) => /批次|batch/.test(hay(i)));
|
||||
const ratio =
|
||||
schema.find((i) => i.kind === 'select' && inputRole(i) === 'aspect_ratio' && i.resolution) ?? null;
|
||||
const megapixels = ratio ? (schema.find((i) => i.nodeId === ratio.resolution!.megapixels) ?? null) : null;
|
||||
|
||||
const media = schema.filter((i) => isMedia(i) && !isList(i));
|
||||
const plainImages = media.filter(
|
||||
(i) => i.kind === 'image' && !isMask(i) && !isFirstFrame(i, kind) && !isLastFrame(i, kind),
|
||||
);
|
||||
const editInput = kind === 'image' && plainImages.length === 1 ? plainImages[0] : null;
|
||||
const slots: SlotDef[] = [];
|
||||
let refIdx = 0;
|
||||
for (const inp of media) {
|
||||
const base = { inp, missing: '', firstFrame: false, lastFrame: false, mask: false };
|
||||
if (inp.kind === 'video') {
|
||||
slots.push({ ...base, label: t('studio.slotRefVideo'), mediaKind: 'video', fromResults: false });
|
||||
} else if (inp.kind === 'audio') {
|
||||
slots.push({ ...base, label: t('studio.slotAudio'), mediaKind: 'audio', fromResults: false });
|
||||
} else if (isMask(inp)) {
|
||||
slots.push({ ...base, mask: true, label: t('studio.slotMask'), mediaKind: 'image', fromResults: false });
|
||||
} else if (isFirstFrame(inp, kind)) {
|
||||
slots.push({
|
||||
...base,
|
||||
firstFrame: true,
|
||||
label: t('studio.slotFirst'),
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errNeedFirstFrame'),
|
||||
});
|
||||
} else if (isLastFrame(inp, kind)) {
|
||||
slots.push({ ...base, lastFrame: true, label: t('studio.slotLast'), mediaKind: 'image', fromResults: true });
|
||||
} else if (inp === editInput) {
|
||||
slots.push({
|
||||
...base,
|
||||
label: t('studio.slotInput'),
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errNeedInputImage'),
|
||||
});
|
||||
} else {
|
||||
refIdx += 1;
|
||||
const label = inp.auto
|
||||
? inp.label
|
||||
: refIdx === 1 && media.length === 1
|
||||
? t('studio.slotRef')
|
||||
: t('studio.slotRefN', { n: refIdx });
|
||||
slots.push({
|
||||
...base,
|
||||
label,
|
||||
mediaKind: 'image',
|
||||
fromResults: true,
|
||||
missing: t('studio.errUpload', { label }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const lists = schema.filter(isList);
|
||||
const common = [ratio, width, height, resolution, duration, seed].filter(
|
||||
(i): i is ComfyDynamicInput => !!i,
|
||||
);
|
||||
const commonIds = new Set(common.map((i) => i.nodeId));
|
||||
const advanced = schema.filter((i) => !isMedia(i) && i !== prompt && !commonIds.has(i.nodeId));
|
||||
|
||||
return {
|
||||
prompt,
|
||||
negative,
|
||||
seed,
|
||||
width,
|
||||
height,
|
||||
resolution,
|
||||
duration,
|
||||
batch,
|
||||
ratio,
|
||||
megapixels,
|
||||
slots,
|
||||
lists,
|
||||
common,
|
||||
advanced,
|
||||
hasH3Hint: lists.some((e) => e.media_hint === 'minimax_h3_ref2va'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Workflow JSON defaults — the 原值 every parameter falls back to. */
|
||||
export function schemaDefaults(schema: ComfyDynamicInput[]): FormValues {
|
||||
const out: FormValues = {};
|
||||
for (const inp of schema) {
|
||||
if (inp.defaultValue !== undefined && inp.defaultValue !== '') out[inp.nodeId] = inp.defaultValue;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isChanged(inp: ComfyDynamicInput, values: FormValues): boolean {
|
||||
const v = values[inp.nodeId];
|
||||
const orig = inp.defaultValue;
|
||||
if (v === undefined || v === '') return orig !== undefined && orig !== '';
|
||||
if (orig === undefined || orig === '') return true;
|
||||
return String(v) !== String(orig);
|
||||
}
|
||||
|
||||
export function changedParams(ds: DeskSchema, values: FormValues): ComfyDynamicInput[] {
|
||||
return [...ds.common, ...ds.advanced].filter((i) => isChanged(i, values));
|
||||
}
|
||||
|
||||
export function outputSize(ds: DeskSchema, values: FormValues): { width: number; height: number } | null {
|
||||
if (!ds.ratio?.resolution) return null;
|
||||
const mp = Number(values[ds.megapixels?.nodeId ?? ''] ?? ds.megapixels?.defaultValue);
|
||||
return resolutionSelectorSize(
|
||||
String(values[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
|
||||
mp,
|
||||
ds.ratio.resolution.multiple,
|
||||
);
|
||||
}
|
||||
|
||||
export function batchCount(ds: DeskSchema, values: FormValues): number {
|
||||
if (!ds.batch) return 1;
|
||||
const n = Number(values[ds.batch.nodeId]);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
|
||||
}
|
||||
|
||||
/** H3 Ref2VA official limits (soft hint, keyed off the node class only). */
|
||||
export function h3Warnings(ds: DeskSchema, values: FormValues): string[] {
|
||||
if (!ds.hasH3Hint) return [];
|
||||
const warns: string[] = [];
|
||||
const counts = { image: 0, video: 0, audio: 0 };
|
||||
for (const g of ds.lists) counts[listKind(g)] += listPaths(values, g).length;
|
||||
const total = counts.image + counts.video + counts.audio;
|
||||
if (total > 12) warns.push(t('wfMedia.h3TotalOver', { n: total }));
|
||||
if (counts.audio > 0 && counts.image === 0 && counts.video === 0) warns.push(t('wfMedia.h3AudioAlone'));
|
||||
return warns;
|
||||
}
|
||||
|
||||
/** Same rules the backend enforces before queueing (comfyui/validate.py). */
|
||||
export function valueError(inp: ComfyDynamicInput, raw: string | number): string | null {
|
||||
const label = inp.label;
|
||||
if (inp.kind === 'select') {
|
||||
return inp.options?.length && !inp.options.includes(String(raw)) ? t('studio.errOption', { label }) : null;
|
||||
}
|
||||
if (inp.kind !== 'number') return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return t('studio.errNumber', { label });
|
||||
if (inp.integer && !Number.isInteger(n)) return t('studio.errInteger', { label });
|
||||
if (inp.min != null && n < inp.min) return t('studio.errMin', { label, v: inp.min });
|
||||
if (inp.max != null && n > inp.max) return t('studio.errMax', { label, v: inp.max });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Field errors keyed by nodeId (spec §6.2 wording); '' keys never block. */
|
||||
export function validateForm(
|
||||
schema: ComfyDynamicInput[],
|
||||
ds: DeskSchema,
|
||||
values: FormValues,
|
||||
): Record<string, string> {
|
||||
const errs: Record<string, string> = {};
|
||||
if (ds.prompt?.required) {
|
||||
const v = String(values[ds.prompt.nodeId] ?? '');
|
||||
if (!v.trim()) errs[ds.prompt.nodeId] = t('studio.errPrompt');
|
||||
else if (v.length > 2000) errs[ds.prompt.nodeId] = t('studio.errPromptLong');
|
||||
}
|
||||
const first = ds.slots.find((s) => s.firstFrame);
|
||||
const last = ds.slots.find((s) => s.lastFrame);
|
||||
if (first && last) {
|
||||
const a = String(values[first.inp.nodeId] ?? '');
|
||||
const b = String(values[last.inp.nodeId] ?? '');
|
||||
if ((!a || !b) && (a || b)) {
|
||||
errs.firstLast = t('studio.errFirstLast');
|
||||
if (!a) errs[first.inp.nodeId] = ' ';
|
||||
if (!b) errs[last.inp.nodeId] = ' ';
|
||||
}
|
||||
}
|
||||
for (const s of ds.slots) {
|
||||
if (!s.inp.required) continue;
|
||||
if (!String(values[s.inp.nodeId] ?? '').trim()) {
|
||||
errs[s.inp.nodeId] = s.missing || t('studio.errUpload', { label: s.label });
|
||||
}
|
||||
}
|
||||
for (const g of ds.lists) {
|
||||
const n = listPaths(values, g).length;
|
||||
const max = g.max ?? 9;
|
||||
if (n < (g.min ?? 1)) {
|
||||
errs[g.nodeId] =
|
||||
g.kind === 'image_list'
|
||||
? t('studio.errNeedRef')
|
||||
: t('studio.errNeedRefMedia', { n: g.min ?? 1, label: g.label });
|
||||
} else if (n > max) {
|
||||
errs[g.nodeId] = t('studio.errRefMax', { max, n, k: n - max });
|
||||
}
|
||||
}
|
||||
for (const inp of [...ds.common, ...ds.advanced]) {
|
||||
if (inp.required && !String(values[inp.nodeId] ?? '').trim()) {
|
||||
errs[inp.nodeId] = t('studio.errFill', { label: inp.label });
|
||||
}
|
||||
}
|
||||
for (const inp of schema) {
|
||||
const raw = values[inp.nodeId];
|
||||
if (raw === undefined || Array.isArray(raw) || String(raw).trim() === '' || errs[inp.nodeId]) continue;
|
||||
const msg = valueError(inp, raw);
|
||||
if (msg) errs[inp.nodeId] = msg;
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
/** Media a slot still waits for — the 「还缺:…」 hint next to 生成. */
|
||||
export function missingMedia(ds: DeskSchema, values: FormValues): Array<{ nodeId: string; label: string }> {
|
||||
const out: Array<{ nodeId: string; label: string }> = [];
|
||||
for (const s of ds.slots) {
|
||||
if (s.inp.required && !String(values[s.inp.nodeId] ?? '').trim()) out.push({ nodeId: s.inp.nodeId, label: s.label });
|
||||
}
|
||||
for (const g of ds.lists) {
|
||||
if (listPaths(values, g).length < (g.min ?? 1)) out.push({ nodeId: g.nodeId, label: g.label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function usageLabel(u: WorkflowUsage): string {
|
||||
return t(`usage.${u}`);
|
||||
}
|
||||
|
||||
/** Mode badge: the stored usage when we have one, else the wiring shape. */
|
||||
export function modeLabel(wf: Workflow | null, ds: DeskSchema, kind: DeskKind): string {
|
||||
if (wf?.usage) return usageLabel(wf.usage);
|
||||
if (ds.lists.some((g) => g.kind === 'image_list')) return t('studio.modeRefToVideo');
|
||||
if (ds.slots.some((s) => s.firstFrame)) return t('studio.modeImageToVideo');
|
||||
if (kind === 'image' && ds.slots.some((s) => s.mediaKind === 'image' && !s.mask))
|
||||
return t('studio.modeImageEdit');
|
||||
if (ds.slots.length) return ds.slots.map((s) => s.label).join(' · ');
|
||||
return kind === 'image' ? t('studio.modeTextToImage') : t('studio.modeTextToVideo');
|
||||
}
|
||||
|
||||
/** Picker row summary of a workflow's real media inputs, e.g. 参考图 1–9 张. */
|
||||
export function slotSummary(wf: Workflow): string {
|
||||
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
|
||||
const parts = [
|
||||
...ds.slots.map((s) => t('desk.slotOne', { label: s.label })),
|
||||
...ds.lists.map((g) => t('desk.slotRange', { label: g.label, min: g.min ?? 1, max: g.max ?? 9 })),
|
||||
];
|
||||
return parts.length ? parts.join(' · ') : t('desk.promptOnly');
|
||||
}
|
||||
|
||||
/** Short chip text for one common parameter value. */
|
||||
export function chipValue(inp: ComfyDynamicInput, values: FormValues, ds: DeskSchema): string {
|
||||
const raw = values[inp.nodeId] ?? inp.defaultValue ?? '';
|
||||
const v = Array.isArray(raw) ? raw.join(',') : String(raw);
|
||||
if (inp === ds.ratio) {
|
||||
const size = outputSize(ds, values);
|
||||
return size ? `${ratioShort(v)} · ${size.width}×${size.height}` : ratioShort(v);
|
||||
}
|
||||
if (inp === ds.duration) return v ? t('desk.chipSeconds', { v }) : t('studio.durationLabel');
|
||||
if (inp === ds.seed) return t('desk.chipSeed', { v: v.length > 8 ? `${v.slice(0, 8)}…` : v || '—' });
|
||||
if (inp === ds.resolution) return t('desk.chipResolution', { v: v || '—' });
|
||||
return v || inp.label;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Job, Workflow } from '$lib/comfy/types';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { resolutionSelectorSize } from '$lib/comfy/resolution';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { classifySchema, inputRole, isFirstFrame, isLastFrame, isMedia } from './schema';
|
||||
|
||||
/** Read-only views of a finished / running take (one playground job). */
|
||||
|
||||
export type FeedFilter = 'all' | 'image' | 'video' | 'failed';
|
||||
|
||||
export function jobValues(job: Job): Record<string, unknown> {
|
||||
return (job.payload?.input_values ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function jobValueByRole(job: Job, wf: Workflow | undefined, ...roles: string[]): string | null {
|
||||
if (!wf) return null;
|
||||
const vals = jobValues(job);
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
const r = inputRole(inp);
|
||||
if (r && roles.includes(r)) {
|
||||
const v = vals[inp.nodeId];
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function promptOf(job: Job, wf: Workflow | undefined): string {
|
||||
return jobValueByRole(job, wf, 'prompt') ?? '';
|
||||
}
|
||||
|
||||
export interface TakeInput {
|
||||
label: string;
|
||||
path: string;
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
}
|
||||
|
||||
/** Media the take was generated from, in workflow order. */
|
||||
export function inputsOf(job: Job, wf: Workflow | undefined): TakeInput[] {
|
||||
if (!wf) return [];
|
||||
const kind = wf.kind;
|
||||
const vals = jobValues(job);
|
||||
const out: TakeInput[] = [];
|
||||
let refIdx = 0;
|
||||
for (const inp of wf.input_schema ?? []) {
|
||||
if (!isMedia(inp)) continue;
|
||||
const v = vals[inp.nodeId];
|
||||
const list = Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : [];
|
||||
const mk = inp.kind.startsWith('video') ? 'video' : inp.kind.startsWith('audio') ? 'audio' : 'image';
|
||||
list.forEach((p, i) => {
|
||||
if (typeof p !== 'string' || !p) return;
|
||||
let label = inp.label;
|
||||
if (inp.kind === 'image_list') label = t('studio.slotRefN', { n: i + 1 });
|
||||
else if (isFirstFrame(inp, kind)) label = t('studio.slotFirst');
|
||||
else if (isLastFrame(inp, kind)) label = t('studio.slotLast');
|
||||
else if (inp.kind === 'image' && inputRole(inp) !== 'mask') label = t('studio.slotRefN', { n: ++refIdx });
|
||||
out.push({ label, path: p, kind: mk });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 888×1184 · 种子 42 · 5 秒 — values that differ per take. */
|
||||
export function specOf(job: Job, wf: Workflow | undefined): Array<{ label: string; value: string }> {
|
||||
const out: Array<{ label: string; value: string }> = [];
|
||||
if (!wf) return out;
|
||||
const w = jobValueByRole(job, wf, 'width');
|
||||
const h = jobValueByRole(job, wf, 'height');
|
||||
if (w && h) out.push({ label: t('desk.specSize'), value: `${w}×${h}` });
|
||||
else {
|
||||
const ds = classifySchema(wf.input_schema ?? [], wf.kind);
|
||||
if (ds.ratio?.resolution) {
|
||||
const vals = jobValues(job);
|
||||
const size = resolutionSelectorSize(
|
||||
String(vals[ds.ratio.nodeId] ?? ds.ratio.defaultValue ?? ''),
|
||||
Number(vals[ds.ratio.resolution.megapixels] ?? ds.megapixels?.defaultValue),
|
||||
ds.ratio.resolution.multiple,
|
||||
);
|
||||
if (size) out.push({ label: t('desk.specSize'), value: `${size.width}×${size.height}` });
|
||||
}
|
||||
}
|
||||
const seed = jobValueByRole(job, wf, 'seed');
|
||||
if (seed) out.push({ label: t('studio.seedLabel'), value: seed });
|
||||
const dur = jobValueByRole(job, wf, 'duration');
|
||||
if (dur) out.push({ label: t('studio.durationLabel'), value: t('desk.chipSeconds', { v: dur }) });
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isVideoOutput(job: Job, path: string): boolean {
|
||||
return job.kind === 'video' && !/\.(png|jpe?g|webp)$/i.test(path);
|
||||
}
|
||||
|
||||
export function elapsedSec(job: Job, nowMs: number): number | null {
|
||||
const from = job.started_at ? parseDbTs(job.started_at) : parseDbTs(job.created_at);
|
||||
if (!Number.isFinite(from)) return null;
|
||||
const to = job.completed_at ? parseDbTs(job.completed_at) : nowMs;
|
||||
return Math.max(0, (to - from) / 1000);
|
||||
}
|
||||
|
||||
export function fmtDur(sec: number | null): string {
|
||||
if (sec == null) return '—';
|
||||
if (sec < 60) return t('desk.durSec', { s: Math.floor(sec) });
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return t('desk.durMin', { m, s: String(s).padStart(2, '0') });
|
||||
}
|
||||
|
||||
export function fmtClock(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 今天 / 昨天 / 9月28日 heading for the feed. */
|
||||
export function dayLabel(ms: number, nowMs: number): string {
|
||||
const d = new Date(ms);
|
||||
const today = new Date(nowMs);
|
||||
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||
const diff = Math.round((startOf(today) - startOf(d)) / 86_400_000);
|
||||
if (diff === 0) return t('desk.today');
|
||||
if (diff === 1) return t('desk.yesterday');
|
||||
return t('desk.dayDate', { m: d.getMonth() + 1, d: d.getDate() });
|
||||
}
|
||||
|
||||
export function matchesFilter(job: Job, filter: FeedFilter): boolean {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'failed') return jobStatusInfo(job).tone === 'fail';
|
||||
return job.kind === filter;
|
||||
}
|
||||
@@ -39,7 +39,8 @@
|
||||
function segClass(key: string): string {
|
||||
const state = stats.stage_status?.[key];
|
||||
if (state === 'confirmed') return 'seg-confirmed';
|
||||
if (state === 'running' || state === 'awaiting_confirm') return 'seg-draft';
|
||||
if (state === 'running') return 'seg-running';
|
||||
if (state === 'awaiting_confirm') return 'seg-draft';
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -70,7 +71,7 @@
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.running')}` };
|
||||
}
|
||||
if (state === 'awaiting_confirm') {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('workbench.state.draft')}` };
|
||||
return { cls: 'st-pending', label: `${stageName} · ${t('workbench.state.draft')}` };
|
||||
}
|
||||
if (stats.stage_confirmed > 0) {
|
||||
return { cls: 'st-progress', label: `${stageName} · ${t('home.statusProgress')}` };
|
||||
@@ -329,7 +330,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(22, 19, 10, 0.82);
|
||||
color: color-mix(in srgb, var(--accent-ink) 82%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.status-tag {
|
||||
@@ -340,7 +341,7 @@
|
||||
border-radius: 999px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
background: rgba(13, 14, 17, 0.78);
|
||||
background: var(--media-scrim);
|
||||
border: 1px solid var(--border-strong);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
@@ -350,7 +351,10 @@
|
||||
color: var(--text-2);
|
||||
}
|
||||
.st-progress {
|
||||
color: var(--accent);
|
||||
color: var(--run);
|
||||
}
|
||||
.st-pending {
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-done {
|
||||
color: var(--success);
|
||||
@@ -409,8 +413,11 @@
|
||||
border-radius: 2px;
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.seg-running {
|
||||
background: var(--run);
|
||||
}
|
||||
.seg-draft {
|
||||
background: var(--accent);
|
||||
background: var(--warn);
|
||||
}
|
||||
.seg-confirmed {
|
||||
background: var(--success);
|
||||
@@ -425,8 +432,8 @@
|
||||
margin-left: auto;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -462,7 +469,7 @@
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: rgba(13, 14, 17, 0.6);
|
||||
background: var(--media-scrim);
|
||||
color: var(--text-2);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { projects } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
import { NAV } from './nav';
|
||||
|
||||
/** Ctrl/⌘K 搜索:跳到任一页面或项目(↑↓ 选择,Enter 打开,Esc 关闭). */
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
interface Entry {
|
||||
key: string;
|
||||
group: 'pages' | 'projects';
|
||||
label: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const listId = `palette-${Math.random().toString(36).slice(2, 9)}`;
|
||||
let query = $state('');
|
||||
let activeIndex = $state(0);
|
||||
let list = $state<HTMLDivElement | null>(null);
|
||||
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const entries = $derived.by<Entry[]>(() => {
|
||||
const pages: Entry[] = NAV.map((item) => ({
|
||||
key: `page-${item.id}`,
|
||||
group: 'pages',
|
||||
label: t(item.labelKey),
|
||||
icon: item.icon,
|
||||
href: item.href,
|
||||
}));
|
||||
const projectEntries: Entry[] = ($projectsQuery.data ?? []).map((p) => ({
|
||||
key: `project-${p.id}`,
|
||||
group: 'projects',
|
||||
label: p.title,
|
||||
icon: 'story',
|
||||
href: `/workbench/${p.id}`,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = [...pages, ...projectEntries];
|
||||
return q ? all.filter((e) => e.label.toLowerCase().includes(q)) : all;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void query;
|
||||
activeIndex = 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) query = '';
|
||||
});
|
||||
|
||||
function optionId(index: number) {
|
||||
return `${listId}-${index}`;
|
||||
}
|
||||
|
||||
function go(entry: Entry) {
|
||||
open = false;
|
||||
void goto(entry.href);
|
||||
}
|
||||
|
||||
function move(step: number) {
|
||||
if (entries.length === 0) return;
|
||||
activeIndex = (activeIndex + step + entries.length) % entries.length;
|
||||
list?.querySelector(`#${optionId(activeIndex)}`)?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
} else if (event.key === 'Enter') {
|
||||
const entry = entries[activeIndex];
|
||||
if (entry) {
|
||||
event.preventDefault();
|
||||
go(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function focusOnMount(node: HTMLInputElement) {
|
||||
requestAnimationFrame(() => node.focus());
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:open title={t('shell.searchTitle')}>
|
||||
<div class="palette">
|
||||
<div class="palette-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
use:focusOnMount
|
||||
class="palette-input"
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder={t('shell.searchPlaceholder')}
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={entries.length ? optionId(activeIndex) : undefined}
|
||||
aria-label={t('shell.searchTitle')}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
</div>
|
||||
<div class="palette-list" id={listId} role="listbox" bind:this={list} aria-label={t('shell.searchTitle')}>
|
||||
{#if entries.length === 0}
|
||||
<p class="palette-empty">{t('shell.searchEmpty', { q: query.trim() })}</p>
|
||||
{/if}
|
||||
{#each entries as entry, i (entry.key)}
|
||||
{#if i === 0 || entries[i - 1].group !== entry.group}
|
||||
<div class="a-menu-group" role="presentation">
|
||||
{entry.group === 'pages' ? t('shell.searchGroupPages') : t('shell.searchGroupProjects')}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
id={optionId(i)}
|
||||
class="a-menu-item"
|
||||
class:active={i === activeIndex}
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-selected={i === activeIndex}
|
||||
onpointermove={() => (activeIndex = i)}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => go(entry)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<Icon name={entry.icon} size={15} />
|
||||
<span class="a-menu-label">{entry.label}</span>
|
||||
<span class="a-menu-meta mono">{entry.href}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="palette-hint">
|
||||
<span class="a-kbd">↑↓</span>{t('shell.searchHintMove')}
|
||||
<span class="a-kbd">Enter</span>{t('shell.searchHintOpen')}
|
||||
<span class="a-kbd">Esc</span>{t('shell.searchHintClose')}
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.palette-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-3);
|
||||
color: var(--text-3);
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.palette-search:focus-within {
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.palette-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-section);
|
||||
}
|
||||
.palette-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-list {
|
||||
max-height: min(360px, 50vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.palette-empty {
|
||||
margin: 0;
|
||||
padding: var(--sp-6) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.palette-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.palette-hint .a-kbd:not(:first-child) {
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
@media (hover: none) {
|
||||
.palette-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { projects } from '$lib/api';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import ShellPop from './ShellPop.svelte';
|
||||
|
||||
/**
|
||||
* 顶栏范围胶囊 — the project picker that replaced the old sidebar project
|
||||
* entry: shows the project the page belongs to (or 全部项目) and lists
|
||||
* recent projects, each opening its five-stage workbench.
|
||||
*/
|
||||
interface Props {
|
||||
projectId: number | null;
|
||||
onProjectsList: boolean;
|
||||
}
|
||||
|
||||
let { projectId, onProjectsList }: Props = $props();
|
||||
|
||||
const RECENT_LIMIT = 8;
|
||||
let open = $state(false);
|
||||
|
||||
const projectsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['projects'],
|
||||
queryFn: projects.list,
|
||||
enabled: open || projectId != null,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const all = $derived($projectsQuery.data ?? []);
|
||||
const current = $derived(projectId == null ? null : (all.find((p) => p.id === projectId) ?? null));
|
||||
const recent = $derived(
|
||||
[...all]
|
||||
.sort((a, b) => (parseDbTs(b.updated_at) || 0) - (parseDbTs(a.updated_at) || 0))
|
||||
.slice(0, RECENT_LIMIT),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="scope">
|
||||
<button
|
||||
type="button"
|
||||
class="status-chip scope-btn"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
title={t('shell.scopeHint')}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="scope-label">{current?.title ?? t('shell.scopeAll')}</span>
|
||||
<Icon name="chevron-down" size={12} />
|
||||
</button>
|
||||
|
||||
<ShellPop bind:open label={t('shell.scopeHint')} width="300px">
|
||||
<a
|
||||
class="a-menu-item"
|
||||
href="/"
|
||||
aria-current={onProjectsList ? 'page' : undefined}
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
<Icon name="folder" size={15} />
|
||||
<span class="a-menu-label">{t('shell.scopeAllList')}</span>
|
||||
{#if all.length > 0}<span class="a-menu-meta mono">{all.length}</span>{/if}
|
||||
</a>
|
||||
<div class="a-menu-group">{t('shell.scopeRecent')}</div>
|
||||
{#if $projectsQuery.isPending}
|
||||
<p class="scope-note">{t('common.loading')}…</p>
|
||||
{:else if $projectsQuery.isError}
|
||||
<p class="scope-note err">{t('shell.scopeLoadFailed')}</p>
|
||||
{:else if recent.length === 0}
|
||||
<a class="a-menu-item" href="/" onclick={() => (open = false)}>
|
||||
<Icon name="plus" size={15} />
|
||||
<span class="a-menu-label">{t('shell.scopeEmpty')}</span>
|
||||
</a>
|
||||
{:else}
|
||||
{#each recent as project (project.id)}
|
||||
<a
|
||||
class="a-menu-item"
|
||||
href="/workbench/{project.id}"
|
||||
aria-current={project.id === projectId ? 'true' : undefined}
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
<Icon name="story" size={15} />
|
||||
<span class="a-menu-label">{project.title}</span>
|
||||
{#if project.stats}
|
||||
<span class="a-menu-meta">
|
||||
{t('shell.scopeStage', { n: project.stats.stage_confirmed })}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scope {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.scope-btn {
|
||||
max-width: 220px;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
.scope-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.scope-note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.scope-note.err {
|
||||
color: var(--err);
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.scope-btn {
|
||||
max-width: 132px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { toStore } from 'svelte/store';
|
||||
import { workflows, type Job } from '$lib/api';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { jobSourceLabel } from '$lib/jobLabels';
|
||||
import { jobStatusInfo, type JobStatusTone } from '$lib/jobStatus';
|
||||
import ShellPop from './ShellPop.svelte';
|
||||
|
||||
/**
|
||||
* 顶栏队列胶囊 + 任务托盘 (§二). The dot is cyan only while something is
|
||||
* actually generating — queued work stays gray, as on the task page.
|
||||
*/
|
||||
let { jobs }: { jobs: Job[] } = $props();
|
||||
|
||||
const TRAY_LIMIT = 6;
|
||||
const TAG_CLASS: Record<JobStatusTone, string> = { ok: 'ok', run: 'run', queue: '', fail: 'err' };
|
||||
|
||||
let open = $state(false);
|
||||
let capsule = $state<HTMLButtonElement | null>(null);
|
||||
|
||||
const workflowsQuery = createQuery(
|
||||
toStore(() => ({
|
||||
queryKey: ['workflows'],
|
||||
queryFn: workflows.list,
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
);
|
||||
|
||||
const active = $derived(jobs.filter((j) => j.status === 'pending' || j.status === 'running'));
|
||||
const running = $derived(active.filter((j) => jobStatusInfo(j).tone === 'run').length);
|
||||
const queued = $derived(active.length - running);
|
||||
const recent = $derived([...jobs].sort((a, b) => b.id - a.id).slice(0, TRAY_LIMIT));
|
||||
|
||||
const label = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (running > 0) parts.push(t('shell.queueRunning', { n: running }));
|
||||
if (queued > 0) parts.push(t('shell.queueQueued', { n: queued }));
|
||||
return parts.length ? parts.join(' · ') : t('shell.queueIdle');
|
||||
});
|
||||
|
||||
// 任务数变化时胶囊轻弹一次(320ms,只播一次).
|
||||
let lastCount: number | null = null;
|
||||
$effect(() => {
|
||||
const count = active.length;
|
||||
if (lastCount !== null && count !== lastCount && capsule) {
|
||||
capsule.classList.remove('bump');
|
||||
void capsule.offsetWidth;
|
||||
capsule.classList.add('bump');
|
||||
}
|
||||
lastCount = count;
|
||||
});
|
||||
|
||||
function workflowName(job: Job): string {
|
||||
return (
|
||||
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ??
|
||||
t('studio.jobName', { id: job.id })
|
||||
);
|
||||
}
|
||||
|
||||
function timeOf(job: Job): string {
|
||||
const ts = parseDbTs(job.created_at);
|
||||
if (!Number.isFinite(ts)) return '—';
|
||||
const d = new Date(ts);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tray">
|
||||
<button
|
||||
bind:this={capsule}
|
||||
type="button"
|
||||
class="status-chip queue-btn"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onclick={() => (open = !open)}
|
||||
onanimationend={() => capsule?.classList.remove('bump')}
|
||||
>
|
||||
<span class="status-chip-dot {running > 0 ? 'run' : 'idle'}" aria-hidden="true"></span>
|
||||
<span class="queue-label">{label}</span>
|
||||
<span class="queue-count mono">{active.length}</span>
|
||||
</button>
|
||||
|
||||
<ShellPop bind:open label={t('shell.trayTitle')} align="end" width="340px">
|
||||
<div class="a-menu-group">{t('shell.trayRecent')}</div>
|
||||
{#if recent.length === 0}
|
||||
<p class="tray-note">{t('shell.trayEmpty')}</p>
|
||||
{:else}
|
||||
{#each recent as job (job.id)}
|
||||
{@const info = jobStatusInfo(job)}
|
||||
<a class="a-menu-item tray-row" href="/tasks" title={info.detail} onclick={() => (open = false)}>
|
||||
<span class="tray-copy">
|
||||
<span class="a-menu-label">{workflowName(job)}</span>
|
||||
<span class="tray-meta">{jobSourceLabel(job)} · <span class="mono">{timeOf(job)}</span></span>
|
||||
</span>
|
||||
<span class="a-tag {TAG_CLASS[info.tone]}">{info.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
<a class="a-menu-item tray-all" href="/tasks" onclick={() => (open = false)}>
|
||||
<span class="a-menu-label">{t('shell.trayAll')}</span>
|
||||
</a>
|
||||
</ShellPop>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tray {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.queue-btn:global(.bump) {
|
||||
animation: queue-bump 320ms var(--ease-out);
|
||||
}
|
||||
@keyframes queue-bump {
|
||||
40% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
}
|
||||
.queue-count {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1179px) {
|
||||
.queue-label {
|
||||
display: none;
|
||||
}
|
||||
.queue-count {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
.tray-row {
|
||||
align-items: center;
|
||||
}
|
||||
.tray-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.tray-meta {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tray-note {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4) var(--sp-5);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.tray-all {
|
||||
margin-top: var(--sp-1);
|
||||
border-top: 1px solid var(--line);
|
||||
border-radius: 0 0 var(--r-md) var(--r-md);
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut, quintOut } from 'svelte/easing';
|
||||
|
||||
/**
|
||||
* Anchored pop for the shell (§四.3 pop): opens 180ms from its trigger,
|
||||
* closes on Esc / outside press, Tab cycles inside, ↑↓ move between rows.
|
||||
* At ≤760px it becomes a bottom sheet. Render it next to its trigger
|
||||
* inside a `position: relative` wrapper — that wrapper counts as inside.
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
label: string;
|
||||
align?: 'start' | 'end';
|
||||
/** `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;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
label,
|
||||
align = 'start',
|
||||
placement = 'bottom',
|
||||
width = '300px',
|
||||
arrowRoving = true,
|
||||
onclose,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
const SHEET_QUERY = '(max-width: 760px)';
|
||||
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
let scrim = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function focusables(): HTMLElement[] {
|
||||
if (!panel) return [];
|
||||
return Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
const list = focusables();
|
||||
if (list.length === 0) return;
|
||||
const current = document.activeElement as HTMLElement | null;
|
||||
const index = current ? list.indexOf(current) : -1;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
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;
|
||||
list[(next + list.length) % list.length].focus();
|
||||
} else if (event.key === 'Tab') {
|
||||
if (event.shiftKey && index <= 0) {
|
||||
event.preventDefault();
|
||||
list[list.length - 1].focus();
|
||||
} else if (!event.shiftKey && (index < 0 || index === list.length - 1)) {
|
||||
event.preventDefault();
|
||||
list[0].focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const host = panel?.parentElement;
|
||||
const target = event.target as Node;
|
||||
if (!host) return;
|
||||
if (!host.contains(target) || target === scrim) open = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const invoker = document.activeElement as HTMLElement | null;
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
requestAnimationFrame(() => focusables()[0]?.focus());
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
const focusWasInside =
|
||||
document.activeElement === document.body || !!panel?.contains(document.activeElement);
|
||||
if (focusWasInside && invoker?.isConnected) invoker.focus();
|
||||
onclose?.();
|
||||
};
|
||||
});
|
||||
|
||||
function cssMs(name: string, fallback: number): number {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(name);
|
||||
const ms = parseFloat(raw);
|
||||
return Number.isFinite(ms) ? ms : fallback;
|
||||
}
|
||||
|
||||
function popIn(_node: Element) {
|
||||
const reduced = document.documentElement.dataset.reduce === '1';
|
||||
const sheet = window.matchMedia(SHEET_QUERY).matches;
|
||||
if (reduced) {
|
||||
return { duration: cssMs('--d-state', 120), css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
if (sheet) {
|
||||
return {
|
||||
duration: cssMs('--d-drawer', 280),
|
||||
easing: quintOut,
|
||||
css: (t: number) => `transform: translateY(${(1 - t) * 100}%)`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
duration: cssMs('--d-pop', 180),
|
||||
easing: cubicOut,
|
||||
css: (t: number) => `opacity: ${t}; transform: scale(${0.96 + 0.04 * t})`,
|
||||
};
|
||||
}
|
||||
|
||||
function fadeScrim(_node: Element) {
|
||||
return { duration: cssMs('--d-pop', 180), css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="pop-scrim" bind:this={scrim} aria-hidden="true" transition:fadeScrim></div>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="shell-pop a-float align-{align} place-{placement}"
|
||||
style:--pop-w={width}
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
transition:popIn
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.shell-pop {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--sp-2));
|
||||
z-index: 60;
|
||||
width: var(--pop-w);
|
||||
max-width: calc(100vw - 2 * var(--sp-5));
|
||||
max-height: min(520px, 70vh);
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-2);
|
||||
box-sizing: border-box;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.shell-pop.align-start {
|
||||
left: 0;
|
||||
}
|
||||
.shell-pop.align-end {
|
||||
right: 0;
|
||||
transform-origin: top right;
|
||||
}
|
||||
.shell-pop.place-top {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--sp-2));
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
.shell-pop.place-top.align-end {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
.pop-scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.pop-scrim {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 59;
|
||||
background: var(--overlay);
|
||||
}
|
||||
.shell-pop,
|
||||
.shell-pop.place-top {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
max-height: 78vh;
|
||||
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||
padding: var(--sp-3) var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { IconName } from '$lib/components/ui/icons';
|
||||
|
||||
export type NavId =
|
||||
| 'projects'
|
||||
| 'workbench'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'tasks'
|
||||
| 'agents'
|
||||
| 'connections'
|
||||
| 'models'
|
||||
| 'settings';
|
||||
|
||||
export interface NavItem {
|
||||
id: NavId;
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
}
|
||||
|
||||
// Order is fixed by spec-v2 §3: 项目 / 创作工作台 / 生图 / 生视频 / 生成任务 /
|
||||
// 智能体 / ComfyUI 连接 / 模型设置 / 设置. HEL-649 §二 puts the connection
|
||||
// and settings entries at the foot of the 60px rail.
|
||||
export const NAV_MAIN: NavItem[] = [
|
||||
{ id: 'projects', labelKey: 'shell.navProjects', icon: 'home', href: '/' },
|
||||
{ id: 'workbench', labelKey: 'shell.navWorkbench', icon: 'story', href: '/workbench' },
|
||||
{ id: 'image', labelKey: 'shell.navImage', icon: 'image', href: '/image' },
|
||||
{ id: 'video', labelKey: 'shell.navVideo', icon: 'film', href: '/video' },
|
||||
{ id: 'tasks', labelKey: 'shell.navTasks', icon: 'queue', href: '/tasks' },
|
||||
{ id: 'agents', labelKey: 'shell.navAgents', icon: 'sparkle', href: '/agents' },
|
||||
];
|
||||
|
||||
export const NAV_FOOT: NavItem[] = [
|
||||
{ id: 'connections', labelKey: 'shell.navConnections', icon: 'link', href: '/connections' },
|
||||
{ id: 'models', labelKey: 'shell.navModels', icon: 'assets', href: '/models' },
|
||||
{ id: 'settings', labelKey: 'shell.navSettings', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
export const NAV: NavItem[] = [...NAV_MAIN, ...NAV_FOOT];
|
||||
|
||||
export interface TabItem {
|
||||
id: 'projects' | 'workbench' | 'generate';
|
||||
labelKey: string;
|
||||
icon: IconName;
|
||||
href: string;
|
||||
/** Pages that light this tab up. */
|
||||
covers: NavId[];
|
||||
}
|
||||
|
||||
/** ≤760px bottom tab bar (§二): 项目 / 创作 / 生成 / 更多. */
|
||||
export const TABS: TabItem[] = [
|
||||
{ id: 'projects', labelKey: 'shell.tabProjects', icon: 'home', href: '/', covers: ['projects'] },
|
||||
{ id: 'workbench', labelKey: 'shell.tabCreate', icon: 'story', href: '/workbench', covers: ['workbench'] },
|
||||
{
|
||||
id: 'generate',
|
||||
labelKey: 'shell.tabGenerate',
|
||||
icon: 'image',
|
||||
href: '/image',
|
||||
covers: ['image', 'video'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Every page a tab does not link to directly lives in the 更多 sheet. 生视频
|
||||
* is still its own route, so it is listed there too until the two
|
||||
* generation pages merge.
|
||||
*/
|
||||
export const MORE_ITEMS: NavItem[] = NAV.filter(
|
||||
(item) => !TABS.some((tab) => tab.href === item.href),
|
||||
);
|
||||
@@ -1,37 +1,33 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { IMAGE_SLOT_ACCEPT, formatBytes, uploadBaseName } from '$lib/comfy/imageFile';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* A-style media upload slot (spec-v2 §5 上传槽, ref-upload-spec §2):
|
||||
* empty = 1.5px dashed border + "click or drop" + format line, drag-hover
|
||||
* = amber dashed, filled = solid border + thumb + name + size + 替换 + ✕,
|
||||
* error = red border + red reason.
|
||||
* Single media input slot as a composer tile (spec-v2 §3.3 输入槽):
|
||||
* empty = dashed + "+", drag-hover = gold, uploading = ring, filled = thumb
|
||||
* (click to replace) with a ✕ on hover/focus. `need` = required but empty
|
||||
* after a blocked 生成 (amber); `error` = rejected file / invalid (red).
|
||||
*/
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Hint on the right of the label, e.g. 必填 · 1 张 / 可从生图结果送入. */
|
||||
/** Hint after the label, e.g. 必填 · 1 张. */
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
/** Current value: a server-side path ('' = empty). */
|
||||
value: string;
|
||||
kind: 'image' | 'video' | 'audio';
|
||||
/** Thumb aspect: wide = 16:9 (first/last frame slots). */
|
||||
aspect?: 'square' | 'wide';
|
||||
/** Empty-state main line (defaults to 点击或拖入). */
|
||||
emptyText?: string;
|
||||
/** Empty-state mono line, e.g. accepted formats and size limit. */
|
||||
emptySub?: string;
|
||||
/** Byte size of the current file when known. */
|
||||
size?: number;
|
||||
uploading?: boolean;
|
||||
uploadName?: string;
|
||||
need?: boolean;
|
||||
error?: string;
|
||||
/** Show the "pick from image results" link (video-page image slots). */
|
||||
/** Show the 从结果选 action (image slots fed from finished takes). */
|
||||
allowFromResults?: boolean;
|
||||
/** Anchor id so 「还缺:…」 can jump focus here. */
|
||||
anchorId?: string;
|
||||
onFile: (file: File) => void;
|
||||
onFromResults?: () => void;
|
||||
onClear: () => void;
|
||||
@@ -43,14 +39,13 @@
|
||||
required = false,
|
||||
value,
|
||||
kind,
|
||||
aspect = 'square',
|
||||
emptyText,
|
||||
emptySub,
|
||||
size,
|
||||
uploading = false,
|
||||
uploadName,
|
||||
need = false,
|
||||
error,
|
||||
allowFromResults = false,
|
||||
anchorId,
|
||||
onFile,
|
||||
onFromResults,
|
||||
onClear,
|
||||
@@ -66,7 +61,10 @@
|
||||
dims = '';
|
||||
});
|
||||
|
||||
const meta = $derived([dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '));
|
||||
const filled = $derived(!!value && !broken);
|
||||
const meta = $derived(
|
||||
[uploadBaseName(value), dims, size != null ? formatBytes(size) : ''].filter(Boolean).join(' · '),
|
||||
);
|
||||
|
||||
function accept(): string {
|
||||
if (kind === 'video') return 'video/*,.mp4,.webm,.mov,.mkv';
|
||||
@@ -87,17 +85,34 @@
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragDepth = 0;
|
||||
if (uploading) return;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) onFile(file);
|
||||
}
|
||||
|
||||
const dragHandlers = {
|
||||
ondragenter: (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
},
|
||||
ondragleave: () => (dragDepth = Math.max(0, dragDepth - 1)),
|
||||
ondragover: (e: DragEvent) => e.preventDefault(),
|
||||
ondrop: onDrop,
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="media-slot" class:wide={aspect === 'wide'}>
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}{#if required}<em>*</em>{/if}</span>
|
||||
{#if hint}<span class="slot-hint">{hint}</span>{/if}
|
||||
<div class="a-slot">
|
||||
<div class="a-slot-label">
|
||||
<span>{label}</span>
|
||||
{#if required}<span class="req" aria-hidden="true">*</span>{/if}
|
||||
{#if hint}<span>{hint}</span>{/if}
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -113,110 +128,61 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if value && !broken}
|
||||
<div class="slot-filled" class:invalid={!!error}>
|
||||
<div class="slot-thumb">
|
||||
{#if uploading}
|
||||
<Spinner size="sm" />
|
||||
{:else if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt={label} onload={noteDims} onerror={() => (broken = true)} />
|
||||
{:else if kind === 'video'}
|
||||
<video src={assetUrl(value)} preload="metadata" muted></video>
|
||||
<span class="kind-badge" aria-hidden="true"><Icon name="film" size={12} /></span>
|
||||
{:else}
|
||||
<span class="kind-badge" aria-hidden="true"><Icon name="music" size={12} /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="slot-meta">
|
||||
<span class="name" title={uploading ? uploadName : uploadBaseName(value)}>
|
||||
{uploading ? uploadName : uploadBaseName(value)}
|
||||
</span>
|
||||
{#if meta && !uploading}<span class="mono dims">{meta}</span>{/if}
|
||||
</div>
|
||||
<div class="slot-actions">
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="mini-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="mini-link" disabled={uploading} onclick={pick}>
|
||||
{t('studio.refReplace')}
|
||||
</button>
|
||||
<div class="a-slot-row">
|
||||
{#if filled}
|
||||
<div class="a-tile" class:drag={dragDepth > 0} class:bad={!!error} {...dragHandlers}>
|
||||
<button
|
||||
type="button"
|
||||
class="slot-clear"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
id={anchorId}
|
||||
class="a-tile-hit"
|
||||
title={`${t('studio.refReplace')} · ${meta}`}
|
||||
aria-label={`${t('studio.refReplace')} ${label}`}
|
||||
disabled={uploading}
|
||||
onclick={onClear}
|
||||
onclick={pick}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
{#if kind === 'image'}
|
||||
<img src={assetUrl(value)} alt="" onload={noteDims} onerror={() => (broken = true)} />
|
||||
{:else if kind === 'video'}
|
||||
<video src={assetUrl(value)} preload="metadata" muted playsinline></video>
|
||||
{:else}
|
||||
<Icon name="music" size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
{#if uploading}
|
||||
<span class="a-tile-ring" title={uploadName}><i></i></span>
|
||||
{:else}
|
||||
<button type="button" class="a-tile-x" aria-label={`${t('studio.deleteBtn')} ${label}`} onclick={onClear}>
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="slot-empty"
|
||||
class:drag={dragDepth > 0}
|
||||
class:invalid={!!error}
|
||||
class:wide={aspect === 'wide'}
|
||||
disabled={uploading}
|
||||
aria-busy={uploading || undefined}
|
||||
onclick={pick}
|
||||
ondragenter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth += 1;
|
||||
}}
|
||||
ondragleave={() => (dragDepth = Math.max(0, dragDepth - 1))}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if uploading}
|
||||
<Spinner size="sm" />
|
||||
<span class="pick-text">{uploadName ?? t('studio.slotPick')}</span>
|
||||
{:else}
|
||||
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'image'} size={16} />
|
||||
<span class="pick-text">{emptyText ?? t('studio.slotPick')}</span>
|
||||
{#if emptySub}<span class="pick-sub mono">{emptySub}</span>{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{#if allowFromResults && onFromResults}
|
||||
<button type="button" class="from-results-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
id={anchorId}
|
||||
class="a-tile add"
|
||||
class:drag={dragDepth > 0}
|
||||
class:need={need && !error}
|
||||
class:bad={!!error}
|
||||
title={t('studio.slotPick')}
|
||||
aria-label={`${label} · ${t('studio.slotPick')}`}
|
||||
aria-busy={uploading || undefined}
|
||||
disabled={uploading}
|
||||
onclick={pick}
|
||||
{...dragHandlers}
|
||||
>
|
||||
{#if uploading}
|
||||
<span class="a-tile-ring" title={uploadName}><i></i></span>
|
||||
{:else}
|
||||
<Icon name={kind === 'video' ? 'film' : kind === 'audio' ? 'music' : 'plus'} size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if error}<span class="slot-error" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.media-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -226,164 +192,4 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.slot-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 86px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
.media-slot.wide .slot-empty {
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.slot-empty:hover:not(:disabled) {
|
||||
color: var(--text-2);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.slot-empty.drag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.slot-empty.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
}
|
||||
.slot-empty:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pick-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.pick-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.from-results-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.slot-filled {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
padding: 8px;
|
||||
}
|
||||
.slot-filled.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.slot-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
.media-slot.wide .slot-thumb {
|
||||
width: 88px;
|
||||
height: 50px;
|
||||
}
|
||||
.slot-thumb img,
|
||||
.slot-thumb video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.slot-thumb .kind-badge {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dims {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.slot-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-link:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini-link:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.slot-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.slot-clear:hover {
|
||||
color: var(--error);
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.slot-error {
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import {
|
||||
IMAGE_SLOT_ACCEPT,
|
||||
@@ -12,11 +11,10 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Reference-media grid (ref-upload-spec §2 `.refgrid`, generalized to
|
||||
* image/video/audio in HEL-643): numbered cells 参考图/参考视频/参考音频
|
||||
* 1…N in the order they reach the workflow, each with replace and ✕; a
|
||||
* dashed add cell while below `max`; rejected files show as error cells
|
||||
* that take no place in the list.
|
||||
* Reference-media list as numbered composer tiles (spec-v2 §3.3 参考图):
|
||||
* 1…N in the order they reach the workflow — click a tile to replace it,
|
||||
* ✕ to remove; a dashed add tile while below `max`; rejected files show
|
||||
* as red tiles that take no place in the list.
|
||||
*/
|
||||
interface Reject {
|
||||
name: string;
|
||||
@@ -35,8 +33,12 @@
|
||||
/** Index being replaced while uploading (-1 = adding). */
|
||||
uploadingIndex?: number;
|
||||
rejects?: Reject[];
|
||||
/** Below the minimum after a blocked 生成 (amber add tile). */
|
||||
need?: boolean;
|
||||
error?: string;
|
||||
allowFromResults?: boolean;
|
||||
/** Anchor id on the add tile so 「还缺:…」 can jump focus here. */
|
||||
anchorId?: string;
|
||||
onAdd: (files: File[]) => void;
|
||||
onReplace: (index: number, file: File) => void;
|
||||
onRemove: (index: number) => void;
|
||||
@@ -53,8 +55,10 @@
|
||||
uploadingName,
|
||||
uploadingIndex = -1,
|
||||
rejects = [],
|
||||
need = false,
|
||||
error,
|
||||
allowFromResults = false,
|
||||
anchorId,
|
||||
onAdd,
|
||||
onReplace,
|
||||
onRemove,
|
||||
@@ -78,16 +82,27 @@
|
||||
? t('studio.refHintMedia', { n: paths.length, max })
|
||||
: t('studio.refHint', { n: paths.length, max }),
|
||||
);
|
||||
function durationOf(path: string): string {
|
||||
const d = durations[path];
|
||||
if (d == null || !Number.isFinite(d)) return '';
|
||||
return `${d.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function noteDuration(path: string, e: Event): void {
|
||||
const el = e.currentTarget as HTMLVideoElement | HTMLAudioElement | null;
|
||||
if (el && Number.isFinite(el.duration)) {
|
||||
durations = { ...durations, [path]: el.duration };
|
||||
}
|
||||
if (el && Number.isFinite(el.duration)) durations = { ...durations, [path]: el.duration };
|
||||
}
|
||||
|
||||
function noteDims(path: string, e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
|
||||
}
|
||||
|
||||
function metaOf(path: string): string {
|
||||
const d = durations[path];
|
||||
return [
|
||||
uploadBaseName(path),
|
||||
dims[path],
|
||||
d != null && Number.isFinite(d) ? `${d.toFixed(1)}s` : null,
|
||||
sizes[path] != null ? formatBytes(sizes[path]) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function pickAdd() {
|
||||
@@ -105,28 +120,24 @@
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragDepth = 0;
|
||||
if (uploading) return;
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
if (files.length) onAdd(files);
|
||||
}
|
||||
|
||||
function noteDims(path: string, e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.naturalWidth) dims = { ...dims, [path]: `${img.naturalWidth}×${img.naturalHeight}` };
|
||||
}
|
||||
|
||||
function metaOf(path: string): string {
|
||||
return [dims[path], durationOf(path), sizes[path] != null ? formatBytes(sizes[path]) : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="refgrid-field">
|
||||
<div class="slot-label">
|
||||
<span class="lbl">{label}<em>*</em></span>
|
||||
<span class="slot-hint">{hintLabel}</span>
|
||||
<div class="a-slot">
|
||||
<div class="a-slot-label">
|
||||
<span>{label}</span>
|
||||
<span class="req" aria-hidden="true">*</span>
|
||||
<span class="mono">{hintLabel}</span>
|
||||
{#if allowFromResults && onFromResults && remaining > 0}
|
||||
<button type="button" class="a-btn ghost xs" onclick={onFromResults}>
|
||||
{t('studio.slotFromResultsShort')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -135,7 +146,7 @@
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={accept}
|
||||
{accept}
|
||||
multiple
|
||||
onchange={(e) => {
|
||||
const files = Array.from(e.currentTarget.files ?? []);
|
||||
@@ -148,28 +159,26 @@
|
||||
class="sr-file"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
accept={accept}
|
||||
{accept}
|
||||
onchange={(e) => {
|
||||
const f = e.currentTarget.files?.[0];
|
||||
if (f && replaceIndex >= 0) onReplace(replaceIndex, f);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="refgrid" class:audio={mediaKind === 'audio'}>
|
||||
<div class="a-slot-row">
|
||||
{#each paths as path, i (path + i)}
|
||||
<div class="ref-cell" class:busy={uploadingIndex === i}>
|
||||
<div class="a-tile">
|
||||
<button
|
||||
type="button"
|
||||
class="ref-thumb"
|
||||
title={t('studio.refReplace')}
|
||||
class="a-tile-hit"
|
||||
title={`${t('studio.refReplace')} · ${metaOf(path)}`}
|
||||
aria-label={`${t('studio.refReplace')} ${label} ${i + 1}`}
|
||||
disabled={uploading}
|
||||
onclick={() => pickReplace(i)}
|
||||
>
|
||||
{#if mediaKind === 'image'}
|
||||
<img
|
||||
src={assetUrl(path)}
|
||||
alt={t('studio.slotRefN', { n: i + 1 })}
|
||||
onload={(e) => noteDims(path, e)}
|
||||
/>
|
||||
<img src={assetUrl(path)} alt="" onload={(e) => noteDims(path, e)} />
|
||||
{:else if mediaKind === 'video'}
|
||||
<video
|
||||
src={assetUrl(path)}
|
||||
@@ -177,64 +186,59 @@
|
||||
muted
|
||||
playsinline
|
||||
onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
title={uploadBaseName(path)}
|
||||
></video>
|
||||
{:else}
|
||||
<span class="audio-glyph"><Icon name="music" size={18} /></span>
|
||||
<audio
|
||||
src={assetUrl(path)}
|
||||
preload="metadata"
|
||||
onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
title={uploadBaseName(path)}
|
||||
<Icon name="music" size={18} />
|
||||
<audio src={assetUrl(path)} preload="metadata" onloadedmetadata={(e) => noteDuration(path, e)}
|
||||
></audio>
|
||||
{/if}
|
||||
<span class="ref-no mono">{i + 1}</span>
|
||||
{#if uploadingIndex === i}
|
||||
<span class="ref-busy"><Spinner size="sm" /></span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={isMedia ? t('studio.refRemoveMedia', { kind: label, n: i + 1 }) : t('studio.refRemove', { n: i + 1 })}
|
||||
onclick={() => onRemove(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
</button>
|
||||
<span class="ref-name" title={uploadBaseName(path)}>{uploadBaseName(path)}</span>
|
||||
<span class="ref-meta mono">
|
||||
{metaOf(path)}
|
||||
<button type="button" class="mini-link" onclick={() => pickReplace(i)}>{t('studio.refReplace')}</button>
|
||||
</span>
|
||||
<span class="a-tile-idx">{i + 1}</span>
|
||||
{#if uploadingIndex === i}
|
||||
<span class="a-tile-ring" title={uploadingName}><i></i></span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="a-tile-x"
|
||||
aria-label={isMedia
|
||||
? t('studio.refRemoveMedia', { kind: label, n: i + 1 })
|
||||
: t('studio.refRemove', { n: i + 1 })}
|
||||
onclick={() => onRemove(i)}
|
||||
>
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<div class="ref-cell rejected" role="alert">
|
||||
<div class="ref-thumb error">
|
||||
<Icon name="close" size={16} />
|
||||
<span class="mono">{t('studio.refRejectedShort')}</span>
|
||||
</div>
|
||||
<div class="a-tile bad" title={`${rej.name} · ${rej.message}`} role="img" aria-label={rej.name}>
|
||||
<Icon name="alert" size={16} />
|
||||
<button
|
||||
type="button"
|
||||
class="ref-x"
|
||||
aria-label={t('studio.deleteBtn')}
|
||||
class="a-tile-x"
|
||||
aria-label={`${t('studio.deleteBtn')} ${rej.name}`}
|
||||
onclick={() => onDismissReject(i)}
|
||||
>
|
||||
<Icon name="close" size={11} />
|
||||
<Icon name="close" size={10} />
|
||||
</button>
|
||||
<span class="ref-name err" title={rej.name}>{rej.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if remaining > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="ref-add"
|
||||
id={anchorId}
|
||||
class="a-tile add"
|
||||
class:drag={dragDepth > 0}
|
||||
class:invalid={!!error}
|
||||
disabled={uploading}
|
||||
class:need={need && !error?.trim()}
|
||||
class:bad={!!error?.trim()}
|
||||
title={isMedia
|
||||
? t('studio.refAddSubMedia', { n: remaining, max })
|
||||
: t('studio.refAddSub', { n: remaining, max })}
|
||||
aria-label={isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}
|
||||
aria-busy={uploading || undefined}
|
||||
disabled={uploading}
|
||||
onclick={pickAdd}
|
||||
ondragenter={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -245,83 +249,24 @@
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#if uploading && uploadingIndex < 0}
|
||||
<Spinner size="sm" />
|
||||
<span class="add-main">{uploadingName}</span>
|
||||
<span class="a-tile-ring" title={uploadingName}><i></i></span>
|
||||
{:else}
|
||||
<Icon name="plus" size={16} />
|
||||
<span class="add-main">{isMedia ? t('studio.refAddMedia', { kind: label }) : t('studio.refAdd')}</span>
|
||||
<span class="add-sub mono">{isMedia ? t('studio.refAddSubMedia', { n: remaining, max }) : t('studio.refAddSub', { n: remaining, max })}</span>
|
||||
<Icon name="plus" size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if allowFromResults && onFromResults && remaining > 0}
|
||||
<button type="button" class="from-results-link" onclick={onFromResults}>
|
||||
{t('studio.slotFromResults')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if remaining === 0}
|
||||
<span class="full-note">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
|
||||
<span class="a-slot-hint">{isMedia ? t('studio.refFullMedia', { max }) : t('studio.refFull', { max })}</span>
|
||||
{/if}
|
||||
{#each rejects as rej, i (rej.name + i)}
|
||||
<span class="slot-error">{rej.message}</span>
|
||||
<span class="a-slot-hint err" role="alert">{rej.message}</span>
|
||||
{/each}
|
||||
{#if error?.trim()}<span class="slot-error" role="alert">{error}</span>{/if}
|
||||
{#if error?.trim()}<span class="a-slot-hint err" role="alert">{error}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.refgrid-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.refgrid.audio {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.refgrid.audio .ref-thumb {
|
||||
aspect-ratio: auto;
|
||||
min-height: 64px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
.refgrid.audio audio {
|
||||
width: 90%;
|
||||
height: 28px;
|
||||
}
|
||||
.refgrid video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.audio-glyph {
|
||||
color: var(--text-3);
|
||||
display: inline-flex;
|
||||
}
|
||||
.slot-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.lbl em {
|
||||
color: var(--error);
|
||||
font-style: normal;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.slot-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -331,194 +276,7 @@
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.refgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.ref-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.ref-cell.rejected {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.ref-thumb.error {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: default;
|
||||
color: var(--error);
|
||||
font-size: 10px;
|
||||
}
|
||||
.ref-no {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
min-width: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.ref-busy {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.ref-x {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ref-x:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-name {
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ref-name.err {
|
||||
color: var(--error);
|
||||
}
|
||||
.ref-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
font-size: 9.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-2);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mini-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 110px;
|
||||
padding: 8px 4px;
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-3);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms;
|
||||
}
|
||||
.ref-add:hover:not(:disabled) {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.ref-add.drag {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.ref-add.invalid {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.ref-add:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.add-main {
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.add-sub {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.from-results-link {
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.from-results-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.full-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.slot-error {
|
||||
font-size: 11.5px;
|
||||
color: var(--error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.refgrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
}
|
||||
audio {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,9 @@
|
||||
import Spinner from './Spinner.svelte';
|
||||
|
||||
interface Props {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md';
|
||||
/** `danger` = outlined (list rows); `danger-solid` = confirm dialogs only. */
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'danger-solid';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Stretch to the container width (e.g. a panel's main action). */
|
||||
block?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -32,6 +33,7 @@
|
||||
{type}
|
||||
class="a-btn btn {variant}"
|
||||
class:sm={size === 'sm'}
|
||||
class:lg={size === 'lg'}
|
||||
class:block
|
||||
class:loading
|
||||
disabled={disabled || loading}
|
||||
@@ -49,7 +51,7 @@
|
||||
</button>
|
||||
|
||||
<style>
|
||||
/* Look comes from the shared `.a-btn` rules in app.css (§5). */
|
||||
/* Look comes from the shared `.a-btn` rules in app.css (§四.1). */
|
||||
.btn {
|
||||
position: relative;
|
||||
}
|
||||
@@ -72,10 +74,14 @@
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.primary .btn-spinner {
|
||||
--spinner-track: rgba(22, 19, 10, 0.3);
|
||||
--spinner-arc: var(--accent-contrast);
|
||||
--spinner-track: color-mix(in srgb, var(--accent-ink) 30%, transparent);
|
||||
--spinner-arc: var(--accent-ink);
|
||||
}
|
||||
.btn.danger .btn-spinner {
|
||||
--spinner-arc: var(--error);
|
||||
--spinner-arc: var(--err);
|
||||
}
|
||||
.btn.danger-solid .btn-spinner {
|
||||
--spinner-track: color-mix(in srgb, var(--on-media) 30%, transparent);
|
||||
--spinner-arc: var(--on-media);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,16 +46,18 @@
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
.card.hoverable {
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
transform 150ms ease;
|
||||
border-color var(--d-state) ease,
|
||||
transform var(--d-state) var(--ease-out);
|
||||
}
|
||||
.card.hoverable:hover {
|
||||
border-color: #3f3f46;
|
||||
transform: translateY(-1px);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.card.hoverable:hover {
|
||||
border-color: var(--line-2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
{#snippet footer()}
|
||||
<Button variant="ghost" onclick={() => (open = false)}>{cancelLabel}</Button
|
||||
>
|
||||
<Button variant={danger ? 'danger' : 'primary'} onclick={handleConfirm}
|
||||
<Button variant={danger ? 'danger-solid' : 'primary'} onclick={handleConfirm}
|
||||
>{confirmLabel}</Button
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -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>
|
||||
@@ -144,22 +144,19 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(5, 5, 8, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: modal-fade 150ms ease-out;
|
||||
background: var(--overlay);
|
||||
animation: modal-fade var(--d-state) ease-out;
|
||||
}
|
||||
.modal-panel {
|
||||
width: min(520px, 100%);
|
||||
max-height: calc(100vh - 96px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||
background: var(--float-bg);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
outline: none;
|
||||
animation: modal-rise 180ms ease-out;
|
||||
animation: modal-rise var(--d-pop) var(--ease-out);
|
||||
}
|
||||
.modal-panel.size-lg {
|
||||
width: min(920px, 100%);
|
||||
@@ -177,7 +174,7 @@
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -194,12 +191,14 @@
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
background-color var(--d-state) ease,
|
||||
color var(--d-state) ease;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.modal-close:hover {
|
||||
background: var(--bg-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
.modal-close:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
indeterminate?: boolean;
|
||||
label?: string;
|
||||
size?: 'sm' | 'md';
|
||||
/** §5 进度条: running = amber, done = green, failed = red (static). */
|
||||
tone?: 'accent' | 'success' | 'error';
|
||||
/** 进度条: running = 信号青, done = green, failed = red (static). */
|
||||
tone?: 'run' | 'success' | 'error';
|
||||
/** Track only — no label / percentage row. */
|
||||
bare?: boolean;
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
indeterminate = false,
|
||||
label,
|
||||
size = 'md',
|
||||
tone = 'accent',
|
||||
tone = 'run',
|
||||
bare = false,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{#if indeterminate}
|
||||
<div class="fill indeterminate"></div>
|
||||
{:else}
|
||||
<div class="fill" style:width={pct + '%'}></div>
|
||||
<div class="fill" style:transform="scaleX({pct / 100})"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,8 +75,8 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--surface-3);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-4);
|
||||
border-radius: var(--r-pill);
|
||||
}
|
||||
.progress-sm .track {
|
||||
height: 4px;
|
||||
@@ -86,15 +86,18 @@
|
||||
}
|
||||
.fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
background: var(--run-grad);
|
||||
box-shadow: 0 0 8px var(--run-soft);
|
||||
transform-origin: left;
|
||||
transition: transform var(--d-progress) linear;
|
||||
}
|
||||
.tone-success .fill {
|
||||
background: var(--success);
|
||||
background: var(--ok);
|
||||
box-shadow: none;
|
||||
}
|
||||
.tone-error .fill {
|
||||
background: var(--error);
|
||||
background: var(--err);
|
||||
box-shadow: none;
|
||||
}
|
||||
.fill.indeterminate {
|
||||
position: absolute;
|
||||
@@ -104,10 +107,11 @@
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--accent) 40%,
|
||||
var(--accent) 60%,
|
||||
var(--run) 40%,
|
||||
var(--run) 60%,
|
||||
transparent
|
||||
);
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
animation: progress-slide 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -19,36 +19,26 @@
|
||||
></div>
|
||||
|
||||
<style>
|
||||
/* §四.9 骨架屏:--bg-3 圆角块,1.4s 呼吸透明度;减少动态时静态。 */
|
||||
.skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-3);
|
||||
border-radius: var(--r-sm);
|
||||
animation: skeleton-breath 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skeleton.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-100%);
|
||||
animation: skeleton-shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
@keyframes skeleton-breath {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton::after {
|
||||
display: none;
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .skeleton {
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,27 +6,27 @@
|
||||
|
||||
let { status, label }: Props = $props();
|
||||
|
||||
type Tone = 'success' | 'info' | 'muted' | 'error' | 'warning' | 'accent';
|
||||
type Tone = 'ok' | 'run' | 'muted' | 'err' | 'warn';
|
||||
|
||||
const toneByStatus: Record<string, Tone> = {
|
||||
ready: 'success',
|
||||
done: 'success',
|
||||
completed: 'success',
|
||||
running: 'info',
|
||||
generating: 'info',
|
||||
queued: 'info',
|
||||
ready: 'ok',
|
||||
done: 'ok',
|
||||
completed: 'ok',
|
||||
running: 'run',
|
||||
generating: 'run',
|
||||
in_progress: 'run',
|
||||
queued: 'muted',
|
||||
pending: 'muted',
|
||||
idle: 'muted',
|
||||
draft: 'muted',
|
||||
failed: 'error',
|
||||
error: 'error',
|
||||
paused: 'warning',
|
||||
in_progress: 'accent',
|
||||
failed: 'err',
|
||||
error: 'err',
|
||||
paused: 'warn',
|
||||
};
|
||||
|
||||
const key = $derived(status.trim().toLowerCase());
|
||||
const tone: Tone = $derived(toneByStatus[key] ?? 'muted');
|
||||
const pulsing = $derived(key === 'running');
|
||||
const pulsing = $derived(tone === 'run');
|
||||
</script>
|
||||
|
||||
<span class="chip chip-{tone}">
|
||||
@@ -35,59 +35,48 @@
|
||||
</span>
|
||||
|
||||
<style>
|
||||
/* §四.2 状态标签:20px 高、--r-sm、12px;语义色文字 + 13% 软底。 */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
gap: var(--sp-2);
|
||||
height: 20px;
|
||||
padding: 0 var(--sp-2);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: var(--fs-aux);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-success {
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--success) 35%, transparent);
|
||||
.chip-ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-soft);
|
||||
}
|
||||
.chip-info {
|
||||
color: var(--info);
|
||||
background: color-mix(in srgb, var(--info) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--info) 35%, transparent);
|
||||
.chip-run {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.chip-error {
|
||||
color: var(--error);
|
||||
background: color-mix(in srgb, var(--error) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--error) 35%, transparent);
|
||||
.chip-err {
|
||||
color: var(--err);
|
||||
background: var(--err-soft);
|
||||
}
|
||||
.chip-warning {
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--warning) 35%, transparent);
|
||||
}
|
||||
.chip-accent {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
.chip-warn {
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
.chip-muted {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--border);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.dot.pulsing {
|
||||
animation: chip-pulse 1.2s ease-in-out infinite;
|
||||
animation: chip-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes chip-pulse {
|
||||
0%,
|
||||
@@ -103,4 +92,7 @@
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
:global(:root[data-reduce='1']) .dot.pulsing {
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
let { checked, label, disabled = false, onchange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- §5 开关: 34×19 capsule; on = amber track + dark knob, off = --surface-3. -->
|
||||
<!-- §3.10 开关: 34×19 capsule; on = --accent-lo track + ink knob, off = --bg-4. -->
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
@@ -28,38 +28,38 @@
|
||||
flex-shrink: 0;
|
||||
width: 34px;
|
||||
height: 19px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-4);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition:
|
||||
background-color 150ms,
|
||||
border-color 150ms;
|
||||
background-color var(--d-state) ease,
|
||||
border-color var(--d-state) ease;
|
||||
}
|
||||
.switch:disabled {
|
||||
opacity: 0.4;
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--text-3);
|
||||
transition:
|
||||
transform 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 150ms;
|
||||
transform var(--d-state) var(--ease-out),
|
||||
background-color var(--d-state) ease;
|
||||
}
|
||||
.switch.on {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-lo);
|
||||
border-color: var(--accent-lo);
|
||||
}
|
||||
.switch.on .knob {
|
||||
transform: translateX(15px);
|
||||
background: var(--accent-contrast);
|
||||
background: var(--accent-ink);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import JobChip from './JobChip.svelte';
|
||||
import PromptBlock from './PromptBlock.svelte';
|
||||
import { deskHref } from '$lib/components/generate/context';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
stage: WorkbenchStage;
|
||||
/** Enables the 在生成台调参 link (desk opens with this asset's context). */
|
||||
projectId?: number;
|
||||
onSave?: (content: Record<string, unknown>) => void;
|
||||
onRun?: (instruction?: string) => void;
|
||||
onGenerate?: (payload: {
|
||||
@@ -20,7 +23,7 @@
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
let { stage, projectId, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draft = $state<WorkbenchAsset[]>([]);
|
||||
@@ -168,6 +171,25 @@
|
||||
<PromptBlock label={t('workbench.style.negativeLabel')} text={asset.negative_prompt} />
|
||||
{/if}
|
||||
<div class="asset-foot">
|
||||
{#if projectId}
|
||||
<a
|
||||
class="a-btn ghost sm"
|
||||
href={deskHref({
|
||||
kind: 'image',
|
||||
projectId,
|
||||
stage: 'assets',
|
||||
label: t('desk.ctxAsset', {
|
||||
kind: t(KIND_LABEL[asset.kind] ?? 'workbench.asset.kindProp'),
|
||||
name: asset.name,
|
||||
}),
|
||||
prompt: asset.prompt,
|
||||
negative: asset.negative_prompt || undefined,
|
||||
})}
|
||||
title={t('desk.openInDeskHint')}
|
||||
>
|
||||
{t('desk.openInDesk')}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost gen"
|
||||
@@ -294,7 +316,9 @@
|
||||
}
|
||||
.asset-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.retry-link {
|
||||
@@ -369,7 +393,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -438,7 +462,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.st-running {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.st-done {
|
||||
color: var(--success);
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -294,7 +294,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.sm {
|
||||
padding: 6px 12px;
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import JobChip from './JobChip.svelte';
|
||||
import PromptBlock from './PromptBlock.svelte';
|
||||
import { deskHref } from '$lib/components/generate/context';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
interface Props {
|
||||
stage: WorkbenchStage;
|
||||
/** Enables the 在生成台调参 link (desk opens with this shot's context). */
|
||||
projectId?: number;
|
||||
onSave?: (content: Record<string, unknown>) => void;
|
||||
onRun?: (instruction?: string) => void;
|
||||
onGenerate?: (payload: {
|
||||
@@ -20,7 +23,7 @@
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
let { stage, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
let { stage, projectId, onSave, onRun, onGenerate, onRetryJob, busy = false }: Props = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let draft = $state<VideoShot[]>([]);
|
||||
@@ -159,6 +162,22 @@
|
||||
{/if}
|
||||
<PromptBlock label={t('workbench.vshot.promptLabel')} text={shot.prompt} />
|
||||
<div class="vshot-foot">
|
||||
{#if projectId}
|
||||
<a
|
||||
class="a-btn ghost sm"
|
||||
href={deskHref({
|
||||
kind: 'video',
|
||||
projectId,
|
||||
stage: 'shot',
|
||||
label: t('desk.ctxShot', { no: shot.shot_no }),
|
||||
prompt: shot.prompt,
|
||||
durationSec: shot.duration_sec,
|
||||
})}
|
||||
title={t('desk.openInDeskHint')}
|
||||
>
|
||||
{t('desk.openInDesk')}
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost gen"
|
||||
@@ -266,7 +285,9 @@
|
||||
}
|
||||
.vshot-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.retry-link {
|
||||
@@ -341,7 +362,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -412,7 +433,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.gen {
|
||||
color: var(--accent);
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Five-stage stepper (a2): one step per agent with the spec §2.1 semantic
|
||||
* colors — gray=待开始, amber=进行中/待确认 (+breath while running),
|
||||
* green=已确认, blue=用户改过草稿. A step unlocks when itself or any
|
||||
* Five-stage stepper (a2): one step per agent with the fusion status
|
||||
* colors — gray=待开始, cyan=进行中 (+breath), amber=待确认,
|
||||
* green=已确认, --info=用户改过草稿. A step unlocks when itself or any
|
||||
* earlier stage has content; later steps stay clickable only after the
|
||||
* previous one is confirmed.
|
||||
*/
|
||||
@@ -159,7 +159,7 @@
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #edeef0;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -188,12 +188,14 @@
|
||||
.st-todo {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.st-running,
|
||||
.st-running {
|
||||
color: var(--run);
|
||||
}
|
||||
.st-draft {
|
||||
color: var(--accent);
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-edited {
|
||||
color: var(--info);
|
||||
color: var(--warn);
|
||||
}
|
||||
.st-confirmed {
|
||||
color: var(--success);
|
||||
@@ -201,14 +203,14 @@
|
||||
|
||||
.step-btn.active[data-state='running'] .avatar,
|
||||
.step-btn[data-state='running'] .avatar {
|
||||
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
|
||||
box-shadow: 0 0 10px var(--run-soft);
|
||||
}
|
||||
|
||||
.breath {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: wb-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -386,7 +386,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
}
|
||||
.dir-card.chosen {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(232, 163, 61, 0.35);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
.dir-card input[type='radio'] {
|
||||
position: absolute;
|
||||
@@ -310,7 +310,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -375,7 +375,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
|
||||
@@ -64,6 +64,7 @@ export const de: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': 'Ausblenden',
|
||||
'toast.undo': 'Rückgängig',
|
||||
'toast.uploadFailed': 'Upload fehlgeschlagen',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -1260,14 +1261,44 @@ export const de: Dict = {
|
||||
'shell.navConnections': 'ComfyUI-Verbindungen',
|
||||
'shell.navModels': 'Modelle',
|
||||
'shell.navSettings': 'Einstellungen',
|
||||
'shell.wip': 'In Entwicklung',
|
||||
'shell.wipHint': 'Diese Seite ist noch im Bau und folgt später',
|
||||
'shell.comfy_ok': '{name} verbunden',
|
||||
'shell.comfy_fail': '{name} nicht erreichbar',
|
||||
'shell.comfy_testing': '{name} wird getestet…',
|
||||
'shell.comfyUnset': 'ComfyUI · nicht konfiguriert',
|
||||
'shell.comfyManage': 'ComfyUI-Adresse in den Einstellungen pflegen',
|
||||
'shell.mobileNotice': 'Bitte öffne diese Werkbank in einem Desktop-Browser (mindestens 768px breit); Handys werden noch nicht unterstützt.',
|
||||
'shell.scopeHint': 'Projekt wechseln',
|
||||
'shell.scopeAll': 'Alle Projekte',
|
||||
'shell.scopeAllList': 'Liste aller Projekte',
|
||||
'shell.scopeRecent': 'Letzte Projekte',
|
||||
'shell.scopeLoadFailed': 'Projekte konnten nicht geladen werden',
|
||||
'shell.scopeEmpty': 'Noch keine Projekte – jetzt anlegen',
|
||||
'shell.scopeStage': '{n}/5 bestätigt',
|
||||
'shell.queueRunning': '{n} in Arbeit',
|
||||
'shell.queueQueued': '{n} in der Warteschlange',
|
||||
'shell.queueIdle': 'Warteschlange leer',
|
||||
'shell.queueActive': '{n} aktiv',
|
||||
'shell.trayTitle': 'Auftragsablage',
|
||||
'shell.trayRecent': 'Letzte Aufträge · klicken für alle Aufträge',
|
||||
'shell.trayEmpty': 'Noch keine Generierungsaufträge',
|
||||
'shell.trayAll': 'Alle Aufträge anzeigen',
|
||||
'shell.searchTitle': 'Seiten und Projekte suchen',
|
||||
'shell.searchPlaceholder': 'Seiten, Projekte suchen',
|
||||
'shell.searchShortcut': 'Suchen (Strg/⌘ + K)',
|
||||
'shell.searchEmpty': 'Keine Seite und kein Projekt passt zu „{q}“',
|
||||
'shell.searchGroupPages': 'Seiten',
|
||||
'shell.searchGroupProjects': 'Projekte · Werkbank öffnen',
|
||||
'shell.searchHintMove': 'auswählen',
|
||||
'shell.searchHintOpen': 'öffnen',
|
||||
'shell.searchHintClose': 'schließen',
|
||||
'shell.reduceMotion': 'Weniger Bewegung',
|
||||
'shell.reduceHint': 'Bewegungs-, Zoom- und Glüheffekte aus; nur kurze Überblendungen',
|
||||
'shell.reduceSystem': 'Folgt der Systemeinstellung „Bewegung reduzieren“',
|
||||
'shell.tabAria': 'Hauptnavigation (schmal)',
|
||||
'shell.tabProjects': 'Projekte',
|
||||
'shell.tabCreate': 'Erstellen',
|
||||
'shell.tabGenerate': 'Generieren',
|
||||
'shell.tabMore': 'Mehr',
|
||||
'shell.morePages': 'Weitere Seiten',
|
||||
'shell.moreTools': 'Werkzeuge',
|
||||
'home.title': 'Projektübersicht',
|
||||
'home.subtitle': 'Verwalte deine Projekte — starte mit einer Idee oder einem fertigen Drehbuch.',
|
||||
'home.projectCount': '{n} Projekte · {m} in Arbeit',
|
||||
@@ -1456,8 +1487,6 @@ export const de: Dict = {
|
||||
'studio.genParams': 'Parameter',
|
||||
'studio.modeTextToImage': 'Text zu Bild',
|
||||
'studio.modeTextToVideo': 'Text zu Video',
|
||||
'studio.chipIdle': '{name} · Warteschlange frei',
|
||||
'studio.chipBusy': '{name} · {n} in Warteschlange',
|
||||
'studio.video.resultsTitle': 'Vorschau',
|
||||
'studio.image.historyTitle': 'Bildverlauf',
|
||||
'studio.video.historyTitle': 'Videoverlauf',
|
||||
@@ -1581,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',
|
||||
@@ -1600,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',
|
||||
@@ -1616,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',
|
||||
@@ -1847,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',
|
||||
@@ -1924,4 +1964,114 @@ export const de: Dict = {
|
||||
'wft.errListMin': '„{label}“ benötigt mindestens {n} {unit}',
|
||||
'tasks.testBadge': 'Test',
|
||||
'tasks.sourceWorkflowTest': 'Workflow-Test',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': 'Original {v}',
|
||||
'desk.title': 'Generieren',
|
||||
'desk.advanced': 'Erweitert · {n} · unverändert gelten die Workflow-Werte',
|
||||
'desk.chipResolution': 'Auflösung {v}',
|
||||
'desk.chipSeconds': '{v} s',
|
||||
'desk.chipSeed': 'Seed {v}',
|
||||
'desk.closeInspector': 'Inspektor schließen',
|
||||
'desk.countActive': '{n} laufen',
|
||||
'desk.countTakes': '{n} Einträge',
|
||||
'desk.ctxAsset': '{kind} „{name}“',
|
||||
'desk.ctxShot': 'Einstellung {no}',
|
||||
'desk.ctxBack': 'Zurück zu {stage}',
|
||||
'desk.ctxDismiss': 'Herkunftshinweis schließen',
|
||||
'desk.ctxFrom': 'Aus „{project}“',
|
||||
'desk.ctxNoBackfill': 'Ergebnisse bleiben im Verlauf und werden nicht in {stage} übernommen',
|
||||
'desk.dayDate': '{d}.{m}.',
|
||||
'desk.download': 'Herunterladen',
|
||||
'desk.dropNoSlot': '{n} Datei(en) ohne passenden Slot: dieser Workflow hat keinen solchen Eingang',
|
||||
'desk.dryRun': 'Probelauf',
|
||||
'desk.durMin': '{m} min {s} s',
|
||||
'desk.durSec': '{s} s',
|
||||
'desk.elapsed': 'Dauer',
|
||||
'desk.emptyFailed': 'Keine fehlgeschlagenen Einträge',
|
||||
'desk.emptyFeed': 'Noch keine Einträge. Prompt unten eingeben und „Generieren“ drücken.',
|
||||
'desk.filterAria': 'Einträge filtern',
|
||||
'desk.filterAll': 'Alle',
|
||||
'desk.filterImage': 'Bilder',
|
||||
'desk.filterVideo': 'Videos',
|
||||
'desk.filterFailed': 'Fehlgeschlagen',
|
||||
'desk.generate': 'Generieren',
|
||||
'desk.genHint': 'Generieren (Strg/⌘ + Enter)',
|
||||
'desk.inputs': 'Eingaben',
|
||||
'desk.inspector': 'Inspektor',
|
||||
'desk.keyClose': 'Schließen',
|
||||
'desk.keyInspector': 'Ein- / ausblenden',
|
||||
'desk.keySelect': 'Auswählen',
|
||||
'desk.keyUse': 'Verwenden',
|
||||
'desk.kvCreated': 'Eingereicht',
|
||||
'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.',
|
||||
'desk.notGenerated': 'Nicht erzeugt',
|
||||
'desk.noWorkflows': 'Noch kein Bild- oder Video-Workflow bereit',
|
||||
'desk.openInDesk': 'Im Generator anpassen',
|
||||
'desk.openInDeskHint': 'Generator mit diesem Prompt öffnen, Workflow und Parameter selbst wählen; Ergebnisse bleiben im Verlauf',
|
||||
'desk.openInspector': 'Inspektor',
|
||||
'desk.origValue': 'Workflow-Wert',
|
||||
'desk.outputN': 'Ergebnis {n}',
|
||||
'desk.outputs': 'Alle Ergebnisse',
|
||||
'desk.paramError': 'Ungültiger Parameter: {msg}',
|
||||
'desk.paramsAll': 'Alle Parameter',
|
||||
'desk.paramsChanged': 'Parameter · {n} geändert',
|
||||
'desk.paramsCommon': 'Häufige Parameter',
|
||||
'desk.pickTake': 'Eintrag im Verlauf wählen, um das Ergebnis zu sehen',
|
||||
'desk.pickWorkflow': 'Workflow wählen',
|
||||
'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',
|
||||
'desk.seedRandom': 'Zufall',
|
||||
'desk.sendToSlot': 'In Bild-Slot senden',
|
||||
'desk.sent': 'Eingereicht',
|
||||
'desk.sentToSlotToast': 'In den Bild-Slot des aktuellen Workflows gesendet',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'Größe',
|
||||
'desk.stillNeed': 'Fehlt: {what}',
|
||||
'desk.tabParams': 'Parameter',
|
||||
'desk.tabResult': 'Ergebnis',
|
||||
'desk.today': 'Heute',
|
||||
'desk.took': 'Dauer {d}',
|
||||
'desk.wfBroken': 'Nicht nutzbar',
|
||||
'desk.wfCurrent': 'aktuell',
|
||||
'desk.wfDefaultConn': 'Standardverbindung',
|
||||
'desk.wfImport': 'Workflows importieren / verwalten',
|
||||
'desk.wfLastFailed': 'Letzter Lauf fehlgeschlagen',
|
||||
'desk.wfNoMatch': 'Kein passender Workflow',
|
||||
'desk.wfOk': 'Bereit',
|
||||
'desk.wfSearchPh': 'Workflows oder Zweck suchen: Bild zu Video, Referenz…',
|
||||
'desk.wfTesting': 'Testlauf läuft',
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ export const en = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': 'Dismiss',
|
||||
'toast.undo': 'Undo',
|
||||
'toast.uploadFailed': 'Upload failed',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -1262,14 +1263,44 @@ export const en = {
|
||||
'shell.navConnections': 'ComfyUI Connections',
|
||||
'shell.navModels': 'Models',
|
||||
'shell.navSettings': 'Settings',
|
||||
'shell.wip': 'In development',
|
||||
'shell.wipHint': 'This page is under construction and will open later',
|
||||
'shell.comfy_ok': '{name} connected',
|
||||
'shell.comfy_fail': '{name} unreachable',
|
||||
'shell.comfy_testing': 'Testing {name}…',
|
||||
'shell.comfyUnset': 'ComfyUI · not configured',
|
||||
'shell.comfyManage': 'Configure the ComfyUI address in Settings',
|
||||
'shell.mobileNotice': 'Please open this workbench in a desktop browser (at least 768px wide). Phones are not supported yet.',
|
||||
'shell.scopeHint': 'Switch project',
|
||||
'shell.scopeAll': 'All projects',
|
||||
'shell.scopeAllList': 'All projects list',
|
||||
'shell.scopeRecent': 'Recent projects',
|
||||
'shell.scopeLoadFailed': 'Could not load projects',
|
||||
'shell.scopeEmpty': 'No projects yet — create one',
|
||||
'shell.scopeStage': '{n}/5 confirmed',
|
||||
'shell.queueRunning': '{n} generating',
|
||||
'shell.queueQueued': '{n} queued',
|
||||
'shell.queueIdle': 'Queue idle',
|
||||
'shell.queueActive': '{n} active',
|
||||
'shell.trayTitle': 'Job tray',
|
||||
'shell.trayRecent': 'Recent jobs · click to see all jobs',
|
||||
'shell.trayEmpty': 'No generation jobs yet',
|
||||
'shell.trayAll': 'View all jobs',
|
||||
'shell.searchTitle': 'Search pages and projects',
|
||||
'shell.searchPlaceholder': 'Search pages, projects',
|
||||
'shell.searchShortcut': 'Search (Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': 'No page or project matches “{q}”',
|
||||
'shell.searchGroupPages': 'Pages',
|
||||
'shell.searchGroupProjects': 'Projects · open the workbench',
|
||||
'shell.searchHintMove': 'select',
|
||||
'shell.searchHintOpen': 'open',
|
||||
'shell.searchHintClose': 'close',
|
||||
'shell.reduceMotion': 'Reduce motion',
|
||||
'shell.reduceHint': 'Turn off movement, scaling and glow animations; keep only short fades',
|
||||
'shell.reduceSystem': 'Following the system “Reduce motion” setting',
|
||||
'shell.tabAria': 'Primary (narrow screens)',
|
||||
'shell.tabProjects': 'Projects',
|
||||
'shell.tabCreate': 'Create',
|
||||
'shell.tabGenerate': 'Generate',
|
||||
'shell.tabMore': 'More',
|
||||
'shell.morePages': 'More pages',
|
||||
'shell.moreTools': 'Tools',
|
||||
'home.title': 'Projects overview',
|
||||
'home.subtitle': 'Manage your projects — start from an idea or a ready-made script.',
|
||||
'home.projectCount': '{n} projects · {m} in progress',
|
||||
@@ -1455,8 +1486,6 @@ export const en = {
|
||||
'studio.genParams': 'Parameters',
|
||||
'studio.modeTextToImage': 'Text to image',
|
||||
'studio.modeTextToVideo': 'Text to video',
|
||||
'studio.chipIdle': '{name} · queue idle',
|
||||
'studio.chipBusy': '{name} · {n} in queue',
|
||||
'studio.video.resultsTitle': 'Preview',
|
||||
'studio.image.historyTitle': 'Image history',
|
||||
'studio.video.historyTitle': 'Video history',
|
||||
@@ -1580,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',
|
||||
@@ -1599,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',
|
||||
@@ -1615,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',
|
||||
@@ -1846,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',
|
||||
@@ -1923,6 +1963,116 @@ export const en = {
|
||||
'wft.errListMin': '"{label}" needs at least {n} {unit}',
|
||||
'tasks.testBadge': 'Test',
|
||||
'tasks.sourceWorkflowTest': 'Workflow test',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': 'Original {v}',
|
||||
'desk.title': 'Generate',
|
||||
'desk.advanced': 'Advanced · {n} · untouched ones keep the workflow values',
|
||||
'desk.chipResolution': 'Resolution {v}',
|
||||
'desk.chipSeconds': '{v}s',
|
||||
'desk.chipSeed': 'Seed {v}',
|
||||
'desk.closeInspector': 'Close inspector',
|
||||
'desk.countActive': '{n} running',
|
||||
'desk.countTakes': '{n} takes',
|
||||
'desk.ctxAsset': '{kind} “{name}”',
|
||||
'desk.ctxShot': 'Shot {no}',
|
||||
'desk.ctxBack': 'Back to {stage}',
|
||||
'desk.ctxDismiss': 'Dismiss source note',
|
||||
'desk.ctxFrom': 'From “{project}”',
|
||||
'desk.ctxNoBackfill': 'Results stay in the desk feed and are not filled back into {stage}',
|
||||
'desk.dayDate': '{m}/{d}',
|
||||
'desk.download': 'Download',
|
||||
'desk.dropNoSlot': '{n} file(s) have no slot: this workflow has no input of that kind',
|
||||
'desk.dryRun': 'Dry run',
|
||||
'desk.durMin': '{m}m {s}s',
|
||||
'desk.durSec': '{s}s',
|
||||
'desk.elapsed': 'Time',
|
||||
'desk.emptyFailed': 'No failed takes',
|
||||
'desk.emptyFeed': 'No takes yet. Write a prompt below and press Generate.',
|
||||
'desk.filterAria': 'Filter takes',
|
||||
'desk.filterAll': 'All',
|
||||
'desk.filterImage': 'Images',
|
||||
'desk.filterVideo': 'Videos',
|
||||
'desk.filterFailed': 'Failed',
|
||||
'desk.generate': 'Generate',
|
||||
'desk.genHint': 'Generate (Ctrl/⌘ + Enter)',
|
||||
'desk.inputs': 'Inputs',
|
||||
'desk.inspector': 'Inspector',
|
||||
'desk.keyClose': 'Close',
|
||||
'desk.keyInspector': 'Show / hide',
|
||||
'desk.keySelect': 'Select',
|
||||
'desk.keyUse': 'Use',
|
||||
'desk.kvCreated': 'Submitted',
|
||||
'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.',
|
||||
'desk.notGenerated': 'Not generated',
|
||||
'desk.noWorkflows': 'No image or video workflow is ready yet',
|
||||
'desk.openInDesk': 'Tune in desk',
|
||||
'desk.openInDeskHint': 'Open the desk with this prompt and pick the workflow and params yourself; results stay in the desk feed',
|
||||
'desk.openInspector': 'Inspector',
|
||||
'desk.origValue': 'Workflow value',
|
||||
'desk.outputN': 'Result {n}',
|
||||
'desk.outputs': 'All results',
|
||||
'desk.paramError': 'Invalid parameter: {msg}',
|
||||
'desk.paramsAll': 'All params',
|
||||
'desk.paramsChanged': 'Params · {n} changed',
|
||||
'desk.paramsCommon': 'Common params',
|
||||
'desk.pickTake': 'Pick a take in the feed to see its result',
|
||||
'desk.pickWorkflow': 'Choose workflow',
|
||||
'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',
|
||||
'desk.seedRandom': 'Random',
|
||||
'desk.sendToSlot': 'Send to image slot',
|
||||
'desk.sent': 'Submitted',
|
||||
'desk.sentToSlotToast': 'Sent to the current workflow’s image slot',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'Size',
|
||||
'desk.stillNeed': 'Missing: {what}',
|
||||
'desk.tabParams': 'Params',
|
||||
'desk.tabResult': 'Result',
|
||||
'desk.today': 'Today',
|
||||
'desk.took': 'Took {d}',
|
||||
'desk.wfBroken': 'Unavailable',
|
||||
'desk.wfCurrent': 'current',
|
||||
'desk.wfDefaultConn': 'Default connection',
|
||||
'desk.wfImport': 'Import / manage workflows',
|
||||
'desk.wfLastFailed': 'Last run failed',
|
||||
'desk.wfNoMatch': 'No matching workflow',
|
||||
'desk.wfOk': 'Ready',
|
||||
'desk.wfSearchPh': 'Search workflows or a usage: image to video, reference…',
|
||||
'desk.wfTesting': 'Test running',
|
||||
'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;
|
||||
|
||||
@@ -64,6 +64,7 @@ export const es: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': 'Descartar',
|
||||
'toast.undo': 'Deshacer',
|
||||
'toast.uploadFailed': 'Error al subir',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -1260,14 +1261,44 @@ export const es: Dict = {
|
||||
'shell.navConnections': 'Conexiones ComfyUI',
|
||||
'shell.navModels': 'Modelos',
|
||||
'shell.navSettings': 'Ajustes',
|
||||
'shell.wip': 'En desarrollo',
|
||||
'shell.wipHint': 'Esta página está en construcción y llegará más adelante',
|
||||
'shell.comfy_ok': '{name} conectado',
|
||||
'shell.comfy_fail': '{name} sin conexión',
|
||||
'shell.comfy_testing': 'Probando {name}…',
|
||||
'shell.comfyUnset': 'ComfyUI · sin configurar',
|
||||
'shell.comfyManage': 'Configura la dirección de ComfyUI en Ajustes',
|
||||
'shell.mobileNotice': 'Abre este banco de trabajo en un navegador de escritorio (mínimo 768 px); los móviles aún no se admiten.',
|
||||
'shell.scopeHint': 'Cambiar de proyecto',
|
||||
'shell.scopeAll': 'Todos los proyectos',
|
||||
'shell.scopeAllList': 'Lista de proyectos',
|
||||
'shell.scopeRecent': 'Proyectos recientes',
|
||||
'shell.scopeLoadFailed': 'No se pudieron cargar los proyectos',
|
||||
'shell.scopeEmpty': 'Aún no hay proyectos: crea uno',
|
||||
'shell.scopeStage': '{n}/5 confirmadas',
|
||||
'shell.queueRunning': '{n} generando',
|
||||
'shell.queueQueued': '{n} en cola',
|
||||
'shell.queueIdle': 'Cola libre',
|
||||
'shell.queueActive': '{n} activos',
|
||||
'shell.trayTitle': 'Bandeja de tareas',
|
||||
'shell.trayRecent': 'Tareas recientes · clic para ver todas',
|
||||
'shell.trayEmpty': 'Aún no hay tareas de generación',
|
||||
'shell.trayAll': 'Ver todas las tareas',
|
||||
'shell.searchTitle': 'Buscar páginas y proyectos',
|
||||
'shell.searchPlaceholder': 'Buscar páginas, proyectos',
|
||||
'shell.searchShortcut': 'Buscar (Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': 'Ninguna página ni proyecto coincide con «{q}»',
|
||||
'shell.searchGroupPages': 'Páginas',
|
||||
'shell.searchGroupProjects': 'Proyectos · abrir el banco de trabajo',
|
||||
'shell.searchHintMove': 'elegir',
|
||||
'shell.searchHintOpen': 'abrir',
|
||||
'shell.searchHintClose': 'cerrar',
|
||||
'shell.reduceMotion': 'Reducir movimiento',
|
||||
'shell.reduceHint': 'Desactiva desplazamientos, escalados y brillos; solo quedan fundidos breves',
|
||||
'shell.reduceSystem': 'Se sigue el ajuste del sistema «Reducir movimiento»',
|
||||
'shell.tabAria': 'Navegación principal (estrecha)',
|
||||
'shell.tabProjects': 'Proyectos',
|
||||
'shell.tabCreate': 'Crear',
|
||||
'shell.tabGenerate': 'Generar',
|
||||
'shell.tabMore': 'Más',
|
||||
'shell.morePages': 'Más páginas',
|
||||
'shell.moreTools': 'Herramientas',
|
||||
'home.title': 'Resumen de proyectos',
|
||||
'home.subtitle': 'Gestiona tus proyectos: empieza con una idea o un guion terminado.',
|
||||
'home.projectCount': '{n} proyectos · {m} en curso',
|
||||
@@ -1456,8 +1487,6 @@ export const es: Dict = {
|
||||
'studio.genParams': 'Parámetros',
|
||||
'studio.modeTextToImage': 'Texto a imagen',
|
||||
'studio.modeTextToVideo': 'Texto a vídeo',
|
||||
'studio.chipIdle': '{name} · cola libre',
|
||||
'studio.chipBusy': '{name} · {n} en cola',
|
||||
'studio.video.resultsTitle': 'Vista previa',
|
||||
'studio.image.historyTitle': 'Historial de imágenes',
|
||||
'studio.video.historyTitle': 'Historial de vídeos',
|
||||
@@ -1581,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',
|
||||
@@ -1600,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',
|
||||
@@ -1616,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',
|
||||
@@ -1847,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',
|
||||
@@ -1924,4 +1964,114 @@ export const es: Dict = {
|
||||
'wft.errListMin': '«{label}» necesita al menos {n} {unit}',
|
||||
'tasks.testBadge': 'Prueba',
|
||||
'tasks.sourceWorkflowTest': 'Prueba de flujo',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': 'Original {v}',
|
||||
'desk.title': 'Generar',
|
||||
'desk.advanced': 'Avanzado · {n} · sin cambios se usan los valores del flujo',
|
||||
'desk.chipResolution': 'Resolución {v}',
|
||||
'desk.chipSeconds': '{v} s',
|
||||
'desk.chipSeed': 'Semilla {v}',
|
||||
'desk.closeInspector': 'Cerrar inspector',
|
||||
'desk.countActive': '{n} en curso',
|
||||
'desk.countTakes': '{n} registros',
|
||||
'desk.ctxAsset': '{kind} «{name}»',
|
||||
'desk.ctxShot': 'Plano {no}',
|
||||
'desk.ctxBack': 'Volver a {stage}',
|
||||
'desk.ctxDismiss': 'Cerrar aviso de origen',
|
||||
'desk.ctxFrom': 'De «{project}»',
|
||||
'desk.ctxNoBackfill': 'Los resultados quedan en el registro y no se devuelven a {stage}',
|
||||
'desk.dayDate': '{d}/{m}',
|
||||
'desk.download': 'Descargar',
|
||||
'desk.dropNoSlot': '{n} archivo(s) sin ranura: este flujo no tiene esa entrada',
|
||||
'desk.dryRun': 'Simulación',
|
||||
'desk.durMin': '{m} min {s} s',
|
||||
'desk.durSec': '{s} s',
|
||||
'desk.elapsed': 'Tiempo',
|
||||
'desk.emptyFailed': 'No hay registros fallidos',
|
||||
'desk.emptyFeed': 'Aún no hay registros. Escribe un prompt abajo y pulsa Generar.',
|
||||
'desk.filterAria': 'Filtrar registros',
|
||||
'desk.filterAll': 'Todo',
|
||||
'desk.filterImage': 'Imágenes',
|
||||
'desk.filterVideo': 'Vídeos',
|
||||
'desk.filterFailed': 'Fallidos',
|
||||
'desk.generate': 'Generar',
|
||||
'desk.genHint': 'Generar (Ctrl/⌘ + Enter)',
|
||||
'desk.inputs': 'Entradas',
|
||||
'desk.inspector': 'Inspector',
|
||||
'desk.keyClose': 'Cerrar',
|
||||
'desk.keyInspector': 'Mostrar / ocultar',
|
||||
'desk.keySelect': 'Elegir',
|
||||
'desk.keyUse': 'Usar',
|
||||
'desk.kvCreated': 'Enviado',
|
||||
'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.',
|
||||
'desk.notGenerated': 'No generado',
|
||||
'desk.noWorkflows': 'Aún no hay flujos de imagen o vídeo listos',
|
||||
'desk.openInDesk': 'Ajustar en el generador',
|
||||
'desk.openInDeskHint': 'Abre el generador con este prompt y elige flujo y parámetros; los resultados quedan en el registro',
|
||||
'desk.openInspector': 'Inspector',
|
||||
'desk.origValue': 'Valor del flujo',
|
||||
'desk.outputN': 'Resultado {n}',
|
||||
'desk.outputs': 'Todos los resultados',
|
||||
'desk.paramError': 'Parámetro no válido: {msg}',
|
||||
'desk.paramsAll': 'Todos los parámetros',
|
||||
'desk.paramsChanged': 'Parámetros · {n} cambiados',
|
||||
'desk.paramsCommon': 'Parámetros comunes',
|
||||
'desk.pickTake': 'Elige un registro para ver su resultado',
|
||||
'desk.pickWorkflow': 'Elegir flujo',
|
||||
'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',
|
||||
'desk.seedRandom': 'Aleatoria',
|
||||
'desk.sendToSlot': 'Enviar a la ranura de imagen',
|
||||
'desk.sent': 'Enviado',
|
||||
'desk.sentToSlotToast': 'Enviado a la ranura de imagen del flujo actual',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'Tamaño',
|
||||
'desk.stillNeed': 'Falta: {what}',
|
||||
'desk.tabParams': 'Parámetros',
|
||||
'desk.tabResult': 'Resultado',
|
||||
'desk.today': 'Hoy',
|
||||
'desk.took': 'Tardó {d}',
|
||||
'desk.wfBroken': 'No disponible',
|
||||
'desk.wfCurrent': 'actual',
|
||||
'desk.wfDefaultConn': 'Conexión predeterminada',
|
||||
'desk.wfImport': 'Importar / gestionar flujos',
|
||||
'desk.wfLastFailed': 'Falló la última vez',
|
||||
'desk.wfNoMatch': 'Ningún flujo coincide',
|
||||
'desk.wfOk': 'Listo',
|
||||
'desk.wfSearchPh': 'Busca flujos o un uso: imagen a vídeo, referencia…',
|
||||
'desk.wfTesting': 'Prueba en curso',
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export const fr: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': 'Ignorer',
|
||||
'toast.undo': 'Annuler',
|
||||
'toast.uploadFailed': "Échec de l'import",
|
||||
|
||||
// --- Modal ---
|
||||
@@ -1260,14 +1261,44 @@ export const fr: Dict = {
|
||||
'shell.navConnections': 'Connexions ComfyUI',
|
||||
'shell.navModels': 'Modèles',
|
||||
'shell.navSettings': 'Réglages',
|
||||
'shell.wip': 'En développement',
|
||||
'shell.wipHint': 'Cette page est en construction et arrivera plus tard',
|
||||
'shell.comfy_ok': '{name} connecté',
|
||||
'shell.comfy_fail': '{name} injoignable',
|
||||
'shell.comfy_testing': 'Test de {name}…',
|
||||
'shell.comfyUnset': 'ComfyUI · non configuré',
|
||||
'shell.comfyManage': 'Configurez l\'adresse ComfyUI dans les Réglages',
|
||||
'shell.mobileNotice': 'Ouvrez cet atelier dans un navigateur de bureau (largeur minimum 768 px) ; les téléphones ne sont pas encore pris en charge.',
|
||||
'shell.scopeHint': 'Changer de projet',
|
||||
'shell.scopeAll': 'Tous les projets',
|
||||
'shell.scopeAllList': 'Liste des projets',
|
||||
'shell.scopeRecent': 'Projets récents',
|
||||
'shell.scopeLoadFailed': 'Impossible de charger les projets',
|
||||
'shell.scopeEmpty': 'Aucun projet — en créer un',
|
||||
'shell.scopeStage': '{n}/5 validées',
|
||||
'shell.queueRunning': '{n} en cours',
|
||||
'shell.queueQueued': '{n} en file',
|
||||
'shell.queueIdle': 'File libre',
|
||||
'shell.queueActive': '{n} actifs',
|
||||
'shell.trayTitle': 'Plateau des tâches',
|
||||
'shell.trayRecent': 'Tâches récentes · cliquer pour tout voir',
|
||||
'shell.trayEmpty': 'Aucune tâche de génération',
|
||||
'shell.trayAll': 'Voir toutes les tâches',
|
||||
'shell.searchTitle': 'Rechercher pages et projets',
|
||||
'shell.searchPlaceholder': 'Rechercher pages, projets',
|
||||
'shell.searchShortcut': 'Rechercher (Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': 'Aucune page ni projet ne correspond à « {q} »',
|
||||
'shell.searchGroupPages': 'Pages',
|
||||
'shell.searchGroupProjects': 'Projets · ouvrir l’atelier',
|
||||
'shell.searchHintMove': 'choisir',
|
||||
'shell.searchHintOpen': 'ouvrir',
|
||||
'shell.searchHintClose': 'fermer',
|
||||
'shell.reduceMotion': 'Réduire les animations',
|
||||
'shell.reduceHint': 'Coupe déplacements, zooms et halos ; garde seulement de courts fondus',
|
||||
'shell.reduceSystem': 'Réglage système « Réduire les animations » appliqué',
|
||||
'shell.tabAria': 'Navigation principale (étroite)',
|
||||
'shell.tabProjects': 'Projets',
|
||||
'shell.tabCreate': 'Créer',
|
||||
'shell.tabGenerate': 'Générer',
|
||||
'shell.tabMore': 'Plus',
|
||||
'shell.morePages': 'Autres pages',
|
||||
'shell.moreTools': 'Outils',
|
||||
'home.title': 'Aperçu des projets',
|
||||
'home.subtitle': 'Gérez vos projets — commencez avec une idée ou un scénario prêt.',
|
||||
'home.projectCount': '{n} projets · {m} en cours',
|
||||
@@ -1456,8 +1487,6 @@ export const fr: Dict = {
|
||||
'studio.genParams': 'Paramètres',
|
||||
'studio.modeTextToImage': 'Texte vers image',
|
||||
'studio.modeTextToVideo': 'Texte vers vidéo',
|
||||
'studio.chipIdle': '{name} · file libre',
|
||||
'studio.chipBusy': '{name} · {n} en file',
|
||||
'studio.video.resultsTitle': 'Aperçu',
|
||||
'studio.image.historyTitle': 'Historique images',
|
||||
'studio.video.historyTitle': 'Historique vidéos',
|
||||
@@ -1581,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',
|
||||
@@ -1600,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',
|
||||
@@ -1616,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',
|
||||
@@ -1847,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',
|
||||
@@ -1924,4 +1964,114 @@ export const fr: Dict = {
|
||||
'wft.errListMin': '« {label} » exige au moins {n} {unit}',
|
||||
'tasks.testBadge': 'Test',
|
||||
'tasks.sourceWorkflowTest': 'Test de flux',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': 'Original {v}',
|
||||
'desk.title': 'Générer',
|
||||
'desk.advanced': 'Avancé · {n} · inchangés, les valeurs du workflow s’appliquent',
|
||||
'desk.chipResolution': 'Résolution {v}',
|
||||
'desk.chipSeconds': '{v} s',
|
||||
'desk.chipSeed': 'Graine {v}',
|
||||
'desk.closeInspector': 'Fermer l’inspecteur',
|
||||
'desk.countActive': '{n} en cours',
|
||||
'desk.countTakes': '{n} prises',
|
||||
'desk.ctxAsset': '{kind} « {name} »',
|
||||
'desk.ctxShot': 'Plan {no}',
|
||||
'desk.ctxBack': 'Retour à {stage}',
|
||||
'desk.ctxDismiss': 'Fermer la note d’origine',
|
||||
'desk.ctxFrom': 'Depuis « {project} »',
|
||||
'desk.ctxNoBackfill': 'Les résultats restent dans le fil et ne sont pas renvoyés vers {stage}',
|
||||
'desk.dayDate': '{d}/{m}',
|
||||
'desk.download': 'Télécharger',
|
||||
'desk.dropNoSlot': '{n} fichier(s) sans emplacement : ce workflow n’a pas cette entrée',
|
||||
'desk.dryRun': 'Simulation',
|
||||
'desk.durMin': '{m} min {s} s',
|
||||
'desk.durSec': '{s} s',
|
||||
'desk.elapsed': 'Durée',
|
||||
'desk.emptyFailed': 'Aucune prise échouée',
|
||||
'desk.emptyFeed': 'Aucune prise. Écrivez un prompt ci-dessous et cliquez sur Générer.',
|
||||
'desk.filterAria': 'Filtrer les prises',
|
||||
'desk.filterAll': 'Tout',
|
||||
'desk.filterImage': 'Images',
|
||||
'desk.filterVideo': 'Vidéos',
|
||||
'desk.filterFailed': 'Échecs',
|
||||
'desk.generate': 'Générer',
|
||||
'desk.genHint': 'Générer (Ctrl/⌘ + Entrée)',
|
||||
'desk.inputs': 'Entrées',
|
||||
'desk.inspector': 'Inspecteur',
|
||||
'desk.keyClose': 'Fermer',
|
||||
'desk.keyInspector': 'Afficher / masquer',
|
||||
'desk.keySelect': 'Choisir',
|
||||
'desk.keyUse': 'Utiliser',
|
||||
'desk.kvCreated': 'Soumis',
|
||||
'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.',
|
||||
'desk.notGenerated': 'Non généré',
|
||||
'desk.noWorkflows': 'Aucun workflow image ou vidéo prêt',
|
||||
'desk.openInDesk': 'Régler dans le générateur',
|
||||
'desk.openInDeskHint': 'Ouvre le générateur avec ce prompt ; choisissez workflow et paramètres, les résultats restent dans le fil',
|
||||
'desk.openInspector': 'Inspecteur',
|
||||
'desk.origValue': 'Valeur du workflow',
|
||||
'desk.outputN': 'Résultat {n}',
|
||||
'desk.outputs': 'Tous les résultats',
|
||||
'desk.paramError': 'Paramètre invalide : {msg}',
|
||||
'desk.paramsAll': 'Tous les paramètres',
|
||||
'desk.paramsChanged': 'Paramètres · {n} modifiés',
|
||||
'desk.paramsCommon': 'Paramètres courants',
|
||||
'desk.pickTake': 'Choisissez une prise pour voir son résultat',
|
||||
'desk.pickWorkflow': 'Choisir un workflow',
|
||||
'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',
|
||||
'desk.seedRandom': 'Aléatoire',
|
||||
'desk.sendToSlot': 'Envoyer vers l’emplacement image',
|
||||
'desk.sent': 'Soumis',
|
||||
'desk.sentToSlotToast': 'Envoyé vers l’emplacement image du workflow actuel',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'Taille',
|
||||
'desk.stillNeed': 'Manque : {what}',
|
||||
'desk.tabParams': 'Paramètres',
|
||||
'desk.tabResult': 'Résultat',
|
||||
'desk.today': 'Aujourd’hui',
|
||||
'desk.took': 'Durée {d}',
|
||||
'desk.wfBroken': 'Indisponible',
|
||||
'desk.wfCurrent': 'actuel',
|
||||
'desk.wfDefaultConn': 'Connexion par défaut',
|
||||
'desk.wfImport': 'Importer / gérer les workflows',
|
||||
'desk.wfLastFailed': 'Dernier essai échoué',
|
||||
'desk.wfNoMatch': 'Aucun workflow correspondant',
|
||||
'desk.wfOk': 'Prêt',
|
||||
'desk.wfSearchPh': 'Chercher un workflow ou un usage : image vers vidéo, référence…',
|
||||
'desk.wfTesting': 'Test en cours',
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export const ja: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': '閉じる',
|
||||
'toast.undo': '元に戻す',
|
||||
'toast.uploadFailed': 'アップロードに失敗しました',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -1260,14 +1261,44 @@ export const ja: Dict = {
|
||||
'shell.navConnections': 'ComfyUI 接続',
|
||||
'shell.navModels': 'モデル設定',
|
||||
'shell.navSettings': '設定',
|
||||
'shell.wip': '開発中',
|
||||
'shell.wipHint': 'このページは準備中です。後ほど公開されます',
|
||||
'shell.comfy_ok': '{name} 接続済み',
|
||||
'shell.comfy_fail': '{name} 接続失敗',
|
||||
'shell.comfy_testing': '{name} テスト中…',
|
||||
'shell.comfyUnset': 'ComfyUI · 未設定',
|
||||
'shell.comfyManage': '「設定」で ComfyUI のアドレスを設定できます',
|
||||
'shell.mobileNotice': 'このワークベンチはデスクトップブラウザ(幅 768px 以上)で開いてください。スマートフォンには未対応です。',
|
||||
'shell.scopeHint': 'プロジェクト切替',
|
||||
'shell.scopeAll': 'すべてのプロジェクト',
|
||||
'shell.scopeAllList': 'プロジェクト一覧',
|
||||
'shell.scopeRecent': '最近のプロジェクト',
|
||||
'shell.scopeLoadFailed': 'プロジェクトを読み込めませんでした',
|
||||
'shell.scopeEmpty': 'プロジェクトはまだありません — 作成する',
|
||||
'shell.scopeStage': '{n}/5 確定',
|
||||
'shell.queueRunning': '{n} 件生成中',
|
||||
'shell.queueQueued': '{n} 件待機',
|
||||
'shell.queueIdle': 'キューは空です',
|
||||
'shell.queueActive': '{n} 件進行中',
|
||||
'shell.trayTitle': 'タスクトレイ',
|
||||
'shell.trayRecent': '最近のタスク · クリックで全タスク',
|
||||
'shell.trayEmpty': '生成タスクはまだありません',
|
||||
'shell.trayAll': 'すべてのタスクを見る',
|
||||
'shell.searchTitle': 'ページとプロジェクトを検索',
|
||||
'shell.searchPlaceholder': 'ページ・プロジェクトを検索',
|
||||
'shell.searchShortcut': '検索(Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': '「{q}」に一致するページやプロジェクトはありません',
|
||||
'shell.searchGroupPages': 'ページ',
|
||||
'shell.searchGroupProjects': 'プロジェクト · ワークベンチを開く',
|
||||
'shell.searchHintMove': '選択',
|
||||
'shell.searchHintOpen': '開く',
|
||||
'shell.searchHintClose': '閉じる',
|
||||
'shell.reduceMotion': '動きを減らす',
|
||||
'shell.reduceHint': '移動・拡大・発光アニメーションを止め、短いフェードのみ残します',
|
||||
'shell.reduceSystem': 'システムの「視差効果を減らす」設定に従っています',
|
||||
'shell.tabAria': 'メインナビ(狭い画面)',
|
||||
'shell.tabProjects': 'プロジェクト',
|
||||
'shell.tabCreate': '創作',
|
||||
'shell.tabGenerate': '生成',
|
||||
'shell.tabMore': 'その他',
|
||||
'shell.morePages': 'その他のページ',
|
||||
'shell.moreTools': 'ツール',
|
||||
'home.title': 'プロジェクト概要',
|
||||
'home.subtitle': '創作プロジェクトを管理 — アイデアでも完成台本でも始められます。',
|
||||
'home.projectCount': 'プロジェクト {n} 件 · 進行中 {m} 件',
|
||||
@@ -1456,8 +1487,6 @@ export const ja: Dict = {
|
||||
'studio.genParams': '生成パラメータ',
|
||||
'studio.modeTextToImage': 'テキストから画像',
|
||||
'studio.modeTextToVideo': 'テキストから動画',
|
||||
'studio.chipIdle': '{name} · キュー空き',
|
||||
'studio.chipBusy': '{name} · キュー {n} 件',
|
||||
'studio.video.resultsTitle': 'プレビュー',
|
||||
'studio.image.historyTitle': '画像生成履歴',
|
||||
'studio.video.historyTitle': '動画生成履歴',
|
||||
@@ -1581,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 接続」で取り込む',
|
||||
@@ -1600,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': '独立動画',
|
||||
@@ -1616,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': 'エージェント',
|
||||
@@ -1847,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': '接続中',
|
||||
@@ -1924,4 +1964,114 @@ export const ja: Dict = {
|
||||
'wft.errListMin': '「{label}」は最低 {n} {unit} 必要です',
|
||||
'tasks.testBadge': 'テスト',
|
||||
'tasks.sourceWorkflowTest': 'ワークフローテスト',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': '元の値 {v}',
|
||||
'desk.title': '生成台',
|
||||
'desk.advanced': '詳細パラメータ {n} 項目 · 変更しなければワークフローの値を使用',
|
||||
'desk.chipResolution': '解像度 {v}',
|
||||
'desk.chipSeconds': '{v} 秒',
|
||||
'desk.chipSeed': 'シード {v}',
|
||||
'desk.closeInspector': 'インスペクターを閉じる',
|
||||
'desk.countActive': '{n} 件実行中',
|
||||
'desk.countTakes': '{n} 件の記録',
|
||||
'desk.ctxAsset': '{kind}「{name}」',
|
||||
'desk.ctxShot': 'ショット {no}',
|
||||
'desk.ctxBack': '{stage}に戻る',
|
||||
'desk.ctxDismiss': '出典の表示を閉じる',
|
||||
'desk.ctxFrom': '『{project}』から',
|
||||
'desk.ctxNoBackfill': '結果は生成台の記録にのみ残り、{stage}へ自動で戻りません',
|
||||
'desk.dayDate': '{m}月{d}日',
|
||||
'desk.download': 'ダウンロード',
|
||||
'desk.dropNoSlot': '{n} 件のファイルを入れる枠がありません:このワークフローにはその入力がありません',
|
||||
'desk.dryRun': 'ドライラン',
|
||||
'desk.durMin': '{m} 分 {s} 秒',
|
||||
'desk.durSec': '{s} 秒',
|
||||
'desk.elapsed': '所要時間',
|
||||
'desk.emptyFailed': '失敗した記録はありません',
|
||||
'desk.emptyFeed': 'まだ記録がありません。下にプロンプトを書いて「生成」を押してください。',
|
||||
'desk.filterAria': '記録を絞り込む',
|
||||
'desk.filterAll': 'すべて',
|
||||
'desk.filterImage': '画像',
|
||||
'desk.filterVideo': '動画',
|
||||
'desk.filterFailed': '失敗',
|
||||
'desk.generate': '生成',
|
||||
'desk.genHint': '生成(Ctrl/⌘ + Enter)',
|
||||
'desk.inputs': '入力',
|
||||
'desk.inspector': 'インスペクター',
|
||||
'desk.keyClose': '閉じる',
|
||||
'desk.keyInspector': '表示 / 非表示',
|
||||
'desk.keySelect': '選択',
|
||||
'desk.keyUse': '使用',
|
||||
'desk.kvCreated': '送信時刻',
|
||||
'desk.kvName': '名前',
|
||||
'desk.kvPromptId': 'ComfyUI タスク ID',
|
||||
'desk.kvUsage': '用途',
|
||||
'desk.loadEarlier': '過去の {n} 件を表示',
|
||||
'desk.missingSub': '同じパラメータでやり直すか、この記録を削除できます',
|
||||
'desk.missingTitle': '結果ファイルはサーバー上にありません',
|
||||
'desk.needWorkflow': '不足:ワークフロー',
|
||||
'desk.noConn': '未接続',
|
||||
'desk.noPromptInput': 'このワークフローにはプロンプト入力がなく、元の値で生成します。',
|
||||
'desk.notGenerated': '未生成',
|
||||
'desk.noWorkflows': '使える画像 / 動画ワークフローがまだありません',
|
||||
'desk.openInDesk': '生成台で調整',
|
||||
'desk.openInDeskHint': 'このプロンプトで生成台を開き、ワークフローとパラメータを選びます。結果は生成台の記録に残ります',
|
||||
'desk.openInspector': 'インスペクター',
|
||||
'desk.origValue': 'ワークフローの値',
|
||||
'desk.outputN': '結果 {n}',
|
||||
'desk.outputs': 'すべての結果',
|
||||
'desk.paramError': 'パラメータが不正です:{msg}',
|
||||
'desk.paramsAll': 'すべてのパラメータ',
|
||||
'desk.paramsChanged': 'パラメータ · {n} 項目変更',
|
||||
'desk.paramsCommon': 'よく使うパラメータ',
|
||||
'desk.pickTake': '記録を選ぶと結果を表示します',
|
||||
'desk.pickWorkflow': 'ワークフローを選択',
|
||||
'desk.promptOnly': 'プロンプトのみ',
|
||||
'desk.promptPh': '欲しい画面を説明…(Ctrl/⌘ + Enter で生成、/ でフォーカス)',
|
||||
'desk.restoreAll': 'すべてワークフローの値に戻す',
|
||||
'desk.redo': '同じパラメータでやり直す',
|
||||
'desk.removeRecord': '記録を削除',
|
||||
'desk.restoreOrig': '元に戻す',
|
||||
'desk.reuse': '再利用',
|
||||
'desk.reuseToComposer': '入力台で再利用',
|
||||
'desk.seedRandom': 'ランダム',
|
||||
'desk.sendToSlot': '現在の画像枠へ送る',
|
||||
'desk.sent': '送信済み',
|
||||
'desk.sentToSlotToast': '現在のワークフローの画像枠へ送りました',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': 'サイズ',
|
||||
'desk.stillNeed': '不足:{what}',
|
||||
'desk.tabParams': 'パラメータ',
|
||||
'desk.tabResult': '結果',
|
||||
'desk.today': '今日',
|
||||
'desk.took': '所要 {d}',
|
||||
'desk.wfBroken': '使用不可',
|
||||
'desk.wfCurrent': '現在',
|
||||
'desk.wfDefaultConn': '既定の接続に従う',
|
||||
'desk.wfImport': 'ワークフローを取り込む / 管理',
|
||||
'desk.wfLastFailed': '前回失敗',
|
||||
'desk.wfNoMatch': '一致するワークフローがありません',
|
||||
'desk.wfOk': '使用可',
|
||||
'desk.wfSearchPh': 'ワークフローや用途を検索:画像から動画、参照画像…',
|
||||
'desk.wfTesting': 'テスト実行中',
|
||||
'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 を閉じる',
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export const ko: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': '닫기',
|
||||
'toast.undo': '실행 취소',
|
||||
'toast.uploadFailed': '업로드 실패',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -1260,14 +1261,44 @@ export const ko: Dict = {
|
||||
'shell.navConnections': 'ComfyUI 연결',
|
||||
'shell.navModels': '모델 설정',
|
||||
'shell.navSettings': '설정',
|
||||
'shell.wip': '개발 중',
|
||||
'shell.wipHint': '이 페이지는 준비 중이며 곧 열립니다',
|
||||
'shell.comfy_ok': '{name} 연결됨',
|
||||
'shell.comfy_fail': '{name} 연결 실패',
|
||||
'shell.comfy_testing': '{name} 테스트 중…',
|
||||
'shell.comfyUnset': 'ComfyUI · 미설정',
|
||||
'shell.comfyManage': '「설정」에서 ComfyUI 주소를 설정하세요',
|
||||
'shell.mobileNotice': '이 워크벤치는 데스크톱 브라우저(폭 768px 이상)에서 열어 주세요. 휴대폰은 아직 지원되지 않습니다.',
|
||||
'shell.scopeHint': '프로젝트 전환',
|
||||
'shell.scopeAll': '전체 프로젝트',
|
||||
'shell.scopeAllList': '프로젝트 목록',
|
||||
'shell.scopeRecent': '최근 프로젝트',
|
||||
'shell.scopeLoadFailed': '프로젝트를 불러오지 못했습니다',
|
||||
'shell.scopeEmpty': '아직 프로젝트가 없습니다 — 만들기',
|
||||
'shell.scopeStage': '{n}/5 확정',
|
||||
'shell.queueRunning': '{n}개 생성 중',
|
||||
'shell.queueQueued': '{n}개 대기',
|
||||
'shell.queueIdle': '대기열 비어 있음',
|
||||
'shell.queueActive': '{n}개 진행 중',
|
||||
'shell.trayTitle': '작업 트레이',
|
||||
'shell.trayRecent': '최근 작업 · 클릭하면 전체 작업',
|
||||
'shell.trayEmpty': '아직 생성 작업이 없습니다',
|
||||
'shell.trayAll': '전체 작업 보기',
|
||||
'shell.searchTitle': '페이지와 프로젝트 검색',
|
||||
'shell.searchPlaceholder': '페이지, 프로젝트 검색',
|
||||
'shell.searchShortcut': '검색 (Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': '‘{q}’와 일치하는 페이지나 프로젝트가 없습니다',
|
||||
'shell.searchGroupPages': '페이지',
|
||||
'shell.searchGroupProjects': '프로젝트 · 워크벤치 열기',
|
||||
'shell.searchHintMove': '선택',
|
||||
'shell.searchHintOpen': '열기',
|
||||
'shell.searchHintClose': '닫기',
|
||||
'shell.reduceMotion': '동작 줄이기',
|
||||
'shell.reduceHint': '이동·확대·발광 애니메이션을 끄고 짧은 페이드만 남깁니다',
|
||||
'shell.reduceSystem': '시스템의 ‘동작 줄이기’ 설정을 따르는 중',
|
||||
'shell.tabAria': '주 탐색 (좁은 화면)',
|
||||
'shell.tabProjects': '프로젝트',
|
||||
'shell.tabCreate': '창작',
|
||||
'shell.tabGenerate': '생성',
|
||||
'shell.tabMore': '더보기',
|
||||
'shell.morePages': '더 많은 페이지',
|
||||
'shell.moreTools': '도구',
|
||||
'home.title': '프로젝트 개요',
|
||||
'home.subtitle': '창작 프로젝트를 관리하세요 — 아이디어나 완성된 대본으로 시작할 수 있습니다.',
|
||||
'home.projectCount': '프로젝트 {n}개 · 진행 중 {m}개',
|
||||
@@ -1456,8 +1487,6 @@ export const ko: Dict = {
|
||||
'studio.genParams': '생성 매개변수',
|
||||
'studio.modeTextToImage': '텍스트→이미지',
|
||||
'studio.modeTextToVideo': '텍스트→영상',
|
||||
'studio.chipIdle': '{name} · 대기열 비어 있음',
|
||||
'studio.chipBusy': '{name} · 대기열 {n}건',
|
||||
'studio.video.resultsTitle': '미리보기',
|
||||
'studio.image.historyTitle': '이미지 생성 기록',
|
||||
'studio.video.historyTitle': '영상 생성 기록',
|
||||
@@ -1581,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 연결에서 가져오기',
|
||||
@@ -1600,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': '독립 비디오',
|
||||
@@ -1616,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': '에이전트',
|
||||
@@ -1847,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': '연결 중',
|
||||
@@ -1924,4 +1964,114 @@ export const ko: Dict = {
|
||||
'wft.errListMin': '«{label}»에 최소 {n}{unit}이(가) 필요합니다',
|
||||
'tasks.testBadge': '테스트',
|
||||
'tasks.sourceWorkflowTest': '워크플로 테스트',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': '원래 값 {v}',
|
||||
'desk.title': '생성대',
|
||||
'desk.advanced': '고급 매개변수 {n}개 · 바꾸지 않으면 워크플로 값을 사용',
|
||||
'desk.chipResolution': '해상도 {v}',
|
||||
'desk.chipSeconds': '{v}초',
|
||||
'desk.chipSeed': '시드 {v}',
|
||||
'desk.closeInspector': '인스펙터 닫기',
|
||||
'desk.countActive': '{n}개 진행 중',
|
||||
'desk.countTakes': '기록 {n}개',
|
||||
'desk.ctxAsset': '{kind} 「{name}」',
|
||||
'desk.ctxShot': '샷 {no}',
|
||||
'desk.ctxBack': '{stage}(으)로 돌아가기',
|
||||
'desk.ctxDismiss': '출처 안내 닫기',
|
||||
'desk.ctxFrom': '《{project}》에서',
|
||||
'desk.ctxNoBackfill': '결과는 생성대 기록에만 남고 {stage}에 자동으로 반영되지 않습니다',
|
||||
'desk.dayDate': '{m}월 {d}일',
|
||||
'desk.download': '다운로드',
|
||||
'desk.dropNoSlot': '파일 {n}개를 넣을 슬롯이 없습니다: 이 워크플로에는 해당 입력이 없습니다',
|
||||
'desk.dryRun': '드라이런',
|
||||
'desk.durMin': '{m}분 {s}초',
|
||||
'desk.durSec': '{s}초',
|
||||
'desk.elapsed': '소요 시간',
|
||||
'desk.emptyFailed': '실패한 기록이 없습니다',
|
||||
'desk.emptyFeed': '아직 기록이 없습니다. 아래에 프롬프트를 쓰고 「생성」을 누르세요.',
|
||||
'desk.filterAria': '기록 필터',
|
||||
'desk.filterAll': '전체',
|
||||
'desk.filterImage': '이미지',
|
||||
'desk.filterVideo': '동영상',
|
||||
'desk.filterFailed': '실패',
|
||||
'desk.generate': '생성',
|
||||
'desk.genHint': '생성 (Ctrl/⌘ + Enter)',
|
||||
'desk.inputs': '입력',
|
||||
'desk.inspector': '인스펙터',
|
||||
'desk.keyClose': '닫기',
|
||||
'desk.keyInspector': '표시 / 숨기기',
|
||||
'desk.keySelect': '선택',
|
||||
'desk.keyUse': '사용',
|
||||
'desk.kvCreated': '제출 시각',
|
||||
'desk.kvName': '이름',
|
||||
'desk.kvPromptId': 'ComfyUI 작업 ID',
|
||||
'desk.kvUsage': '용도',
|
||||
'desk.loadEarlier': '이전 {n}개 표시',
|
||||
'desk.missingSub': '같은 설정으로 다시 만들거나 이 기록을 제거할 수 있습니다',
|
||||
'desk.missingTitle': '결과 파일이 서버에 없습니다',
|
||||
'desk.needWorkflow': '부족: 워크플로',
|
||||
'desk.noConn': '연결 안 됨',
|
||||
'desk.noPromptInput': '이 워크플로에는 프롬프트 입력이 없어 원래 값으로 생성합니다.',
|
||||
'desk.notGenerated': '생성 안 됨',
|
||||
'desk.noWorkflows': '사용할 수 있는 이미지 / 동영상 워크플로가 아직 없습니다',
|
||||
'desk.openInDesk': '생성대에서 조정',
|
||||
'desk.openInDeskHint': '이 프롬프트로 생성대를 열고 워크플로와 매개변수를 직접 고릅니다. 결과는 생성대 기록에 남습니다',
|
||||
'desk.openInspector': '인스펙터',
|
||||
'desk.origValue': '워크플로 값',
|
||||
'desk.outputN': '결과 {n}',
|
||||
'desk.outputs': '모든 결과',
|
||||
'desk.paramError': '잘못된 매개변수: {msg}',
|
||||
'desk.paramsAll': '모든 매개변수',
|
||||
'desk.paramsChanged': '매개변수 · {n}개 변경',
|
||||
'desk.paramsCommon': '자주 쓰는 매개변수',
|
||||
'desk.pickTake': '기록을 선택하면 결과가 표시됩니다',
|
||||
'desk.pickWorkflow': '워크플로 선택',
|
||||
'desk.promptOnly': '프롬프트만',
|
||||
'desk.promptPh': '원하는 장면을 설명… (Ctrl/⌘ + Enter 생성, / 포커스)',
|
||||
'desk.restoreAll': '모두 워크플로 값으로 되돌리기',
|
||||
'desk.redo': '같은 설정으로 다시 생성',
|
||||
'desk.removeRecord': '기록 제거',
|
||||
'desk.restoreOrig': '원래 값으로',
|
||||
'desk.reuse': '재사용',
|
||||
'desk.reuseToComposer': '입력대에서 재사용',
|
||||
'desk.seedRandom': '무작위',
|
||||
'desk.sendToSlot': '현재 이미지 슬롯으로 보내기',
|
||||
'desk.sent': '제출됨',
|
||||
'desk.sentToSlotToast': '현재 워크플로의 이미지 슬롯으로 보냈습니다',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': '크기',
|
||||
'desk.stillNeed': '부족: {what}',
|
||||
'desk.tabParams': '매개변수',
|
||||
'desk.tabResult': '결과',
|
||||
'desk.today': '오늘',
|
||||
'desk.took': '소요 {d}',
|
||||
'desk.wfBroken': '사용 불가',
|
||||
'desk.wfCurrent': '현재',
|
||||
'desk.wfDefaultConn': '기본 연결 따름',
|
||||
'desk.wfImport': '워크플로 가져오기 / 관리',
|
||||
'desk.wfLastFailed': '지난번 실패',
|
||||
'desk.wfNoMatch': '일치하는 워크플로가 없습니다',
|
||||
'desk.wfOk': '사용 가능',
|
||||
'desk.wfSearchPh': '워크플로나 용도 검색: 이미지→동영상, 참조 이미지…',
|
||||
'desk.wfTesting': '테스트 실행 중',
|
||||
'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 접기',
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export const zh: Dict = {
|
||||
|
||||
// --- Toast ---
|
||||
'toast.dismiss': '关闭',
|
||||
'toast.undo': '撤销',
|
||||
'toast.uploadFailed': '上传失败',
|
||||
|
||||
// --- Modal ---
|
||||
@@ -1262,14 +1263,44 @@ export const zh: Dict = {
|
||||
'shell.navConnections': 'ComfyUI 连接',
|
||||
'shell.navModels': '模型设置',
|
||||
'shell.navSettings': '设置',
|
||||
'shell.wip': '开发中',
|
||||
'shell.wipHint': '该页面正在开发中,稍后开放',
|
||||
'shell.comfy_ok': '{name} 已连接',
|
||||
'shell.comfy_fail': '{name} 连接失败',
|
||||
'shell.comfy_testing': '{name} 测试中…',
|
||||
'shell.comfyUnset': 'ComfyUI · 未配置',
|
||||
'shell.comfyManage': '管理 ComfyUI 连接',
|
||||
'shell.mobileNotice': '请用桌面浏览器(宽 768px 以上)打开影工坊,手机端暂不适配。',
|
||||
'shell.scopeHint': '切换项目',
|
||||
'shell.scopeAll': '全部项目',
|
||||
'shell.scopeAllList': '全部项目列表',
|
||||
'shell.scopeRecent': '最近项目',
|
||||
'shell.scopeLoadFailed': '项目列表加载失败',
|
||||
'shell.scopeEmpty': '还没有项目,去创建',
|
||||
'shell.scopeStage': '已确认 {n}/5',
|
||||
'shell.queueRunning': '{n} 个生成中',
|
||||
'shell.queueQueued': '{n} 个排队',
|
||||
'shell.queueIdle': '队列空闲',
|
||||
'shell.queueActive': '{n} 个进行中',
|
||||
'shell.trayTitle': '任务托盘',
|
||||
'shell.trayRecent': '最近任务 · 点击查看全部任务',
|
||||
'shell.trayEmpty': '还没有生成任务',
|
||||
'shell.trayAll': '查看全部任务',
|
||||
'shell.searchTitle': '搜索页面和项目',
|
||||
'shell.searchPlaceholder': '搜索页面、项目',
|
||||
'shell.searchShortcut': '搜索(Ctrl/⌘ + K)',
|
||||
'shell.searchEmpty': '没有找到与「{q}」相关的页面或项目',
|
||||
'shell.searchGroupPages': '页面',
|
||||
'shell.searchGroupProjects': '项目 · 打开创作工作台',
|
||||
'shell.searchHintMove': '选择',
|
||||
'shell.searchHintOpen': '打开',
|
||||
'shell.searchHintClose': '关闭',
|
||||
'shell.reduceMotion': '减少动态',
|
||||
'shell.reduceHint': '关闭位移、缩放和光晕动画,只保留短暂淡入淡出',
|
||||
'shell.reduceSystem': '已跟随系统的「减少动态效果」设置',
|
||||
'shell.tabAria': '主导航(窄屏)',
|
||||
'shell.tabProjects': '项目',
|
||||
'shell.tabCreate': '创作',
|
||||
'shell.tabGenerate': '生成',
|
||||
'shell.tabMore': '更多',
|
||||
'shell.morePages': '更多页面',
|
||||
'shell.moreTools': '工具',
|
||||
'home.title': '项目总览',
|
||||
'home.subtitle': '管理创作项目,从一个创意或一份现成剧本开始。',
|
||||
'home.projectCount': '{n} 个项目 · {m} 个进行中',
|
||||
@@ -1455,8 +1486,6 @@ export const zh: Dict = {
|
||||
'studio.genParams': '生成参数',
|
||||
'studio.modeTextToImage': '文生图',
|
||||
'studio.modeTextToVideo': '文生视频',
|
||||
'studio.chipIdle': '{name} · 队列空闲',
|
||||
'studio.chipBusy': '{name} · 队列 {n} 个任务',
|
||||
'studio.video.resultsTitle': '成片预览',
|
||||
'studio.image.historyTitle': '生图历史',
|
||||
'studio.video.historyTitle': '生视频历史',
|
||||
@@ -1581,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 连接」导入',
|
||||
@@ -1600,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': '独立生视频',
|
||||
@@ -1616,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': '智能体',
|
||||
@@ -1847,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': '连接中',
|
||||
@@ -1924,4 +1964,114 @@ export const zh: Dict = {
|
||||
'wft.errListMin': '「{label}」需要至少 {n} {unit}',
|
||||
'tasks.testBadge': '测试',
|
||||
'tasks.sourceWorkflowTest': '工作流测试',
|
||||
// --- 生成台 (HEL-653) ---
|
||||
'cdf.orig': '原值 {v}',
|
||||
'desk.title': '生成台',
|
||||
'desk.advanced': '高级参数 {n} 项 · 不改就沿用工作流原值',
|
||||
'desk.chipResolution': '分辨率 {v}',
|
||||
'desk.chipSeconds': '{v} 秒',
|
||||
'desk.chipSeed': '种子 {v}',
|
||||
'desk.closeInspector': '关闭检视区',
|
||||
'desk.countActive': '{n} 个进行中',
|
||||
'desk.countTakes': '{n} 条记录',
|
||||
'desk.ctxAsset': '{kind}「{name}」',
|
||||
'desk.ctxShot': '镜头 {no}',
|
||||
'desk.ctxBack': '返回{stage}',
|
||||
'desk.ctxDismiss': '关闭来源提示',
|
||||
'desk.ctxFrom': '来自《{project}》',
|
||||
'desk.ctxNoBackfill': '结果只留在生成台记录,不会自动回填到{stage}',
|
||||
'desk.dayDate': '{m}月{d}日',
|
||||
'desk.download': '下载',
|
||||
'desk.dropNoSlot': '{n} 个文件没有可放的槽位:当前工作流没有接这类输入',
|
||||
'desk.dryRun': '演练模式',
|
||||
'desk.durMin': '{m} 分 {s} 秒',
|
||||
'desk.durSec': '{s} 秒',
|
||||
'desk.elapsed': '用时',
|
||||
'desk.emptyFailed': '没有失败的记录',
|
||||
'desk.emptyFeed': '还没有记录。在下方写提示词,点「生成」。',
|
||||
'desk.filterAria': '筛选记录',
|
||||
'desk.filterAll': '全部',
|
||||
'desk.filterImage': '图片',
|
||||
'desk.filterVideo': '视频',
|
||||
'desk.filterFailed': '失败',
|
||||
'desk.generate': '生成',
|
||||
'desk.genHint': '生成(Ctrl/⌘ + Enter)',
|
||||
'desk.inputs': '输入',
|
||||
'desk.inspector': '检视区',
|
||||
'desk.keyClose': '关闭',
|
||||
'desk.keyInspector': '显示 / 收起',
|
||||
'desk.keySelect': '选择',
|
||||
'desk.keyUse': '使用',
|
||||
'desk.kvCreated': '提交时间',
|
||||
'desk.kvName': '名称',
|
||||
'desk.kvPromptId': 'ComfyUI 任务号',
|
||||
'desk.kvUsage': '用途',
|
||||
'desk.loadEarlier': '显示更早的 {n} 条',
|
||||
'desk.missingSub': '可用相同参数重做,或移除这条记录',
|
||||
'desk.missingTitle': '结果文件已不在服务器上',
|
||||
'desk.needWorkflow': '还缺:工作流',
|
||||
'desk.noConn': '未连接',
|
||||
'desk.noPromptInput': '这个工作流没有提示词输入,按工作流原值生成。',
|
||||
'desk.notGenerated': '未生成',
|
||||
'desk.noWorkflows': '还没有可用的生图 / 生视频工作流',
|
||||
'desk.openInDesk': '在生成台调参',
|
||||
'desk.openInDeskHint': '带上提示词打开生成台,自选工作流和参数;结果留在生成台记录',
|
||||
'desk.openInspector': '检视区',
|
||||
'desk.origValue': '工作流原值',
|
||||
'desk.outputN': '第 {n} 个结果',
|
||||
'desk.outputs': '全部结果',
|
||||
'desk.paramError': '参数有误:{msg}',
|
||||
'desk.paramsAll': '全部参数',
|
||||
'desk.paramsChanged': '参数 · 已改 {n} 项',
|
||||
'desk.paramsCommon': '常用参数',
|
||||
'desk.pickTake': '在记录流里选一条查看结果',
|
||||
'desk.pickWorkflow': '选择工作流',
|
||||
'desk.promptOnly': '仅提示词',
|
||||
'desk.promptPh': '描述想要的画面…(Ctrl/⌘ + Enter 生成,/ 快速聚焦)',
|
||||
'desk.restoreAll': '全部恢复工作流原值',
|
||||
'desk.redo': '用相同参数重做',
|
||||
'desk.removeRecord': '移除记录',
|
||||
'desk.restoreOrig': '恢复原值',
|
||||
'desk.reuse': '复用',
|
||||
'desk.reuseToComposer': '复用到输入台',
|
||||
'desk.seedRandom': '随机',
|
||||
'desk.sendToSlot': '送入当前图片槽',
|
||||
'desk.sent': '已提交',
|
||||
'desk.sentToSlotToast': '已送入当前工作流的图片槽',
|
||||
'desk.slotOne': '{label} ×1',
|
||||
'desk.slotRange': '{label} {min}–{max}',
|
||||
'desk.specSize': '尺寸',
|
||||
'desk.stillNeed': '还缺:{what}',
|
||||
'desk.tabParams': '参数',
|
||||
'desk.tabResult': '结果',
|
||||
'desk.today': '今天',
|
||||
'desk.took': '用时 {d}',
|
||||
'desk.wfBroken': '不可用',
|
||||
'desk.wfCurrent': '当前',
|
||||
'desk.wfDefaultConn': '跟随默认连接',
|
||||
'desk.wfImport': '导入 / 管理工作流',
|
||||
'desk.wfLastFailed': '上次失败',
|
||||
'desk.wfNoMatch': '没有匹配的工作流',
|
||||
'desk.wfOk': '可用',
|
||||
'desk.wfSearchPh': '搜索工作流,或输入用途:图生视频、参考图…',
|
||||
'desk.wfTesting': '试运行中',
|
||||
'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;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { storageGet, storageSet } from './storage';
|
||||
|
||||
/**
|
||||
* 减少动态效果 (HEL-649 §五). The OS setting always wins (app.css also reacts
|
||||
* to `prefers-reduced-motion` directly, so it cannot be switched off here);
|
||||
* otherwise the top-bar switch decides. The effective value is mirrored to
|
||||
* `<html data-reduce="1">`, which is what every reduced-motion rule keys on.
|
||||
*/
|
||||
const STORAGE_KEY = 'aigc-reduce-motion';
|
||||
const QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
export const reduceMotion = $state({ system: false, manual: false });
|
||||
|
||||
let stop: (() => void) | null = null;
|
||||
|
||||
function apply() {
|
||||
document.documentElement.dataset.reduce =
|
||||
reduceMotion.system || reduceMotion.manual ? '1' : '0';
|
||||
}
|
||||
|
||||
/** Call once on the client (root layout). Returns the teardown. */
|
||||
export function initReduceMotion(): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
stop?.();
|
||||
const mq = window.matchMedia(QUERY);
|
||||
reduceMotion.system = mq.matches;
|
||||
reduceMotion.manual = storageGet(STORAGE_KEY) === '1';
|
||||
apply();
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
reduceMotion.system = e.matches;
|
||||
apply();
|
||||
};
|
||||
mq.addEventListener('change', onChange);
|
||||
stop = () => mq.removeEventListener('change', onChange);
|
||||
return () => {
|
||||
stop?.();
|
||||
stop = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function setReduceMotion(on: boolean) {
|
||||
reduceMotion.manual = on;
|
||||
storageSet(STORAGE_KEY, on ? '1' : '0');
|
||||
apply();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import ToastHost from '$lib/components/ToastHost.svelte';
|
||||
import { initReduceMotion } from '$lib/reduceMotion.svelte';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => initReduceMotion());
|
||||
</script>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -245,11 +245,11 @@
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function jobBar(job: Job): { value: number; tone: 'accent' | 'success' | 'error' } {
|
||||
function jobBar(job: Job): { value: number; tone: 'run' | 'success' | 'error' } {
|
||||
if (job.status === 'done') return { value: 100, tone: 'success' };
|
||||
if (job.status === 'failed') return { value: 100, tone: 'error' };
|
||||
if (job.status === 'running') return { value: progressFor(job.id)?.progress ?? 3, tone: 'accent' };
|
||||
return { value: 0, tone: 'accent' };
|
||||
if (job.status === 'running') return { value: progressFor(job.id)?.progress ?? 3, tone: 'run' };
|
||||
return { value: 0, tone: 'run' };
|
||||
}
|
||||
|
||||
function jobStatusLabel(status: string): string {
|
||||
@@ -493,7 +493,7 @@
|
||||
<aside class="tasks">
|
||||
<div class="tasks-head">
|
||||
<h2>{t('home.tasksTitle')}</h2>
|
||||
<span class="chip" class:amber={activeJobCount > 0}>
|
||||
<span class="chip" class:run={activeJobCount > 0}>
|
||||
{t('home.activeJobs', { n: activeJobCount })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -580,9 +580,9 @@
|
||||
font-size: 11.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip.amber {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
.chip.run {
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
}
|
||||
|
||||
.entry-btn {
|
||||
@@ -616,7 +616,7 @@
|
||||
color: var(--text);
|
||||
}
|
||||
.entry-btn.secondary:hover {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.entry-btn:active {
|
||||
transform: scale(0.98);
|
||||
@@ -651,7 +651,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.entry-card h2 {
|
||||
margin: 4px 0 0;
|
||||
@@ -736,10 +736,10 @@
|
||||
}
|
||||
.f-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.f-input.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.f-hint {
|
||||
display: block;
|
||||
@@ -908,13 +908,10 @@
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tag-local {
|
||||
background: var(--info-dim);
|
||||
color: var(--info);
|
||||
}
|
||||
.tag-local,
|
||||
.tag-cloud {
|
||||
background: var(--cloud-dim);
|
||||
color: var(--cloud);
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.job-name {
|
||||
flex: 1;
|
||||
@@ -950,7 +947,7 @@
|
||||
color: var(--success);
|
||||
}
|
||||
.running-text {
|
||||
color: var(--accent);
|
||||
color: var(--run);
|
||||
}
|
||||
.queued-text {
|
||||
color: var(--text-3);
|
||||
@@ -966,8 +963,8 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
background: var(--run);
|
||||
box-shadow: 0 0 10px var(--run-soft);
|
||||
}
|
||||
.dot.breathe {
|
||||
animation: breathe 2s ease-in-out infinite;
|
||||
|
||||
@@ -1133,11 +1133,11 @@
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #edeef0;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar.dark {
|
||||
color: #16130a;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.avatar.lg {
|
||||
width: 42px;
|
||||
@@ -1294,8 +1294,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-tag.busy {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
background: var(--run-soft);
|
||||
color: var(--run);
|
||||
}
|
||||
.chat-head-actions {
|
||||
display: flex;
|
||||
@@ -1618,7 +1618,7 @@
|
||||
background: var(--success);
|
||||
}
|
||||
.dot.amber {
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
}
|
||||
.dot.amber.breathing {
|
||||
animation: breathe 2s ease-in-out infinite;
|
||||
|
||||
+769
-150
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
@@ -1654,7 +1171,7 @@
|
||||
transition: border-color 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.card.failed {
|
||||
border-color: rgba(229, 72, 77, 0.35);
|
||||
border-color: color-mix(in srgb, var(--err) 35%, transparent);
|
||||
}
|
||||
.card-row {
|
||||
display: flex;
|
||||
@@ -1671,7 +1188,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-name {
|
||||
@@ -1688,13 +1205,10 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-local,
|
||||
.tag-lan {
|
||||
color: var(--info);
|
||||
background: var(--info-dim);
|
||||
}
|
||||
.tag-lan,
|
||||
.tag-cloud {
|
||||
color: var(--cloud);
|
||||
background: var(--cloud-dim);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.tag-auth {
|
||||
color: var(--text-2);
|
||||
@@ -1743,8 +1257,8 @@
|
||||
background: var(--text-3);
|
||||
}
|
||||
.dot.amber {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
background: var(--run);
|
||||
box-shadow: 0 0 10px var(--run-soft);
|
||||
}
|
||||
.dot.breathing {
|
||||
animation: breathe 2s ease-in-out infinite;
|
||||
@@ -1769,7 +1283,7 @@
|
||||
color: var(--error);
|
||||
}
|
||||
.status-text.amber {
|
||||
color: var(--accent);
|
||||
color: var(--run);
|
||||
}
|
||||
.status-text.gray {
|
||||
color: var(--text-3);
|
||||
@@ -1824,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 {
|
||||
@@ -1902,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(--accent);
|
||||
}
|
||||
.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(--accent);
|
||||
}
|
||||
.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;
|
||||
@@ -2067,31 +1450,7 @@
|
||||
cursor: help;
|
||||
}
|
||||
.wf-meta-item.recheck.warn .wf-recheck-text {
|
||||
color: var(--accent);
|
||||
}
|
||||
.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%;
|
||||
color: var(--warn);
|
||||
}
|
||||
.wf-slot-rows {
|
||||
display: flex;
|
||||
@@ -2175,38 +1534,16 @@
|
||||
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(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.tag-disabled {
|
||||
color: var(--text-3);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.wf-pending-note {
|
||||
color: var(--accent);
|
||||
color: var(--warn);
|
||||
}
|
||||
.wf-row.pending .wf-name {
|
||||
color: var(--text-3);
|
||||
@@ -2215,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: rgba(229, 72, 77, 0.65);
|
||||
}
|
||||
.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: rgba(255, 255, 255, 0.24);
|
||||
}
|
||||
.file-btn input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-table {
|
||||
display: flex;
|
||||
@@ -2302,13 +1578,10 @@
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-kind.image {
|
||||
color: var(--info);
|
||||
background: var(--info-dim);
|
||||
}
|
||||
.wf-kind.image,
|
||||
.wf-kind.video {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.wf-name {
|
||||
flex: 1;
|
||||
@@ -2379,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;
|
||||
@@ -2460,14 +1733,14 @@
|
||||
}
|
||||
.f-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.f-input:disabled {
|
||||
color: var(--text-3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.f-input.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.f-hint {
|
||||
display: block;
|
||||
@@ -2475,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;
|
||||
@@ -2610,7 +1877,7 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 790;
|
||||
background: rgba(5, 5, 8, 0.5);
|
||||
background: var(--overlay);
|
||||
}
|
||||
.wf-import-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -2654,5 +1921,4 @@
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
}</style>
|
||||
|
||||
@@ -929,7 +929,7 @@
|
||||
transition: border-color 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.card.failed {
|
||||
border-color: rgba(229, 72, 77, 0.35);
|
||||
border-color: color-mix(in srgb, var(--err) 35%, transparent);
|
||||
}
|
||||
.card-row {
|
||||
display: flex;
|
||||
@@ -946,7 +946,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-name {
|
||||
@@ -963,13 +963,10 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.tag-local,
|
||||
.tag-lan {
|
||||
color: var(--info);
|
||||
background: var(--info-dim);
|
||||
}
|
||||
.tag-lan,
|
||||
.tag-cloud {
|
||||
color: var(--cloud);
|
||||
background: var(--cloud-dim);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
.tag-default {
|
||||
color: var(--accent);
|
||||
@@ -1010,8 +1007,8 @@
|
||||
background: var(--text-3);
|
||||
}
|
||||
.dot.amber {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
background: var(--run);
|
||||
box-shadow: 0 0 10px var(--run-soft);
|
||||
}
|
||||
.dot.breathing {
|
||||
animation: breathe 2s ease-in-out infinite;
|
||||
@@ -1036,7 +1033,7 @@
|
||||
color: var(--error);
|
||||
}
|
||||
.status-text.amber {
|
||||
color: var(--accent);
|
||||
color: var(--run);
|
||||
}
|
||||
.status-text.gray {
|
||||
color: var(--text-3);
|
||||
@@ -1179,7 +1176,7 @@
|
||||
color: var(--text);
|
||||
}
|
||||
.avatar.dark {
|
||||
color: #16130a;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.agent-name {
|
||||
font-size: 12.5px;
|
||||
@@ -1282,14 +1279,14 @@
|
||||
}
|
||||
.f-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.f-input:disabled {
|
||||
color: var(--text-3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.f-input.invalid {
|
||||
border-color: rgba(229, 72, 77, 0.65);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.f-hint {
|
||||
display: block;
|
||||
@@ -1433,7 +1430,7 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 790;
|
||||
background: rgba(5, 5, 8, 0.5);
|
||||
background: var(--overlay);
|
||||
}
|
||||
.agents-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
@@ -669,7 +669,7 @@
|
||||
color: var(--text-2);
|
||||
}
|
||||
.note.warn :global(.icon) {
|
||||
color: var(--accent);
|
||||
color: var(--warn);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -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,23 +361,48 @@
|
||||
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: rgba(229, 72, 77, 0.35);
|
||||
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 {
|
||||
@@ -323,14 +413,8 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tkind.image {
|
||||
color: var(--info);
|
||||
background: var(--info-dim);
|
||||
}
|
||||
.tkind.video {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
|
||||
.tthumb {
|
||||
@@ -338,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,
|
||||
@@ -366,6 +450,9 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tname .wf-gone {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tmeta {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
@@ -374,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;
|
||||
}
|
||||
|
||||
@@ -386,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,
|
||||
@@ -402,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(--accent);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
@@ -450,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;
|
||||
@@ -467,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;
|
||||
@@ -475,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>
|
||||
|
||||
@@ -471,6 +471,7 @@
|
||||
{:else if stageKey === 'assets'}
|
||||
<AssetsStagePanel
|
||||
stage={panelProps.stage}
|
||||
{projectId}
|
||||
busy={busyFor('assets')}
|
||||
onSave={(content) => $saveMutation.mutate({ key: 'assets', content })}
|
||||
onRun={() => handleRun('assets')}
|
||||
@@ -480,6 +481,7 @@
|
||||
{:else}
|
||||
<ShotStagePanel
|
||||
stage={panelProps.stage}
|
||||
{projectId}
|
||||
busy={busyFor('shot')}
|
||||
onSave={(content) => $saveMutation.mutate({ key: 'shot', content })}
|
||||
onRun={() => handleRun('shot')}
|
||||
@@ -670,10 +672,13 @@
|
||||
color: var(--text-2);
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.st-running,
|
||||
.st-running {
|
||||
color: var(--run);
|
||||
background: var(--run-soft);
|
||||
}
|
||||
.st-draft {
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
.st-confirmed {
|
||||
color: var(--success);
|
||||
@@ -684,8 +689,8 @@
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--info);
|
||||
background: var(--info-dim);
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
@@ -695,7 +700,7 @@
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--error-dim);
|
||||
border: 1px solid rgba(229, 72, 77, 0.35);
|
||||
border: 1px solid color-mix(in srgb, var(--err) 35%, transparent);
|
||||
color: var(--error);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
@@ -706,9 +711,9 @@
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid rgba(232, 163, 61, 0.3);
|
||||
color: var(--accent);
|
||||
background: var(--warn-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--warn) 30%, transparent);
|
||||
color: var(--warn);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.running-banner {
|
||||
@@ -766,7 +771,7 @@
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #edeef0;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agent-copy {
|
||||
@@ -833,13 +838,10 @@
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kind-image {
|
||||
background: var(--info-dim);
|
||||
color: var(--info);
|
||||
}
|
||||
.kind-image,
|
||||
.kind-video {
|
||||
background: var(--cloud-dim);
|
||||
color: var(--cloud);
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.job-body {
|
||||
display: flex;
|
||||
@@ -872,7 +874,7 @@
|
||||
color: var(--success);
|
||||
}
|
||||
.job-state.st-running {
|
||||
color: var(--accent);
|
||||
color: var(--run);
|
||||
}
|
||||
.job-state.st-pending {
|
||||
color: var(--text-2);
|
||||
@@ -904,9 +906,11 @@
|
||||
.flow-dot.st-confirmed {
|
||||
background: var(--success);
|
||||
}
|
||||
.flow-dot.st-running,
|
||||
.flow-dot.st-running {
|
||||
background: var(--run);
|
||||
}
|
||||
.flow-dot.st-awaiting_confirm {
|
||||
background: var(--accent);
|
||||
background: var(--warn);
|
||||
}
|
||||
.flow-name {
|
||||
font-size: 12px;
|
||||
@@ -953,7 +957,7 @@
|
||||
}
|
||||
.wb-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(232, 163, 61, 0.6);
|
||||
border-color: var(--accent-line);
|
||||
}
|
||||
.wb-input::placeholder {
|
||||
color: var(--text-3);
|
||||
@@ -990,7 +994,7 @@
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.btn.sm {
|
||||
padding: 6px 11px;
|
||||
@@ -1024,7 +1028,7 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
background: var(--run);
|
||||
animation: wb-page-breath 2s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||
}
|
||||
.breath.big {
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
// Colors and radii resolve to the design tokens in src/routes/app.css so the
|
||||
// Tailwind palette can never drift from the single source of truth.
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#0a0a0c',
|
||||
surface: '#141418',
|
||||
elevated: '#1e1e24',
|
||||
border: '#2a2a32',
|
||||
muted: '#71717a',
|
||||
accent: '#8b5cf6',
|
||||
'accent-hover': '#7c3aed',
|
||||
primary: 'var(--bg)',
|
||||
surface: 'var(--bg-2)',
|
||||
elevated: 'var(--bg-3)',
|
||||
border: 'var(--line)',
|
||||
muted: 'var(--text-3)',
|
||||
accent: 'var(--accent)',
|
||||
'accent-hover': 'var(--accent-hi)',
|
||||
ok: 'var(--ok)',
|
||||
run: 'var(--run)',
|
||||
err: 'var(--err)',
|
||||
warn: 'var(--warn)',
|
||||
},
|
||||
borderRadius: {
|
||||
sm: '6px',
|
||||
md: '10px',
|
||||
lg: '16px',
|
||||
sm: 'var(--r-sm)',
|
||||
md: 'var(--r-md)',
|
||||
lg: 'var(--r-lg)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 条专项用
|
||||
路由拦截验证。
|
||||
@@ -0,0 +1,6 @@
|
||||
logs-*.txt
|
||||
run.log
|
||||
shots/
|
||||
fake_mode.json
|
||||
pid-*
|
||||
node_modules/
|
||||
+49
@@ -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()
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
Binary file not shown.
Reference in New Issue
Block a user