feat: 更新仪表盘标题和描述,添加异常状态查询字段,优化管理员导航和API导出
This commit is contained in:
@@ -29,7 +29,7 @@ export type AdminNavItem = {
|
||||
};
|
||||
|
||||
export const adminShellNavItems: AdminNavItem[] = [
|
||||
{ segment: "dashboard", label: "总览", href: "/admin" },
|
||||
{ segment: "dashboard", label: "仪表盘", href: "/admin" },
|
||||
{
|
||||
segment: "service_desk",
|
||||
label: "客服 / 财务",
|
||||
|
||||
794
src/modules/dashboard/dashboard-console.tsx
Normal file
794
src/modules/dashboard/dashboard-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export const dashboardModuleMeta = {
|
||||
segment: "dashboard",
|
||||
title: "总览",
|
||||
description:
|
||||
"后台仪表盘与接口连通状态;后续可放核心业务指标卡片。",
|
||||
title: "仪表盘",
|
||||
description: "仪表盘:大厅当前期、投注/派彩/风控占用与快捷入口。",
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user