feat(admin): build real admin UI for users, VIP/quota, TTS and audit
This commit is contained in:
parent
8aae84b404
commit
2aa1af97a4
@ -1,9 +1,14 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight } from "@/components/icons";
|
||||
import { PreviewNotice, StatusCard } from "@/components/ui";
|
||||
import { AdminGate, AdminPageShell } from "@/components/admin/gate";
|
||||
import { OverviewPanel } from "@/components/admin/overview";
|
||||
|
||||
export const metadata = { title: "管理后台" };
|
||||
|
||||
export default function AdminPage() {
|
||||
return <div className="container-shell py-16 sm:py-24"><div className="flex flex-col justify-between gap-5 border-b border-line pb-10 sm:flex-row sm:items-end"><div><p className="eyebrow">Operations</p><h1 className="mt-5 text-4xl font-semibold tracking-[-0.05em]">管理后台</h1></div><span className="text-sm text-subtle">role=admin / 预览</span></div><div className="mt-8"><PreviewNotice>管理权限、用户查询、会员和额度操作均未接入。此阶段不提供可执行的管理操作。</PreviewNotice></div><div className="mt-8 grid gap-5 md:grid-cols-3"><StatusCard title="用户" description="分页、禁用和恢复将在 Phase 4 接入。" /><StatusCard title="TTS 任务" description="任务、失败和使用量将在 Phase 4 接入。" tone="info" /><StatusCard title="审计" description="操作原因和前后值将在业务 API 接入后记录。" tone="warning" /></div><div className="mt-8 grid gap-4 sm:grid-cols-2"><Link className="group rounded-2xl border border-line bg-panel/50 p-6 hover:border-blue/60" href="/admin/users"><p className="text-xs uppercase tracking-[0.18em] text-subtle">01 / Users</p><h2 className="mt-3 text-xl font-semibold">用户与会员</h2><p className="mt-2 text-sm leading-6 text-muted">布局预览:筛选、状态、Plan 和额度。</p><span className="mt-5 inline-flex text-sm font-semibold text-cyan">打开预览 <ArrowRight /></span></Link><Link className="group rounded-2xl border border-line bg-panel/50 p-6 hover:border-cyan/60" href="/admin/tts"><p className="text-xs uppercase tracking-[0.18em] text-subtle">02 / TTS</p><h2 className="mt-3 text-xl font-semibold">TTS 任务与使用量</h2><p className="mt-2 text-sm leading-6 text-muted">布局预览:任务状态、耗时和错误。</p><span className="mt-5 inline-flex text-sm font-semibold text-cyan">打开预览 <ArrowRight /></span></Link></div></div>;
|
||||
export default function AdminIndexPage() {
|
||||
return (
|
||||
<AdminGate>
|
||||
<AdminPageShell eyebrow="Operations" title="管理概览" description="查看用户规模、TTS 任务状态与额度流水,全量管理入口在你获得管理员会话后可用。">
|
||||
<OverviewPanel />
|
||||
</AdminPageShell>
|
||||
</AdminGate>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import { PreviewNotice, StatusCard } from "@/components/ui";
|
||||
import { AdminGate, AdminPageShell } from "@/components/admin/gate";
|
||||
import { TtsAuditPanel } from "@/components/admin/tts";
|
||||
|
||||
export const metadata = { title: "TTS 任务管理" };
|
||||
export const metadata = { title: "TTS 任务与审计" };
|
||||
|
||||
export default function AdminTtsPage() {
|
||||
return <div className="container-shell py-16 sm:py-24"><p className="eyebrow eyebrow-cyan">Admin / TTS</p><h1 className="mt-5 text-4xl font-semibold tracking-[-0.05em]">TTS 任务与使用量</h1><p className="mt-4 max-w-xl text-muted">用于运营查看任务状态、耗时、错误码和计量消耗,不默认展示完整输入文本。</p><div className="mt-8"><PreviewNotice>当前没有真实任务。筛选和统计均为布局占位,不会读取或修改上游服务。</PreviewNotice></div><div className="mt-8 grid gap-5 md:grid-cols-3"><StatusCard title="任务数" description="待任务服务接入" /><StatusCard title="成功率" description="待统计服务接入" tone="success" /><StatusCard title="冻结异常" description="待额度服务接入" tone="warning" /></div><div className="mt-8 overflow-hidden rounded-2xl border border-line bg-panel/50"><div className="grid gap-4 border-b border-line p-5 sm:grid-cols-3"><select className="field-input" disabled aria-label="任务状态"><option>状态:待接入</option></select><input className="field-input" disabled aria-label="按任务 ID 筛选" placeholder="任务 ID(待接入)" /><button className="button-ghost" disabled type="button">查询(待接入)</button></div><div className="p-10 text-center text-sm text-muted">暂无可显示的任务记录。</div></div></div>;
|
||||
return (
|
||||
<AdminGate>
|
||||
<AdminPageShell eyebrow="Admin / TTS" title="TTS 任务与审计" description="按用户、时间、状态查询任务;查看计量、耗时、错误与结算;查看管理员操作审计。默认不展开完整用户文本,不显示任何秘密配置。">
|
||||
<TtsAuditPanel />
|
||||
</AdminPageShell>
|
||||
</AdminGate>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import { PreviewNotice, StatusCard } from "@/components/ui";
|
||||
import { AdminGate, AdminPageShell } from "@/components/admin/gate";
|
||||
import { UsersPanel } from "@/components/admin/users";
|
||||
|
||||
export const metadata = { title: "用户管理" };
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
return <div className="container-shell py-16 sm:py-24"><p className="eyebrow">Admin / Users</p><h1 className="mt-5 text-4xl font-semibold tracking-[-0.05em]">用户与会员</h1><p className="mt-4 max-w-xl text-muted">Phase 4 管理界面结构预览。当前不查询或修改真实用户。</p><div className="mt-8"><PreviewNotice>筛选、禁用、VIP 授予和额度调整按钮保持不可用,不会产生任何操作成功提示。</PreviewNotice></div><div className="mt-8 grid gap-5 md:grid-cols-3"><StatusCard title="用户总数" description="待 API 接入" /><StatusCard title="VIP 用户" description="待会员服务接入" tone="info" /><StatusCard title="待处理" description="无可核实数据" tone="warning" /></div><div className="mt-8 overflow-hidden rounded-2xl border border-line bg-panel/50"><div className="flex flex-col gap-4 border-b border-line p-5 sm:flex-row"><input aria-label="按邮箱筛选" className="field-input sm:max-w-sm" disabled placeholder="按邮箱筛选(待接入)" /><button className="button-ghost sm:ml-auto" disabled type="button">筛选(待接入)</button></div><div className="p-10 text-center text-sm text-muted">暂无真实用户数据。表格将在 API 与权限模型完成后启用。</div></div></div>;
|
||||
return (
|
||||
<AdminGate>
|
||||
<AdminPageShell eyebrow="Admin / Users" title="用户与会员" description="分页查询用户,按邮箱/手机号/状态筛选;禁用恢复会撤销会话,授予/撤销 VIP,并调整周期额度。所有写操作都需要原因。">
|
||||
<UsersPanel />
|
||||
</AdminPageShell>
|
||||
</AdminGate>
|
||||
);
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@ export function AccountPanel() {
|
||||
return <>
|
||||
<div className="mt-8 grid gap-5 md:grid-cols-2 lg:grid-cols-4"><StatusCard title="账户状态" description={user.status === "active" ? "正常" : "已停用"} tone="success" /><StatusCard title="会员计划" description={usage.plan.toUpperCase()} /><StatusCard title="可用额度" description={`${usage.available.toLocaleString()} 字符`} tone="info" /><StatusCard title="验证状态" description={`邮箱 ${user.email_verified ? "已验证" : "未验证"} · 手机 ${user.phone_verified ? "已验证" : "未验证"}`} tone="warning" /></div>
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold">基本资料</h2><dl className="mt-6 space-y-5 text-sm"><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">邮箱</dt><dd className="text-copy">{user.email}</dd></div><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">角色</dt><dd className="text-copy">{user.role}</dd></div><div className="flex justify-between gap-4"><dt className="text-muted">额度周期</dt><dd className="text-right text-subtle">{new Date(usage.period_start).toLocaleDateString()} 至 {new Date(usage.period_end).toLocaleDateString()}</dd></div></dl><button className="button-ghost mt-7 w-full" onClick={logout} type="button">退出登录 <ArrowRight /></button></section>
|
||||
<section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Profile</p><h2 className="mt-3 text-xl font-semibold">基本资料</h2><dl className="mt-6 space-y-5 text-sm"><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">邮箱</dt><dd className="text-copy">{user.email}</dd></div><div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">角色</dt><dd className="text-copy">{user.role}</dd></div>{user.role === "admin" ? <div className="flex justify-between gap-4 border-b border-line pb-4"><dt className="text-muted">后台</dt><dd><Link className="text-cyan hover:text-copy" href="/admin">进入管理后台</Link></dd></div> : null}<div className="flex justify-between gap-4"><dt className="text-muted">额度周期</dt><dd className="text-right text-subtle">{new Date(usage.period_start).toLocaleDateString()} 至 {new Date(usage.period_end).toLocaleDateString()}</dd></div></dl><button className="button-ghost mt-7 w-full" onClick={logout} type="button">退出登录 <ArrowRight /></button></section>
|
||||
<div className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-6"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Usage</p><h2 className="mt-3 text-xl font-semibold">额度明细</h2></div><Link className="text-sm text-cyan hover:text-copy" href="/tts">打开工作台</Link></div><div className="mt-6 grid gap-4 sm:grid-cols-3"><StatusCard title="周期上限" description={usage.limit.toLocaleString()} /><StatusCard title="已使用" description={usage.used.toLocaleString()} /><StatusCard title="冻结" description={usage.reserved.toLocaleString()} /></div></section><section className="rounded-2xl border border-line bg-panel/60 p-6"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">Security</p><h2 className="mt-3 text-xl font-semibold">修改密码</h2><form className="mt-5 grid gap-4 sm:grid-cols-2" onSubmit={changePassword}><div><label className="field-label" htmlFor="current-password">当前密码</label><input className="field-input" id="current-password" name="current_password" required type="password" /></div><div><label className="field-label" htmlFor="new-password">新密码</label><input className="field-input" id="new-password" minLength={8} name="new_password" required type="password" /></div>{passwordMessage ? <p className="sm:col-span-2 text-sm text-muted" role="status">{passwordMessage}</p> : null}<button className="button-secondary sm:col-span-2" type="submit">更新密码</button></form></section></div>
|
||||
</div>
|
||||
</>;
|
||||
|
||||
90
components/admin/gate.tsx
Normal file
90
components/admin/gate.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getMe } from "@/lib/admin";
|
||||
import { PreviewNotice, StatusCard } from "@/components/ui";
|
||||
|
||||
type Me = { email: string; role: string; plan: string; status: string };
|
||||
|
||||
const nav = [
|
||||
{ href: "/admin", label: "概览" },
|
||||
{ href: "/admin/users", label: "用户" },
|
||||
{ href: "/admin/tts", label: "TTS 与审计" },
|
||||
];
|
||||
|
||||
export function AdminGate({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [state, setState] = useState<"loading" | "denied" | "ready">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
getMe()
|
||||
.then((m) => {
|
||||
setMe(m);
|
||||
setState(m.role === "admin" ? "ready" : "denied");
|
||||
})
|
||||
.catch(() => setState("denied"));
|
||||
}, []);
|
||||
|
||||
if (state === "loading") {
|
||||
return (
|
||||
<div className="container-shell py-16 sm:py-24">
|
||||
<StatusCard title="正在校验管理权限" description="正从业务 API 读取当前管理员会话。" tone="info" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === "denied") {
|
||||
return (
|
||||
<div className="container-shell py-16 sm:py-24">
|
||||
<PreviewNotice>访问被拒绝:需要管理员身份。普通用户无法查看或操作管理后台。</PreviewNotice>
|
||||
<div className="mt-8">
|
||||
<StatusCard title="无权访问" description="当前会话不是管理员,或未登录。前往账户页登录后,只有 role=admin 的账户才能看到管理功能。" tone="warning" />
|
||||
<Link className="button-ghost mt-6 inline-flex" href="/account">
|
||||
前往账户
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container-shell grid gap-6 py-10 lg:grid-cols-[220px_1fr]">
|
||||
<aside>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-subtle">Operations</p>
|
||||
<nav className="mt-4 flex gap-1 lg:flex-col" aria-label="管理后台导航">
|
||||
{nav.map((item) => {
|
||||
const active = item.href === "/admin" ? pathname === "/admin" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
className={`rounded-lg px-3 py-2 text-sm font-semibold transition-colors ${active ? "bg-blue/10 text-cyan" : "text-muted hover:text-copy"}`}
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<p className="mt-6 rounded-xl border border-line bg-panel/50 p-4 text-xs leading-6 text-subtle">
|
||||
当前管理员:{me?.email}
|
||||
</p>
|
||||
</aside>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPageShell({ eyebrow, title, description, children }: { eyebrow: string; title: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-[-0.04em] sm:text-4xl">{title}</h1>
|
||||
{description ? <p className="mt-3 max-w-2xl leading-6 text-muted">{description}</p> : null}
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
components/admin/overview.tsx
Normal file
51
components/admin/overview.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getUsageSummary } from "@/lib/admin";
|
||||
import { StatusCard } from "@/components/ui";
|
||||
|
||||
type Summary = {
|
||||
quota: { used: number; reserved: number; adjustment: number };
|
||||
ledger_total: number;
|
||||
tasks: { total: number; by_status: Record<string, number>; failed: number };
|
||||
};
|
||||
|
||||
export function OverviewPanel() {
|
||||
const [data, setData] = useState<Summary | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
getUsageSummary()
|
||||
.then(setData)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : "无法加载概览"));
|
||||
}, []);
|
||||
|
||||
if (error) return <div className="rounded-xl border border-danger/30 bg-danger/10 p-4 text-sm text-danger">{error}</div>;
|
||||
if (!data) return <StatusCard title="正在加载概览" description="正在汇总额度与任务。" tone="info" />;
|
||||
|
||||
const statusLabels: Record<string, string> = { queued: "排队", running: "进行中", succeeded: "成功", failed: "失败" };
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<StatusCard title="累计使用" description={`${data.quota.used.toLocaleString()} 字符`} tone="success" />
|
||||
<StatusCard title="冻结中" description={`${data.quota.reserved.toLocaleString()} 字符`} tone="warning" />
|
||||
<StatusCard title="手动调整" description={`${data.quota.adjustment.toLocaleString()} 字符`} tone="info" />
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<StatusCard title="任务总数" description={`${data.tasks.total}`} />
|
||||
<StatusCard title={statusLabels.succeeded} description={`${data.tasks.by_status.succeeded ?? 0} 个`} tone="success" />
|
||||
<StatusCard title="失败任务" description={`${data.tasks.failed} 个`} tone="warning" />
|
||||
</div>
|
||||
<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>
|
||||
<dl className="mt-3 grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div className="flex justify-between gap-4 border-b border-line pb-3 sm:border-0 sm:pb-0"><dt className="text-muted">额度账本合计</dt><dd className="text-copy">{data.ledger_total.toLocaleString()}</dd></div>
|
||||
<div className="flex justify-between gap-4 sm:justify-start"><dt className="text-muted">quota_accounts.used</dt><dd className="text-copy">{data.quota.used.toLocaleString()}</dd></div>
|
||||
<div className="flex justify-between gap-4 sm:justify-start"><dt className="text-muted">计入调整</dt><dd className="text-copy">{data.quota.adjustment.toLocaleString()}</dd></div>
|
||||
</dl>
|
||||
<p className="mt-3 text-xs leading-5 text-subtle">版本构建号会随部署更新;所有数字来自数据库流水,不包含未入账的中途值。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
components/admin/tts.tsx
Normal file
226
components/admin/tts.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
275
components/admin/users.tsx
Normal file
275
components/admin/users.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
adjustQuota,
|
||||
changeUserStatus,
|
||||
fetchCsrf,
|
||||
grantMembership,
|
||||
getUserDetail,
|
||||
getUserQuota,
|
||||
listUsers,
|
||||
revokeMembership,
|
||||
type AdminUser,
|
||||
type AdminUserDetail,
|
||||
} from "@/lib/admin";
|
||||
import { StatusCard } from "@/components/ui";
|
||||
|
||||
type UserRow = AdminUser;
|
||||
|
||||
export function UsersPanel() {
|
||||
const [rows, setRows] = useState<UserRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [page, setPage] = useState(1);
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<AdminUserDetail | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
async function load(pageNum = page) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await listUsers({ email, phone, status, page: pageNum, limit: 20 });
|
||||
setRows(res.items);
|
||||
setTotal(res.total);
|
||||
setPages(res.pages);
|
||||
setPage(res.page);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setError("");
|
||||
try {
|
||||
const detail = await getUserDetail(id);
|
||||
setSelected(detail);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "加载详情失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<StatusCard title="用户总数" description={`${total.toLocaleString()} 个`} />
|
||||
<StatusCard title="当前页" description={`${rows.length} 条`} tone="info" />
|
||||
<StatusCard title="总页数" description={`${pages} 页`} />
|
||||
</div>
|
||||
|
||||
<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_1fr_1fr_auto_auto]">
|
||||
<input aria-label="按邮箱筛选" className="field-input" onChange={(e) => setEmail(e.target.value)} placeholder="按邮箱筛选" value={email} />
|
||||
<input aria-label="按手机号筛选" className="field-input" onChange={(e) => setPhone(e.target.value)} placeholder="按手机号筛选" value={phone} />
|
||||
<select aria-label="按状态筛选" className="field-input" onChange={(e) => setStatus(e.target.value)} value={status}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">正常</option>
|
||||
<option value="disabled">已禁用</option>
|
||||
</select>
|
||||
<button className="button-secondary" onClick={() => void load(1)} type="button">
|
||||
查询
|
||||
</button>
|
||||
<button className="button-ghost" onClick={() => { setEmail(""); setPhone(""); setStatus(""); void load(1); }} type="button">
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="m-5 rounded-lg border border-danger/30 bg-danger/10 p-3 text-sm text-danger">{error}</div> : null}
|
||||
|
||||
<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">有效 Plan</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={6}>加载中…</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr><td className="px-5 py-8 text-center text-muted" colSpan={6}>无匹配用户</td></tr>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<tr className="hover:bg-panel/40" key={row.id}>
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-semibold text-copy">{row.email}</p>
|
||||
<p className="text-xs text-subtle">{row.phone ?? "无手机号"}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-cyan">{row.effective_plan.toUpperCase()}</td>
|
||||
<td className="px-5 py-3">{row.role}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={row.status === "active" ? "text-success" : "text-danger"}>{row.status === "active" ? "正常" : "已禁用"}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-subtle">{new Date(row.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-5 py-3">
|
||||
<button className="text-sm font-semibold text-cyan hover:text-copy" onClick={() => void openDetail(row.id)} type="button">
|
||||
详情
|
||||
</button>
|
||||
</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">第 {page} / {pages} 页</span>
|
||||
<div className="flex gap-2">
|
||||
<button className="button-ghost disabled:opacity-50" disabled={page <= 1} onClick={() => void load(Math.max(1, page - 1))} type="button">
|
||||
上一页
|
||||
</button>
|
||||
<button className="button-ghost disabled:opacity-50" disabled={page >= pages} onClick={() => void load(page + 1)} type="button">
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected ? <UserDetailPanel detail={selected} onRefresh={() => void openDetail(selected.user.id)} setNotice={setNotice} onClose={() => setSelected(null)} /> : null}
|
||||
{notice ? <div className="rounded-xl border border-success/30 bg-success/10 p-4 text-sm text-success" role="status">{notice}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDetailPanel({ detail, onRefresh, setNotice, onClose }: { detail: AdminUserDetail; onRefresh: () => void; setNotice: (msg: string) => void; onClose: () => void }) {
|
||||
const [csrf, setCsrf] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [vipDays, setVipDays] = useState("30");
|
||||
const [amount, setAmount] = useState("100");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void fetchCsrf().then(setCsrf);
|
||||
}, []);
|
||||
|
||||
async function run(fn: () => Promise<unknown>, okMsg: string) {
|
||||
if (!reason.trim()) { setMessage("请填写原因"); return; }
|
||||
setBusy(true);
|
||||
setMessage("");
|
||||
try {
|
||||
await fn();
|
||||
setNotice(okMsg);
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
setMessage(e instanceof Error ? e.message : "操作失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStatus() {
|
||||
const target: "active" | "disabled" = detail.user.status === "active" ? "disabled" : "active";
|
||||
return run(() => changeUserStatus(detail.user.id, target, reason, csrf), target === "disabled" ? "已禁用并撤销会话" : "已恢复");
|
||||
}
|
||||
|
||||
function doGrant() {
|
||||
const days = Number(vipDays);
|
||||
const expiresAt = days > 0 ? new Date(Date.now() + days * 86400000).toISOString() : null;
|
||||
return run(() => grantMembership(detail.user.id, "vip", expiresAt, reason, csrf), "已授予 VIP");
|
||||
}
|
||||
|
||||
function doRevoke() {
|
||||
return run(() => revokeMembership(detail.user.id, reason, csrf), "已撤销 VIP");
|
||||
}
|
||||
|
||||
function doAdjust() {
|
||||
const amt = Number(amount);
|
||||
if (!Number.isFinite(amt)) { setMessage("额度需为数字"); return; }
|
||||
return run(() => adjustQuota(detail.user.id, amt, reason, `admin-${Date.now()}-${Math.random()}`, csrf), "额度已调整");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-line bg-panel/60 p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">{detail.user.email}</h3>
|
||||
<p className="mt-1 text-sm text-subtle">有效 Plan:{detail.effective_plan.toUpperCase()} · 状态:{detail.user.status === "active" ? "正常" : "已禁用"}</p>
|
||||
</div>
|
||||
<button className="button-ghost" onClick={onClose} type="button">关闭</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-5 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-line bg-panel/40 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-subtle">额度周期</p>
|
||||
<p className="mt-2 text-sm text-muted">{new Date(detail.quota.period_start).toLocaleDateString()} → {new Date(detail.quota.period_end).toLocaleDateString()}</p>
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
<div className="flex justify-between"><dt className="text-subtle">上限</dt><dd>{detail.quota.limit.toLocaleString()}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-subtle">已用</dt><dd>{detail.quota.used.toLocaleString()}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-subtle">冻结</dt><dd>{detail.quota.reserved.toLocaleString()}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-subtle">调整</dt><dd>{detail.quota.adjustment.toLocaleString()}</dd></div>
|
||||
<div className="flex justify-between border-t border-line pt-2"><dt className="text-subtle">可用</dt><dd className="text-cyan">{detail.quota.available.toLocaleString()}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="rounded-xl border border-line bg-panel/40 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-subtle">当前会员</p>
|
||||
{detail.effective_grant ? (
|
||||
<div className="mt-2 text-sm text-muted">
|
||||
<p className="text-cyan">{detail.effective_grant.plan.toUpperCase()}</p>
|
||||
<p className="mt-1 text-subtle">至 {detail.effective_grant.expires_at ? new Date(detail.effective_grant.expires_at).toLocaleDateString() : "长期"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted">无有效会员</p>
|
||||
)}
|
||||
{detail.membership_history.length ? (
|
||||
<ul className="mt-4 space-y-2 text-xs text-subtle">
|
||||
{detail.membership_history.slice(0, 4).map((g) => (
|
||||
<li key={g.id}>{g.plan.toUpperCase()} · {new Date(g.starts_at).toLocaleDateString()} → {g.expires_at ? new Date(g.expires_at).toLocaleDateString() : "长期"}{g.revoked_at ? " · 已撤销" : ""}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border border-line bg-panel/40 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-subtle">额度调整记录</p>
|
||||
{detail.adjustment_records.length ? (
|
||||
<ul className="mt-2 space-y-2 text-xs text-subtle">
|
||||
{detail.adjustment_records.map((r) => (
|
||||
<li key={r.id}>{r.amount > 0 ? "+" : ""}{r.amount.toLocaleString()} · {r.reason ?? "—"}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-muted">暂无调整记录</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div><label className="field-label" htmlFor="admin-reason">操作原因(必填)</label><textarea className="field-input" id="admin-reason" onChange={(e) => setReason(e.target.value)} placeholder="例如:运营调整 / 违规禁用" rows={2} value={reason} /></div>
|
||||
{message ? <p className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning" role="status">{message}</p> : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void toggleStatus()} type="button">
|
||||
{detail.user.status === "active" ? "禁用用户" : "恢复用户"}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<input aria-label="VIP 天数" className="field-input w-20" onChange={(e) => setVipDays(e.target.value)} value={vipDays} />
|
||||
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doGrant()} type="button">授予 VIP</button>
|
||||
</div>
|
||||
<button className="button-ghost disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doRevoke()} type="button">撤销 VIP</button>
|
||||
<div className="flex gap-2">
|
||||
<input aria-label="额度调整量" className="field-input w-24" onChange={(e) => setAmount(e.target.value)} value={amount} />
|
||||
<button className="button-secondary disabled:opacity-50" disabled={busy || !csrf} onClick={() => void doAdjust()} type="button">调整额度</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-subtle">调整额度不能使可用额度低于已用;同一操作不可重复提交。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
lib/admin.ts
Normal file
112
lib/admin.ts
Normal file
@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
|
||||
|
||||
export type AdminUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
role: "user" | "admin";
|
||||
plan: string;
|
||||
effective_plan: string;
|
||||
status: "active" | "disabled";
|
||||
email_verified: boolean;
|
||||
phone_verified: boolean;
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
};
|
||||
export type AdminUserDetail = {
|
||||
user: AdminUser;
|
||||
effective_plan: string;
|
||||
effective_grant: { id: string; plan: string; starts_at: string; expires_at: string | null; reason: string } | null;
|
||||
quota: { period_start: string; period_end: string; limit: number; adjustment: number; used: number; reserved: number; available: number };
|
||||
membership_history: Array<{ id: string; plan: string; starts_at: string; expires_at: string | null; revoked_at: string | null; reason: string; created_at: string }>;
|
||||
adjustment_records: Array<{ id: string; amount: number; idempotency_key: string; created_at: string; reason: string | null; actor_id: string | null }>;
|
||||
};
|
||||
|
||||
export type Page<T> = { items: T[]; total: number; page: number; limit: number; pages: number };
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
|
||||
...options,
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
if (response.status === 403) throw new Error("无权执行此操作");
|
||||
throw new Error("请登录");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const message = body.error?.message ?? body.detail ?? "请求失败";
|
||||
const msg = Array.isArray(message) ? (message as Array<{ msg?: string }>).map((m) => m.msg ?? "").join("; ") : (message as string);
|
||||
throw new Error(msg || "请求失败");
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function fetchCsrf(): Promise<string> {
|
||||
const response = await fetch(`${apiBase}/auth/csrf`, { credentials: "include" });
|
||||
const body = await response.json();
|
||||
return body.csrf_token ?? "";
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<{ email: string; role: string; plan: string; status: string }> {
|
||||
return request("/auth/me");
|
||||
}
|
||||
|
||||
export async function listUsers(params: { email?: string; phone?: string; status?: string; page: number; limit: number }): Promise<Page<AdminUser>> {
|
||||
const q = new URLSearchParams();
|
||||
if (params.email) q.set("email", params.email);
|
||||
if (params.phone) q.set("phone", params.phone);
|
||||
if (params.status) q.set("status", params.status);
|
||||
q.set("page", String(params.page));
|
||||
q.set("limit", String(params.limit));
|
||||
return request(`/admin/users?${q.toString()}`);
|
||||
}
|
||||
|
||||
export async function getUserDetail(id: string): Promise<AdminUserDetail> {
|
||||
return request(`/admin/users/${id}`);
|
||||
}
|
||||
|
||||
export async function changeUserStatus(id: string, status: "active" | "disabled", reason: string, csrf: string): Promise<{ id: string; status: string; sessions_revoked: number }> {
|
||||
return request(`/admin/users/${id}/status`, { method: "PATCH", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify({ status, reason }) });
|
||||
}
|
||||
|
||||
export async function grantMembership(id: string, plan: "free" | "vip", expiresAt: string | null, reason: string, csrf: string) {
|
||||
return request(`/admin/users/${id}/membership`, { method: "PUT", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify({ plan, expires_at: expiresAt, reason }) });
|
||||
}
|
||||
|
||||
export async function revokeMembership(id: string, reason: string, csrf: string) {
|
||||
return request(`/admin/users/${id}/membership/revoke`, { method: "POST", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify({ reason }) });
|
||||
}
|
||||
|
||||
export async function getUserQuota(id: string) {
|
||||
return request(`/admin/users/${id}/quota`);
|
||||
}
|
||||
|
||||
export async function adjustQuota(id: string, amount: number, reason: string, idempotencyKey: string, csrf: string) {
|
||||
return request(`/admin/users/${id}/quota-adjustments`, { method: "POST", headers: { "X-CSRF-Token": csrf }, body: JSON.stringify({ amount, reason, idempotency_key: idempotencyKey }) });
|
||||
}
|
||||
|
||||
export async function listTtsTasks(params: { user_id?: string; status?: string; created_after?: string; created_before?: string; page: number; limit: number }) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.user_id) q.set("user_id", params.user_id);
|
||||
if (params.status) q.set("status", params.status);
|
||||
if (params.created_after) q.set("created_after", params.created_after);
|
||||
if (params.created_before) q.set("created_before", params.created_before);
|
||||
q.set("page", String(params.page));
|
||||
q.set("limit", String(params.limit));
|
||||
return request<Page<Record<string, unknown>>>(`/admin/tts/tasks?${q.toString()}`);
|
||||
}
|
||||
export async function getUsageSummary(): Promise<{ quota: { used: number; reserved: number; adjustment: number }; ledger_total: number; tasks: { total: number; by_status: Record<string, number>; failed: number } }> {
|
||||
return request("/admin/usage/summary");
|
||||
}
|
||||
|
||||
export async function listAuditLogs(params: { target_type?: string; page: number; limit: number }) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.target_type) q.set("target_type", params.target_type);
|
||||
q.set("page", String(params.page));
|
||||
q.set("limit", String(params.limit));
|
||||
return request<Page<Record<string, unknown>>>(`/admin/audit-logs?${q.toString()}`);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user