333 lines
18 KiB
TypeScript
333 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|