229 lines
8.7 KiB
TypeScript
229 lines
8.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
|
||
type Config = {
|
||
llm?: {
|
||
provider?: "gemini" | "openai";
|
||
gemini?: { api_key?: string; base_url?: string };
|
||
openai?: { api_key?: string; base_url?: string };
|
||
};
|
||
data_sources?: {
|
||
tushare?: { api_key?: string };
|
||
finnhub?: { api_key?: string };
|
||
jp_source?: { api_key?: string };
|
||
};
|
||
database?: { url?: string };
|
||
prompts?: { info?: string; finance?: string };
|
||
};
|
||
|
||
export default function ConfigPage() {
|
||
const [cfg, setCfg] = useState<Config | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
const [health, setHealth] = useState<string>("unknown");
|
||
|
||
// form inputs (敏感字段不回显,留空表示保持现有值)
|
||
const [provider, setProvider] = useState<"gemini" | "openai">("gemini");
|
||
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
|
||
const [geminiKey, setGeminiKey] = useState(""); // 留空则保留
|
||
const [openaiBaseUrl, setOpenaiBaseUrl] = useState("");
|
||
const [openaiKey, setOpenaiKey] = useState(""); // 留空则保留
|
||
const [tushareKey, setTushareKey] = useState(""); // 留空则保留
|
||
const [finnhubKey, setFinnhubKey] = useState(""); // 留空则保留
|
||
const [jpKey, setJpKey] = useState(""); // 留空则保留
|
||
const [dbUrl, setDbUrl] = useState("");
|
||
const [promptInfo, setPromptInfo] = useState("");
|
||
const [promptFinance, setPromptFinance] = useState("");
|
||
|
||
async function loadConfig() {
|
||
try {
|
||
const res = await fetch("/api/config");
|
||
const data: Config = await res.json();
|
||
setCfg(data);
|
||
// 非敏感字段可回显
|
||
setProvider((data.llm?.provider as any) ?? "gemini");
|
||
setGeminiBaseUrl(data.llm?.gemini?.base_url ?? "");
|
||
setOpenaiBaseUrl(data.llm?.openai?.base_url ?? "");
|
||
setDbUrl(data.database?.url ?? "");
|
||
setPromptInfo(data.prompts?.info ?? "");
|
||
setPromptFinance(data.prompts?.finance ?? "");
|
||
} catch {
|
||
setMsg("加载配置失败");
|
||
}
|
||
}
|
||
|
||
async function saveConfig() {
|
||
if (!cfg) return;
|
||
setSaving(true);
|
||
setMsg(null);
|
||
try {
|
||
// 构造覆盖配置:敏感字段若为空则沿用现有值
|
||
const next: Config = {
|
||
llm: {
|
||
provider,
|
||
gemini: {
|
||
base_url: geminiBaseUrl,
|
||
api_key: geminiKey || cfg.llm?.gemini?.api_key || undefined,
|
||
},
|
||
openai: {
|
||
base_url: openaiBaseUrl,
|
||
api_key: openaiKey || cfg.llm?.openai?.api_key || undefined,
|
||
},
|
||
},
|
||
data_sources: {
|
||
tushare: { api_key: tushareKey || cfg.data_sources?.tushare?.api_key || undefined },
|
||
finnhub: { api_key: finnhubKey || cfg.data_sources?.finnhub?.api_key || undefined },
|
||
jp_source: { api_key: jpKey || cfg.data_sources?.jp_source?.api_key || undefined },
|
||
},
|
||
database: { url: dbUrl },
|
||
prompts: { info: promptInfo, finance: promptFinance },
|
||
};
|
||
const res = await fetch("/api/config", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(next),
|
||
});
|
||
const ok = await res.json();
|
||
if (ok?.status === "ok") {
|
||
setMsg("保存成功");
|
||
await loadConfig();
|
||
// 清空敏感输入(避免页面存储)
|
||
setGeminiKey("");
|
||
setOpenaiKey("");
|
||
setTushareKey("");
|
||
setFinnhubKey("");
|
||
setJpKey("");
|
||
} else {
|
||
setMsg("保存失败");
|
||
}
|
||
} catch {
|
||
setMsg("保存失败");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function testHealth() {
|
||
try {
|
||
const res = await fetch("/health");
|
||
const h = await res.json();
|
||
setHealth(h?.status ?? "unknown");
|
||
} catch {
|
||
setHealth("error");
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadConfig();
|
||
testHealth();
|
||
}, []);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<header className="space-y-2">
|
||
<h1 className="text-2xl font-semibold">配置中心</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
切换 LLM、配置数据源与模板;不回显敏感密钥,留空表示保持现值。
|
||
</p>
|
||
</header>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>后端健康</CardTitle>
|
||
<CardDescription>GET /health</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex items-center gap-2">
|
||
<Badge variant={health === "ok" ? "secondary" : "outline"}>{health}</Badge>
|
||
<Button variant="outline" onClick={testHealth}>重新测试</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>LLM 设置</CardTitle>
|
||
<CardDescription>Gemini / OpenAI</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<label className="text-sm w-28">Provider</label>
|
||
<select
|
||
className="border rounded px-2 py-1 bg-background"
|
||
value={provider}
|
||
onChange={(e) => setProvider(e.target.value as any)}
|
||
>
|
||
<option value="gemini">Gemini</option>
|
||
<option value="openai">OpenAI</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<label className="text-sm w-28">Gemini Base URL</label>
|
||
<Input placeholder="可留空" value={geminiBaseUrl} onChange={(e) => setGeminiBaseUrl(e.target.value)} />
|
||
</div>
|
||
<div className="flex gap-2 items-center">
|
||
<label className="text-sm w-28">Gemini Key</label>
|
||
<Input type="password" placeholder="留空=保持现值" value={geminiKey} onChange={(e) => setGeminiKey(e.target.value)} />
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<label className="text-sm w-28">OpenAI Base URL</label>
|
||
<Input placeholder="可留空" value={openaiBaseUrl} onChange={(e) => setOpenaiBaseUrl(e.target.value)} />
|
||
</div>
|
||
<div className="flex gap-2 items-center">
|
||
<label className="text-sm w-28">OpenAI Key</label>
|
||
<Input type="password" placeholder="留空=保持现值" value={openaiKey} onChange={(e) => setOpenaiKey(e.target.value)} />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>数据源密钥</CardTitle>
|
||
<CardDescription>TuShare / Finnhub / JP</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="flex gap-2 items-center">
|
||
<label className="text-sm w-28">TuShare</label>
|
||
<Input type="password" placeholder="留空=保持现值" value={tushareKey} onChange={(e) => setTushareKey(e.target.value)} />
|
||
</div>
|
||
<div className="flex gap-2 items-center">
|
||
<label className="text-sm w-28">Finnhub</label>
|
||
<Input type="password" placeholder="留空=保持现值" value={finnhubKey} onChange={(e) => setFinnhubKey(e.target.value)} />
|
||
</div>
|
||
<div className="flex gap-2 items-center">
|
||
<label className="text-sm w-28">JP Source</label>
|
||
<Input type="password" placeholder="留空=保持现值" value={jpKey} onChange={(e) => setJpKey(e.target.value)} />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>数据库与模板</CardTitle>
|
||
<CardDescription>非敏感配置可回显</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<label className="text-sm w-28">DB URL</label>
|
||
<Input placeholder="postgresql+asyncpg://..." value={dbUrl} onChange={(e) => setDbUrl(e.target.value)} />
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<label className="text-sm w-28">Prompt Info</label>
|
||
<Input placeholder="模板:info" value={promptInfo} onChange={(e) => setPromptInfo(e.target.value)} />
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<label className="text-sm w-28">Prompt Finance</label>
|
||
<Input placeholder="模板:finance" value={promptFinance} onChange={(e) => setPromptFinance(e.target.value)} />
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button onClick={saveConfig} disabled={saving}>{saving ? "保存中…" : "保存配置"}</Button>
|
||
{msg && <span className="text-xs text-muted-foreground">{msg}</span>}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
} |