Files
NianAIGC/components/settings-panel.tsx
2026-05-29 15:54:13 +08:00

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;
}