www_site/components/admin/tts.tsx

228 lines
10 KiB
TypeScript
Raw 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 { 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>
);
}