From d0f75fcec8d397fef2007404353b504405decb4a Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 11 May 2026 16:57:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E7=9B=98=E6=A0=87=E9=A2=98=E5=92=8C=E6=8F=8F=E8=BF=B0=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=BC=82=E5=B8=B8=E7=8A=B6=E6=80=81=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=AD=97=E6=AE=B5=EF=BC=8C=E4=BC=98=E5=8C=96=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E5=AF=BC=E8=88=AA=E5=92=8CAPI=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin-wallet.ts | 1 + src/api/index.ts | 2 + src/api/public-draw.ts | 19 + src/app/admin/(shell)/page.tsx | 45 +- src/components/ui/progress.tsx | 34 + src/modules/_config/admin-nav.ts | 2 +- src/modules/dashboard/dashboard-console.tsx | 794 ++++++++++++++++++++ src/modules/dashboard/meta.ts | 5 +- src/types/api/public-draw.ts | 17 + 9 files changed, 887 insertions(+), 32 deletions(-) create mode 100644 src/api/public-draw.ts create mode 100644 src/components/ui/progress.tsx create mode 100644 src/modules/dashboard/dashboard-console.tsx create mode 100644 src/types/api/public-draw.ts diff --git a/src/api/admin-wallet.ts b/src/api/admin-wallet.ts index 0ab9fd5..ef7d878 100644 --- a/src/api/admin-wallet.ts +++ b/src/api/admin-wallet.ts @@ -21,6 +21,7 @@ export type TransferOrderListQuery = { created_from?: string; created_to?: string; status?: string; + /** 仅异常:processing / failed / pending_reconcile */ abnormal?: boolean; }; diff --git a/src/api/index.ts b/src/api/index.ts index 7b6ccbf..f23bdee 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,6 @@ 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 { getAdminPing } from "@/api/admin-ping"; export { diff --git a/src/api/public-draw.ts b/src/api/public-draw.ts new file mode 100644 index 0000000..6bc40c4 --- /dev/null +++ b/src/api/public-draw.ts @@ -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 { + if (!hasLotteryAdminApiBaseUrl()) { + return null; + } + try { + return await publicAdminRequest({ + url: `${API_V1_PREFIX}/draw/current`, + method: "GET", + }); + } catch { + return null; + } +} diff --git a/src/app/admin/(shell)/page.tsx b/src/app/admin/(shell)/page.tsx index d4ce2f1..c5884dd 100644 --- a/src/app/admin/(shell)/page.tsx +++ b/src/app/admin/(shell)/page.tsx @@ -1,9 +1,10 @@ import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { getAdminPing } from "@/api"; +import { DashboardConsole } from "@/modules/dashboard/dashboard-console"; import type { Metadata } from "next"; export const metadata: Metadata = { - title: "总览", + title: "仪表盘", }; 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() !== ""; return ( - -
-
-

- API 基底 -

-

+ + + +

+ API 连通性(调试) +
+

+ 基底: {apiReady ? ( - - 已配置 NEXT_PUBLIC_LOTTERY_API_BASE_URL - + 已配置 ) : ( - - 未配置环境变量,无法在服务端探测 Laravel + + 未配置 NEXT_PUBLIC_LOTTERY_API_BASE_URL )}

-

- 与玩家端一致,指向 Laravel 根地址(会自动请求{" "} - - /api/v1/admin/ping - - )。 +

+ Admin ping(服务端无 Token 时多为空): + {!apiReady ? "—" : ping ? JSON.stringify(ping) : "未返回"}

-
-

- Admin Ping -

-

- {!apiReady ? "—" : ping ? JSON.stringify(ping) : "请求失败或未返回信封"} -

-
-
+ ); } diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..40d55eb --- /dev/null +++ b/src/components/ui/progress.tsx @@ -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 ( +
+
+
+ ); +} + +export { Progress }; diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index c860086..a5d9b8b 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -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: "客服 / 财务", diff --git a/src/modules/dashboard/dashboard-console.tsx b/src/modules/dashboard/dashboard-console.tsx new file mode 100644 index 0000000..cb51a5b --- /dev/null +++ b/src/modules/dashboard/dashboard-console.tsx @@ -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 { + 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 ( +
+ + + + +
+

{v.toFixed(2)}%

+

封顶占用

+
+
+ ); +} + +function HotBarChart({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement { + const maxU = Math.max(0.0001, ...rows.map((r) => r.usage_ratio ?? 0)); + + return ( +
+
+ + 占用率 + + {rows.length === 0 ? ( +

该维度暂无池数据

+ ) : ( + rows.map((row) => { + const u = row.usage_ratio ?? 0; + const h = Math.max(6, (u / maxU) * 100); + return ( +
+
+ {row.normalized_number} +
+ ); + }) + )} +
+

号码(按占用率)

+
+ ); +} + +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 ( +
+

暂无售罄号码

+
+ ); + } + + 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 ( +
+
+
+
+

{total}

+

售罄合计

+
+
+
    + {entries.map((e) => ( +
  • + + + {e.label} + + {buckets[e.key]} +
  • + ))} +
+
+ ); +} + +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(null); + + const [hall, setHall] = useState(null); + const [drawId, setDrawId] = useState(null); + const [finance, setFinance] = useState(null); + const [drawDetail, setDrawDetail] = useState(null); + const [riskLocked, setRiskLocked] = useState(0); + const [riskCap, setRiskCap] = useState(0); + const [hotPoolSample, setHotPoolSample] = useState([]); + const [soldOutBuckets, setSoldOutBuckets] = useState(null); + const [abnormalTransferTotal, setAbnormalTransferTotal] = useState(null); + const [hotTab, setHotTab] = useState("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: }, + { href: "/admin/draws", label: "开售 / 期号", icon: }, + { + href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws", + label: "开奖结果", + icon: , + }, + { href: "/admin/tickets", label: "注单管理", icon: }, + { href: "/admin/wallet/transactions", label: "钱包流水", icon: }, + { href: "/admin/reports", label: "报表中心", icon: }, + { href: "/admin/audit-logs", label: "审计日志", icon: }, + ]; + + return ( +
+
+
+

仪表盘

+

大厅当前期 · 财务与风控总览

+
+
+ {todayLabel} + +
+
+ + {error ? ( + + 提示 + {error} + + ) : null} + + {/* Row 1 — 核心财务 KPI */} +
+ {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ )) + ) : ( + <> +
+
+
+ +
+
+

当期投注总额

+

+ {finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"} +

+

+ + 当前大厅期财务汇总 +

+
+
+
+
+
+
+ +
+
+

当期派彩

+

+ {finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"} +

+

+ + 中奖派彩 + Jackpot +

+
+
+
+
+
+
+ +
+
+

当期平台盈亏

+

+ {finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"} +

+

+ + 投注 − 派彩(近似) +

+
+
+
+ + )} +
+ + {/* Row 2 — 期号 / 投注 / 风险表 */} +
+ {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ )) + ) : ( + <> +
+
+
+ +
+
+

当前期号

+

{hall?.draw_no ?? "—"}

+

+ 第 {hall?.sequence_no ?? "—"} 期 + · + + + {hallStatusLabel} + +

+ {drawId != null ? ( + + 期号详情 + + ) : null} +
+
+
+
+
+
+ +
+
+

本期注单笔数

+

+ {finance != null ? finance.ticket_item_count.toLocaleString("zh-CN") : "—"} +

+

+ 关联投注额{" "} + + {finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"} + +

+
+
+
+
+
+
+ +
+
+

风险封顶占用

+

+ 已占用 {formatMoneyMinor(riskLocked, currency)} / 封顶{" "} + {formatMoneyMinor(riskCap, currency)} +

+
+ +
+ {drawId != null ? ( + + 占用明细 + + ) : null} +
+
+
+ + )} +
+ + {/* Row 3 — 图表 */} +
+ + +
+ 热门号码 Top 10 + 按风险池占用率(前 100 池内筛选维度) +
+
+
+ {(["4D", "3D", "2D"] as const).map((tab) => ( + + ))} +
+ {drawId != null ? ( + + 查看全部 + + ) : null} +
+
+ + {loading ? ( + + ) : ( + + )} + +
+ + + +
+ 售罄分布 + 按号码形态汇总售罄池数量 +
+ {drawId != null ? ( + + 查看全部 + + ) : null} +
+ + {loading ? ( + + ) : soldOutBuckets ? ( + + ) : ( +

暂无数据

+ )} +
+
+
+ + {/* Row 4 — 待办 */} +
+
+
+
+ +
+
+

待审核开奖

+

+ {pendingReview ?? "—"} +

+

需人工复核的批次

+
+
+ {drawId != null ? ( + + 立即审核 + + ) : null} +
+
+
+
+ +
+
+

异常转账单

+

+ {abnormalTransferTotal ?? "—"} +

+

待跟进或待对账

+
+
+ + 查看转账单 + +
+
+ + {/* Row 5 — 快捷入口 */} + + + {quickLinks.map((q) => ( + + + {q.icon} + + {q.label} + + ))} + + + + +
+ ); +} diff --git a/src/modules/dashboard/meta.ts b/src/modules/dashboard/meta.ts index 4271be7..293cbfc 100644 --- a/src/modules/dashboard/meta.ts +++ b/src/modules/dashboard/meta.ts @@ -1,6 +1,5 @@ export const dashboardModuleMeta = { segment: "dashboard", - title: "总览", - description: - "后台仪表盘与接口连通状态;后续可放核心业务指标卡片。", + title: "仪表盘", + description: "仪表盘:大厅当前期、投注/派彩/风控占用与快捷入口。", } as const; diff --git a/src/types/api/public-draw.ts b/src/types/api/public-draw.ts new file mode 100644 index 0000000..01b5a55 --- /dev/null +++ b/src/types/api/public-draw.ts @@ -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[]; +};