From 86fc51e486c16c0fc12537d40f854ca476a5e02b Mon Sep 17 00:00:00 2001 From: flym Date: Sat, 12 Sep 2026 06:08:27 +0800 Subject: [PATCH] feat: admin TTS settings (db-backed) with probe-before-save; worker uses dynamic tts config --- components/admin/tts.tsx | 115 +++++++++++++++- lib/admin.ts | 33 +++++ services/api/app/main.py | 132 +++++++++++++++++-- services/api/app/schemas.py | 16 +++ services/api/migrations/007_tts_settings.sql | 9 ++ 5 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 services/api/migrations/007_tts_settings.sql diff --git a/components/admin/tts.tsx b/components/admin/tts.tsx index 2e28d1c..0f35210 100644 --- a/components/admin/tts.tsx +++ b/components/admin/tts.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { getUsageSummary, listAuditLogs, listTtsTasks } from "@/lib/admin"; +import { fetchCsrf, getTtsSettings, testTtsSettings, updateTtsSettings, getUsageSummary, listAuditLogs, listTtsTasks, type TtsSettings, type TtsSettingsTestResult } from "@/lib/admin"; import { BrandIcon } from "@/components/brand-icon"; import { StatusCard } from "@/components/ui"; @@ -45,10 +45,112 @@ const actionLabel: Record = { membership: "授予/变更会员", membership_revoke: "撤销会员", quota_adjustment: "额度调整", + tts_settings_update: "TTS 配置变更", }; +type ConfigForm = { upstream_url: string; api_key: string; timeout_seconds: number; default_model: string; reason: string }; + +function TtsConfigPanel() { + const [csrf, setCsrf] = useState(""); + const [current, setCurrent] = useState(null); + const [form, setForm] = useState({ upstream_url: "", api_key: "", timeout_seconds: 120, default_model: "qwen3-tts", reason: "" }); + const [busy, setBusy] = useState(false); + const [testing, setTesting] = useState(false); + const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); + const [testResult, setTestResult] = useState(null); + + async function load() { + try { + const s = await getTtsSettings(); + setCurrent(s); + setForm((f) => ({ ...f, upstream_url: s.upstream_url, timeout_seconds: s.timeout_seconds, default_model: s.default_model })); + } catch (e) { + setMessage({ ok: false, text: e instanceof Error ? e.message : "加载配置失败" }); + } + } + + useEffect(() => { + void fetchCsrf().then(setCsrf).catch(() => undefined); + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function save() { + setBusy(true); + setMessage(null); + try { + await updateTtsSettings({ upstream_url: form.upstream_url.trim(), api_key: form.api_key.trim() || undefined, timeout_seconds: form.timeout_seconds, default_model: form.default_model.trim(), reason: form.reason.trim() }, csrf); + setForm((f) => ({ ...f, api_key: "" })); + setTestResult(null); + setMessage({ ok: true, text: "配置已保存,新任务立即生效" }); + await load(); + } catch (e) { + setMessage({ ok: false, text: e instanceof Error ? e.message : "保存失败" }); + } finally { + setBusy(false); + } + } + + async function probe() { + setTesting(true); + setTestResult(null); + try { + const res = await testTtsSettings({ upstream_url: form.upstream_url.trim() || undefined, api_key: form.api_key.trim() || undefined, timeout_seconds: form.timeout_seconds, default_model: form.default_model.trim() || undefined }, csrf); + setTestResult(res); + } catch (e) { + setTestResult({ ok: false, message: e instanceof Error ? e.message : "探测请求失败" }); + } finally { + setTesting(false); + } + } + + return ( +
+
+

TTS 上游配置

+

留空 API Key 表示保持不变。保存后新的生成任务立即使用新配置;建议先探测、确认成功再保存。探测会消耗上游一次生成额度。

+
+
setForm((f) => ({ ...f, upstream_url: e.target.value }))} />
+
setForm((f) => ({ ...f, api_key: e.target.value }))} />
+
+
setForm((f) => ({ ...f, timeout_seconds: Math.max(5, Math.min(600, Number(e.target.value) || 120)) }))} />
+
setForm((f) => ({ ...f, default_model: e.target.value }))} />
+
+
setForm((f) => ({ ...f, reason: e.target.value }))} />
+
+ + +
+
+ {message ?

{message.text}

: null} +
+
+
+

当前生效配置

+ {current ? ( +
+
来源
{current.source === "db" ? "管理后台(数据库)" : "服务器环境变量"}
+
上游地址
{current.upstream_url || "未配置"}
+
API Key
{current.api_key_set ? current.api_key_masked : "未配置"}
+
超时 / 默认模型
{current.timeout_seconds}s · {current.default_model}
+ {current.updated_at ?
最近修改
{new Date(current.updated_at).toLocaleString()}
: null} +
+ ) :

加载中…

} +
+ {testResult ? ( +
+

探测结果

+

{testResult.message}

+

{[testResult.status_code != null ? `HTTP ${testResult.status_code}` : null, testResult.latency_ms != null ? `耗时 ${testResult.latency_ms}ms` : null, testResult.content_type ? testResult.content_type : null, testResult.size_bytes ? `${(testResult.size_bytes / 1024).toFixed(1)} KB` : null].filter(Boolean).join(" · ")}

+
+ ) : null} +
+
+ ); +} + export function TtsAuditPanel() { - const [tab, setTab] = useState<"tasks" | "audit">("tasks"); + const [tab, setTab] = useState<"tasks" | "config" | "audit">("tasks"); const [status, setStatus] = useState(""); const [taskPage, setTaskPage] = useState(1); const [tasks, setTasks] = useState([]); @@ -100,10 +202,10 @@ export function TtsAuditPanel() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - function switchTab(next: "tasks" | "audit") { + function switchTab(next: "tasks" | "config" | "audit") { setTab(next); if (next === "tasks") void loadTasks(1); - else void loadAudit(1); + else if (next === "audit") void loadAudit(1); } return ( @@ -121,6 +223,9 @@ export function TtsAuditPanel() { + @@ -128,7 +233,7 @@ export function TtsAuditPanel() { {error ?
{error}
: null} - {tab === "tasks" ? ( + {tab === "config" ? : tab === "tasks" ? (