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
+524 -1
View File
@@ -59,6 +59,11 @@ textarea {
font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
select {
font-family: inherit;
color: inherit;
}
button,
input,
textarea {
@@ -69,6 +74,11 @@ button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
@@ -150,6 +160,14 @@ textarea:focus-visible,
gap: 9px;
}
.settings-alert-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--warning);
box-shadow: 0 0 0 3px var(--warning-soft);
}
.save-state {
gap: 5px;
margin-right: 6px;
@@ -853,6 +871,448 @@ textarea:focus-visible,
font-size: 10px;
}
.settings-backdrop {
position: fixed;
inset: 0;
z-index: 100;
padding: 24px;
display: grid;
place-items: center;
background: rgb(18 24 20 / 0.56);
backdrop-filter: blur(8px);
}
.settings-dialog {
width: min(1120px, 100%);
height: min(820px, calc(100dvh - 48px));
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
border: 1px solid var(--line-strong);
border-radius: var(--radius-surface);
background: var(--surface);
box-shadow: 0 34px 100px rgb(7 13 9 / 0.34);
}
.settings-header {
padding: 20px 22px 16px;
display: flex;
justify-content: space-between;
gap: 24px;
border-bottom: 1px solid var(--line);
background: var(--surface-raised);
}
.settings-header h1,
.settings-header p,
.settings-section-heading h2,
.settings-section-heading p,
.settings-readiness p,
.setting-status-row p,
.setting-toggle-row p,
.deployment-note p {
margin: 0;
}
.settings-header h1 {
margin-top: 3px;
font-size: 21px;
font-weight: 690;
letter-spacing: -0.02em;
}
.settings-header > div > p:last-child {
margin-top: 5px;
color: var(--text-soft);
font-size: 11px;
}
.settings-kicker {
color: var(--accent-strong);
font-size: 9px;
font-weight: 720;
letter-spacing: 0.12em;
}
.settings-readiness {
min-height: 70px;
padding: 11px 22px;
display: grid;
grid-template-columns: auto minmax(220px, auto) minmax(180px, 1fr);
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--accent-soft) 68%, var(--surface));
}
.readiness-orb {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--warning);
background: var(--warning-soft);
}
.readiness-orb.ready {
color: var(--accent-strong);
background: var(--accent-soft);
}
.settings-readiness strong {
display: block;
font-size: 12px;
}
.settings-readiness p {
margin-top: 4px;
color: var(--text-soft);
font-size: 10px;
}
.readiness-meter {
height: 5px;
overflow: hidden;
border-radius: 5px;
background: var(--line);
}
.readiness-meter span {
height: 100%;
display: block;
border-radius: inherit;
background: var(--accent);
transition: width 240ms ease;
}
.settings-layout {
min-height: 0;
display: grid;
grid-template-columns: 190px minmax(0, 1fr);
}
.settings-nav {
padding: 14px 10px;
display: grid;
align-content: start;
gap: 3px;
border-right: 1px solid var(--line);
background: var(--surface-muted);
}
.settings-nav button {
min-height: 43px;
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid transparent;
border-radius: 8px;
color: var(--text-soft);
background: transparent;
text-align: left;
font-size: 11px;
font-weight: 620;
}
.settings-nav button:hover {
background: color-mix(in srgb, var(--surface-raised) 72%, transparent);
}
.settings-nav button.active {
color: var(--text);
border-color: var(--line);
background: var(--surface-raised);
}
.settings-nav small {
color: var(--text-faint);
font-size: 8px;
font-weight: 650;
}
.settings-nav small.required {
color: var(--warning);
}
.settings-nav small.done {
color: var(--accent-strong);
}
.settings-content {
min-width: 0;
overflow-y: auto;
padding: 22px 26px 30px;
}
.settings-loading {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-soft);
font-size: 11px;
}
.settings-section-heading {
padding-bottom: 16px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
border-bottom: 1px solid var(--line);
}
.settings-section-heading h2 {
font-size: 16px;
font-weight: 690;
}
.settings-section-heading p {
max-width: 620px;
margin-top: 5px;
color: var(--text-soft);
font-size: 10px;
line-height: 1.6;
}
.advanced-toggle {
min-width: 86px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
color: var(--text-soft);
font-size: 9px;
}
.advanced-toggle input {
accent-color: var(--accent);
}
.settings-fields {
display: grid;
}
.setting-field,
.setting-toggle-row,
.setting-status-row {
padding: 15px 0;
border-bottom: 1px solid var(--line);
}
.setting-field {
display: grid;
grid-template-columns: minmax(160px, 0.36fr) minmax(260px, 0.64fr);
gap: 4px 22px;
}
.setting-field-label {
display: flex;
align-items: baseline;
gap: 6px;
}
.setting-field-label strong,
.setting-toggle-row strong,
.setting-status-row strong {
font-size: 11px;
font-weight: 650;
}
.setting-field-label small {
color: var(--warning);
font-size: 8px;
}
.setting-description {
grid-column: 1;
color: var(--text-faint);
font-size: 9px;
line-height: 1.55;
}
.setting-control-wrap {
grid-column: 2;
grid-row: 1 / span 2;
display: flex;
align-items: center;
gap: 7px;
}
.setting-control-wrap input,
.setting-control-wrap select {
width: 100%;
min-width: 0;
height: 38px;
padding: 0 10px;
border: 1px solid var(--line-strong);
border-radius: 8px;
color: var(--text);
background: var(--surface-raised);
font-size: 10px;
}
.setting-control-wrap input::placeholder {
color: var(--text-faint);
opacity: 1;
}
.generate-button {
min-width: 88px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--accent-strong);
background: var(--accent-soft);
font-size: 9px;
font-weight: 650;
}
.option-help {
grid-column: 2;
color: var(--accent-strong);
font-size: 9px;
}
.setting-toggle-row,
.setting-status-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 14px;
}
.setting-toggle-row p,
.setting-status-row p {
margin-top: 4px;
color: var(--text-faint);
font-size: 9px;
line-height: 1.5;
}
.setting-toggle-row input {
width: 35px;
height: 19px;
accent-color: var(--accent);
}
.setting-status-row {
grid-template-columns: auto 1fr auto;
}
.setting-status-row > span {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 50%;
}
.setting-status-row > span.configured {
color: var(--accent-strong);
background: var(--accent-soft);
}
.setting-status-row > span.missing {
color: var(--warning);
background: var(--warning-soft);
}
.setting-status-row > small {
color: var(--text-soft);
font-size: 9px;
}
.deployment-note {
margin-top: 16px;
padding: 12px;
display: grid;
grid-template-columns: auto 1fr;
gap: 9px;
color: var(--text-soft);
border-left: 3px solid var(--accent);
background: var(--accent-soft);
}
.deployment-note p {
font-size: 9px;
line-height: 1.65;
}
.deployment-note code {
color: var(--text);
font-family: "Cascadia Code", monospace;
}
.settings-test-actions {
margin-top: 18px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.settings-test-actions .button svg:last-child {
color: var(--accent-strong);
}
.spin {
animation: settings-spin 900ms linear infinite;
}
@keyframes settings-spin {
to { transform: rotate(360deg); }
}
.settings-footer {
min-height: 68px;
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
border-top: 1px solid var(--line);
background: var(--surface-raised);
}
.settings-notice,
.settings-footer-actions {
display: flex;
align-items: center;
}
.settings-notice {
min-width: 0;
gap: 6px;
color: var(--text-soft);
font-size: 9px;
}
.settings-notice span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-notice.error {
color: var(--warning);
}
.settings-notice.success {
color: var(--accent-strong);
}
.settings-footer-actions {
gap: 8px;
}
@media (max-width: 1180px) {
.studio-grid {
grid-template-columns: 190px minmax(520px, 1fr);
@@ -871,7 +1331,7 @@ textarea:focus-visible,
}
.save-state,
.topbar-actions .button-secondary,
.topbar-actions .button-secondary:not(.settings-button),
.brand-subtitle {
display: none;
}
@@ -933,6 +1393,69 @@ textarea:focus-visible,
.conversation {
min-height: 230px;
}
.settings-backdrop {
padding: 0;
}
.settings-dialog {
height: 100dvh;
border: 0;
border-radius: 0;
}
.settings-header {
padding: 14px;
}
.settings-readiness {
padding: 10px 14px;
grid-template-columns: auto 1fr;
}
.readiness-meter {
grid-column: 1 / -1;
}
.settings-layout {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
}
.settings-nav {
padding: 8px;
display: flex;
overflow-x: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.settings-nav button {
min-width: 108px;
}
.settings-content {
padding: 18px 15px 24px;
}
.setting-field {
grid-template-columns: 1fr;
}
.setting-control-wrap,
.option-help {
grid-column: 1;
grid-row: auto;
margin-top: 6px;
}
.settings-footer {
padding: 10px 12px;
}
.settings-notice {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
+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>
);
+52
View File
@@ -0,0 +1,52 @@
export type SettingOption = {
value: string;
label: string;
help: string;
};
export type SettingField = {
key: string;
label: string;
description: string;
kind: "text" | "password" | "url" | "select" | "combobox" | "toggle" | "number" | "status";
required: boolean;
secret: boolean;
default: unknown;
placeholder: string;
options: SettingOption[];
generator: "hex24" | "hex32" | "base64_32" | null;
advanced: boolean;
visible_when: Record<string, unknown> | null;
};
export type SettingCategory = {
id: string;
label: string;
description: string;
test_targets: string[];
required_for_workflow: boolean;
fields: SettingField[];
};
export type SettingsReadiness = {
ready: boolean;
completed_required: number;
total_required: number;
missing: string[];
untested: string[];
};
export type SettingsResponse = {
categories: SettingCategory[];
values: Record<string, unknown>;
configured: Record<string, boolean>;
tests: Record<string, { ok: boolean; message: string }>;
readiness: SettingsReadiness;
};
export type TestResult = {
target: string;
ok: boolean;
message: string;
details: Record<string, unknown>;
};