feat: add visual settings and readiness gate

This commit is contained in:
Codex
2026-08-01 22:14:20 +08:00
parent 6a15217d55
commit 3b4106e8cc
25 changed files with 2447 additions and 239 deletions
+412
View File
@@ -0,0 +1,412 @@
"use client";
import {
ArrowClockwiseIcon,
CheckCircleIcon,
DatabaseIcon,
FloppyDiskIcon,
KeyIcon,
PlugIcon,
WarningCircleIcon,
XIcon,
} from "@phosphor-icons/react";
import { useEffect, useMemo, useState } from "react";
import type {
SettingField,
SettingsReadiness,
SettingsResponse,
TestResult,
} from "@/types/settings";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
const testLabels: Record<string, string> = {
infrastructure: "测试数据库与队列",
storage: "测试 MinIO",
baidu_ocr: "测试百度 OCR",
ai_models: "验证 API 与模型",
gpu: "测试 GPU Worker",
langfuse: "测试 Langfuse",
sentry: "测试 Sentry",
};
type SettingsCenterProps = {
open: boolean;
onClose: () => void;
onReadinessChange: (readiness: SettingsReadiness) => void;
};
function isVisible(field: SettingField, draft: Record<string, unknown>) {
if (!field.visible_when) return true;
return Object.entries(field.visible_when).every(([key, expected]) => {
if (key.endsWith("__not")) return draft[key.slice(0, -5)] !== expected;
return draft[key] === expected;
});
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : "请求失败,请检查 API 服务是否启动。";
}
export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCenterProps) {
const [data, setData] = useState<SettingsResponse | null>(null);
const [draft, setDraft] = useState<Record<string, unknown>>({});
const [activeCategoryId, setActiveCategoryId] = useState("deployment");
const [showAdvanced, setShowAdvanced] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<{ tone: "success" | "error"; text: string } | null>(null);
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
const activeCategory = useMemo(
() => data?.categories.find((category) => category.id === activeCategoryId) ?? data?.categories[0],
[activeCategoryId, data],
);
function applyResponse(response: SettingsResponse) {
setData(response);
setDraft(response.values);
onReadinessChange(response.readiness);
}
async function loadSettings() {
setBusy("load");
setNotice(null);
try {
const response = await fetch(`${API_BASE}/v1/settings`, { cache: "no-store" });
if (!response.ok) throw new Error("系统设置读取失败。请确认后端 API 已启动。");
applyResponse(await response.json() as SettingsResponse);
} catch (error) {
setNotice({ tone: "error", text: getErrorMessage(error) });
} finally {
setBusy(null);
}
}
useEffect(() => {
if (!open) return;
let cancelled = false;
fetch(`${API_BASE}/v1/settings`, { cache: "no-store" })
.then((response) => {
if (!response.ok) throw new Error("系统设置读取失败。请确认后端 API 已启动。");
return response.json() as Promise<SettingsResponse>;
})
.then((response) => {
if (cancelled) return;
setData(response);
setDraft(response.values);
onReadinessChange(response.readiness);
})
.catch((error: unknown) => {
if (!cancelled) setNotice({ tone: "error", text: getErrorMessage(error) });
});
return () => { cancelled = true; };
}, [open, onReadinessChange]);
function updateField(key: string, value: unknown) {
setDraft((current) => ({ ...current, [key]: value }));
setNotice(null);
}
async function saveSettings(showSuccess = true) {
setBusy("save");
setNotice(null);
try {
const response = await fetch(`${API_BASE}/v1/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values: draft }),
});
if (!response.ok) throw new Error("保存失败,请检查填写内容。");
const payload = await response.json() as SettingsResponse;
applyResponse(payload);
if (showSuccess) setNotice({ tone: "success", text: "设置已加密保存。" });
return payload;
} catch (error) {
setNotice({ tone: "error", text: getErrorMessage(error) });
throw error;
} finally {
setBusy(null);
}
}
async function testTarget(target: string) {
setBusy(target);
setNotice(null);
try {
await saveSettings(false);
setBusy(target);
const response = await fetch(`${API_BASE}/v1/settings/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target, values: {} }),
});
const result = await response.json() as TestResult;
setTestResults((current) => ({ ...current, [target]: result }));
await loadSettings();
setNotice({ tone: result.ok ? "success" : "error", text: result.message });
} catch (error) {
setNotice({ tone: "error", text: getErrorMessage(error) });
} finally {
setBusy(null);
}
}
async function generateFor(field: SettingField) {
if (!field.generator) return;
setBusy(field.key);
try {
const response = await fetch(`${API_BASE}/v1/settings/generate-secret`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: field.generator }),
});
if (!response.ok) throw new Error("密钥生成失败。");
const payload = await response.json() as { value: string };
updateField(field.key, payload.value);
setNotice({ tone: "success", text: `${field.label}已生成,点击保存后会加密存储。` });
} catch (error) {
setNotice({ tone: "error", text: getErrorMessage(error) });
} finally {
setBusy(null);
}
}
function closeSafely() {
if (data) {
const secretKeys = new Set(data.categories.flatMap((category) => category.fields)
.filter((field) => field.secret)
.map((field) => field.key));
setDraft((current) => Object.fromEntries(
Object.entries(current).filter(([key]) => !secretKeys.has(key)),
));
}
onClose();
}
if (!open) return null;
return (
<div className="settings-backdrop" role="presentation">
<section className="settings-dialog" role="dialog" aria-modal="true" aria-label="系统设置">
<header className="settings-header">
<div>
<p className="settings-kicker">使</p>
<h1></h1>
<p>使</p>
</div>
<button className="icon-button" type="button" onClick={closeSafely} aria-label="关闭系统设置">
<XIcon size={18} />
</button>
</header>
{data ? (
<div className="settings-readiness">
<div className={`readiness-orb ${data.readiness.ready ? "ready" : ""}`}>
{data.readiness.ready ? <CheckCircleIcon size={22} weight="fill" /> : <DatabaseIcon size={22} />}
</div>
<div>
<strong>{data.readiness.ready ? "工作流已就绪" : "完成必备配置后才能开始设计"}</strong>
<p>{data.readiness.completed_required} / {data.readiness.total_required} </p>
</div>
<div className="readiness-meter" aria-label="配置完成度">
<span style={{ width: `${(data.readiness.completed_required / data.readiness.total_required) * 100}%` }} />
</div>
</div>
) : null}
<div className="settings-layout">
<nav className="settings-nav" aria-label="设置分类">
{data?.categories.map((category) => {
const categoryTested = category.test_targets.length === 0 || category.test_targets.every(
(target) => data.tests[target]?.ok,
);
return (
<button
key={category.id}
type="button"
className={category.id === activeCategory?.id ? "active" : ""}
onClick={() => setActiveCategoryId(category.id)}
>
<span>{category.label}</span>
{category.required_for_workflow ? (
<small className={categoryTested ? "done" : "required"}>
{categoryTested ? "已测试" : "必备"}
</small>
) : <small></small>}
</button>
);
})}
</nav>
<div className="settings-content">
{busy === "load" && !data ? (
<div className="settings-loading"><ArrowClockwiseIcon size={24} /> </div>
) : activeCategory && data ? (
<>
<div className="settings-section-heading">
<div>
<h2>{activeCategory.label}</h2>
<p>{activeCategory.description}</p>
</div>
{activeCategory.fields.some((field) => field.advanced) ? (
<label className="advanced-toggle">
<input
type="checkbox"
checked={showAdvanced}
onChange={(event) => setShowAdvanced(event.target.checked)}
/>
</label>
) : null}
</div>
<div className="settings-fields">
{activeCategory.fields.map((field) => {
if (!isVisible(field, draft) || (field.advanced && !showAdvanced)) return null;
return (
<SettingControl
key={field.key}
field={field}
value={draft[field.key]}
configured={Boolean(data.configured[field.key])}
onChange={(value) => updateField(field.key, value)}
onGenerate={() => void generateFor(field)}
busy={busy === field.key}
/>
);
})}
</div>
{activeCategory.id === "deployment" ? (
<div className="deployment-note">
<KeyIcon size={18} />
<p> Redis <code>scripts/bootstrap.ps1</code> OpenSSL</p>
</div>
) : null}
<div className="settings-test-actions">
{activeCategory.test_targets.map((target) => {
const recorded = testResults[target] ?? (data.tests[target] as TestResult | undefined);
return (
<button
className="button button-secondary"
type="button"
key={target}
onClick={() => void testTarget(target)}
disabled={Boolean(busy)}
>
{busy === target ? <ArrowClockwiseIcon className="spin" size={16} /> : <PlugIcon size={16} />}
{testLabels[target] ?? "测试连接"}
{recorded?.ok ? <CheckCircleIcon size={15} weight="fill" /> : null}
</button>
);
})}
</div>
</>
) : null}
</div>
</div>
<footer className="settings-footer">
<div className={`settings-notice ${notice?.tone ?? ""}`}>
{notice?.tone === "error" ? <WarningCircleIcon size={17} /> : null}
{notice?.tone === "success" ? <CheckCircleIcon size={17} weight="fill" /> : null}
<span>{notice?.text ?? "必备项完成并测试通过后,设计工作流会自动解锁。"}</span>
</div>
<div className="settings-footer-actions">
<button className="button button-secondary" type="button" onClick={closeSafely}></button>
<button
className="button button-primary"
type="button"
onClick={() => void saveSettings()}
disabled={Boolean(busy) || !data}
>
<FloppyDiskIcon size={16} />
</button>
</div>
</footer>
</section>
</div>
);
}
type SettingControlProps = {
field: SettingField;
value: unknown;
configured: boolean;
onChange: (value: unknown) => void;
onGenerate: () => void;
busy: boolean;
};
function SettingControl({ field, value, configured, onChange, onGenerate, busy }: SettingControlProps) {
const selectedOption = field.options.find((option) => option.value === String(value ?? ""));
if (field.kind === "status") {
return (
<div className="setting-status-row">
<span className={configured ? "configured" : "missing"}>
{configured ? <CheckCircleIcon size={18} weight="fill" /> : <WarningCircleIcon size={18} />}
</span>
<div>
<strong>{field.label}</strong>
<p>{field.description}</p>
</div>
<small>{configured ? "已配置" : "缺失"}</small>
</div>
);
}
if (field.kind === "toggle") {
return (
<label className="setting-toggle-row">
<div>
<strong>{field.label}</strong>
<p>{field.description}</p>
</div>
<input type="checkbox" checked={Boolean(value)} onChange={(event) => onChange(event.target.checked)} />
</label>
);
}
return (
<label className="setting-field">
<span className="setting-field-label">
<strong>{field.label}</strong>
{field.required ? <small></small> : null}
</span>
<span className="setting-description">{field.description}</span>
<span className="setting-control-wrap">
{field.kind === "select" ? (
<select value={String(value ?? "")} onChange={(event) => onChange(event.target.value)}>
<option value="" disabled></option>
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
) : (
<>
<input
type={field.kind === "password" ? "password" : field.kind === "number" ? "number" : "text"}
list={field.kind === "combobox" ? `options-${field.key}` : undefined}
value={String(value ?? "")}
placeholder={field.secret && configured ? "已安全保存,留空不修改" : field.placeholder}
onChange={(event) => onChange(event.target.value)}
autoComplete="off"
/>
{field.kind === "combobox" ? (
<datalist id={`options-${field.key}`}>
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</datalist>
) : null}
</>
)}
{field.generator ? (
<button className="generate-button" type="button" onClick={onGenerate} disabled={busy}>
<KeyIcon size={14} />
</button>
) : null}
</span>
{selectedOption ? <span className="option-help">{selectedOption.help}</span> : null}
</label>
);
}
+42 -3
View File
@@ -11,6 +11,7 @@ import {
CubeIcon,
EyeIcon,
EyeSlashIcon,
GearSixIcon,
ImageIcon,
LockSimpleIcon,
PaperPlaneTiltIcon,
@@ -21,8 +22,9 @@ import {
WarningCircleIcon,
} from "@phosphor-icons/react";
import Image from "next/image";
import { FormEvent, useMemo, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { SettingsCenter } from "@/components/settings-center";
import {
initialLayers,
initialMessages,
@@ -30,8 +32,10 @@ import {
workflowStages,
} from "@/lib/demo";
import type { ChatMessage } from "@/types/workflow";
import type { SettingsReadiness } from "@/types/settings";
const iconWeight = "regular" as const;
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
export function WorkflowStudio() {
const [layers, setLayers] = useState(initialLayers);
@@ -39,12 +43,32 @@ export function WorkflowStudio() {
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [draft, setDraft] = useState("");
const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference");
const [settingsOpen, setSettingsOpen] = useState(false);
const [readiness, setReadiness] = useState<SettingsReadiness | null>(null);
const currentDirection = useMemo(
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
[selectedDirection],
);
useEffect(() => {
async function loadReadiness() {
try {
const response = await fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" });
if (!response.ok) return;
const current = await response.json() as SettingsReadiness;
setReadiness(current);
if (!current.ready && !window.sessionStorage.getItem("settings-intro-seen")) {
window.sessionStorage.setItem("settings-intro-seen", "true");
setSettingsOpen(true);
}
} catch {
setReadiness(null);
}
}
void loadReadiness();
}, []);
function toggleLayer(id: string) {
setLayers((items) =>
items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)),
@@ -84,9 +108,19 @@ export function WorkflowStudio() {
<div className="topbar-actions">
<span className="save-state"><CheckIcon size={14} weight="bold" /> </span>
<button className="button button-secondary settings-button" type="button" onClick={() => setSettingsOpen(true)}>
<GearSixIcon size={16} />
{readiness && !readiness.ready ? <span className="settings-alert-dot" aria-label="有必备配置未完成" /> : null}
</button>
<button className="button button-secondary" type="button"></button>
<button className="button button-primary" type="button">
<ArrowRightIcon size={16} weight="bold" />
<button
className="button button-primary"
type="button"
disabled={!readiness?.ready}
onClick={() => { if (!readiness?.ready) setSettingsOpen(true); }}
title={!readiness?.ready ? "请先完成系统设置" : undefined}
>
{readiness?.ready ? "确认结构" : "配置后开始"} <ArrowRightIcon size={16} weight="bold" />
</button>
</div>
</header>
@@ -327,6 +361,11 @@ export function WorkflowStudio() {
</div>
</aside>
</div>
<SettingsCenter
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
onReadinessChange={setReadiness}
/>
</main>
</Tooltip.Provider>
);