feat: 更新仪表盘标题和描述,添加异常状态查询字段,优化管理员导航和API导出

This commit is contained in:
2026-05-11 16:57:01 +08:00
parent b539bf0660
commit d0f75fcec8
9 changed files with 887 additions and 32 deletions

View File

@@ -0,0 +1,794 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import {
AlertTriangle,
ClipboardList,
Diamond,
FileSearch,
FileSpreadsheet,
Gift,
RefreshCw,
ScrollText,
Shield,
ShieldCheck,
Ticket,
TrendingUp,
Wallet,
} from "lucide-react";
import { getAdminDraw, getAdminDrawFinanceSummary, getAdminDraws } from "@/api/admin-draws";
import { getAdminRiskPools } from "@/api/admin-risk";
import { getAdminTransferOrders } from "@/api/admin-wallet";
import { getDrawCurrent } from "@/api/public-draw";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
type HotPlayTab = "4D" | "3D" | "2D";
type SoldOutBuckets = {
d4: number;
d3: number;
d2: number;
special: number;
other: number;
};
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
const major = minor / 100;
const code = (currencyCode ?? "CNY").toUpperCase();
try {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: code,
maximumFractionDigits: 2,
}).format(major);
} catch {
return new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
maximumFractionDigits: 2,
}).format(major);
}
}
function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
if (minor === 0) {
return formatMoneyMinor(0, currencyCode);
}
const s = minor > 0 ? "+" : "";
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
}
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
const raw = normalizedNumber.trim();
if (/[A-Za-z]/.test(raw)) {
return "other";
}
const digits = raw.replace(/\D/g, "");
const len = digits.length > 0 ? digits.length : raw.length;
if (len >= 4) {
return "4D";
}
if (len === 3) {
return "3D";
}
if (len === 2) {
return "2D";
}
return "other";
}
function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPoolRow[] {
return [...pools]
.filter((p) => poolPlayCategory(p.normalized_number) === tab)
.sort((a, b) => (b.usage_ratio ?? 0) - (a.usage_ratio ?? 0))
.slice(0, 10);
}
function soldOutBucketKey(normalizedNumber: string): keyof SoldOutBuckets {
const raw = normalizedNumber.trim();
if (/[A-Za-z]/.test(raw)) {
return "special";
}
const digits = raw.replace(/\D/g, "");
const len = digits.length > 0 ? digits.length : raw.length;
if (len >= 4) {
return "d4";
}
if (len === 3) {
return "d3";
}
if (len === 2) {
return "d2";
}
return "other";
}
async function aggregateRiskPools(
drawId: number,
): Promise<{ locked: number; cap: number; soldOutCount: number; top10: AdminRiskPoolRow[] }> {
let locked = 0;
let cap = 0;
let soldOutCount = 0;
let top10: AdminRiskPoolRow[] = [];
let page = 1;
const safetyMaxPages = 200;
while (page <= safetyMaxPages) {
const d = await getAdminRiskPools(drawId, {
page,
per_page: 100,
sort: "usage_desc",
});
if (page === 1) {
top10 = d.items.slice(0, 10);
}
for (const it of d.items) {
locked += it.locked_amount;
cap += it.total_cap_amount;
if (it.is_sold_out) {
soldOutCount += 1;
}
}
if (page >= d.meta.last_page) {
break;
}
page += 1;
}
return { locked, cap, soldOutCount, top10 };
}
async function aggregateSoldOutBuckets(drawId: number): Promise<SoldOutBuckets> {
const buckets: SoldOutBuckets = { d4: 0, d3: 0, d2: 0, special: 0, other: 0 };
let page = 1;
const safetyMaxPages = 80;
while (page <= safetyMaxPages) {
const d = await getAdminRiskPools(drawId, {
page,
per_page: 100,
sold_out_only: true,
sort: "usage_desc",
});
for (const it of d.items) {
buckets[soldOutBucketKey(it.normalized_number)] += 1;
}
if (page >= d.meta.last_page) {
break;
}
page += 1;
}
return buckets;
}
function RiskSemiGauge({ pct }: { pct: number }): ReactElement {
const v = Math.min(100, Math.max(0, pct));
const r = 76;
const arcLen = Math.PI * r;
return (
<div className="relative mx-auto flex w-full max-w-[220px] flex-col items-center">
<svg viewBox="0 0 200 118" className="w-full" aria-hidden>
<path
d="M 24 100 A 76 76 0 0 1 176 100"
fill="none"
stroke="oklch(0.93 0.01 260)"
strokeWidth="14"
strokeLinecap="round"
/>
<path
d="M 24 100 A 76 76 0 0 1 176 100"
fill="none"
stroke="oklch(0.55 0.22 25)"
strokeWidth="14"
strokeLinecap="round"
strokeDasharray={arcLen}
strokeDashoffset={arcLen * (1 - v / 100)}
className="transition-[stroke-dashoffset] duration-500 ease-out"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1 text-center">
<p className="text-lg font-bold tabular-nums text-[#1a365d]">{v.toFixed(2)}%</p>
<p className="text-[11px] leading-tight text-muted-foreground"></p>
</div>
</div>
);
}
function HotBarChart({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
const maxU = Math.max(0.0001, ...rows.map((r) => r.usage_ratio ?? 0));
return (
<div className="flex h-52 flex-col">
<div className="relative flex flex-1 items-end gap-1.5 border-b border-slate-200/90 pb-0.5 pl-7">
<span
className="pointer-events-none absolute bottom-6 left-0 top-2 w-6 rotate-180 text-[10px] leading-tight text-muted-foreground [writing-mode:vertical-rl]"
aria-hidden
>
</span>
{rows.length === 0 ? (
<p className="w-full pb-6 text-center text-sm text-muted-foreground"></p>
) : (
rows.map((row) => {
const u = row.usage_ratio ?? 0;
const h = Math.max(6, (u / maxU) * 100);
return (
<div key={row.normalized_number} className="flex min-w-0 flex-1 flex-col items-center gap-1">
<div
className="w-full max-w-[2.25rem] rounded-t-sm bg-[#c41e3a]/90 shadow-sm transition-all"
style={{ height: `${h}%` }}
title={`${row.normalized_number}: ${(u * 100).toFixed(1)}%`}
/>
<span className="truncate font-mono text-[10px] text-[#1a365d]">{row.normalized_number}</span>
</div>
);
})
)}
</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground"></p>
</div>
);
}
function SoldOutDonut({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
const entries: { key: keyof SoldOutBuckets; label: string; color: string }[] = [
{ key: "d4", label: "4D", color: "oklch(0.32 0.08 260)" },
{ key: "d3", label: "3D", color: "oklch(0.48 0.12 250)" },
{ key: "d2", label: "2D", color: "oklch(0.78 0.14 95)" },
{ key: "special", label: "特别号", color: "oklch(0.55 0.22 25)" },
{ key: "other", label: "其他", color: "oklch(0.62 0.16 145)" },
];
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
if (total === 0) {
return (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<p></p>
</div>
);
}
let acc = 0;
const parts = entries
.filter((e) => buckets[e.key] > 0)
.map((e) => {
const frac = buckets[e.key] / total;
const start = acc;
acc += frac;
return { ...e, frac, start };
});
const gradientStops =
parts.length === 1
? `${parts[0].color} 0deg 360deg`
: parts
.map((p) => {
const a0 = p.start * 360;
const a1 = (p.start + p.frac) * 360;
return `${p.color} ${a0}deg ${a1}deg`;
})
.join(", ");
return (
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-center">
<div className="relative mx-auto size-44 shrink-0">
<div
className="size-full rounded-full"
style={{
background: `conic-gradient(from -90deg, ${gradientStops})`,
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
}}
/>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<p className="text-2xl font-bold tabular-nums text-[#1a365d]">{total}</p>
<p className="text-[11px] text-muted-foreground"></p>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-2 text-sm">
{entries.map((e) => (
<li key={e.key} className="flex items-center justify-between gap-3">
<span className="flex items-center gap-2">
<span className="size-2.5 shrink-0 rounded-sm" style={{ background: e.color }} />
<span className="text-muted-foreground">{e.label}</span>
</span>
<span className="font-medium tabular-nums text-[#1a365d]">{buckets[e.key]}</span>
</li>
))}
</ul>
</div>
);
}
export function DashboardConsole(): ReactElement {
const [todayLabel] = useState(() => format(new Date(), "yyyy-MM-dd EEEE", { locale: zhCN }));
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
const [drawDetail, setDrawDetail] = useState<AdminDrawShowData | null>(null);
const [riskLocked, setRiskLocked] = useState(0);
const [riskCap, setRiskCap] = useState(0);
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
const [soldOutBuckets, setSoldOutBuckets] = useState<SoldOutBuckets | null>(null);
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
setFinance(null);
setDrawDetail(null);
setDrawId(null);
setRiskLocked(0);
setRiskCap(0);
setHotPoolSample([]);
setSoldOutBuckets(null);
setAbnormalTransferTotal(null);
try {
const current = await getDrawCurrent();
setHall(current);
if (!current) {
return;
}
let resolvedId: number | null = null;
try {
const list = await getAdminDraws({ draw_no: current.draw_no, per_page: 1, page: 1 });
resolvedId = list.items[0]?.id ?? null;
} catch {
setError("已连接大厅期号,但需登录后台后才能拉取汇总与风控数据。");
return;
}
if (resolvedId == null) {
setError("大厅期号在后台期号列表中未匹配到记录(或无权查看)。");
return;
}
setDrawId(resolvedId);
const results = await Promise.allSettled([
getAdminDrawFinanceSummary(resolvedId),
getAdminDraw(resolvedId),
aggregateRiskPools(resolvedId),
getAdminTransferOrders({ abnormal: true, per_page: 1, page: 1 }),
getAdminRiskPools(resolvedId, { page: 1, per_page: 100, sort: "usage_desc" }),
aggregateSoldOutBuckets(resolvedId),
]);
if (results[0].status === "fulfilled") {
setFinance(results[0].value);
}
if (results[1].status === "fulfilled") {
setDrawDetail(results[1].value);
}
if (results[2].status === "fulfilled") {
const r = results[2].value;
setRiskLocked(r.locked);
setRiskCap(r.cap);
}
if (results[3].status === "fulfilled") {
setAbnormalTransferTotal(results[3].value.total);
}
if (results[4].status === "fulfilled") {
setHotPoolSample(results[4].value.items);
}
if (results[5].status === "fulfilled") {
setSoldOutBuckets(results[5].value);
}
const failed = results.find((r) => r.status === "rejected");
if (failed && failed.status === "rejected") {
const msg =
failed.reason instanceof LotteryApiBizError
? failed.reason.message
: "部分数据加载失败(权限或网络)。";
setError(msg);
}
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载失败,请检查 API 与登录状态。";
setError(msg);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
const t = window.setTimeout(() => {
void load(false);
}, 0);
return () => window.clearTimeout(t);
}, [load]);
const currency = finance?.currency_code ?? null;
const usagePct = riskCap > 0 ? (riskLocked / riskCap) * 100 : 0;
const pendingReview = drawDetail?.result_batch_counts?.pending_review ?? null;
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
const hallStatusLabel = hall?.status ?? "—";
const isOpenLike =
hallStatusLabel.toLowerCase().includes("open") ||
hallStatusLabel.includes("开售") ||
hallStatusLabel.includes("开放");
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
{ href: "/admin/draws", label: "创建期计划", icon: <Diamond className="size-5" /> },
{ href: "/admin/draws", label: "开售 / 期号", icon: <Ticket className="size-5" /> },
{
href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws",
label: "开奖结果",
icon: <FileSearch className="size-5" />,
},
{ href: "/admin/tickets", label: "注单管理", icon: <Shield className="size-5" /> },
{ href: "/admin/wallet/transactions", label: "钱包流水", icon: <Wallet className="size-5" /> },
{ href: "/admin/reports", label: "报表中心", icon: <FileSpreadsheet className="size-5" /> },
{ href: "/admin/audit-logs", label: "审计日志", icon: <ScrollText className="size-5" /> },
];
return (
<div className="space-y-6 pb-10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#1a365d]"></h1>
<p className="text-sm text-muted-foreground"> · </p>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{todayLabel}</span>
<Button
type="button"
variant="outline"
size="sm"
className="border-slate-300"
disabled={loading || refreshing}
onClick={() => void load(true)}
>
<RefreshCw className={refreshing ? "size-4 animate-spin" : "size-4"} />
</Button>
</div>
</div>
{error ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle></AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{/* Row 1 — 核心财务 KPI */}
<div className="grid gap-4 md:grid-cols-3">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm"
>
<Skeleton className="h-24 w-full" />
</div>
))
) : (
<>
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
<div className="flex gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#c41e3a] text-white shadow-md">
<Wallet className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
</p>
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
<div className="flex gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#2563eb] text-white shadow-md">
<Gift className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"}
</p>
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
+ Jackpot
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
<div className="flex gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#1a365d] text-white shadow-md">
<TrendingUp className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"}
</p>
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
</p>
</div>
</div>
</div>
</>
)}
</div>
{/* Row 2 — 期号 / 投注 / 风险表 */}
<div className="grid gap-4 md:grid-cols-3">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
<Skeleton className="h-32 w-full" />
</div>
))
) : (
<>
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
<div className="flex gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#1a365d] text-white shadow-md">
<Ticket className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="mt-1 font-mono text-2xl font-bold text-[#c41e3a]">{hall?.draw_no ?? "—"}</p>
<p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span> {hall?.sequence_no ?? "—"} </span>
<span className="hidden sm:inline">·</span>
<span className="inline-flex items-center gap-1.5">
<span
className={cn(
"size-1.5 rounded-full",
isOpenLike ? "bg-emerald-500" : "bg-slate-400",
)}
/>
{hallStatusLabel}
</span>
</p>
{drawId != null ? (
<Link
className="mt-2 inline-block text-xs font-medium text-[#2563eb] underline-offset-4 hover:underline"
href={`/admin/draws/${drawId}`}
>
</Link>
) : null}
</div>
</div>
</div>
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
<div className="flex gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#1a365d] text-white shadow-md">
<Wallet className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance != null ? finance.ticket_item_count.toLocaleString("zh-CN") : "—"}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{" "}
<span className="font-medium text-foreground">
{finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
</span>
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#1a365d] text-white shadow-md">
<Shield className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1 text-center sm:text-left">
<p className="text-sm font-medium text-slate-600"></p>
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
{formatMoneyMinor(riskLocked, currency)} / {" "}
{formatMoneyMinor(riskCap, currency)}
</p>
<div className="mt-2">
<RiskSemiGauge pct={usagePct} />
</div>
{drawId != null ? (
<Link
className="mt-1 inline-block text-xs font-medium text-[#2563eb] underline-offset-4 hover:underline"
href={`/admin/risk/draws/${drawId}/occupancy`}
>
</Link>
) : null}
</div>
</div>
</div>
</>
)}
</div>
{/* Row 3 — 图表 */}
<div className="grid gap-4 lg:grid-cols-2">
<Card className="border-slate-200/90 shadow-sm">
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-2 space-y-0 pb-2">
<div>
<CardTitle className="text-base font-semibold text-[#1a365d]"> Top 10</CardTitle>
<CardDescription> 100 </CardDescription>
</div>
<div className="flex items-center gap-3">
<div role="tablist" aria-label="玩法维度" className="flex gap-1 border-b border-transparent">
{(["4D", "3D", "2D"] as const).map((tab) => (
<button
key={tab}
type="button"
role="tab"
aria-selected={hotTab === tab}
className={cn(
"-mb-px border-b-2 px-2.5 py-1 text-sm font-medium transition-colors",
hotTab === tab
? "border-[#c41e3a] text-[#c41e3a]"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
onClick={() => setHotTab(tab)}
>
{tab}
</button>
))}
</div>
{drawId != null ? (
<Link
href={`/admin/risk/draws/${drawId}/hot`}
className="text-xs font-medium text-[#2563eb] underline-offset-4 hover:underline"
>
</Link>
) : null}
</div>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-52 w-full" />
) : (
<HotBarChart rows={hotRows} />
)}
</CardContent>
</Card>
<Card className="border-slate-200/90 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle className="text-base font-semibold text-[#1a365d]"></CardTitle>
<CardDescription></CardDescription>
</div>
{drawId != null ? (
<Link
href={`/admin/risk/draws/${drawId}/sold-out`}
className="text-xs font-medium text-[#2563eb] underline-offset-4 hover:underline"
>
</Link>
) : null}
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-52 w-full" />
) : soldOutBuckets ? (
<SoldOutDonut buckets={soldOutBuckets} />
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
</div>
{/* Row 4 — 待办 */}
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col justify-between gap-4 rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm sm:flex-row sm:items-center">
<div className="flex gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#c41e3a] text-white shadow-md">
<ClipboardList className="size-5" aria-hidden />
</div>
<div>
<p className="text-sm font-medium text-slate-600"></p>
<p className="mt-1 text-4xl font-bold tabular-nums text-[#c41e3a]">
{pendingReview ?? "—"}
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
{drawId != null ? (
<Link
href={`/admin/draws/${drawId}/review`}
className={cn(
buttonVariants({ variant: "outline", size: "default" }),
"shrink-0 border-[#c41e3a] text-[#c41e3a] hover:bg-[#c41e3a]/5",
)}
>
</Link>
) : null}
</div>
<div className="flex flex-col justify-between gap-4 rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm sm:flex-row sm:items-center">
<div className="flex gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#c41e3a] text-white shadow-md">
<AlertTriangle className="size-5" aria-hidden />
</div>
<div>
<p className="text-sm font-medium text-slate-600"></p>
<p className="mt-1 text-4xl font-bold tabular-nums text-[#c41e3a]">
{abnormalTransferTotal ?? "—"}
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<Link
href="/admin/wallet/transfer-orders"
className={cn(
buttonVariants({ variant: "outline", size: "default" }),
"shrink-0 border-[#c41e3a] text-[#c41e3a] hover:bg-[#c41e3a]/5",
)}
>
</Link>
</div>
</div>
{/* Row 5 — 快捷入口 */}
<Card className="border-slate-200/90 shadow-sm">
<CardContent className="flex flex-wrap justify-center gap-3 py-6 sm:gap-6">
{quickLinks.map((q) => (
<Link
key={q.label}
href={q.href}
className="flex w-[5.5rem] flex-col items-center gap-2 rounded-lg border border-transparent p-2 text-center text-xs font-medium text-[#1a365d] transition-colors hover:border-slate-200 hover:bg-slate-50 sm:w-24"
>
<span className="flex size-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-800 shadow-sm">
{q.icon}
</span>
{q.label}
</Link>
))}
</CardContent>
</Card>
<footer className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<ShieldCheck className="size-4 text-[#c41e3a]" aria-hidden />
<span> · 访</span>
</footer>
</div>
);
}