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