feat: 更新仪表盘标题和描述,添加异常状态查询字段,优化管理员导航和API导出
This commit is contained in:
@@ -21,6 +21,7 @@ export type TransferOrderListQuery = {
|
|||||||
created_from?: string;
|
created_from?: string;
|
||||||
created_to?: string;
|
created_to?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
/** 仅异常:processing / failed / pending_reconcile */
|
||||||
abnormal?: boolean;
|
abnormal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export { API_V1_PREFIX } from "@/api/paths";
|
export { API_V1_PREFIX } from "@/api/paths";
|
||||||
|
export { getDrawCurrent } from "@/api/public-draw";
|
||||||
|
export { getAdminRiskPools } from "@/api/admin-risk";
|
||||||
export { getAdminCaptcha, postAdminLogin } from "@/api/admin-auth";
|
export { getAdminCaptcha, postAdminLogin } from "@/api/admin-auth";
|
||||||
export { getAdminPing } from "@/api/admin-ping";
|
export { getAdminPing } from "@/api/admin-ping";
|
||||||
export {
|
export {
|
||||||
|
|||||||
19
src/api/public-draw.ts
Normal file
19
src/api/public-draw.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { hasLotteryAdminApiBaseUrl, publicAdminRequest } from "@/lib/admin-http";
|
||||||
|
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||||
|
|
||||||
|
import { API_V1_PREFIX } from "@/api/paths";
|
||||||
|
|
||||||
|
/** 大厅当前期(无需 Bearer) */
|
||||||
|
export async function getDrawCurrent(): Promise<DrawCurrentSnapshot | null> {
|
||||||
|
if (!hasLotteryAdminApiBaseUrl()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await publicAdminRequest<DrawCurrentSnapshot | null>({
|
||||||
|
url: `${API_V1_PREFIX}/draw/current`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { getAdminPing } from "@/api";
|
import { getAdminPing } from "@/api";
|
||||||
|
import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "总览",
|
title: "仪表盘",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminDashboardPage() {
|
export default async function AdminDashboardPage() {
|
||||||
@@ -11,40 +12,28 @@ export default async function AdminDashboardPage() {
|
|||||||
const apiReady = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim() !== "";
|
const apiReady = process.env.NEXT_PUBLIC_LOTTERY_API_BASE_URL?.trim() !== "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold>
|
<ModuleScaffold className="max-w-7xl">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<DashboardConsole />
|
||||||
<div className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
|
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
<details className="mt-8 rounded-lg border border-border/80 bg-muted/30 px-4 py-3 text-sm">
|
||||||
API 基底
|
<summary className="cursor-pointer font-medium text-muted-foreground">API 连通性(调试)</summary>
|
||||||
</h2>
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
<p className="mt-2 text-sm text-foreground">
|
<p className="text-muted-foreground">
|
||||||
|
基底:
|
||||||
{apiReady ? (
|
{apiReady ? (
|
||||||
<span className="font-medium text-emerald-600 dark:text-emerald-400">
|
<span className="ml-1 font-medium text-emerald-600 dark:text-emerald-400">已配置</span>
|
||||||
已配置 NEXT_PUBLIC_LOTTERY_API_BASE_URL
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
<span className="ml-1 font-medium text-amber-600 dark:text-amber-400">
|
||||||
未配置环境变量,无法在服务端探测 Laravel
|
未配置 NEXT_PUBLIC_LOTTERY_API_BASE_URL
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
|
<p className="font-mono text-xs text-foreground">
|
||||||
与玩家端一致,指向 Laravel 根地址(会自动请求{" "}
|
Admin ping(服务端无 Token 时多为空):
|
||||||
<code className="rounded bg-zinc-100 px-1 py-0.5 dark:bg-zinc-800">
|
{!apiReady ? "—" : ping ? JSON.stringify(ping) : "未返回"}
|
||||||
/api/v1/admin/ping
|
|
||||||
</code>
|
|
||||||
)。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-black/10 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-zinc-900">
|
</details>
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
|
||||||
Admin Ping
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 font-mono text-sm text-foreground">
|
|
||||||
{!apiReady ? "—" : ping ? JSON.stringify(ping) : "请求失败或未返回信封"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/components/ui/progress.tsx
Normal file
34
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ProgressProps = React.ComponentProps<"div"> & {
|
||||||
|
/** 0–100 */
|
||||||
|
value?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Progress({ className, value = 0, ...props }: ProgressProps): React.ReactElement {
|
||||||
|
const pct = Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={Math.round(pct)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
className={cn(
|
||||||
|
"relative h-2 w-full overflow-hidden rounded-full bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-[width] duration-500 ease-out"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
@@ -29,7 +29,7 @@ export type AdminNavItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const adminShellNavItems: AdminNavItem[] = [
|
export const adminShellNavItems: AdminNavItem[] = [
|
||||||
{ segment: "dashboard", label: "总览", href: "/admin" },
|
{ segment: "dashboard", label: "仪表盘", href: "/admin" },
|
||||||
{
|
{
|
||||||
segment: "service_desk",
|
segment: "service_desk",
|
||||||
label: "客服 / 财务",
|
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 = {
|
export const dashboardModuleMeta = {
|
||||||
segment: "dashboard",
|
segment: "dashboard",
|
||||||
title: "总览",
|
title: "仪表盘",
|
||||||
description:
|
description: "仪表盘:大厅当前期、投注/派彩/风控占用与快捷入口。",
|
||||||
"后台仪表盘与接口连通状态;后续可放核心业务指标卡片。",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
17
src/types/api/public-draw.ts
Normal file
17
src/types/api/public-draw.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/** `GET /api/v1/draw/current` 大厅快照(无登录) */
|
||||||
|
export type DrawCurrentSnapshot = {
|
||||||
|
draw_no: string;
|
||||||
|
business_date: string;
|
||||||
|
sequence_no: number;
|
||||||
|
status: string;
|
||||||
|
start_time: string | null;
|
||||||
|
close_time: string | null;
|
||||||
|
draw_time: string | null;
|
||||||
|
seconds_to_close: number;
|
||||||
|
seconds_to_draw: number;
|
||||||
|
cooling_end_time: string | null;
|
||||||
|
seconds_remaining_in_cooldown: number | null;
|
||||||
|
result_version?: number;
|
||||||
|
result_source?: string | null;
|
||||||
|
result_items?: unknown[];
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user