WIP: HEL-730 管理页面与工作流闭环施工稿 #4
@@ -1,37 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { language, setLanguage, type Language } from '$lib/i18n.svelte';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { language, setLanguage, type Language } from "$lib/i18n.svelte";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
let { segmented = false }: { segmented?: boolean } = $props();
|
||||
const languages: [Language, string][] = [
|
||||
["zh", "中文"],
|
||||
["en", "English"],
|
||||
["ja", "日本語"],
|
||||
["ko", "한국어"],
|
||||
["de", "Deutsch"],
|
||||
["es", "Español"],
|
||||
["fr", "Français"],
|
||||
];
|
||||
</script>
|
||||
|
||||
<select
|
||||
class="lang-switcher"
|
||||
value={language.current}
|
||||
aria-label={t('common.language')}
|
||||
onchange={(e) => setLanguage(e.currentTarget.value as Language)}
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
{#if segmented}<div class="mg-options" aria-label={t("common.language")}>
|
||||
{#each languages as [code, label]}<button
|
||||
class="mg-option"
|
||||
class:on={language.current === code}
|
||||
aria-pressed={language.current === code}
|
||||
onclick={() => setLanguage(code)}>{label}</button
|
||||
>{/each}
|
||||
</div>{:else}
|
||||
<select
|
||||
class="lang-switcher"
|
||||
value={language.current}
|
||||
aria-label={t("common.language")}
|
||||
onchange={(e) => setLanguage(e.currentTarget.value as Language)}
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.lang-switcher {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
min-height: 34px;
|
||||
}
|
||||
.lang-switcher:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
.lang-switcher {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
min-height: 34px;
|
||||
}
|
||||
.lang-switcher:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isWorkflowRunnable } from '$lib/workflowState';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { createMutation, useQueryClient } from '@tanstack/svelte-query';
|
||||
@@ -134,7 +135,7 @@
|
||||
if (index >= limit) limit = index + 24;
|
||||
});
|
||||
function runnable(m: WorkflowMeta) {
|
||||
return m.is_enabled && !!m.usage && !m.needs_mapping;
|
||||
return isWorkflowRunnable(m);
|
||||
}
|
||||
function choose(m: WorkflowMeta) {
|
||||
if (runnable(m)) onPick(m.id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isWorkflowRunnable } from '$lib/workflowState';
|
||||
import { readDeskDraft, writeDeskDraft } from './draftCache';
|
||||
import HistoryRail from './HistoryRail.svelte';
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
@@ -123,7 +124,7 @@
|
||||
const deskMetas = $derived(
|
||||
allMetas.filter(
|
||||
(m) =>
|
||||
m.is_enabled && m.usage !== null && !m.needs_mapping && m.kind === kind,
|
||||
isWorkflowRunnable(m) && m.kind === kind,
|
||||
),
|
||||
);
|
||||
const pendingCount = $derived(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isWorkflowRunnable } from '$lib/workflowState';
|
||||
import { tick } from 'svelte';
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { workflows, settings, apiErrorMessage } from '$lib/api';
|
||||
@@ -127,7 +128,7 @@
|
||||
}
|
||||
function pick(m: WorkflowMeta) {
|
||||
if (onPreview) onPreview(m.id);
|
||||
else if (m.is_enabled && !m.needs_mapping && m.usage) onPick(m.id);
|
||||
else if (isWorkflowRunnable(m)) onPick(m.id);
|
||||
}
|
||||
function move(delta: number) {
|
||||
const i = Math.max(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
let { kind = "all" }: { kind?: "all" | "image" | "video" } = $props();
|
||||
const pending = [
|
||||
{ kind: "image", name: "云端 · 聚合 API(GPT-Image-2 · Nano Banana Pro)" },
|
||||
{ kind: "video", name: "云端 · 可灵" },
|
||||
{ kind: "video", name: "云端 · 即梦 CLI" },
|
||||
];
|
||||
</script>
|
||||
|
||||
{#each pending.filter((item) => kind === "all" || item.kind === kind) as item}<article
|
||||
class="mg-card pending"
|
||||
>
|
||||
<div class="mg-row">
|
||||
<span class="mg-dot"></span><b>{item.name}</b><span class="mg-grow"
|
||||
></span><span class="mg-tag">未接入</span>
|
||||
</div>
|
||||
<div class="mg-mono">尚未接入</div>
|
||||
<div class="mg-muted">后续接入批次 · 当前不可用于生成</div>
|
||||
<div class="mg-row">
|
||||
<span class="mg-tag">0 个工作流使用</span><span class="mg-grow"
|
||||
></span><Button size="sm" disabled>等待接入</Button>
|
||||
</div>
|
||||
</article>{/each}
|
||||
@@ -0,0 +1,645 @@
|
||||
<script lang="ts">
|
||||
import CloudModels from "./CloudModels.svelte";
|
||||
import {
|
||||
createMutation,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/svelte-query";
|
||||
import { untrack } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import ConfirmDialog from "$lib/components/ui/ConfirmDialog.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
|
||||
import { comfy, settings, workflows, type ComfyEndpoint } from "$lib/api";
|
||||
import { toast } from "$lib/toast";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Data
|
||||
// ------------------------------------------------------------------
|
||||
const settingsQuery = createQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: settings.get,
|
||||
});
|
||||
// HEL-682: the library browses metadata only (view=meta) — the heavy
|
||||
// workflow_json payloads are fetched per-row inside WorkflowLibrary
|
||||
// exactly when an action needs them.
|
||||
const wfMetaQuery = createQuery({
|
||||
queryKey: ["workflows", "meta"],
|
||||
queryFn: workflows.listMeta,
|
||||
});
|
||||
|
||||
const endpoints = $derived($settingsQuery.data?.comfyui_endpoints ?? []);
|
||||
const activeId = $derived(
|
||||
$settingsQuery.data?.comfyui_active_id ?? endpoints[0]?.id ?? "",
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Local test records — presentational only; the connectivity facts
|
||||
// always come from the server-side probe.
|
||||
// ------------------------------------------------------------------
|
||||
type TestRec = {
|
||||
ok: boolean;
|
||||
ms?: number;
|
||||
version?: string;
|
||||
at: number;
|
||||
reason?: string;
|
||||
};
|
||||
const TESTS_KEY = "calliope.connections.tests";
|
||||
let tests = $state<Record<string, TestRec>>({});
|
||||
let pendingTests = $state<Record<string, boolean>>({});
|
||||
|
||||
function loadTests() {
|
||||
try {
|
||||
const raw = localStorage.getItem(TESTS_KEY);
|
||||
if (raw) tests = JSON.parse(raw) as Record<string, TestRec>;
|
||||
} catch {
|
||||
tests = {};
|
||||
}
|
||||
}
|
||||
function saveTests(map: Record<string, TestRec>) {
|
||||
tests = map;
|
||||
try {
|
||||
localStorage.setItem(TESTS_KEY, JSON.stringify(map));
|
||||
} catch {
|
||||
/* storage disabled — the page still works without persistence */
|
||||
}
|
||||
}
|
||||
function recordTest(id: string, rec: TestRec) {
|
||||
saveTests({ ...tests, [id]: rec });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Connection form (a6: right column, add / edit)
|
||||
// ------------------------------------------------------------------
|
||||
type FormMode = "idle" | "add" | "edit";
|
||||
let formMode = $state<FormMode>("idle");
|
||||
let editingId = $state<string | null>(null);
|
||||
let form = $state({
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
authType: "none" as "none" | "bearer" | "header",
|
||||
token: "",
|
||||
headerName: "",
|
||||
notes: "",
|
||||
});
|
||||
let formTest = $state<{ ok: boolean; ms?: number; version?: string } | null>(
|
||||
null,
|
||||
);
|
||||
let formTestFailure = $state<{ message: string; detail: string } | null>(
|
||||
null,
|
||||
);
|
||||
let formPending = $state(false);
|
||||
let showErrors = $state(false);
|
||||
|
||||
function resetForm() {
|
||||
formMode = "idle";
|
||||
editingId = null;
|
||||
form = {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
authType: "none",
|
||||
token: "",
|
||||
headerName: "",
|
||||
notes: "",
|
||||
};
|
||||
formTest = null;
|
||||
formTestFailure = null;
|
||||
showErrors = false;
|
||||
}
|
||||
|
||||
export function startAdd() {
|
||||
resetForm();
|
||||
formMode = "add";
|
||||
}
|
||||
|
||||
function startEdit(ep: ComfyEndpoint) {
|
||||
resetForm();
|
||||
formMode = "edit";
|
||||
editingId = ep.id;
|
||||
form = {
|
||||
name: ep.name,
|
||||
baseUrl: ep.base_url,
|
||||
authType: ep.auth_type,
|
||||
token: "",
|
||||
headerName: ep.header_name ?? "",
|
||||
notes: ep.notes ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function touchFormConnectivity() {
|
||||
formTest = null;
|
||||
formTestFailure = null;
|
||||
}
|
||||
|
||||
const urlValid = $derived.by(() => {
|
||||
try {
|
||||
const u = new URL(form.baseUrl.trim());
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const formErrors = $derived.by(() => {
|
||||
const errs: Record<string, string> = {};
|
||||
if (!form.name.trim()) errs.name = t("conn.errName");
|
||||
if (!urlValid) errs.baseUrl = t("conn.errUrl");
|
||||
if (form.authType === "header" && !form.headerName.trim())
|
||||
errs.headerName = t("conn.errHeaderName");
|
||||
return errs;
|
||||
});
|
||||
const formValid = $derived(Object.keys(formErrors).length === 0);
|
||||
|
||||
const editingEndpoint = $derived.by(() => {
|
||||
if (formMode !== "edit" || !editingId) return null;
|
||||
return endpoints.find((ep) => ep.id === editingId) ?? null;
|
||||
});
|
||||
|
||||
async function runTest() {
|
||||
showErrors = true;
|
||||
if (!formValid) return;
|
||||
formPending = true;
|
||||
formTest = null;
|
||||
formTestFailure = null;
|
||||
try {
|
||||
const res = await comfy.testEndpoint({
|
||||
...(formMode === "edit" && editingId ? { endpoint_id: editingId } : {}),
|
||||
base_url: form.baseUrl.trim(),
|
||||
auth_type: form.authType,
|
||||
...(form.authType !== "none" && form.token.trim()
|
||||
? { auth_token: form.token.trim() }
|
||||
: {}),
|
||||
...(form.authType === "header" && form.headerName.trim()
|
||||
? { header_name: form.headerName.trim() }
|
||||
: {}),
|
||||
timeout_sec: 5,
|
||||
});
|
||||
if (res.ok) {
|
||||
formTest = {
|
||||
ok: true,
|
||||
ms: res.elapsed_ms,
|
||||
version: res.comfyui_version,
|
||||
};
|
||||
} else {
|
||||
formTest = { ok: false };
|
||||
formTestFailure = {
|
||||
message: res.message ?? res.detail ?? res.error ?? "",
|
||||
detail: res.detail ?? "",
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
formTest = { ok: false };
|
||||
formTestFailure = {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
detail: "",
|
||||
};
|
||||
} finally {
|
||||
formPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
const saveMutation = createMutation({
|
||||
mutationFn: async () => {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
base_url: form.baseUrl.trim(),
|
||||
auth_type: form.authType,
|
||||
notes: form.notes.trim() || null,
|
||||
};
|
||||
if (form.authType !== "none" && form.token.trim()) {
|
||||
payload["auth_token"] = form.token.trim();
|
||||
}
|
||||
if (form.authType === "header")
|
||||
payload["header_name"] = form.headerName.trim();
|
||||
if (formMode === "edit" && editingId) {
|
||||
return comfy.updateEndpoint(editingId, payload);
|
||||
}
|
||||
return comfy.createEndpoint(
|
||||
payload as Parameters<typeof comfy.createEndpoint>[0],
|
||||
);
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
client.invalidateQueries({ queryKey: ["settings"] });
|
||||
if (formTest?.ok && editingId) {
|
||||
recordTest(editingId, {
|
||||
ok: true,
|
||||
ms: formTest.ms,
|
||||
version: formTest.version,
|
||||
at: Date.now(),
|
||||
});
|
||||
} else if (formTest?.ok && res.endpoints.length) {
|
||||
// newest is the one we just created when adding
|
||||
const created = res.endpoints.find(
|
||||
(ep) => ep.base_url === form.baseUrl.trim(),
|
||||
);
|
||||
if (created)
|
||||
recordTest(created.id, {
|
||||
ok: true,
|
||||
ms: formTest.ms,
|
||||
version: formTest.version,
|
||||
at: Date.now(),
|
||||
});
|
||||
}
|
||||
toast.success(t("conn.saved"));
|
||||
resetForm();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t("conn.saveFailed", {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function submitSave() {
|
||||
showErrors = true;
|
||||
if (!formValid || !formTest?.ok || $saveMutation.isPending) return;
|
||||
$saveMutation.mutate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Card actions
|
||||
// ------------------------------------------------------------------
|
||||
async function testEndpoint(ep: ComfyEndpoint) {
|
||||
pendingTests = { ...pendingTests, [ep.id]: true };
|
||||
try {
|
||||
const res = await comfy.testEndpoint({
|
||||
endpoint_id: ep.id,
|
||||
timeout_sec: 10,
|
||||
});
|
||||
if (res.ok) {
|
||||
recordTest(ep.id, {
|
||||
ok: true,
|
||||
ms: res.elapsed_ms,
|
||||
version: res.comfyui_version,
|
||||
at: Date.now(),
|
||||
});
|
||||
} else {
|
||||
recordTest(ep.id, {
|
||||
ok: false,
|
||||
at: Date.now(),
|
||||
reason: res.message ?? res.detail ?? res.error ?? "unknown",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
recordTest(ep.id, {
|
||||
ok: false,
|
||||
at: Date.now(),
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
const next = { ...pendingTests };
|
||||
delete next[ep.id];
|
||||
pendingTests = next;
|
||||
}
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
let deleteTarget = $state<ComfyEndpoint | null>(null);
|
||||
|
||||
function requestDelete(ep: ComfyEndpoint) {
|
||||
deleteTarget = ep;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
function workflowsBound(id: string): number {
|
||||
return ($wfMetaQuery.data ?? []).filter(
|
||||
(wf) => wf.endpoint_id === id || (!wf.endpoint_id && id === activeId),
|
||||
).length;
|
||||
}
|
||||
|
||||
const deleteMessage = $derived.by(() => {
|
||||
if (!deleteTarget) return "";
|
||||
const n = workflowsBound(deleteTarget.id);
|
||||
return n > 0
|
||||
? t("conn.delConfirmUsed", { name: deleteTarget.name })
|
||||
: t("conn.delConfirmFree", { name: deleteTarget.name });
|
||||
});
|
||||
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: () => {
|
||||
if (!deleteTarget) throw new Error("no target");
|
||||
return comfy.deleteEndpoint(deleteTarget.id);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (deleteTarget) {
|
||||
const next = { ...tests };
|
||||
delete next[deleteTarget.id];
|
||||
saveTests(next);
|
||||
if (editingId === deleteTarget.id) resetForm();
|
||||
}
|
||||
client.invalidateQueries({ queryKey: ["settings"] });
|
||||
toast.success(t("conn.deleted"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t("conn.saveFailed", {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const defaultMutation = createMutation({
|
||||
mutationFn: (id: string) => comfy.activateEndpoint(id),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ["settings"] });
|
||||
toast.success(t("conn.defaultSetToast"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t("conn.saveFailed", {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function tokenTail(ep: ComfyEndpoint): string {
|
||||
const tail = ep.token_tail ?? "";
|
||||
return tail ? `****${tail}` : "";
|
||||
}
|
||||
|
||||
loadTests();
|
||||
$effect(() => {
|
||||
// prune stale test records once the endpoint list is known
|
||||
const data = $settingsQuery.data;
|
||||
if (data) {
|
||||
const keep = new Set(data.comfyui_endpoints.map((ep) => ep.id));
|
||||
let changed = false;
|
||||
const next: Record<string, TestRec> = {};
|
||||
for (const [k, v] of Object.entries(tests)) {
|
||||
if (keep.has(k)) next[k] = v;
|
||||
else changed = true;
|
||||
}
|
||||
if (changed) saveTests(next);
|
||||
}
|
||||
});
|
||||
|
||||
// Probe every connection once per visit so the cards show real state
|
||||
// instead of 「未测试」; results younger than a minute are reused.
|
||||
const autoProbed = new Set<string>();
|
||||
$effect(() => {
|
||||
const list = endpoints;
|
||||
untrack(() => {
|
||||
const now = Date.now();
|
||||
for (const ep of list) {
|
||||
if (autoProbed.has(ep.id)) continue;
|
||||
autoProbed.add(ep.id);
|
||||
const rec = tests[ep.id];
|
||||
if (!rec || now - rec.at > 60_000) void testEndpoint(ep);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $settingsQuery.isLoading}<p class="mg-empty">
|
||||
正在读取连接…
|
||||
</p>{:else if $settingsQuery.isError}<p class="mg-empty" role="alert">
|
||||
连接加载失败<Button onclick={() => $settingsQuery.refetch()}>重试</Button>
|
||||
</p>{:else}
|
||||
<div class="mg-cards">
|
||||
{#each endpoints as ep (ep.id)}{@const rec = tests[ep.id]}
|
||||
<article class="mg-card">
|
||||
<div class="mg-row">
|
||||
<span
|
||||
class="mg-dot"
|
||||
class:ok={rec?.ok === true}
|
||||
class:err={rec?.ok === false}
|
||||
></span><b>{ep.name}</b><span class="mg-grow"></span><span
|
||||
class="mg-latency"
|
||||
>{rec?.ms != null ? Math.round(rec.ms) + "ms" : "—"}</span
|
||||
>
|
||||
</div>
|
||||
<div class="mg-mono">{ep.base_url}</div>
|
||||
<div class="mg-muted">
|
||||
{rec?.ok === false
|
||||
? "连接失败 · " + (rec.reason ?? "原因未知")
|
||||
: rec?.ok
|
||||
? "连接测试通过"
|
||||
: "尚未测试"} · 队列未知 · 显存未知
|
||||
</div>
|
||||
<div class="mg-row">
|
||||
<span class="mg-tag">{workflowsBound(ep.id)} 个工作流使用</span><span
|
||||
class="mg-grow"
|
||||
></span><Button
|
||||
size="sm"
|
||||
loading={pendingTests[ep.id]}
|
||||
onclick={() => testEndpoint(ep)}>测试连接</Button
|
||||
><Button variant="ghost" size="sm" onclick={() => startEdit(ep)}
|
||||
>编辑</Button
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
<CloudModels />
|
||||
<button class="mg-card pending mg-add" onclick={startAdd}
|
||||
><Icon name="plus" size={22} />添加连接</button
|
||||
>
|
||||
</div>{/if}
|
||||
<Modal
|
||||
open={formMode !== "idle"}
|
||||
title={formMode === "add" ? t("conn.formTitleAdd") : t("conn.formTitleEdit")}
|
||||
dismissible={!formPending && !$saveMutation.isPending}
|
||||
onclose={resetForm}
|
||||
>
|
||||
<aside
|
||||
class="form-col"
|
||||
class:open={formMode !== "idle"}
|
||||
aria-label={t("conn.formTitleAdd")}
|
||||
>
|
||||
{#if formMode !== "idle"}
|
||||
<div class="form-head">
|
||||
<h2>
|
||||
{formMode === "add"
|
||||
? t("conn.formTitleAdd")
|
||||
: t("conn.formTitleEdit")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t("conn.fName")}<em>*</em></span>
|
||||
<input
|
||||
class="f-input"
|
||||
class:invalid={showErrors && formErrors.name}
|
||||
value={form.name}
|
||||
disabled={formPending}
|
||||
oninput={(e) => (form.name = e.currentTarget.value)}
|
||||
placeholder={t("conn.fNamePh")}
|
||||
/>
|
||||
{#if showErrors && formErrors.name}<span class="f-error"
|
||||
>{formErrors.name}</span
|
||||
>{/if}
|
||||
</label>
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t("conn.fBaseUrl")}<em>*</em></span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && formErrors.baseUrl}
|
||||
value={form.baseUrl}
|
||||
disabled={formPending}
|
||||
spellcheck="false"
|
||||
oninput={(e) => {
|
||||
form.baseUrl = e.currentTarget.value;
|
||||
touchFormConnectivity();
|
||||
}}
|
||||
placeholder={t("conn.fBaseUrlPh")}
|
||||
/>
|
||||
{#if showErrors && formErrors.baseUrl}
|
||||
<span class="f-error">{formErrors.baseUrl}</span>
|
||||
{/if}
|
||||
<span class="f-hint">{t("conn.baseUrlHelp")}</span>
|
||||
</label>
|
||||
|
||||
<div class="f-field">
|
||||
<span class="f-label">{t("conn.fAuth")}</span>
|
||||
<div class="mg-seg" role="radiogroup" aria-label={t("conn.fAuth")}>
|
||||
{#each [["none", t("conn.authNone")], ["bearer", t("conn.authBearer")], ["header", t("conn.authHeader")]] as [value, label] (value)}
|
||||
<button
|
||||
type="button"
|
||||
class="seg-item"
|
||||
class:on={form.authType === value}
|
||||
aria-pressed={form.authType === value}
|
||||
onclick={() => {
|
||||
form.authType = value as typeof form.authType;
|
||||
touchFormConnectivity();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="f-hint">{t("conn.authHelp")}</span>
|
||||
</div>
|
||||
|
||||
{#if form.authType !== "none"}
|
||||
{#if form.authType === "header"}
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t("conn.fHeaderName")}<em>*</em></span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && formErrors.headerName}
|
||||
value={form.headerName}
|
||||
disabled={formPending}
|
||||
spellcheck="false"
|
||||
oninput={(e) => {
|
||||
form.headerName = e.currentTarget.value;
|
||||
touchFormConnectivity();
|
||||
}}
|
||||
placeholder={t("conn.fHeaderNamePh")}
|
||||
/>
|
||||
{#if showErrors && formErrors.headerName}
|
||||
<span class="f-error">{formErrors.headerName}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
<label class="f-field">
|
||||
<span class="f-label">
|
||||
{t("conn.fToken")}
|
||||
<span class="kh-inline mono">{t("conn.keyNoEcho")}</span>
|
||||
</span>
|
||||
<span class="key-wrap">
|
||||
<input
|
||||
class="f-input key-input"
|
||||
type="password"
|
||||
value={form.token}
|
||||
disabled={formPending}
|
||||
autocomplete="off"
|
||||
oninput={(e) => {
|
||||
form.token = e.currentTarget.value;
|
||||
touchFormConnectivity();
|
||||
}}
|
||||
placeholder={formMode === "edit" && editingEndpoint?.has_token
|
||||
? t("conn.tokenSaved")
|
||||
: "…"}
|
||||
/>
|
||||
{#if formMode === "edit" && editingEndpoint?.has_token && tokenTail(editingEndpoint)}
|
||||
<span class="kh mono">{tokenTail(editingEndpoint)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="f-hint">{t("conn.keyHelp")}</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t("conn.fNotes")}</span>
|
||||
<input
|
||||
class="f-input"
|
||||
value={form.notes}
|
||||
disabled={formPending}
|
||||
oninput={(e) => (form.notes = e.currentTarget.value)}
|
||||
placeholder={t("conn.fNotesPh")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if formTest?.ok}
|
||||
<div class="result-bar ok" transition:fade={{ duration: 250 }}>
|
||||
{t("conn.testOk", {
|
||||
ms: formTest.ms ?? "?",
|
||||
v: formTest.version ?? "?",
|
||||
})}
|
||||
</div>
|
||||
{:else if formTest?.ok === false && formTestFailure}
|
||||
<div class="result-bar err" transition:fade={{ duration: 250 }}>
|
||||
<p>{t("conn.testFail", { reason: formTestFailure.message })}</p>
|
||||
{#if formTestFailure.detail}
|
||||
<p class="mono detail">{formTestFailure.detail}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<span class="need-test" class:passed={formTest?.ok}
|
||||
>{t("conn.needTest")}</span
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={formPending}
|
||||
onclick={runTest}
|
||||
>
|
||||
{formPending ? t("conn.testing") : t("conn.testBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!formValid || !formTest?.ok}
|
||||
loading={$saveMutation.isPending}
|
||||
onclick={submitSave}
|
||||
>
|
||||
{t("conn.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if formMode === "edit" && editingId}<div class="mg-row">
|
||||
{#if editingId !== activeId}<Button
|
||||
size="sm"
|
||||
onclick={() => $defaultMutation.mutate(editingId!)}>设为默认</Button
|
||||
>{/if}<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => {
|
||||
const target = editingEndpoint;
|
||||
if (target) requestDelete(target);
|
||||
}}>删除</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</aside></Modal
|
||||
>
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t("conn.delTitle")}
|
||||
message={deleteMessage}
|
||||
confirmLabel={t("conn.delete")}
|
||||
danger
|
||||
onconfirm={() => $deleteMutation.mutate()}
|
||||
oncancel={() => (deleteTarget = null)}
|
||||
/>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
model,
|
||||
muted = false,
|
||||
large = false,
|
||||
}: { model: string | null; muted?: boolean; large?: boolean } = $props();
|
||||
const family = $derived.by(() => {
|
||||
const value = (model ?? "").toLowerCase();
|
||||
const known: [string, string, string][] = [
|
||||
["qwen", "qwen", "Qwen"],
|
||||
["krea", "krea", "Kr2"],
|
||||
["flux", "flux", "FLX"],
|
||||
["z-image", "zimg", "Z"],
|
||||
["minimax", "h3", "H3"],
|
||||
["wan", "wan", "Wan"],
|
||||
["anima", "anima", "Ani"],
|
||||
];
|
||||
const hit = known.find(([prefix]) => value.includes(prefix));
|
||||
return hit
|
||||
? { color: `var(--family-${hit[1]})`, label: hit[2] }
|
||||
: { color: "var(--tx-3)", label: model?.slice(0, 3) ?? "本地" };
|
||||
});
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="family-badge"
|
||||
class:muted
|
||||
class:large
|
||||
style:--family-color={family.color}
|
||||
aria-hidden="true">{family.label}</span
|
||||
>
|
||||
|
||||
<style>
|
||||
.family-badge {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
font: 700 9px var(--mono);
|
||||
color: var(--family-ink);
|
||||
background: var(--family-color);
|
||||
}
|
||||
.muted {
|
||||
filter: grayscale(1) brightness(0.55);
|
||||
}
|
||||
.large {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,833 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
createMutation,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/svelte-query";
|
||||
import { untrack } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import ConfirmDialog from "$lib/components/ui/ConfirmDialog.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import { settings, type LlmProfile, type Settings } from "$lib/api";
|
||||
import { randomId } from "$lib/ids";
|
||||
import { toast } from "$lib/toast";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
|
||||
const client = useQueryClient();
|
||||
|
||||
const settingsQuery = createQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: settings.get,
|
||||
});
|
||||
|
||||
// A profile while being edited here: `api_key` is the public boolean,
|
||||
// except for a freshly typed key waiting to be sent to the server.
|
||||
type WorkingProfile = Omit<LlmProfile, "api_key"> & {
|
||||
api_key: boolean | string;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Local test-state persistence (last result / time / consecutive
|
||||
// failures per profile). Purely presentational; connectivity facts
|
||||
// always come from the server-side probe.
|
||||
// ------------------------------------------------------------------
|
||||
type TestRec = {
|
||||
ok: boolean;
|
||||
ms?: number;
|
||||
at: number;
|
||||
fails: number;
|
||||
reason?: string;
|
||||
};
|
||||
const TESTS_KEY = "calliope.models.tests";
|
||||
let tests = $state<Record<string, TestRec>>({});
|
||||
let pendingTests = $state<Record<string, boolean>>({});
|
||||
|
||||
function loadTests() {
|
||||
try {
|
||||
const raw = localStorage.getItem(TESTS_KEY);
|
||||
if (raw) tests = JSON.parse(raw) as Record<string, TestRec>;
|
||||
} catch {
|
||||
tests = {};
|
||||
}
|
||||
}
|
||||
function saveTests(map: Record<string, TestRec>) {
|
||||
tests = map;
|
||||
try {
|
||||
localStorage.setItem(TESTS_KEY, JSON.stringify(map));
|
||||
} catch {
|
||||
/* storage full / disabled — the page still works without persistence */
|
||||
}
|
||||
}
|
||||
function recordTest(id: string, rec: TestRec) {
|
||||
saveTests({ ...tests, [id]: rec });
|
||||
}
|
||||
function pruneTests(ids: string[]) {
|
||||
const keep = new Set(ids);
|
||||
const next: Record<string, TestRec> = {};
|
||||
let changed = false;
|
||||
for (const [k, v] of Object.entries(tests)) {
|
||||
if (keep.has(k)) next[k] = v;
|
||||
else changed = true;
|
||||
}
|
||||
if (changed) saveTests(next);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Form state — add / edit custom LLM (spec §9.3)
|
||||
// ------------------------------------------------------------------
|
||||
type FormMode = "idle" | "add" | "edit";
|
||||
let formMode = $state<FormMode>("idle");
|
||||
let editingId = $state<string | null>(null);
|
||||
let form = $state({
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
model: "",
|
||||
apiKey: "",
|
||||
timeout: 10,
|
||||
});
|
||||
let formTest = $state<{ ok: boolean; ms?: number } | null>(null);
|
||||
let formTestFailure = $state<{ text: string; detail: string } | null>(null);
|
||||
let formPending = $state(false);
|
||||
let showErrors = $state(false);
|
||||
|
||||
const TIMEOUT_OPTIONS = [5, 10, 15, 30, 60];
|
||||
|
||||
function resetForm() {
|
||||
formMode = "idle";
|
||||
editingId = null;
|
||||
form = { name: "", baseUrl: "", model: "", apiKey: "", timeout: 10 };
|
||||
formTest = null;
|
||||
formTestFailure = null;
|
||||
showErrors = false;
|
||||
}
|
||||
|
||||
export function startAdd() {
|
||||
resetForm();
|
||||
formMode = "add";
|
||||
}
|
||||
|
||||
function startEdit(profile: LlmProfile) {
|
||||
resetForm();
|
||||
formMode = "edit";
|
||||
editingId = profile.id;
|
||||
form = {
|
||||
name: profile.name,
|
||||
baseUrl: profile.base_url,
|
||||
model: profile.model,
|
||||
apiKey: "",
|
||||
timeout: 10,
|
||||
};
|
||||
}
|
||||
|
||||
// A connectivity-relevant edit invalidates the previous passing test.
|
||||
function touchFormConnectivity() {
|
||||
formTest = null;
|
||||
formTestFailure = null;
|
||||
}
|
||||
|
||||
const urlValid = $derived.by(() => {
|
||||
try {
|
||||
const u = new URL(form.baseUrl.trim());
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const formErrors = $derived.by(() => {
|
||||
const errs: Record<string, string> = {};
|
||||
if (!form.name.trim()) errs.name = t("models.errName");
|
||||
if (!urlValid) errs.baseUrl = t("models.errUrl");
|
||||
if (!form.model.trim()) errs.model = t("models.errModel");
|
||||
// Key is required when creating; on edit an empty field keeps the
|
||||
// stored key (masked tail placeholder covers the affordance).
|
||||
if (formMode === "add" && !form.apiKey.trim())
|
||||
errs.apiKey = t("models.errKey");
|
||||
return errs;
|
||||
});
|
||||
const formValid = $derived(Object.keys(formErrors).length === 0);
|
||||
|
||||
const editingProfile = $derived.by(() => {
|
||||
if (formMode !== "edit" || !editingId || !$settingsQuery.data) return null;
|
||||
return (
|
||||
$settingsQuery.data.llm_profiles.find((p) => p.id === editingId) ?? null
|
||||
);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Failure classification → spec §9.6 copy (primary sentence) with the
|
||||
// server-provided detail as a mono secondary line where it adds info.
|
||||
// ------------------------------------------------------------------
|
||||
function classifyFailure(
|
||||
res: { error?: string; detail?: string; timeout_sec?: number },
|
||||
fallbackSec: number,
|
||||
): { text: string; detail: string } {
|
||||
const detail = res.detail ?? "";
|
||||
switch (res.error) {
|
||||
case "timeout":
|
||||
return {
|
||||
text: t("models.failTimeout", {
|
||||
sec: res.timeout_sec ?? fallbackSec,
|
||||
}),
|
||||
detail,
|
||||
};
|
||||
case "auth":
|
||||
return { text: t("models.failAuth"), detail };
|
||||
case "not_found":
|
||||
return { text: t("models.failNotFound"), detail };
|
||||
case "http": {
|
||||
if (detail.startsWith("HTTP 404"))
|
||||
return { text: t("models.failNotOpenai"), detail };
|
||||
const status = detail.split(":")[0].replace("HTTP ", "").trim();
|
||||
return {
|
||||
text: t("models.failHttp", { status: status || detail }),
|
||||
detail,
|
||||
};
|
||||
}
|
||||
case "connect":
|
||||
default:
|
||||
return { text: t("models.failConnect"), detail };
|
||||
}
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
showErrors = true;
|
||||
if (!urlValid || !form.model.trim()) return;
|
||||
// On add the key is required before anything can be probed.
|
||||
if (formMode === "add" && !form.apiKey.trim()) return;
|
||||
formPending = true;
|
||||
formTest = null;
|
||||
formTestFailure = null;
|
||||
try {
|
||||
const res = await settings.testLlm({
|
||||
...(formMode === "edit" && editingId ? { profile_id: editingId } : {}),
|
||||
base_url: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
...(form.apiKey.trim() ? { api_key: form.apiKey.trim() } : {}),
|
||||
timeout_sec: form.timeout,
|
||||
});
|
||||
if (res.ok) {
|
||||
formTest = { ok: true, ms: res.elapsed_ms };
|
||||
} else {
|
||||
formTest = { ok: false };
|
||||
formTestFailure = classifyFailure(res, form.timeout);
|
||||
}
|
||||
} catch (err) {
|
||||
formTest = { ok: false };
|
||||
formTestFailure = {
|
||||
text: t("models.failConnect"),
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
formPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function llmProfilesFromSettings(s: Settings): WorkingProfile[] {
|
||||
if (s.llm_profiles?.length) return s.llm_profiles.map((p) => ({ ...p }));
|
||||
return [
|
||||
{
|
||||
id: s.llm_active_id || "legacy",
|
||||
name: s.llm_model || "LLM",
|
||||
base_url: s.llm_base_url,
|
||||
model: s.llm_model,
|
||||
api_key: s.llm_api_key,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const saveMutation = createMutation({
|
||||
mutationFn: async () => {
|
||||
const s = $settingsQuery.data;
|
||||
if (!s) throw new Error("settings unavailable");
|
||||
const base = llmProfilesFromSettings(s);
|
||||
const typedKey = form.apiKey.trim();
|
||||
let list: WorkingProfile[];
|
||||
if (formMode === "edit" && editingId) {
|
||||
list = base.map((p) =>
|
||||
p.id === editingId
|
||||
? {
|
||||
...p,
|
||||
name: form.name.trim(),
|
||||
base_url: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
...(typedKey ? { api_key: typedKey } : {}),
|
||||
}
|
||||
: p,
|
||||
);
|
||||
} else {
|
||||
list = [
|
||||
...base,
|
||||
{
|
||||
id: randomId(),
|
||||
name: form.name.trim(),
|
||||
base_url: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
api_key: typedKey,
|
||||
},
|
||||
];
|
||||
}
|
||||
return settings.update({
|
||||
llm_profiles: list.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name.trim(),
|
||||
base_url: p.base_url.trim(),
|
||||
model: p.model.trim(),
|
||||
...(typeof p.api_key === "string" && p.api_key.trim()
|
||||
? { api_key: p.api_key.trim() }
|
||||
: {}),
|
||||
})),
|
||||
});
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
client.invalidateQueries({ queryKey: ["settings"] });
|
||||
// Carry the passing test over so the new card shows 已连接.
|
||||
if (formTest?.ok) {
|
||||
const id =
|
||||
formMode === "edit" && editingId
|
||||
? editingId
|
||||
: (saved.llm_profiles.at(-1)?.id ?? "");
|
||||
if (id)
|
||||
recordTest(id, {
|
||||
ok: true,
|
||||
ms: formTest.ms,
|
||||
at: Date.now(),
|
||||
fails: 0,
|
||||
});
|
||||
}
|
||||
toast.success(t("models.saved"));
|
||||
resetForm();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t("models.saveFailed", {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function submitSave() {
|
||||
showErrors = true;
|
||||
if (!formValid || !formTest?.ok || $saveMutation.isPending) return;
|
||||
$saveMutation.mutate();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Card actions: per-profile test / delete / set default
|
||||
// ------------------------------------------------------------------
|
||||
async function testProfile(profile: LlmProfile) {
|
||||
pendingTests = { ...pendingTests, [profile.id]: true };
|
||||
try {
|
||||
const res = await settings.testLlm({
|
||||
profile_id: profile.id,
|
||||
base_url: profile.base_url.trim(),
|
||||
model: profile.model.trim(),
|
||||
timeout_sec: 10,
|
||||
});
|
||||
const prev = tests[profile.id];
|
||||
if (res.ok) {
|
||||
recordTest(profile.id, {
|
||||
ok: true,
|
||||
ms: res.elapsed_ms,
|
||||
at: Date.now(),
|
||||
fails: 0,
|
||||
});
|
||||
} else {
|
||||
const cls = classifyFailure(res, 10);
|
||||
recordTest(profile.id, {
|
||||
ok: false,
|
||||
at: Date.now(),
|
||||
fails: (prev?.fails ?? 0) + 1,
|
||||
reason: cls.text + (cls.detail ? `(${cls.detail})` : ""),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const prev = tests[profile.id];
|
||||
recordTest(profile.id, {
|
||||
ok: false,
|
||||
at: Date.now(),
|
||||
fails: (prev?.fails ?? 0) + 1,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
const next = { ...pendingTests };
|
||||
delete next[profile.id];
|
||||
pendingTests = next;
|
||||
}
|
||||
}
|
||||
|
||||
let deleteOpen = $state(false);
|
||||
let deleteTarget = $state<LlmProfile | null>(null);
|
||||
|
||||
function requestDelete(profile: LlmProfile) {
|
||||
deleteTarget = profile;
|
||||
deleteOpen = true;
|
||||
}
|
||||
|
||||
function agentsUsing(s: Settings, profileId: string): number {
|
||||
return Object.values(s.agent_llm_assignments ?? {}).filter(
|
||||
(v) => v === profileId,
|
||||
).length;
|
||||
}
|
||||
|
||||
const deleteMessage = $derived.by(() => {
|
||||
const s = $settingsQuery.data;
|
||||
if (!deleteTarget || !s) return "";
|
||||
const n = agentsUsing(s, deleteTarget.id);
|
||||
return n > 0
|
||||
? t("models.delConfirmUsed", { n })
|
||||
: t("models.delConfirmFree");
|
||||
});
|
||||
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: async () => {
|
||||
const s = $settingsQuery.data;
|
||||
const target = deleteTarget;
|
||||
if (!s || !target) throw new Error("no target");
|
||||
const list = llmProfilesFromSettings(s).filter((p) => p.id !== target.id);
|
||||
return settings.update({
|
||||
llm_profiles: list.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name.trim(),
|
||||
base_url: p.base_url.trim(),
|
||||
model: p.model.trim(),
|
||||
})),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (deleteTarget) {
|
||||
const next = { ...tests };
|
||||
delete next[deleteTarget.id];
|
||||
saveTests(next);
|
||||
if (editingId === deleteTarget.id) resetForm();
|
||||
}
|
||||
client.invalidateQueries({ queryKey: ["settings"] });
|
||||
toast.success(t("models.deleted"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t("models.saveFailed", {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const defaultMutation = createMutation({
|
||||
mutationFn: (id: string) => settings.update({ llm_active_id: id }),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ["settings"] });
|
||||
toast.success(t("models.defaultSetToast"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
t("models.saveFailed", {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Agent default-model panel (spec §9.4). The five AIGC agents; 视觉设计
|
||||
// binds to the `style` role added for the upcoming style agent.
|
||||
// ------------------------------------------------------------------
|
||||
const AGENTS: {
|
||||
role: string;
|
||||
labelKey: string;
|
||||
color: string;
|
||||
char: string;
|
||||
dark?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
role: "story",
|
||||
labelKey: "models.agentStory",
|
||||
color: "var(--avatar-1)",
|
||||
char: "剧",
|
||||
dark: true,
|
||||
},
|
||||
{
|
||||
role: "style",
|
||||
labelKey: "models.agentStyle",
|
||||
color: "var(--avatar-2)",
|
||||
char: "视",
|
||||
dark: true,
|
||||
},
|
||||
{
|
||||
role: "script",
|
||||
labelKey: "models.agentScript",
|
||||
color: "var(--avatar-3)",
|
||||
char: "分",
|
||||
},
|
||||
{
|
||||
role: "assets",
|
||||
labelKey: "models.agentAssets",
|
||||
color: "var(--avatar-4)",
|
||||
char: "素",
|
||||
},
|
||||
{
|
||||
role: "video",
|
||||
labelKey: "models.agentVideo",
|
||||
color: "var(--avatar-5)",
|
||||
char: "镜",
|
||||
},
|
||||
];
|
||||
|
||||
const assignMutation = createMutation({
|
||||
mutationFn: (payload: Record<string, string | null>) =>
|
||||
settings.update({ agent_llm_assignments: payload }),
|
||||
onSuccess: () => {
|
||||
client.invalidateQueries({ queryKey: ["settings"] });
|
||||
toast.success(t("models.assignSaved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
client.invalidateQueries({ queryKey: ["settings"] });
|
||||
toast.error(
|
||||
t("models.saveFailed", {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function setAssignment(role: string, value: string) {
|
||||
// Single-role delta only (HEL-619): replaying the whole cached
|
||||
// snapshot races concurrent dropdown switches — an older snapshot
|
||||
// could overwrite a newer choice. The server merges per role.
|
||||
$assignMutation.mutate({ [role]: value === "" ? null : value });
|
||||
}
|
||||
|
||||
function keyTail(profile: LlmProfile): string {
|
||||
const tail = profile.api_key_tail ?? "";
|
||||
return tail ? `sk-****${tail}` : "";
|
||||
}
|
||||
|
||||
loadTests();
|
||||
// Prune stale test records once the profile list is known.
|
||||
$effect(() => {
|
||||
const data = $settingsQuery.data;
|
||||
if (data) pruneTests(data.llm_profiles.map((p) => p.id));
|
||||
});
|
||||
|
||||
// Test each model once per visit (1-token probe) so the cards show real
|
||||
// state instead of 「未测试」; results younger than 10 minutes are reused.
|
||||
const autoProbed = new Set<string>();
|
||||
$effect(() => {
|
||||
const list = $settingsQuery.data?.llm_profiles ?? [];
|
||||
untrack(() => {
|
||||
const now = Date.now();
|
||||
for (const p of list) {
|
||||
if (autoProbed.has(p.id)) continue;
|
||||
autoProbed.add(p.id);
|
||||
const rec = tests[p.id];
|
||||
if (!rec || now - rec.at > 600_000) void testProfile(p);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $settingsQuery.isLoading}<p class="mg-empty">
|
||||
正在读取模型…
|
||||
</p>{:else if !$settingsQuery.data}<p class="mg-empty" role="alert">
|
||||
模型加载失败<Button onclick={() => $settingsQuery.refetch()}>重试</Button>
|
||||
</p>{:else}{@const s = $settingsQuery.data}{@const profiles = s.llm_profiles}
|
||||
<div class="mg-cards">
|
||||
{#each profiles as profile (profile.id)}{@const rec = tests[profile.id]}
|
||||
<article class="mg-card">
|
||||
<div class="mg-row">
|
||||
<span
|
||||
class="mg-dot"
|
||||
class:ok={rec?.ok === true}
|
||||
class:err={rec?.ok === false}
|
||||
></span><b>{profile.name}</b><span class="mg-grow"></span><span
|
||||
class="mg-latency"
|
||||
>{rec?.ms != null ? Math.round(rec.ms) + "ms" : "—"}</span
|
||||
>
|
||||
</div>
|
||||
<div class="mg-mono">{profile.base_url}</div>
|
||||
<div class="mg-muted">{profile.model}</div>
|
||||
<div class="mg-dim">
|
||||
{rec?.ok === false
|
||||
? (rec.reason ?? "连接失败")
|
||||
: rec?.ok
|
||||
? "连接测试通过"
|
||||
: "尚未测试"}
|
||||
</div>
|
||||
<div class="mg-row">
|
||||
<span class="mg-tag">{agentsUsing(s, profile.id)} 个智能体使用</span
|
||||
><span class="mg-grow"></span><Button
|
||||
size="sm"
|
||||
loading={pendingTests[profile.id]}
|
||||
onclick={() => testProfile(profile)}>测试连接</Button
|
||||
><Button size="sm" variant="ghost" onclick={() => startEdit(profile)}
|
||||
>编辑</Button
|
||||
>
|
||||
</div>
|
||||
</article>{/each}<button class="mg-card pending mg-add" onclick={startAdd}
|
||||
><Icon name="plus" size={22} />添加大语言模型</button
|
||||
>
|
||||
<div class="model-assignments">
|
||||
<section class="mg-field">
|
||||
<div class="mg-row">
|
||||
<h2>{t("models.agentsTitle")}</h2>
|
||||
<span class="panel-hint">{t("models.agentsHint")}</span>
|
||||
</div>
|
||||
<div class="mg-assignments">
|
||||
{#each AGENTS as agent (agent.role)}
|
||||
<label class="agent-cell">
|
||||
<span
|
||||
class="avatar"
|
||||
style:background={agent.color}
|
||||
class:dark={agent.dark}
|
||||
>
|
||||
{agent.char}
|
||||
</span>
|
||||
<span class="agent-name">{t(agent.labelKey)}</span>
|
||||
<select
|
||||
class="a-select"
|
||||
value={s.agent_llm_assignments?.[agent.role] ?? ""}
|
||||
onchange={(e) =>
|
||||
setAssignment(agent.role, e.currentTarget.value)}
|
||||
>
|
||||
<option value="">{t("models.agentDefault")}</option>
|
||||
{#each profiles as p (p.id)}
|
||||
<option
|
||||
value={p.id}
|
||||
class:unavailable={tests[p.id]?.ok === false}
|
||||
>
|
||||
{p.name} — {p.model}{tests[p.id]?.ok === false
|
||||
? t("models.unavailable")
|
||||
: ""}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>{/if}
|
||||
<Modal
|
||||
open={formMode !== "idle"}
|
||||
title={formMode === "edit"
|
||||
? t("models.formTitleEdit")
|
||||
: t("models.formTitleAdd")}
|
||||
dismissible={!formPending}
|
||||
onclose={resetForm}
|
||||
size="md"
|
||||
><aside
|
||||
class="form-col"
|
||||
class:open={formMode !== "idle"}
|
||||
aria-label={t("models.formTitleAdd")}
|
||||
>
|
||||
{#if formMode === "idle"}
|
||||
<div class="form-idle">
|
||||
<Icon name="sparkle" size={20} />
|
||||
<p>{t("models.idleFormHint")}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="form-head">
|
||||
<h2>
|
||||
{formMode === "add"
|
||||
? t("models.formTitleAdd")
|
||||
: t("models.formTitleEdit")}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
aria-label={t("models.formClose")}
|
||||
onclick={resetForm}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t("models.fName")}<em>*</em></span>
|
||||
<input
|
||||
class="f-input"
|
||||
class:invalid={showErrors && formErrors.name}
|
||||
value={form.name}
|
||||
disabled={formPending}
|
||||
oninput={(e) => (form.name = e.currentTarget.value)}
|
||||
placeholder={t("models.fNamePh")}
|
||||
/>
|
||||
{#if showErrors && formErrors.name}<span class="f-error"
|
||||
>{formErrors.name}</span
|
||||
>{/if}
|
||||
</label>
|
||||
|
||||
<div class="f-field">
|
||||
<span class="f-label">{t("models.fApiType")}</span>
|
||||
<div class="seg" role="radiogroup" aria-label={t("models.fApiType")}>
|
||||
<span class="seg-item selected" role="radio" aria-checked="true">
|
||||
{t("models.apiOpenai")}
|
||||
</span>
|
||||
<span class="seg-item disabled" aria-disabled="true">
|
||||
{t("models.apiAnthropic")}
|
||||
</span>
|
||||
<span class="seg-item disabled" aria-disabled="true"
|
||||
>{t("models.apiOther")}</span
|
||||
>
|
||||
</div>
|
||||
<span class="f-hint">{t("models.apiTypeHelp")}</span>
|
||||
</div>
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t("models.fBaseUrl")}<em>*</em></span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && formErrors.baseUrl}
|
||||
value={form.baseUrl}
|
||||
disabled={formPending}
|
||||
spellcheck="false"
|
||||
oninput={(e) => {
|
||||
form.baseUrl = e.currentTarget.value;
|
||||
touchFormConnectivity();
|
||||
}}
|
||||
placeholder="http://192.168.200.18:11434/v1"
|
||||
/>
|
||||
{#if showErrors && formErrors.baseUrl}
|
||||
<span class="f-error">{formErrors.baseUrl}</span>
|
||||
{/if}
|
||||
<span class="f-hint">{t("models.baseUrlHelp")}</span>
|
||||
</label>
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t("models.fModel")}<em>*</em></span>
|
||||
<input
|
||||
class="f-input mono"
|
||||
class:invalid={showErrors && formErrors.model}
|
||||
value={form.model}
|
||||
disabled={formPending}
|
||||
spellcheck="false"
|
||||
oninput={(e) => {
|
||||
form.model = e.currentTarget.value;
|
||||
touchFormConnectivity();
|
||||
}}
|
||||
placeholder="deepseek-chat"
|
||||
/>
|
||||
{#if showErrors && formErrors.model}
|
||||
<span class="f-error">{formErrors.model}</span>
|
||||
{/if}
|
||||
<span class="f-hint">{t("models.modelHelp")}</span>
|
||||
</label>
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">
|
||||
{t("models.fApiKey")}<em>*</em>
|
||||
<span class="kh-inline mono">{t("models.keyNoEcho")}</span>
|
||||
</span>
|
||||
<span class="key-wrap">
|
||||
<input
|
||||
class="f-input key-input"
|
||||
type="password"
|
||||
class:invalid={showErrors && formErrors.apiKey}
|
||||
value={form.apiKey}
|
||||
disabled={formPending}
|
||||
autocomplete="off"
|
||||
oninput={(e) => {
|
||||
form.apiKey = e.currentTarget.value;
|
||||
touchFormConnectivity();
|
||||
}}
|
||||
placeholder={formMode === "edit" && editingProfile?.api_key
|
||||
? t("models.keyKeepHint")
|
||||
: "sk-…"}
|
||||
/>
|
||||
{#if formMode === "edit" && editingProfile?.api_key && keyTail(editingProfile)}
|
||||
<span class="kh mono">{keyTail(editingProfile)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if showErrors && formErrors.apiKey}
|
||||
<span class="f-error">{formErrors.apiKey}</span>
|
||||
{/if}
|
||||
<span class="f-hint">{t("models.keyHelp")}</span>
|
||||
</label>
|
||||
|
||||
<label class="f-field">
|
||||
<span class="f-label">{t("models.fTimeout")}</span>
|
||||
<select
|
||||
class="f-input"
|
||||
value={form.timeout}
|
||||
disabled={formPending}
|
||||
onchange={(e) => {
|
||||
form.timeout = Number(e.currentTarget.value);
|
||||
touchFormConnectivity();
|
||||
}}
|
||||
>
|
||||
{#each TIMEOUT_OPTIONS as sec (sec)}
|
||||
<option value={sec}>{t("models.timeoutSec", { n: sec })}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if formTest?.ok}
|
||||
<div class="result-bar ok" transition:fade={{ duration: 250 }}>
|
||||
{t("models.testOk", {
|
||||
ms: formTest.ms ?? "?",
|
||||
model: form.model.trim(),
|
||||
})}
|
||||
</div>
|
||||
{:else if formTest?.ok === false && formTestFailure}
|
||||
<div class="result-bar err" transition:fade={{ duration: 250 }}>
|
||||
<p>
|
||||
{t("models.testFail", { reason: formTestFailure.text })}
|
||||
</p>
|
||||
{#if formTestFailure.detail}
|
||||
<p class="mono detail">{formTestFailure.detail}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-actions">
|
||||
<span class="need-test" class:passed={formTest?.ok}
|
||||
>{t("models.needTest")}</span
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={formPending}
|
||||
onclick={runTest}
|
||||
>
|
||||
{formPending ? t("models.testing") : t("models.testBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={!formValid || !formTest?.ok}
|
||||
loading={$saveMutation.isPending}
|
||||
onclick={submitSave}
|
||||
>
|
||||
{t("models.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if formMode === "edit" && editingId}<div class="mg-row">
|
||||
{#if editingId !== $settingsQuery.data?.llm_active_id}<Button
|
||||
size="sm"
|
||||
onclick={() => $defaultMutation.mutate(editingId!)}>设为默认</Button
|
||||
>{/if}<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => {
|
||||
const target = editingProfile;
|
||||
if (target) requestDelete(target);
|
||||
}}>删除</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</aside></Modal
|
||||
>
|
||||
<ConfirmDialog
|
||||
bind:open={deleteOpen}
|
||||
title={t("models.delTitle")}
|
||||
message={deleteMessage}
|
||||
confirmLabel={t("models.delete")}
|
||||
danger
|
||||
onconfirm={() => $deleteMutation.mutate()}
|
||||
oncancel={() => (deleteTarget = null)}
|
||||
/>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import AppShell from "$lib/components/shell/AppShell.svelte";
|
||||
import type { NavId } from "$lib/components/shell/nav";
|
||||
import "./management.css";
|
||||
let {
|
||||
title,
|
||||
active,
|
||||
tools,
|
||||
actions,
|
||||
children,
|
||||
scene = "manage",
|
||||
}: {
|
||||
scene?: "manage" | "gold";
|
||||
title: string;
|
||||
active: NavId;
|
||||
tools?: Snippet;
|
||||
actions?: Snippet;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AppShell {title} {active} workspace>
|
||||
{#snippet pageHeader()}
|
||||
<header
|
||||
class="management-header"
|
||||
style:--scene={scene === "gold" ? "var(--gold)" : "var(--sc-manage)"}
|
||||
>
|
||||
<b>{title}</b>{@render tools?.()}<span class="mg-grow"
|
||||
></span>{@render actions?.()}
|
||||
</header>
|
||||
{/snippet}
|
||||
<section
|
||||
class="management-page"
|
||||
aria-label={title}
|
||||
style:--scene={scene === "gold" ? "var(--gold)" : "var(--sc-manage)"}
|
||||
>
|
||||
{@render children()}
|
||||
</section>
|
||||
</AppShell>
|
||||
@@ -0,0 +1,754 @@
|
||||
/* HEL-726 approved management primitives. Geometry and colours use theme tokens. */
|
||||
.management-header {
|
||||
--icon-stroke: var(--manage-icon-stroke);
|
||||
letter-spacing: var(--manage-header-letter-spacing);
|
||||
box-sizing: border-box;
|
||||
--button-weight: var(--manage-control-weight);
|
||||
--button-primary-weight: var(--manage-control-weight);
|
||||
--button-height: var(--manage-control-height);
|
||||
--button-font-size: var(--manage-control-size);
|
||||
--button-radius: var(--manage-control-radius);
|
||||
--button-secondary-bg: var(--bg-3);
|
||||
--button-sm-height: var(--manage-button-height);
|
||||
--button-sm-padding: var(--manage-button-padding);
|
||||
--button-secondary-radius: var(--manage-button-radius);
|
||||
height: var(--head);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(10px);
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.management-header > b {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.management-page {
|
||||
--icon-stroke: var(--manage-icon-stroke);
|
||||
box-sizing: border-box;
|
||||
--button-weight: var(--manage-control-weight);
|
||||
--button-primary-weight: var(--manage-control-weight);
|
||||
--button-height: var(--manage-control-height);
|
||||
--button-font-size: var(--manage-control-size);
|
||||
--button-radius: var(--manage-control-radius);
|
||||
--button-secondary-bg: var(--bg-3);
|
||||
animation: management-enter var(--t-slow) var(--ease-out);
|
||||
--button-sm-height: var(--manage-button-height);
|
||||
--button-sm-padding: var(--manage-button-padding);
|
||||
--button-sm-radius: var(--manage-button-radius);
|
||||
--button-primary-height: var(--manage-control-height);
|
||||
--button-primary-padding: 12px;
|
||||
--button-primary-radius: var(--manage-control-radius);
|
||||
--button-secondary-radius: var(--manage-button-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
color: var(--tx-1);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mg-grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.manage {
|
||||
--scene: var(--sc-manage);
|
||||
}
|
||||
.mg-layout {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: var(--manage-filter-width) minmax(0, 1fr) var(
|
||||
--manage-detail-width
|
||||
);
|
||||
min-height: 0;
|
||||
}
|
||||
.mg-side {
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--manage-panel-background);
|
||||
padding: 12px 10px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.mg-side .mg-label {
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
padding: 10px 8px 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.mg-filter {
|
||||
border: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
flex: none;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px;
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.mg-filter:hover {
|
||||
background: rgb(var(--ink) / 0.04);
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.mg-filter.on {
|
||||
background: var(--bg-3);
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.mg-filter .n {
|
||||
margin-left: auto;
|
||||
font: 500 11px var(--mono);
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.mg-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mg-tools {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.mg-table {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
.mg-table table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.mg-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-0);
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--tx-3);
|
||||
font-size: 11.5px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.mg-table td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mg-table tr {
|
||||
cursor: pointer;
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.mg-table tbody tr:hover {
|
||||
background: rgb(var(--ink) / 0.025);
|
||||
}
|
||||
.mg-table tr.on {
|
||||
background: color-mix(in srgb, var(--sc-manage) 10%, transparent);
|
||||
}
|
||||
.mg-table .nm b {
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
}
|
||||
.mg-table .nm small {
|
||||
font: 11px var(--mono);
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.mg-detail {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--manage-panel-background);
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.mg-map {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 18px 1fr;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mg-map .a {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--line);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.mg-map .b {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--sc-image) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--sc-image) 30%, transparent);
|
||||
}
|
||||
.mg-media {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
columns: 5 200px;
|
||||
column-gap: 10px;
|
||||
}
|
||||
.mg-media > article {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 10px;
|
||||
border-radius: var(--manage-media-radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
position: relative;
|
||||
}
|
||||
.mg-media img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.mg-cards {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(var(--manage-card-min), 1fr));
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
grid-auto-rows: max-content;
|
||||
}
|
||||
.mg-card {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border-radius: var(--manage-card-radius);
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--bg-2);
|
||||
padding: var(--manage-card-padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.mg-card .mg-row b {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.mg-latency {
|
||||
font: 600 11px var(--mono);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.mg-filter small {
|
||||
margin-left: auto;
|
||||
font: 500 11px var(--mono);
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.mg-search {
|
||||
height: 34px;
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--well);
|
||||
border: 1px solid var(--line-2);
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--tx-3);
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.mg-search input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--tx-1);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
}
|
||||
.mg-search:focus-within {
|
||||
border-color: var(--scene);
|
||||
}
|
||||
.mg-search kbd {
|
||||
font: 10px var(--mono);
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.mg-seg {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
padding: 3px;
|
||||
border-radius: var(--manage-segment-radius);
|
||||
background: var(--well);
|
||||
border: 1px solid var(--line);
|
||||
gap: 2px;
|
||||
}
|
||||
.mg-seg button {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: var(--manage-segment-option-radius);
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all var(--t-base) var(--ease-out);
|
||||
}
|
||||
.mg-seg button.on {
|
||||
background: var(--bg-3);
|
||||
color: var(--tx-1);
|
||||
box-shadow: var(--sh-1);
|
||||
}
|
||||
.mg-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mg-muted {
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.mg-dim {
|
||||
color: var(--tx-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.mg-mono {
|
||||
font: 11px/var(--lh-body) var(--mono);
|
||||
color: var(--tx-3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mg-tag {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgb(var(--ink) / 0.05);
|
||||
font-size: 11px;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.mg-tag.ok {
|
||||
color: var(--ok);
|
||||
border-color: color-mix(in srgb, var(--ok) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--ok) 7%, transparent);
|
||||
}
|
||||
.mg-tag.warn {
|
||||
color: var(--warn);
|
||||
border-color: color-mix(in srgb, var(--warn) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--warn) 7%, transparent);
|
||||
}
|
||||
.mg-tag.err {
|
||||
color: var(--err);
|
||||
border-color: color-mix(in srgb, var(--err) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--err) 7%, transparent);
|
||||
}
|
||||
.mg-field {
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--manage-field-radius);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.mg-field h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--tx-2);
|
||||
margin: 0;
|
||||
}
|
||||
.mg-detail h2 {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mg-name {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mg-name b {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mg-name small {
|
||||
font: 11px var(--mono);
|
||||
color: var(--tx-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mg-empty {
|
||||
padding: 40px 16px;
|
||||
text-align: center;
|
||||
color: var(--tx-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.mg-card.pending {
|
||||
border-style: dashed;
|
||||
background: transparent;
|
||||
}
|
||||
.mg-add {
|
||||
min-height: var(--manage-add-min-height);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--tx-3);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.mg-dot {
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--tx-3);
|
||||
flex: none;
|
||||
}
|
||||
.mg-dot.ok {
|
||||
background: var(--ok);
|
||||
}
|
||||
.mg-dot.err {
|
||||
background: var(--err);
|
||||
}
|
||||
.mg-click {
|
||||
border: 0;
|
||||
background: none;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.management-page button:focus-visible,
|
||||
.management-header button:focus-visible {
|
||||
outline: 2px solid var(--scene, var(--info));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Shared connection/model edit form, hosted by the existing focus-trapping Modal. */
|
||||
.form-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.form-head h2 {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.f-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.f-label {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.f-label em {
|
||||
color: var(--err);
|
||||
}
|
||||
.f-input {
|
||||
height: 34px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--well);
|
||||
padding: 0 10px;
|
||||
color: var(--tx-1);
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
textarea.f-input {
|
||||
height: 70px;
|
||||
padding: 8px 10px;
|
||||
resize: vertical;
|
||||
}
|
||||
.f-input.invalid {
|
||||
border-color: var(--err);
|
||||
}
|
||||
.f-input:focus {
|
||||
outline: 2px solid var(--info);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.f-error {
|
||||
color: var(--err);
|
||||
font-size: 11px;
|
||||
}
|
||||
.f-hint,
|
||||
.need-test {
|
||||
color: var(--tx-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.need-test.passed {
|
||||
color: var(--ok);
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.form-test-result,
|
||||
.form-test-failure {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-test-failure {
|
||||
color: var(--err);
|
||||
}
|
||||
.form-col pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: 11px var(--mono);
|
||||
}
|
||||
.mg-assignments {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.agent-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.agent-cell .avatar {
|
||||
display: none;
|
||||
}
|
||||
.model-assignments {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.wf-map-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.wf-slot-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.wf-out-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.wf-slot-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wf-slot-id,
|
||||
.wf-map-hint {
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.wf-slot-row select {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.result-bar.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.result-bar.err {
|
||||
color: var(--err);
|
||||
}
|
||||
.result-bar .detail {
|
||||
font-size: 11px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.key-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.kh,
|
||||
.kh-inline {
|
||||
font: 11px var(--mono);
|
||||
color: var(--tx-3);
|
||||
}
|
||||
|
||||
.mg-table .mg-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.mg-field p {
|
||||
margin: 0;
|
||||
}
|
||||
.mg-card > .mg-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.mg-card > .mg-row > b {
|
||||
min-width: 0;
|
||||
}
|
||||
.mg-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mg-option {
|
||||
box-sizing: border-box;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--r-sm);
|
||||
border: 1px solid var(--line-2);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--tx-2);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--t-fast);
|
||||
}
|
||||
.mg-option.on {
|
||||
background: color-mix(in srgb, var(--sc-manage) 12%, var(--bg-3));
|
||||
border-color: color-mix(in srgb, var(--sc-manage) 60%, transparent);
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.mg-option:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.mg-step {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 114px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--bg-0);
|
||||
}
|
||||
.mg-step button {
|
||||
width: 24px;
|
||||
height: 26px;
|
||||
border: 0;
|
||||
color: var(--tx-2);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mg-step input {
|
||||
width: 50px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--tx-1);
|
||||
font: 600 12px var(--mono);
|
||||
text-align: center;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.mg-step input::-webkit-inner-spin-button {
|
||||
appearance: none;
|
||||
}
|
||||
@keyframes management-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mg-card .mg-muted {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mg-pin {
|
||||
width: var(--manage-pin-size);
|
||||
height: var(--manage-pin-size);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--manage-pin-radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--tx-3);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--t-fast),
|
||||
background var(--t-fast);
|
||||
}
|
||||
.mg-pin:hover {
|
||||
background: rgb(var(--ink) / 0.08);
|
||||
color: var(--tx-1);
|
||||
}
|
||||
.mg-pin[aria-pressed="true"] {
|
||||
color: var(--gold);
|
||||
}
|
||||
.mg-pin[aria-pressed="true"] svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
* real queue and connection status. Individual tools keep their routes. */
|
||||
interface Props {
|
||||
workspace?: boolean;
|
||||
pageHeader?: import("svelte").Snippet;
|
||||
active: NavId;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
@@ -30,7 +31,13 @@
|
||||
children?: import("svelte").Snippet;
|
||||
}
|
||||
|
||||
let { title, actions, children, workspace = false }: Props = $props();
|
||||
let {
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
pageHeader,
|
||||
workspace = false,
|
||||
}: Props = $props();
|
||||
|
||||
let paletteOpen = $state(false);
|
||||
|
||||
@@ -258,52 +265,57 @@
|
||||
>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="app-body">
|
||||
<header class="topbar">
|
||||
<div class="breadcrumb">
|
||||
{currentNav?.label ?? title}<span>/</span><span class="page-name"
|
||||
>{title}</span
|
||||
<div
|
||||
class="app-body"
|
||||
style:background={pageHeader ? "var(--manage-page-background)" : undefined}
|
||||
>
|
||||
{#if pageHeader}{@render pageHeader()}{:else}
|
||||
<header class="topbar">
|
||||
<div class="breadcrumb">
|
||||
{currentNav?.label ?? title}<span>/</span><span class="page-name"
|
||||
>{title}</span
|
||||
>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
{#if currentNav?.id === "connections"}
|
||||
<a
|
||||
class="a-btn secondary sm"
|
||||
href="/connections"
|
||||
aria-current={page.url.pathname === "/connections"
|
||||
? "page"
|
||||
: undefined}>ComfyUI 连接</a
|
||||
>
|
||||
<a
|
||||
class="a-btn secondary sm"
|
||||
href="/models"
|
||||
aria-current={page.url.pathname === "/models" ? "page" : undefined}
|
||||
>大语言模型</a
|
||||
>
|
||||
{/if}
|
||||
{#if scopeProjectId}<ProjectScope
|
||||
projectId={scopeProjectId}
|
||||
onProjectsList={false}
|
||||
/>{/if}
|
||||
{#if actions}<div class="topbar-actions">{@render actions()}</div>{/if}
|
||||
<button
|
||||
class="search-trigger"
|
||||
onclick={openPalette}
|
||||
aria-label={t("shell.searchTitle")}
|
||||
aria-keyshortcuts="Control+K Meta+K"
|
||||
><Icon name="search" size={16} /><span>搜索</span><kbd>Ctrl K</kbd
|
||||
></button
|
||||
>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
{#if currentNav?.id === "connections"}
|
||||
<a
|
||||
class="a-btn secondary sm"
|
||||
href="/connections"
|
||||
aria-current={page.url.pathname === "/connections"
|
||||
? "page"
|
||||
: undefined}>ComfyUI 连接</a
|
||||
<label class="motion"
|
||||
><Switch
|
||||
checked={reduceMotion.system || reduceMotion.manual}
|
||||
disabled={reduceMotion.system}
|
||||
label={t("shell.reduceMotion")}
|
||||
onchange={setReduceMotion}
|
||||
/><span>减少动效</span></label
|
||||
>
|
||||
<a
|
||||
class="a-btn secondary sm"
|
||||
href="/models"
|
||||
aria-current={page.url.pathname === "/models" ? "page" : undefined}
|
||||
>大语言模型</a
|
||||
>
|
||||
{/if}
|
||||
{#if scopeProjectId}<ProjectScope
|
||||
projectId={scopeProjectId}
|
||||
onProjectsList={false}
|
||||
/>{/if}
|
||||
{#if actions}<div class="topbar-actions">{@render actions()}</div>{/if}
|
||||
<button
|
||||
class="search-trigger"
|
||||
onclick={openPalette}
|
||||
aria-label={t("shell.searchTitle")}
|
||||
aria-keyshortcuts="Control+K Meta+K"
|
||||
><Icon name="search" size={16} /><span>搜索</span><kbd>Ctrl K</kbd
|
||||
></button
|
||||
>
|
||||
<label class="motion"
|
||||
><Switch
|
||||
checked={reduceMotion.system || reduceMotion.manual}
|
||||
disabled={reduceMotion.system}
|
||||
label={t("shell.reduceMotion")}
|
||||
onchange={setReduceMotion}
|
||||
/><span>减少动效</span></label
|
||||
>
|
||||
<LanguageSwitcher /><QueueTray jobs={genJobs} />
|
||||
</header>
|
||||
<LanguageSwitcher /><QueueTray jobs={genJobs} />
|
||||
</header>
|
||||
{/if}
|
||||
<div class="body" class:bounded={workspace}>{@render children?.()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { THEMES, theme, setTheme, themeStorageUnavailable } from "$lib/theme";
|
||||
let { compact = false }: { compact?: boolean } = $props();
|
||||
let {
|
||||
compact = false,
|
||||
tiles = false,
|
||||
}: { compact?: boolean; tiles?: boolean } = $props();
|
||||
function move(event: KeyboardEvent) {
|
||||
const steps: Record<string, number> = {
|
||||
ArrowRight: 1,
|
||||
@@ -28,26 +31,38 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="choices" class:compact role="group" aria-label="外观主题">
|
||||
<div
|
||||
class="choices"
|
||||
class:compact
|
||||
class:tiles
|
||||
role="group"
|
||||
aria-label="外观主题"
|
||||
>
|
||||
{#each THEMES as item}
|
||||
<button
|
||||
class="choice"
|
||||
class:selected={$theme === item.id}
|
||||
aria-pressed={$theme === item.id}
|
||||
onkeydown={move}
|
||||
onclick={() => setTheme(item.id)}
|
||||
onclick={(event) => setTheme(item.id, event)}
|
||||
>
|
||||
<span class="preview" data-theme={item.id} aria-hidden="true"
|
||||
><span class="rail"></span><span class="list"
|
||||
><i></i><i></i><i></i></span
|
||||
><span class="stage"><i></i><b></b></span></span
|
||||
>
|
||||
<span class="copy"
|
||||
><strong>{item.name}</strong><small>{item.english}</small><span
|
||||
>{item.description}</span
|
||||
><span class="stage"
|
||||
><i></i><b
|
||||
>{#if tiles}<em></em>{/if}</b
|
||||
></span
|
||||
></span
|
||||
>
|
||||
<span class="check" aria-hidden="true"
|
||||
<span class="copy"
|
||||
><strong
|
||||
>{item.name}{#if tiles && item.id === "night"}
|
||||
<span class="mg-tag theme-default">默认</span>{/if}</strong
|
||||
><small>{tiles ? item.description.split(" · ")[0] : item.english}</small
|
||||
>{#if !tiles}<span>{item.description}</span>{/if}</span
|
||||
>
|
||||
<span class="check" class:tile-check={tiles} aria-hidden="true"
|
||||
>{$theme === item.id ? "✓" : ""}</span
|
||||
>
|
||||
</button>
|
||||
@@ -63,16 +78,38 @@
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.choices.tiles {
|
||||
--choice-check-width: 0px;
|
||||
--choice-box-sizing: border-box;
|
||||
--choice-gap: 6px;
|
||||
--choice-copy-gap: 6px;
|
||||
--choice-tone-align: flex-start;
|
||||
--choice-preview-art: var(--theme-preview-art);
|
||||
--choice-dock-width: 100%;
|
||||
--choice-dock-background: var(--bg-3);
|
||||
--choice-radius: 12px;
|
||||
--choice-name-size: 12.5px;
|
||||
--choice-tone-font: 11px var(--font-body);
|
||||
--choice-selected: color-mix(in srgb, var(--gold) 60%, transparent);
|
||||
--choice-shadow: 0 0 0 3px color-mix(in srgb, var(--gold) 14%, transparent);
|
||||
}
|
||||
.tile-check {
|
||||
display: none;
|
||||
}
|
||||
.tiles .copy {
|
||||
flex-direction: column;
|
||||
}
|
||||
.choices.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.choice {
|
||||
box-sizing: var(--choice-box-sizing, content-box);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 18px;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) var(--choice-check-width, 18px);
|
||||
gap: var(--choice-gap, 8px);
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r-md);
|
||||
border-radius: var(--choice-radius, var(--r-md));
|
||||
background: var(--bg-2);
|
||||
color: var(--tx-1);
|
||||
text-align: left;
|
||||
@@ -83,7 +120,8 @@
|
||||
background: var(--bg-3);
|
||||
}
|
||||
.choice.selected {
|
||||
border-color: var(--gold);
|
||||
border-color: var(--choice-selected, var(--gold));
|
||||
box-shadow: var(--choice-shadow, none);
|
||||
}
|
||||
.choice:focus-visible {
|
||||
outline: 2px solid var(--info);
|
||||
@@ -94,6 +132,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
.preview {
|
||||
box-sizing: var(--choice-box-sizing, content-box);
|
||||
grid-column: 1 / -1;
|
||||
height: 64px;
|
||||
border-radius: var(--r-sm);
|
||||
@@ -144,29 +183,37 @@
|
||||
.stage i {
|
||||
flex: 1;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-3);
|
||||
background: var(--choice-preview-art, var(--bg-3));
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
.stage b {
|
||||
height: 9px;
|
||||
width: 18px;
|
||||
width: var(--choice-dock-width, 18px);
|
||||
align-self: end;
|
||||
border-radius: 2px;
|
||||
background: var(--choice-dock-background, var(--grad-gold));
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1px;
|
||||
}
|
||||
.stage em {
|
||||
width: 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--grad-gold);
|
||||
}
|
||||
.copy {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 6px;
|
||||
gap: var(--choice-copy-gap, 2px) 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.copy strong {
|
||||
font-size: 13px;
|
||||
font-size: var(--choice-name-size, 13px);
|
||||
}
|
||||
.copy small {
|
||||
font: 10px var(--mono);
|
||||
font: var(--choice-tone-font, 10px var(--mono));
|
||||
color: var(--tx-3);
|
||||
align-self: center;
|
||||
align-self: var(--choice-tone-align, center);
|
||||
}
|
||||
.copy > span {
|
||||
width: 100%;
|
||||
@@ -182,8 +229,13 @@
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
@media (max-width: 1366px) {
|
||||
.choices:not(.compact) {
|
||||
.choices:not(.compact):not(.tiles) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.theme-default {
|
||||
box-sizing: border-box;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { icons, type IconName } from './icons';
|
||||
import { icons, type IconName } from "./icons";
|
||||
|
||||
interface Props {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
}
|
||||
interface Props {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { name, size = 18 }: Props = $props();
|
||||
let { name, size = 18 }: Props = $props();
|
||||
|
||||
const content = $derived(icons[name] ?? '');
|
||||
const content = $derived(icons[name] ?? "");
|
||||
</script>
|
||||
|
||||
{#if content}
|
||||
<svg
|
||||
class="icon"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html content}
|
||||
</svg>
|
||||
<svg
|
||||
class="icon"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
style:stroke-width="var(--icon-stroke, 1.5)"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{@html content}
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: -0.125em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: -0.125em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,76 +4,81 @@
|
||||
* Each entry is SVG inner content rendered by Icon.svelte via {@html}.
|
||||
*/
|
||||
export const icons = {
|
||||
// Navigation
|
||||
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M9 22V12h6v10"/>',
|
||||
story:
|
||||
'<path d="M2 4h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2z"/><path d="M22 4h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/>',
|
||||
assets:
|
||||
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
script:
|
||||
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
|
||||
video:
|
||||
'<rect x="2" y="2" width="20" height="20" rx="2.18"/><path d="M7 2v20"/><path d="M17 2v20"/><path d="M2 12h20"/><path d="M2 7h5"/><path d="M2 17h5"/><path d="M17 17h5"/><path d="M17 7h5"/>',
|
||||
queue:
|
||||
'<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
|
||||
settings:
|
||||
'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
playground:
|
||||
'<path d="M10 2v7.53a2 2 0 0 1-.21.9L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45L14.21 10.4a2 2 0 0 1-.21-.9V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
|
||||
// Actions
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
'link-off':
|
||||
'<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 2 8"/><path d="m8 8 8 8"/>',
|
||||
trash:
|
||||
'<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
|
||||
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
|
||||
retry:
|
||||
'<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
|
||||
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
download:
|
||||
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
|
||||
upload:
|
||||
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
|
||||
paperclip:
|
||||
'<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
copy:
|
||||
'<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>',
|
||||
'external-link':
|
||||
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/>',
|
||||
'zoom-in':
|
||||
'<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/><path d="M11 8v6"/><path d="M8 11h6"/>',
|
||||
// Media transport
|
||||
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
|
||||
pause:
|
||||
'<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
|
||||
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
|
||||
// Status
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
alert:
|
||||
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
||||
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
|
||||
sparkle:
|
||||
'<path d="M12 3l1.9 5.76a2 2 0 0 0 1.24 1.24L21 12l-5.86 1.9a2 2 0 0 0-1.24 1.24L12 21l-1.9-5.86a2 2 0 0 0-1.24-1.24L3 12l5.86-1.9a2 2 0 0 0 1.24-1.24Z"/>',
|
||||
// Media
|
||||
image:
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/>',
|
||||
film: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 8h20"/><path d="M2 16h20"/><path d="M9 8v8"/><path d="M15 8v8"/>',
|
||||
music:
|
||||
'<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
|
||||
folder:
|
||||
'<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
||||
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
|
||||
// Chevrons
|
||||
'chevron-left': '<path d="m15 18-6-6 6-6"/>',
|
||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||
'chevron-up': '<path d="m18 15-6-6-6 6"/>',
|
||||
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
|
||||
// Misc
|
||||
drag: '<circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/>',
|
||||
"search-manage": '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
|
||||
"upload-manage": '<path d="M12 16V4M6 9l6-6 6 6M4 20h16"/>',
|
||||
"film-manage":
|
||||
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M7 4v16M17 4v16M2 9h5M2 15h5M17 9h5M17 15h5"/>',
|
||||
"arrow-right": '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
||||
pin: '<path d="M12 17v5M5 17h14l-2-4V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v8z"/>',
|
||||
// Navigation
|
||||
home: '<path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M9 22V12h6v10"/>',
|
||||
story:
|
||||
'<path d="M2 4h6a4 4 0 0 1 4 4v12a3 3 0 0 0-3-3H2z"/><path d="M22 4h-6a4 4 0 0 0-4 4v12a3 3 0 0 1 3-3h7z"/>',
|
||||
assets:
|
||||
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
script:
|
||||
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
|
||||
video:
|
||||
'<rect x="2" y="2" width="20" height="20" rx="2.18"/><path d="M7 2v20"/><path d="M17 2v20"/><path d="M2 12h20"/><path d="M2 7h5"/><path d="M2 17h5"/><path d="M17 17h5"/><path d="M17 7h5"/>',
|
||||
queue:
|
||||
'<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
|
||||
settings:
|
||||
'<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
playground:
|
||||
'<path d="M10 2v7.53a2 2 0 0 1-.21.9L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45L14.21 10.4a2 2 0 0 1-.21-.9V2"/><path d="M8.5 2h7"/><path d="M7 16h10"/>',
|
||||
// Actions
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
"link-off":
|
||||
'<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 0 1 2 8"/><path d="m8 8 8 8"/>',
|
||||
trash:
|
||||
'<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
|
||||
edit: '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
|
||||
retry:
|
||||
'<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
|
||||
close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
download:
|
||||
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
|
||||
upload:
|
||||
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
|
||||
paperclip:
|
||||
'<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
copy: '<rect x="8" y="8" width="14" height="14" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>',
|
||||
"external-link":
|
||||
'<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/>',
|
||||
"zoom-in":
|
||||
'<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/><path d="M11 8v6"/><path d="M8 11h6"/>',
|
||||
// Media transport
|
||||
play: '<polygon points="6 3 20 12 6 21 6 3"/>',
|
||||
pause:
|
||||
'<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
|
||||
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
|
||||
// Status
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
alert:
|
||||
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
||||
clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
|
||||
sparkle:
|
||||
'<path d="M12 3l1.9 5.76a2 2 0 0 0 1.24 1.24L21 12l-5.86 1.9a2 2 0 0 0-1.24 1.24L12 21l-1.9-5.86a2 2 0 0 0-1.24-1.24L3 12l5.86-1.9a2 2 0 0 0 1.24-1.24Z"/>',
|
||||
// Media
|
||||
image:
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/>',
|
||||
film: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 8h20"/><path d="M2 16h20"/><path d="M9 8v8"/><path d="M15 8v8"/>',
|
||||
music:
|
||||
'<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
|
||||
folder:
|
||||
'<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
|
||||
file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
|
||||
// Chevrons
|
||||
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
|
||||
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
|
||||
"chevron-up": '<path d="m18 15-6-6-6 6"/>',
|
||||
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
||||
// Misc
|
||||
drag: '<circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/>',
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import { tick } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
/** Approved HEL-726 themes. Colours live only in static/themes.css. */
|
||||
@@ -36,16 +37,53 @@ export function isTheme(value: unknown): value is ThemeId {
|
||||
return THEMES.some((item) => item.id === value);
|
||||
}
|
||||
|
||||
export function setTheme(id: ThemeId) {
|
||||
let themeRequest = 0;
|
||||
let transition: ViewTransition | undefined;
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
/** HEL-726: 520ms reveal, 280ms fallback, immediate under reduced motion.
|
||||
* Only root tokens and the store change; no editor/component is remounted. */
|
||||
export function setTheme(id: ThemeId, event?: MouseEvent) {
|
||||
if (typeof document === "undefined" || !isTheme(id)) return;
|
||||
// Only the root attribute changes: editors, uploads and jobs stay mounted.
|
||||
document.documentElement.dataset.theme = id;
|
||||
theme.set(id);
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, id);
|
||||
themeStorageUnavailable.set(false);
|
||||
} catch {
|
||||
themeStorageUnavailable.set(true);
|
||||
const root = document.documentElement;
|
||||
const request = ++themeRequest;
|
||||
transition?.skipTransition();
|
||||
clearTimeout(fallbackTimer);
|
||||
root.classList.remove("theme-anim");
|
||||
if (root.dataset.theme === id) return;
|
||||
const apply = () => {
|
||||
if (request !== themeRequest) return;
|
||||
root.dataset.theme = id;
|
||||
theme.set(id);
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, id);
|
||||
themeStorageUnavailable.set(false);
|
||||
} catch {
|
||||
themeStorageUnavailable.set(true);
|
||||
}
|
||||
};
|
||||
const reduced =
|
||||
root.dataset.reduce === "1" ||
|
||||
matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
if (document.startViewTransition) {
|
||||
if (event && event.detail !== 0) {
|
||||
root.style.setProperty("--theme-x", event.clientX + "px");
|
||||
root.style.setProperty("--theme-y", event.clientY + "px");
|
||||
}
|
||||
transition = document.startViewTransition(async () => {
|
||||
apply();
|
||||
await tick();
|
||||
});
|
||||
void transition.ready.catch(() => {});
|
||||
void transition.finished.catch(() => {});
|
||||
} else {
|
||||
root.classList.add("theme-anim");
|
||||
apply();
|
||||
fallbackTimer = setTimeout(() => root.classList.remove("theme-anim"), 340);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { WorkflowMeta } from "./comfy/types";
|
||||
/** Shared contract for creation and management. A directory file is never a runnable workflow. */
|
||||
export function isWorkflowRunnable(
|
||||
row: Pick<WorkflowMeta, "is_enabled" | "usage" | "needs_mapping">,
|
||||
): boolean {
|
||||
return row.is_enabled && !!row.usage && !row.needs_mapping;
|
||||
}
|
||||
export function workflowState(row: WorkflowMeta): {
|
||||
label: string;
|
||||
tone: string;
|
||||
} {
|
||||
if (row.needs_mapping || !row.usage) return { label: "待配置", tone: "warn" };
|
||||
if (!row.is_enabled) return { label: "已停用", tone: "" };
|
||||
return { label: "可运行", tone: "ok" };
|
||||
}
|
||||
@@ -68,13 +68,13 @@ code,
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: 34px;
|
||||
min-height: var(--button-height, 34px);
|
||||
padding: 0 var(--sp-6);
|
||||
border-radius: var(--r-md);
|
||||
border-radius: var(--button-radius, var(--r-md));
|
||||
border: 1px solid transparent;
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
font-size: var(--button-font-size, var(--fs-body));
|
||||
font-weight: var(--button-weight, 600);
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
@@ -92,10 +92,10 @@ code,
|
||||
padding: 0 var(--sp-7);
|
||||
}
|
||||
.a-btn.sm {
|
||||
min-height: 28px;
|
||||
padding: 0 var(--sp-4);
|
||||
min-height: var(--button-sm-height, 28px);
|
||||
padding: 0 var(--button-sm-padding, var(--sp-4));
|
||||
font-size: var(--fs-aux);
|
||||
border-radius: var(--r-sm);
|
||||
border-radius: var(--button-sm-radius, var(--r-sm));
|
||||
}
|
||||
.a-btn.xs {
|
||||
min-height: 24px;
|
||||
@@ -107,12 +107,19 @@ code,
|
||||
width: 100%;
|
||||
}
|
||||
.a-btn.primary {
|
||||
border-color: transparent;
|
||||
border-bottom-width: 0;
|
||||
min-height: var(--button-primary-height, 40px);
|
||||
padding: 0 var(--button-primary-padding, 18px);
|
||||
border-radius: var(--button-primary-radius, 8px);
|
||||
font-weight: var(--button-primary-weight, 550);
|
||||
background: var(--accent-grad);
|
||||
color: var(--accent-ink);
|
||||
box-shadow: var(--btn-glow);
|
||||
}
|
||||
.a-btn.secondary {
|
||||
background: var(--bg-3);
|
||||
background: var(--button-secondary-bg, var(--surface));
|
||||
border-radius: var(--button-secondary-radius, 8px);
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -949,18 +956,6 @@ select:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.a-btn.primary {
|
||||
border-color: transparent;
|
||||
border-bottom-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 0 18px;
|
||||
border-radius: 8px;
|
||||
font-weight: 550;
|
||||
}
|
||||
.a-btn.secondary {
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.a-stage {
|
||||
background: var(--stage-bg);
|
||||
border-radius: 14px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,518 +1,275 @@
|
||||
<script lang="ts">
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { libraryApi, type LibraryMediaItem } from '$lib/api';
|
||||
import { assetUrl } from '$lib/api';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import AttachToProject from '$lib/components/AttachToProject.svelte';
|
||||
import ImageLightbox from '$lib/components/ImageLightbox.svelte';
|
||||
import SafeMedia from '$lib/components/SafeMedia.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Skeleton from '$lib/components/ui/Skeleton.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import {
|
||||
createMutation,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/svelte-query";
|
||||
import { libraryApi, type LibraryMediaItem } from "$lib/api";
|
||||
import { assetUrl } from "$lib/api";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
|
||||
import AttachToProject from "$lib/components/AttachToProject.svelte";
|
||||
import ImageLightbox from "$lib/components/ImageLightbox.svelte";
|
||||
import SafeMedia from "$lib/components/SafeMedia.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import ConfirmDialog from "$lib/components/ui/ConfirmDialog.svelte";
|
||||
import Modal from "$lib/components/ui/Modal.svelte";
|
||||
import { toast } from "$lib/toast";
|
||||
|
||||
const client = useQueryClient();
|
||||
const client = useQueryClient();
|
||||
|
||||
const mediaQuery = createQuery({
|
||||
queryKey: ['library-media'],
|
||||
queryFn: libraryApi.list,
|
||||
});
|
||||
const mediaQuery = createQuery({
|
||||
queryKey: ["library-media"],
|
||||
queryFn: libraryApi.list,
|
||||
});
|
||||
|
||||
const items = $derived($mediaQuery.data ?? []);
|
||||
const items = $derived($mediaQuery.data ?? []);
|
||||
|
||||
// ---- filter + lazy paging ------------------------------------------------
|
||||
// The listing endpoint returns the full (small) file index; filtering and
|
||||
// paging are client-side. Media heavy <video> cards make a 50-per-page cap
|
||||
// necessary even so.
|
||||
// ---- filter + lazy paging ------------------------------------------------
|
||||
// The listing endpoint returns the full (small) file index; filtering and
|
||||
// paging are client-side. Media heavy <video> cards make a 50-per-page cap
|
||||
// necessary even so.
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
let filter = $state<'all' | 'image' | 'video'>('all');
|
||||
let visibleCount = $state(PAGE_SIZE);
|
||||
let loadingMore = $state(false);
|
||||
let sentinel = $state<HTMLElement | null>(null);
|
||||
const PAGE_SIZE = 50;
|
||||
let filter = $state<"all" | "image" | "video">("all");
|
||||
let visibleCount = $state(PAGE_SIZE);
|
||||
let loadingMore = $state(false);
|
||||
let sentinel = $state<HTMLElement | null>(null);
|
||||
|
||||
const filtered = $derived(filter === 'all' ? items : items.filter((i) => i.kind === filter));
|
||||
const visible = $derived(filtered.slice(0, visibleCount));
|
||||
const hasMore = $derived(filtered.length > visible.length);
|
||||
const remaining = $derived(filtered.length - visible.length);
|
||||
const filtered = $derived(
|
||||
filter === "all" ? items : items.filter((i) => i.kind === filter),
|
||||
);
|
||||
const visible = $derived(filtered.slice(0, visibleCount));
|
||||
const hasMore = $derived(filtered.length > visible.length);
|
||||
const remaining = $derived(filtered.length - visible.length);
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore || loadingMore) return;
|
||||
loadingMore = true;
|
||||
// Yield a frame so the loading row paints before the (sync) append.
|
||||
setTimeout(() => {
|
||||
visibleCount += PAGE_SIZE;
|
||||
loadingMore = false;
|
||||
}, 120);
|
||||
}
|
||||
function loadMore() {
|
||||
if (!hasMore || loadingMore) return;
|
||||
loadingMore = true;
|
||||
// Yield a frame so the loading row paints before the (sync) append.
|
||||
setTimeout(() => {
|
||||
visibleCount += PAGE_SIZE;
|
||||
loadingMore = false;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Auto-append the next page when the sentinel scrolls near the viewport.
|
||||
if (!sentinel || !hasMore) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) loadMore();
|
||||
},
|
||||
{ rootMargin: '400px' },
|
||||
);
|
||||
io.observe(sentinel);
|
||||
return () => io.disconnect();
|
||||
});
|
||||
$effect(() => {
|
||||
// Auto-append the next page when the sentinel scrolls near the viewport.
|
||||
if (!sentinel || !hasMore) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) loadMore();
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
);
|
||||
io.observe(sentinel);
|
||||
return () => io.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// New filter → back to the first page.
|
||||
void filter;
|
||||
visibleCount = PAGE_SIZE;
|
||||
});
|
||||
$effect(() => {
|
||||
// New filter → back to the first page.
|
||||
void filter;
|
||||
visibleCount = PAGE_SIZE;
|
||||
});
|
||||
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
let preview = $state<{
|
||||
src: string;
|
||||
kind: 'image' | 'video';
|
||||
name: string;
|
||||
} | null>(null);
|
||||
let attachItem = $state<LibraryMediaItem | null>(null);
|
||||
let confirmingDelete = $state(false);
|
||||
let selected = $state<Set<string>>(new Set());
|
||||
let preview = $state<{
|
||||
src: string;
|
||||
kind: "image" | "video";
|
||||
name: string;
|
||||
} | null>(null);
|
||||
let attachItem = $state<LibraryMediaItem | null>(null);
|
||||
let confirmingDelete = $state(false);
|
||||
|
||||
const selectedCount = $derived(selected.size);
|
||||
const allSelected = $derived(filtered.length > 0 && filtered.every((i) => selected.has(i.path)));
|
||||
const selectedCount = $derived(selected.size);
|
||||
const allSelected = $derived(
|
||||
filtered.length > 0 && filtered.every((i) => selected.has(i.path)),
|
||||
);
|
||||
|
||||
function toggle(path: string) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
selected = next;
|
||||
}
|
||||
function toggle(path: string) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
selected = allSelected ? new Set() : new Set(filtered.map((i) => i.path));
|
||||
}
|
||||
function toggleAll() {
|
||||
selected = allSelected ? new Set() : new Set(filtered.map((i) => i.path));
|
||||
}
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: (paths: string[]) => libraryApi.deleteMany(paths),
|
||||
onSuccess: (res) => {
|
||||
selected = new Set();
|
||||
confirmingDelete = false;
|
||||
void client.invalidateQueries({ queryKey: ['library-media'] });
|
||||
const kept = res.results.filter((r) => r.status === 'kept');
|
||||
if (kept.length > 0) {
|
||||
toast.error(t('library.deletedPartial', { kept: kept.length }));
|
||||
} else {
|
||||
toast.success(t('library.deleted', { count: res.deleted.length }));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
confirmingDelete = false;
|
||||
toast.error(err instanceof Error ? err.message : t('library.deleteFailed'));
|
||||
},
|
||||
});
|
||||
const deleteMutation = createMutation({
|
||||
mutationFn: (paths: string[]) => libraryApi.deleteMany(paths),
|
||||
onSuccess: (res) => {
|
||||
selected = new Set();
|
||||
confirmingDelete = false;
|
||||
void client.invalidateQueries({ queryKey: ["library-media"] });
|
||||
const kept = res.results.filter((r) => r.status === "kept");
|
||||
if (kept.length > 0) {
|
||||
toast.error(t("library.deletedPartial", { kept: kept.length }));
|
||||
} else {
|
||||
toast.success(t("library.deleted", { count: res.deleted.length }));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
confirmingDelete = false;
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("library.deleteFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function deleteSelected() {
|
||||
if (selected.size === 0) return;
|
||||
$deleteMutation.mutate([...selected]);
|
||||
}
|
||||
function deleteSelected() {
|
||||
if (selected.size === 0) return;
|
||||
$deleteMutation.mutate([...selected]);
|
||||
}
|
||||
|
||||
function openPreview(item: LibraryMediaItem) {
|
||||
const src = assetUrl(item.path);
|
||||
if (!src) return;
|
||||
preview = { src, kind: item.kind, name: item.name };
|
||||
}
|
||||
function openPreview(item: LibraryMediaItem) {
|
||||
const src = assetUrl(item.path);
|
||||
if (!src) return;
|
||||
preview = { src, kind: item.kind, name: item.name };
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppShell active="library" title={t('nav.library')} subtitle={t('library.subtitle')}>
|
||||
{#snippet actions()}
|
||||
<span class="status-chip" data-testid="library-count">
|
||||
{t('library.itemCount', { count: filtered.length })}
|
||||
</span>
|
||||
{#if selectedCount > 0}
|
||||
<Button variant="danger" onclick={() => (confirmingDelete = true)}>
|
||||
<Icon name="close" size={14} />
|
||||
{t('library.deleteSelected', { count: selectedCount })}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="library">
|
||||
<header class="refract-library-title">
|
||||
<div class="eyebrow">YOUR CREATIVE MATERIALS</div>
|
||||
<h1>创作的原材料。</h1>
|
||||
<p>让每一次探索,留下可继续创作的线索。</p>
|
||||
</header>
|
||||
<nav class="resource-tabs">
|
||||
<a class="active" href="/library">参考素材</a><a href="/workflows">模型与工作流</a>
|
||||
</nav>
|
||||
<div class="toolbar">
|
||||
<div class="filters" role="tablist" aria-label={t('library.filterLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:sel={filter === 'all'}
|
||||
aria-pressed={filter === 'all'}
|
||||
onclick={() => (filter = 'all')}
|
||||
>
|
||||
<span class="lbl">{t('library.filterAll')}</span>
|
||||
<span class="chip-count">{items.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:sel={filter === 'image'}
|
||||
aria-pressed={filter === 'image'}
|
||||
onclick={() => (filter = 'image')}
|
||||
>
|
||||
<span class="lbl">{t('library.filterImage')}</span>
|
||||
<span class="chip-count">{items.filter((i) => i.kind === 'image').length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip"
|
||||
class:sel={filter === 'video'}
|
||||
aria-pressed={filter === 'video'}
|
||||
onclick={() => (filter = 'video')}
|
||||
>
|
||||
<span class="lbl">{t('library.filterVideo')}</span>
|
||||
<span class="chip-count">{items.filter((i) => i.kind === 'video').length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="select-all">
|
||||
<input type="checkbox" checked={allSelected} onchange={toggleAll} />
|
||||
<span>{allSelected ? t('library.deselectAll') : t('library.selectAll')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if $mediaQuery.isLoading}
|
||||
<div class="grid" aria-busy="true">
|
||||
{#each Array(8) as _, i (i)}
|
||||
<div class="card skeleton-card"><Skeleton height="220px" /></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if $mediaQuery.isError}
|
||||
<EmptyState title={t('library.loadError')}>
|
||||
{#snippet action()}
|
||||
<Button variant="secondary" onclick={() => $mediaQuery.refetch()}>
|
||||
<Icon name="retry" size={14} />
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{:else if filtered.length === 0}
|
||||
<EmptyState
|
||||
title={filter === 'all'
|
||||
? t('library.emptyTitle')
|
||||
: t('library.emptyFilterTitle', {
|
||||
kind: filter === 'image' ? t('library.filterImage') : t('library.filterVideo'),
|
||||
})}
|
||||
body={filter === 'all' ? t('library.emptyBody') : t('library.emptyFilterBody')}
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each visible as item (item.path)}
|
||||
<div
|
||||
class="card"
|
||||
class:selected={selected.has(item.path)}
|
||||
class:a-frame={selected.has(item.path)}
|
||||
style:--frame-inset="3px"
|
||||
style:--frame-arm="16px"
|
||||
>
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.path)}
|
||||
onchange={() => toggle(item.path)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="media a-photo"
|
||||
onclick={() => openPreview(item)}
|
||||
title={item.name}
|
||||
>
|
||||
<SafeMedia
|
||||
src={assetUrl(item.path)}
|
||||
kind={item.kind}
|
||||
alt={item.name}
|
||||
controls={false}
|
||||
/>
|
||||
</button>
|
||||
<div class="meta">
|
||||
<span class="name" title={item.name}>{item.name}</span>
|
||||
<span class="sub">{item.kind} · {fmtSize(item.size)}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<Button variant="secondary" onclick={() => (attachItem = item)}>
|
||||
{t('library.addToProject')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasMore}
|
||||
<div class="load-more" bind:this={sentinel} aria-hidden="true"></div>
|
||||
<div class="more-row">
|
||||
{#if loadingMore}
|
||||
<span class="loading-note">{t('library.loadingMore')}</span>
|
||||
{:else}
|
||||
<Button variant="secondary" onclick={loadMore}>
|
||||
{t('library.loadMore', { count: remaining })}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</AppShell>
|
||||
<ManagementPage active="library" title="素材库" scene="gold">
|
||||
{#snippet tools()}<div class="mg-seg asset-tabs" aria-label="素材类型">
|
||||
{#each [["all", "全部"], ["image", "图片"], ["video", "视频"]] as [value, label]}<button
|
||||
class:on={filter === value}
|
||||
aria-pressed={filter === value}
|
||||
onclick={() => (filter = value as typeof filter)}>{label}</button
|
||||
>{/each}<button disabled title="当前素材接口不提供音频索引">音频</button
|
||||
><button disabled title="当前素材接口不提供项目归属信息">未归属</button>
|
||||
</div>{/snippet}
|
||||
{#snippet actions()}
|
||||
<span class="mg-dim">{filtered.length} 项素材</span><label class="mg-dim"
|
||||
><input type="checkbox" checked={allSelected} onchange={toggleAll} />
|
||||
{allSelected ? t("library.deselectAll") : t("library.selectAll")}</label
|
||||
>
|
||||
<span class="mg-tag" title="当前素材索引没有项目归属信息">项目:全部</span
|
||||
>{#if selectedCount}<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => (confirmingDelete = true)}
|
||||
>删除已选 {selectedCount} 项</Button
|
||||
>{/if}{/snippet}
|
||||
|
||||
{#if $mediaQuery.isLoading}<p class="mg-empty">
|
||||
正在读取素材…
|
||||
</p>{:else if $mediaQuery.isError}<div class="mg-empty" role="alert">
|
||||
素材加载失败<Button onclick={() => $mediaQuery.refetch()}>重试</Button>
|
||||
</div>{:else if !visible.length}<p class="mg-empty">
|
||||
还没有素材。创作台生成的作品会在这里保留。
|
||||
</p>{:else}
|
||||
<div class="mg-media">
|
||||
{#each visible as item (item.path)}<article class="asset-card">
|
||||
<button
|
||||
class="asset-preview"
|
||||
onclick={() => openPreview(item)}
|
||||
aria-label={"预览 " + item.name}
|
||||
><SafeMedia
|
||||
src={assetUrl(item.path)}
|
||||
kind={item.kind}
|
||||
alt={item.name}
|
||||
controls={false}
|
||||
/></button
|
||||
>
|
||||
<div class="asset-meta">
|
||||
<label class="mg-name"
|
||||
><input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.path)}
|
||||
onchange={() => toggle(item.path)}
|
||||
/><b title={item.name}>{item.name}</b><small
|
||||
>{item.kind} · {fmtSize(item.size)}</small
|
||||
></label
|
||||
><Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => (attachItem = item)}>存入项目</Button
|
||||
>
|
||||
</div>
|
||||
</article>{/each}
|
||||
{#if hasMore}<div bind:this={sentinel}>
|
||||
<Button loading={loadingMore} onclick={loadMore}
|
||||
>再显示 {Math.min(50, remaining)} 项</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</div>{/if}
|
||||
</ManagementPage>
|
||||
{#if preview}
|
||||
<ImageLightbox
|
||||
src={preview.src}
|
||||
alt={preview.name}
|
||||
kind={preview.kind}
|
||||
caption={preview.name}
|
||||
onClose={() => (preview = null)}
|
||||
/>
|
||||
<ImageLightbox
|
||||
src={preview.src}
|
||||
alt={preview.name}
|
||||
kind={preview.kind}
|
||||
caption={preview.name}
|
||||
onClose={() => (preview = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Modal
|
||||
open={attachItem !== null}
|
||||
title={t('library.addToProject')}
|
||||
onclose={() => (attachItem = null)}
|
||||
open={attachItem !== null}
|
||||
title={t("library.addToProject")}
|
||||
onclose={() => (attachItem = null)}
|
||||
>
|
||||
{#if attachItem}
|
||||
<p class="modal-file">{attachItem.name}</p>
|
||||
<AttachToProject path={attachItem.path} kind={attachItem.kind} />
|
||||
{/if}
|
||||
{#if attachItem}
|
||||
<p class="modal-file">{attachItem.name}</p>
|
||||
<AttachToProject path={attachItem.path} kind={attachItem.kind} />
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmingDelete}
|
||||
title={t('library.deleteSelected', { count: selectedCount })}
|
||||
message={t('library.confirmDelete', { count: selectedCount })}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger={true}
|
||||
onconfirm={deleteSelected}
|
||||
oncancel={() => (confirmingDelete = false)}
|
||||
open={confirmingDelete}
|
||||
title={t("library.deleteSelected", { count: selectedCount })}
|
||||
message={t("library.confirmDelete", { count: selectedCount })}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
danger={true}
|
||||
onconfirm={deleteSelected}
|
||||
oncancel={() => (confirmingDelete = false)}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.library {
|
||||
padding: 20px 28px 40px;
|
||||
max-width: 1560px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-6);
|
||||
margin-bottom: var(--sp-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip-count {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
font-size: var(--fs-body-sm);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.select-all input {
|
||||
accent-color: var(--accent-lo);
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-lg);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--d-state) ease;
|
||||
}
|
||||
.card.selected {
|
||||
background: linear-gradient(180deg, var(--accent-soft), transparent 32%), var(--bg-2);
|
||||
}
|
||||
.card :global(.media-el) {
|
||||
object-fit: cover;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 240px;
|
||||
}
|
||||
.check {
|
||||
position: absolute;
|
||||
top: var(--sp-3);
|
||||
left: var(--sp-3);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: var(--media-scrim);
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.check input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: var(--accent-lo);
|
||||
cursor: pointer;
|
||||
}
|
||||
.media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--media-scrim);
|
||||
cursor: zoom-in;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 相纸白边 (HEL-680):素材墙里的作品按相纸呈现 */
|
||||
.media.a-photo {
|
||||
border: var(--paper-w, 4px) solid var(--accent);
|
||||
border-bottom-width: calc(var(--paper-w, 4px) + 2px);
|
||||
background: var(--accent);
|
||||
border-radius: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.media :global(img),
|
||||
.media :global(video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--sp-4) var(--sp-5) var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-size: var(--fs-body);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub {
|
||||
font-size: var(--fs-mini);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.card-actions {
|
||||
padding: var(--sp-3) var(--sp-5) var(--sp-5);
|
||||
}
|
||||
.load-more {
|
||||
height: 1px;
|
||||
}
|
||||
.more-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-6) 0 var(--sp-2);
|
||||
}
|
||||
.loading-note {
|
||||
font-size: var(--fs-body-sm);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.modal-file {
|
||||
margin: 0 0 var(--sp-4);
|
||||
font-size: var(--fs-aux);
|
||||
color: var(--text-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.library {
|
||||
padding: var(--sp-5) var(--sp-5) var(--sp-8);
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
}
|
||||
|
||||
.library {
|
||||
padding: 28px 36px 56px;
|
||||
}
|
||||
.refract-library-title {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 1.3px;
|
||||
}
|
||||
.refract-library-title h1 {
|
||||
margin: 8px 0 7px;
|
||||
font-size: 34px;
|
||||
letter-spacing: -1.3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.refract-library-title p {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
}
|
||||
.resource-tabs {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.resource-tabs a {
|
||||
padding: 9px 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
border-bottom: 2px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
.resource-tabs a.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.library {
|
||||
max-width: none;
|
||||
padding: 24px 28px 30px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.asset-tabs {
|
||||
width: 300px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.asset-card {
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.asset-preview {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: var(--media-bg);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
.asset-preview :global(img),
|
||||
.asset-preview :global(video) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.asset-meta {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.asset-meta label {
|
||||
flex: 1;
|
||||
}
|
||||
.asset-meta input {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
}
|
||||
.modal-file {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/svelte-query";
|
||||
import AppShell from "$lib/components/shell/AppShell.svelte";
|
||||
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
|
||||
import LanguageSwitcher from "$lib/components/LanguageSwitcher.svelte";
|
||||
import { reduceMotion, setReduceMotion } from "$lib/reduceMotion.svelte";
|
||||
import ThemeChoices from "$lib/components/shell/ThemeChoices.svelte";
|
||||
import MemoryPanel from "$lib/components/settings/MemoryPanel.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
@@ -58,37 +60,11 @@
|
||||
},
|
||||
];
|
||||
|
||||
const ELSEWHERE: {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
blurbKey: string;
|
||||
icon: IconName;
|
||||
}[] = [
|
||||
{
|
||||
href: "/models",
|
||||
labelKey: "shell.navModels",
|
||||
blurbKey: "settings.linkModelsBlurb",
|
||||
icon: "assets",
|
||||
},
|
||||
{
|
||||
href: "/connections",
|
||||
labelKey: "shell.navConnections",
|
||||
blurbKey: "settings.linkConnectionsBlurb",
|
||||
icon: "link",
|
||||
},
|
||||
{
|
||||
href: "/agents",
|
||||
labelKey: "shell.navAgents",
|
||||
blurbKey: "settings.linkAgentsBlurb",
|
||||
icon: "sparkle",
|
||||
},
|
||||
];
|
||||
|
||||
// Old Calliope tab links (/settings?tab=llm …) land on the page that now
|
||||
// owns that setting instead of a dead tab.
|
||||
const MOVED_TABS: Record<string, string> = {
|
||||
llm: "/models",
|
||||
workflows: "/connections",
|
||||
workflows: "/workflows",
|
||||
skills: "/agents",
|
||||
};
|
||||
|
||||
@@ -125,24 +101,6 @@
|
||||
);
|
||||
const isDirty = $derived(dirtyKeys.length > 0);
|
||||
|
||||
const FIELD_SECTION: Record<string, Section> = {
|
||||
dry_run: "queue",
|
||||
queue_concurrency: "queue",
|
||||
queue_poll_interval_sec: "queue",
|
||||
queue_poll_timeout_sec: "queue",
|
||||
queue_max_retries: "queue",
|
||||
agent_max_steps: "agent",
|
||||
agent_hardening_prompt: "agent",
|
||||
agent_history_char_budget: "agent",
|
||||
agent_shell_enabled: "agent",
|
||||
data_dir: "storage",
|
||||
assets_dir: "storage",
|
||||
agent_workspace_dir: "storage",
|
||||
};
|
||||
const dirtySections = $derived(
|
||||
new Set(dirtyKeys.map((k) => FIELD_SECTION[k]).filter(Boolean)),
|
||||
);
|
||||
|
||||
// Mirrors backend Field(ge=..., le=...) limits — validated client-side so
|
||||
// save never trips a raw 422.
|
||||
const NUMERIC_LIMITS: Record<
|
||||
@@ -339,14 +297,6 @@
|
||||
return s.agent_hardening_prompt ?? "";
|
||||
}
|
||||
|
||||
function selectSection(id: Section) {
|
||||
goto(`/settings?tab=${id}`, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
type NumField = {
|
||||
key: keyof Settings & string;
|
||||
labelKey: string;
|
||||
@@ -379,7 +329,6 @@
|
||||
</script>
|
||||
|
||||
<svelte:window onbeforeunload={onBeforeUnload} />
|
||||
|
||||
{#snippet numberField(s: Settings, f: NumField)}
|
||||
{@const limits = NUMERIC_LIMITS[f.key]}
|
||||
<label class="field">
|
||||
@@ -409,266 +358,263 @@
|
||||
</label>
|
||||
{/snippet}
|
||||
|
||||
<AppShell
|
||||
active="settings"
|
||||
title={t("shell.navSettings")}
|
||||
subtitle={t("settings.pageSub")}
|
||||
>
|
||||
{#snippet actions()}
|
||||
<span class="status-chip" class:accent={isDirty}>
|
||||
{#if isDirty}<span class="status-chip-dot" aria-hidden="true"></span>{/if}
|
||||
{isDirty ? t("settings.unsaved") : t("settings.allSaved")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!isDirty || $saveMutation.isPending}
|
||||
onclick={discardDraft}
|
||||
>
|
||||
{t("settings.discard")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!isDirty || !isValid}
|
||||
loading={$saveMutation.isPending}
|
||||
onclick={() => $saveMutation.mutate()}
|
||||
>
|
||||
{t("settings.saveChanges")}
|
||||
</Button>
|
||||
{/snippet}
|
||||
|
||||
<div class="page">
|
||||
<nav class="sections" aria-label={t("shell.navSettings")}>
|
||||
{#each SECTIONS as sec (sec.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="sec"
|
||||
class:active={tab === sec.id}
|
||||
aria-current={tab === sec.id ? "page" : undefined}
|
||||
onclick={() => selectSection(sec.id)}
|
||||
>
|
||||
<span class="sec-icon"><Icon name={sec.icon} size={15} /></span>
|
||||
<span class="sec-copy">
|
||||
<span class="sec-label">
|
||||
{sec.id === "appearance" ? "外观主题" : t(sec.labelKey)}
|
||||
{#if dirtySections.has(sec.id)}
|
||||
<span class="dirty-dot" title={t("settings.unsaved")}></span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="sec-blurb"
|
||||
>{sec.id === "appearance"
|
||||
? "选择片场灯光"
|
||||
: t(sec.blurbKey)}</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<p class="elsewhere-head">{t("settings.elsewhere")}</p>
|
||||
{#each ELSEWHERE as link (link.href)}
|
||||
<a class="sec link" href={link.href}>
|
||||
<span class="sec-icon"><Icon name={link.icon} size={15} /></span>
|
||||
<span class="sec-copy">
|
||||
<span class="sec-label">{t(link.labelKey)}</span>
|
||||
<span class="sec-blurb">{t(link.blurbKey)}</span>
|
||||
</span>
|
||||
<span class="sec-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
{#if tab === "appearance"}
|
||||
<section class="a-panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="a-panel-title">外观主题</h2>
|
||||
<p class="a-panel-lead">同一个片场,换一种灯光。</p>
|
||||
</header>
|
||||
<ThemeChoices />
|
||||
</section>
|
||||
{:else if $settingsQuery.isLoading}
|
||||
<p class="muted">{t("settings.loading")}</p>
|
||||
{:else if $settingsQuery.isError}
|
||||
<div class="a-panel error-panel" role="alert">
|
||||
<Icon name="alert" size={16} />
|
||||
<p>{t("settings.loadFailed")}</p>
|
||||
<Button size="sm" onclick={() => $settingsQuery.refetch()}
|
||||
>{t("common.retry")}</Button
|
||||
<ManagementPage active="settings" title="设置">
|
||||
<div class="settings-scroll">
|
||||
<div class="settings-grid">
|
||||
<section class="mg-field wide">
|
||||
<h3>
|
||||
外观主题 <span class="mg-dim"
|
||||
>· 同一布局与功能;切换不影响正在编辑的内容,选择会被记住</span
|
||||
>
|
||||
</div>
|
||||
{:else if $settingsQuery.data}
|
||||
{@const s = $settingsQuery.data}
|
||||
{#if tab === "queue"}
|
||||
<section class="a-panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="a-panel-title">{t("settings.queueSection")}</h2>
|
||||
<p class="a-panel-lead">{t("settings.queueLead")}</p>
|
||||
</header>
|
||||
<div class="grid-2">
|
||||
{#each QUEUE_FIELDS as f (f.key)}
|
||||
{@render numberField(s, f)}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="a-panel">
|
||||
<div class="switch-row">
|
||||
<div class="switch-copy">
|
||||
<h2 class="a-panel-title">{t("settings.dryRunTitle")}</h2>
|
||||
<p class="a-panel-lead">{t("settings.dryRunHint")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={boolValue(s, "dry_run")}
|
||||
label={t("settings.dryRunLabel")}
|
||||
onchange={(next) => (draft.dry_run = next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{:else if tab === "agent"}
|
||||
<section class="a-panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="a-panel-title">{t("settings.agentRunSection")}</h2>
|
||||
<p class="a-panel-lead">{t("settings.agentRunLead")}</p>
|
||||
</header>
|
||||
<div class="grid-2">
|
||||
{@render numberField(s, {
|
||||
key: "agent_max_steps",
|
||||
labelKey: "settings.agentMaxStepsField",
|
||||
hintKey: "settings.agentMaxStepsHint",
|
||||
step: "1",
|
||||
})}
|
||||
<div class="budget">
|
||||
{@render numberField(s, {
|
||||
key: "agent_history_char_budget",
|
||||
labelKey: "settings.historyBudgetField",
|
||||
step: "1000",
|
||||
})}
|
||||
<div class="presets">
|
||||
{#each HISTORY_BUDGET_PRESETS as preset (preset.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="preset"
|
||||
class:active={historyBudgetActive(s, preset.value)}
|
||||
onclick={() =>
|
||||
(draft.agent_history_char_budget = String(
|
||||
preset.value,
|
||||
))}
|
||||
>
|
||||
{t(preset.labelKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</h3>
|
||||
<ThemeChoices tiles />
|
||||
</section>
|
||||
<section class="mg-field wide">
|
||||
<h3>界面语言 <span class="mg-tag">规则说明</span></h3>
|
||||
<LanguageSwitcher segmented />
|
||||
</section>
|
||||
{#if $settingsQuery.isLoading}<p class="mg-empty">
|
||||
正在读取设置…
|
||||
</p>{:else if $settingsQuery.isError}<p role="alert">
|
||||
设置加载失败<Button onclick={() => $settingsQuery.refetch()}
|
||||
>重试</Button
|
||||
>
|
||||
</p>{:else if $settingsQuery.data}{@const s = $settingsQuery.data}
|
||||
<section class="mg-field">
|
||||
<h3>任务队列 <span class="mg-tag">规则说明</span></h3>
|
||||
<div class="queue-fields">
|
||||
{#each QUEUE_FIELDS.filter((f) => f.key === "queue_concurrency" || f.key === "queue_poll_timeout_sec") as f}<div
|
||||
class="queue-row"
|
||||
>
|
||||
<label for={"setting-" + f.key}>{t(f.labelKey)}</label>
|
||||
<div class="mg-step">
|
||||
<button
|
||||
aria-label={"减少" + t(f.labelKey)}
|
||||
disabled={Number(fieldValue(f.key, s[f.key] as number)) <=
|
||||
NUMERIC_LIMITS[f.key].min}
|
||||
onclick={() =>
|
||||
(draft[f.key] = String(
|
||||
Math.max(
|
||||
NUMERIC_LIMITS[f.key].min,
|
||||
Number(fieldValue(f.key, s[f.key] as number)) - 1,
|
||||
),
|
||||
))}>−</button
|
||||
><input
|
||||
id={"setting-" + f.key}
|
||||
type="number"
|
||||
min={NUMERIC_LIMITS[f.key].min}
|
||||
max={NUMERIC_LIMITS[f.key].max}
|
||||
value={String(fieldValue(f.key, s[f.key] as number))}
|
||||
oninput={(e) => (draft[f.key] = e.currentTarget.value)}
|
||||
onblur={(e) => clampOnBlur(e, f.key)}
|
||||
/><button
|
||||
aria-label={"增加" + t(f.labelKey)}
|
||||
disabled={Number(fieldValue(f.key, s[f.key] as number)) >=
|
||||
NUMERIC_LIMITS[f.key].max}
|
||||
onclick={() =>
|
||||
(draft[f.key] = String(
|
||||
Math.min(
|
||||
NUMERIC_LIMITS[f.key].max,
|
||||
Number(fieldValue(f.key, s[f.key] as number)) + 1,
|
||||
),
|
||||
))}>+</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint">{t("settings.historyBudgetHint")}</p>
|
||||
</section>
|
||||
|
||||
<section class="a-panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="a-panel-title">{t("settings.hardeningSection")}</h2>
|
||||
<p class="a-panel-lead">
|
||||
{t("settings.hardeningLead")}
|
||||
{t("settings.hardeningCalloutStrong")}{t(
|
||||
"settings.hardeningCalloutBody",
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
<label class="field">
|
||||
<span class="field-label"
|
||||
>{t("settings.hardeningRulesLabel")}</span
|
||||
{#if validationErrors[f.key]}<p class="field-error">
|
||||
{validationErrors[f.key]}
|
||||
</p>{/if}{/each}
|
||||
</div>
|
||||
</section>
|
||||
<section class="mg-field">
|
||||
<h3>动效强度</h3>
|
||||
<div class="mg-options">
|
||||
<button
|
||||
class="mg-option"
|
||||
class:on={!reduceMotion.system && !reduceMotion.manual}
|
||||
aria-pressed={!reduceMotion.system && !reduceMotion.manual}
|
||||
disabled={reduceMotion.system}
|
||||
onclick={() => setReduceMotion(false)}>完整</button
|
||||
><button
|
||||
class="mg-option"
|
||||
disabled
|
||||
title="当前动效偏好只提供完整和减少动效,克制档尚未实现"
|
||||
>克制</button
|
||||
><button
|
||||
class="mg-option"
|
||||
class:on={reduceMotion.system || reduceMotion.manual}
|
||||
aria-pressed={reduceMotion.system || reduceMotion.manual}
|
||||
onclick={() => setReduceMotion(true)}>关闭(减少动效)</button
|
||||
>
|
||||
</div>
|
||||
<p class="mg-dim">
|
||||
系统开启“减少动态效果”时自动关闭;主题切换保留当前编辑内容。
|
||||
</p>
|
||||
</section>
|
||||
<section class="mg-field">
|
||||
<h3>Dry-run <span class="mg-tag warn">测试用</span></h3>
|
||||
<div class="mg-row">
|
||||
<span class="mg-dim">只写占位文件,不调用 ComfyUI</span><span
|
||||
class="mg-grow"
|
||||
></span><Switch
|
||||
checked={boolValue(s, "dry_run")}
|
||||
label="Dry-run"
|
||||
onchange={(next) => (draft.dry_run = next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mg-field">
|
||||
<h3>存储路径 <span class="mg-tag">规则说明</span></h3>
|
||||
<input
|
||||
class="a-input"
|
||||
aria-label={t("settings.dataDir")}
|
||||
value={String(fieldValue("data_dir", s.data_dir))}
|
||||
oninput={(e) => (draft.data_dir = e.currentTarget.value)}
|
||||
/>
|
||||
</section>
|
||||
<details
|
||||
class="advanced wide"
|
||||
open={tab === "agent" || tab === "storage"}
|
||||
>
|
||||
<summary>高级设置 · 队列 / 存储 / 智能体</summary>
|
||||
<div class="advanced-grid">
|
||||
<section class="mg-field">
|
||||
<h3>队列高级选项</h3>
|
||||
{#each QUEUE_FIELDS.filter((f) => f.key !== "queue_concurrency" && f.key !== "queue_poll_timeout_sec") as f}{@render numberField(
|
||||
s,
|
||||
f,
|
||||
)}{/each}
|
||||
</section>
|
||||
<section class="mg-field">
|
||||
<h3>素材与工作目录</h3>
|
||||
<label class="field"
|
||||
><span class="field-label">{t("settings.assetsDir")}</span
|
||||
><input
|
||||
class="a-input"
|
||||
value={String(fieldValue("assets_dir", s.assets_dir))}
|
||||
oninput={(e) => (draft.assets_dir = e.currentTarget.value)}
|
||||
/></label
|
||||
><label class="field"
|
||||
><span class="field-label">{t("settings.workspaceDir")}</span
|
||||
><input
|
||||
class="a-input"
|
||||
value={String(
|
||||
fieldValue("agent_workspace_dir", s.agent_workspace_dir),
|
||||
)}
|
||||
oninput={(e) =>
|
||||
(draft.agent_workspace_dir = e.currentTarget.value)}
|
||||
/></label
|
||||
>
|
||||
<textarea
|
||||
class="a-textarea mono"
|
||||
rows={10}
|
||||
spellcheck="false"
|
||||
value={hardeningDraft(s)}
|
||||
oninput={(e) =>
|
||||
(draft.agent_hardening_prompt = e.currentTarget.value)}
|
||||
placeholder={t("settings.hardeningPlaceholder")}
|
||||
></textarea>
|
||||
<p class="field-hint">{t("settings.hardeningHint")}</p>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="a-panel">
|
||||
<div class="switch-row">
|
||||
<div class="switch-copy">
|
||||
<h2 class="a-panel-title">{t("settings.shellSection")}</h2>
|
||||
<p class="a-panel-lead">{t("settings.shellLead")}</p>
|
||||
<p class="mg-dim">保存新路径不会迁移已有文件。</p>
|
||||
</section>
|
||||
<section class="mg-field">
|
||||
<header class="panel-head">
|
||||
<h2 class="a-panel-title">{t("settings.agentRunSection")}</h2>
|
||||
<p class="a-panel-lead">{t("settings.agentRunLead")}</p>
|
||||
</header>
|
||||
<div class="grid-2">
|
||||
{@render numberField(s, {
|
||||
key: "agent_max_steps",
|
||||
labelKey: "settings.agentMaxStepsField",
|
||||
hintKey: "settings.agentMaxStepsHint",
|
||||
step: "1",
|
||||
})}
|
||||
<div class="budget">
|
||||
{@render numberField(s, {
|
||||
key: "agent_history_char_budget",
|
||||
labelKey: "settings.historyBudgetField",
|
||||
step: "1000",
|
||||
})}
|
||||
<div class="presets">
|
||||
{#each HISTORY_BUDGET_PRESETS as preset (preset.value)}
|
||||
<button
|
||||
type="button"
|
||||
class="preset"
|
||||
class:active={historyBudgetActive(s, preset.value)}
|
||||
onclick={() =>
|
||||
(draft.agent_history_char_budget = String(
|
||||
preset.value,
|
||||
))}
|
||||
>
|
||||
{t(preset.labelKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={boolValue(s, "agent_shell_enabled")}
|
||||
label={t("settings.shellLabel")}
|
||||
onchange={(next) => (draft.agent_shell_enabled = next)}
|
||||
/>
|
||||
</div>
|
||||
<div class="note warn">
|
||||
<Icon name="alert" size={14} />
|
||||
<p>
|
||||
<strong>{t("settings.shellCalloutStrong")}</strong>
|
||||
{t("settings.shellCalloutBody")}
|
||||
</p>
|
||||
</div>
|
||||
<p class="field-hint">{t("settings.shellHint")}</p>
|
||||
</section>
|
||||
<p class="field-hint">{t("settings.historyBudgetHint")}</p>
|
||||
</section>
|
||||
|
||||
<MemoryPanel />
|
||||
{:else if tab === "storage"}
|
||||
<section class="a-panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="a-panel-title">{t("settings.storageSection")}</h2>
|
||||
<p class="a-panel-lead">{t("settings.storageLead")}</p>
|
||||
</header>
|
||||
<div class="note warn">
|
||||
<Icon name="alert" size={14} />
|
||||
<p>
|
||||
<strong>{t("settings.storageCalloutStrong")}</strong>
|
||||
{t("settings.storageCalloutBody")}
|
||||
</p>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span class="field-label">{t("settings.dataDir")}</span>
|
||||
<input
|
||||
class="a-input mono"
|
||||
value={String(fieldValue("data_dir", s.data_dir))}
|
||||
oninput={(e) => (draft.data_dir = e.currentTarget.value)}
|
||||
/>
|
||||
<p class="field-hint">
|
||||
{t("settings.current")} <code>{s.data_dir}</code>
|
||||
</p>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t("settings.assetsDir")}</span>
|
||||
<input
|
||||
class="a-input mono"
|
||||
value={String(fieldValue("assets_dir", s.assets_dir))}
|
||||
oninput={(e) => (draft.assets_dir = e.currentTarget.value)}
|
||||
/>
|
||||
<p class="field-hint">
|
||||
{t("settings.current")} <code>{s.assets_dir}</code>
|
||||
</p>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">{t("settings.workspaceDir")}</span>
|
||||
<input
|
||||
class="a-input mono"
|
||||
value={String(
|
||||
fieldValue("agent_workspace_dir", s.agent_workspace_dir),
|
||||
)}
|
||||
oninput={(e) =>
|
||||
(draft.agent_workspace_dir = e.currentTarget.value)}
|
||||
/>
|
||||
<p class="field-hint">{t("settings.workspaceDirHint")}</p>
|
||||
<p class="field-hint">
|
||||
{t("settings.current")} <code>{s.agent_workspace_dir}</code>
|
||||
</p>
|
||||
</label>
|
||||
</section>
|
||||
{/if}
|
||||
<section class="mg-field">
|
||||
<header class="panel-head">
|
||||
<h2 class="a-panel-title">{t("settings.hardeningSection")}</h2>
|
||||
<p class="a-panel-lead">
|
||||
{t("settings.hardeningLead")}
|
||||
{t("settings.hardeningCalloutStrong")}{t(
|
||||
"settings.hardeningCalloutBody",
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
<label class="field">
|
||||
<span class="field-label"
|
||||
>{t("settings.hardeningRulesLabel")}</span
|
||||
>
|
||||
<textarea
|
||||
class="a-textarea mono"
|
||||
rows={10}
|
||||
spellcheck="false"
|
||||
value={hardeningDraft(s)}
|
||||
oninput={(e) =>
|
||||
(draft.agent_hardening_prompt = e.currentTarget.value)}
|
||||
placeholder={t("settings.hardeningPlaceholder")}
|
||||
></textarea>
|
||||
<p class="field-hint">{t("settings.hardeningHint")}</p>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="mg-field">
|
||||
<div class="switch-row">
|
||||
<div class="switch-copy">
|
||||
<h2 class="a-panel-title">{t("settings.shellSection")}</h2>
|
||||
<p class="a-panel-lead">{t("settings.shellLead")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={boolValue(s, "agent_shell_enabled")}
|
||||
label={t("settings.shellLabel")}
|
||||
onchange={(next) => (draft.agent_shell_enabled = next)}
|
||||
/>
|
||||
</div>
|
||||
<div class="note warn">
|
||||
<Icon name="alert" size={14} />
|
||||
<p>
|
||||
<strong>{t("settings.shellCalloutStrong")}</strong>
|
||||
{t("settings.shellCalloutBody")}
|
||||
</p>
|
||||
</div>
|
||||
<p class="field-hint">{t("settings.shellHint")}</p>
|
||||
</section>
|
||||
|
||||
<MemoryPanel />
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<footer class="settings-save">
|
||||
<span class="mg-dim" role="status"
|
||||
>{isDirty ? t("settings.unsaved") : t("settings.allSaved")}</span
|
||||
><span class="mg-grow"></span><Button
|
||||
variant="ghost"
|
||||
disabled={!isDirty || $saveMutation.isPending}
|
||||
onclick={discardDraft}>放弃修改</Button
|
||||
><Button
|
||||
variant="primary"
|
||||
disabled={!isDirty || !isValid}
|
||||
loading={$saveMutation.isPending}
|
||||
onclick={() => $saveMutation.mutate()}>保存设置</Button
|
||||
>
|
||||
</footer>
|
||||
</ManagementPage>
|
||||
<ConfirmDialog
|
||||
bind:open={leaveOpen}
|
||||
title={t("settings.leaveTitle")}
|
||||
@@ -680,228 +626,115 @@
|
||||
/>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 24px 28px 40px;
|
||||
align-items: flex-start;
|
||||
.settings-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ---------------- section list ---------------- */
|
||||
.sections {
|
||||
width: 248px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
.settings-grid {
|
||||
box-sizing: border-box;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.sec {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 150ms;
|
||||
max-width: 980px;
|
||||
margin: 20px auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.sec:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
.wide {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
.sec.active {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
.advanced-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
.sec-icon {
|
||||
display: inline-flex;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sec.active .sec-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
.sec-copy {
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.sec-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sec-blurb {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.sec-arrow {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.dirty-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.elsewhere-head {
|
||||
margin: 12px 10px 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ---------------- content ---------------- */
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 820px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.panel-head {
|
||||
margin-bottom: 16px;
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
margin: 0;
|
||||
}
|
||||
.field-error {
|
||||
font-size: 11px;
|
||||
color: var(--err);
|
||||
}
|
||||
.invalid {
|
||||
border-color: var(--err);
|
||||
}
|
||||
.settings-save {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bg-1);
|
||||
}
|
||||
.advanced summary {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--tx-2);
|
||||
padding: 12px;
|
||||
}
|
||||
.advanced-grid {
|
||||
padding-top: 12px;
|
||||
}
|
||||
.panel-head h2 {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
.a-panel-lead,
|
||||
.note,
|
||||
.field-hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
.budget .presets {
|
||||
margin: -6px 0 14px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
/* §4.3 分段 chip: selected = amber outline + amber text + dim fill */
|
||||
.preset {
|
||||
padding: 3px 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms,
|
||||
color 150ms,
|
||||
background-color 150ms;
|
||||
}
|
||||
.preset:hover {
|
||||
border-color: var(--border-hover);
|
||||
color: var(--text);
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--tx-2);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 11px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.preset.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border-color: var(--gold);
|
||||
}
|
||||
.note {
|
||||
.queue-fields {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 14px 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.note.warn :global(.icon) {
|
||||
color: var(--warn);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.note p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.note strong {
|
||||
color: var(--text);
|
||||
}
|
||||
.field-hint code {
|
||||
color: var(--text-2);
|
||||
word-break: break-all;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.error-panel {
|
||||
.queue-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--error);
|
||||
}
|
||||
.error-panel p {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* ---------------- responsive (§4.5) ---------------- */
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
flex-direction: column;
|
||||
padding: 18px;
|
||||
}
|
||||
.sections {
|
||||
width: 100%;
|
||||
position: static;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sec {
|
||||
width: auto;
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.elsewhere-head {
|
||||
flex-basis: 100%;
|
||||
margin: 6px 10px 0;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.content {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
.grid-2 {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.page {
|
||||
max-width: 1280px;
|
||||
padding: 24px 28px 30px;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,604 +1,349 @@
|
||||
<script lang="ts">
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import Icon from '$lib/components/ui/Icon.svelte';
|
||||
import {
|
||||
apiErrorMessage,
|
||||
assetUrl,
|
||||
jobsApi,
|
||||
playgroundApi,
|
||||
projects,
|
||||
workflows,
|
||||
type Job,
|
||||
} from '$lib/api';
|
||||
import { connectEvents } from '$lib/events';
|
||||
import { parseDbTs } from '$lib/dbtime';
|
||||
import { handleJobEvent, progressFor } from '$lib/jobProgress';
|
||||
import { jobStatusInfo } from '$lib/jobStatus';
|
||||
import { isFileMissing, markOutputMissing } from '$lib/missingOutputs.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import { t } from '$lib/i18n.svelte';
|
||||
import { isStandaloneJob, isWorkflowTestJob, jobSourceLabel } from '$lib/jobLabels';
|
||||
import { createTakeDeleteQueue } from '$lib/components/generate/takeDelete.svelte';
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import ManagementPage from "$lib/components/manage/ManagementPage.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import {
|
||||
apiErrorMessage,
|
||||
assetUrl,
|
||||
jobsApi,
|
||||
projects,
|
||||
workflows,
|
||||
type Job,
|
||||
} from "$lib/api";
|
||||
import { connectEvents } from "$lib/events";
|
||||
import { parseDbTs } from "$lib/dbtime";
|
||||
import { handleJobEvent, progressFor } from "$lib/jobProgress";
|
||||
import { jobStatusInfo } from "$lib/jobStatus";
|
||||
import { isFileMissing, markOutputMissing } from "$lib/missingOutputs.svelte";
|
||||
import { toast } from "$lib/toast";
|
||||
import { t } from "$lib/i18n.svelte";
|
||||
import {
|
||||
isStandaloneJob,
|
||||
isWorkflowTestJob,
|
||||
jobSourceLabel,
|
||||
} from "$lib/jobLabels";
|
||||
import { createTakeDeleteQueue } from "$lib/components/generate/takeDelete.svelte";
|
||||
|
||||
/**
|
||||
* 生成任务页 (spec-v2 §3.5, HEL-612/654 D 批): one table summing both
|
||||
* task classes — image and video — from standalone windows, the
|
||||
* creation flow, and Playground. Filter chips + search; running rows
|
||||
* carry a 2px progress bar; 重试 is a ghost text op (spec v2 §1.2);
|
||||
* deleting one task is record-level: 200ms collapse + 5s undo Toast,
|
||||
* no dialog. The real DELETE only fires after the undo window.
|
||||
*/
|
||||
const client = useQueryClient();
|
||||
/** Three-column real tasks: source filters, queue list and details. Existing SSE, retry, cancellation and undo-delete behavior is preserved. */
|
||||
const client = useQueryClient();
|
||||
|
||||
const jobsQuery = createQuery({
|
||||
queryKey: ['jobs'],
|
||||
queryFn: () => jobsApi.list(),
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
const workflowsQuery = createQuery({ queryKey: ['workflows'], queryFn: workflows.listMeta });
|
||||
const projectsQuery = createQuery({ queryKey: ['projects'], queryFn: projects.list });
|
||||
const jobsQuery = createQuery({
|
||||
queryKey: ["jobs"],
|
||||
queryFn: () => jobsApi.list(),
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
const workflowsQuery = createQuery({
|
||||
queryKey: ["workflows"],
|
||||
queryFn: workflows.listMeta,
|
||||
});
|
||||
const projectsQuery = createQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: projects.list,
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const off = connectEvents((ev) => {
|
||||
handleJobEvent(ev);
|
||||
if (ev.type.startsWith('job.') || ev.type === 'events.resync') {
|
||||
void client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
void client.invalidateQueries({ queryKey: ['playground-jobs'] });
|
||||
}
|
||||
});
|
||||
return off;
|
||||
});
|
||||
$effect(() => {
|
||||
const off = connectEvents((ev) => {
|
||||
handleJobEvent(ev);
|
||||
if (ev.type.startsWith("job.") || ev.type === "events.resync") {
|
||||
void client.invalidateQueries({ queryKey: ["jobs"] });
|
||||
void client.invalidateQueries({ queryKey: ["playground-jobs"] });
|
||||
}
|
||||
});
|
||||
return off;
|
||||
});
|
||||
|
||||
type Filter = 'all' | 'image' | 'video' | 'failed' | 'active';
|
||||
let filter = $state<Filter>('all');
|
||||
let search = $state('');
|
||||
type Filter = "all" | "image" | "video" | "failed" | "active";
|
||||
let filter = $state<Filter>("all");
|
||||
let search = $state("");
|
||||
|
||||
const deleteQueue = createTakeDeleteQueue(client);
|
||||
const pendingIds = deleteQueue.pending;
|
||||
$effect(() => deleteQueue.destroy);
|
||||
const deleteQueue = createTakeDeleteQueue(client);
|
||||
const pendingIds = deleteQueue.pending;
|
||||
$effect(() => deleteQueue.destroy);
|
||||
|
||||
const genJobs = $derived(($jobsQuery.data ?? []).filter((j) => j.kind === 'image' || j.kind === 'video'));
|
||||
const shown = $derived.by(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return genJobs.filter((j) => {
|
||||
if (pendingIds.has(j.id)) return false;
|
||||
if (filter === 'image' || filter === 'video') {
|
||||
if (j.kind !== filter) return false;
|
||||
} else if (filter === 'failed' && jobStatusInfo(j).tone !== 'fail') return false;
|
||||
else if (filter === 'active' && j.status !== 'pending' && j.status !== 'running') return false;
|
||||
if (!q) return true;
|
||||
return `${wfName(j)} ${jobSourceLabel(j)} ${j.error ?? ''} ${j.id}`.toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
const activeCount = $derived(genJobs.filter((j) => j.status === 'pending' || j.status === 'running').length);
|
||||
const genJobs = $derived(
|
||||
($jobsQuery.data ?? []).filter(
|
||||
(j) => j.kind === "image" || j.kind === "video",
|
||||
),
|
||||
);
|
||||
const shown = $derived.by(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return genJobs.filter((j) => {
|
||||
if (!matchesSource(j, sourceFilter)) return false;
|
||||
if (pendingIds.has(j.id)) return false;
|
||||
if (filter === "image" || filter === "video") {
|
||||
if (j.kind !== filter) return false;
|
||||
} else if (filter === "failed" && jobStatusInfo(j).tone !== "fail")
|
||||
return false;
|
||||
else if (
|
||||
filter === "active" &&
|
||||
j.status !== "pending" &&
|
||||
j.status !== "running"
|
||||
)
|
||||
return false;
|
||||
if (!q) return true;
|
||||
return `${wfName(j)} ${jobSourceLabel(j)} ${j.error ?? ""} ${j.id}`
|
||||
.toLowerCase()
|
||||
.includes(q);
|
||||
});
|
||||
});
|
||||
const activeCount = $derived(
|
||||
genJobs.filter((j) => j.status === "pending" || j.status === "running")
|
||||
.length,
|
||||
);
|
||||
|
||||
function wfName(job: Job): string {
|
||||
return (
|
||||
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)?.name ??
|
||||
t('studio.jobName', { id: job.id })
|
||||
);
|
||||
}
|
||||
function wfName(job: Job): string {
|
||||
return (
|
||||
($workflowsQuery.data ?? []).find((w) => w.id === job.workflow_id)
|
||||
?.name ?? t("studio.jobName", { id: job.id })
|
||||
);
|
||||
}
|
||||
|
||||
function projectOf(job: Job): string {
|
||||
if (isStandaloneJob(job)) return '';
|
||||
const title = ($projectsQuery.data ?? []).find((p) => p.id === job.project_id)?.title;
|
||||
return title ? `《${title}》` : '';
|
||||
}
|
||||
function projectOf(job: Job): string {
|
||||
if (isStandaloneJob(job)) return "";
|
||||
const title = ($projectsQuery.data ?? []).find(
|
||||
(p) => p.id === job.project_id,
|
||||
)?.title;
|
||||
return title ? `《${title}》` : "";
|
||||
}
|
||||
|
||||
function timeOf(job: Job): string {
|
||||
const ts = parseDbTs(job.created_at);
|
||||
if (!Number.isFinite(ts)) return '—';
|
||||
const d = new Date(ts);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
function timeOf(job: Job): string {
|
||||
const ts = parseDbTs(job.created_at);
|
||||
if (!Number.isFinite(ts)) return "—";
|
||||
const d = new Date(ts);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 2px 进度条贴行底 — real progress from the SSE store only. */
|
||||
function rowProgress(job: Job): number {
|
||||
if (job.status !== 'running') return 0;
|
||||
const live = progressFor(job.id);
|
||||
const phase = live?.phase ?? job.phase;
|
||||
if (phase !== 'comfy_running' && phase !== 'saving') return 0;
|
||||
return live?.progress ?? 3;
|
||||
}
|
||||
function toneOf(job: Job, base: string): string {
|
||||
if (base === "ok" && isFileMissing(job)) return "warn";
|
||||
if (base === "queue") return "";
|
||||
if (base === "fail") return "err";
|
||||
return base;
|
||||
}
|
||||
|
||||
function toneOf(job: Job, base: string): string {
|
||||
if (base === 'ok' && isFileMissing(job)) return 'warn';
|
||||
if (base === 'queue') return '';
|
||||
if (base === 'fail') return 'err';
|
||||
return base;
|
||||
}
|
||||
function labelOf(job: Job, base: { tone: string; label: string }): string {
|
||||
return base.tone === "ok" && isFileMissing(job)
|
||||
? t("jobst.missing")
|
||||
: base.label;
|
||||
}
|
||||
|
||||
function labelOf(job: Job, base: { tone: string; label: string }): string {
|
||||
return base.tone === 'ok' && isFileMissing(job) ? t('jobst.missing') : base.label;
|
||||
}
|
||||
function requestDelete(job: Job) {
|
||||
if (job.status === "pending" || job.status === "running") return;
|
||||
deleteQueue.request(job, {
|
||||
message: () =>
|
||||
(job.output_paths?.length ?? 0) > 0
|
||||
? t("studio.deletedTakeToast", { name: wfName(job) })
|
||||
: t("studio.deletedTakeNoFilesToast", { name: wfName(job) }),
|
||||
});
|
||||
}
|
||||
|
||||
function requestDelete(job: Job) {
|
||||
if (job.status === 'pending' || job.status === 'running') return;
|
||||
deleteQueue.request(job, {
|
||||
message: () =>
|
||||
(job.output_paths?.length ?? 0) > 0
|
||||
? t('studio.deletedTakeToast', { name: wfName(job) })
|
||||
: t('studio.deletedTakeNoFilesToast', { name: wfName(job) }),
|
||||
});
|
||||
}
|
||||
let busyId = $state<number | null>(null);
|
||||
async function retry(job: Job) {
|
||||
if (busyId != null) return;
|
||||
busyId = job.id;
|
||||
try {
|
||||
await jobsApi.retry(job.id);
|
||||
toast.success(t("studio.retryQueuedToast"));
|
||||
await client.invalidateQueries({ queryKey: ["jobs"] });
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("studio.retryFailedToast", { detail: apiErrorMessage(err) }),
|
||||
);
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
let busyId = $state<number | null>(null);
|
||||
async function retry(job: Job) {
|
||||
if (busyId != null) return;
|
||||
busyId = job.id;
|
||||
try {
|
||||
await jobsApi.retry(job.id);
|
||||
toast.success(t('studio.retryQueuedToast'));
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(t('studio.retryFailedToast', { detail: apiErrorMessage(err) }));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
async function cancel(job: Job) {
|
||||
if (busyId != null) return;
|
||||
busyId = job.id;
|
||||
try {
|
||||
await jobsApi.cancel(job.id);
|
||||
toast.info(t("studio.cancelToast"));
|
||||
await client.invalidateQueries({ queryKey: ["jobs"] });
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(job: Job) {
|
||||
if (busyId != null) return;
|
||||
busyId = job.id;
|
||||
try {
|
||||
await jobsApi.cancel(job.id);
|
||||
toast.info(t('studio.cancelToast'));
|
||||
await client.invalidateQueries({ queryKey: ['jobs'] });
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
busyId = null;
|
||||
}
|
||||
}
|
||||
let sourceFilter = $state("all");
|
||||
let activeId = $state<number | null>(null);
|
||||
function matchesSource(job: Job, value: string) {
|
||||
if (value === "all") return true;
|
||||
if (value === "test") return isWorkflowTestJob(job);
|
||||
if (isWorkflowTestJob(job)) return false;
|
||||
return value === "standalone"
|
||||
? isStandaloneJob(job)
|
||||
: !isStandaloneJob(job);
|
||||
}
|
||||
const activeJob = $derived(
|
||||
genJobs.find((j) => j.id === activeId && !pendingIds.has(j.id)) ?? null,
|
||||
);
|
||||
</script>
|
||||
|
||||
<AppShell active="tasks" title={t('tasks.title')} subtitle={t('tasks.subtitle')}>
|
||||
{#snippet actions()}
|
||||
<span class="status-chip">{t('tasks.count', { n: genJobs.length, m: activeCount })}</span>
|
||||
{/snippet}
|
||||
|
||||
<div class="tasks-page">
|
||||
<div class="toolbar">
|
||||
<div class="chips" role="group" aria-label={t('tasks.title')}>
|
||||
{#each [['all', t('tasks.filterAll')], ['image', t('tasks.filterImage')], ['video', t('tasks.filterVideo')], ['failed', t('tasks.filterFailed')], ['active', t('tasks.filterActive')]] as [value, label] (value)}
|
||||
<button
|
||||
type="button"
|
||||
class="a-chip fchip"
|
||||
class:sel={filter === value}
|
||||
aria-pressed={filter === value}
|
||||
onclick={() => (filter = value as Filter)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="search">
|
||||
<Icon name="search" size={13} />
|
||||
<input type="search" bind:value={search} placeholder={t('tasks.searchPh')} aria-label={t('tasks.searchPh')} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if !shown.length}
|
||||
<div class="empty-card">
|
||||
<Icon name="queue" size={22} />
|
||||
<p>{search || filter !== 'all' ? t('tasks.emptyFiltered') : t('tasks.empty')}</p>
|
||||
<a class="a-btn primary" href="/image">{t('tasks.goGenerate')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="task-table" role="table" aria-label={t('tasks.title')}>
|
||||
{#each shown as job (job.id)}
|
||||
{@const st = jobStatusInfo(job)}
|
||||
{@const progress = rowProgress(job)}
|
||||
<div class="trow" class:failed={st.tone === 'fail'} class:running={st.tone === 'run'}>
|
||||
<span class="tkind {job.kind}">
|
||||
<Icon name={job.kind === 'image' ? 'image' : 'film'} size={14} />
|
||||
</span>
|
||||
<div class="tthumb">
|
||||
{#if job.output_paths?.[0]}
|
||||
{#if job.kind === 'image'}
|
||||
<img
|
||||
src={assetUrl(job.output_paths[0])}
|
||||
alt={wfName(job)}
|
||||
loading="lazy"
|
||||
onerror={() => job.output_paths[0] && markOutputMissing(job.output_paths[0])}
|
||||
/>
|
||||
{:else}
|
||||
<video src={assetUrl(job.output_paths[0])} preload="metadata" muted></video>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tmain">
|
||||
<div class="tname" title={wfName(job)}>
|
||||
{#if isWorkflowTestJob(job)}<span class="tag-test">{t('tasks.testBadge')}</span>{/if}
|
||||
{wfName(job)}
|
||||
{#if job.workflow_id == null}<span class="a-tag wf-gone">{t('studio.wfDeletedTag')}</span>{/if}
|
||||
</div>
|
||||
<div class="tmeta mono">
|
||||
{#if projectOf(job)}{projectOf(job)} · {/if}{jobSourceLabel(job)} · {timeOf(job)}
|
||||
{#if (job.output_paths?.length ?? 0) > 0}
|
||||
· {t('tasks.outputs', { n: job.output_paths.length })}
|
||||
{/if}
|
||||
</div>
|
||||
{#if job.status === 'failed' && job.error}
|
||||
<div class="terror">{job.error}</div>
|
||||
{:else if st.tone === 'fail' || (job.status === 'done' && job.phase === 'dry_run')}
|
||||
<div class="terror">{st.detail}</div>
|
||||
{:else if st.tone === 'ok' && isFileMissing(job)}
|
||||
<div class="twarn">{t('jobst.missingDetail')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tout">
|
||||
{#if job.output_paths?.length}
|
||||
<div class="tout-thumbs">
|
||||
{#each job.output_paths.slice(0, 4) as p (p)}
|
||||
<a
|
||||
href={assetUrl(p)}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t('tasks.download')}
|
||||
>
|
||||
{#if job.kind === 'image'}
|
||||
<img src={assetUrl(p)} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<video src={assetUrl(p)} preload="metadata" muted></video>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="a-tag tst {toneOf(job, st.tone)}" title={st.detail}>{labelOf(job, st)}</span>
|
||||
<span class="tactions">
|
||||
{#if (st.tone === 'fail' || (st.tone === 'ok' && isFileMissing(job))) && job.error !== 'cancelled'}
|
||||
<button type="button" class="link-ghost" disabled={busyId === job.id} onclick={() => retry(job)}>
|
||||
{t('tasks.retry')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.status === 'pending' || job.status === 'running'}
|
||||
<button type="button" class="link-ghost" disabled={busyId === job.id} onclick={() => cancel(job)}>
|
||||
{t('tasks.cancel')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if job.output_paths?.length}
|
||||
<a class="link-ghost" href={assetUrl(job.output_paths[0])} download>
|
||||
{t('tasks.download')}
|
||||
</a>
|
||||
{/if}
|
||||
{#if isStandaloneJob(job) && job.status !== 'pending' && job.status !== 'running'}
|
||||
<button
|
||||
type="button"
|
||||
class="link-danger"
|
||||
title={t('tasks.delete')}
|
||||
onclick={() => requestDelete(job)}
|
||||
>
|
||||
<Icon name="trash" size={13} />
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{#if progress > 0}
|
||||
<div class="rowbar" style:--p={progress / 100}><i></i></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</AppShell>
|
||||
<ManagementPage active="tasks" title="生成任务">
|
||||
{#snippet tools()}<div class="mg-seg task-tabs" aria-label="任务筛选">
|
||||
{#each [["all", "全部"], ["active", "进行中"], ["failed", "失败"], ["image", "图片"], ["video", "视频"]] as [value, label]}<button
|
||||
class:on={filter === value}
|
||||
aria-pressed={filter === value}
|
||||
onclick={() => (filter = value as Filter)}>{label}</button
|
||||
>{/each}
|
||||
</div>{/snippet}
|
||||
{#snippet actions()}<label class="mg-search task-search"
|
||||
><Icon name="search-manage" size={14} /><input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder="搜索任务…"
|
||||
aria-label="搜索任务…"
|
||||
/></label
|
||||
>{/snippet}
|
||||
<div class="mg-layout">
|
||||
<aside class="mg-side" aria-label="任务来源">
|
||||
<div class="mg-label">来源</div>
|
||||
{#each [["all", "全部任务"], ["standalone", "创作台"], ["project", "项目"]] as [value, label]}<button
|
||||
class="mg-filter"
|
||||
class:on={sourceFilter === value}
|
||||
onclick={() => (sourceFilter = value)}
|
||||
>{label}<small
|
||||
>{genJobs.filter((j) => matchesSource(j, value)).length}</small
|
||||
></button
|
||||
>{/each}
|
||||
<div class="mg-label">队列</div>
|
||||
<span class="mg-dim">进行中 {activeCount} 项</span>
|
||||
</aside>
|
||||
<div class="mg-main">
|
||||
<div class="mg-table">
|
||||
<table>
|
||||
<colgroup
|
||||
><col style="width:42%" /><col style="width:20%" /><col
|
||||
style="width:20%"
|
||||
/><col style="width:18%" /></colgroup
|
||||
><thead
|
||||
><tr
|
||||
><th>任务 / 工作流</th><th>来源</th><th>进度</th><th>时间</th></tr
|
||||
></thead
|
||||
><tbody>
|
||||
{#each shown as job (job.id)}{@const st = jobStatusInfo(job)}
|
||||
<tr class:on={activeId === job.id}
|
||||
><td
|
||||
><button class="mg-click" onclick={() => (activeId = job.id)}
|
||||
><Icon
|
||||
name={job.kind === "image" ? "image" : "film-manage"}
|
||||
size={14}
|
||||
/><span class="mg-name"
|
||||
><b>{wfName(job)}</b><small
|
||||
>#{job.id}{isWorkflowTestJob(job)
|
||||
? " · 工作流试跑"
|
||||
: ""}</small
|
||||
></span
|
||||
></button
|
||||
></td
|
||||
><td title={projectOf(job) || jobSourceLabel(job)}
|
||||
>{projectOf(job) || jobSourceLabel(job)}</td
|
||||
><td
|
||||
><span class="mg-tag {toneOf(job, st.tone)}" title={st.detail}
|
||||
>{labelOf(job, st)}</span
|
||||
>{#if job.status === "running" && progressFor(job.id)?.progress != null}<span
|
||||
class="mg-dim">{progressFor(job.id)?.progress}%</span
|
||||
>{/if}</td
|
||||
><td class="mg-mono">{timeOf(job)}</td></tr
|
||||
>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if $jobsQuery.isLoading}<p class="mg-empty">
|
||||
正在读取任务…
|
||||
</p>{:else if $jobsQuery.isError}<p class="mg-empty" role="alert">
|
||||
任务加载失败<Button onclick={() => $jobsQuery.refetch()}
|
||||
>重试</Button
|
||||
>
|
||||
</p>{:else if !shown.length}<div class="mg-empty">
|
||||
<p>
|
||||
{search || filter !== "all"
|
||||
? t("tasks.emptyFiltered")
|
||||
: t("tasks.empty")}
|
||||
</p>
|
||||
<a class="a-btn secondary sm" href="/image">前往创作台</a>
|
||||
</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<aside class="mg-detail" aria-label="任务详情">
|
||||
{#if activeJob}{@const job = activeJob}{@const st = jobStatusInfo(job)}
|
||||
<h2>{wfName(job)}</h2>
|
||||
<div class="mg-mono">#{job.id} · {timeOf(job)}</div>
|
||||
<div class="mg-row">
|
||||
<span class="mg-tag {toneOf(job, st.tone)}">{labelOf(job, st)}</span
|
||||
><span class="mg-dim">{jobSourceLabel(job)}</span>
|
||||
</div>
|
||||
<div class="mg-field">
|
||||
<h3>运行状态</h3>
|
||||
<p class="mg-dim">{job.error ?? st.detail}</p>
|
||||
{#if job.status === "running"}<p class="mg-dim">
|
||||
{progressFor(job.id)?.progress != null
|
||||
? progressFor(job.id)?.progress + "%"
|
||||
: "等待服务报告进度"}
|
||||
</p>{/if}
|
||||
</div>
|
||||
{#if job.output_paths?.length}<div class="mg-field">
|
||||
<h3>产出 · {job.output_paths.length}</h3>
|
||||
{#each job.output_paths as path}<div class="task-output">
|
||||
{#if job.kind === "image"}<img
|
||||
src={assetUrl(path)}
|
||||
alt={wfName(job)}
|
||||
loading="lazy"
|
||||
onerror={() => markOutputMissing(path)}
|
||||
/>{:else}<video
|
||||
src={assetUrl(path)}
|
||||
controls
|
||||
preload="metadata"><track kind="captions" /></video
|
||||
>{/if}<a
|
||||
class="a-btn secondary sm"
|
||||
href={assetUrl(path)}
|
||||
download>下载</a
|
||||
>
|
||||
</div>{/each}
|
||||
</div>{/if}
|
||||
<div class="mg-row">
|
||||
{#if (st.tone === "fail" && job.error !== "cancelled") || (st.tone === "ok" && isFileMissing(job))}<Button
|
||||
size="sm"
|
||||
disabled={busyId === job.id}
|
||||
onclick={() => retry(job)}>重试</Button
|
||||
>{/if}{#if job.status === "pending" || job.status === "running"}<Button
|
||||
size="sm"
|
||||
disabled={busyId === job.id}
|
||||
onclick={() => cancel(job)}>取消任务</Button
|
||||
>{/if}{#if isStandaloneJob(job) && job.status !== "pending" && job.status !== "running"}<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => requestDelete(job)}>删除</Button
|
||||
>{/if}
|
||||
</div>
|
||||
{:else}<div class="mg-empty">选择任务查看状态与产出</div>{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</ManagementPage>
|
||||
|
||||
<style>
|
||||
.tasks-page {
|
||||
padding: 20px 28px 40px;
|
||||
max-width: 1560px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* §3.5: 筛选芯片组(选中 = 金色文字 + accent-soft 底)+ 右侧搜索框 */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.fchip {
|
||||
height: 28px;
|
||||
}
|
||||
.search {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-pill);
|
||||
background: var(--bg-2);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.search:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.search input {
|
||||
min-width: 0;
|
||||
width: 180px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.search input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.empty-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 56px 24px;
|
||||
border: 1.5px dashed var(--line-2);
|
||||
border-radius: var(--r-lg);
|
||||
color: var(--text-3);
|
||||
}
|
||||
.empty-card p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* 表格行:行底 --bg-2,悬停 --bg-3,进行中行底贴 2px 青进度条 */
|
||||
.task-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.trow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--bg-2);
|
||||
min-height: 56px;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.trow:hover {
|
||||
background: var(--bg-3);
|
||||
}
|
||||
}
|
||||
.trow.failed {
|
||||
border-color: color-mix(in srgb, var(--err) 35%, transparent);
|
||||
}
|
||||
.rowbar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 var(--r-md) var(--r-md);
|
||||
}
|
||||
.rowbar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-origin: left;
|
||||
transform: scaleX(var(--p));
|
||||
background: var(--run-grad);
|
||||
transition: transform var(--d-progress) linear;
|
||||
}
|
||||
|
||||
.tkind {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-2);
|
||||
background: var(--bg-4);
|
||||
}
|
||||
|
||||
.tthumb {
|
||||
width: 48px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tthumb img,
|
||||
.tthumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tmain {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tname {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tname .wf-gone {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tmeta {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.terror {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--err);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.twarn {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--warn);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tout {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tout-thumbs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.tout-thumbs a {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
display: block;
|
||||
}
|
||||
.tout-thumbs img,
|
||||
.tout-thumbs video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tst {
|
||||
width: 64px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* §1.2 ghost:次级文字操作(重试/取消/下载)文字 --text-2,悬停 --text */
|
||||
.tactions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.link-ghost {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.link-ghost:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
.link-ghost:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.link-ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.link-danger {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--err);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
opacity: 0.85;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.link-danger:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background: var(--err-soft);
|
||||
}
|
||||
}
|
||||
.link-danger:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.link-danger:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
.tag-test {
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-4);
|
||||
color: var(--text-2);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* 窄屏:表格转卡片堆叠 */
|
||||
@media (max-width: 1023px) {
|
||||
.tasks-page {
|
||||
padding: 14px 16px 32px;
|
||||
}
|
||||
.trow {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
.tout {
|
||||
order: 5;
|
||||
width: 100%;
|
||||
}
|
||||
.tactions {
|
||||
order: 6;
|
||||
margin-left: auto;
|
||||
}
|
||||
.tst {
|
||||
order: 4;
|
||||
width: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
.search {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.search input {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.task-tabs {
|
||||
width: 340px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
.task-search {
|
||||
width: 240px;
|
||||
}
|
||||
.task-output img,
|
||||
.task-output video {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
object-fit: contain;
|
||||
background: var(--media-bg);
|
||||
}
|
||||
.task-output {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,152 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { workflows, settings } from '$lib/api';
|
||||
import AppShell from '$lib/components/shell/AppShell.svelte';
|
||||
import WorkflowBay from '$lib/components/desk/WorkflowBay.svelte';
|
||||
import WorkflowLibrary from '$lib/components/workflows/WorkflowLibrary.svelte';
|
||||
let manage = $state(false),
|
||||
activeId = $state<number | null>(null);
|
||||
const meta = createQuery({
|
||||
queryKey: ['workflows-meta'],
|
||||
queryFn: workflows.listMeta,
|
||||
});
|
||||
const config = createQuery({ queryKey: ['settings'], queryFn: settings.get });
|
||||
$effect(() => {
|
||||
if (page.url.searchParams.get('manage') === '1') manage = true;
|
||||
});
|
||||
const rows = $derived($meta.data ?? []);
|
||||
function pick(id: number) {
|
||||
const m = rows.find((x) => x.id === id);
|
||||
if (m) goto(`/${m.kind}?wf=${id}`);
|
||||
}
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { workflows, settings } from "$lib/api";
|
||||
import WorkflowLibrary from "$lib/components/workflows/WorkflowLibrary.svelte";
|
||||
const meta = createQuery({
|
||||
queryKey: ["workflows", "meta"],
|
||||
queryFn: workflows.listMeta,
|
||||
});
|
||||
const config = createQuery({ queryKey: ["settings"], queryFn: settings.get });
|
||||
</script>
|
||||
|
||||
<AppShell active="workflows" title="工作流目录" workspace>
|
||||
<div class="workflows-page">
|
||||
<header>
|
||||
<div>
|
||||
<div class="eyebrow">YOUR CREATIVE MATERIALS</div>
|
||||
<h1>工作流目录</h1>
|
||||
<p>按用途挑选工具,未完成配置的工作流先到管理页核对。</p>
|
||||
</div>
|
||||
<button class="a-btn secondary" onclick={() => (manage = !manage)}
|
||||
>{manage ? '返回工具目录' : '导入与管理'} ↗</button
|
||||
>
|
||||
</header>
|
||||
<nav class="resource-tabs">
|
||||
<a href="/library">参考素材</a><a class="active" href="/workflows"
|
||||
>模型与工作流 <small>{rows.length}</small></a
|
||||
>
|
||||
</nav>
|
||||
{#if $meta.isLoading}<p>正在打开工具目录…</p>{:else if $meta.isError}<p>
|
||||
工具目录没有加载出来。<button class="a-btn secondary" onclick={() => $meta.refetch()}
|
||||
>重新加载</button
|
||||
>
|
||||
</p>{:else if manage}
|
||||
<WorkflowLibrary {rows} endpoints={$config.data?.comfyui_endpoints ?? []} />
|
||||
{:else}<div class="catalogue-frame">
|
||||
<WorkflowBay
|
||||
catalogue
|
||||
open
|
||||
metas={rows}
|
||||
pendingCount={rows.filter((r) => r.needs_mapping).length}
|
||||
selectedId={null}
|
||||
bind:activeId
|
||||
endpoints={$config.data?.comfyui_endpoints ?? []}
|
||||
onPick={pick}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</div>{/if}
|
||||
</div>
|
||||
</AppShell>
|
||||
|
||||
<style>
|
||||
.workflows-page {
|
||||
padding: 28px 36px 56px;
|
||||
max-width: 1560px;
|
||||
margin: auto;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 27px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 1.3px;
|
||||
}
|
||||
h1 {
|
||||
margin: 8px 0 7px;
|
||||
font-size: 34px;
|
||||
letter-spacing: -1.3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
header p {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
margin: 0;
|
||||
}
|
||||
.resource-tabs {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.resource-tabs a {
|
||||
padding: 9px 0 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
border-bottom: 2px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
.resource-tabs a.active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
small {
|
||||
font-size: 10px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.catalogue-frame {
|
||||
height: calc(100vh - 305px);
|
||||
min-height: 450px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
padding: 0 20px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.workflows-page {
|
||||
max-width: none;
|
||||
padding: 20px 24px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
header p {
|
||||
font-size: 11px;
|
||||
}
|
||||
.resource-tabs {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.catalogue-frame {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
</style>
|
||||
<WorkflowLibrary
|
||||
rows={$meta.data ?? []}
|
||||
endpoints={$config.data?.comfyui_endpoints ?? []}
|
||||
loading={$meta.isLoading}
|
||||
error={$meta.isError}
|
||||
onRetry={() => $meta.refetch()}
|
||||
/>
|
||||
|
||||
@@ -56,6 +56,40 @@
|
||||
--desk-dock-focus:
|
||||
var(--sh-2), 0 0 0 3px color-mix(in srgb, var(--gold) 8%, transparent),
|
||||
0 0 40px color-mix(in srgb, var(--gold) 8%, transparent);
|
||||
--theme-reveal-duration: 520ms;
|
||||
--theme-fallback-duration: 280ms;
|
||||
--theme-preview-art: linear-gradient(160deg, #2f5d7c, #f2b36b);
|
||||
--family-qwen: #8aa2ff;
|
||||
--family-krea: #ff86b0;
|
||||
--family-flux: #62d2ff;
|
||||
--family-zimg: #a6e16a;
|
||||
--family-h3: #ff9a52;
|
||||
--family-wan: #c79bff;
|
||||
--family-anima: #ffb86b;
|
||||
--family-ink: #0c0e14;
|
||||
--manage-field-radius: 10px;
|
||||
--manage-card-radius: 14px;
|
||||
--manage-segment-radius: 9px;
|
||||
--manage-segment-option-radius: 7px;
|
||||
--manage-page-background: var(--bg-0);
|
||||
--manage-panel-background: var(--bg-1);
|
||||
--manage-icon-stroke: 1.7;
|
||||
--manage-pin-radius: 6px;
|
||||
--manage-pin-size: 24px;
|
||||
--manage-control-height: 32px;
|
||||
--manage-control-size: 12.5px;
|
||||
--manage-control-weight: 500;
|
||||
--manage-control-radius: var(--r-sm);
|
||||
--manage-button-height: 26px;
|
||||
--manage-button-padding: 9px;
|
||||
--manage-button-radius: 5px;
|
||||
--manage-header-letter-spacing: normal;
|
||||
--manage-filter-width: 220px;
|
||||
--manage-detail-width: 360px;
|
||||
--manage-media-radius: 10px;
|
||||
--manage-add-min-height: 130px;
|
||||
--manage-card-min: 280px;
|
||||
--manage-card-padding: 14px;
|
||||
--desk-head-height: var(--head);
|
||||
--desk-head-inset: 16px;
|
||||
--desk-head-bg: var(--glass);
|
||||
@@ -291,6 +325,12 @@
|
||||
--tx-inv: #141516;
|
||||
--r-xs: 2px;
|
||||
--r-sm: 4px;
|
||||
--manage-button-radius: 5px;
|
||||
--manage-header-letter-spacing: 0.2px;
|
||||
--manage-field-radius: 4px;
|
||||
--manage-card-radius: 4px;
|
||||
--manage-segment-radius: 4px;
|
||||
--manage-segment-option-radius: 4px;
|
||||
--r-md: 6px;
|
||||
--r-lg: 8px;
|
||||
--desk-dock-radius: 8px;
|
||||
@@ -372,6 +412,15 @@
|
||||
--bg-3: #1f2c5c;
|
||||
--bg-4: #2a386e;
|
||||
--r-sm: 8px;
|
||||
--manage-button-radius: 8px;
|
||||
--manage-control-radius: 10px;
|
||||
--manage-page-background: linear-gradient(
|
||||
180deg,
|
||||
#0c1435 0%,
|
||||
#121640 55%,
|
||||
#24163f 100%
|
||||
);
|
||||
--manage-panel-background: color-mix(in srgb, var(--bg-1) 78%, transparent);
|
||||
--r-md: 12px;
|
||||
--r-lg: 18px;
|
||||
--desk-dock-radius: 18px;
|
||||
@@ -607,3 +656,40 @@ html {
|
||||
background: var(--bg-0);
|
||||
color: var(--tx-1);
|
||||
}
|
||||
|
||||
/* HEL-726 theme switch: the approved lamp reveal and its colour-only fallback. */
|
||||
html.theme-anim,
|
||||
html.theme-anim *,
|
||||
html.theme-anim *::before,
|
||||
html.theme-anim *::after {
|
||||
transition:
|
||||
background-color var(--theme-fallback-duration) var(--ease-io),
|
||||
border-color var(--theme-fallback-duration) var(--ease-io),
|
||||
color var(--theme-fallback-duration) var(--ease-io),
|
||||
fill var(--theme-fallback-duration),
|
||||
box-shadow var(--theme-fallback-duration) !important;
|
||||
}
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: theme-lamp var(--theme-reveal-duration) var(--ease-out);
|
||||
}
|
||||
@keyframes theme-lamp {
|
||||
from {
|
||||
clip-path: circle(0 at var(--theme-x, 34px) var(--theme-y, 85%));
|
||||
}
|
||||
to {
|
||||
clip-path: circle(150% at var(--theme-x, 34px) var(--theme-y, 85%));
|
||||
}
|
||||
}
|
||||
:root[data-reduce="1"]::view-transition-new(root) {
|
||||
animation: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# HEL-730 · B2 管理页面施工稿
|
||||
|
||||
本稿尚未通过本卡的完整验收门槛。未合并、未部署、未迁移数据、未修改用户配置或密钥、未触发真实/付费生成;没有新增项目依赖。
|
||||
|
||||
基底精确为 `feat/hel-729-creative-desk@634965f5f51c8a1e489e6462395fc26037dcacba`,独立施工分支 `feat/hel-730-management`,PR 目标仍是该 B1 分支。原样本通过 Multica 附件下载,SHA256 为 `0473d80d22b90194375f3566c1b8ca30dbd7433581b18f60956eabca252242a4`。依据 HEL-727 B2 边界与 HEL-735 审计执行,未启动其他批次。
|
||||
|
||||
## 实现
|
||||
|
||||
- `src/lib/components/manage/ManagementPage.svelte` / `management.css` 与 `static/themes.css`:统一管理页头、220px 筛选、弹性列表、360px 详情、表格、状态标签、模型卡片和表单。旧页面样式整体移除,不追加第二套覆盖样式。现有 `Button`、`Modal`、导入/试跑/元数据组件继续复用。
|
||||
- `routes/workflows` / `WorkflowLibrary.svelte`:工作流库成为独立管理页,搜索、用途/模型族/状态/媒介/收藏筛选、真实计数、最近排序、逐批显示、单文件导入、扫描/转换/批量导入、去重、批量标注与启停、连接绑定、节点检查、输出映射与试跑入口均保留。列表使用轻量 meta,详情按需读取。修复同时预加载和打开映射时错过打开的问题,合并同条详情的并发读取,试跑前强制取得新详情,避免沿用旧绑定。
|
||||
- `workflowState.ts` 统一可运行契约:`is_enabled && usage && !needs_mapping`;创作台的三处原判断改为调用同一纯函数,其他创作交互保留。
|
||||
- `routes/connections`:合并 ComfyUI、大语言模型、图片模型、视频模型四页签。提取原连接/LLM 业务逻辑为 `ComfyConnections` / `LlmModels`,保留连接测试、编辑、默认项、删除、密钥留空保留和智能体模型分配。连接使用数从真实工作流的显式绑定或默认绑定计算;队列和显存没有接口则显示未知。云端图片、可灵、即梦卡片明确未接入且不能生成。`/models`、旧工作流入口兼容转向新位置。
|
||||
- `routes/tasks`:按本卡及 HEL-727 的明确要求使用管理三栏;来源过滤把工作流试跑单列,不计入创作台来源。进度使用 SSE 实际值,未上报则说明等待服务;取消、重试、下载、缺失输出提示和原撤销删除队列保留。
|
||||
- `routes/library`:使用素材墙,保留真实文件索引、分页、预览、选择、删除确认、存入项目。视频默认暂停,进入预览后也需手动播放。接口只提供图片/视频且没有归属字段,因此音频/未归属筛选明确禁用,项目范围显示全部,不假造归属。
|
||||
- `routes/settings`:两列分组、四主题预览卡、七种真实语言、队列数值、动效开关、Dry-run、存储目录、固定保存栏;更多队列/目录/智能体选项保留在高级分组。原验证、草稿、放弃修改、离页保护及保存逻辑保留。
|
||||
- `AppShell` 只增加可选页头插槽;未改导航内容。`ThemeChoices` 的新 tiles 形态仅用于设置,原主题弹层形态保留。`LanguageSwitcher` 保留原下拉形态,并增加设置页分段形态。
|
||||
- 主题切换按 HEL-726 补 520ms 圆形展开、280ms 颜色回退、减少动效立即切换、连续选择以最后一次为准。仅改变根 token 和主题 store,不重新挂载编辑器。现有按钮在原统一规则中改用可选尺寸/字重 token,默认值保留 B1 最终值,管理页消费样本值。Icon 通过可选 stroke token 保留 B1 默认 1.5,仅管理页用原件 1.7,并在既有图标表中补原件的置顶、搜索、上传与映射箭头等路径,旧图标键不改;置顶复用创作台的本地 pin 存储,原后端收藏操作仍保留在详情。输入映射左侧显示真实节点类及字段,右侧显示控件名称。
|
||||
|
||||
上述路径除 `static` 外均相对 `calliope-web/`。
|
||||
|
||||
## 自测与证据
|
||||
|
||||
`verify-hel730.py` 只在单独的本机数据库、配置和本机假 ComfyUI 上运行,进程在脚本退出时关闭。它扫描真实示例目录,从 146 个 UI 格式文件中选取 `3-图像生成/Anima/Anima_Klein放大_超强动漫模型.json`,只转换/导入一份,确认输出节点 99,绑定假服务,检查节点后试跑。产物 SHA256、原文件 SHA256、输出隔离及创作历史排除见附件 `closed-loop.json`。假服务返回 1px 验证文件,不代表真实 GPU/模型效果,也不用于装饰产品截图。
|
||||
|
||||
浏览器脚本采集 5 页 × 4 主题 × 3 尺寸的 60 组同窗样本/实际截图及 DOM 实测。素材页默认空态的对照不冒充有作品状态;对照完成后才上传带 HEL730 fixture 名称的本地视频,用于播放、删除取消和存入项目空态回归。待配置工作流 fixture 同样在视觉采集之后加入,用于验证从 B1 创作台进入映射行,不属于目录导入数量。
|
||||
|
||||
- `npm run check`:0 errors,保留 B1 的 ShotStagePanel 字幕 warning 1 条。
|
||||
- `npm run build`、前端四组守护检查通过。
|
||||
- 相关后端回归 126 项通过:导入/转换、未知输出映射、试跑隔离、连接、LLM、真实状态、图输入和上传。
|
||||
- 浏览器检查包含搜索、空态、导入焦点与归还、试跑入口、连接弹层关闭、四页签/旧入口、草稿保留与放弃、520ms 主题展开、减少动效、快速切换、无 View Transitions 回退、视频手动播放、删除取消、存入项目空态以及 B1 待配置入口。最终数量以附件 JSON 为准。
|
||||
- 管理源码定向 Prettier 检查通过;`git diff --check` 通过。
|
||||
|
||||
复现:在仓库父目录用 `multica attachment download 01a15ddd-040d-7e9a-a1a3-04932a3aa0c8 -o ./attachments` 下载样本,然后从仓库根设置 `PYTHONPATH=calliope-backend/src:calliope-backend`,运行 `python3 scripts/verify-hel730.py`。浏览器工具是运行环境已有工具;可通过 `PLAYWRIGHT_MODULE` 指向它的 `index.mjs`,没有将 Playwright 加为项目依赖。生成的本机 URL 只用于脚本过程,脚本结束后不可访问,交付物是任务卡附件。
|
||||
|
||||
## 尚未通过的门槛
|
||||
|
||||
全仓 `npm run lint` 仍失败。独立导出 B1 精确基底复跑,同样存在 62 个格式文件问题,且 ESLint 9 没有任何 `eslint.config.*`,单独执行也退出 2。不能把定向格式/类型检查写成全仓 lint 已通过;未擅自跨批重排全部基线文件或增加 lint 解析器依赖,需总管明确基线工具修复的范围。
|
||||
|
||||
样本“动效强度”的克制档没有实际行为规则,现有偏好仅完整/减少动效;本稿把克制档诚实禁用,未自创第三套时长。需总管明确归入 B5 收尾还是补充本卡规则。任务原件的全宽表格与其组织说明不一致,本稿遵本卡/HEL-727 的明确三栏要求,证据中保留这一结构差异,不宣称整页像素相等。
|
||||
|
||||
仍须总工独立重拍样本/实际页面并审核;真实数据、空态、更多管理入口、继承 B1 的导航与样本的差异均可见于附件,不以功能通过代替视觉一致性,也不宣称整套样本落地。
|
||||
|
||||
## 回滚
|
||||
|
||||
本稿零后端/数据库变更。尚无部署可回滚;后续若获准部署,回退至 B1 精确提交 `634965f5f51c8a1e489e6462395fc26037dcacba` 的构建即可,保持原配置和数据。施工稿和原 B1 使用独立分支及独立 PR,不合并或改写旧 PR。
|
||||
@@ -0,0 +1,549 @@
|
||||
/** Browser evidence for a foreground, isolated HEL-730 server. */
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE || "playwright"
|
||||
);
|
||||
const out = process.env.HEL730_OUT,
|
||||
base = process.env.HEL730_URL;
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
const evidence = { errors: [], checks: [], measurements: [] };
|
||||
const check = (condition, name) => {
|
||||
evidence.checks.push({ name, pass: !!condition });
|
||||
if (!condition) throw Error(name);
|
||||
};
|
||||
let activePage;
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
recordVideo: {
|
||||
dir: path.join(out, "videos"),
|
||||
size: { width: 1440, height: 900 },
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
activePage = page;
|
||||
page.on("pageerror", (error) => evidence.errors.push(error.message));
|
||||
await page.goto(base + "/workflows");
|
||||
await page.getByRole("button", { name: "试运行", exact: true }).waitFor();
|
||||
await page.keyboard.press("/");
|
||||
check(
|
||||
await page
|
||||
.locator(".workflow-search input")
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
"workflow / focuses search",
|
||||
);
|
||||
await page.locator(".workflow-search input").fill("missing-result");
|
||||
check(
|
||||
await page.getByText("没有匹配的工作流", { exact: true }).isVisible(),
|
||||
"search empty state",
|
||||
);
|
||||
await page.locator(".workflow-search input").fill("");
|
||||
check(
|
||||
(await page
|
||||
.locator(".management-header .icon")
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).strokeWidth)) === "1.7px",
|
||||
"management icon stroke matches approved 1.7",
|
||||
);
|
||||
await page.locator(".mg-side .mg-filter").nth(1).hover();
|
||||
await page.screenshot({
|
||||
path: path.join(out, "interaction-filter-hover.png"),
|
||||
});
|
||||
await page.route("**/api/workflows/*/check", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await route.continue();
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "检查节点与模型", exact: true })
|
||||
.click();
|
||||
await page.locator(".mg-detail .a-btn.loading").waitFor();
|
||||
await page.screenshot({
|
||||
path: path.join(out, "interaction-check-loading.png"),
|
||||
});
|
||||
check(
|
||||
true,
|
||||
"real node check exposes loading feedback under 400ms transport delay",
|
||||
);
|
||||
await page.locator(".mg-detail .a-btn.loading").waitFor({ state: "hidden" });
|
||||
await page.unroute("**/api/workflows/*/check");
|
||||
const pinButton = page.locator(".mg-pin").first();
|
||||
await pinButton.click();
|
||||
check(
|
||||
(await pinButton.getAttribute("aria-pressed")) === "true",
|
||||
"workflow pin is selected",
|
||||
);
|
||||
await page.goto(base + "/image", { waitUntil: "networkidle" });
|
||||
check(
|
||||
(await page
|
||||
.locator(".sidebar .icon")
|
||||
.first()
|
||||
.evaluate((el) => getComputedStyle(el).strokeWidth)) === "1.5px",
|
||||
"B1 icon stroke remains 1.5",
|
||||
);
|
||||
check(
|
||||
await page.getByText("置顶工作流", { exact: true }).isVisible(),
|
||||
"library pin is shared with B1 creation rail",
|
||||
);
|
||||
await page.goto(base + "/workflows", { waitUntil: "networkidle" });
|
||||
await page.locator(".mg-pin").first().click();
|
||||
await page.getByRole("button", { name: "批量标注", exact: true }).click();
|
||||
await page
|
||||
.getByRole("checkbox", { name: "全选当前列表", exact: true })
|
||||
.check();
|
||||
await page.getByRole("button", { name: "批量标注", exact: true }).click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({
|
||||
path: path.join(out, "interaction-batch-metadata.png"),
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
await page.getByRole("button", { name: "取消选择", exact: true }).click();
|
||||
check(
|
||||
(await page
|
||||
.getByRole("checkbox", { name: "全选当前列表", exact: true })
|
||||
.count()) === 0,
|
||||
"bulk entry preserves a clean default table",
|
||||
);
|
||||
const importButton = page.getByRole("button", {
|
||||
name: "导入工作流",
|
||||
exact: true,
|
||||
});
|
||||
await importButton.click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({
|
||||
path: path.join(out, "interaction-import-open.png"),
|
||||
});
|
||||
for (let i = 0; i < 12; i++) await page.keyboard.press("Tab");
|
||||
check(
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.evaluate((el) => el.contains(document.activeElement)),
|
||||
"import focus remains trapped",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
check(
|
||||
await importButton.evaluate((el) => el === document.activeElement),
|
||||
"import focus returns",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "扫描 / 转换 / 批量导入", exact: true })
|
||||
.click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: path.join(out, "interaction-scan-open.png") });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
await page.getByRole("button", { name: "试运行", exact: true }).click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: path.join(out, "interaction-test-open.png") });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
await page.goto(base + "/connections");
|
||||
await page.getByRole("button", { name: "测试连接", exact: true }).click();
|
||||
await page.getByText("连接测试通过", { exact: false }).first().waitFor();
|
||||
check(true, "ComfyUI test uses real local fake HTTP");
|
||||
await page.getByRole("button", { name: "编辑", exact: true }).first().click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
check(true, "ComfyUI edit modal closes and restores card");
|
||||
await page
|
||||
.getByRole("button", { name: "图片模型 · 后续", exact: true })
|
||||
.click();
|
||||
await page.waitForURL("**/connections?tab=image");
|
||||
check(
|
||||
await page.getByRole("button", { name: "等待接入" }).isDisabled(),
|
||||
"cloud image remains disabled",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "视频模型 · 后续", exact: true })
|
||||
.click();
|
||||
await page.waitForURL("**/connections?tab=video");
|
||||
check(
|
||||
await page.getByRole("button", { name: "等待接入" }).first().isDisabled(),
|
||||
"cloud video remains disabled",
|
||||
);
|
||||
await page.goto(base + "/models");
|
||||
await page.waitForURL("**/connections?tab=llm");
|
||||
await page
|
||||
.getByRole("button", { name: "添加大语言模型", exact: true })
|
||||
.waitFor();
|
||||
check(true, "legacy models redirects to LLM tab");
|
||||
await page
|
||||
.getByRole("button", { name: "添加大语言模型", exact: true })
|
||||
.click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.waitForTimeout(350);
|
||||
await page.screenshot({ path: path.join(out, "interaction-llm-add.png") });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.goto(base + "/tasks");
|
||||
await page.locator(".mg-table tbody button").first().click();
|
||||
check(
|
||||
(await page.locator(".mg-detail h2").textContent())?.length > 0,
|
||||
"task details selectable",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(out, "interaction-task-detail.png"),
|
||||
});
|
||||
await page.goto(base + "/settings");
|
||||
await page.locator(".queue-fields input").first().waitFor();
|
||||
const queue = page.locator(".queue-fields input").first();
|
||||
const original = await queue.inputValue();
|
||||
await queue.fill(original === "2" ? "3" : "2");
|
||||
await page.getByRole("button", { name: /日光棚/ }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.dataset.theme === "day",
|
||||
);
|
||||
if (await page.evaluate(() => !!document.startViewTransition)) {
|
||||
await page.waitForFunction(() =>
|
||||
document.getAnimations().some((a) => a.animationName === "theme-lamp"),
|
||||
);
|
||||
const duration = await page.evaluate(
|
||||
() =>
|
||||
document
|
||||
.getAnimations()
|
||||
.find((a) => a.animationName === "theme-lamp")
|
||||
?.effect.getTiming().duration,
|
||||
);
|
||||
check(duration === 520, "theme lamp duration matches 520ms sample");
|
||||
await page.screenshot({
|
||||
path: path.join(out, "interaction-theme-reveal-frame.png"),
|
||||
});
|
||||
}
|
||||
check(
|
||||
(await queue.inputValue()) !== original,
|
||||
"theme preserves unsaved settings",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(out, "interaction-settings-draft.png"),
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
await page.getByRole("button", { name: "放弃修改", exact: true }).click();
|
||||
check(
|
||||
(await queue.inputValue()) === original,
|
||||
"discard settings restores draft",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "关闭(减少动效)", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: /夜片场/ }).click();
|
||||
check(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.dataset.theme === "night" &&
|
||||
document.documentElement.dataset.reduce === "1" &&
|
||||
!document.getAnimations().some((a) => a.animationName === "theme-lamp"),
|
||||
),
|
||||
"reduced motion changes theme immediately",
|
||||
);
|
||||
await page.getByRole("button", { name: "完整", exact: true }).click();
|
||||
await page.evaluate(() => {
|
||||
for (const id of ["day", "bluehour", "night"])
|
||||
document
|
||||
.querySelector('.choices .preview[data-theme="' + id + '"]')
|
||||
.closest("button")
|
||||
.click();
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
check(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.dataset.theme === "night",
|
||||
),
|
||||
"rapid theme switching keeps the final choice",
|
||||
);
|
||||
const advanced = page.locator(".advanced");
|
||||
await advanced.locator("summary").click();
|
||||
check(
|
||||
await advanced.evaluate((el) => el.open),
|
||||
"settings advanced controls expand",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(out, "interaction-settings-expanded.png"),
|
||||
});
|
||||
await advanced.locator("summary").click();
|
||||
check(
|
||||
!(await advanced.evaluate((el) => el.open)),
|
||||
"settings advanced controls collapse",
|
||||
);
|
||||
// Approved sample, downloaded separately through the CLI; do not modify it.
|
||||
const sampleFile = path.resolve(
|
||||
"..",
|
||||
"attachments",
|
||||
"夜片场-多主题交互样本.html",
|
||||
);
|
||||
const ref = await context.newPage();
|
||||
await ref.goto(pathToFileURL(sampleFile).href, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
const measure = async (p, selectors) =>
|
||||
p.evaluate(
|
||||
(selectors) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(selectors).map(([name, selector]) => {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return [name, null];
|
||||
const r = el.getBoundingClientRect(),
|
||||
s = getComputedStyle(el);
|
||||
return [
|
||||
name,
|
||||
{
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
fontSize: s.fontSize,
|
||||
fontWeight: s.fontWeight,
|
||||
letterSpacing: s.letterSpacing,
|
||||
borderRadius: s.borderRadius,
|
||||
color: s.color,
|
||||
background: s.backgroundColor,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
selectors,
|
||||
);
|
||||
for (const [width, height] of process.env.HEL730_SKIP_VISUAL
|
||||
? []
|
||||
: [
|
||||
[1440, 900],
|
||||
[1366, 768],
|
||||
[1280, 720],
|
||||
]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await ref.setViewportSize({ width, height });
|
||||
for (const theme of ["night", "day", "grade", "bluehour"]) {
|
||||
for (const [route, sample] of [
|
||||
["workflows", "workflows"],
|
||||
["connections", "models"],
|
||||
["tasks", "tasks"],
|
||||
["library", "library"],
|
||||
["settings", "settings"],
|
||||
]) {
|
||||
await page.goto(base + "/" + route, { waitUntil: "networkidle" });
|
||||
await page.evaluate((theme) => {
|
||||
localStorage.setItem("aigc-theme", theme);
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "aigc-theme", newValue: theme }),
|
||||
);
|
||||
}, theme);
|
||||
await ref.evaluate(
|
||||
({ sample, theme }) => {
|
||||
go(sample);
|
||||
setTheme(theme);
|
||||
},
|
||||
{ sample, theme },
|
||||
);
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({
|
||||
path: path.join(out, `${route}-${theme}-${width}-actual.png`),
|
||||
});
|
||||
await ref.screenshot({
|
||||
path: path.join(out, `${route}-${theme}-${width}-sample.png`),
|
||||
});
|
||||
const actual = await measure(page, {
|
||||
header: ".management-header",
|
||||
title: ".management-header>b",
|
||||
side: ".mg-side",
|
||||
detail: ".mg-detail",
|
||||
input: ".mg-search",
|
||||
button: ".management-header .a-btn",
|
||||
card: ".mg-card",
|
||||
seg: ".management-header .mg-seg",
|
||||
tag: ".management-page .mg-tag",
|
||||
});
|
||||
const reference = await measure(ref, {
|
||||
header: `#p-${sample} .ph`,
|
||||
title: `#p-${sample} .crumb b`,
|
||||
side: `#p-${sample} .mg-side`,
|
||||
detail: `#p-${sample} .mg-detail`,
|
||||
input: `#p-${sample} .input`,
|
||||
button: `#p-${sample} .ph .btn`,
|
||||
card: `#p-${sample} .mcard`,
|
||||
seg: `#p-${sample} .ph .seg`,
|
||||
tag: `#p-${sample} .tag`,
|
||||
});
|
||||
evidence.measurements.push({
|
||||
route,
|
||||
theme,
|
||||
width,
|
||||
height,
|
||||
actual,
|
||||
sample: reference,
|
||||
documentOverflow: await page.evaluate(
|
||||
() => document.documentElement.scrollWidth > innerWidth,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Uploaded media is an explicitly named local fixture, added after empty-state visual captures.
|
||||
const fixturePath = path.resolve(
|
||||
"design/prototypes/reframe-20261014/assets/preview.webm",
|
||||
);
|
||||
const upload = await page.request.post(base + "/api/playground/uploads", {
|
||||
multipart: {
|
||||
file: {
|
||||
name: "HEL730-playback-fixture.webm",
|
||||
mimeType: "video/webm",
|
||||
buffer: await fs.readFile(fixturePath),
|
||||
},
|
||||
},
|
||||
});
|
||||
check(upload.ok(), "isolated video fixture upload succeeds");
|
||||
await page.goto(base + "/library", { waitUntil: "networkidle" });
|
||||
const preview = page.getByRole("button", {
|
||||
name: /预览 .*HEL730-playback-fixture/,
|
||||
});
|
||||
await preview.click();
|
||||
const player = page.getByRole("dialog").locator("video");
|
||||
await player.waitFor();
|
||||
await player.evaluate(
|
||||
(video) =>
|
||||
new Promise((resolve) => {
|
||||
if (video.readyState >= 1) resolve();
|
||||
else video.addEventListener("loadedmetadata", resolve, { once: true });
|
||||
}),
|
||||
);
|
||||
check(
|
||||
await player.evaluate(
|
||||
(video) => video.paused && video.controls && !video.autoplay,
|
||||
),
|
||||
"library video preview waits for manual play",
|
||||
);
|
||||
await player.evaluate((video) => video.play());
|
||||
await page.waitForTimeout(300);
|
||||
check(
|
||||
await player.evaluate((video) => video.currentTime > 0 && !video.paused),
|
||||
"library video plays on request",
|
||||
);
|
||||
await player.evaluate((video) => video.pause());
|
||||
await page.screenshot({
|
||||
path: path.join(out, "interaction-library-video-paused.png"),
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("dialog").waitFor({ state: "hidden" });
|
||||
await page.locator('.asset-meta input[type="checkbox"]').check();
|
||||
await page.getByRole("button", { name: /删除已选/ }).click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.keyboard.press("Escape");
|
||||
check(
|
||||
await preview.isVisible(),
|
||||
"library delete cancellation retains fixture",
|
||||
);
|
||||
await page.getByRole("button", { name: "存入项目", exact: true }).click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.screenshot({
|
||||
path: path.join(out, "interaction-library-attach-empty.png"),
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
// Test-only fixture is added after all visual snapshots; it never represents an imported directory file.
|
||||
const fixture = await page.request.post(base + "/api/workflows", {
|
||||
data: {
|
||||
name: "HEL730 待配置入口回归",
|
||||
workflow_json: {
|
||||
1: {
|
||||
class_type: "PrimitiveStringMultiline",
|
||||
inputs: { value: "fixture" },
|
||||
},
|
||||
7: {
|
||||
class_type: "GlaxSamplerNode",
|
||||
inputs: { prompt: ["1", 0], length: 121 },
|
||||
},
|
||||
15: { class_type: "QuarkMuffinDisplay", inputs: { payload: ["7", 0] } },
|
||||
},
|
||||
usage: "text2image",
|
||||
usage_source: "user",
|
||||
is_enabled: false,
|
||||
},
|
||||
});
|
||||
check(fixture.ok(), "isolated pending fixture created");
|
||||
const pending = await fixture.json();
|
||||
check(
|
||||
pending.needs_mapping && !pending.is_enabled,
|
||||
"pending fixture cannot generate",
|
||||
);
|
||||
// B1 compatibility: readonly desk search, pending workflow deep link, no imaginary text-to-image input.
|
||||
await page.goto(base + "/image", { waitUntil: "networkidle" });
|
||||
await page.keyboard.press("/");
|
||||
check(
|
||||
await page.evaluate(() => document.activeElement?.tagName === "INPUT"),
|
||||
"B1 / search remains available",
|
||||
);
|
||||
await page.keyboard.type("HEL730 keyboard");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.screenshot({ path: path.join(out, "b1-keyboard-regression.png") });
|
||||
await page
|
||||
.getByRole("searchbox", { name: "搜索工作流", exact: true })
|
||||
.fill("");
|
||||
await page.getByRole("checkbox", { name: "仅可运行", exact: true }).uncheck();
|
||||
await page
|
||||
.locator('button.workflow-pick[title="HEL730 待配置入口回归"]')
|
||||
.click();
|
||||
await page.waitForURL("**/workflows?manage=1&wf=" + pending.id);
|
||||
await page.locator(".wf-map-editor").waitFor();
|
||||
check(
|
||||
(await page.locator(".mg-detail h2").textContent()) ===
|
||||
"HEL730 待配置入口回归",
|
||||
"B1 pending link opens selected mapping row",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(out, "b1-pending-mapping-regression.png"),
|
||||
});
|
||||
const fallback = await context.newPage();
|
||||
await fallback.addInitScript(() =>
|
||||
Object.defineProperty(document, "startViewTransition", {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
}),
|
||||
);
|
||||
await fallback.goto(base + "/settings", { waitUntil: "networkidle" });
|
||||
const previous = await fallback.evaluate(
|
||||
() => document.documentElement.dataset.theme,
|
||||
);
|
||||
await fallback
|
||||
.getByRole("button", { name: previous === "day" ? /夜片场/ : /日光棚/ })
|
||||
.click();
|
||||
check(
|
||||
await fallback.evaluate(() =>
|
||||
document.documentElement.classList.contains("theme-anim"),
|
||||
),
|
||||
"unsupported View Transitions uses colour fallback",
|
||||
);
|
||||
await fallback.waitForTimeout(400);
|
||||
check(
|
||||
!(await fallback.evaluate(() =>
|
||||
document.documentElement.classList.contains("theme-anim"),
|
||||
)),
|
||||
"fallback cleans up after 340ms",
|
||||
);
|
||||
await fallback.screenshot({
|
||||
path: path.join(out, "interaction-theme-fallback.png"),
|
||||
});
|
||||
await context.close();
|
||||
evidence.videos = {
|
||||
actual: path.basename(await page.video().path()),
|
||||
reference: path.basename(await ref.video().path()),
|
||||
};
|
||||
check(evidence.errors.length === 0, "no browser runtime errors");
|
||||
} catch (error) {
|
||||
await activePage?.screenshot({
|
||||
path: path.join(out, "verification-failure.png"),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.writeFile(
|
||||
path.join(out, "browser-evidence.json"),
|
||||
JSON.stringify(evidence, null, 2),
|
||||
);
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Reproduce HEL-730 on an isolated database and HTTP fake, never on a user server.
|
||||
Run from repo root: PYTHONPATH=calliope-backend/src:calliope-backend python3 scripts/verify-hel730.py
|
||||
Optional: PLAYWRIGHT_MODULE=/absolute/path/to/playwright/index.mjs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
from PIL import Image
|
||||
|
||||
from calliope.config import settings
|
||||
import calliope.config as config_module
|
||||
from calliope.db import migrate_db
|
||||
from calliope.main import create_app
|
||||
from tests.test_studio_generation import fake_comfy, PNG_1PX
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT.parent / 'hel730-evidence'
|
||||
OUT.mkdir(exist_ok=True)
|
||||
DATA = OUT / ('isolated-data-'+uuid.uuid4().hex[:8])
|
||||
DATA.mkdir(exist_ok=True)
|
||||
settings.data_dir = DATA
|
||||
settings.assets_dir = DATA / 'assets'
|
||||
settings.dry_run = False
|
||||
settings.queue_poll_interval_sec = .5
|
||||
settings.queue_poll_timeout_sec = 10
|
||||
settings.llm_profiles = []
|
||||
settings.llm_active_id = None
|
||||
settings.comfyui_endpoints = []
|
||||
settings.comfyui_active_id = None
|
||||
config_module.CONFIG_FILE = DATA / 'isolated-config.json'
|
||||
# Configuration and queue data are local to this evidence run.
|
||||
asyncio.run(migrate_db(settings.db_path))
|
||||
fake_generator = fake_comfy.__wrapped__()
|
||||
fake = next(fake_generator)
|
||||
sock = socket.socket()
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
server = uvicorn.Server(uvicorn.Config(create_app(ROOT/'calliope-web'/'build'), log_level='warning'))
|
||||
thread = threading.Thread(target=lambda: server.run(sockets=[sock]), daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
with httpx.Client(base_url=f'http://127.0.0.1:{port}', timeout=30) as client:
|
||||
for _ in range(200):
|
||||
if server.started: break
|
||||
time.sleep(.05)
|
||||
assert server.started
|
||||
def request(method, url, **kwargs):
|
||||
r = client.request(method, url, **kwargs)
|
||||
assert r.is_success, (url, r.status_code, r.text)
|
||||
return r.json()
|
||||
endpoint = request('POST','/api/comfyui/endpoints',json={'name':'HEL-730 本机隔离假服务','base_url':f'http://127.0.0.1:{fake.server_address[1]}','auth_type':'none'})['endpoints'][0]
|
||||
scan = request('POST','/api/workflows/import-scan',json={'root':str(ROOT/'example_ComfyUI_workflows'),'dry_run':True})
|
||||
(OUT/'scan.json').write_text(json.dumps(scan,ensure_ascii=False,indent=2))
|
||||
assert request('GET','/api/workflows?view=meta') == []
|
||||
candidates = [f for f in scan['files'] if f.get('importable') and f.get('suggested',{}).get('usage') == 'text2image' and 'image' in f.get('suggested',{}).get('media_out',[])]
|
||||
assert candidates, 'No convertible UI-format workflow in the real directory'
|
||||
# Prefer text-only inputs so the fake run does not need synthetic user reference media.
|
||||
candidates.sort(key=lambda f: (bool(f.get('suggested',{}).get('media_in')),f.get('source_path','')))
|
||||
chosen = candidates[0]
|
||||
path = ROOT/'example_ComfyUI_workflows'/chosen['source_path']
|
||||
graph = json.loads(path.read_text())
|
||||
convert = request('POST','/api/workflows/convert',json={'files':[{'source_path':chosen['source_path'],'workflow_json':graph}],'include_workflows':True})
|
||||
(OUT/'convert.json').write_text(json.dumps(convert,ensure_ascii=False,indent=2))
|
||||
imported = request('POST','/api/workflows/import-batch',json={'files':[{'source_path':chosen['source_path'],'workflow_json':graph}]})
|
||||
(OUT/'import.json').write_text(json.dumps(imported,ensure_ascii=False,indent=2))
|
||||
rows = request('GET','/api/workflows')
|
||||
assert len(rows) == 1
|
||||
wf = rows[0]
|
||||
wid = wf['id']
|
||||
outputs = wf.get('output_map') or [{'nodeId':o['nodeId'],'kind':o.get('kind') or 'image'} for o in wf['output_schema'] if o.get('kind') in ('image','video')]
|
||||
assert outputs, wf
|
||||
wf = request('PATCH',f'/api/workflows/{wid}',json={'usage':wf.get('usage') or 'text2image','usage_source':'user','output_map':outputs,'output_map_source':'user','endpoint_id':endpoint['id'],'is_enabled':True})
|
||||
fake.forced_output_node = outputs[0]['nodeId']
|
||||
fake.outputs_key = 'videos' if outputs[0]['kind']=='video' else 'images'
|
||||
fake.out_name = 'hel730-isolated.png'
|
||||
fake.object_info = {n['class_type']:{'input':{'required':{},'optional':{}},'output':[]} for n in wf['workflow_json'].values() if isinstance(n,dict) and n.get('class_type')}
|
||||
check = request('POST',f'/api/workflows/{wid}/check',json={'endpoint_id':endpoint['id'],'refresh':True})
|
||||
values = {i['nodeId']:i.get('defaultValue','HEL-730 fake-service verification') for i in wf['input_schema'] if i['kind'] in ('text','textarea','number','select')}
|
||||
result = request('POST',f'/api/workflows/{wid}/test',json={'input_values':values})
|
||||
jobid = result['job']['id']
|
||||
deadline = time.monotonic()+30
|
||||
while True:
|
||||
job = request('GET',f'/api/jobs/{jobid}')
|
||||
if job['status'] not in ('pending','running'): break
|
||||
assert time.monotonic()<deadline, job
|
||||
time.sleep(.1)
|
||||
assert job['status']=='done', job
|
||||
assert job['origin']=='workflow_test'
|
||||
assert job['output_paths']
|
||||
output = Path(job['output_paths'][0])
|
||||
assert output.read_bytes()==PNG_1PX
|
||||
assert '_workflow_test' in str(output)
|
||||
history = request('GET','/api/playground/jobs')
|
||||
assert all(j['id']!=jobid for j in history)
|
||||
meta = request('GET','/api/workflows?view=meta')
|
||||
assert meta[0]['is_enabled'] and meta[0]['usage'] and not meta[0]['needs_mapping']
|
||||
summary = {'source_path':chosen['source_path'],'source_sha256':hashlib.sha256(path.read_bytes()).hexdigest(),'ui_format_files':scan['total'],'directory_json_files':len(list((ROOT/'example_ComfyUI_workflows').rglob('*.json'))),'imported':len(meta),'workflow_id':wid,'mapping':outputs,'check':check,'test_job_id':jobid,'test_status':job['status'],'output_sha256':hashlib.sha256(output.read_bytes()).hexdigest(),'output_isolated':True,'excluded_from_creation_history':True,'fake_prompt_requests':len(fake.prompts),'service_url':f'http://127.0.0.1:{port}'}
|
||||
(OUT/'closed-loop.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2))
|
||||
# This server only offers a fake 1px response. The UI screenshot must not pretend it is artwork.
|
||||
env = {**os.environ,'HEL730_URL':f'http://127.0.0.1:{port}','HEL730_OUT':str(OUT),'HEL730_WORKFLOW':str(wid)}
|
||||
subprocess.run(['node',str(ROOT/'scripts'/'verify-hel730.mjs')],env=env,check=True)
|
||||
print(json.dumps(summary,ensure_ascii=False,indent=2))
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=10)
|
||||
sock.close()
|
||||
fake_generator.close()
|
||||
Reference in New Issue
Block a user