301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Loader2, RefreshCw, Save } from "lucide-react";
|
|
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
|
|
|
type SettingsField = {
|
|
key: string;
|
|
label: string;
|
|
description?: string;
|
|
secret?: boolean;
|
|
type?: "text" | "password" | "number" | "select";
|
|
options?: Array<{ label: string; value: string }>;
|
|
value: string;
|
|
configured: boolean;
|
|
};
|
|
|
|
type SettingsGroup = {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
fields: SettingsField[];
|
|
};
|
|
|
|
type SettingsPayload = {
|
|
envPath: string;
|
|
modes: {
|
|
visual: string;
|
|
evolink: string;
|
|
seedance: string;
|
|
auth: string;
|
|
data: string;
|
|
};
|
|
capabilities: Array<{
|
|
id: string;
|
|
label: string;
|
|
reqKey: string;
|
|
enabled: boolean;
|
|
engine?: string;
|
|
engineLabel?: string;
|
|
}>;
|
|
engineAssignments: Array<{
|
|
id: string;
|
|
label: string;
|
|
engine: string;
|
|
engineLabel: string;
|
|
mode: string;
|
|
modeLabel: string;
|
|
reqKey: string;
|
|
configurable: boolean;
|
|
field?: SettingsField;
|
|
}>;
|
|
groups: SettingsGroup[];
|
|
};
|
|
|
|
export function SettingsPanel() {
|
|
const [payload, setPayload] = useState<SettingsPayload | null>(null);
|
|
const [values, setValues] = useState<Record<string, string>>({});
|
|
const [activeTab, setActiveTab] = useState("status");
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const settingsRef = useRef<HTMLDivElement | null>(null);
|
|
const contentRef = useRef<HTMLElement | null>(null);
|
|
const feedbackRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const fields = useMemo(() => payload?.groups.flatMap((group) => group.fields) || [], [payload]);
|
|
const activeGroup = useMemo(() => payload?.groups.find((group) => group.id === activeTab), [activeTab, payload]);
|
|
const tabs = useMemo(() => [
|
|
{ id: "status", label: "状态" },
|
|
...(payload?.groups.map((group) => ({ id: group.id, label: shortGroupLabel(group.id, group.title) })) || [])
|
|
], [payload]);
|
|
|
|
useEffect(() => {
|
|
void loadSettings();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return runScopedMotion(settingsRef, (scope) => revealChildren(scope));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
pulseFeedback(feedbackRef.current);
|
|
}, [error, message]);
|
|
|
|
useEffect(() => {
|
|
crossfadeIn(contentRef.current);
|
|
}, [activeTab]);
|
|
|
|
async function loadSettings() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const response = await fetch("/api/settings", { cache: "no-store" });
|
|
const nextPayload = await response.json();
|
|
if (!response.ok) throw new Error(nextPayload.error || "读取设置失败");
|
|
setPayload(nextPayload);
|
|
setValues(valuesFromPayload(nextPayload));
|
|
if (activeTab !== "status" && !nextPayload.groups.some((group: SettingsGroup) => group.id === activeTab)) {
|
|
setActiveTab(nextPayload.groups[0]?.id || "status");
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function saveSettings() {
|
|
setSaving(true);
|
|
setError(null);
|
|
setMessage(null);
|
|
try {
|
|
const response = await fetch("/api/settings", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ values })
|
|
});
|
|
const nextPayload = await response.json();
|
|
if (!response.ok) throw new Error(nextPayload.error || "保存设置失败");
|
|
setPayload(nextPayload);
|
|
setValues(valuesFromPayload(nextPayload));
|
|
setMessage("配置已保存并应用到当前服务。");
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (loading && !payload) {
|
|
return (
|
|
<section className="panel settings-loading">
|
|
<Loader2 className="spin" size={18} />
|
|
正在读取配置
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="settings-panel" ref={settingsRef}>
|
|
<div className="workspace-head" data-animate>
|
|
<div>
|
|
<h1 className="workspace-title">服务设置</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<section className="panel settings-actions" data-animate>
|
|
<div className="segmented settings-tabs" aria-label="设置分类">
|
|
{tabs.map((tab) => (
|
|
<button className={activeTab === tab.id ? "active" : ""} aria-pressed={activeTab === tab.id} type="button" key={tab.id} onClick={() => setActiveTab(tab.id)}>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="toolbar">
|
|
<button className="button" type="button" onClick={loadSettings} disabled={loading || saving}>
|
|
{loading ? <Loader2 className="spin" size={18} /> : <RefreshCw size={18} />}
|
|
刷新
|
|
</button>
|
|
<button className="button primary" type="button" onClick={saveSettings} disabled={saving || (!fields.length && !payload?.engineAssignments.length)}>
|
|
{saving ? <Loader2 className="spin" size={18} /> : <Save size={18} />}
|
|
保存
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{error || message ? (
|
|
<div ref={feedbackRef}>
|
|
{error ? <div className="callout" role="alert">{error}</div> : null}
|
|
{message ? <div className="callout success-callout" role="status" aria-live="polite">{message}</div> : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{activeGroup ? (
|
|
<section className="panel settings-group" key={activeGroup.id} data-animate ref={contentRef}>
|
|
<div className="settings-group-head">
|
|
<div>
|
|
<h2>{activeGroup.title}</h2>
|
|
</div>
|
|
</div>
|
|
<div className="settings-field-grid">
|
|
{activeGroup.fields.map((field) => (
|
|
<label className="field settings-field" key={field.key}>
|
|
<span>
|
|
{field.label}
|
|
{field.secret ? <em>{field.configured ? "已配置" : "未配置"}</em> : null}
|
|
</span>
|
|
{field.type === "select" ? (
|
|
<select value={values[field.key] ?? field.value} onChange={(event) => setValues((items) => ({ ...items, [field.key]: event.target.value }))}>
|
|
{(field.options || []).map((option) => (
|
|
<option value={option.value} key={option.value}>{option.label}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
type={field.secret ? "password" : field.type === "number" ? "number" : "text"}
|
|
value={values[field.key] ?? ""}
|
|
placeholder={field.secret && field.configured ? "留空保留当前密钥" : field.description || ""}
|
|
onChange={(event) => setValues((items) => ({ ...items, [field.key]: event.target.value }))}
|
|
/>
|
|
)}
|
|
<small>{field.key}</small>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{activeTab === "status" ? (
|
|
<section className="panel settings-group" data-animate ref={contentRef}>
|
|
<div className="settings-group-head">
|
|
<div>
|
|
<h2>功能引擎</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="settings-service-strip">
|
|
<ServiceBadge label="即梦" value={payload?.modes.visual === "real" ? "真实接口" : "Mock"} ready={payload?.modes.visual === "real"} />
|
|
<ServiceBadge label="EvoLink" value={payload?.modes.evolink === "real" ? "真实接口" : "Mock"} ready={payload?.modes.evolink === "real"} />
|
|
<ServiceBadge label="Seedance" value={payload?.modes.seedance === "real" ? "真实接口" : "Mock"} ready={payload?.modes.seedance === "real"} />
|
|
<ServiceBadge label="账户" value={authModeLabel(payload?.modes.auth)} ready={payload?.modes.auth === "configured"} />
|
|
<ServiceBadge label="数据层" value={payload?.modes.data === "supabase" ? "Supabase" : "本地 JSON"} ready={false} />
|
|
</div>
|
|
|
|
<div className="settings-engine-table" role="table" aria-label="功能引擎">
|
|
<div className="settings-engine-row settings-engine-head" role="row">
|
|
<span>功能</span>
|
|
<span>引擎</span>
|
|
<span>接口</span>
|
|
<span>模型 / Key</span>
|
|
</div>
|
|
{payload?.engineAssignments.map((assignment) => (
|
|
<div className="settings-engine-row" role="row" key={assignment.id}>
|
|
<div className="settings-engine-name">
|
|
<strong>{assignment.label}</strong>
|
|
<small>{assignment.id}</small>
|
|
</div>
|
|
{assignment.field ? (
|
|
<select
|
|
className="settings-engine-control"
|
|
value={values[assignment.field.key] ?? assignment.field.value}
|
|
onChange={(event) => setValues((items) => ({ ...items, [assignment.field!.key]: event.target.value }))}
|
|
aria-label={`${assignment.label}创作引擎`}
|
|
>
|
|
{(assignment.field.options || []).map((option) => (
|
|
<option value={option.value} key={option.value}>{option.label}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<strong className="settings-engine-value">{assignment.engineLabel}</strong>
|
|
)}
|
|
<span className={`status ${assignment.mode === "mock" ? "failed" : ""}`}>{assignment.modeLabel}</span>
|
|
<div className="settings-engine-key">
|
|
<small>{assignment.reqKey}</small>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function valuesFromPayload(payload: SettingsPayload) {
|
|
const nextValues: Record<string, string> = {};
|
|
for (const field of payload.groups.flatMap((group) => group.fields)) {
|
|
nextValues[field.key] = field.secret ? "" : field.value;
|
|
}
|
|
for (const assignment of payload.engineAssignments) {
|
|
if (assignment.field) nextValues[assignment.field.key] = assignment.field.value;
|
|
}
|
|
return nextValues;
|
|
}
|
|
|
|
function ServiceBadge({ label, value, ready }: { label: string; value: string; ready: boolean }) {
|
|
return (
|
|
<div className={`settings-service-badge ${ready ? "ready" : ""}`}>
|
|
<span>{label}</span>
|
|
<strong>{value}</strong>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function authModeLabel(mode?: string) {
|
|
if (mode === "configured") return "已启用";
|
|
if (mode === "missing") return "待配置";
|
|
return "未启用";
|
|
}
|
|
|
|
function shortGroupLabel(id: string, title: string) {
|
|
if (id === "auth") return "登录";
|
|
if (id === "visual") return "图片";
|
|
if (id === "evolink") return "EvoLink";
|
|
if (id === "seedance") return "视频";
|
|
if (id === "oss") return "OSS";
|
|
return title;
|
|
}
|