www_site/components/admin/tts.tsx

228 lines
10 KiB
TypeScript
Raw Normal View History

"use client";
import { useEffect, useState } from "react";
import { getUsageSummary, listAuditLogs, listTtsTasks } 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: "额度调整",
};
export function TtsAuditPanel() {
const [tab, setTab] = useState<"tasks" | "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" | "audit") {
setTab(next);
if (next === "tasks") void loadTasks(1);
else 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 === "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 === "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>
);
}