diff --git a/app/admin/page.tsx b/app/admin/page.tsx index cc3e799..2e1ffa6 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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

Operations

管理后台

role=admin / 预览
管理权限、用户查询、会员和额度操作均未接入。此阶段不提供可执行的管理操作。

01 / Users

用户与会员

布局预览:筛选、状态、Plan 和额度。

打开预览

02 / TTS

TTS 任务与使用量

布局预览:任务状态、耗时和错误。

打开预览
; +export default function AdminIndexPage() { + return ( + + + + + + ); } diff --git a/app/admin/tts/page.tsx b/app/admin/tts/page.tsx index 49d28c9..4860f3d 100644 --- a/app/admin/tts/page.tsx +++ b/app/admin/tts/page.tsx @@ -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

Admin / TTS

TTS 任务与使用量

用于运营查看任务状态、耗时、错误码和计量消耗,不默认展示完整输入文本。

当前没有真实任务。筛选和统计均为布局占位,不会读取或修改上游服务。
暂无可显示的任务记录。
; + return ( + + + + + + ); } diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 9f45222..69d61b4 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -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

Admin / Users

用户与会员

Phase 4 管理界面结构预览。当前不查询或修改真实用户。

筛选、禁用、VIP 授予和额度调整按钮保持不可用,不会产生任何操作成功提示。
暂无真实用户数据。表格将在 API 与权限模型完成后启用。
; + return ( + + + + + + ); } diff --git a/components/account-panel.tsx b/components/account-panel.tsx index 7b4b110..6adcbac 100644 --- a/components/account-panel.tsx +++ b/components/account-panel.tsx @@ -54,7 +54,7 @@ export function AccountPanel() { return <>
-

Profile

基本资料

邮箱
{user.email}
角色
{user.role}
额度周期
{new Date(usage.period_start).toLocaleDateString()} 至 {new Date(usage.period_end).toLocaleDateString()}
+

Profile

基本资料

邮箱
{user.email}
角色
{user.role}
{user.role === "admin" ?
后台
进入管理后台
: null}
额度周期
{new Date(usage.period_start).toLocaleDateString()} 至 {new Date(usage.period_end).toLocaleDateString()}

Usage

额度明细

打开工作台

Security

修改密码

{passwordMessage ?

{passwordMessage}

: null}
; diff --git a/components/admin/gate.tsx b/components/admin/gate.tsx new file mode 100644 index 0000000..8be419f --- /dev/null +++ b/components/admin/gate.tsx @@ -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(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 ( +
+ +
+ ); + } + if (state === "denied") { + return ( +
+ 访问被拒绝:需要管理员身份。普通用户无法查看或操作管理后台。 +
+ + + 前往账户 + +
+
+ ); + } + + return ( + <> +
+ +
{children}
+
+ + ); +} + +export function AdminPageShell({ eyebrow, title, description, children }: { eyebrow: string; title: string; description?: string; children: React.ReactNode }) { + return ( +
+

{eyebrow}

+

{title}

+ {description ?

{description}

: null} +
{children}
+
+ ); +} diff --git a/components/admin/overview.tsx b/components/admin/overview.tsx new file mode 100644 index 0000000..cd53849 --- /dev/null +++ b/components/admin/overview.tsx @@ -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; failed: number }; +}; + +export function OverviewPanel() { + const [data, setData] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + getUsageSummary() + .then(setData) + .catch((e) => setError(e instanceof Error ? e.message : "无法加载概览")); + }, []); + + if (error) return
{error}
; + if (!data) return ; + + const statusLabels: Record = { queued: "排队", running: "进行中", succeeded: "成功", failed: "失败" }; + + return ( +
+
+ + + +
+
+ + + +
+
+

额度对账

+
+
额度账本合计
{data.ledger_total.toLocaleString()}
+
quota_accounts.used
{data.quota.used.toLocaleString()}
+
计入调整
{data.quota.adjustment.toLocaleString()}
+
+

版本构建号会随部署更新;所有数字来自数据库流水,不包含未入账的中途值。

+
+
+ ); +} diff --git a/components/admin/tts.tsx b/components/admin/tts.tsx new file mode 100644 index 0000000..aaba809 --- /dev/null +++ b/components/admin/tts.tsx @@ -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 | null; + after_value: Record | null; + reason: string; + created_at: string; +}; + +type Summary = { tasks: { total: number; by_status: Record; failed: number }; quota: { used: number; reserved: number; adjustment: number }; ledger_total: number }; + +const statusLabel: Record = { queued: "排队", running: "进行中", succeeded: "成功", failed: "失败" }; +const settleLabel: Record = { consumed: "已消费", released: "已释放", pending: "待结算" }; +const actionLabel: Record = { + 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([]); + const [taskTotal, setTaskTotal] = useState(0); + const [taskPages, setTaskPages] = useState(1); + const [auditPage, setAuditPage] = useState(1); + const [audits, setAudits] = useState([]); + const [auditTotal, setAuditTotal] = useState(0); + const [auditPages, setAuditPages] = useState(1); + const [summary, setSummary] = useState(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 ( +
+ {summary ? ( +
+ + + + +
+ ) : null} + +
+ + +
+ + {error ?
{error}
: null} + + {tab === "tasks" ? ( +
+
+ + +
+
+ + + + + + + + + + + + + + {loading ? ( + + ) : tasks.length === 0 ? ( + + ) : ( + tasks.map((task) => ( + + + + + + + + + + )) + )} + +
用户状态计量耗时结算错误
加载中…
无匹配任务

{task.user_email}

{new Date(task.created_at).toLocaleString()}

{statusLabel[task.status] ?? task.status}{task.text_length} 字符{task.duration_ms != null ? `${(task.duration_ms / 1000).toFixed(1)}s` : "—"}{settleLabel[task.settlement] ?? task.settlement}{task.error_code ?? "—"}{task.id.slice(0, 8)}
+
+
+ 第 {taskPage} / {taskPages} 页 +
+ + +
+
+
+ ) : ( +
+

审计记录默认不含完整用户输入文本,也不显示任何密钥或令牌。账号禁用、会员变更、VIP 撤销和额度调整均记录操作者、前后值与原因。

+
+ + + + + + + + + + + + {audits.length === 0 ? ( + + ) : ( + audits.map((a) => ( + + + + + + + + )) + )} + +
操作者动作目标原因时间
暂无审计记录
{a.actor_email ?? "—"}{actionLabel[a.action] ?? a.action}{a.target_type} · {String(a.target_id).slice(0, 8)}{a.reason}{new Date(a.created_at).toLocaleString()}
+
+
+ 第 {auditPage} / {auditPages} 页 +
+ + +
+
+
+ )} +
+ ); +} diff --git a/components/admin/users.tsx b/components/admin/users.tsx new file mode 100644 index 0000000..47e6a52 --- /dev/null +++ b/components/admin/users.tsx @@ -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([]); + 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(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 ( +
+
+ + + +
+ +
+
+ setEmail(e.target.value)} placeholder="按邮箱筛选" value={email} /> + setPhone(e.target.value)} placeholder="按手机号筛选" value={phone} /> + + + +
+ + {error ?
{error}
: null} + +
+ + + + + + + + + + + + + {loading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + rows.map((row) => ( + + + + + + + + + )) + )} + +
用户有效 Plan角色状态注册时间
加载中…
无匹配用户
+

{row.email}

+

{row.phone ?? "无手机号"}

+
{row.effective_plan.toUpperCase()}{row.role} + {row.status === "active" ? "正常" : "已禁用"} + {new Date(row.created_at).toLocaleDateString()} + +
+
+ +
+ 第 {page} / {pages} 页 +
+ + +
+
+
+ + {selected ? void openDetail(selected.user.id)} setNotice={setNotice} onClose={() => setSelected(null)} /> : null} + {notice ?
{notice}
: null} +
+ ); +} + +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, 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 ( +
+
+
+

{detail.user.email}

+

有效 Plan:{detail.effective_plan.toUpperCase()} · 状态:{detail.user.status === "active" ? "正常" : "已禁用"}

+
+ +
+ +
+
+

额度周期

+

{new Date(detail.quota.period_start).toLocaleDateString()} → {new Date(detail.quota.period_end).toLocaleDateString()}

+
+
上限
{detail.quota.limit.toLocaleString()}
+
已用
{detail.quota.used.toLocaleString()}
+
冻结
{detail.quota.reserved.toLocaleString()}
+
调整
{detail.quota.adjustment.toLocaleString()}
+
可用
{detail.quota.available.toLocaleString()}
+
+
+
+

当前会员

+ {detail.effective_grant ? ( +
+

{detail.effective_grant.plan.toUpperCase()}

+

至 {detail.effective_grant.expires_at ? new Date(detail.effective_grant.expires_at).toLocaleDateString() : "长期"}

+
+ ) : ( +

无有效会员

+ )} + {detail.membership_history.length ? ( +
    + {detail.membership_history.slice(0, 4).map((g) => ( +
  • {g.plan.toUpperCase()} · {new Date(g.starts_at).toLocaleDateString()} → {g.expires_at ? new Date(g.expires_at).toLocaleDateString() : "长期"}{g.revoked_at ? " · 已撤销" : ""}
  • + ))} +
+ ) : null} +
+
+

额度调整记录

+ {detail.adjustment_records.length ? ( +
    + {detail.adjustment_records.map((r) => ( +
  • {r.amount > 0 ? "+" : ""}{r.amount.toLocaleString()} · {r.reason ?? "—"}
  • + ))} +
+ ) : ( +

暂无调整记录

+ )} +
+
+ +
+