-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
{t("loginTitle", { defaultValue: authModuleMeta.title })}
+
+ {t("loginSubtitle")}
+
-
+
+
);
}
diff --git a/src/i18n/locales/en/auth.json b/src/i18n/locales/en/auth.json
index 5f2073e..0e9c8b9 100644
--- a/src/i18n/locales/en/auth.json
+++ b/src/i18n/locales/en/auth.json
@@ -1,6 +1,7 @@
{
"title": "Login",
"loginTitle": "Admin Login",
+ "loginSubtitle": "Sign in with your admin account",
"account": "Account",
"accountPlaceholder": "Login account",
"password": "Password",
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
index 8631d46..e849da3 100644
--- a/src/i18n/locales/en/common.json
+++ b/src/i18n/locales/en/common.json
@@ -54,7 +54,8 @@
},
"aria": {
"expand": "Expand",
- "collapse": "Collapse"
+ "collapse": "Collapse",
+ "rowActionsMenu": "Row actions menu"
},
"export": {
"drawsList": { "filename": "draws-list", "sheetName": "Draws" },
@@ -155,7 +156,9 @@
"workspace": "Workspace"
},
"auth": {
- "checking": "Checking sign-in status…"
+ "checking": "Checking sign-in status…",
+ "checkingShort": "Loading workspace…",
+ "sessionExpired": "Your session has expired. Please sign in again."
},
"confirm": {
"cancel": "Cancel",
diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json
index 625cea8..1986fd3 100644
--- a/src/i18n/locales/en/config.json
+++ b/src/i18n/locales/en/config.json
@@ -353,6 +353,26 @@
},
"odds": {
"sectionHint": "Pick a version to edit prize-tier odds; publishing applies to new tickets immediately.",
+ "sections": {
+ "playScope": "Play scope",
+ "oddsConfig": "Odds"
+ },
+ "currentSelection": "Selection: {{category}} / {{play}}",
+ "playGroups": {
+ "bigSmall": "Big / small",
+ "combo4": "4D position",
+ "number3": "3D position",
+ "number2": "2D position",
+ "other": "Other"
+ },
+ "summary": {
+ "title": "Summary",
+ "version": "Version",
+ "statusLabel": "Status",
+ "readOnlyTag": "Read-only",
+ "readOnlyHint": "This version is read-only. Create a draft to make changes.",
+ "activeHint": "This version is active; new tickets use these settings."
+ },
"tabs": {
"all": "All"
},
diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json
index a207339..7b42b75 100644
--- a/src/i18n/locales/en/dashboard.json
+++ b/src/i18n/locales/en/dashboard.json
@@ -7,10 +7,13 @@
"lifetime": "All-time totals",
"currentDraw": "Current draw",
"currentDrawDetail": "Current draw · {{drawNo}}",
- "operations": "Operations (current draw)"
+ "operations": "Operations (current draw)",
+ "snapshot": "Current draw snapshot"
},
+ "countdownToClose": "Time to close",
+ "scheduledDrawTime": "Draw at {{time}}",
"analytics": {
- "title": "Financial analytics",
+ "title": "Finance overview",
"periodLabel": "Period",
"metricLabel": "Metric",
"playLabel": "Play filter",
@@ -22,8 +25,16 @@
"summaryBet": "Period bet",
"summaryPayout": "Period payout",
"summaryProfit": "Period profit",
- "dailyTrend": "Daily trend",
+ "dailyTrend": "Period trend",
+ "granularityDay": "By day",
"playBreakdown": "Play breakdown",
+ "playRanking": "Top 5 plays",
+ "rankingMetricLabel": "Ranking metric",
+ "rankingMetrics": {
+ "bet": "By bet amount",
+ "payout": "By payout",
+ "profit": "By profit"
+ },
"periodDistribution": "Period structure",
"noPlayData": "No play data in this period",
"periods": {
@@ -57,6 +68,7 @@
"currentDrawPayout": "Draw payout",
"currentDrawProfit": "Draw profit",
"drawFinanceDetails": "Draw finance details",
+ "detailsShort": "Details",
"todayBetTotal": "Today's total bet",
"todayPayout": "Today's payout",
"todayProfit": "Today's profit",
@@ -97,6 +109,10 @@
"soldOutTotal": "Total sold out",
"pendingReviewResults": "Pending result review",
"abnormalTransferOrders": "Abnormal transfer orders",
+ "abnormalTransferScope": "Flagged by wallet reconciliation",
+ "abnormalTransferPending": "{{count}} pending review",
+ "abnormalTransferAllClear": "Reconciliation clear",
+ "abnormalTransferAction": "Open transfer orders to resolve",
"viewTransferOrders": "View transfer orders",
"noSoldOutNumbers": "No sold-out numbers",
"noPoolData": "No pool data for this dimension",
@@ -121,7 +137,11 @@
"results": "Results",
"tickets": "Ticket management",
"walletTransactions": "Wallet transactions",
- "auditLogs": "Audit logs"
+ "auditLogs": "Audit logs",
+ "reports": "Reports",
+ "payoutRules": "Odds & rebate",
+ "riskMonitor": "Risk monitor",
+ "systemSettings": "System settings"
},
"warnings": {
"drawPermission": "This account has no draw/dashboard view permission. Finance and risk data were not returned.",
diff --git a/src/i18n/locales/ne/auth.json b/src/i18n/locales/ne/auth.json
index 2cfc110..576d65f 100644
--- a/src/i18n/locales/ne/auth.json
+++ b/src/i18n/locales/ne/auth.json
@@ -1,6 +1,7 @@
{
"title": "लगइन",
"loginTitle": "एडमिन लगइन",
+ "loginSubtitle": "कृपया एडमिन खाताबाट लगइन गर्नुहोस्",
"account": "खाता",
"accountPlaceholder": "लगइन खाता",
"password": "पासवर्ड",
diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json
index 7389c01..cd7dc51 100644
--- a/src/i18n/locales/ne/common.json
+++ b/src/i18n/locales/ne/common.json
@@ -54,7 +54,8 @@
},
"aria": {
"expand": "खोल्नुहोस्",
- "collapse": "बन्द गर्नुहोस्"
+ "collapse": "बन्द गर्नुहोस्",
+ "rowActionsMenu": "पङ्क्ति कार्य मेनु"
},
"export": {
"drawsList": { "filename": "draw-suchi", "sheetName": "Draw" },
@@ -155,7 +156,9 @@
"workspace": "कार्यस्थान"
},
"auth": {
- "checking": "लगइन स्थिति जाँच हुँदैछ…"
+ "checking": "लगइन स्थिति जाँच हुँदैछ…",
+ "checkingShort": "कार्यस्थान खोल्दै…",
+ "sessionExpired": "लगइन समाप्त भयो। कृपया पुनः लगइन गर्नुहोस्।"
},
"confirm": {
"cancel": "रद्द",
diff --git a/src/i18n/locales/ne/config.json b/src/i18n/locales/ne/config.json
index dfa1ec9..311410a 100644
--- a/src/i18n/locales/ne/config.json
+++ b/src/i18n/locales/ne/config.json
@@ -353,6 +353,26 @@
},
"odds": {
"sectionHint": "संस्करण छानेर पुरस्कार-स्तर बाधा सम्पादन गर्नुहोस्; प्रकाशनपछि नयाँ टिकटमा लागू हुन्छ।",
+ "sections": {
+ "playScope": "खेल दायरा",
+ "oddsConfig": "बाधा सेटिङ"
+ },
+ "currentSelection": "हालको छनोट: {{category}} / {{play}}",
+ "playGroups": {
+ "bigSmall": "ठूलो / सानो",
+ "combo4": "4D स्थिति",
+ "number3": "3D स्थिति",
+ "number2": "2D स्थिति",
+ "other": "अन्य"
+ },
+ "summary": {
+ "title": "सारांश",
+ "version": "संस्करण",
+ "statusLabel": "स्थिति",
+ "readOnlyTag": "पढ्न मात्र",
+ "readOnlyHint": "यो संस्करण पढ्न मात्र हो। परिवर्तन गर्न ड्राफ्ट बनाउनुहोस्।",
+ "activeHint": "यो संस्करण सक्रिय छ; नयाँ टिकट यही सेटिङ प्रयोग गर्छ।"
+ },
"tabs": {
"all": "सबै"
},
diff --git a/src/i18n/locales/ne/dashboard.json b/src/i18n/locales/ne/dashboard.json
index 2af433b..067383f 100644
--- a/src/i18n/locales/ne/dashboard.json
+++ b/src/i18n/locales/ne/dashboard.json
@@ -7,10 +7,13 @@
"lifetime": "ऐतिहासिक कुल",
"currentDraw": "हालको ड्रअ",
"currentDrawDetail": "हालको ड्रअ · {{drawNo}}",
- "operations": "सञ्चालन (हालको ड्रअ)"
+ "operations": "सञ्चालन (हालको ड्रअ)",
+ "snapshot": "हालको ड्रअ स्न्यापसट"
},
+ "countdownToClose": "बन्द हुन बाँकी",
+ "scheduledDrawTime": "ड्रअ {{time}}",
"analytics": {
- "title": "वित्त विश्लेषण",
+ "title": "वित्त सारांश",
"periodLabel": "अवधि",
"metricLabel": "मेट्रिक",
"playLabel": "प्ले फिल्टर",
@@ -22,8 +25,16 @@
"summaryBet": "अवधि बेट",
"summaryPayout": "अवधि भुक्तानी",
"summaryProfit": "अवधि नाफा",
- "dailyTrend": "दैनिक ट्रेन्ड",
+ "dailyTrend": "अवधि ट्रेन्ड",
+ "granularityDay": "दैनिक",
"playBreakdown": "प्ले विभाजन",
+ "playRanking": "शीर्ष ५ प्ले",
+ "rankingMetricLabel": "रैंकिङ मेट्रिक",
+ "rankingMetrics": {
+ "bet": "बेट रकम",
+ "payout": "भुक्तानी",
+ "profit": "नाफा"
+ },
"periodDistribution": "अवधि संरचना",
"noPlayData": "यस अवधिमा प्ले डाटा छैन",
"periods": {
@@ -57,6 +68,7 @@
"currentDrawPayout": "हालको भुक्तानी",
"currentDrawProfit": "हालको नाफा/नोक्सान",
"drawFinanceDetails": "ड्रअ वित्त विवरण",
+ "detailsShort": "विवरण",
"todayBetTotal": "आजको कुल बेट",
"todayPayout": "आजको भुक्तानी",
"todayProfit": "आजको नाफा/नोक्सान",
@@ -96,6 +108,10 @@
"soldOutTotal": "कुल बिक्री समाप्त",
"pendingReviewResults": "समीक्षा बाँकी परिणाम",
"abnormalTransferOrders": "असामान्य ट्रान्सफर अर्डर",
+ "abnormalTransferScope": "वालेट मिलानबाट चिनिएको",
+ "abnormalTransferPending": "{{count}} समीक्षा बाँकी",
+ "abnormalTransferAllClear": "मिलान ठीक, असामान्य छैन",
+ "abnormalTransferAction": "समाधान गर्न ट्रान्सफर सूची खोल्नुहोस्",
"viewTransferOrders": "ट्रान्सफर अर्डर हेर्नुहोस्",
"noSoldOutNumbers": "बिक्री समाप्त नम्बर छैन",
"noPoolData": "यस डाइमेन्सनमा पूल डाटा छैन",
@@ -120,7 +136,11 @@
"results": "परिणाम",
"tickets": "टिकट व्यवस्थापन",
"walletTransactions": "वालेट कारोबार",
- "auditLogs": "अडिट लग"
+ "auditLogs": "अडिट लग",
+ "reports": "रिपोर्ट केन्द्र",
+ "payoutRules": "बाधा र रिबेट",
+ "riskMonitor": "जोखिम निगरानी",
+ "systemSettings": "प्रणाली सेटिङ"
},
"warnings": {
"drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।",
diff --git a/src/i18n/locales/zh/auth.json b/src/i18n/locales/zh/auth.json
index a8a18e7..4008845 100644
--- a/src/i18n/locales/zh/auth.json
+++ b/src/i18n/locales/zh/auth.json
@@ -1,6 +1,7 @@
{
"title": "登录",
"loginTitle": "后台登录",
+ "loginSubtitle": "请使用管理员账号登录",
"account": "账号",
"accountPlaceholder": "登录账号",
"password": "密码",
diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json
index 1110bc8..7c8f6f0 100644
--- a/src/i18n/locales/zh/common.json
+++ b/src/i18n/locales/zh/common.json
@@ -54,7 +54,8 @@
},
"aria": {
"expand": "展开",
- "collapse": "收起"
+ "collapse": "收起",
+ "rowActionsMenu": "行操作菜单"
},
"export": {
"drawsList": { "filename": "期号列表", "sheetName": "期号列表" },
@@ -148,14 +149,16 @@
"audit": "审计日志",
"settings": "系统设置",
"account": "账号设置",
- "integration": "主站接入站点",
+ "integration": "接入站点",
"config": "运营配置"
},
"sidebar": {
"workspace": "工作台"
},
"auth": {
- "checking": "正在校验登录状态…"
+ "checking": "正在校验登录状态…",
+ "checkingShort": "正在进入工作台…",
+ "sessionExpired": "登录已失效,请重新登录"
},
"confirm": {
"cancel": "取消",
diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json
index d639b74..4910c13 100644
--- a/src/i18n/locales/zh/config.json
+++ b/src/i18n/locales/zh/config.json
@@ -30,11 +30,11 @@
"jackpotDesc": "奖池参数与进账流水",
"riskCapTitle": "限额版本",
"riskCapDesc": "号码赔付封顶与占用视图",
- "integrationTitle": "主站接入站点",
+ "integrationTitle": "接入站点",
"integrationDesc": "site_code、JWT 密钥、主站钱包 URL 与 iframe 白名单"
},
"integrationSites": {
- "title": "主站接入站点",
+ "title": "接入站点",
"description": "由运营在后台维护各主站对接参数,并通过权限控制谁能查看或修改。site_code 创建后不可修改。",
"create": "新建站点",
"edit": "编辑",
@@ -353,6 +353,26 @@
},
"odds": {
"sectionHint": "选择版本后可编辑各奖级赔率;发布后立即作用于新注单。",
+ "sections": {
+ "playScope": "玩法范围",
+ "oddsConfig": "赔率配置"
+ },
+ "currentSelection": "当前选择:{{category}} / {{play}}",
+ "playGroups": {
+ "bigSmall": "大小类",
+ "combo4": "组合类",
+ "number3": "号码类",
+ "number2": "2D 位置类",
+ "other": "其他类"
+ },
+ "summary": {
+ "title": "配置摘要",
+ "version": "版本",
+ "statusLabel": "状态",
+ "readOnlyTag": "只读",
+ "readOnlyHint": "当前为只读版本,如需修改请先创建草稿。",
+ "activeHint": "当前版本已生效,新注单将按此配置计算。"
+ },
"tabs": {
"all": "全部"
},
diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json
index 11044ac..db78fe7 100644
--- a/src/i18n/locales/zh/dashboard.json
+++ b/src/i18n/locales/zh/dashboard.json
@@ -7,10 +7,13 @@
"lifetime": "历史累计",
"currentDraw": "当前期号",
"currentDrawDetail": "当期明细 · {{drawNo}}",
- "operations": "运营监控(当期)"
+ "operations": "运营监控(当期)",
+ "snapshot": "当期快照"
},
+ "countdownToClose": "距截止投注",
+ "scheduledDrawTime": "开奖 {{time}}",
"analytics": {
- "title": "财务分析",
+ "title": "财务概览",
"periodLabel": "统计区间",
"metricLabel": "指标类型",
"playLabel": "玩法筛选",
@@ -22,8 +25,16 @@
"summaryBet": "区间下注",
"summaryPayout": "区间派彩",
"summaryProfit": "区间盈亏",
- "dailyTrend": "每日趋势",
+ "dailyTrend": "区间趋势",
+ "granularityDay": "按天",
"playBreakdown": "玩法拆解 Top",
+ "playRanking": "玩法排行榜 Top 5",
+ "rankingMetricLabel": "排行维度",
+ "rankingMetrics": {
+ "bet": "按投注金额",
+ "payout": "按派彩金额",
+ "profit": "按盈亏"
+ },
"periodDistribution": "区间结构对比",
"noPlayData": "该区间暂无玩法数据",
"periods": {
@@ -57,6 +68,7 @@
"currentDrawPayout": "当期派彩",
"currentDrawProfit": "当期盈亏",
"drawFinanceDetails": "期号财务详情",
+ "detailsShort": "详情",
"todayBetTotal": "今日下注总额",
"todayPayout": "今日派彩",
"todayProfit": "今日盈亏",
@@ -97,6 +109,10 @@
"soldOutTotal": "售罄合计",
"pendingReviewResults": "待审核开奖",
"abnormalTransferOrders": "异常转账单",
+ "abnormalTransferScope": "钱包对账标记的异常转账",
+ "abnormalTransferPending": "{{count}} 笔待核对",
+ "abnormalTransferAllClear": "对账正常,暂无异常",
+ "abnormalTransferAction": "前往转账单列表处理",
"viewTransferOrders": "查看转账单",
"noSoldOutNumbers": "暂无售罄号码",
"noPoolData": "该维度暂无池数据",
@@ -121,7 +137,11 @@
"results": "开奖结果",
"tickets": "注单管理",
"walletTransactions": "钱包流水",
- "auditLogs": "审计日志"
+ "auditLogs": "审计日志",
+ "reports": "报表中心",
+ "payoutRules": "赔付规则",
+ "riskMonitor": "风控监控",
+ "systemSettings": "系统设置"
},
"warnings": {
"drawPermission": "当前账号无开奖/仪表盘查看权限,财务与风控数据未返回。",
diff --git a/src/lib/admin-auth-reject.ts b/src/lib/admin-auth-reject.ts
new file mode 100644
index 0000000..035ec9f
--- /dev/null
+++ b/src/lib/admin-auth-reject.ts
@@ -0,0 +1,122 @@
+import { isAxiosError } from "axios";
+import { toast } from "sonner";
+
+import i18n from "@/i18n";
+import { getAdminSessionState } from "@/stores/admin-session";
+import { readToken } from "@/stores/admin-token";
+import { isApiEnvelope } from "@/types/api/envelope";
+import { LotteryApiBizError } from "@/types/api/errors";
+
+/** 与后端 {@see ErrorCode::AdminUnauthenticated} 一致 */
+export const ADMIN_UNAUTHENTICATED_CODE = 8110;
+
+/** 与后端 {@see ErrorCode::AdminAccountDisabled} 一致 */
+export const ADMIN_ACCOUNT_DISABLED_CODE = 8113;
+
+/** 登录验证码错误等,HTTP 可能为 401/422,不应踢出会话 */
+const LOGIN_FORM_ERROR_CODES = new Set([8111, 8112]);
+
+let authRejectHandling = false;
+
+function isAdminLoginPath(): boolean {
+ if (typeof window === "undefined") {
+ return false;
+ }
+
+ return window.location.pathname === "/admin/login";
+}
+
+/** 未登录 / Token 失效 / 账号禁用(非普通权限 403) */
+export function isAdminAuthRejected(err: unknown): boolean {
+ if (err instanceof LotteryApiBizError) {
+ return (
+ err.code === ADMIN_UNAUTHENTICATED_CODE ||
+ err.code === ADMIN_ACCOUNT_DISABLED_CODE
+ );
+ }
+
+ if (!isAxiosError(err)) {
+ return false;
+ }
+
+ const body = err.response?.data;
+ const envelopeCode =
+ body && typeof body === "object" && "code" in body
+ ? (body as { code?: unknown }).code
+ : undefined;
+
+ if (
+ typeof envelopeCode === "number" &&
+ LOGIN_FORM_ERROR_CODES.has(envelopeCode)
+ ) {
+ return false;
+ }
+
+ if (
+ envelopeCode === ADMIN_UNAUTHENTICATED_CODE ||
+ envelopeCode === ADMIN_ACCOUNT_DISABLED_CODE
+ ) {
+ return true;
+ }
+
+ if (isApiEnvelope(body) && body.code !== 0) {
+ return (
+ body.code === ADMIN_UNAUTHENTICATED_CODE ||
+ body.code === ADMIN_ACCOUNT_DISABLED_CODE
+ );
+ }
+
+ const status = err.response?.status;
+ if (status === 401) {
+ return true;
+ }
+
+ if (status === 403 && envelopeCode === ADMIN_ACCOUNT_DISABLED_CODE) {
+ return true;
+ }
+
+ return false;
+}
+
+export function redirectToAdminLogin(): void {
+ if (typeof window === "undefined" || isAdminLoginPath()) {
+ return;
+ }
+
+ const loginPath = "/admin/login";
+ if (window.location.pathname !== loginPath) {
+ window.location.replace(loginPath);
+ }
+}
+
+/**
+ * 清除本地会话并跳转登录页。可在 axios 拦截器、/auth/me 刷新等任意上下文调用。
+ */
+export function handleAdminAuthRejected(): void {
+ if (typeof window === "undefined" || isAdminLoginPath()) {
+ return;
+ }
+ if (authRejectHandling) {
+ return;
+ }
+
+ authRejectHandling = true;
+
+ const hadSession = readToken() != null;
+ getAdminSessionState().clearSession();
+
+ if (hadSession) {
+ toast.error(
+ i18n.t("auth.sessionExpired", {
+ ns: "common",
+ defaultValue: "Sign-in expired. Please log in again.",
+ }),
+ );
+ }
+
+ redirectToAdminLogin();
+
+ queueMicrotask(() => {
+ authRejectHandling = false;
+ });
+}
diff --git a/src/lib/admin-fetch-me.ts b/src/lib/admin-fetch-me.ts
new file mode 100644
index 0000000..f672726
--- /dev/null
+++ b/src/lib/admin-fetch-me.ts
@@ -0,0 +1,15 @@
+import { getAdminMe } from "@/api/admin-auth";
+import type { AdminAuthMeResponse } from "@/types/api/admin-auth";
+
+let inflightMe: Promise
| null = null;
+
+/** 合并并发的 `/auth/me` 请求,避免 rehydrate 与 ShellAuthGate 重复打接口 */
+export function fetchAdminMeDeduped(): Promise {
+ if (!inflightMe) {
+ inflightMe = getAdminMe().finally(() => {
+ inflightMe = null;
+ });
+ }
+
+ return inflightMe;
+}
diff --git a/src/lib/admin-http.ts b/src/lib/admin-http.ts
index 311afa7..a35cf38 100644
--- a/src/lib/admin-http.ts
+++ b/src/lib/admin-http.ts
@@ -5,6 +5,10 @@ import axios, {
} from "axios";
import { withAdminAuthHeader } from "@/lib/admin-auth";
+import {
+ handleAdminAuthRejected,
+ isAdminAuthRejected,
+} from "@/lib/admin-auth-reject";
import { withAdminLocaleHeaders } from "@/lib/admin-locale";
import { LotteryApiBizError, LotteryApiEnvelopeError } from "@/types/api/errors";
import { isApiEnvelope } from "@/types/api/envelope";
@@ -18,6 +22,23 @@ export const adminHttp = axios.create({
headers: { Accept: "application/json" },
});
+adminHttp.interceptors.response.use(
+ (response) => response,
+ (error: unknown) => {
+ if (isAdminAuthRejected(error)) {
+ handleAdminAuthRejected();
+ }
+ return Promise.reject(error);
+ },
+);
+
+function rejectAfterAuthCheck(err: unknown): never {
+ if (isAdminAuthRejected(err)) {
+ handleAdminAuthRejected();
+ }
+ throw err;
+}
+
export function unwrapData(payload: unknown): T {
if (!isApiEnvelope(payload)) {
throw new LotteryApiEnvelopeError();
@@ -48,7 +69,7 @@ export async function publicAdminRequest(
throw new LotteryApiBizError(body.msg, body.code, body.data);
}
}
- throw err;
+ rejectAfterAuthCheck(err);
}
}
@@ -64,7 +85,7 @@ export async function request(config: AxiosRequestConfig): Promise {
throw new LotteryApiBizError(body.msg, body.code, body.data);
}
}
- throw err;
+ rejectAfterAuthCheck(err);
}
}
diff --git a/src/lib/admin-nav-label.ts b/src/lib/admin-nav-label.ts
new file mode 100644
index 0000000..71feda1
--- /dev/null
+++ b/src/lib/admin-nav-label.ts
@@ -0,0 +1,38 @@
+import type { TFunction } from "i18next";
+
+/** 与 {@link AdminBreadcrumb} / 侧栏共用:仅用 i18n,避免 API 旧 label 作 defaultValue 导致水合不一致 */
+const NAV_SEGMENT_I18N_KEYS: Record = {
+ dashboard: "dashboard",
+ admin_users: "admin_users",
+ admin_roles: "admin_roles",
+ players: "players",
+ currencies: "currencies",
+ wallet: "wallet",
+ draws: "draws",
+ rules_plays: "rules_plays",
+ rules_odds: "rules_odds",
+ jackpot: "jackpot",
+ risk_cap: "risk_cap",
+ risk: "risk",
+ settlement: "settlement",
+ reconcile: "reconcile",
+ reports: "reports",
+ tickets: "tickets",
+ audit: "audit",
+ settings: "settings",
+ integration: "integration",
+ config: "config",
+};
+
+export function adminNavLabel(
+ segment: string,
+ t: TFunction,
+ apiLabel?: string | null,
+): string {
+ const key = NAV_SEGMENT_I18N_KEYS[segment];
+ if (key) {
+ return t(`nav.${key}`, { ns: "common" });
+ }
+
+ return apiLabel?.trim() ? apiLabel : segment;
+}
diff --git a/src/lib/admin-session-verify.ts b/src/lib/admin-session-verify.ts
new file mode 100644
index 0000000..491b1c4
--- /dev/null
+++ b/src/lib/admin-session-verify.ts
@@ -0,0 +1,28 @@
+import { fetchAdminMeDeduped } from "@/lib/admin-fetch-me";
+import { isAdminAuthRejected } from "@/lib/admin-auth-reject";
+import { getAdminSessionState } from "@/stores/admin-session";
+import { readToken } from "@/stores/admin-token";
+
+/**
+ * 用 `/auth/me` 校验本地 Token 是否仍有效;失败时清会话(不跳转,由调用方决定)。
+ */
+export async function verifyStoredAdminSession(): Promise {
+ const token = readToken();
+ if (!token) {
+ return false;
+ }
+
+ const session = getAdminSessionState();
+ session.setBearerToken(token);
+
+ try {
+ const result = await fetchAdminMeDeduped();
+ session.setAdminProfile(result.admin);
+ return true;
+ } catch (err) {
+ if (isAdminAuthRejected(err)) {
+ session.clearSession();
+ }
+ return false;
+ }
+}
diff --git a/src/lib/admin-token-constants.ts b/src/lib/admin-token-constants.ts
new file mode 100644
index 0000000..436b45f
--- /dev/null
+++ b/src/lib/admin-token-constants.ts
@@ -0,0 +1,5 @@
+/** localStorage / Cookie 共用键名,须与 {@link middleware} 一致 */
+export const ADMIN_TOKEN_STORAGE_KEY = "lottery_admin_token";
+
+/** 与后端 `lottery.admin_api.token_ttl_days` 默认 7 天对齐(秒) */
+export const ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
diff --git a/src/lib/admin-token-cookie.ts b/src/lib/admin-token-cookie.ts
new file mode 100644
index 0000000..d8a9a68
--- /dev/null
+++ b/src/lib/admin-token-cookie.ts
@@ -0,0 +1,61 @@
+import {
+ ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS,
+ ADMIN_TOKEN_STORAGE_KEY,
+} from "@/lib/admin-token-constants";
+
+export function readAdminTokenFromCookieString(
+ cookieHeader: string | null | undefined,
+): string | null {
+ if (!cookieHeader) {
+ return null;
+ }
+
+ const prefix = `${ADMIN_TOKEN_STORAGE_KEY}=`;
+ for (const part of cookieHeader.split(";")) {
+ const trimmed = part.trim();
+ if (!trimmed.startsWith(prefix)) {
+ continue;
+ }
+ const raw = trimmed.slice(prefix.length);
+ if (!raw) {
+ return null;
+ }
+ try {
+ const decoded = decodeURIComponent(raw).trim();
+ return decoded !== "" ? decoded : null;
+ } catch {
+ return null;
+ }
+ }
+
+ return null;
+}
+
+export function readAdminTokenFromDocumentCookie(): string | null {
+ if (typeof document === "undefined") {
+ return null;
+ }
+
+ return readAdminTokenFromCookieString(document.cookie);
+}
+
+export function writeAdminTokenCookie(token: string | null): void {
+ if (typeof document === "undefined") {
+ return;
+ }
+
+ const secure =
+ typeof window !== "undefined" && window.location.protocol === "https:";
+ const base = `path=/; SameSite=Lax`;
+
+ if (!token || token.trim() === "") {
+ document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=; ${base}; max-age=0`;
+ return;
+ }
+
+ const value = encodeURIComponent(token.trim());
+ const maxAge = `max-age=${ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS}`;
+ document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=${value}; ${base}; ${maxAge}${
+ secure ? "; Secure" : ""
+ }`;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..7b56f58
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,45 @@
+import { NextResponse, type NextRequest } from "next/server";
+
+import { ADMIN_TOKEN_STORAGE_KEY } from "@/lib/admin-token-constants";
+import { readAdminTokenFromCookieString } from "@/lib/admin-token-cookie";
+
+const ADMIN_LOGIN_PATH = "/admin/login";
+
+function isAdminLoginPath(pathname: string): boolean {
+ return pathname === ADMIN_LOGIN_PATH || pathname.startsWith(`${ADMIN_LOGIN_PATH}/`);
+}
+
+function readTokenFromRequest(request: NextRequest): string | null {
+ const fromCookie = request.cookies.get(ADMIN_TOKEN_STORAGE_KEY)?.value?.trim();
+ if (fromCookie) {
+ return fromCookie;
+ }
+
+ return readAdminTokenFromCookieString(request.headers.get("cookie"));
+}
+
+export function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ if (!pathname.startsWith("/admin")) {
+ return NextResponse.next();
+ }
+
+ if (isAdminLoginPath(pathname)) {
+ return NextResponse.next();
+ }
+
+ const token = readTokenFromRequest(request);
+ if (!token) {
+ const loginUrl = request.nextUrl.clone();
+ loginUrl.pathname = ADMIN_LOGIN_PATH;
+ loginUrl.search = "";
+ return NextResponse.redirect(loginUrl);
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ["/admin", "/admin/:path*"],
+};
diff --git a/src/modules/admin-roles/admin-roles-console.tsx b/src/modules/admin-roles/admin-roles-console.tsx
index f8cf4a5..3d0b6a7 100644
--- a/src/modules/admin-roles/admin-roles-console.tsx
+++ b/src/modules/admin-roles/admin-roles-console.tsx
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
-import { ChevronDown } from "lucide-react";
+import { ChevronDown, KeyRound, Pencil, Trash2 } from "lucide-react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
@@ -15,6 +15,7 @@ import {
putAdminRole,
putAdminRolePermissions,
} from "@/api/admin-users";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Badge } from "@/components/ui/badge";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
@@ -342,7 +343,7 @@ export function AdminRolesConsole(): React.ReactElement {
{t("roleTable.status")}
{t("roleTable.users")}
{t("roleTable.permissions")}
- {t("roleTable.actions")}
+ {t("roleTable.actions")}
@@ -377,25 +378,32 @@ export function AdminRolesConsole(): React.ReactElement {
{role.user_count}
{role.permission_slugs.length}
-
+
{canManageRoles ? (
-
-
-
-
-
+ openRolePermissionEditor(role),
+ },
+ {
+ key: "edit",
+ label: t("actions.edit"),
+ icon: Pencil,
+ onClick: () => openEditRole(role),
+ },
+ {
+ key: "delete",
+ label: t("actions.delete"),
+ icon: Trash2,
+ destructive: true,
+ disabled: role.is_system || role.user_count > 0,
+ onClick: () => setRoleDeleteTarget(role),
+ },
+ ]}
+ />
) : (
—
)}
diff --git a/src/modules/admin-users/admin-users-console.tsx b/src/modules/admin-users/admin-users-console.tsx
index 294930f..3e689fa 100644
--- a/src/modules/admin-users/admin-users-console.tsx
+++ b/src/modules/admin-users/admin-users-console.tsx
@@ -1,5 +1,6 @@
"use client";
+import { KeyRound, Pencil, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
@@ -15,6 +16,7 @@ import {
putAdminUserRoles,
} from "@/api/admin-users";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Badge } from "@/components/ui/badge";
@@ -371,7 +373,7 @@ export function AdminUsersConsole(): React.ReactElement {
{t("table.status")}
{t("table.roles")}
{t("table.effective")}
- {t("table.actions")}
+ {t("table.actions")}
@@ -412,44 +414,34 @@ export function AdminUsersConsole(): React.ReactElement {
{row.effective_permissions.length}
-
- {canManageUsers ? (
-
- ) : null}
- {canManageUsers ? (
-
- ) : null}
- {canManageUsers ? (
-
- ) : null}
-
+ {canManageUsers ? (
+ openPermissionEditor(row),
+ },
+ {
+ key: "edit",
+ label: t("actions.edit"),
+ icon: Pencil,
+ onClick: () => openEditAccount(row),
+ },
+ {
+ key: "delete",
+ label: t("actions.delete"),
+ icon: Trash2,
+ destructive: true,
+ disabled: profile?.id === row.id,
+ onClick: () => setDeleteTarget(row),
+ },
+ ]}
+ />
+ ) : (
+ —
+ )}
))
diff --git a/src/modules/config/config-workflow-section.tsx b/src/modules/config/config-workflow-section.tsx
new file mode 100644
index 0000000..abd2306
--- /dev/null
+++ b/src/modules/config/config-workflow-section.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from "react";
+
+import { cn } from "@/lib/utils";
+
+type ConfigWorkflowSectionProps = {
+ step: number;
+ title: string;
+ description?: ReactNode;
+ children: ReactNode;
+ className?: string;
+ contentClassName?: string;
+};
+
+/** 编号步骤卡片,用于赔率/回水等合并配置页主栏分区。 */
+export function ConfigWorkflowSection({
+ step,
+ title,
+ description,
+ children,
+ className,
+ contentClassName,
+}: ConfigWorkflowSectionProps) {
+ return (
+
+
+
+ {step}
+
+
+
{title}
+ {description ?
{description}
: null}
+
+
+ {children}
+
+ );
+}
diff --git a/src/modules/config/doc/odds-config-doc-screen.tsx b/src/modules/config/doc/odds-config-doc-screen.tsx
index 14ab694..96a4e26 100644
--- a/src/modules/config/doc/odds-config-doc-screen.tsx
+++ b/src/modules/config/doc/odds-config-doc-screen.tsx
@@ -1,5 +1,6 @@
"use client";
+import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -50,6 +51,16 @@ import type {
OddsVersionDetail,
} from "@/types/api/admin-config";
+import { ConfigWorkflowSection } from "@/modules/config/config-workflow-section";
+import {
+ OddsConfigSummaryPanel,
+ playRebatePercentFromScopes,
+} from "@/modules/config/doc/odds-config-summary-panel";
+import {
+ buildOddsPlayFilterGroups,
+ filterOddsPlayTypesByCategory,
+ type OddsCategoryTab,
+} from "@/modules/config/doc/odds-play-type-groups";
import {
PRIZE_SCOPE_MULTIPLIER_HINT,
PRIZE_SCOPE_ORDER,
@@ -57,7 +68,7 @@ import {
type PrizeScopeCode,
} from "@/modules/config/doc/prize-scopes";
-type CatTab = "all" | "d4" | "d3" | "d2";
+type CatTab = OddsCategoryTab;
function oddsMultiplierLabel(oddsValue: number): string {
return (oddsValue / 10000).toFixed(4);
@@ -72,17 +83,13 @@ function parseOddsMultiplierInput(raw: string): number {
return Number.isSafeInteger(scaled) ? scaled : 0;
}
-function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[] {
- if (tab === "all") {
- return types;
- }
- const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
- return types.filter((t) => t.dimension === dim);
-}
-
type OddsConfigDocScreenProps = {
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
embedded?: boolean;
+ /** 合并页:左侧三步骤 + 右侧配置摘要(参考设计稿) */
+ mergedLayout?: boolean;
+ /** 合并页第 3 步:佣金 / 回水 */
+ rebateSection?: ReactNode;
/** 合并页共享数据层(避免与回水区块重复拉取版本详情) */
workspace?: OddsConfigWorkspace;
/** 与回水分区共用版本选择(无 workspace 时) */
@@ -92,6 +99,8 @@ type OddsConfigDocScreenProps = {
export function OddsConfigDocScreen({
embedded = false,
+ mergedLayout = false,
+ rebateSection,
workspace,
versionId: controlledVersionId,
onVersionIdChange,
@@ -234,7 +243,15 @@ export function OddsConfigDocScreen({
[resolvedTypes],
);
- const filteredTypes = useMemo(() => filterTypes(catTab, sortedTypes), [catTab, sortedTypes]);
+ const filteredTypes = useMemo(
+ () => filterOddsPlayTypesByCategory(catTab, sortedTypes),
+ [catTab, sortedTypes],
+ );
+
+ const playFilterGroups = useMemo(
+ () => buildOddsPlayFilterGroups(catTab, sortedTypes),
+ [catTab, sortedTypes],
+ );
const resolvedPlayCode = useMemo(() => {
if (filteredTypes.length === 0) {
@@ -483,8 +500,13 @@ export function OddsConfigDocScreen({
{ id: "d2", label: "2D" },
];
- const filtersBlock = (
-
+ const activeCatLabel = catTabs.find((tab) => tab.id === catTab)?.label ?? catTab;
+ const activePlayLabel = resolvedPlayCode
+ ? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
+ : "—";
+
+ const filtersInner = (
+ <>
{catTabs.map((tab) => (
))}
-
- {filteredTypes.length === 0 ? (
- {t("odds.noPlayTypes", { ns: "config" })}
- ) : (
-
- {filteredTypes.map((type) => (
-
setPlayCode(type.play_code)}
- className="shrink-0"
+ {mergedLayout ? (
+
+ {playFilterGroups.length === 0 ? (
+ {t("odds.noPlayTypes", { ns: "config" })}
+ ) : (
+ playFilterGroups.map((group) => (
+
- {resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
-
- ))}
-
- )}
-
+ {group.types.map((type) => (
+ setPlayCode(type.play_code)}
+ >
+ {resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
+
+ ))}
+
+ ))
+ )}
+
+ ) : (
+
+ {filteredTypes.length === 0 ? (
+ {t("odds.noPlayTypes", { ns: "config" })}
+ ) : (
+
+ {filteredTypes.map((type) => (
+ setPlayCode(type.play_code)}
+ className="shrink-0"
+ >
+ {resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
+
+ ))}
+
+ )}
+
+ )}
+ >
+ );
+
+ const filtersBlock = mergedLayout ? (
+ filtersInner
+ ) : (
+
+ {filtersInner}
);
@@ -578,16 +638,12 @@ export function OddsConfigDocScreen({
{resolvedError ? {resolvedError}
: null}
{resolvedLoadingDetail || resolvedLoadingTypes ? (
-
+
{t("odds.loadingDetails", { ns: "config" })}
) : resolvedPlayCode ? (
-
-
+
+
{PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
@@ -721,6 +777,43 @@ export function OddsConfigDocScreen({
>
);
+ if (embedded && mergedLayout) {
+ return (
+
+
{toolbarBlock}
+
+
+
+ {filtersBlock}
+
+
+ {mainBlock}
+
+ {rebateSection}
+
+
+
+ {dialogs}
+
+ );
+ }
+
if (embedded) {
return (
diff --git a/src/modules/config/doc/odds-config-summary-panel.tsx b/src/modules/config/doc/odds-config-summary-panel.tsx
new file mode 100644
index 0000000..c568956
--- /dev/null
+++ b/src/modules/config/doc/odds-config-summary-panel.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { FileText, Info } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
+import { inferRebatePercentFromDimension, rateToPercentUi } from "@/modules/config/doc/odds-rebate-rates";
+import { prizeScopeLabel, type PrizeScopeCode } from "@/modules/config/doc/prize-scopes";
+import { cn } from "@/lib/utils";
+import type { AdminPlayTypeRow, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config";
+
+function oddsMultiplierLabel(oddsValue: number): string {
+ return (oddsValue / 10000).toFixed(4);
+}
+
+type SummaryRow = {
+ label: string;
+ value: string;
+};
+
+type OddsConfigSummaryPanelProps = {
+ catTabLabel: string;
+ playLabel: string;
+ detail: OddsVersionDetail | null;
+ draftRows: OddsItemRow[];
+ types: AdminPlayTypeRow[];
+ scopeRows: Partial
>;
+ playRebatePercent: string;
+ className?: string;
+};
+
+export function OddsConfigSummaryPanel({
+ catTabLabel,
+ playLabel,
+ detail,
+ draftRows,
+ types,
+ scopeRows,
+ playRebatePercent,
+ className,
+}: OddsConfigSummaryPanelProps) {
+ const { t } = useTranslation("config");
+
+ const isDraft = detail?.status === "draft";
+ const isActive = detail?.status === "active";
+
+ const rows: SummaryRow[] = [
+ { label: t("odds.category"), value: catTabLabel },
+ { label: t("odds.playType"), value: playLabel || "—" },
+ ];
+
+ for (const scope of ["first", "second", "third", "starter", "consolation"] as PrizeScopeCode[]) {
+ const row = scopeRows[scope];
+ rows.push({
+ label: prizeScopeLabel(scope, t),
+ value: row ? oddsMultiplierLabel(row.odds_value) : "—",
+ });
+ }
+
+ rows.push({
+ label: t("odds.rebateRate"),
+ value: playRebatePercent,
+ });
+
+ rows.push(
+ {
+ label: t("rebate.fields.d2"),
+ value: inferRebatePercentFromDimension(2, draftRows, types),
+ },
+ {
+ label: t("rebate.fields.d3"),
+ value: inferRebatePercentFromDimension(3, draftRows, types),
+ },
+ {
+ label: t("rebate.fields.d4"),
+ value: inferRebatePercentFromDimension(4, draftRows, types),
+ },
+ );
+
+ const versionLabel = detail ? `v${detail.version_no}` : "—";
+
+ return (
+
+ );
+}
+
+/** 当前玩法在摘要中展示的回水百分比(与赔率区输入一致)。 */
+export function playRebatePercentFromScopes(
+ scopeRows: Partial>,
+ order: readonly PrizeScopeCode[],
+): string {
+ const first = order.map((s) => scopeRows[s]).find(Boolean);
+ if (!first) {
+ return "0";
+ }
+ return rateToPercentUi(String(first.rebate_rate));
+}
diff --git a/src/modules/config/doc/odds-play-type-groups.ts b/src/modules/config/doc/odds-play-type-groups.ts
new file mode 100644
index 0000000..955da75
--- /dev/null
+++ b/src/modules/config/doc/odds-play-type-groups.ts
@@ -0,0 +1,60 @@
+import type { AdminPlayTypeRow } from "@/types/api/admin-config";
+
+export type OddsCategoryTab = "all" | "d4" | "d3" | "d2";
+
+export type OddsPlayFilterGroupKey = "bigSmall" | "combo4" | "number3" | "number2" | "other";
+
+type GroupDef = {
+ key: OddsPlayFilterGroupKey;
+ match: (row: AdminPlayTypeRow) => boolean;
+};
+
+const ODDS_PLAY_FILTER_GROUPS: GroupDef[] = [
+ {
+ key: "bigSmall",
+ match: (row) => row.play_code === "big" || row.play_code === "small",
+ },
+ {
+ key: "combo4",
+ match: (row) => row.category === "position" && row.dimension === 4,
+ },
+ {
+ key: "number3",
+ match: (row) => row.category === "position" && row.dimension === 3,
+ },
+ {
+ key: "number2",
+ match: (row) => row.category === "position" && row.dimension === 2,
+ },
+ {
+ key: "other",
+ match: (row) =>
+ row.category === "box"
+ || row.category === "attribute"
+ || (row.category === "standard" && row.play_code !== "big" && row.play_code !== "small"),
+ },
+];
+
+export function filterOddsPlayTypesByCategory(
+ tab: OddsCategoryTab,
+ types: AdminPlayTypeRow[],
+): AdminPlayTypeRow[] {
+ if (tab === "all") {
+ return types;
+ }
+ const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
+ return types.filter((t) => t.dimension === dim);
+}
+
+export function buildOddsPlayFilterGroups(
+ tab: OddsCategoryTab,
+ types: AdminPlayTypeRow[],
+): { key: OddsPlayFilterGroupKey; types: AdminPlayTypeRow[] }[] {
+ const filtered = filterOddsPlayTypesByCategory(tab, types);
+ return ODDS_PLAY_FILTER_GROUPS.map((def) => ({
+ key: def.key,
+ types: filtered
+ .filter(def.match)
+ .sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code)),
+ })).filter((group) => group.types.length > 0);
+}
diff --git a/src/modules/config/doc/odds-rebate-rates.ts b/src/modules/config/doc/odds-rebate-rates.ts
new file mode 100644
index 0000000..f435218
--- /dev/null
+++ b/src/modules/config/doc/odds-rebate-rates.ts
@@ -0,0 +1,29 @@
+import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
+import type { AdminPlayTypeRow, OddsItemRow } from "@/types/api/admin-config";
+
+export function rateToPercentUi(rateStr: string): string {
+ const n = Number.parseFloat(rateStr);
+ if (!Number.isFinite(n)) {
+ return "0.00";
+ }
+ return (Math.round(n * 10000) / 100).toFixed(2);
+}
+
+export function inferRebatePercentFromDimension(
+ dim: 2 | 3 | 4,
+ rows: OddsItemRow[],
+ typeList: AdminPlayTypeRow[],
+): string {
+ const codes = typeList
+ .filter((t) => (t.dimension ?? 2) === dim)
+ .map((t) => t.play_code)
+ .sort((a, b) => a.localeCompare(b));
+ const scope = PRIZE_SCOPE_ORDER[0];
+ for (const code of codes) {
+ const hit = rows.find((r) => r.play_code === code && r.prize_scope === scope);
+ if (hit) {
+ return rateToPercentUi(String(hit.rebate_rate));
+ }
+ }
+ return "0.00";
+}
diff --git a/src/modules/config/doc/rebate-config-doc-screen.tsx b/src/modules/config/doc/rebate-config-doc-screen.tsx
index bbef94d..70ce689 100644
--- a/src/modules/config/doc/rebate-config-doc-screen.tsx
+++ b/src/modules/config/doc/rebate-config-doc-screen.tsx
@@ -51,34 +51,16 @@ import type {
OddsVersionDetail,
} from "@/types/api/admin-config";
+import { ConfigWorkflowSection } from "@/modules/config/config-workflow-section";
+import {
+ inferRebatePercentFromDimension,
+ rateToPercentUi,
+} from "@/modules/config/doc/odds-rebate-rates";
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
const SETTLEMENT_GROUP = "settlement";
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
-function rateToPercentUi(rateStr: string): string {
- const n = Number.parseFloat(rateStr);
- if (!Number.isFinite(n)) {
- return "0.00";
- }
- return (Math.round(n * 10000) / 100).toFixed(2);
-}
-
-function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPlayTypeRow[]): string {
- const codes = typeList
- .filter((t) => (t.dimension ?? 2) === dim)
- .map((t) => t.play_code)
- .sort((a, b) => a.localeCompare(b));
- const scope = PRIZE_SCOPE_ORDER[0];
- for (const code of codes) {
- const hit = rows.find((r) => r.play_code === code && r.prize_scope === scope);
- if (hit) {
- return rateToPercentUi(String(hit.rebate_rate));
- }
- }
- return "0";
-}
-
function dimensionDistinctPrimaryScopePercents(
dim: 2 | 3 | 4,
rows: OddsItemRow[],
@@ -101,6 +83,8 @@ function dimensionDistinctPrimaryScopePercents(
type RebateConfigDocScreenProps = {
embedded?: boolean;
+ /** 合并页第 3 步卡片 */
+ mergedSection?: boolean;
workspace?: OddsConfigWorkspace;
versionId?: string;
onVersionIdChange?: (id: string) => void;
@@ -108,6 +92,7 @@ type RebateConfigDocScreenProps = {
export function RebateConfigDocScreen({
embedded = false,
+ mergedSection = false,
workspace,
versionId: controlledVersionId,
onVersionIdChange,
@@ -205,9 +190,9 @@ export function RebateConfigDocScreen({
if (!workspace) {
return;
}
- setP2(inferPercentFrom(2, workspace.draftRows, workspace.types));
- setP3(inferPercentFrom(3, workspace.draftRows, workspace.types));
- setP4(inferPercentFrom(4, workspace.draftRows, workspace.types));
+ setP2(inferRebatePercentFromDimension(2, workspace.draftRows, workspace.types));
+ setP3(inferRebatePercentFromDimension(3, workspace.draftRows, workspace.types));
+ setP4(inferRebatePercentFromDimension(4, workspace.draftRows, workspace.types));
}, [workspace?.draftRows, workspace?.types, workspace]);
async function handleWinEnjoyChange(checked: boolean): Promise {
@@ -236,9 +221,9 @@ export function RebateConfigDocScreen({
const rows = d.items.map((it) => ({ ...it }));
setDetail(d);
setDraftRows(rows);
- setP2(inferPercentFrom(2, rows, typeList));
- setP3(inferPercentFrom(3, rows, typeList));
- setP4(inferPercentFrom(4, rows, typeList));
+ setP2(inferRebatePercentFromDimension(2, rows, typeList));
+ setP3(inferRebatePercentFromDimension(3, rows, typeList));
+ setP4(inferRebatePercentFromDimension(4, rows, typeList));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setDetail(null);
@@ -357,9 +342,9 @@ export function RebateConfigDocScreen({
setDetail(d);
setDraftRows(rows);
}
- setP2(inferPercentFrom(2, rows, resolvedTypes));
- setP3(inferPercentFrom(3, rows, resolvedTypes));
- setP4(inferPercentFrom(4, rows, resolvedTypes));
+ setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
+ setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
+ setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void (workspace?.refreshList() ?? refreshList());
} catch (e) {
@@ -383,9 +368,9 @@ export function RebateConfigDocScreen({
setDetail(d);
setDraftRows(rows);
}
- setP2(inferPercentFrom(2, rows, resolvedTypes));
- setP3(inferPercentFrom(3, rows, resolvedTypes));
- setP4(inferPercentFrom(4, rows, resolvedTypes));
+ setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
+ setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
+ setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
toast.success(t("rebate.publishSuccess", { ns: "config" }));
void (workspace?.refreshList() ?? refreshList());
setSelectedId(String(d.id));
@@ -414,9 +399,9 @@ export function RebateConfigDocScreen({
setDetail(d);
setDraftRows(rows);
}
- setP2(inferPercentFrom(2, rows, resolvedTypes));
- setP3(inferPercentFrom(3, rows, resolvedTypes));
- setP4(inferPercentFrom(4, rows, resolvedTypes));
+ setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
+ setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
+ setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.createDraftFailed", { ns: "config" }));
} finally {
@@ -457,9 +442,9 @@ export function RebateConfigDocScreen({
setDetail(d);
setDraftRows(rows);
}
- setP2(inferPercentFrom(2, rows, resolvedTypes));
- setP3(inferPercentFrom(3, rows, resolvedTypes));
- setP4(inferPercentFrom(4, rows, resolvedTypes));
+ setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
+ setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
+ setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
setRollbackOpen(false);
setRollbackTarget(null);
} catch (e) {
@@ -658,6 +643,23 @@ export function RebateConfigDocScreen({
);
+ if (embedded && mergedSection) {
+ return (
+ <>
+
+ {fieldsBlock}
+
+ {rollbackDialog}
+
+ >
+ );
+ }
+
if (embedded) {
return (
diff --git a/src/modules/config/doc/risk-cap-doc-screen.tsx b/src/modules/config/doc/risk-cap-doc-screen.tsx
index 2ed009a..86af5d5 100644
--- a/src/modules/config/doc/risk-cap-doc-screen.tsx
+++ b/src/modules/config/doc/risk-cap-doc-screen.tsx
@@ -1,5 +1,6 @@
"use client";
+import { Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -13,6 +14,7 @@ import {
publishRiskCapVersion,
putRiskCapItems,
} from "@/api/admin-config";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
@@ -505,7 +507,7 @@ export function RiskCapDocScreen() {
{t("riskCap.table.number", { ns: "config" })}
{t("riskCap.table.capAmount", { ns: "config" })}
- {t("riskCap.table.actions", { ns: "config" })}
+ {t("riskCap.table.actions", { ns: "config" })}
@@ -549,17 +551,20 @@ export function RiskCapDocScreen() {
)}
-
+
{canEditDraft ? (
-
+ removeRow(idx),
+ },
+ ]}
+ />
) : (
{t("riskCap.readOnly", { ns: "config" })}
)}
diff --git a/src/modules/dashboard/dashboard-analytics-panel.tsx b/src/modules/dashboard/dashboard-analytics-panel.tsx
index 394e43d..6b6e00a 100644
--- a/src/modules/dashboard/dashboard-analytics-panel.tsx
+++ b/src/modules/dashboard/dashboard-analytics-panel.tsx
@@ -1,12 +1,10 @@
"use client";
import Link from "next/link";
-import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
-import { format, subDays } from "date-fns";
+import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
-import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { buttonVariants } from "@/components/ui/button";
@@ -20,58 +18,345 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
-import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { getAdminRequestLocale } from "@/lib/admin-locale";
-import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import { cn } from "@/lib/utils";
-import { StatCard } from "@/modules/dashboard/dashboard-visuals";
+import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import {
DailyTrendChart,
- PeriodCompareStrip,
PlayBreakdownChart,
} from "@/modules/dashboard/dashboard-trend-charts";
-import { LotteryApiBizError } from "@/types/api/errors";
-import type {
- AdminDashboardAnalyticsData,
- DashboardAnalyticsMetric,
- DashboardAnalyticsPeriod,
-} from "@/types/api/admin-dashboard-analytics";
+import {
+ DASHBOARD_ANALYTICS_PERIODS,
+ DASHBOARD_RANKING_METRICS,
+ useDashboardAnalytics,
+ type DashboardAnalyticsState,
+} from "@/modules/dashboard/use-dashboard-analytics";
-const PERIOD_OPTIONS: DashboardAnalyticsPeriod[] = [
- "today",
- "last_7_days",
- "last_30_days",
- "this_month",
- "lifetime",
- "custom",
-];
-
-const METRIC_OPTIONS: DashboardAnalyticsMetric[] = ["overview", "bet", "payout", "profit"];
-
-function formatMoneyMinor(minor: number, currencyCode: string | null): string {
- const code = (currencyCode ?? "NPR").toUpperCase();
- const decimals = getAdminCurrencyDecimalPlaces(code);
- const major = minor / 10 ** decimals;
- try {
- return new Intl.NumberFormat(getAdminRequestLocale(), {
- style: "currency",
- currency: code,
- minimumFractionDigits: decimals,
- maximumFractionDigits: decimals,
- }).format(major);
- } catch {
- return formatAdminMinorUnits(minor, code, decimals);
+function computeDeltaPercent(series: number[]): string | null {
+ if (series.length < 2) {
+ return null;
}
+ const prev = series[series.length - 2];
+ const last = series[series.length - 1];
+ if (prev === 0) {
+ return null;
+ }
+ const pct = ((last - prev) / Math.abs(prev)) * 100;
+ const sign = pct >= 0 ? "▲" : "▼";
+ return `${sign} ${Math.abs(pct).toFixed(1)}%`;
}
-function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
- if (minor === 0) {
- return formatMoneyMinor(0, currencyCode);
+function deltaClassName(series: number[]): string {
+ if (series.length < 2) {
+ return "text-muted-foreground";
}
- const s = minor > 0 ? "+" : "−";
- return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
+ const last = series[series.length - 1];
+ const prev = series[series.length - 2];
+ if (last >= prev) {
+ return "text-emerald-600 dark:text-emerald-400";
+ }
+ return "text-destructive";
}
+export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnalyticsState }): ReactNode {
+ const { t } = useTranslation(["dashboard", "common"]);
+ const {
+ enabled,
+ period,
+ setPeriod,
+ playCode,
+ setPlayCode,
+ customFrom,
+ setCustomFrom,
+ customTo,
+ setCustomTo,
+ loading,
+ error,
+ data,
+ currency,
+ summary,
+ periodRangeLabel,
+ playFilterLabel,
+ playOptions,
+ sparklines,
+ formatMoney,
+ formatSignedMoney,
+ } = analytics;
+
+ if (!enabled) {
+ return null;
+ }
+
+ return (
+
+
+
+ {t("analytics.title")}
+
+
+ {t("viewReports")}
+
+
+
+
+ {DASHBOARD_ANALYTICS_PERIODS.map((p) => (
+
+ ))}
+
+
+
+ {period === "custom" ? (
+
{
+ setCustomFrom(from);
+ setCustomTo(to);
+ }}
+ />
+ ) : (
+
+ {periodRangeLabel
+ ? t("analytics.rangeHint", { range: periodRangeLabel })
+ : t("analytics.selectPeriod")}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {data?.chart_meta.truncated ? (
+
+ {t("analytics.chartTruncated", {
+ from: data.chart_meta.chart_date_from,
+ to: data.chart_meta.chart_date_to,
+ days: data.chart_meta.span_days,
+ })}
+
+ ) : null}
+
+ {loading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : summary ? (
+
+ }
+ sparklineValues={sparklines.bet}
+ deltaLabel={
+ computeDeltaPercent(sparklines.bet) ? (
+
+ {computeDeltaPercent(sparklines.bet)}
+
+ ) : undefined
+ }
+ />
+ 0
+ ? t("payoutRateOfBet", {
+ rate: ((summary.total_payout_minor / summary.total_bet_minor) * 100).toFixed(1),
+ })
+ : undefined
+ }
+ icon={}
+ accent="destructive"
+ sparklineValues={sparklines.payout}
+ deltaLabel={
+ computeDeltaPercent(sparklines.payout) ? (
+
+ {computeDeltaPercent(sparklines.payout)}
+
+ ) : undefined
+ }
+ />
+ 0
+ ? t("marginRate", {
+ rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1),
+ })
+ : undefined
+ }
+ icon={}
+ sparklineValues={sparklines.profit}
+ deltaLabel={
+ computeDeltaPercent(sparklines.profit) ? (
+
+ {computeDeltaPercent(sparklines.profit)}
+
+ ) : undefined
+ }
+ />
+
+ ) : null}
+
+
+
+
{t("analytics.dailyTrend")}
+
{t("analytics.granularityDay")}
+
+
+ {loading ? (
+
+ ) : data ? (
+
+ ) : (
+
+ {t("states.noData", { ns: "common" })}
+
+ )}
+
+
+
+
+ );
+}
+
+export function DashboardPlayRankingCard({
+ analytics,
+}: {
+ analytics: DashboardAnalyticsState;
+}): ReactNode {
+ const { t } = useTranslation(["dashboard", "common"]);
+ const {
+ enabled,
+ rankingMetric,
+ setRankingMetric,
+ period,
+ setPeriod,
+ loading,
+ data,
+ currency,
+ topPlayRows,
+ resolvePlayLabel,
+ formatMoney,
+ } = analytics;
+
+ if (!enabled) {
+ return null;
+ }
+
+ return (
+
+
+ {t("analytics.playRanking")}
+
+ {DASHBOARD_RANKING_METRICS.map((m) => (
+
+ ))}
+
+
+
+
+ {loading ? (
+
+ ) : data && topPlayRows.length > 0 ? (
+
+ ) : (
+
+ {t("analytics.noPlayData")}
+
+ )}
+
+
+ );
+}
+
+/** 单列堆叠布局(兼容旧用法) */
export function DashboardAnalyticsPanel({
enabled,
playOptions,
@@ -79,309 +364,11 @@ export function DashboardAnalyticsPanel({
enabled: boolean;
playOptions: { code: string; label: string }[];
}): ReactNode {
- const { t } = useTranslation(["dashboard", "common"]);
- const playLabel = useAdminPlayCodeLabel();
-
- const [period, setPeriod] = useState("last_7_days");
- const [metric, setMetric] = useState("overview");
- const [playCode, setPlayCode] = useState("");
- const [customFrom, setCustomFrom] = useState(() => format(subDays(new Date(), 6), "yyyy-MM-dd"));
- const [customTo, setCustomTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
-
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [data, setData] = useState(null);
-
- const load = useCallback(async () => {
- if (!enabled) {
- setLoading(false);
- setData(null);
- return;
- }
-
- setLoading(true);
- setError(null);
-
- try {
- const payload = await getAdminDashboardAnalytics({
- period,
- metric,
- play_code: playCode !== "" ? playCode : undefined,
- ...(period === "custom"
- ? { date_from: customFrom, date_to: customTo }
- : {}),
- });
- setData(payload);
- } catch (e) {
- setData(null);
- const raw = e instanceof LotteryApiBizError ? e.message : "";
- const needsAuthSync =
- raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
- setError(
- needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
- );
- } finally {
- setLoading(false);
- }
- }, [enabled, period, metric, playCode, customFrom, customTo, t]);
-
- useEffect(() => {
- const timer = window.setTimeout(() => {
- void load();
- }, 0);
- return () => window.clearTimeout(timer);
- }, [load]);
-
- const currency = data?.currency_code ?? null;
- const summary = data?.summary;
-
- const periodRangeLabel = useMemo(() => {
- if (!data) {
- return null;
- }
- return data.date_from === data.date_to
- ? data.date_from
- : `${data.date_from} — ${data.date_to}`;
- }, [data]);
-
- const metricLabel = useMemo(
- () => t(`analytics.metrics.${metric}`),
- [metric, t],
- );
-
- const playFilterLabel = useMemo(() => {
- if (playCode === "") {
- return t("analytics.allPlays");
- }
- return playOptions.find((p) => p.code === playCode)?.label ?? playCode;
- }, [playCode, playOptions, t]);
-
- const resolvePlayLabel = useCallback(
- (code: string, dimension: number) => {
- const base = playLabel(code);
- return dimension > 0 ? `${base} · ${dimension}D` : base;
- },
- [playLabel],
- );
-
- if (!enabled) {
- return null;
- }
-
+ const analytics = useDashboardAnalytics({ enabled, playOptions });
return (
-
-
-
- {t("analytics.title")}
-
-
- {t("viewReports")}
-
-
-
-
- {PERIOD_OPTIONS.map((p) => (
-
- ))}
-
-
-
- {period === "custom" ? (
-
{
- setCustomFrom(from);
- setCustomTo(to);
- }}
- />
- ) : (
-
- {periodRangeLabel
- ? t("analytics.rangeHint", { range: periodRangeLabel })
- : t("analytics.selectPeriod")}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {error ? (
-
- {error}
-
- ) : null}
-
- {data?.chart_meta.truncated ? (
-
- {t("analytics.chartTruncated", {
- from: data.chart_meta.chart_date_from,
- to: data.chart_meta.chart_date_to,
- days: data.chart_meta.span_days,
- })}
-
- ) : null}
-
- {loading ? (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- ) : summary ? (
-
- }
- />
- 0
- ? t("payoutRateOfBet", {
- rate: ((summary.total_payout_minor / summary.total_bet_minor) * 100).toFixed(1),
- })
- : undefined
- }
- icon={}
- accent="destructive"
- />
- 0
- ? t("marginRate", {
- rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1),
- })
- : undefined
- }
- icon={}
- />
-
- ) : null}
-
-
-
-
-
-
- {t("analytics.dailyTrend")}
-
-
- {loading ? (
-
- ) : data ? (
-
- ) : (
- {t("states.noData", { ns: "common" })}
- )}
-
-
-
-
-
- {t("analytics.playBreakdown")}
-
-
- {loading ? (
-
- ) : data ? (
-
- ) : (
- {t("states.noData", { ns: "common" })}
- )}
-
-
-
-
- {data && !loading ? (
-
-
- {t("analytics.periodDistribution")}
-
-
-
-
-
- ) : null}
+
+
);
}
diff --git a/src/modules/dashboard/dashboard-chart-empty.tsx b/src/modules/dashboard/dashboard-chart-empty.tsx
index 6a23b32..e5accd8 100644
--- a/src/modules/dashboard/dashboard-chart-empty.tsx
+++ b/src/modules/dashboard/dashboard-chart-empty.tsx
@@ -2,6 +2,23 @@
import type { ReactElement } from "react";
-export function DashboardChartEmpty({ message }: { message: string }): ReactElement {
- return {message}
;
+import { cn } from "@/lib/utils";
+
+export function DashboardChartEmpty({
+ message,
+ compact = false,
+}: {
+ message: string;
+ compact?: boolean;
+}): ReactElement {
+ return (
+
+ {message}
+
+ );
}
diff --git a/src/modules/dashboard/dashboard-console.tsx b/src/modules/dashboard/dashboard-console.tsx
index 12b3775..bec3ca6 100644
--- a/src/modules/dashboard/dashboard-console.tsx
+++ b/src/modules/dashboard/dashboard-console.tsx
@@ -10,9 +10,12 @@ import {
FileSearch,
RefreshCw,
ScrollText,
+ Settings,
Shield,
Ticket,
Wallet,
+ BarChart3,
+ Scale,
} from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
@@ -23,20 +26,25 @@ import {
getCachedAdminPlayTypes,
resolveAdminPlayTypeDisplayName,
} from "@/lib/admin-play-types";
-import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
+import {
+ DashboardAnalyticsMain,
+ DashboardPlayRankingCard,
+} from "@/modules/dashboard/dashboard-analytics-panel";
+import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
+import { useDashboardAnalytics } from "@/modules/dashboard/use-dashboard-analytics";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
+ AbnormalTransferPanelFooter,
CapUsageBar,
FinanceStructureChart,
HotUsageBars,
- PayoutCompositionChart,
+ PayoutPanelSnapshot,
ResultBatchProgress,
- StatCard,
+ DashboardPanelCard,
SettlementStatusChart,
- SoldOutRing,
} from "@/modules/dashboard/dashboard-visuals";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
@@ -52,14 +60,6 @@ import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
type HotPlayTab = "4D" | "3D" | "2D" | "special";
-type SoldOutBuckets = {
- d4: number;
- d3: number;
- d2: number;
- special: number;
- other: number;
-};
-
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
@@ -76,6 +76,14 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
}
}
+function drawScopedHref(
+ drawId: number | null,
+ suffix = "",
+ fallback = "/admin/draws",
+): string {
+ return drawId != null ? `/admin/draws/${drawId}${suffix}` : fallback;
+}
+
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
const raw = normalizedNumber.trim();
const digits = raw.replace(/\D/g, "");
@@ -130,7 +138,6 @@ export function DashboardConsole(): ReactElement {
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 [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
@@ -171,7 +178,6 @@ export function DashboardConsole(): ReactElement {
setRiskLocked(0);
setRiskCap(0);
setHotPoolSample([]);
- setSoldOutBuckets(null);
setAbnormalTransferTotal(null);
try {
@@ -194,7 +200,6 @@ export function DashboardConsole(): ReactElement {
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);
} catch (e) {
@@ -220,41 +225,44 @@ export function DashboardConsole(): ReactElement {
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
- const hallStatusLabel = hall?.status ?? "—";
- const isOpenLike =
- hallStatusLabel.toLowerCase().includes("open") ||
- hallStatusLabel.toLowerCase().includes("sale");
+ const analytics = useDashboardAnalytics({ enabled: canFinance, playOptions });
+ const showAnalytics = canFinance;
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
- { href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: },
- { href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: },
+ { href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: },
+ { href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: },
{
href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws",
label: t("quickLinks.results"),
- icon: ,
+ icon: ,
},
- { href: "/admin/tickets", label: t("quickLinks.tickets"), icon: },
- { href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: },
- { href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: },
+ { href: "/admin/tickets", label: t("quickLinks.tickets"), icon: },
+ { href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: },
+ { href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: },
+ { href: "/admin/reports", label: t("quickLinks.reports"), icon: },
+ { href: "/admin/rules/odds", label: t("quickLinks.payoutRules"), icon: },
+ { href: "/admin/risk", label: t("quickLinks.riskMonitor"), icon: },
+ { href: "/admin/settings", label: t("quickLinks.systemSettings"), icon: },
];
return (
-
-
-
{t("title")}
-
-
{todayLabel}
-
+
+
+
+
{t("title")}
+
{todayLabel}
+
{error ? (
@@ -271,302 +279,267 @@ export function DashboardConsole(): ReactElement {
) : null}
- {!loading && hall ? (
-
-
-
-
-
{t("sections.currentDraw")}
-
{hall.draw_no}
-
-
- {t("drawSequence", { sequence: hall.sequence_no ?? "—" })}
-
-
-
- {hallStatusLabel}
-
-
- {drawId != null ? (
-
- {t("drawFinanceDetails")}
-
- ) : null}
-
- ) : null}
-
-
-
+
+
+
0
+ ? t("actions.reviewNow", { ns: "common" })
+ : t("drawDetails")
+ }
icon={}
accent={(pendingReview ?? 0) > 0 ? "destructive" : "muted"}
- />
- 0}
+ loading={loading}
+ >
+ {drawPanel ? : null}
+
+
+
}
- accent={(abnormalTransferTotal ?? 0) > 0 ? "destructive" : "muted"}
- />
-
0 ? "warning" : "muted"}
+ loading={loading}
+ highlight={(abnormalTransferTotal ?? 0) > 0}
+ >
+
+
+
+ }
- accent={usagePct >= 90 ? "destructive" : usagePct >= 70 ? "primary" : "muted"}
- />
- }
+ accent={
+ usagePct >= 90 ? "destructive" : usagePct >= 70 ? "primary" : "muted"
+ }
+ loading={loading}
+ >
+
+
+
+ }
accent="primary"
- />
-
+ loading={loading}
+ >
+ {finance ? (
+
+ ) : null}
+
+
+
-
-
-
- {t("riskCapUsage")}
- {drawId != null ? (
-
- {t("occupancyDetails")}
-
- ) : null}
-
-
- {loading ? (
-
- ) : (
-
- )}
-
-
+
+
+ {showAnalytics ?
: null}
-
-
- {t("soldOutDistribution")}
- {drawId != null ? (
-
- {t("actions.viewAll", { ns: "common" })}
-
- ) : null}
-
-
- {loading ? (
-
- ) : soldOutBuckets ? (
-
- ) : (
- {t("states.noData", { ns: "common" })}
- )}
-
-
-
-
-
- {t("resultBatches")}
- {drawId != null ? (
-
- {t("drawDetails")}
-
- ) : null}
-
-
- {loading ? (
-
- ) : drawPanel ? (
-
- ) : (
- {t("states.noData", { ns: "common" })}
- )}
-
-
-
-
-
- {t("payoutComposition")}
-
-
- {loading ? (
-
- ) : finance ? (
-
- ) : (
- {t("states.noData", { ns: "common" })}
- )}
-
-
-
-
-
-
-
-
-
- {t("financeStructure")}
-
-
- {loading ? (
-
- ) : finance ? (
-
- ) : (
- {t("states.noData", { ns: "common" })}
- )}
-
-
-
-
-
- {t("settlementOverview")}
- {drawId != null ? (
-
- {t("actions.viewAll", { ns: "common" })}
-
- ) : null}
-
-
- {loading ? (
-
- ) : finance ? (
-
- ) : (
- {t("states.noData", { ns: "common" })}
- )}
-
-
-
-
-
- {t("hotNumbersTop10")}
-
-
- {([
- { value: "4D", label: t("tabs.4d") },
- { value: "3D", label: t("tabs.3d") },
- { value: "2D", label: t("tabs.2d") },
- { value: "special", label: t("tabs.special") },
- ] as const).map((tab) => (
-
-
-
{loading ? : }
-
-
+ {t("actions.viewAll", { ns: "common" })}
+
+ ) : null}
+
+
+ {loading ? (
+
+ ) : finance ? (
+
+ ) : (
+
+ {t("states.noData", { ns: "common" })}
+
+ )}
+
+
-
{t("sections.operations")}
-
-
-
-
-
-
-
-
-
{t("pendingReviewResults")}
-
{pendingReview ?? "—"}
-
+
+
+ {t("hotNumbersTop10")}
+
+
+ {([
+ { value: "4D", label: t("tabs.4d") },
+ { value: "3D", label: t("tabs.3d") },
+ { value: "2D", label: t("tabs.2d") },
+ { value: "special", label: t("tabs.special") },
+ ] as const).map((tab) => (
+
+ ))}
+
+
+
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+
- {drawId != null ? (
-
- {t("actions.reviewNow", { ns: "common" })}
-
+
+ {!showAnalytics ? (
+
+
+
+ {t("financeStructure")}
+
+
+ {loading ? (
+
+ ) : finance ? (
+
+ ) : (
+
+ {t("states.noData", { ns: "common" })}
+
+ )}
+
+
+
+
+
+ {t("quickLinksTitle")}
+
+
+ {quickLinks.map((q) => (
+
+
+ {q.icon}
+
+ {q.label}
+
+ ))}
+
+
+
) : null}
-
-
-
-
-
{t("abnormalTransferOrders")}
-
{abnormalTransferTotal ?? "—"}
-
-
-
- {t("viewTransferOrders")}
-
-
-
-
-
-
- {t("quickLinksTitle")}
-
-
- {quickLinks.map((q) => (
-
-
- {q.icon}
-
- {q.label}
-
- ))}
-
-
-
+ {showAnalytics ? (
+
+ ) : null}
+
);
}
diff --git a/src/modules/dashboard/dashboard-current-draw-card.tsx b/src/modules/dashboard/dashboard-current-draw-card.tsx
new file mode 100644
index 0000000..adedef2
--- /dev/null
+++ b/src/modules/dashboard/dashboard-current-draw-card.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import Link from "next/link";
+import type { ReactElement } from "react";
+import { useTranslation } from "react-i18next";
+import { ArrowRight, Clock, Ticket } from "lucide-react";
+
+import { buttonVariants } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
+import { cn } from "@/lib/utils";
+import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
+
+function isOpenLikeStatus(status: string): boolean {
+ const lower = status.toLowerCase();
+ return lower.includes("open") || lower.includes("sale");
+}
+
+type DashboardCurrentDrawCardProps = {
+ hall: DrawCurrentSnapshot | null;
+ drawId: number | null;
+ loading?: boolean;
+};
+
+export function DashboardCurrentDrawCard({
+ hall,
+ drawId,
+ loading = false,
+}: DashboardCurrentDrawCardProps): ReactElement {
+ const { t } = useTranslation("dashboard");
+ const formatDt = useAdminDateTimeFormatter();
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (!hall) {
+ return (
+
+
+
+ {t("sections.currentDraw")}
+ {t("states.noData", { ns: "common" })}
+
+
+ );
+ }
+
+ const openLike = isOpenLikeStatus(hall.status);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {t("sections.currentDraw")}
+
+
+ {hall.draw_no}
+
+
+
+ {t("drawSequence", { sequence: hall.sequence_no ?? "—" })}
+
+
+
+ {hall.status}
+
+
+
+
+
+
+ {hall.draw_time ? (
+
+
+ {t("scheduledDrawTime", { time: formatDt(hall.draw_time) })}
+
+ ) : null}
+ {drawId != null ? (
+
+ {t("drawFinanceDetails")}
+
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/src/modules/dashboard/dashboard-trend-charts.tsx b/src/modules/dashboard/dashboard-trend-charts.tsx
index 5c3dd74..c423bcb 100644
--- a/src/modules/dashboard/dashboard-trend-charts.tsx
+++ b/src/modules/dashboard/dashboard-trend-charts.tsx
@@ -175,12 +175,14 @@ export function PlayBreakdownChart({
formatMoney,
currency,
playLabel,
+ compact = false,
}: {
rows: AdminDashboardAnalyticsPlayRow[];
metric: DashboardAnalyticsMetric;
formatMoney: MoneyFormatter;
currency: string | null;
playLabel: (code: string, dimension: number) => string;
+ compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const activeMetric = metric === "overview" ? "bet" : metric;
@@ -216,19 +218,21 @@ export function PlayBreakdownChart({
return ;
}
- const chartHeight = Math.min(480, Math.max(180, rows.length * 36 + 48));
+ const chartHeight = compact
+ ? Math.max(160, rows.length * 32 + 24)
+ : Math.min(480, Math.max(180, rows.length * 36 + 48));
return (
}
/>
-
+
{chartData.map((entry) => (
|
))}
@@ -263,10 +267,13 @@ export function PlayBreakdownChart({
+ typeof value === "string" && value.length > 10 ? `${value.slice(0, 10)}…` : String(value)
+ }
/>
diff --git a/src/modules/dashboard/dashboard-visuals.tsx b/src/modules/dashboard/dashboard-visuals.tsx
index 5e3bad6..fa92d2d 100644
--- a/src/modules/dashboard/dashboard-visuals.tsx
+++ b/src/modules/dashboard/dashboard-visuals.tsx
@@ -1,8 +1,10 @@
"use client";
+import Link from "next/link";
import type { ReactElement, ReactNode } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
+import { AlertTriangle, ArrowRightIcon, CheckCircle2, ChevronRightIcon } from "lucide-react";
import {
Bar,
BarChart,
@@ -18,6 +20,7 @@ import {
} from "recharts";
import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
import {
ChartContainer,
ChartLegend,
@@ -87,18 +90,137 @@ function settlementBarColor(status: string): string {
}
}
+type DashboardKpiAccent = "primary" | "destructive" | "muted";
+
+function kpiAccentClass(accent: DashboardKpiAccent): string {
+ switch (accent) {
+ case "destructive":
+ return "bg-destructive/10 text-destructive";
+ case "muted":
+ return "bg-muted text-muted-foreground";
+ default:
+ return "bg-primary/10 text-primary";
+ }
+}
+
+/** 财务概览区紧凑 KPI,避免 StatCard 在窄栅格内撑破布局 */
+export function DashboardKpiCard({
+ label,
+ value,
+ hint,
+ icon,
+ accent = "primary",
+ sparklineValues,
+ deltaLabel,
+}: {
+ label: string;
+ value: ReactNode;
+ hint?: ReactNode;
+ icon: ReactNode;
+ accent?: DashboardKpiAccent;
+ sparklineValues?: number[];
+ deltaLabel?: ReactNode;
+}): ReactElement {
+ return (
+
+
+
+ {icon}
+
+
+
{label}
+
+ {value}
+
+ {deltaLabel ?
{deltaLabel}
: null}
+
+
+ {sparklineValues && sparklineValues.length >= 2 ? (
+
+
+
+ ) : null}
+ {hint ? (
+
{hint}
+ ) : null}
+
+ );
+}
+
+function MiniSparkline({
+ values,
+ strokeClass,
+}: {
+ values: number[];
+ strokeClass: string;
+}): ReactElement | null {
+ if (values.length < 2) {
+ return null;
+ }
+ const width = 88;
+ const height = 32;
+ const max = Math.max(...values, 1);
+ const min = Math.min(...values, 0);
+ const range = Math.max(max - min, 1);
+ const points = values
+ .map((v, i) => {
+ const x = (i / (values.length - 1)) * width;
+ const y = height - ((v - min) / range) * (height - 4) - 2;
+ return `${x},${y}`;
+ })
+ .join(" ");
+
+ return (
+
+ );
+}
+
export function StatCard({
label,
value,
hint,
icon,
accent = "primary",
+ href,
+ sparklineValues,
+ deltaLabel,
}: {
label: string;
value: ReactNode;
hint?: ReactNode;
icon: ReactNode;
accent?: "primary" | "destructive" | "muted";
+ /** 整张卡片可点击跳转 */
+ href?: string;
+ sparklineValues?: number[];
+ deltaLabel?: ReactNode;
}): ReactElement {
const accentClass =
accent === "destructive"
@@ -107,9 +229,15 @@ export function StatCard({
? "bg-muted text-foreground"
: "bg-primary text-primary-foreground";
- return (
-
-
+ const card = (
+
+
{icon}
-
+
{label}
-
{value}
- {hint ?
{hint}
: null}
+
+ {value}
+
+ {deltaLabel ? (
+
{deltaLabel}
+ ) : null}
+
+ {hint ?? "\u00a0"}
+
+ {sparklineValues ? (
+
+ ) : href ? (
+
+ ) : null}
);
+
+ const shellClass = "flex h-full min-h-0 rounded-2xl";
+
+ if (!href) {
+ return
{card}
;
+ }
+
+ return (
+
+ {card}
+
+ );
+}
+
+type DashboardPanelAccent = "primary" | "destructive" | "warning" | "muted";
+
+function panelAccentClass(accent: DashboardPanelAccent): string {
+ switch (accent) {
+ case "destructive":
+ return "bg-destructive/10 text-destructive";
+ case "warning":
+ return "bg-amber-500/15 text-amber-700 dark:text-amber-400";
+ case "muted":
+ return "bg-muted text-muted-foreground";
+ default:
+ return "bg-primary/10 text-primary";
+ }
+}
+
+/** 仪表盘 KPI:整卡可点,主指标 + 可选底部可视化 */
+export function DashboardPanelCard({
+ href,
+ icon,
+ title,
+ value,
+ subtitle,
+ actionLabel,
+ accent = "primary",
+ loading = false,
+ highlight = false,
+ children,
+}: {
+ href: string;
+ title: string;
+ value: ReactNode;
+ subtitle?: ReactNode;
+ actionLabel: string;
+ icon: ReactNode;
+ accent?: DashboardPanelAccent;
+ loading?: boolean;
+ /** 有异常/待办时强调边框 */
+ highlight?: boolean;
+ children?: ReactNode;
+}): ReactElement {
+ const hasFooter = children != null;
+
+ return (
+
+
+
+
+
+
+
{title}
+ {loading ? (
+
+ ) : (
+
+ {value}
+
+ )}
+ {subtitle && !loading ? (
+
+ {subtitle}
+
+ ) : null}
+
+
+ {hasFooter ? (
+
+ {loading ? (
+
+ ) : (
+
+ {children}
+
+ )}
+
+ ) : null}
+
+
+
+ );
+}
+
+/** 异常转账 KPI 底部:待办提示或正常态 */
+export function AbnormalTransferPanelFooter({
+ total,
+ walletPermission = true,
+}: {
+ total: number | null;
+ walletPermission?: boolean;
+}): ReactElement {
+ const { t } = useTranslation("dashboard");
+
+ if (!walletPermission) {
+ return (
+
+ {t("warnings.walletPermission")}
+
+ );
+ }
+
+ if (total == null) {
+ return (
+
+ {t("states.noData", { ns: "common" })}
+
+ );
+ }
+
+ if (total > 0) {
+ return (
+
+
+
+
+ {t("abnormalTransferPending", { count: total })}
+
+
+ {t("abnormalTransferAction")}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("abnormalTransferAllClear")}
+
+
+ );
+}
+
+/** 派彩 KPI 底部:投注/中奖/奖池拆分,有派彩时再附饼图 */
+export function PayoutPanelSnapshot({
+ finance,
+ formatMoney,
+}: {
+ finance: AdminDrawFinanceSummaryData;
+ formatMoney: MoneyFormatter;
+}): ReactElement {
+ const { t } = useTranslation("dashboard");
+ const currency = finance.currency_code;
+ const bet = finance.total_bet_minor;
+ const win = finance.total_win_payout_minor;
+ const jackpot = finance.total_jackpot_win_minor;
+ const hasPayout = win + jackpot > 0;
+
+ if (bet <= 0 && !hasPayout) {
+ return
;
+ }
+
+ const cells = [
+ { key: "bet", label: t("currentDrawBetTotal"), amount: bet, emphasize: bet > 0 },
+ { key: "win", label: t("winPayout"), amount: win, emphasize: win > 0 },
+ { key: "jackpot", label: t("jackpotPayout"), amount: jackpot, emphasize: jackpot > 0 },
+ ] as const;
+
+ return (
+
+
+ {cells.map((cell) => (
+
+
{cell.label}
+
+ {formatMoney(cell.amount, currency)}
+
+
+ ))}
+
+ {hasPayout ? (
+
+ ) : (
+
+ {t("noPayoutYet")}
+
+ )}
+
+ );
}
export function CapUsageBar({
@@ -134,12 +540,15 @@ export function CapUsageBar({
usagePct,
formatMoney,
currency,
+ compact = false,
}: {
locked: number;
cap: number;
usagePct: number;
formatMoney: MoneyFormatter;
currency: string | null;
+ /** 嵌入 DashboardPanelCard 时隐藏底部说明、缩小图表 */
+ compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const pct = Math.min(100, Math.max(0, usagePct));
@@ -150,6 +559,24 @@ export function CapUsageBar({
);
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
+ if (compact) {
+ return (
+
+ );
+ }
+
return (
- {pct.toFixed(1)}%
+
+ {pct.toFixed(1)}%
+
);
}}
@@ -240,7 +669,7 @@ export function FinanceStructureChart({
formatMoney(Number(value), currency)} />
+ formatMoney(Number(value), currency)} />
}
/>
@@ -259,9 +688,11 @@ export function FinanceStructureChart({
export function PayoutCompositionChart({
finance,
formatMoney,
+ compact = false,
}: {
finance: AdminDrawFinanceSummaryData;
formatMoney: MoneyFormatter;
+ compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
@@ -279,7 +710,7 @@ export function PayoutCompositionChart({
);
if (total <= 0) {
- return ;
+ return ;
}
const pieData = [
@@ -288,7 +719,13 @@ export function PayoutCompositionChart({
];
return (
-
+
))}
- } />
+ {compact ? null : (
+ } />
+ )}
);
}
-export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
+export function HotUsageBars({
+ rows,
+ compact = false,
+}: {
+ rows: AdminRiskPoolRow[];
+ compact?: boolean;
+}): ReactElement {
const { t } = useTranslation("dashboard");
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
@@ -337,7 +782,9 @@ export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactEleme
return ;
}
- const chartHeight = Math.min(420, Math.max(160, rows.length * 32 + 48));
+ const chartHeight = compact
+ ? Math.min(220, Math.max(120, rows.length * 22 + 36))
+ : Math.min(420, Math.max(160, rows.length * 32 + 48));
return (
+
+
+ {pending_review}
+
+
{t("batchPending")}
+
+
+
+ {published}
+
+
{t("batchPublished")}
+
+
+
+ {total}
+
+
{t("batchTotal")}
+
+
+ );
+
+ if (compact) {
+ return statCells;
+ }
+
return (
@@ -478,20 +968,7 @@ export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel })
-
-
-
{pending_review}
-
{t("batchPending")}
-
-
-
{published}
-
{t("batchPublished")}
-
-
-
{total}
-
{t("batchTotal")}
-
-
+ {statCells}
);
}
diff --git a/src/modules/dashboard/use-dashboard-analytics.ts b/src/modules/dashboard/use-dashboard-analytics.ts
new file mode 100644
index 0000000..3ea9018
--- /dev/null
+++ b/src/modules/dashboard/use-dashboard-analytics.ts
@@ -0,0 +1,193 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { format, subDays } from "date-fns";
+import { useTranslation } from "react-i18next";
+
+import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
+import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
+import { getAdminRequestLocale } from "@/lib/admin-locale";
+import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
+import { LotteryApiBizError } from "@/types/api/errors";
+import type {
+ AdminDashboardAnalyticsData,
+ DashboardAnalyticsMetric,
+ DashboardAnalyticsPeriod,
+} from "@/types/api/admin-dashboard-analytics";
+
+export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
+ "today",
+ "last_7_days",
+ "last_30_days",
+ "this_month",
+ "lifetime",
+ "custom",
+];
+
+export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
+
+export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
+ const code = (currencyCode ?? "NPR").toUpperCase();
+ const decimals = getAdminCurrencyDecimalPlaces(code);
+ const major = minor / 10 ** decimals;
+ try {
+ return new Intl.NumberFormat(getAdminRequestLocale(), {
+ style: "currency",
+ currency: code,
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ }).format(major);
+ } catch {
+ return formatAdminMinorUnits(minor, code, decimals);
+ }
+}
+
+export function formatDashboardSignedMoneyMinor(minor: number, currencyCode: string | null): string {
+ if (minor === 0) {
+ return formatDashboardMoneyMinor(0, currencyCode);
+ }
+ const s = minor > 0 ? "+" : "−";
+ return `${s}${formatDashboardMoneyMinor(Math.abs(minor), currencyCode)}`;
+}
+
+export function useDashboardAnalytics({
+ enabled,
+ playOptions,
+}: {
+ enabled: boolean;
+ playOptions: { code: string; label: string }[];
+}) {
+ const { t } = useTranslation(["dashboard", "common"]);
+ const playLabel = useAdminPlayCodeLabel();
+
+ const [period, setPeriod] = useState
("last_7_days");
+ const [rankingMetric, setRankingMetric] = useState("bet");
+ const [playCode, setPlayCode] = useState("");
+ const [customFrom, setCustomFrom] = useState(() => format(subDays(new Date(), 6), "yyyy-MM-dd"));
+ const [customTo, setCustomTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
+
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [data, setData] = useState(null);
+
+ const load = useCallback(async () => {
+ if (!enabled) {
+ setLoading(false);
+ setData(null);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const payload = await getAdminDashboardAnalytics({
+ period,
+ metric: "overview",
+ play_code: playCode !== "" ? playCode : undefined,
+ ...(period === "custom" ? { date_from: customFrom, date_to: customTo } : {}),
+ });
+ setData(payload);
+ } catch (e) {
+ setData(null);
+ const raw = e instanceof LotteryApiBizError ? e.message : "";
+ const needsAuthSync =
+ raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
+ setError(
+ needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, [enabled, period, playCode, customFrom, customTo, t]);
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => {
+ void load();
+ }, 0);
+ return () => window.clearTimeout(timer);
+ }, [load]);
+
+ const currency = data?.currency_code ?? null;
+ const summary = data?.summary;
+
+ const periodRangeLabel = useMemo(() => {
+ if (!data) {
+ return null;
+ }
+ return data.date_from === data.date_to
+ ? data.date_from
+ : `${data.date_from} — ${data.date_to}`;
+ }, [data]);
+
+ const playFilterLabel = useMemo(() => {
+ if (playCode === "") {
+ return t("analytics.allPlays");
+ }
+ return playOptions.find((p) => p.code === playCode)?.label ?? playCode;
+ }, [playCode, playOptions, t]);
+
+ const resolvePlayLabel = useCallback(
+ (code: string, dimension: number) => {
+ const base = playLabel(code);
+ return dimension > 0 ? `${base} · ${dimension}D` : base;
+ },
+ [playLabel],
+ );
+
+ const topPlayRows = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+ const rows = [...data.play_breakdown];
+ rows.sort((a, b) => {
+ if (rankingMetric === "payout") {
+ return b.total_payout_minor - a.total_payout_minor;
+ }
+ if (rankingMetric === "profit") {
+ return b.approx_house_gross_minor - a.approx_house_gross_minor;
+ }
+ return b.total_bet_minor - a.total_bet_minor;
+ });
+ return rows.slice(0, 5);
+ }, [data, rankingMetric]);
+
+ const sparklines = useMemo(() => {
+ const series = data?.daily_series ?? [];
+ return {
+ bet: series.map((d) => d.total_bet_minor),
+ payout: series.map((d) => d.total_payout_minor),
+ profit: series.map((d) => d.approx_house_gross_minor),
+ };
+ }, [data?.daily_series]);
+
+ return {
+ enabled,
+ period,
+ setPeriod,
+ rankingMetric,
+ setRankingMetric,
+ playCode,
+ setPlayCode,
+ customFrom,
+ setCustomFrom,
+ customTo,
+ setCustomTo,
+ loading,
+ error,
+ data,
+ currency,
+ summary,
+ periodRangeLabel,
+ playFilterLabel,
+ playOptions,
+ resolvePlayLabel,
+ topPlayRows,
+ sparklines,
+ formatMoney: formatDashboardMoneyMinor,
+ formatSignedMoney: formatDashboardSignedMoneyMinor,
+ t,
+ };
+}
+
+export type DashboardAnalyticsState = ReturnType;
diff --git a/src/modules/draws/draw-review-console.tsx b/src/modules/draws/draw-review-console.tsx
index 2feb53a..9321447 100644
--- a/src/modules/draws/draw-review-console.tsx
+++ b/src/modules/draws/draw-review-console.tsx
@@ -1,12 +1,13 @@
"use client";
-import Link from "next/link";
+import { Rocket } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminDrawResultBatches, postAdminCreateManualResultBatch } from "@/api/admin-draws";
-import { Button, buttonVariants } from "@/components/ui/button";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
+import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
@@ -18,7 +19,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action";
-import { cn } from "@/lib/utils";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -204,7 +204,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
{t("batchId")}
{t("version", { version: "" }).replace(" v", "").trim()}
{t("numberCount")}
- {t("actions")}
+ {t("actions")}
@@ -215,12 +215,16 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
{b.items.length}
{canManageDraw ? (
-
- {t("reviewAndPublishAction")}
-
+
) : (
{t("noPublishPermission")}
)}
diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx
index 58158b1..20b175e 100644
--- a/src/modules/draws/draws-index-console.tsx
+++ b/src/modules/draws/draws-index-console.tsx
@@ -1,6 +1,6 @@
"use client";
-import Link from "next/link";
+import { Ban, Eye, Pencil, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -14,7 +14,8 @@ import {
} from "@/api/admin-draws";
import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale";
-import { Button, buttonVariants } from "@/components/ui/button";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
+import { Button } from "@/components/ui/button";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -404,7 +405,7 @@ export function DrawsIndexConsole() {
{t("betTotal")}
{t("payoutTotal")}
{t("profitLoss")}
- {t("actions")}
+ {t("actions")}
@@ -462,60 +463,29 @@ export function DrawsIndexConsole() {
: "—"}
-
-
- {t("viewDetails")}
-
- {canManageDraw && canEditDrawRow(row) ? (
-
- ) : null}
- {canManageDraw && canDeleteDrawRow(row) ? (
-
- ) : null}
- {canManageDraw &&
- canCancelDrawRow(row) &&
- !canDeleteDrawRow(row) ? (
-
- ) : null}
-
+ }),
+ },
+ {
+ key: "delete",
+ label: t("deleteDraw.action"),
+ icon: Trash2,
+ destructive: true,
+ hidden: !(canManageDraw && canDeleteDrawRow(row)),
+ onClick: () =>
+ requestConfirm({
+ title: t("deleteDraw.title"),
+ description: t("deleteDraw.description", { drawNo: row.draw_no }),
+ confirmVariant: "destructive",
+ onConfirm: async () => {
+ try {
+ await deleteAdminDraw(row.id);
+ toast.success(t("deleteDraw.success"));
+ await load();
+ } catch (e) {
+ toast.error(
+ e instanceof LotteryApiBizError
+ ? e.message
+ : t("deleteDraw.failed"),
+ );
+ }
+ },
+ }),
+ },
+ ]}
+ />
))
diff --git a/src/modules/integration/integration-sites-console.tsx b/src/modules/integration/integration-sites-console.tsx
index 1e6f550..ee9abff 100644
--- a/src/modules/integration/integration-sites-console.tsx
+++ b/src/modules/integration/integration-sites-console.tsx
@@ -1,5 +1,6 @@
"use client";
+import { Download, Link2, Pencil, ShieldAlert } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -14,6 +15,7 @@ import {
putAdminIntegrationSite,
} from "@/api/admin-integration-sites";
import { AdminPageCard } from "@/components/admin/admin-page-card";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import {
@@ -361,7 +363,7 @@ export function IntegrationSitesConsole() {
{t("integrationSites.columns.name")}
{t("integrationSites.columns.status")}
{t("integrationSites.columns.walletUrl")}
- {t("integrationSites.columns.actions")}
+ {t("integrationSites.columns.actions")}
@@ -381,46 +383,40 @@ export function IntegrationSitesConsole() {
{row.wallet_api_url ?? "—"}
-
-
-
-
- {canManage ? (
-
- ) : null}
- {canManage ? (
-
- ) : null}
-
+
+ openConnectivity(row),
+ },
+ {
+ key: "export",
+ label: t("integrationSites.exportParams"),
+ icon: Download,
+ disabled: exportBusyId === row.id,
+ onClick: () => void exportParameterSheet(row),
+ },
+ {
+ key: "edit",
+ label: t("integrationSites.edit"),
+ icon: Pencil,
+ hidden: !canManage,
+ onClick: () => void openEdit(row),
+ },
+ {
+ key: "rotate",
+ label: t("integrationSites.rotateSecrets"),
+ icon: ShieldAlert,
+ destructive: true,
+ hidden: !canManage,
+ onClick: () => setRotateTarget(row),
+ },
+ ]}
+ />
))}
diff --git a/src/modules/players/players-console.tsx b/src/modules/players/players-console.tsx
index 359b2b1..420d411 100644
--- a/src/modules/players/players-console.tsx
+++ b/src/modules/players/players-console.tsx
@@ -1,5 +1,6 @@
"use client";
+import { Pencil, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
@@ -16,6 +17,7 @@ import {
putAdminPlayer,
} from "@/api/admin-player";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
@@ -392,7 +394,7 @@ export function PlayersConsole(): React.ReactElement {
{t("available")}
{t("status")}
{t("lastLogin")}
- {t("actions")}
+ {t("actions")}
@@ -471,32 +473,25 @@ export function PlayersConsole(): React.ReactElement {
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
-
- {canManagePlayers || canFreezePlayers ? (
-
- {canManagePlayers ? (
- <>
-
-
- >
- ) : null}
-
+
+ {canManagePlayers ? (
+ openEditAccount(row),
+ },
+ {
+ key: "delete",
+ label: t("delete"),
+ icon: Trash2,
+ destructive: true,
+ onClick: () => setDeleteTarget(row),
+ },
+ ]}
+ />
) : (
—
)}
diff --git a/src/modules/reconcile/reconcile-console.tsx b/src/modules/reconcile/reconcile-console.tsx
index 0a1cbae..6fc557c 100644
--- a/src/modules/reconcile/reconcile-console.tsx
+++ b/src/modules/reconcile/reconcile-console.tsx
@@ -1,5 +1,6 @@
"use client";
+import { Eye } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -12,6 +13,7 @@ import {
import { getAdminPlayers } from "@/api/admin-player";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -373,7 +375,7 @@ export function ReconcileConsole(): React.ReactElement {
{t("status")}
{t("period")}
{t("createdAt")}
-
+
{t("operate")}
@@ -410,18 +412,20 @@ export function ReconcileConsole(): React.ReactElement {
{formatTs(row.created_at)}
-
+ {
+ setSelectedId(row.id);
+ setItemsPage(1);
+ setDetailOpen(true);
+ },
+ },
+ ]}
+ />
))
diff --git a/src/modules/reports/report-jobs-panel.tsx b/src/modules/reports/report-jobs-panel.tsx
index fc38ac9..3bcb981 100644
--- a/src/modules/reports/report-jobs-panel.tsx
+++ b/src/modules/reports/report-jobs-panel.tsx
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { Download, RefreshCw } from "lucide-react";
import { downloadAdminReportJob, getAdminReportJobs } from "@/api/admin-report-jobs";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -105,7 +106,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
{t("tasks.columns.format")}
{t("tasks.columns.status")}
{t("tasks.columns.createdAt")}
- {t("tasks.columns.actions")}
+ {t("tasks.columns.actions")}
@@ -135,17 +136,19 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
{formatTs(job.created_at ?? job.finished_at)}
-
-
+
+ void handleDownload(job),
+ },
+ ]}
+ />
))
diff --git a/src/modules/risk/risk-index-console.tsx b/src/modules/risk/risk-index-console.tsx
index a7258b6..5731291 100644
--- a/src/modules/risk/risk-index-console.tsx
+++ b/src/modules/risk/risk-index-console.tsx
@@ -1,14 +1,15 @@
"use client";
-import Link from "next/link";
+import { Shield } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { getAdminDraws } from "@/api/admin-draws";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
-import { Button, buttonVariants } from "@/components/ui/button";
+import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -29,7 +30,6 @@ import {
} from "@/components/ui/table";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
-import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
@@ -184,7 +184,7 @@ export function RiskIndexConsole() {
{t("drawNo")}
{t("status")}
{t("closeTime")}
- {t("actions")}
+ {t("actions")}
@@ -205,12 +205,16 @@ export function RiskIndexConsole() {
{row.close_time ? formatDt(row.close_time) : "—"}
-
- {t("enterRisk")}
-
+
))
diff --git a/src/modules/risk/risk-pools-console.tsx b/src/modules/risk/risk-pools-console.tsx
index 3b45f54..e62dbfd 100644
--- a/src/modules/risk/risk-pools-console.tsx
+++ b/src/modules/risk/risk-pools-console.tsx
@@ -1,6 +1,6 @@
"use client";
-import Link from "next/link";
+import { Eye, Lock, Unlock } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -11,9 +11,9 @@ import {
postAdminRiskPoolRecover,
} from "@/api/admin-risk";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Button } from "@/components/ui/button";
-import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -254,7 +254,7 @@ export function RiskPoolsConsole({
{t("remainingAmount")}
{t("usageRatio")}
{t("poolStatus")}
- {t("actions")}
+ {t("actions")}
@@ -302,39 +302,39 @@ export function RiskPoolsConsole({
-
- {canManageRiskPools ? (
-
- ) : null}
-
- {t("view")}
-
-
+ }),
+ },
+ ]}
+ />
);
diff --git a/src/modules/rules/rules-odds-config-screen.tsx b/src/modules/rules/rules-odds-config-screen.tsx
index 9a35783..d11b34f 100644
--- a/src/modules/rules/rules-odds-config-screen.tsx
+++ b/src/modules/rules/rules-odds-config-screen.tsx
@@ -1,26 +1,21 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { PRD_RULES_ODDS_ACCESS_ANY } from "@/lib/admin-prd";
import { ConfigDocPage } from "@/modules/config/config-doc-page";
-import { ConfigSection } from "@/modules/config/config-section";
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen";
import { useOddsConfigWorkspace } from "@/modules/config/use-odds-config-workspace";
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
-/** 赔率与回水:共用赔率版本线,单页上下分区。 */
+/** 赔率与回水:共用赔率版本线,主栏三步骤 + 右侧配置摘要。 */
export function RulesOddsConfigScreen() {
const { t } = useTranslation("config");
const [sharedVersionId, setSharedVersionId] = useState("");
const workspace = useOddsConfigWorkspace(sharedVersionId, setSharedVersionId);
- const rebateSectionRef = useRef(null);
- const [rebateMounted, setRebateMounted] = useState(
- () => typeof window !== "undefined" && window.location.hash === "#rebate",
- );
useEffect(() => {
const scrollToRebate = () => {
@@ -34,44 +29,26 @@ export function RulesOddsConfigScreen() {
return () => window.removeEventListener("hashchange", scrollToRebate);
}, []);
- useEffect(() => {
- if (rebateMounted) {
- return;
- }
- const node = rebateSectionRef.current;
- if (!node) {
- return;
- }
- const observer = new IntersectionObserver(
- ([entry]) => {
- if (entry?.isIntersecting) {
- setRebateMounted(true);
- }
- },
- { rootMargin: "240px 0px" },
- );
- observer.observe(node);
- return () => observer.disconnect();
- }, [rebateMounted]);
+ const rebateSection = (
+
+
+
+ );
return (
-
-
-
-
-
-
- {rebateMounted ? (
-
- ) : (
-
- {t("rebate.lazyLoadHint", { ns: "config" })}
-
- )}
-
-
+
+
diff --git a/src/modules/settings/currency-settings-panel.tsx b/src/modules/settings/currency-settings-panel.tsx
index 937dd11..288a3ee 100644
--- a/src/modules/settings/currency-settings-panel.tsx
+++ b/src/modules/settings/currency-settings-panel.tsx
@@ -1,5 +1,6 @@
"use client";
+import { Pencil, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
@@ -12,6 +13,7 @@ import {
putAdminCurrency,
} from "@/api/admin-currencies";
import { AdminPageCard } from "@/components/admin/admin-page-card";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
@@ -228,7 +230,7 @@ export function CurrencySettingsPanel() {
{t("currencies.table.decimals", { ns: "config" })}
{t("currencies.table.enabled", { ns: "config" })}
{t("currencies.table.bettable", { ns: "config" })}
- {t("currencies.table.actions", { ns: "config" })}
+ {t("currencies.table.actions", { ns: "config" })}
@@ -267,14 +269,23 @@ export function CurrencySettingsPanel() {
-
-
-
-
+ openEdit(row),
+ },
+ {
+ key: "delete",
+ label: t("currencies.actions.delete", { ns: "config" }),
+ icon: Trash2,
+ destructive: true,
+ onClick: () => setDeleteTarget(row),
+ },
+ ]}
+ />
))
diff --git a/src/modules/settlement/settlement-batches-console.tsx b/src/modules/settlement/settlement-batches-console.tsx
index 90b0b23..ad38e8f 100644
--- a/src/modules/settlement/settlement-batches-console.tsx
+++ b/src/modules/settlement/settlement-batches-console.tsx
@@ -1,6 +1,6 @@
"use client";
-import Link from "next/link";
+import { Check, Eye, HandCoins, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
@@ -13,10 +13,11 @@ import {
postAdminRejectSettlementBatch,
} from "@/api/admin-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
-import { Button, buttonVariants } from "@/components/ui/button";
+import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
@@ -287,51 +288,45 @@ export function SettlementBatchesConsole() {
{settlementStatusText(row.status, t)}
-
-
-
- {t("details")}
-
- {canReviewSettlement ? (
-
- ) : null}
- {canReviewSettlement ? (
-
- ) : null}
- {canManagePayout ? (
-
- ) : null}
-
+ || row.review_status !== "approved",
+ onClick: () => openActionDialog(row, "payout"),
+ },
+ ]}
+ />
))}
diff --git a/src/modules/wallet/wallet-console.tsx b/src/modules/wallet/wallet-console.tsx
index e51d484..bc7bed1 100644
--- a/src/modules/wallet/wallet-console.tsx
+++ b/src/modules/wallet/wallet-console.tsx
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
-import { Copy, Loader2, MoreHorizontal } from "lucide-react";
+import { Copy, RotateCcw, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -15,16 +15,10 @@ import {
} from "@/api/admin-wallet";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
+import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button, buttonVariants } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -278,55 +272,34 @@ function TransferOrderRowActions({
onManualProcess,
t,
}: TransferOrderRowActionsProps): React.ReactElement {
- const showComplete = canCompleteTransferInCredit(row, canWriteWallet);
- const showReverse = canReverseTransferOrder(row, canWriteWallet);
- const showManual = canManuallyProcessTransferOrder(row, canWriteWallet);
-
- if (!showComplete && !showReverse && !showManual) {
- return —;
- }
-
return (
-
-
- {busy ? (
-
- ) : (
-
- )}
-
-
- {showComplete ? (
- onCompleteCredit(row.transfer_no)}>
- {t("completeCredit")}
-
- ) : null}
- {showManual ? (
- onManualProcess(row.transfer_no)}>
- {t("manualProcess")}
-
- ) : null}
- {showReverse ? (
- <>
- {showComplete || showManual ? : null}
- onReverse(row.transfer_no)}
- >
- {t("reverse")}
-
- >
- ) : null}
-
-
+ onCompleteCredit(row.transfer_no),
+ },
+ {
+ key: "manual",
+ label: t("manualProcess"),
+ icon: Wrench,
+ hidden: !canManuallyProcessTransferOrder(row, canWriteWallet),
+ onClick: () => onManualProcess(row.transfer_no),
+ },
+ {
+ key: "reverse",
+ label: t("reverse"),
+ icon: RotateCcw,
+ destructive: true,
+ hidden: !canReverseTransferOrder(row, canWriteWallet),
+ onClick: () => onReverse(row.transfer_no),
+ },
+ ]}
+ />
);
}
diff --git a/src/stores/admin-session.ts b/src/stores/admin-session.ts
index 21b0c50..6f19bcf 100644
--- a/src/stores/admin-session.ts
+++ b/src/stores/admin-session.ts
@@ -4,11 +4,14 @@
* - **组件内**:`useAdminProfile()`、`useAdminSessionStore(...)`
* - **组件外**(axios、工具函数):`getAdminProfile()`、`useAdminSessionStore.getState()`
*/
-import { isAxiosError } from "axios";
import { create } from "zustand";
-import { getAdminMe } from "@/api/admin-auth";
+import { fetchAdminMeDeduped } from "@/lib/admin-fetch-me";
import { setAdminBearerToken } from "@/lib/admin-auth";
+import {
+ handleAdminAuthRejected,
+ isAdminAuthRejected,
+} from "@/lib/admin-auth-reject";
import { readProfile, writeProfile } from "@/stores/admin-profile";
import { readToken, writeToken } from "@/stores/admin-token";
import type { AdminProfile } from "@/types/api/admin-auth";
@@ -25,28 +28,12 @@ function profileForRehydrate(profile: AdminProfile | null): AdminProfile | null
};
}
-function isAdminAuthRejected(err: unknown): boolean {
- if (!isAxiosError(err)) {
- return false;
- }
-
- const status = err.response?.status;
- if (status === 401 || status === 403) {
- return true;
- }
-
- const body = err.response?.data;
- if (body && typeof body === "object" && "code" in body) {
- const code = (body as { code?: unknown }).code;
- return code === 401 || code === 403;
- }
-
- return false;
-}
-
export type AdminSessionState = {
bearerToken: string | null;
adminProfile: AdminProfile | null;
+ /** Shell 路由守卫正在校验 `/auth/me` 时为 true,用于侧栏/顶栏骨架屏 */
+ shellAuthPending: boolean;
+ setShellAuthPending: (pending: boolean) => void;
setBearerToken: (token: string | null) => void;
setAdminProfile: (profile: AdminProfile | null) => void;
clearSession: () => void;
@@ -60,6 +47,11 @@ export type AdminSessionState = {
export const useAdminSessionStore = create((set, get) => ({
bearerToken: null,
adminProfile: null,
+ shellAuthPending: false,
+
+ setShellAuthPending: (pending) => {
+ set({ shellAuthPending: pending });
+ },
setBearerToken: (token) => {
const normalized = token?.trim() ? token.trim() : null;
@@ -109,12 +101,12 @@ export const useAdminSessionStore = create((set, get) => ({
}
try {
- const result = await getAdminMe();
+ const result = await fetchAdminMeDeduped();
writeProfile(result.admin);
set({ adminProfile: result.admin });
} catch (err) {
if (isAdminAuthRejected(err)) {
- get().clearSession();
+ handleAdminAuthRejected();
return;
}
diff --git a/src/stores/admin-token.ts b/src/stores/admin-token.ts
index 8e3de4f..3cf31f6 100644
--- a/src/stores/admin-token.ts
+++ b/src/stores/admin-token.ts
@@ -1,21 +1,33 @@
-const KEY = "lottery_admin_token";
+import { ADMIN_TOKEN_STORAGE_KEY } from "@/lib/admin-token-constants";
+import {
+ readAdminTokenFromDocumentCookie,
+ writeAdminTokenCookie,
+} from "@/lib/admin-token-cookie";
export function readToken(): string | null {
if (typeof window === "undefined") {
return null;
}
- const raw = window.localStorage.getItem(KEY)?.trim();
- return raw && raw !== "" ? raw : null;
+ const fromStorage = window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim();
+ if (fromStorage) {
+ return fromStorage;
+ }
+
+ return readAdminTokenFromDocumentCookie();
}
export function writeToken(t: string | null): void {
if (typeof window === "undefined") {
return;
}
+
if (t && t.trim() !== "") {
- window.localStorage.setItem(KEY, t.trim());
+ const normalized = t.trim();
+ window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, normalized);
+ writeAdminTokenCookie(normalized);
} else {
- window.localStorage.removeItem(KEY);
+ window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
+ writeAdminTokenCookie(null);
}
}