www_site/components/admin/tts.tsx

333 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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