feat: 重构仪表盘逻辑,整合管理员仪表盘API,移除冗余代码,优化数据加载和状态管理
This commit is contained in:
12
src/api/admin-dashboard.ts
Normal file
12
src/api/admin-dashboard.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { adminRequest } from "@/lib/admin-http";
|
||||||
|
|
||||||
|
import { API_V1_PREFIX } from "./paths";
|
||||||
|
|
||||||
|
import type { AdminDashboardData } from "@/types/api/admin-dashboard";
|
||||||
|
|
||||||
|
const A = `${API_V1_PREFIX}/admin`;
|
||||||
|
|
||||||
|
/** 首页仪表盘聚合(大厅 + 当期财务/风控/异常转账等,按账号权限填充各块) */
|
||||||
|
export async function getAdminDashboard(): Promise<AdminDashboardData> {
|
||||||
|
return adminRequest.get<AdminDashboardData>(`${A}/dashboard`);
|
||||||
|
}
|
||||||
@@ -20,10 +20,7 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { getAdminDraw, getAdminDrawFinanceSummary, getAdminDraws } from "@/api/admin-draws";
|
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||||
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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -31,7 +28,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
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 { AdminRiskPoolRow } from "@/types/api/admin-risk";
|
||||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||||
|
|
||||||
@@ -97,84 +93,6 @@ function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPo
|
|||||||
.slice(0, 10);
|
.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 {
|
function RiskSemiGauge({ pct }: { pct: number }): ReactElement {
|
||||||
const v = Math.min(100, Math.max(0, pct));
|
const v = Math.min(100, Math.max(0, pct));
|
||||||
const r = 76;
|
const r = 76;
|
||||||
@@ -320,11 +238,12 @@ export function DashboardConsole(): ReactElement {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||||
const [drawId, setDrawId] = useState<number | null>(null);
|
const [drawId, setDrawId] = useState<number | null>(null);
|
||||||
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
|
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||||
const [drawDetail, setDrawDetail] = useState<AdminDrawShowData | null>(null);
|
const [pendingReview, setPendingReview] = useState<number | null>(null);
|
||||||
const [riskLocked, setRiskLocked] = useState(0);
|
const [riskLocked, setRiskLocked] = useState(0);
|
||||||
const [riskCap, setRiskCap] = useState(0);
|
const [riskCap, setRiskCap] = useState(0);
|
||||||
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
|
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
|
||||||
@@ -339,8 +258,9 @@ export function DashboardConsole(): ReactElement {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
setFinance(null);
|
setFinance(null);
|
||||||
setDrawDetail(null);
|
setPendingReview(null);
|
||||||
setDrawId(null);
|
setDrawId(null);
|
||||||
setRiskLocked(0);
|
setRiskLocked(0);
|
||||||
setRiskCap(0);
|
setRiskCap(0);
|
||||||
@@ -349,67 +269,35 @@ export function DashboardConsole(): ReactElement {
|
|||||||
setAbnormalTransferTotal(null);
|
setAbnormalTransferTotal(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const current = await getDrawCurrent();
|
const d = await getAdminDashboard();
|
||||||
setHall(current);
|
setHall(d.hall);
|
||||||
|
|
||||||
if (!current) {
|
if (d.resolved_draw != null) {
|
||||||
return;
|
setDrawId(d.resolved_draw.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolvedId: number | null = null;
|
if (d.finance != null) {
|
||||||
try {
|
setFinance(d.finance);
|
||||||
const list = await getAdminDraws({ draw_no: current.draw_no, per_page: 1, page: 1 });
|
|
||||||
resolvedId = list.items[0]?.id ?? null;
|
|
||||||
} catch {
|
|
||||||
setError("已连接大厅期号,但需登录后台后才能拉取汇总与风控数据。");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (d.draw != null) {
|
||||||
|
setPendingReview(d.draw.result_batch_counts.pending_review);
|
||||||
|
}
|
||||||
|
if (d.risk != null) {
|
||||||
|
setRiskLocked(d.risk.locked_amount);
|
||||||
|
setRiskCap(d.risk.cap_amount);
|
||||||
|
setHotPoolSample(d.risk.hot_pool_rows);
|
||||||
|
setSoldOutBuckets(d.risk.sold_out_buckets);
|
||||||
|
}
|
||||||
|
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
||||||
|
|
||||||
if (resolvedId == null) {
|
const noticeParts: string[] = d.warnings.map((w) => w.message);
|
||||||
setError("大厅期号在后台期号列表中未匹配到记录(或无权查看)。");
|
if (d.resolved_draw != null && !d.capabilities.draw_finance_risk) {
|
||||||
return;
|
noticeParts.push("当前账号无开奖查看/管理权限,财务与风控数据未返回。");
|
||||||
}
|
}
|
||||||
|
if (d.hall != null && !d.capabilities.wallet_transfer_view) {
|
||||||
setDrawId(resolvedId);
|
noticeParts.push("当前账号无钱包对账查看权限,异常转账计数未返回。");
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
setNotice(noticeParts.length > 0 ? noticeParts.join(" ") : null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg =
|
const msg =
|
||||||
e instanceof LotteryApiBizError ? e.message : "加载失败,请检查 API 与登录状态。";
|
e instanceof LotteryApiBizError ? e.message : "加载失败,请检查 API 与登录状态。";
|
||||||
@@ -429,7 +317,6 @@ export function DashboardConsole(): ReactElement {
|
|||||||
|
|
||||||
const currency = finance?.currency_code ?? null;
|
const currency = finance?.currency_code ?? null;
|
||||||
const usagePct = riskCap > 0 ? (riskLocked / riskCap) * 100 : 0;
|
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 hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
||||||
|
|
||||||
@@ -483,6 +370,13 @@ export function DashboardConsole(): ReactElement {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{notice && !error ? (
|
||||||
|
<Alert className="border-sky-200 bg-sky-50 dark:border-sky-900/50 dark:bg-sky-950/30">
|
||||||
|
<AlertTitle>说明</AlertTitle>
|
||||||
|
<AlertDescription>{notice}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Row 1 — 核心财务 KPI */}
|
{/* Row 1 — 核心财务 KPI */}
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|||||||
62
src/types/api/admin-dashboard.ts
Normal file
62
src/types/api/admin-dashboard.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||||
|
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
|
||||||
|
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||||
|
|
||||||
|
export type AdminDashboardWarning = {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDashboardResolvedDraw = {
|
||||||
|
id: number;
|
||||||
|
draw_no: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDashboardDrawPanel = {
|
||||||
|
id: number;
|
||||||
|
draw_no: string;
|
||||||
|
business_date: string;
|
||||||
|
sequence_no: number;
|
||||||
|
status: string;
|
||||||
|
hall_preview_status: string;
|
||||||
|
result_batch_counts: {
|
||||||
|
total: number;
|
||||||
|
pending_review: number;
|
||||||
|
published: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDashboardSoldOutBuckets = {
|
||||||
|
d4: number;
|
||||||
|
d3: number;
|
||||||
|
d2: number;
|
||||||
|
special: number;
|
||||||
|
other: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDashboardRiskSnapshot = {
|
||||||
|
draw_id: number;
|
||||||
|
draw_no: string;
|
||||||
|
locked_amount: number;
|
||||||
|
cap_amount: number;
|
||||||
|
usage_percent: number;
|
||||||
|
hot_pool_rows: AdminRiskPoolRow[];
|
||||||
|
sold_out_buckets: AdminDashboardSoldOutBuckets;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminDashboardCapabilities = {
|
||||||
|
draw_finance_risk: boolean;
|
||||||
|
wallet_transfer_view: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `GET /api/v1/admin/dashboard` */
|
||||||
|
export type AdminDashboardData = {
|
||||||
|
hall: DrawCurrentSnapshot | null;
|
||||||
|
resolved_draw: AdminDashboardResolvedDraw | null;
|
||||||
|
finance: AdminDrawFinanceSummaryData | null;
|
||||||
|
draw: AdminDashboardDrawPanel | null;
|
||||||
|
risk: AdminDashboardRiskSnapshot | null;
|
||||||
|
abnormal_transfer_total: number | null;
|
||||||
|
warnings: AdminDashboardWarning[];
|
||||||
|
capabilities: AdminDashboardCapabilities;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user