www_site/components/admin/tts.tsx

333 lines
18 KiB
TypeScript
Raw Normal View History

"use client";
import { useEffect, useState } from "react";
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";
type Task = {
id: string;
user_id: string;
user_email: string;
status: string;
text_length: number;
provider_voice_id: string;
error_code: string | null;
attempt_count: number;
reserved_amount: number;
created_at: string;
started_at: string | null;
finished_at: string | null;
duration_ms: number | null;
settlement: string;
reserved: number;
used: number;
};
type Audit = {
id: string;
actor_email: string | null;
action: string;
target_type: string;
target_id: string;
before_value: Record<string, unknown> | null;
after_value: Record<string, unknown> | null;
reason: string;
created_at: string;
};
type Summary = { tasks: { total: number; by_status: Record<string, number>; failed: number }; quota: { used: number; reserved: number; adjustment: number }; ledger_total: number };
const statusLabel: Record<string, string> = { queued: "排队", running: "进行中", succeeded: "成功", failed: "失败" };
const settleLabel: Record<string, string> = { consumed: "已消费", released: "已释放", pending: "待结算" };
const actionLabel: Record<string, string> = {
user_status: "用户状态变更",
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<TtsSettings | null>(null);
const [form, setForm] = useState<ConfigForm>({ 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<TtsSettingsTestResult | null>(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 (
<div className="grid gap-5 lg:grid-cols-2">
<div className="rounded-2xl border border-line bg-panel/50 p-5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">TTS </p>
<p className="mt-2 text-xs leading-6 text-subtle"> API Key 使</p>
<div className="mt-4 space-y-4">
<div><label className="field-label" htmlFor="tts-url">Base URL</label><input className="field-input" id="tts-url" placeholder="http://43.248.188.28:44480" value={form.upstream_url} onChange={(e) => setForm((f) => ({ ...f, upstream_url: e.target.value }))} /></div>
<div><label className="field-label" htmlFor="tts-key">API Key</label><input className="field-input" id="tts-key" placeholder={current?.api_key_set ? `已配置(${current.api_key_masked}),留空保持不变` : "未配置"} type="password" value={form.api_key} onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))} /></div>
<div className="grid gap-4 sm:grid-cols-2">
<div><label className="field-label" htmlFor="tts-timeout"></label><input className="field-input" id="tts-timeout" min={5} max={600} type="number" value={form.timeout_seconds} onChange={(e) => setForm((f) => ({ ...f, timeout_seconds: Math.max(5, Math.min(600, Number(e.target.value) || 120)) }))} /></div>
<div><label className="field-label" htmlFor="tts-model"></label><input className="field-input" id="tts-model" value={form.default_model} onChange={(e) => setForm((f) => ({ ...f, default_model: e.target.value }))} /></div>
</div>
<div><label className="field-label" htmlFor="tts-reason"></label><input className="field-input" id="tts-reason" maxLength={500} placeholder="例如:切换生产上游地址" value={form.reason} onChange={(e) => setForm((f) => ({ ...f, reason: e.target.value }))} /></div>
<div className="flex flex-wrap gap-3 pt-1">
<button className="button-secondary" disabled={testing || busy} onClick={() => void probe()} type="button"><BrandIcon />{testing ? "探测中…" : "探测当前表单"}</button>
<button className="button-primary" disabled={busy || testing || !csrf} onClick={() => void save()} type="button"><BrandIcon />{busy ? "保存中…" : "保存配置"}</button>
</div>
</div>
{message ? <p className={`mt-4 rounded-lg border px-3 py-2 text-sm ${message.ok ? "border-success/30 bg-success/10 text-success" : "border-danger/30 bg-danger/10 text-danger"}`}>{message.text}</p> : null}
</div>
<div className="space-y-5">
<div className="rounded-2xl border border-line bg-panel/50 p-5">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle"></p>
{current ? (
<dl className="mt-3 space-y-2 text-sm">
<div className="flex justify-between gap-4"><dt className="text-muted"></dt><dd className="text-copy">{current.source === "db" ? "管理后台(数据库)" : "服务器环境变量"}</dd></div>
<div className="flex justify-between gap-4"><dt className="text-muted"></dt><dd className="break-all text-copy">{current.upstream_url || "未配置"}</dd></div>
<div className="flex justify-between gap-4"><dt className="text-muted">API Key</dt><dd className="text-copy">{current.api_key_set ? current.api_key_masked : "未配置"}</dd></div>
<div className="flex justify-between gap-4"><dt className="text-muted"> / </dt><dd className="text-copy">{current.timeout_seconds}s · {current.default_model}</dd></div>
{current.updated_at ? <div className="flex justify-between gap-4"><dt className="text-muted"></dt><dd className="text-subtle">{new Date(current.updated_at).toLocaleString()}</dd></div> : null}
</dl>
) : <p className="mt-3 text-sm text-muted"></p>}
</div>
{testResult ? (
<div className={`rounded-2xl border p-5 ${testResult.ok ? "border-success/30 bg-success/5" : "border-danger/30 bg-danger/5"}`}>
<p className={`text-xs font-semibold uppercase tracking-[0.18em] ${testResult.ok ? "text-success" : "text-danger"}`}></p>
<p className="mt-2 text-sm leading-6 text-copy">{testResult.message}</p>
<p className="mt-2 text-xs text-subtle">{[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(" · ")}</p>
</div>
) : null}
</div>
</div>
);
}
export function TtsAuditPanel() {
const [tab, setTab] = useState<"tasks" | "config" | "audit">("tasks");
const [status, setStatus] = useState("");
const [taskPage, setTaskPage] = useState(1);
const [tasks, setTasks] = useState<Task[]>([]);
const [taskTotal, setTaskTotal] = useState(0);
const [taskPages, setTaskPages] = useState(1);
const [auditPage, setAuditPage] = useState(1);
const [audits, setAudits] = useState<Audit[]>([]);
const [auditTotal, setAuditTotal] = useState(0);
const [auditPages, setAuditPages] = useState(1);
const [summary, setSummary] = useState<Summary | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function loadTasks(p = taskPage) {
setLoading(true);
setError("");
try {
const res = await listTtsTasks({ status, page: p, limit: 20 });
setTasks(res.items as unknown as Task[]);
setTaskTotal(res.total);
setTaskPages(res.pages);
setTaskPage(res.page);
} catch (e) {
setError(e instanceof Error ? e.message : "加载失败");
} finally {
setLoading(false);
}
}
async function loadAudit(p = auditPage) {
setLoading(true);
setError("");
try {
const res = await listAuditLogs({ page: p, limit: 30 });
setAudits(res.items as unknown as Audit[]);
setAuditTotal(res.total);
setAuditPages(res.pages);
setAuditPage(res.page);
} catch (e) {
setError(e instanceof Error ? e.message : "加载失败");
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadTasks(1);
void getUsageSummary().then(setSummary).catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function switchTab(next: "tasks" | "config" | "audit") {
setTab(next);
if (next === "tasks") void loadTasks(1);
else if (next === "audit") void loadAudit(1);
}
return (
<div className="space-y-6">
{summary ? (
<div className="grid gap-5 md:grid-cols-4">
<StatusCard title="任务总数" description={`${summary.tasks.total}`} />
<StatusCard title="成功" description={`${summary.tasks.by_status.succeeded ?? 0}`} tone="success" />
<StatusCard title="失败" description={`${summary.tasks.failed}`} tone="warning" />
<StatusCard title="额度账本" description={summary.ledger_total.toLocaleString()} tone="info" />
</div>
) : null}
<div className="flex gap-2">
<button className={`rounded-lg px-4 py-2 text-sm font-semibold ${tab === "tasks" ? "bg-blue/10 text-cyan" : "text-muted hover:text-copy"}`} onClick={() => switchTab("tasks")} type="button">
</button>
<button className={`rounded-lg px-4 py-2 text-sm font-semibold ${tab === "config" ? "bg-blue/10 text-cyan" : "text-muted hover:text-copy"}`} onClick={() => switchTab("config")} type="button">
TTS
</button>
<button className={`rounded-lg px-4 py-2 text-sm font-semibold ${tab === "audit" ? "bg-blue/10 text-cyan" : "text-muted hover:text-copy"}`} onClick={() => switchTab("audit")} type="button">
</button>
</div>
{error ? <div className="rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm text-danger">{error}</div> : null}
{tab === "config" ? <TtsConfigPanel /> : tab === "tasks" ? (
<div className="rounded-2xl border border-line bg-panel/50">
<div className="grid gap-3 border-b border-line p-5 sm:grid-cols-[1fr_auto]">
<select aria-label="按状态筛选" className="field-input" onChange={(e) => setStatus(e.target.value)} value={status}>
<option value=""></option>
<option value="queued"></option>
<option value="running"></option>
<option value="succeeded"></option>
<option value="failed"></option>
</select>
<button className="button-secondary" onClick={() => void loadTasks(1)} type="button"><BrandIcon /></button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-line text-xs uppercase tracking-[0.14em] text-subtle">
<tr>
<th className="px-5 py-3"></th>
<th className="px-5 py-3"></th>
<th className="px-5 py-3"></th>
<th className="px-5 py-3"></th>
<th className="px-5 py-3"></th>
<th className="px-5 py-3"></th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{loading ? (
<tr><td className="px-5 py-8 text-center text-muted" colSpan={7}></td></tr>
) : tasks.length === 0 ? (
<tr><td className="px-5 py-8 text-center text-muted" colSpan={7}></td></tr>
) : (
tasks.map((task) => (
<tr className="hover:bg-panel/40" key={task.id}>
<td className="px-5 py-3"><p className="text-copy">{task.user_email}</p><p className="text-xs text-subtle">{new Date(task.created_at).toLocaleString()}</p></td>
<td className="px-5 py-3">{statusLabel[task.status] ?? task.status}</td>
<td className="px-5 py-3">{task.text_length} </td>
<td className="px-5 py-3">{task.duration_ms != null ? `${(task.duration_ms / 1000).toFixed(1)}s` : "—"}</td>
<td className="px-5 py-3">{settleLabel[task.settlement] ?? task.settlement}</td>
<td className="px-5 py-3 text-danger">{task.error_code ?? "—"}</td>
<td className="px-5 py-3"><span className="text-xs text-subtle">{task.id.slice(0, 8)}</span></td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between gap-3 border-t border-line p-4 text-sm">
<span className="text-subtle"> {taskPage} / {taskPages} </span>
<div className="flex gap-2">
<button className="button-ghost disabled:opacity-50" disabled={taskPage <= 1} onClick={() => void loadTasks(Math.max(1, taskPage - 1))} type="button"><BrandIcon /></button>
<button className="button-ghost disabled:opacity-50" disabled={taskPage >= taskPages} onClick={() => void loadTasks(taskPage + 1)} type="button"><BrandIcon /></button>
</div>
</div>
</div>
) : (
<div className="rounded-2xl border border-line bg-panel/50">
<p className="border-b border-line p-5 text-xs leading-6 text-subtle">VIP </p>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b border-line text-xs uppercase tracking-[0.14em] text-subtle">
<tr>
<th className="px-5 py-3"></th>
<th className="px-5 py-3"></th>
<th className="px-5 py-3"></th>
<th className="px-5 py-3"></th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{audits.length === 0 ? (
<tr><td className="px-5 py-8 text-center text-muted" colSpan={5}></td></tr>
) : (
audits.map((a) => (
<tr className="hover:bg-panel/40" key={a.id}>
<td className="px-5 py-3 text-copy">{a.actor_email ?? "—"}</td>
<td className="px-5 py-3">{actionLabel[a.action] ?? a.action}</td>
<td className="px-5 py-3"><span className="text-xs text-subtle">{a.target_type} · {String(a.target_id).slice(0, 8)}</span></td>
<td className="px-5 py-3 text-muted">{a.reason}</td>
<td className="px-5 py-3 text-subtle">{new Date(a.created_at).toLocaleString()}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between gap-3 border-t border-line p-4 text-sm">
<span className="text-subtle"> {auditPage} / {auditPages} </span>
<div className="flex gap-2">
<button className="button-ghost disabled:opacity-50" disabled={auditPage <= 1} onClick={() => void loadAudit(Math.max(1, auditPage - 1))} type="button"><BrandIcon /></button>
<button className="button-ghost disabled:opacity-50" disabled={auditPage >= auditPages} onClick={() => void loadAudit(auditPage + 1)} type="button"><BrandIcon /></button>
</div>
</div>
</div>
)}
</div>
);
}