Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4c7334f45 | ||
|
|
6064bd805d |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
|
||||
/**
|
||||
* Drawer — the shared right-side floating layer (HEL-649 §四.3):
|
||||
* slides in over 280ms with --ease-drawer and reverses mid-flight,
|
||||
* focus is trapped while open, Esc / backdrop click close it, and
|
||||
* reduced-motion keeps only a ≤120ms opacity fade (no slide).
|
||||
*/
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
title?: string;
|
||||
/** Panel width on >760px screens. */
|
||||
width?: number;
|
||||
dismissible?: boolean;
|
||||
onclose?: () => void;
|
||||
footer?: Snippet;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title,
|
||||
width = 380,
|
||||
dismissible = true,
|
||||
onclose,
|
||||
footer,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
const titleId = `drawer-title-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
let panel = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function requestClose() {
|
||||
if (!dismissible) return;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Tab') trapFocus(event);
|
||||
}
|
||||
|
||||
function trapFocus(event: KeyboardEvent) {
|
||||
if (!panel) return;
|
||||
const focusables = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
|
||||
).filter((el) => el.getClientRects().length > 0);
|
||||
if (focusables.length === 0) {
|
||||
event.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement;
|
||||
const focusInside = panel.contains(active);
|
||||
if (event.shiftKey && (!focusInside || active === first)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!focusInside || active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) requestClose();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const invoker = document.activeElement as HTMLElement | null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('keydown', handleKeydown, true);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!panel || panel.contains(document.activeElement)) return;
|
||||
const first = panel.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(first ?? panel).focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeydown, true);
|
||||
if (invoker?.isConnected) invoker.focus();
|
||||
onclose?.();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="drawer-backdrop" onclick={handleBackdropClick}>
|
||||
<div
|
||||
bind:this={panel}
|
||||
class="drawer-panel"
|
||||
style:--drawer-w="{width}px"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? titleId : undefined}
|
||||
aria-label={title ? undefined : t('modal.dialog')}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if title || dismissible}
|
||||
<div class="drawer-header">
|
||||
{#if title}
|
||||
<h2 class="drawer-title" id={titleId}>{title}</h2>
|
||||
{/if}
|
||||
{#if dismissible}
|
||||
<button
|
||||
type="button"
|
||||
class="drawer-close"
|
||||
aria-label={t('modal.closeDialog')}
|
||||
onclick={requestClose}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="drawer-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
{#if footer}
|
||||
<div class="drawer-footer">
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: var(--overlay);
|
||||
animation: drawer-fade var(--d-state) ease-out;
|
||||
}
|
||||
.drawer-panel {
|
||||
width: min(var(--drawer-w), 100vw);
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--float-bg);
|
||||
border-radius: var(--r-lg) 0 0 var(--r-lg);
|
||||
box-shadow: var(--shadow-float);
|
||||
outline: none;
|
||||
animation: drawer-slide var(--d-drawer) var(--ease-drawer) both;
|
||||
}
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 20px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.drawer-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-title);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.drawer-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--d-state) ease,
|
||||
color var(--d-state) ease;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.drawer-close:hover {
|
||||
background: var(--bg-4);
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.drawer-close:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.drawer-body {
|
||||
padding: 14px 20px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
}
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes drawer-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes drawer-slide {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
/* 减少动态效果 (§五):去位移,只留 ≤120ms 透明度 */
|
||||
:global(:root[data-reduce='1']) .drawer-panel {
|
||||
animation: drawer-fade var(--d-drawer) ease-out both;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drawer-panel {
|
||||
animation: drawer-fade var(--d-drawer) ease-out both;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.drawer-panel {
|
||||
width: 100vw;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2058,4 +2058,20 @@ export const de: Dict = {
|
||||
'desk.wfUntested': 'Nicht getestet',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Gestern',
|
||||
'wfd.stepParse': 'JSON parsen',
|
||||
'wfd.stepNodes': 'Knoten erkennen',
|
||||
'wfd.stepModels': 'Modelle prüfen',
|
||||
'wfd.stepSlots': 'Eingangsslots aufbauen',
|
||||
'wfd.noOutput': 'Der Workflow hat keinen Ausgabeknoten — Import nicht möglich',
|
||||
'wfd.nodesAutoOk': 'Verwendung automatisch erkannt: {usage}',
|
||||
'wfd.nodesUserOk': 'Verwendung bestätigt: {usage}',
|
||||
'wfd.modelsOk': '„{name}“ hat alle benötigten Knoten und Modelle',
|
||||
'wfd.slotsOk': '{n} Eingangsslot(s) aufgebaut ({m} Medien)',
|
||||
'wfd.checksAria': 'Import-Prüfergebnisse',
|
||||
'wfd.showDetails': 'Fehlerdetails anzeigen',
|
||||
'wfd.hideDetails': 'Fehlerdetails ausblenden',
|
||||
'wfd.unreachableSuggest': 'Eine misslungene Prüfung verhindert das Speichern nicht; nach Reparatur erneut prüfen.',
|
||||
'wfd.nodesPickSuggest': 'Wähle unten die Verwendung und schließe die Ausgangszuordnung ab, bevor du speicherst.',
|
||||
'wfd.showRaw': 'Rohes JSON anzeigen',
|
||||
'wfd.hideRaw': 'Rohes JSON ausblenden',
|
||||
};
|
||||
|
||||
@@ -2057,6 +2057,22 @@ export const en = {
|
||||
'desk.wfUntested': 'Not tested',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Yesterday',
|
||||
'wfd.stepParse': 'Parse JSON',
|
||||
'wfd.stepNodes': 'Identify nodes',
|
||||
'wfd.stepModels': 'Verify models',
|
||||
'wfd.stepSlots': 'Build input slots',
|
||||
'wfd.noOutput': 'The workflow has no output node — cannot import',
|
||||
'wfd.nodesAutoOk': 'Usage auto-detected: {usage}',
|
||||
'wfd.nodesUserOk': 'Usage confirmed: {usage}',
|
||||
'wfd.modelsOk': '"{name}" has all required nodes and models',
|
||||
'wfd.slotsOk': '{n} input slot(s) built ({m} media)',
|
||||
'wfd.checksAria': 'Import check results',
|
||||
'wfd.showDetails': 'Show error details',
|
||||
'wfd.hideDetails': 'Hide error details',
|
||||
'wfd.unreachableSuggest': 'Being unverifiable does not block saving; re-check after fixing the connection.',
|
||||
'wfd.nodesPickSuggest': 'Pick the usage and finish the output mapping below before saving.',
|
||||
'wfd.showRaw': 'View raw JSON',
|
||||
'wfd.hideRaw': 'Hide raw JSON',
|
||||
};
|
||||
|
||||
export type Dict = typeof en;
|
||||
|
||||
@@ -2058,4 +2058,20 @@ export const es: Dict = {
|
||||
'desk.wfUntested': 'Sin probar',
|
||||
'desk.workflow': 'Flujo',
|
||||
'desk.yesterday': 'Ayer',
|
||||
'wfd.stepParse': 'Analizar JSON',
|
||||
'wfd.stepNodes': 'Identificar nodos',
|
||||
'wfd.stepModels': 'Verificar modelos',
|
||||
'wfd.stepSlots': 'Crear slots de entrada',
|
||||
'wfd.noOutput': 'El flujo no tiene nodos de salida; no se puede importar',
|
||||
'wfd.nodesAutoOk': 'Uso detectado automáticamente: {usage}',
|
||||
'wfd.nodesUserOk': 'Uso confirmado: {usage}',
|
||||
'wfd.modelsOk': '«{name}» tiene todos los nodos y modelos necesarios',
|
||||
'wfd.slotsOk': '{n} slot(s) de entrada creados ({m} de medios)',
|
||||
'wfd.checksAria': 'Resultados de la comprobación de importación',
|
||||
'wfd.showDetails': 'Mostrar detalles del error',
|
||||
'wfd.hideDetails': 'Ocultar detalles del error',
|
||||
'wfd.unreachableSuggest': 'No poder verificar no impide guardar; vuelve a comprobar tras arreglar la conexión.',
|
||||
'wfd.nodesPickSuggest': 'Elige el uso y completa el mapeo de salida abajo antes de guardar.',
|
||||
'wfd.showRaw': 'Ver JSON original',
|
||||
'wfd.hideRaw': 'Ocultar JSON original',
|
||||
};
|
||||
|
||||
@@ -2058,4 +2058,20 @@ export const fr: Dict = {
|
||||
'desk.wfUntested': 'Non testé',
|
||||
'desk.workflow': 'Workflow',
|
||||
'desk.yesterday': 'Hier',
|
||||
'wfd.stepParse': 'Analyser le JSON',
|
||||
'wfd.stepNodes': 'Identifier les nœuds',
|
||||
'wfd.stepModels': 'Vérifier les modèles',
|
||||
'wfd.stepSlots': 'Créer les slots d’entrée',
|
||||
'wfd.noOutput': "Le flux n'a aucun nœud de sortie — import impossible",
|
||||
'wfd.nodesAutoOk': 'Usage détecté automatiquement : {usage}',
|
||||
'wfd.nodesUserOk': 'Usage confirmé : {usage}',
|
||||
'wfd.modelsOk': '« {name} » possède tous les nœuds et modèles requis',
|
||||
'wfd.slotsOk': "{n} slot(s) d'entrée créés ({m} média)",
|
||||
'wfd.checksAria': "Résultats de la vérification d'import",
|
||||
'wfd.showDetails': "Afficher les détails de l'erreur",
|
||||
'wfd.hideDetails': "Masquer les détails de l'erreur",
|
||||
'wfd.unreachableSuggest': "L'impossibilité de vérifier n'empêche pas l'enregistrement ; revérifiez après avoir réparé la connexion.",
|
||||
'wfd.nodesPickSuggest': "Choisissez l'usage et terminez le mappage des sorties ci-dessous avant d'enregistrer.",
|
||||
'wfd.showRaw': 'Voir le JSON brut',
|
||||
'wfd.hideRaw': 'Masquer le JSON brut',
|
||||
};
|
||||
|
||||
@@ -2058,4 +2058,20 @@ export const ja: Dict = {
|
||||
'desk.wfUntested': '未テスト',
|
||||
'desk.workflow': 'ワークフロー',
|
||||
'desk.yesterday': '昨日',
|
||||
'wfd.stepParse': 'JSON を解析',
|
||||
'wfd.stepNodes': 'ノードを識別',
|
||||
'wfd.stepModels': 'モデルを確認',
|
||||
'wfd.stepSlots': '入力スロットを構築',
|
||||
'wfd.noOutput': '出力ノードがないためインポートできません',
|
||||
'wfd.nodesAutoOk': '用途を自動判定:{usage}',
|
||||
'wfd.nodesUserOk': '用途を確認済み:{usage}',
|
||||
'wfd.modelsOk': '「{name}」に必要なノードとモデルが揃っています',
|
||||
'wfd.slotsOk': '入力スロット {n} 件を構築(うちメディア {m} 件)',
|
||||
'wfd.checksAria': 'インポート検査結果',
|
||||
'wfd.showDetails': 'エラー詳細を展開',
|
||||
'wfd.hideDetails': 'エラー詳細を折りたたむ',
|
||||
'wfd.unreachableSuggest': '検査できなくても保存は可能です。接続を直して「再チェック」してください。',
|
||||
'wfd.nodesPickSuggest': '保存前に下で用途を選び、出力マッピングを完了してください。',
|
||||
'wfd.showRaw': '元の JSON を見る',
|
||||
'wfd.hideRaw': '元の JSON を閉じる',
|
||||
};
|
||||
|
||||
@@ -2058,4 +2058,20 @@ export const ko: Dict = {
|
||||
'desk.wfUntested': '테스트 안 함',
|
||||
'desk.workflow': '워크플로',
|
||||
'desk.yesterday': '어제',
|
||||
'wfd.stepParse': 'JSON 파싱',
|
||||
'wfd.stepNodes': '노드 식별',
|
||||
'wfd.stepModels': '모델 확인',
|
||||
'wfd.stepSlots': '입력 슬롯 구성',
|
||||
'wfd.noOutput': '출력 노드가 없어 가져올 수 없습니다',
|
||||
'wfd.nodesAutoOk': '용도 자동 판정: {usage}',
|
||||
'wfd.nodesUserOk': '용도 확인됨: {usage}',
|
||||
'wfd.modelsOk': "'{name}'에 필요한 노드와 모델이 모두 있습니다",
|
||||
'wfd.slotsOk': '입력 슬롯 {n}개 구성(미디어 {m}개)',
|
||||
'wfd.checksAria': '가져오기 검사 결과',
|
||||
'wfd.showDetails': '오류 자세히 보기',
|
||||
'wfd.hideDetails': '오류 자세히 접기',
|
||||
'wfd.unreachableSuggest': '확인 실패는 저장을 막지 않습니다. 연결을 고친 뒤 다시 검사하세요.',
|
||||
'wfd.nodesPickSuggest': '저장하기 전에 아래에서 용도를 선택하고 출력 매핑을 완료하세요.',
|
||||
'wfd.showRaw': '원본 JSON 보기',
|
||||
'wfd.hideRaw': '원본 JSON 접기',
|
||||
};
|
||||
|
||||
@@ -2058,4 +2058,20 @@ export const zh: Dict = {
|
||||
'desk.wfUntested': '未试运行',
|
||||
'desk.workflow': '工作流',
|
||||
'desk.yesterday': '昨天',
|
||||
'wfd.stepParse': '解析 JSON',
|
||||
'wfd.stepNodes': '识别节点',
|
||||
'wfd.stepModels': '核对模型',
|
||||
'wfd.stepSlots': '建立输入槽',
|
||||
'wfd.noOutput': '工作流没有任何输出节点,无法导入',
|
||||
'wfd.nodesAutoOk': '自动判定用途:{usage}',
|
||||
'wfd.nodesUserOk': '用途已确认:{usage}',
|
||||
'wfd.modelsOk': '「{name}」已装齐所需节点与模型',
|
||||
'wfd.slotsOk': '已建立 {n} 个输入槽(含 {m} 个媒体槽)',
|
||||
'wfd.checksAria': '导入检查结果',
|
||||
'wfd.showDetails': '展开错误详情',
|
||||
'wfd.hideDetails': '收起错误详情',
|
||||
'wfd.unreachableSuggest': '无法核查不会阻止保存;修好连接后点「重新检查」复查。',
|
||||
'wfd.nodesPickSuggest': '请在下方选择用途并完成输出映射后再保存。',
|
||||
'wfd.showRaw': '查看原始 JSON',
|
||||
'wfd.hideRaw': '收起原始 JSON',
|
||||
};
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import WorkflowImportDrawer from '$lib/components/WorkflowImportDrawer.svelte';
|
||||
import WorkflowTestDialog from '$lib/components/WorkflowTestDialog.svelte';
|
||||
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowAnalyzeResult, type WorkflowConnectionCheck } from '$lib/api';
|
||||
import { apiErrorMessage, comfy, settings, workflows, type ComfyEndpoint, type Workflow, type WorkflowOutputMapEntry, type WorkflowUsage, type WorkflowConnectionCheck } from '$lib/api';
|
||||
import { WORKFLOW_USAGES } from '$lib/comfy/types';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
@@ -327,214 +328,24 @@
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Workflow library (import + binding) — two-step wizard: paste/analyze,
|
||||
// then confirm the judged usage and slot roles before saving (HEL-636).
|
||||
// Workflow library (binding + per-row recheck / test). The import
|
||||
// wizard itself lives in WorkflowImportDrawer (HEL-655 E 批).
|
||||
// ------------------------------------------------------------------
|
||||
let wfJsonText = $state('');
|
||||
let wfImportOpen = $state(false);
|
||||
let wfImportName = $state('');
|
||||
let wfImportEndpoint = $state('');
|
||||
let wfStep = $state<'input' | 'confirm'>('input');
|
||||
let wfAnalysis = $state<WorkflowAnalyzeResult | null>(null);
|
||||
let wfUsage = $state<WorkflowUsage | null>(null);
|
||||
let wfRoleOverrides = $state<Record<string, string>>({});
|
||||
// HEL-638: explicit output-node designation for unknown custom nodes.
|
||||
let wfOutputPicked = $state<Record<string, boolean>>({});
|
||||
let wfOutputKind = $state<Record<string, 'image' | 'video'>>({});
|
||||
let wfAnalyzing = $state(false);
|
||||
let wfAnalyzeError = $state('');
|
||||
|
||||
const wfParsed = $derived.by(() => {
|
||||
const text = wfJsonText.trim();
|
||||
if (!text) return { valid: false as const, json: null as Record<string, unknown> | null };
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed) &&
|
||||
Object.values(parsed).some(
|
||||
(v) => typeof v === 'object' && v !== null && 'class_type' in (v as object),
|
||||
)
|
||||
) {
|
||||
return { valid: true as const, json: parsed as Record<string, unknown> };
|
||||
}
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
return { valid: false as const, json: null };
|
||||
});
|
||||
|
||||
function usageLabel(u: WorkflowUsage | null | undefined): string {
|
||||
return u ? t(`usage.${u}`) : t('usage.pending');
|
||||
}
|
||||
|
||||
function startWfImport() {
|
||||
wfImportOpen = true;
|
||||
wfImportName = '';
|
||||
wfJsonText = '';
|
||||
wfImportEndpoint = '';
|
||||
wfStep = 'input';
|
||||
wfAnalysis = null;
|
||||
wfUsage = null;
|
||||
wfRoleOverrides = {};
|
||||
wfOutputPicked = {};
|
||||
wfOutputKind = {};
|
||||
wfAnalyzeError = '';
|
||||
}
|
||||
|
||||
function usageLabel(u: WorkflowUsage | null | undefined): string {
|
||||
return u ? t(`usage.${u}`) : t('usage.pending');
|
||||
}
|
||||
|
||||
function wfProblems(wf: Workflow): string[] {
|
||||
return (wf.warnings ?? []).filter((w) => w.level === 'error').map((w) => w.message);
|
||||
}
|
||||
|
||||
async function runWfAnalyze(refresh = false) {
|
||||
if (!wfParsed.valid || !wfParsed.json) return;
|
||||
wfAnalyzing = true;
|
||||
wfAnalyzeError = '';
|
||||
try {
|
||||
const res = await workflows.analyze(
|
||||
wfParsed.json,
|
||||
wfImportEndpoint || undefined,
|
||||
refresh,
|
||||
);
|
||||
wfAnalysis = res;
|
||||
wfUsage = res.usage ?? null;
|
||||
wfRoleOverrides = {};
|
||||
// Unknown output candidates: preselect only when there is exactly
|
||||
// one — with several the user must decide (HEL-638).
|
||||
const candidates = (res.usage_evidence.outputs ?? []).filter(
|
||||
(o) => o.evidence === 'unknown_node',
|
||||
);
|
||||
const picked: Record<string, boolean> = {};
|
||||
const kinds: Record<string, 'image' | 'video'> = {};
|
||||
for (const c of candidates) {
|
||||
kinds[c.nodeId] = c.suggested_kind ?? 'image';
|
||||
}
|
||||
if (candidates.length === 1) picked[candidates[0].nodeId] = true;
|
||||
wfOutputPicked = picked;
|
||||
wfOutputKind = kinds;
|
||||
wfStep = 'confirm';
|
||||
} catch (err) {
|
||||
wfAnalyzeError = apiErrorMessage(err);
|
||||
} finally {
|
||||
wfAnalyzing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the graph has no output node at all — nothing to confirm. */
|
||||
const wfFatal = $derived(
|
||||
wfStep === 'confirm' && wfAnalysis !== null && (wfAnalysis.outputs?.length ?? 0) === 0,
|
||||
);
|
||||
/** Unknown custom output nodes the user must designate (HEL-638). */
|
||||
const wfOutputCandidates = $derived(
|
||||
wfStep === 'confirm' && wfAnalysis !== null
|
||||
? (wfAnalysis.usage_evidence.outputs ?? []).filter((o) => o.evidence === 'unknown_node')
|
||||
: [],
|
||||
);
|
||||
const wfOutputRows = $derived(
|
||||
wfOutputCandidates.map((o) => ({
|
||||
...o,
|
||||
picked: wfOutputPicked[o.nodeId] ?? false,
|
||||
kind: wfOutputKind[o.nodeId] ?? o.suggested_kind ?? 'image',
|
||||
})),
|
||||
);
|
||||
const wfOutputPickedCount = $derived(
|
||||
Object.values(wfOutputPicked).filter(Boolean).length,
|
||||
);
|
||||
const wfMustPick = $derived(
|
||||
wfStep === 'confirm' &&
|
||||
wfAnalysis !== null &&
|
||||
!wfFatal &&
|
||||
(wfAnalysis.usage_ambiguous || wfAnalysis.needs_mapping) &&
|
||||
wfUsage === null,
|
||||
);
|
||||
const wfSaveable = $derived(
|
||||
wfStep === 'confirm' &&
|
||||
wfUsage !== null &&
|
||||
!wfFatal &&
|
||||
(wfOutputCandidates.length === 0 || wfOutputPickedCount > 0),
|
||||
);
|
||||
/** Media slots the confirm page lets the user re-role. */
|
||||
const wfSlotRows = $derived.by(() => {
|
||||
if (wfStep !== 'confirm' || !wfAnalysis) return [];
|
||||
return wfAnalysis.usage_evidence.image_slots.map((s) => ({
|
||||
...s,
|
||||
role: wfRoleOverrides[s.nodeId] ?? s.role ?? 'image',
|
||||
fixed: s.kind === 'image_list',
|
||||
}));
|
||||
});
|
||||
const wfParamPreview = $derived.by(() => {
|
||||
if (!wfAnalysis) return [];
|
||||
// Count EVERY analyzed input (media slots included) so the preview
|
||||
// matches the inline count shown after import (input_schema.length)
|
||||
// and in the studio — HEL-619.
|
||||
return wfAnalysis.inputs.map((i) => i.label);
|
||||
});
|
||||
|
||||
const wfImportMutation = createMutation({
|
||||
mutationFn: async () => {
|
||||
if (!wfParsed.valid || !wfParsed.json || !wfAnalysis || !wfUsage)
|
||||
throw new Error(t('conn.wfPickUsage'));
|
||||
if (wfOutputCandidates.length > 0 && wfOutputPickedCount === 0)
|
||||
throw new Error(t('conn.wfOutputPick'));
|
||||
const corrected = Object.keys(wfRoleOverrides).length > 0;
|
||||
const schema = wfAnalysis.inputs.map((e) =>
|
||||
wfRoleOverrides[e.nodeId]
|
||||
? { ...e, role: wfRoleOverrides[e.nodeId], manual: true }
|
||||
: e,
|
||||
);
|
||||
const outputMap = wfOutputRows
|
||||
.filter((r) => r.picked)
|
||||
.map((r) => ({
|
||||
nodeId: r.nodeId,
|
||||
kind: r.kind,
|
||||
label: r.label,
|
||||
...(r.class_type ? { class_type: r.class_type } : {}),
|
||||
}));
|
||||
return workflows.create({
|
||||
name: wfImportName.trim(),
|
||||
workflow_json: wfParsed.json,
|
||||
usage: wfUsage,
|
||||
usage_source: wfUsage !== wfAnalysis.usage || corrected ? 'user' : 'auto',
|
||||
input_schema: schema,
|
||||
...(outputMap.length ? { output_map: outputMap, output_map_source: 'user' } : {}),
|
||||
...(wfImportEndpoint ? { endpoint_id: wfImportEndpoint } : {}),
|
||||
});
|
||||
},
|
||||
onSuccess: (wf) => {
|
||||
client.invalidateQueries({ queryKey: ['workflows'] });
|
||||
const n = wf.input_schema?.length ?? 0;
|
||||
const problems = wfProblems(wf);
|
||||
if (problems.length) toast.error(t('conn.wfImportedBroken', { detail: problems.join(';') }));
|
||||
else if (n) toast.success(t('conn.wfImportedWithUsage', { usage: usageLabel(wf.usage), n }));
|
||||
else toast.info(t('conn.wfImportedNoParams'));
|
||||
wfImportOpen = false;
|
||||
wfJsonText = '';
|
||||
wfImportName = '';
|
||||
wfStep = 'input';
|
||||
wfAnalysis = null;
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t('conn.wfImportFailed', {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function submitWfImport() {
|
||||
if (!wfSaveable || !wfImportName.trim() || $wfImportMutation.isPending) return;
|
||||
$wfImportMutation.mutate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HEL-643 L2: connection-compatibility check on the import confirm
|
||||
// step, and per-row 重新检查 / 测试 in the workflow library.
|
||||
// ------------------------------------------------------------------
|
||||
const wfConnCheck = $derived<WorkflowConnectionCheck | null>(
|
||||
wfAnalysis?.connection_check ?? null,
|
||||
);
|
||||
// HEL-643 L2: per-row 重新检查 / 测试 in the workflow library.
|
||||
let wfRecheckId = $state<number | null>(null);
|
||||
const wfRecheckResult = $state<Record<number, WorkflowConnectionCheck>>({});
|
||||
|
||||
@@ -686,15 +497,6 @@
|
||||
},
|
||||
});
|
||||
|
||||
async function onWfFile(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
wfJsonText = await file.text();
|
||||
if (!wfImportName.trim()) wfImportName = file.name.replace(/\.json$/i, '');
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
const wfDeleteMutation = createMutation({
|
||||
mutationFn: (id: number) => workflows.delete(id),
|
||||
onSuccess: () => {
|
||||
@@ -921,296 +723,6 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if wfImportOpen}
|
||||
<div class="wf-import" transition:fade={{ duration: 180 }}>
|
||||
<div class="wf-import-head">
|
||||
<h3>
|
||||
{wfStep === 'input' ? t('conn.wfImportTitle') : t('conn.wfConfirmTitle')}
|
||||
<span class="wf-step-tag">
|
||||
{wfStep === 'input' ? `1 / 2 · ${t('conn.wfStepAnalyze')}` : `2 / 2 · ${t('conn.wfStepConfirm')}`}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t('conn.formClose')}
|
||||
onclick={() => (wfImportOpen = false)}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if wfStep === 'input'}
|
||||
<p class="wf-import-hint">{t('conn.wfImportHint')}</p>
|
||||
<div class="wf-import-grid">
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('conn.fName')}<em>*</em></span>
|
||||
<input
|
||||
class="f-input"
|
||||
value={wfImportName}
|
||||
oninput={(e) => (wfImportName = e.currentTarget.value)}
|
||||
placeholder="Qwen Image 2.1 文生图"
|
||||
/>
|
||||
</label>
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t('conn.wfConnection')}</span>
|
||||
<select
|
||||
class="f-input"
|
||||
value={wfImportEndpoint}
|
||||
onchange={(e) => (wfImportEndpoint = e.currentTarget.value)}
|
||||
>
|
||||
<option value="">{t('conn.wfConnectionActive')}</option>
|
||||
{#each endpoints as ep (ep.id)}
|
||||
<option value={ep.id}>{ep.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="f-field">
|
||||
<span class="f-label">API JSON<em>*</em></span>
|
||||
<div class="wf-json-row">
|
||||
<textarea
|
||||
class="f-input wf-json"
|
||||
class:invalid={wfJsonText.trim() !== '' && !wfParsed.valid}
|
||||
spellcheck="false"
|
||||
rows="3"
|
||||
value={wfJsonText}
|
||||
oninput={(e) => (wfJsonText = e.currentTarget.value)}
|
||||
placeholder={t('conn.wfJsonPh')}
|
||||
></textarea>
|
||||
<label class="file-btn">
|
||||
<Icon name="upload" size={14} />
|
||||
{t('conn.wfChooseFile')}
|
||||
<input type="file" accept=".json,application/json" onchange={onWfFile} />
|
||||
</label>
|
||||
</div>
|
||||
{#if wfJsonText.trim() !== '' && !wfParsed.valid}
|
||||
<span class="f-error">{t('conn.wfJsonInvalid')}</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<span class="need-test" class:passed={wfParsed.valid && wfImportName.trim() !== ''}>
|
||||
{wfParsed.valid
|
||||
? t('conn.wfNodes', { n: Object.keys(wfParsed.json ?? {}).length })
|
||||
: 'JSON'}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!wfParsed.valid || !wfImportName.trim()}
|
||||
loading={wfAnalyzing}
|
||||
onclick={() => runWfAnalyze()}
|
||||
>
|
||||
{wfAnalyzing ? t('conn.wfAnalyzing') : t('conn.wfAnalyze')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else if wfAnalysis}
|
||||
<p class="wf-import-hint">{t('conn.wfConfirmHint')}</p>
|
||||
|
||||
{#if wfAnalysis.usage_problem}
|
||||
<div class="wf-note" class:fatal={wfFatal} role={wfFatal ? 'alert' : 'note'}>
|
||||
<Icon name={wfFatal ? 'alert' : 'info'} size={14} />
|
||||
<p>{wfAnalysis.usage_problem}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if wfAnalyzeError}
|
||||
<div class="wf-note fatal" role="alert">
|
||||
<Icon name="alert" size={14} />
|
||||
<p>{wfAnalyzeError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if wfConnCheck}
|
||||
<div class="wf-conn-check" class:ok={wfConnCheck.ok} class:bad={wfConnCheck.ok === false && wfConnCheck.reachable} class:warn={!wfConnCheck.reachable}>
|
||||
<div class="wf-conn-head">
|
||||
<Icon name={wfConnCheck.ok ? 'check' : wfConnCheck.reachable ? 'alert' : 'info'} size={14} />
|
||||
<span class="wf-conn-title">{t('conn.wfCheckTitle')}</span>
|
||||
{#if wfConnCheck.endpoint_name}
|
||||
<span class="wf-conn-ep">{t('conn.wfCheckEndpoint', { name: wfConnCheck.endpoint_name })}</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="mini-link"
|
||||
disabled={wfAnalyzing}
|
||||
onclick={() => runWfAnalyze(true)}
|
||||
>
|
||||
{wfAnalyzing ? t('conn.wfChecking') : t('conn.wfCheckBtn')}
|
||||
</button>
|
||||
</div>
|
||||
{#if !wfConnCheck.reachable}
|
||||
<p class="wf-conn-line">{wfConnCheck.message ?? t('conn.wfCheckUnreachable')}</p>
|
||||
{:else if wfConnCheck.ok}
|
||||
<p class="wf-conn-line">{t('conn.wfCheckOk')}</p>
|
||||
{:else}
|
||||
<ul class="wf-conn-findings">
|
||||
{#each wfConnCheck.findings as f (f.node_id + f.code)}
|
||||
<li>
|
||||
<span class="wf-conn-msg">{f.message}</span>
|
||||
{#if f.hint}<span class="wf-conn-hint">{f.hint}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="wf-conn-line">{t('conn.wfCheckSaveHint')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="wf-evidence">
|
||||
{#each wfAnalysis.usage_evidence.summary as line (line)}
|
||||
<li>{line}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="f-field">
|
||||
<span class="f-label">
|
||||
{t('conn.wfUsageLabel')}<em>*</em>
|
||||
<span class="f-hint-inline">
|
||||
{#if wfAnalysis.usage}
|
||||
{t('conn.wfUsageAuto', { usage: usageLabel(wfAnalysis.usage) })}
|
||||
{:else}
|
||||
{t('conn.wfUsageNeedPick')}
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
<div class="seg wrap" role="radiogroup">
|
||||
{#each WORKFLOW_USAGES as u (u)}
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:selected={wfUsage === u}
|
||||
aria-pressed={wfUsage === u}
|
||||
onclick={() => (wfUsage = u)}
|
||||
>
|
||||
{usageLabel(u)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if wfMustPick}<span class="f-error">{t('conn.wfPickUsage')}</span>{/if}
|
||||
</div>
|
||||
|
||||
{#if wfOutputRows.length}
|
||||
<div class="f-field">
|
||||
<span class="f-label">
|
||||
{t('conn.wfOutputLabel')}<em>*</em>
|
||||
<span class="f-hint-inline">{t('conn.wfOutputNeedPick')}</span>
|
||||
</span>
|
||||
<div class="wf-slot-rows">
|
||||
{#each wfOutputRows as row (row.nodeId)}
|
||||
<div class="wf-slot-row" class:off={!row.picked}>
|
||||
<label class="wf-out-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={row.picked}
|
||||
onchange={(e) =>
|
||||
(wfOutputPicked = {
|
||||
...wfOutputPicked,
|
||||
[row.nodeId]: e.currentTarget.checked,
|
||||
})}
|
||||
/>
|
||||
<span class="wf-slot-id mono">{row.nodeId}</span>
|
||||
<span class="wf-slot-name" title={row.class_type}>{row.label}</span>
|
||||
</label>
|
||||
<select
|
||||
class="a-select"
|
||||
aria-label={t('conn.wfOutputKindAria')}
|
||||
disabled={!row.picked}
|
||||
value={row.kind}
|
||||
onchange={(e) =>
|
||||
(wfOutputKind = {
|
||||
...wfOutputKind,
|
||||
[row.nodeId]: e.currentTarget.value as 'image' | 'video',
|
||||
})}
|
||||
>
|
||||
<option value="image">{t('conn.wfKindImage')}</option>
|
||||
<option value="video">{t('conn.wfKindVideo')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="f-hint">{t('conn.wfOutputHint')}</span>
|
||||
{#if wfOutputPickedCount === 0}
|
||||
<span class="f-error">{t('conn.wfOutputPick')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if wfSlotRows.length}
|
||||
<div class="f-field">
|
||||
<span class="f-label">{t('conn.wfSlotsLabel')}</span>
|
||||
<div class="wf-slot-rows">
|
||||
{#each wfSlotRows as row (row.nodeId)}
|
||||
<div class="wf-slot-row">
|
||||
<span class="wf-slot-id mono">{row.nodeId}</span>
|
||||
<span class="wf-slot-name">{row.label}</span>
|
||||
{#if row.fixed}
|
||||
<span class="tag tag-usage">{t('wfRole.reference_image')}</span>
|
||||
{:else}
|
||||
<select
|
||||
class="a-select"
|
||||
value={row.role}
|
||||
onchange={(e) =>
|
||||
(wfRoleOverrides = {
|
||||
...wfRoleOverrides,
|
||||
[row.nodeId]: e.currentTarget.value,
|
||||
})}
|
||||
>
|
||||
{#each ['image', 'first_frame', 'last_frame', 'reference_image', 'mask'] as r (r)}
|
||||
<option value={r}>{t(`wfRole.${r}`)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="f-hint">{t('conn.wfSlotsHint')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="wf-params">
|
||||
{t('conn.wfParams', { n: wfParamPreview.length })}
|
||||
{#if wfParamPreview.length}
|
||||
<span class="wf-params-list">{wfParamPreview.slice(0, 8).join(' · ')}{wfParamPreview.length > 8 ? ' …' : ''}</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if (wfAnalysis.warnings ?? []).length}
|
||||
<ul class="wf-warnings">
|
||||
{#each wfAnalysis.warnings as w (w.node_id + w.code)}
|
||||
<li class:err={w.level === 'error'}>{w.message}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={$wfImportMutation.isPending}
|
||||
onclick={() => (wfStep = 'input')}
|
||||
>
|
||||
{t('conn.wfBack')}
|
||||
</Button>
|
||||
<span class="need-test" class:passed={wfSaveable && wfImportName.trim() !== ''}>
|
||||
{wfUsage
|
||||
? wfOutputCandidates.length && wfOutputPickedCount === 0
|
||||
? t('conn.wfOutputPick')
|
||||
: usageLabel(wfUsage)
|
||||
: t('conn.wfUsageNeedPick')}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!wfSaveable || !wfImportName.trim()}
|
||||
loading={$wfImportMutation.isPending}
|
||||
onclick={submitWfImport}
|
||||
>
|
||||
{$wfImportMutation.isPending ? t('conn.wfImporting') : t('conn.wfConfirmSave')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ($workflowsQuery.data ?? []).length === 0}
|
||||
<EmptyState title={t('conn.wfEmpty')} />
|
||||
<p class="missing-note">{t('conn.missingMaterial')}</p>
|
||||
@@ -1592,6 +1104,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<WorkflowImportDrawer
|
||||
bind:open={wfImportOpen}
|
||||
{endpoints}
|
||||
onchanged={() => client.invalidateQueries({ queryKey: ['workflows'] })}
|
||||
/>
|
||||
<WorkflowTestDialog
|
||||
bind:open={testOpen}
|
||||
workflow={testWf}
|
||||
@@ -1640,10 +1157,10 @@
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
/* ---------------- connection cards ---------------- */
|
||||
/* ---------------- connection cards (§3.7 卡片网格) ---------------- */
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.card {
|
||||
@@ -1821,17 +1338,19 @@
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* 次级文字操作走 ghost(HEL-649 v2 §1.2):默认 --text-2,悬停 --text,
|
||||
不用金色文字链接。金色聚焦 outline 由全局 :focus-visible 提供。 */
|
||||
.link-accent {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.link-accent:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notice {
|
||||
@@ -1899,139 +1418,6 @@
|
||||
padding-left: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.wf-import {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 9px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 14px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.wf-step-tag {
|
||||
margin-left: 8px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0 0 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 7px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wf-note :global(.icon) {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wf-note.fatal {
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.wf-note.fatal :global(.icon) {
|
||||
color: var(--error);
|
||||
}
|
||||
.wf-note p {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wf-conn-check {
|
||||
margin: 0 0 12px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 7px;
|
||||
background: var(--surface-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wf-conn-check.ok {
|
||||
border-color: var(--success);
|
||||
}
|
||||
.wf-conn-check.bad {
|
||||
border-color: var(--error);
|
||||
}
|
||||
.wf-conn-check.warn {
|
||||
border-color: var(--warn);
|
||||
}
|
||||
.wf-conn-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wf-conn-head :global(.icon) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-conn-check.ok .wf-conn-head :global(.icon) {
|
||||
color: var(--success);
|
||||
}
|
||||
.wf-conn-check.bad .wf-conn-head :global(.icon) {
|
||||
color: var(--error);
|
||||
}
|
||||
.wf-conn-check.warn .wf-conn-head :global(.icon) {
|
||||
color: var(--warn);
|
||||
}
|
||||
.wf-conn-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wf-conn-ep {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.mini-link {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-link:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.wf-conn-line {
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wf-conn-findings {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.wf-conn-findings li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--error-dim);
|
||||
}
|
||||
.wf-conn-msg {
|
||||
font-size: 11.5px;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-conn-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-test-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2066,30 +1452,6 @@
|
||||
.wf-meta-item.recheck.warn .wf-recheck-text {
|
||||
color: var(--warn);
|
||||
}
|
||||
.wf-evidence {
|
||||
margin: 0 0 12px;
|
||||
padding: 0 0 0 4px;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.wf-evidence li {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-evidence li::before {
|
||||
content: '·';
|
||||
margin-right: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.seg.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.seg.wrap .seg-item {
|
||||
flex: 1 0 30%;
|
||||
}
|
||||
.wf-slot-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2172,28 +1534,6 @@
|
||||
font-size: 11.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-params-list {
|
||||
margin-left: 6px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wf-warnings {
|
||||
margin: 0 0 10px;
|
||||
padding: 8px 12px;
|
||||
list-style: none;
|
||||
border-radius: 7px;
|
||||
background: var(--error-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.wf-warnings li {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.wf-warnings li.err {
|
||||
color: var(--error);
|
||||
}
|
||||
.tag-usage {
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
@@ -2212,67 +1552,6 @@
|
||||
max-width: 150px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.wf-import-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.wf-import-head h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.wf-import-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.wf-import-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 14px;
|
||||
}
|
||||
.wf-import-grid .f-field:nth-child(3) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.wf-json-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.wf-json {
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wf-json.invalid {
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.file-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.file-btn input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wf-table {
|
||||
display: flex;
|
||||
@@ -2373,12 +1652,12 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.target-link {
|
||||
color: var(--accent);
|
||||
color: var(--text-2);
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.target-link:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--text);
|
||||
}
|
||||
.target-note {
|
||||
margin: 10px 0 0;
|
||||
@@ -2469,12 +1748,6 @@
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.f-hint-inline {
|
||||
margin-left: auto;
|
||||
font-size: 10.5px;
|
||||
font-weight: 400;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.f-error {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
@@ -2648,5 +1921,4 @@
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
}</style>
|
||||
|
||||
+15
-4
@@ -1,9 +1,16 @@
|
||||
"""Standalone fake ComfyUI for HEL-654 D-batch browser validation.
|
||||
"""Standalone fake ComfyUI for HEL-654 D-batch / HEL-655 E-batch browser validation.
|
||||
|
||||
Mode file e2e/fake_mode.json: {"mode": "ok"|"fail", "delay_sec": 0}
|
||||
Mode file e2e/fake_mode.json:
|
||||
{"mode": "ok"|"fail", "delay_sec": 0, "videos": false,
|
||||
"object_info": {class_type: {...}}, "no_outputs": false}
|
||||
- ok + delay 0: history is ready immediately (job completes fast)
|
||||
- ok + delay N: history appears N seconds after /prompt (progress state)
|
||||
- fail: /prompt returns 500 (job fails with a Chinese error from the worker)
|
||||
- object_info: when present, /object_info serves this catalogue verbatim —
|
||||
the E-batch script builds it from the workflow it imports so the L2 check
|
||||
can be driven all-green (every class present) or missing-node (catalogue
|
||||
omits classes). Absent → {} (every node "missing").
|
||||
- no_outputs: history entries complete with no files (test-run no-output pass)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json, threading, uuid
|
||||
@@ -39,7 +46,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"devices": [{"name": "FakeGPU", "type": "cuda"}]})
|
||||
return
|
||||
if self.path == "/object_info":
|
||||
self._json(200, {})
|
||||
self._json(200, read_mode().get("object_info") or {})
|
||||
return
|
||||
if self.path == "/queue":
|
||||
# Prompts accepted but not yet in history: first one "running",
|
||||
@@ -74,11 +81,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
pid = uuid.uuid4().hex
|
||||
self.server.accepted.append(pid)
|
||||
videos = mode.get("videos") is True
|
||||
no_outputs = mode.get("no_outputs") is True
|
||||
out_name = "out-1.mp4" if videos else "out-1.png"
|
||||
key = "videos" if videos else "images"
|
||||
outputs = {} if no_outputs else {
|
||||
self._output_node(body): {key: [{"filename": out_name, "subfolder": "", "type": "output"}]}
|
||||
}
|
||||
entry = {
|
||||
"status": {"status_str": "success", "completed": True},
|
||||
"outputs": {self._output_node(body): {key: [{"filename": out_name, "subfolder": "", "type": "output"}]}},
|
||||
"outputs": outputs,
|
||||
"prompt": [],
|
||||
}
|
||||
delay = float(mode.get("delay_sec") or 0)
|
||||
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
/* HEL-655 E 批 browser validation against the isolated stack:
|
||||
* vite dev (5173) → backend (8247) → fake ComfyUI (18188).
|
||||
* Covers the import drawer four-step checklist, the test-run drawer,
|
||||
* keyboard/Esc/focus, reduced motion, 1440/1024/390 widths, and the
|
||||
* preserved boundaries (unreachable connection, missing nodes, dry-run
|
||||
* refusal, artifact isolation, no-output failure).
|
||||
* Prints PASS/FAIL per check; screenshots land in e2e/shots/hel655-*.png. */
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SHOTS = path.join(ROOT, "shots");
|
||||
fs.mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
const API = "http://127.0.0.1:8247";
|
||||
const MODE_FILE = path.join(ROOT, "fake_mode.json");
|
||||
const setMode = (m) => fs.writeFileSync(MODE_FILE, JSON.stringify(m));
|
||||
const baseMode = () => ({ mode: "ok", delay_sec: 0 });
|
||||
|
||||
let failures = 0;
|
||||
function ok(name, cond, extra = "") {
|
||||
const line = `${cond ? "PASS" : "FAIL"} ${name}${extra ? " — " + extra : ""}`;
|
||||
console.log(line);
|
||||
if (!cond) failures++;
|
||||
}
|
||||
|
||||
const api = async (p, init) => {
|
||||
const r = await fetch(API + p, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
const body = await r.text();
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
return { raw: body, status: r.status };
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------- workflow fixtures ---------------- */
|
||||
const WF_NAME = `E批·文生图回归 ${Date.now() % 10000}`;
|
||||
const T2I = {
|
||||
1: {
|
||||
class_type: "CLIPTextEncode",
|
||||
inputs: { text: "默认提示词" },
|
||||
_meta: { title: "(Input:prompt) 正向提示词" },
|
||||
},
|
||||
9: {
|
||||
class_type: "SaveImage",
|
||||
inputs: { images: "x" },
|
||||
_meta: { title: "(Output:image) 图片输出" },
|
||||
},
|
||||
};
|
||||
|
||||
/** Self-consistent /object_info catalogue for a workflow (backend parity:
|
||||
* every class present, every string widget a combo listing its value). */
|
||||
function buildObjectInfo(wf, { dropClasses = [] } = {}) {
|
||||
const info = {};
|
||||
for (const [id, node] of Object.entries(wf)) {
|
||||
const cls = node.class_type;
|
||||
if (!cls || dropClasses.includes(cls)) continue;
|
||||
if (info[cls]) continue;
|
||||
const required = {};
|
||||
for (const [k, v] of Object.entries(node.inputs ?? {})) {
|
||||
if (typeof v === "string") required[k] = [[v], {}];
|
||||
else if (typeof v === "number")
|
||||
required[k] = Number.isInteger(v)
|
||||
? ["INT", { min: 0, max: 2 ** 31 }]
|
||||
: ["FLOAT", { min: 0, max: 1e6 }];
|
||||
}
|
||||
info[cls] = { input: { required }, output: ["IMAGE"] };
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
/* ---------------- stack prep ---------------- */
|
||||
const seeded = (async () => {
|
||||
// one fake endpoint + one imported t2i workflow bound to it
|
||||
for (const e of (await api("/api/comfyui/endpoints")).endpoints ?? []) {
|
||||
if (e.base_url === "http://127.0.0.1:18188")
|
||||
await api(`/api/comfyui/endpoints/${e.id}`, { method: "DELETE" });
|
||||
}
|
||||
let r = await api("/api/comfyui/endpoints", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "假 ComfyUI",
|
||||
base_url: "http://127.0.0.1:18188",
|
||||
auth_type: "none",
|
||||
}),
|
||||
});
|
||||
const ep = r.endpoints.find((e) => e.base_url === "http://127.0.0.1:18188");
|
||||
await api(`/api/comfyui/endpoints/${ep.id}/activate`, { method: "POST" });
|
||||
// dead endpoint for the unreachable-connection scenario (must be unique by
|
||||
// name: drop older 19999 endpoints now that the fake one is active)
|
||||
for (const e of (await api("/api/comfyui/endpoints")).endpoints ?? []) {
|
||||
if (e.base_url === "http://127.0.0.1:19999")
|
||||
await api(`/api/comfyui/endpoints/${e.id}`, { method: "DELETE" });
|
||||
}
|
||||
await api("/api/comfyui/endpoints", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "断开的连接",
|
||||
base_url: "http://127.0.0.1:19999",
|
||||
auth_type: "none",
|
||||
}),
|
||||
});
|
||||
return { endpointId: ep.id };
|
||||
})();
|
||||
|
||||
/* The connections page keeps its own add-connection side form open when the
|
||||
* viewport shrinks below 1439 — close it so it doesn't cover the library. */
|
||||
async function closeSideFormIfOpen(page) {
|
||||
const close = page.locator(".form-col.open .form-head .icon-btn");
|
||||
if ((await page.locator(".form-col.open").count()) === 0) return;
|
||||
if ((await close.count()) > 0) {
|
||||
await close.first().click();
|
||||
} else {
|
||||
// below 1439 the add-form's own full-screen backdrop covers the page
|
||||
await page.mouse.click(20, 400);
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const { endpointId } = await seeded;
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
page.on("pageerror", (e) => console.log("PAGEERROR:", e.message));
|
||||
|
||||
await page.goto("http://127.0.0.1:5173/connections");
|
||||
await page.waitForSelector(".wf-panel", { timeout: 15000 });
|
||||
|
||||
// ---------- 1. 导入入口统一:按钮打开右侧抽屉 ----------
|
||||
await page
|
||||
.getByRole("button", { name: /导入工作流/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
const panel = page.locator(".drawer-panel");
|
||||
ok(
|
||||
"导入抽屉从右侧滑入(挂载即向左滑动)",
|
||||
await panel.evaluate((el) => {
|
||||
const name = getComputedStyle(el).animationName;
|
||||
return name.includes("drawer-slide") || name === "none";
|
||||
}),
|
||||
);
|
||||
await page.waitForTimeout(360); // let the 280ms drawer settle
|
||||
const box = await panel.boundingBox();
|
||||
ok("抽屉宽度 380px", Math.round(box.width) === 380, `w=${box.width}`);
|
||||
ok("抽屉贴右缘", Math.abs(box.x + box.width - 1440) < 2, `x=${box.x}`);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-01-import-input-1440.png` });
|
||||
|
||||
// ---------- 2. Step1:无效 JSON 的中文结论 ----------
|
||||
await page
|
||||
.locator(".drawer-panel textarea.a-textarea")
|
||||
.fill('{"不是":"API 格式"}');
|
||||
ok(
|
||||
"无效 JSON 显示中文错误",
|
||||
await page
|
||||
.locator(".drawer-panel .field-error")
|
||||
.textContent()
|
||||
.then((s) => s.includes("API 格式")),
|
||||
);
|
||||
ok(
|
||||
"无效 JSON 时「分析」禁用",
|
||||
await page.locator(".drawer-panel .a-btn.primary").isDisabled(),
|
||||
);
|
||||
await page
|
||||
.locator(".drawer-panel textarea.a-textarea")
|
||||
.fill(JSON.stringify(T2I));
|
||||
await page.locator(".drawer-panel input.a-input").fill(WF_NAME);
|
||||
await page
|
||||
.locator(".drawer-panel select.a-select")
|
||||
.selectOption({ label: "假 ComfyUI" });
|
||||
|
||||
// ---------- 3. 四步检查 · 全绿 ----------
|
||||
setMode({ ...baseMode(), object_info: buildObjectInfo(T2I) });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click();
|
||||
await page.waitForSelector(".drawer-panel .checks .check");
|
||||
const t0 = Date.now();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll(".drawer-panel .checks .check").length === 4,
|
||||
);
|
||||
const spread = Date.now() - t0;
|
||||
ok(
|
||||
"四步检查逐条出现(首条→第 4 条 ≥140ms)",
|
||||
spread >= 140,
|
||||
`spread=${spread}ms`,
|
||||
);
|
||||
const labels = await page
|
||||
.locator(".drawer-panel .checks .check-label")
|
||||
.allTextContents();
|
||||
ok(
|
||||
"四步顺序:解析 JSON→识别节点→核对模型→建立输入槽",
|
||||
labels[0].includes("解析") &&
|
||||
labels[1].includes("识别") &&
|
||||
labels[2].includes("核对") &&
|
||||
labels[3].includes("输入槽"),
|
||||
labels.join("|"),
|
||||
);
|
||||
const states = await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.evaluateAll((els) =>
|
||||
els.map((el) => ({
|
||||
ok: el.className.includes("state-ok"),
|
||||
color: getComputedStyle(el.querySelector(".mark")).color,
|
||||
})),
|
||||
);
|
||||
ok(
|
||||
"① 解析 JSON 绿 ✓",
|
||||
states[0].ok && states[0].color === "rgb(73, 203, 144)",
|
||||
states[0].color,
|
||||
);
|
||||
ok(
|
||||
"② 识别节点判定文生图(绿)",
|
||||
states[1].ok,
|
||||
await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(1)
|
||||
.locator(".check-line")
|
||||
.textContent(),
|
||||
);
|
||||
ok(
|
||||
"③ 核对模型全绿(假 object_info 装齐)",
|
||||
states[2].ok && states[2].color === "rgb(73, 203, 144)",
|
||||
states[2].color,
|
||||
);
|
||||
ok("④ 建立输入槽(1 参数 0 媒体)", states[3].ok);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/hel655-02-checks-allgreen-1440.png`,
|
||||
});
|
||||
|
||||
// ---------- 4. 缺节点:③ 红 + 可展开错误 + 重试恢复 ----------
|
||||
setMode({
|
||||
...baseMode(),
|
||||
object_info: buildObjectInfo(T2I, { dropClasses: ["SaveImage"] }),
|
||||
});
|
||||
await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(2)
|
||||
.locator(".recheck")
|
||||
.click();
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelectorAll(".drawer-panel .checks .check")[2];
|
||||
return el && el.className.includes("state-fail");
|
||||
});
|
||||
const failLine = await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(2)
|
||||
.locator(".check-line")
|
||||
.textContent();
|
||||
ok("③ 缺节点显示中文问题数", /1/.test(failLine), failLine);
|
||||
const finding = await page
|
||||
.locator(".drawer-panel .findings li .finding-msg")
|
||||
.first()
|
||||
.textContent();
|
||||
ok(
|
||||
"错误详情可展开且为中文原因",
|
||||
finding.includes("缺少自定义节点"),
|
||||
finding.slice(0, 40),
|
||||
);
|
||||
ok(
|
||||
"给出下一步建议",
|
||||
(
|
||||
await page
|
||||
.locator(".drawer-panel .checks .check .suggest")
|
||||
.allTextContents()
|
||||
).some((s) => s.includes("重新检查")),
|
||||
);
|
||||
ok(
|
||||
"缺节点不阻断保存(沿用既有边界)",
|
||||
!(await page.locator(".drawer-panel .a-btn.primary").last().isDisabled()),
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-03-missing-node-1440.png` });
|
||||
setMode({ ...baseMode(), object_info: buildObjectInfo(T2I) });
|
||||
await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(2)
|
||||
.locator(".recheck")
|
||||
.click();
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelectorAll(".drawer-panel .checks .check")[2];
|
||||
return el && el.className.includes("state-ok");
|
||||
});
|
||||
ok("「重新检查」复查后恢复绿", true);
|
||||
|
||||
// ---------- 5. 断开的连接:琥珀「无法核查」,不伪装通过 ----------
|
||||
await page.locator(".drawer-panel .a-btn.secondary").first().click(); // 上一步
|
||||
await page
|
||||
.locator(".drawer-panel select.a-select")
|
||||
.selectOption({ label: "断开的连接" });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click(); // 重新分析
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll(".drawer-panel .checks .check").length === 4,
|
||||
);
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelectorAll(".drawer-panel .checks .check")[2];
|
||||
return el && el.className.includes("state-warn");
|
||||
});
|
||||
const warnColor = await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(2)
|
||||
.locator(".mark")
|
||||
.evaluate((el) => getComputedStyle(el).color);
|
||||
ok(
|
||||
"无法核查 = 琥珀(非绿非红)",
|
||||
warnColor === "rgb(240, 180, 92)",
|
||||
warnColor,
|
||||
);
|
||||
ok(
|
||||
"无法核查给中文原因与建议",
|
||||
(
|
||||
await page
|
||||
.locator(".drawer-panel .checks .check")
|
||||
.nth(2)
|
||||
.locator(".check-line")
|
||||
.textContent()
|
||||
).includes("无法核查"),
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-04-unreachable-1440.png` });
|
||||
|
||||
// ---------- 6. 保存导入(回到可用连接) ----------
|
||||
await page.locator(".drawer-panel .a-btn.secondary").first().click(); // 上一步
|
||||
await page
|
||||
.locator(".drawer-panel select.a-select")
|
||||
.selectOption({ label: "假 ComfyUI" });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click(); // 分析
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelectorAll(".drawer-panel .checks .check").length === 4,
|
||||
);
|
||||
await page.locator(".drawer-panel .a-btn.primary").last().click(); // 确认保存
|
||||
await page.waitForSelector(".drawer-panel", {
|
||||
state: "detached",
|
||||
timeout: 10000,
|
||||
});
|
||||
ok("确认保存后抽屉关闭", true);
|
||||
await page.waitForFunction(
|
||||
(n) => document.body.textContent.includes(n),
|
||||
WF_NAME,
|
||||
);
|
||||
ok("工作流入库名称正确", true);
|
||||
|
||||
// ---------- 7. 试运行抽屉:全链路(进行中→完成→隔离) ----------
|
||||
const wfId = (await api("/api/workflows")).find(
|
||||
(w) => w.name === WF_NAME,
|
||||
)?.id;
|
||||
ok("已导入工作流可查询", !!wfId);
|
||||
const row = page.locator(".wf-row", { hasText: WF_NAME });
|
||||
await row.locator(".wf-test-btn").first().click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
const tbox = await page.locator(".drawer-panel").boundingBox();
|
||||
ok(
|
||||
"试运行抽屉 420px 右侧",
|
||||
Math.round(tbox.width) === 420,
|
||||
`w=${tbox.width}`,
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-05-test-form-1440.png` });
|
||||
|
||||
// dry_run 拒绝(边界保留)
|
||||
await api("/api/settings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dry_run: true }),
|
||||
});
|
||||
await page.locator(".drawer-panel .a-btn.primary").click();
|
||||
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
|
||||
await page.waitForFunction(() => {
|
||||
const els = [...document.querySelectorAll(".drawer-panel .field-error")];
|
||||
return els.some((e) => e.textContent.includes("Dry-run"));
|
||||
});
|
||||
ok("dry_run 下试运行被拒绝(中文原因)", true);
|
||||
await api("/api/settings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dry_run: false }),
|
||||
});
|
||||
|
||||
// 真实试运行:进行中(青进度 + 阶段日志)→ 通过(绿 + 缩略图横排)
|
||||
setMode({ mode: "ok", delay_sec: 3, object_info: buildObjectInfo(T2I) });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click();
|
||||
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
|
||||
await page.waitForSelector(".drawer-panel .wft-active", { timeout: 8000 });
|
||||
await page.waitForSelector(".drawer-panel .wft-active .chip.run", {
|
||||
timeout: 8000,
|
||||
});
|
||||
const chipColor = await page
|
||||
.locator(".drawer-panel .wft-active .chip.run")
|
||||
.evaluate((el) => getComputedStyle(el).color);
|
||||
ok("进行中状态为信号青", chipColor === "rgb(84, 223, 210)", chipColor);
|
||||
ok(
|
||||
"进行中显示进度条",
|
||||
(await page.locator(".drawer-panel .wft-active .track").count()) === 1,
|
||||
);
|
||||
await page.waitForSelector(".drawer-panel .wft-log p", { timeout: 6000 });
|
||||
const logText = await page.locator(".drawer-panel .wft-log").textContent();
|
||||
ok(
|
||||
"阶段日志为中文滚动区",
|
||||
/队列|排队|连接|运行|保存|执行|ComfyUI/.test(logText),
|
||||
logText.slice(0, 30),
|
||||
);
|
||||
ok(
|
||||
"阶段日志为 mono 字体",
|
||||
await page
|
||||
.locator(".drawer-panel .wft-log")
|
||||
.evaluate((el) => getComputedStyle(el).fontFamily.includes("Mono")),
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-06-test-running-1440.png` });
|
||||
await page.waitForSelector(".drawer-panel .wft-result.ok", {
|
||||
timeout: 30000,
|
||||
});
|
||||
const verdict = await page
|
||||
.locator(".drawer-panel .wft-verdict")
|
||||
.textContent();
|
||||
ok("只有真实输出才算通过(绿判定)", verdict.includes("测试通过"), verdict);
|
||||
const thumbs = await page
|
||||
.locator(".drawer-panel .wft-outputs .wft-out")
|
||||
.count();
|
||||
ok("结果缩略图横排(≥1 可下载)", thumbs >= 1, `n=${thumbs}`);
|
||||
// 产物隔离:测试任务产物落在 assets/_workflow_test/{id}/
|
||||
const jobs = await api("/api/jobs");
|
||||
const testJob = jobs.find(
|
||||
(j) =>
|
||||
j.workflow_id === wfId &&
|
||||
j.status === "done" &&
|
||||
(j.output_paths ?? []).length,
|
||||
);
|
||||
ok(
|
||||
"试运行产物与普通素材隔离(_workflow_test/)",
|
||||
!!testJob && (testJob.output_paths ?? [])[0].includes("_workflow_test"),
|
||||
testJob?.output_paths?.[0],
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-07-test-passed-1440.png` });
|
||||
|
||||
// ---------- 8. 失败路径:执行报错 红 + 中文 ----------
|
||||
setMode({ mode: "fail" });
|
||||
await page.waitForSelector(".drawer-panel .wft-form", { timeout: 10000 });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click();
|
||||
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
|
||||
await page.waitForSelector(".drawer-panel .wft-result.bad", {
|
||||
timeout: 30000,
|
||||
});
|
||||
const badColor = await page
|
||||
.locator(".drawer-panel .wft-result.bad .wft-verdict")
|
||||
.evaluate((el) => getComputedStyle(el).color);
|
||||
ok(
|
||||
"失败判定为红 + 中文标题",
|
||||
badColor === "rgb(240, 100, 90)",
|
||||
await page
|
||||
.locator(".drawer-panel .wft-result.bad .wft-verdict")
|
||||
.textContent(),
|
||||
);
|
||||
ok(
|
||||
"失败给出具体原因",
|
||||
((await page.locator(".drawer-panel .wft-error").textContent()) ?? "")
|
||||
.length > 0,
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-08-test-failed-1440.png` });
|
||||
|
||||
// ---------- 9. 无输出:不算通过 ----------
|
||||
setMode({ mode: "ok", delay_sec: 0, no_outputs: true });
|
||||
await page.waitForSelector(".drawer-panel .wft-form", { timeout: 10000 });
|
||||
await page.locator(".drawer-panel .a-btn.primary").click();
|
||||
await page.locator(".modal-panel .modal-footer .a-btn.primary").click();
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const bad = document.querySelector(".drawer-panel .wft-result.bad");
|
||||
if (!bad) return false;
|
||||
// the previous fail verdict also matches .bad — wait until the error text changes
|
||||
return (
|
||||
!bad.textContent.includes("boom") || bad.textContent.includes("输出")
|
||||
);
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
ok("无输出不判通过(红/失败结论)", true);
|
||||
setMode({ ...baseMode() });
|
||||
|
||||
// ---------- 10. 键盘:Esc 关闭、焦点圈、焦点回归 ----------
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".drawer-panel", { state: "detached" });
|
||||
ok("Esc 关闭试运行抽屉", true);
|
||||
await row.locator(".wf-test-btn").first().click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
await page.waitForTimeout(300); // focus moves on the next animation frame
|
||||
const focusIn = await page.evaluate(
|
||||
() => document.activeElement.closest(".drawer-panel") !== null,
|
||||
);
|
||||
ok("抽屉打开后焦点进入面板", focusIn);
|
||||
for (let i = 0; i < 12; i++) await page.keyboard.press("Tab");
|
||||
const stillIn = await page.evaluate(
|
||||
() => document.activeElement.closest(".drawer-panel") !== null,
|
||||
);
|
||||
ok("Tab 在抽屉内循环(焦点不逃逸)", stillIn);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".drawer-panel", { state: "detached" });
|
||||
const backOnBtn = await page.evaluate(
|
||||
() =>
|
||||
document.activeElement?.getAttribute("title") === "测试工作流" ||
|
||||
(document.activeElement?.textContent ?? "").trim().startsWith("测试"),
|
||||
);
|
||||
ok("关闭后焦点回到触发按钮", backOnBtn === true);
|
||||
|
||||
// ---------- 11. 减少动态效果:抽屉只留透明度 ----------
|
||||
await page.evaluate(() => (document.documentElement.dataset.reduce = "1"));
|
||||
await page
|
||||
.getByRole("button", { name: /导入工作流/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
const anim = await page
|
||||
.locator(".drawer-panel")
|
||||
.evaluate((el) => getComputedStyle(el).animationName);
|
||||
ok("减动效时抽屉无位移动画(仅淡变)", anim.includes("drawer-fade"), anim);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.evaluate(() => delete document.documentElement.dataset.reduce);
|
||||
|
||||
// ---------- 12. 中屏 1024 / 窄屏 390 ----------
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await closeSideFormIfOpen(page);
|
||||
await page
|
||||
.getByRole("button", { name: /导入工作流/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
await page.waitForTimeout(320);
|
||||
const mid = await page.locator(".drawer-panel").boundingBox();
|
||||
ok(
|
||||
"中屏抽屉仍 380px 贴右",
|
||||
Math.round(mid.width) === 380 && Math.abs(mid.x + mid.width - 1024) < 2,
|
||||
);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-09-import-1024.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await closeSideFormIfOpen(page);
|
||||
await page
|
||||
.getByRole("button", { name: /导入工作流/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
await page.waitForTimeout(320);
|
||||
const narrow = await page.locator(".drawer-panel").boundingBox();
|
||||
ok("窄屏抽屉全屏宽", Math.round(narrow.width) === 390, `w=${narrow.width}`);
|
||||
const overflow = await page.evaluate(
|
||||
() =>
|
||||
document.scrollingElement.scrollWidth -
|
||||
document.scrollingElement.clientWidth,
|
||||
);
|
||||
ok("窄屏无横向溢出", overflow <= 0, `overflow=${overflow}px`);
|
||||
await page.screenshot({ path: `${SHOTS}/hel655-10-import-390.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForSelector(".drawer-panel", { state: "detached" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
// ---------- 13. 试运行历史与清除 ----------
|
||||
await row.locator(".wf-test-btn").first().click();
|
||||
await page.waitForSelector(".drawer-panel");
|
||||
await page.waitForSelector(".drawer-panel .wft-rows li", { timeout: 8000 });
|
||||
const histCount = await page.locator(".drawer-panel .wft-rows li").count();
|
||||
ok("测试记录列表保留", histCount >= 2, `n=${histCount}`);
|
||||
await page.locator(".drawer-panel .link-danger").click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelectorAll(".drawer-panel .wft-rows li").length === 0,
|
||||
{ timeout: 8000 },
|
||||
);
|
||||
ok("清除测试记录生效", true);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await browser.close();
|
||||
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILURES`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
};
|
||||
|
||||
run().catch(async (e) => {
|
||||
console.error("FATAL", e?.message?.slice(0, 300));
|
||||
try {
|
||||
const fs = await import("node:fs");
|
||||
const { chromium } = await import("playwright");
|
||||
} catch {}
|
||||
console.error("FATAL", e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user