227 lines
10 KiB
TypeScript
227 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { getUsageSummary, listAuditLogs, listTtsTasks } from "@/lib/admin";
|
||
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">查询</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">上一页</button>
|
||
<button className="button-ghost disabled:opacity-50" disabled={taskPage >= taskPages} onClick={() => void loadTasks(taskPage + 1)} type="button">下一页</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">上一页</button>
|
||
<button className="button-ghost disabled:opacity-50" disabled={auditPage >= auditPages} onClick={() => void loadAudit(auditPage + 1)} type="button">下一页</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|