feat: add visual settings and readiness gate
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user