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