diff --git a/AGENTS.md b/AGENTS.md index e006961..ab21acd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,18 +34,19 @@ This version has breaking changes — APIs, conventions, and file structure may - 占成/授信/回水/上限等数值字段用 `AdminNumericStepper`(± 步进 + 可手输),勿单独裸 `input[type=number]`。 - 对外文档(接入 + 后台运营手册)禁用 RBAC slug(如 `prd.settlement.agent.manage`);对客户称「贵司」;排版忌 AI 感,正文对比度与字号可读优先。 - 文档 i18n:`useTranslation` 须显式 `ns`;`returnObjects` 列表用 `Array.isArray` 守卫;避免节名与表头 key 冲突(如 `billStatus`)。 +- 运营页少堆 `text-xs` 说明与多层免责条;口径/默认范围合并进各模块一行 `summary`,勿叠加 filterPanel/queryHint/disclaimer 等小字(对账、报表中心已按此精简)。 +- i18n 按语言懒加载,不要一次 import 三语全套。 ## Learned Workspace Facts - 无接入站时依赖站点的页面展示 ``;仅 `profile.is_super_admin` 显示创建入口。 - 超管判定用登录态 `is_super_admin`,勿用站点角色或 `admin_user_site_roles` 绑定推断。 -- 站点管理员(`profile.site != null`)代理 UI 绕过选中代理的 `can_create_*` 门控,按自身 manage 权限展示 Tab/操作。 -- 站点管理员在代理下创建玩家须传 `agent_node_id`(与超管同逻辑),勿默认挂根代理。 -- 客户对外文档:`/docs`(首页)、`/docs/integration`(API 接入)、`/docs/admin`(后台运营手册),均公开免 `/admin` 登录;读者为接入方技术与站点运营/代理,非内部运维;旧 `/admin/docs/integration-guide` 重定向 `/docs/integration`;顶栏「管理后台」链 `/admin`(勿 `/admin/login`)。 +- 站点管理员(`profile.site != null`)代理 UI 绕过选中代理的 `can_create_*` 门控,按自身 manage 权限展示 Tab/操作;在代理下创建玩家须传 `agent_node_id`,勿默认挂根代理。 +- 客户对外文档:`/docs`、`/docs/integration`、`/docs/admin` 公开免登录;读者为接入方与站点运营/代理;顶栏「管理后台」链 `/admin`;SSO 无登录换票、JWT+`player/me`、iframe `data.token`;Docs 侧栏 `--docs-sticky-top`;静态校验用 `document.body.innerText`。 - `SettlementBillRow` 无 `currency_code`;账单金额展示用玩家 `default_currency`。 - 浏览器 `/api/v1/*` 由 Next 转发到 `LOTTERY_API_UPSTREAM`;与本地 Postgres 对账前须确认 upstream 与所查库一致。 -- 接入文档页静态内容校验用 `document.body.innerText`;`DocCode` 非裸 `
/`,勿只查 code 选择器。
-- Docs 侧栏粘性定位用 CSS 变量 `--docs-sticky-top`(含顶栏/header 偏移)。
 - Tanumo 联调/生产默认:H5 `https://front.tanumo.com`、API `https://lotterylaravel.tanumo.com`、管理端/文档 `https://lotteryadmin.tanumo.com`。
-- 接入文档 SSO:无「登录换票」;主站 JWT 直传 H5/iframe,`GET /api/v1/player/me` 自动建档;iframe 约定 token 在顶层 `data.token`;勿引用 main-site/monorepo 等内部仓库路径。
 - 风控页默认:风险池仅显示有占用/高风险;单号详情可看占用来源;组合明细在注单详情页。
+- 模块边界:对账中心=主站↔彩票钱包划转;报表中心=钱包盘经营分析;结算中心=信用账期占成/收付。
+- 报表中心:默认近 30 天;盈亏类按 `draws.business_date`;金额读 API `currency_code`;导出仅后端 `report-jobs` 全量(按筛选,无需先预览);注单盈亏≠信用账期结算。
+- 对账中心:校验 `transfer_orders` vs 彩票 `wallet_txns`;差异 B 侧引用=彩票 `wallet_txns` 流水号;补单/冲正在钱包转账单页。
diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json
index 7fbecfd..dff0128 100644
--- a/src/i18n/locales/en/dashboard.json
+++ b/src/i18n/locales/en/dashboard.json
@@ -77,6 +77,7 @@
   "todayPayout": "Today's payout",
   "todayProfit": "Today's profit",
   "todayBusinessDateHint": "Business date {{date}}",
+  "todayPayoutHint": "Payout {{amount}}",
   "drawNoHint": "Draw {{drawNo}}",
   "orderAndTicket": "{{orders}} orders · {{tickets}} items",
   "marginRate": "Gross margin ~{{rate}}%",
@@ -94,6 +95,9 @@
   "batchPendingDraws": "Draws involved",
   "batchPendingDrawsCount": "{{count}} draws pending",
   "platformLockedAndCap": "Site locked {{locked}} / cap {{cap}}",
+  "platformLockedLabel": "Locked",
+  "platformCapLabel": "Cap",
+  "platformCapUnset": "Not set",
   "platformCapNotConfigured": "Site locked {{locked}} · cap not configured",
   "platformOrderAndTicket": "Site-wide {{orders}} orders · {{tickets}} lines",
   "platformBetTotal": "Lifetime bet",
@@ -162,6 +166,8 @@
     "todayBet": "Today's bets",
     "todayProfit": "Today's P/L",
     "sevenDayTitle": "Last 7 days",
+    "sevenDayBet": "7-day bets",
+    "sevenDayPayout": "7-day payout",
     "sevenDayProfit": "7-day P/L",
     "profitScopeHint": "Site scope: bets minus payouts",
     "activePlayersToday": "Active players today",
@@ -174,6 +180,8 @@
     "agentCount": "Agent nodes",
     "playerCount": "Players",
     "topAgentToday": "Top agent today: {{name}} ({{amount}})",
+    "topAgentTodayLabel": "Top agent today",
+    "overviewEmpty": "No site operations data. Confirm the integration site is bound.",
     "quickLinks": {
       "tickets": "Tickets",
       "players": "Players",
@@ -194,6 +202,7 @@
     "creditAllocatedLabel": "Allocated credit",
     "creditUsedLabel": "Used credit",
     "shareRate": "Total share {{rate}}%",
+    "shareProfitScopeHint": "Share profit at this node (from bet share_snapshot)",
     "settlementCycle": "Cycle {{cycle}}",
     "teamTitle": "Team size",
     "directChildren": "Direct child agents",
@@ -208,8 +217,11 @@
     "todayProfit": "Today's profit",
     "todayShareProfit": "Today's share profit",
     "sevenDayTitle": "Last 7 days",
+    "sevenDayBet": "7-day bets",
     "sevenDayPayout": "Payout {{amount}}",
+    "sevenDayPayoutLabel": "7-day payout",
     "sevenDayProfit": "Profit {{amount}}",
+    "sevenDayShareProfitLabel": "7-day share profit",
     "sevenDayShareProfit": "Share profit {{amount}}",
     "pendingBills": "Open agent bills",
     "pendingUnpaid": "Unpaid total {{amount}}",
@@ -230,6 +242,7 @@
     "no": "No",
     "viewBills": "View bills",
     "lineMeta": "Depth {{depth}} · child agents {{childAgent}} · players {{player}}",
+    "overviewEmpty": "No line operations data. Confirm the agent binding is valid.",
     "viewLine": "Agent line",
     "quickLinks": {
       "tickets": "Tickets",
@@ -242,6 +255,7 @@
   "warnings": {
     "drawPermission": "This account has no draw/dashboard view permission. Finance and risk data were not returned.",
     "walletPermission": "This account has no wallet reconciliation permission. Abnormal transfer count was not returned.",
+    "analyticsUnavailable": "Trend and ranking analytics are unavailable. KPI cards above remain visible.",
     "loadFailed": "Failed to load. Check the API and login state.",
     "apiResourceMissing": "Dashboard analytics API is not registered. Run: php artisan lottery:admin-auth-sync (or apply the latest migration), then refresh."
   }
diff --git a/src/i18n/locales/en/reports.json b/src/i18n/locales/en/reports.json
index d23c019..a262e3b 100644
--- a/src/i18n/locales/en/reports.json
+++ b/src/i18n/locales/en/reports.json
@@ -13,7 +13,7 @@
   "exportSuccess": "Exported {{report}} ({{format}})",
   "exportServerSuccess": "Job {{jobNo}} created and downloaded {{report}} ({{format}})",
   "exportFailed": "Export failed",
-  "exportHint": "Server exports use the current filters to build a full file. Completed jobs can be downloaded again below.",
+  "exportHint": "Export uses the current filters to build the full dataset. Preview query is not required.",
   "exportServerHint": "Full export via server job (preview query not required)",
   "exportClientHint": "Export current preview page (run query first)",
   "validation": {
@@ -281,19 +281,15 @@
     },
     "daily_profit": {
       "title": "Daily P&L summary",
-      "summary": "Summarize bet amount, payout, and house P&L by business date. Refund and standalone net amount are not included yet."
+      "summary": "Betting P&L by business date; defaults to the last 30 days. Ticket amounts — not credit-line period settlement."
     },
     "player_win_loss": {
       "title": "Player win/loss report",
-      "summary": "Track player win/loss over a selected period for finance and support review."
-    },
-    "profit_reports": {
-      "disclaimer": "These reports use ticket bet/win amounts (betting results), not credit-line period settlement. For share, rebate, and collections, use Settlement Center → Period reports."
+      "summary": "Win/loss by player and business date; defaults to the last 30 days. Ticket amounts — not credit-line settlement."
     },
     "player_transfer": {
       "title": "Player transfer report",
-      "summary": "Review player transfers in, transfers out, reversals, and exception handling.",
-      "disclaimer": "Main-site wallet transfer orders only (wallet-mode players). Credit-line players have no such transfers — use Settlement Center for period ledger."
+      "summary": "Wallet-mode main-site transfers by record time; defaults to the last 30 days."
     },
     "hot_number_risk": {
       "title": "Hot number risk report",
@@ -301,7 +297,7 @@
     },
     "play_dimension": {
       "title": "Play dimension report",
-      "summary": "Break down betting volume, payout, rebate, and P&L structure by play."
+      "summary": "P&L by play and business date; defaults to the last 30 days. Ticket amounts — not credit-line settlement."
     },
     "sold_out_number": {
       "title": "Sold-out number report",
@@ -309,12 +305,11 @@
     },
     "rebate_commission": {
       "title": "Commission / rebate report",
-      "summary": "Summarize commission, rebate, and matched rules by play and period.",
-      "disclaimer": "Wallet-mode instant rebate/commission — not agent credit-line period settlement. Use Agent → Settlement bills for credit-line reports."
+      "summary": "Wallet-mode instant rebate by business date; defaults to the last 30 days. Not credit-line period settlement."
     },
     "admin_audit": {
       "title": "Admin operation audit report",
-      "summary": "Export key admin operation traces by operator and period."
+      "summary": "Admin actions by operator and record time; defaults to the last 30 days."
     }
   }
 }
diff --git a/src/i18n/locales/ne/dashboard.json b/src/i18n/locales/ne/dashboard.json
index 8a5bff4..6cd5a7a 100644
--- a/src/i18n/locales/ne/dashboard.json
+++ b/src/i18n/locales/ne/dashboard.json
@@ -75,6 +75,7 @@
   "todayPayout": "आजको भुक्तानी",
   "todayProfit": "आजको नाफा/नोक्सान",
   "todayBusinessDateHint": "व्यापार मिति {{date}}",
+  "todayPayoutHint": "भुक्तानी {{amount}}",
   "drawNoHint": "ड्रअ {{drawNo}}",
   "orderAndTicket": "{{orders}} अर्डर · {{tickets}} वस्तु",
   "marginRate": "सकल मार्जिन ~{{rate}}%",
@@ -92,6 +93,9 @@
   "batchPendingDraws": "सम्बन्धित ड्रअ",
   "batchPendingDrawsCount": "{{count}} ड्रअ पेन्डिङ",
   "platformLockedAndCap": "साइट लक {{locked}} / क्याप {{cap}}",
+  "platformLockedLabel": "लक",
+  "platformCapLabel": "क्याप",
+  "platformCapUnset": "सेट छैन",
   "platformCapNotConfigured": "साइट लक {{locked}} · क्याप कन्फिगर गरिएको छैन",
   "platformOrderAndTicket": "साइटव्यापी {{orders}} अर्डर · {{tickets}} लाइन",
   "platformBetTotal": "जम्मा बेट",
@@ -159,6 +163,8 @@
     "todayBet": "आजको बाजी",
     "todayProfit": "आजको नाफा/नोक्सान",
     "sevenDayTitle": "पछिल्लो ७ दिन",
+    "sevenDayBet": "७-दिने बाजी",
+    "sevenDayPayout": "७-दिने भुक्तानी",
     "sevenDayProfit": "७-दिने नाफा/नोक्सान",
     "profitScopeHint": "साइट दायरा: बाजी माइनस भुक्तानी",
     "activePlayersToday": "आज सक्रिय खेलाडी",
@@ -171,6 +177,8 @@
     "agentCount": "एजेन्ट नोड",
     "playerCount": "खेलाडी संख्या",
     "topAgentToday": "आजको शीर्ष एजेन्ट: {{name}} ({{amount}})",
+    "topAgentTodayLabel": "आजको शीर्ष एजेन्ट",
+    "overviewEmpty": "साइट सञ्चालन डाटा छैन। कृपया साइट बाइन्डिङ जाँच गर्नुहोस्।",
     "quickLinks": {
       "tickets": "टिकट",
       "players": "खेलाडी",
@@ -179,9 +187,41 @@
       "bills": "सेटलमेन्ट"
     }
   },
+  "agent": {
+    "title": "सञ्चालन सारांश",
+    "subtitle": "{{name}} · यो लाइन",
+    "creditTitle": "क्रेडिट सीमा",
+    "creditAvailable": "उपलब्ध {{amount}}",
+    "creditAllocatedLabel": "बाँडिएको क्रेडिट",
+    "creditUsedLabel": "प्रयोग भएको क्रेडिट",
+    "shareRate": "कुल शेयर {{rate}}%",
+    "shareProfitScopeHint": "यस नोडको शेयर नाफा (share_snapshot)",
+    "teamTitle": "टोली परिमाण",
+    "directChildren": "प्रत्यक्ष सन्तान एजेन्ट",
+    "directPlayers": "प्रत्यक्ष खेलाडी",
+    "subtreeAgents": "लाइन एजेन्ट",
+    "teamPlayers": "लाइन खेलाडी",
+    "activePlayersToday": "आज सक्रिय खेलाडी",
+    "betOrdersTodayHint": "आज {{count}} अर्डर",
+    "todayBet": "आजको बाजी",
+    "todayShareProfit": "आजको शेयर नाफा",
+    "sevenDayTitle": "पछिल्लो ७ दिन",
+    "sevenDayBet": "७-दिने बाजी",
+    "sevenDayPayoutLabel": "७-दिने भुक्तानी",
+    "sevenDayShareProfitLabel": "७-दिने शेयर नाफा",
+    "pendingBills": "बाँकी बिल",
+    "pendingUnpaid": "नतिरेको {{amount}}",
+    "latestBetAt": "पछिल्लो बाजी {{time}}",
+    "noBetToday": "आज अहिलेसम्म बाजी छैन",
+    "yes": "हो",
+    "no": "होइन",
+    "lineMeta": "गहिराइ {{depth}} · सन्तान एजेन्ट {{childAgent}} · खेलाडी {{player}}",
+    "overviewEmpty": "लाइन सञ्चालन डाटा छैन। एजेन्ट बाइन्डिङ जाँच गर्नुहोस्।"
+  },
   "warnings": {
     "drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।",
     "walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।",
+    "analyticsUnavailable": "ट्रेन्ड/र्याङ्किङ विश्लेषण उपलब्ध छैन। माथिको KPI हेर्न सकिन्छ।",
     "loadFailed": "लोड असफल भयो। API र लगइन अवस्था जाँच गर्नुहोस्।"
   }
 }
diff --git a/src/i18n/locales/ne/reports.json b/src/i18n/locales/ne/reports.json
index bf3208c..a7164e4 100644
--- a/src/i18n/locales/ne/reports.json
+++ b/src/i18n/locales/ne/reports.json
@@ -13,7 +13,7 @@
   "exportSuccess": "{{report}} ({{format}}) निर्यात भयो",
   "exportServerSuccess": "कार्य {{jobNo}} सिर्जना भई {{report}} ({{format}}) डाउनलोड भयो",
   "exportFailed": "निर्यात असफल भयो",
-  "exportHint": "सर्भर निर्यातले हालका फिल्टरअनुसार पूर्ण फाइल बनाउँछ। सम्पन्न कार्य तलबाट पुन: डाउनलोड गर्न सकिन्छ।",
+  "exportHint": "हालका फिल्टरअनुसार पूर्ण डाटा निर्यात; पूर्वावलोकन क्वेरी अनिवार्य छैन।",
   "exportServerHint": "पूर्ण निर्यात सर्भर कार्यबाट (पूर्वावलोकन क्वेरी अनिवार्य छैन)",
   "exportClientHint": "हालको पूर्वावलोकन पृष्ठ निर्यात (पहिले क्वेरी चलाउनुहोस्)",
   "validation": {
@@ -278,19 +278,15 @@
     },
     "daily_profit": {
       "title": "दैनिक P&L सारांश",
-      "summary": "व्यावसायिक मितिअनुसार बेट रकम, पेआउट र हाउस P&L सारांश गर्नुहोस्। रिफन्ड र छुट्टै नेट रकम अहिले समावेश छैन।"
+      "summary": "व्यावसायिक मितिअनुसार बेट P&L; मिति नचयेमा पछिल्लो ३० दिन। टिकट रकम — क्रेडिट-लाइन सेटलमेन्ट होइन।"
     },
     "player_win_loss": {
       "title": "खेलाडी जित/हार रिपोर्ट",
-      "summary": "चयन गरिएको अवधिमा खेलाडीको जित/हार वित्त र सपोर्टका लागि हेर्नुहोस्।"
-    },
-    "profit_reports": {
-      "disclaimer": "यी रिपोर्ट टिकट बेट/जित रकम (बेटिङ नतिजा) मा आधारित छन्, क्रेडिट-लाइन अवधि सेटलमेन्ट होइन। शेयर, रिबेट र सङ्कलनका लागि सेटलमेन्ट सेन्टर → अवधि रिपोर्ट प्रयोग गर्नुहोस्।"
+      "summary": "खेलाडी र व्यावसायिक मितिअनुसार जित/हार; मिति नचयेमा पछिल्लो ३० दिन।"
     },
     "player_transfer": {
       "title": "खेलाडी ट्रान्सफर रिपोर्ट",
-      "summary": "खेलाडी ट्रान्सफर इन, आउट, रिभर्सल र अपवाद रेकर्ड हेर्नुहोस्।",
-      "disclaimer": "मुख्य साइट वालेट ट्रान्सफर मात्र (वालेट-मोड खेलाडी)। क्रेडिट-लाइन खेलाडीमा यस्तो ट्रान्सफर हुँदैन — अवधि लेजरका लागि सेटलमेन्ट सेन्टर प्रयोग गर्नुहोस्।"
+      "summary": "वालेट-मोड मुख्य साइट ट्रान्सफर, रेकर्ड समय अनुसार; मिति नचयेमा पछिल्लो ३० दिन।"
     },
     "hot_number_risk": {
       "title": "हट नम्बर जोखिम रिपोर्ट",
@@ -298,7 +294,7 @@
     },
     "play_dimension": {
       "title": "खेल आयाम रिपोर्ट",
-      "summary": "खेल अनुसार बेट भोल्युम, पेआउट, रिबेट र P&L संरचना छुट्याउनुहोस्।"
+      "summary": "खेल र व्यावसायिक मितिअनुसार P&L; मिति नचयेमा पछिल्लो ३० दिन।"
     },
     "sold_out_number": {
       "title": "सोल्ड-आउट नम्बर रिपोर्ट",
@@ -306,11 +302,11 @@
     },
     "rebate_commission": {
       "title": "कमिसन / रिबेट रिपोर्ट",
-      "summary": "खेल र अवधिअनुसार कमिसन, रिबेट र मिलेको नियम सारांश गर्नुहोस्।"
+      "summary": "वालेट-मोड तत्काल रिबेट, व्यावसायिक मितिअनुसार; मिति नचयेमा पछिल्लो ३० दिन।"
     },
     "admin_audit": {
       "title": "एडमिन अपरेशन अडिट रिपोर्ट",
-      "summary": "अपरेटर र अवधिअनुसार मुख्य एडमिन अपरेशन ट्रेस निर्यात गर्नुहोस्।"
+      "summary": "अपरेटर र रेकर्ड समय अनुसार; मिति नचयेमा पछिल्लो ३० दिन।"
     }
   }
 }
diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json
index 0ca9ed2..adf1a12 100644
--- a/src/i18n/locales/zh/dashboard.json
+++ b/src/i18n/locales/zh/dashboard.json
@@ -77,6 +77,7 @@
   "todayPayout": "今日派彩",
   "todayProfit": "今日盈亏",
   "todayBusinessDateHint": "业务日 {{date}}",
+  "todayPayoutHint": "派彩 {{amount}}",
   "drawNoHint": "期号 {{drawNo}}",
   "orderAndTicket": "{{orders}} 单 · {{tickets}} 笔",
   "marginRate": "毛利率约 {{rate}}%",
@@ -94,6 +95,9 @@
   "batchPendingDraws": "涉及期数",
   "batchPendingDrawsCount": "{{count}} 期待审",
   "platformLockedAndCap": "全站已占用 {{locked}} / 封顶 {{cap}}",
+  "platformLockedLabel": "已占用",
+  "platformCapLabel": "封顶",
+  "platformCapUnset": "未配置",
   "platformCapNotConfigured": "全站已占用 {{locked}} · 尚未配置封顶",
   "platformOrderAndTicket": "全站 {{orders}} 单 · {{tickets}} 笔",
   "platformBetTotal": "累计投注",
@@ -162,6 +166,8 @@
     "todayBet": "今日下注",
     "todayProfit": "今日盈亏",
     "sevenDayTitle": "近 7 天走势",
+    "sevenDayBet": "近 7 天下注",
+    "sevenDayPayout": "近 7 天派彩",
     "sevenDayProfit": "近 7 天盈亏",
     "profitScopeHint": "站点口径:投注减派彩(不含占成拆分)",
     "activePlayersToday": "今日活跃玩家",
@@ -174,6 +180,8 @@
     "agentCount": "代理节点",
     "playerCount": "玩家总数",
     "topAgentToday": "今日投注最高代理:{{name}}({{amount}})",
+    "topAgentTodayLabel": "今日投注最高代理",
+    "overviewEmpty": "暂无站点经营数据,请确认已绑定接入站点。",
     "quickLinks": {
       "tickets": "注单查询",
       "players": "玩家管理",
@@ -194,6 +202,7 @@
     "creditAllocatedLabel": "已下发额度",
     "creditUsedLabel": "已占用额度",
     "shareRate": "总占成 {{rate}}%",
+    "shareProfitScopeHint": "本级占成口径(按注单 share_snapshot)",
     "settlementCycle": "账期 {{cycle}}",
     "teamTitle": "团队规模",
     "directChildren": "直属下级代理",
@@ -208,8 +217,11 @@
     "todayProfit": "今日盈亏",
     "todayShareProfit": "今日本级占成",
     "sevenDayTitle": "近 7 天走势",
+    "sevenDayBet": "近 7 天下注",
     "sevenDayPayout": "派彩 {{amount}}",
+    "sevenDayPayoutLabel": "近 7 天派彩",
     "sevenDayProfit": "盈亏 {{amount}}",
+    "sevenDayShareProfitLabel": "近 7 日本级占成",
     "sevenDayShareProfit": "本级占成 {{amount}}",
     "pendingBills": "待结代理账单",
     "pendingUnpaid": "未结合计 {{amount}}",
@@ -230,6 +242,7 @@
     "no": "否",
     "viewBills": "查看账单",
     "lineMeta": "层级 {{depth}} · 可开下级 {{childAgent}} · 可开玩家 {{player}}",
+    "overviewEmpty": "暂无线路经营数据,请确认代理绑定有效。",
     "viewLine": "代理线路",
     "quickLinks": {
       "tickets": "注单查询",
@@ -242,6 +255,7 @@
   "warnings": {
     "drawPermission": "当前账号无开奖/仪表盘查看权限,财务与风控数据未返回。",
     "walletPermission": "当前账号无钱包对账查看权限,异常转账计数未返回。",
+    "analyticsUnavailable": "暂无趋势与排行分析权限,上方 KPI 仍可查看。",
     "loadFailed": "加载失败,请检查 API 与登录状态。",
     "apiResourceMissing": "仪表盘分析接口未注册。请在服务端执行:php artisan lottery:admin-auth-sync,或运行最新数据库迁移后重试。"
   }
diff --git a/src/i18n/locales/zh/reports.json b/src/i18n/locales/zh/reports.json
index 156abb4..fae9a92 100644
--- a/src/i18n/locales/zh/reports.json
+++ b/src/i18n/locales/zh/reports.json
@@ -13,7 +13,7 @@
   "exportSuccess": "已导出 {{report}}({{format}})",
   "exportServerSuccess": "已生成任务 {{jobNo}} 并下载 {{report}}({{format}})",
   "exportFailed": "导出失败",
-  "exportHint": "服务端导出会按当前筛选条件生成全量文件;任务完成后可在下方列表再次下载。",
+  "exportHint": "导出按当前筛选条件生成全量数据,无需先查询预览。",
   "exportServerHint": "全量导出走服务端任务(无需先查询预览)",
   "exportClientHint": "导出当前预览页数据(需先查询)",
   "validation": {
@@ -281,19 +281,15 @@
     },
     "daily_profit": {
       "title": "每日盈亏汇总",
-      "summary": "按业务日汇总投注、派彩与平台盈亏,当前不包含退款与单独净额字段。"
+      "summary": "按业务日汇总投注与盈亏;未选日期默认近 30 天。注单口径,非信用账期结算。"
     },
     "player_win_loss": {
       "title": "玩家输赢报表",
-      "summary": "按玩家和时间段追踪输赢表现,适合客服与财务复核。"
-    },
-    "profit_reports": {
-      "disclaimer": "本组报表按注单下注/中奖金额统计投注结果,不等同于信用占成盘账期结算。占成、回水与收付请使用「结算中心 → 账期报表」。"
+      "summary": "按业务日与玩家汇总输赢;未选日期默认近 30 天。注单口径,非信用账期结算。"
     },
     "player_transfer": {
       "title": "玩家转入转出报表",
-      "summary": "集中查看玩家转入、转出、冲正和异常处理记录。",
-      "disclaimer": "仅统计主站钱包划转单(钱包盘玩家)。信用盘玩家无此类转账,请使用结算中心查看账期账务。"
+      "summary": "钱包盘主站划转记录,按创建时间筛选;未选日期默认近 30 天。"
     },
     "hot_number_risk": {
       "title": "热门号码风险报表",
@@ -301,7 +297,7 @@
     },
     "play_dimension": {
       "title": "玩法维度报表",
-      "summary": "按玩法拆分投注量、派彩、回水和盈亏结构。"
+      "summary": "按玩法与业务日拆分盈亏;未选日期默认近 30 天。注单口径,非信用账期结算。"
     },
     "sold_out_number": {
       "title": "售罄号码报表",
@@ -309,12 +305,11 @@
     },
     "rebate_commission": {
       "title": "佣金/回水报表",
-      "summary": "按玩法与时间段汇总佣金、回水与配置命中情况。",
-      "disclaimer": "本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。"
+      "summary": "钱包盘下注立减回水,按业务日汇总;未选日期默认近 30 天。非信用占成账期。"
     },
     "admin_audit": {
       "title": "后台操作审计报表",
-      "summary": "按操作人和时间段导出关键后台操作留痕。"
+      "summary": "按操作人与记录创建时间筛选;未选日期默认近 30 天。"
     }
   }
 }
diff --git a/src/lib/admin-datetime.ts b/src/lib/admin-datetime.ts
index 1ce6849..8ea8cf4 100644
--- a/src/lib/admin-datetime.ts
+++ b/src/lib/admin-datetime.ts
@@ -52,6 +52,15 @@ export function formatAdminCalendarToday(locale: AdminApiLocale, weekdayLabel: s
   return `${datePart} ${weekdayLabel}`;
 }
 
+/** 与后端 `now()->toDateString()` 对齐的业务日(本地日历日 YYYY-MM-DD)。 */
+export function formatAdminBusinessDateIso(date: Date = new Date()): string {
+  const y = date.getFullYear();
+  const m = String(date.getMonth() + 1).padStart(2, "0");
+  const day = String(date.getDate()).padStart(2, "0");
+
+  return `${y}-${m}-${day}`;
+}
+
 const NAIVE_SCHEDULE_CLOCK_RE =
   /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
 
diff --git a/src/lib/admin-prd.ts b/src/lib/admin-prd.ts
index 809b3fb..e44c67a 100644
--- a/src/lib/admin-prd.ts
+++ b/src/lib/admin-prd.ts
@@ -76,6 +76,12 @@ export const PRD_RISK_ACCESS_ANY = [
 export const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
 export const PRD_REPORTS_EXPORT_ACCESS_ANY = [PRD_REPORT_EXPORT] as const;
 
+/** 仪表盘趋势/排行(后端 analytics 需 dashboard.view;站点另有 report.view) */
+export const PRD_DASHBOARD_ANALYTICS_ACCESS_ANY = [
+  PRD_DASHBOARD_VIEW,
+  ...PRD_REPORTS_VIEW_ACCESS_ANY,
+] as const;
+
 /** 系统设置(与后端 admin.settings.* 资源口径一致) */
 export const PRD_SETTINGS_ACCESS_ANY = [
   PRD_WALLET_RECONCILE_MANAGE,
diff --git a/src/lib/admin-signed-money.tsx b/src/lib/admin-signed-money.tsx
index 4862e70..1498b2f 100644
--- a/src/lib/admin-signed-money.tsx
+++ b/src/lib/admin-signed-money.tsx
@@ -16,6 +16,28 @@ export function signedMoneyClass(amount: number, emphasize = false): string {
   return cn("text-muted-foreground", emphasize && "font-medium");
 }
 
+/** 环比趋势色:升/降何者为「好」因指标而异 */
+export function signedTrendClassName(
+  series: number[],
+  mode: "higherBetter" | "lowerBetter" | "signed",
+): string {
+  if (series.length < 2) {
+    return "text-muted-foreground";
+  }
+  const last = series[series.length - 1] ?? 0;
+  const prev = series[series.length - 2] ?? 0;
+  if (mode === "signed") {
+    return signedMoneyClass(last - prev, false);
+  }
+  const improved = mode === "higherBetter" ? last > prev : last < prev;
+  if (last === prev) {
+    return "text-muted-foreground";
+  }
+  return improved
+    ? "text-emerald-600 dark:text-emerald-400"
+    : "text-destructive";
+}
+
 export function SignedMoney({
   amount,
   children,
diff --git a/src/lib/report-export-map.ts b/src/lib/report-export-map.ts
index 503c031..cdf3ecd 100644
--- a/src/lib/report-export-map.ts
+++ b/src/lib/report-export-map.ts
@@ -34,19 +34,6 @@ export const REPORT_UI_TO_JOB_TYPE: Record = {
   admin_audit: "audit_operation_report",
 };
 
-/** Report types with full server-side export (POST /admin/report-jobs). */
-export const REPORT_UI_SERVER_FULL_EXPORT = new Set([
-  "draw_profit",
-  "daily_profit",
-  "player_win_loss",
-  "player_transfer",
-  "hot_number_risk",
-  "play_dimension",
-  "sold_out_number",
-  "rebate_commission",
-  "admin_audit",
-]);
-
 export function buildReportJobParameters(
   key: ReportUiKey,
   filters: ReportFilterSnapshot,
diff --git a/src/modules/dashboard/agent-dashboard-console.tsx b/src/modules/dashboard/agent-dashboard-console.tsx
index 8513116..cdac8d2 100644
--- a/src/modules/dashboard/agent-dashboard-console.tsx
+++ b/src/modules/dashboard/agent-dashboard-console.tsx
@@ -10,40 +10,45 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
 import { useTranslationRef } from "@/hooks/use-translation-ref";
 import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
 import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
+import { adminHasAnyPermission } from "@/lib/admin-permissions";
+import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
 import { normalizeAdminLanguage } from "@/i18n";
-import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
-import { signedMoneyClass } from "@/lib/admin-signed-money";
+import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
 import { cn } from "@/lib/utils";
 import { useAdminProfile } from "@/stores/admin-session";
+import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
 import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
 import { Skeleton } from "@/components/ui/skeleton";
 import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
 import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
-import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
+import {
+  DashboardKpiCard,
+  DashboardScopeMetric,
+  DashboardSignedStatRow,
+  DashboardStatRow,
+} from "@/modules/dashboard/dashboard-visuals";
 import {
   formatDashboardCreditMajor,
   formatDashboardMoneyMinor,
-  formatDashboardSignedMoneyMinor,
 } from "@/modules/dashboard/use-dashboard-analytics";
-import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard";
+import type { AdminDashboardAgentOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
 import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
 import { LotteryApiBizError } from "@/types/api/errors";
 
-function AgentMetric({
-  label,
-  value,
-}: {
-  label: string;
-  value: string;
-}): ReactElement {
-  return (
-    
-

{label}

-

{value}

-
- ); +function buildTodayBetHint( + businessDate: string, + latestBetAt: string | null, + t: (key: string, opts?: Record) => string, + formatDt: (iso: string) => string, +): string { + const dateHint = t("todayBusinessDateHint", { date: businessDate }); + if (latestBetAt) { + return `${dateHint} · ${t("agent.latestBetAt", { time: formatDt(latestBetAt) })}`; + } + + return `${dateHint} · ${t("agent.noBetToday")}`; } export function AgentDashboardConsole(): ReactElement { @@ -52,6 +57,9 @@ export function AgentDashboardConsole(): ReactElement { const formatDt = useAdminDateTimeFormatter(); const profile = useAdminProfile(); const agent = profile?.agent ?? null; + const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]); + const businessDateToday = useMemo(() => formatAdminBusinessDateIso(), []); + const todayLabel = useMemo(() => { const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language); const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" }); @@ -65,10 +73,10 @@ export function AgentDashboardConsole(): ReactElement { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const [apiWarnings, setApiWarnings] = useState([]); const [hall, setHall] = useState(null); const [drawId, setDrawId] = useState(null); const [overview, setOverview] = useState(null); - const [canFinance, setCanFinance] = useState(false); const analyticsScope = useMemo( () => ({ @@ -78,6 +86,8 @@ export function AgentDashboardConsole(): ReactElement { [agent?.id, agent?.site_code], ); + const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]); + const load = useCallback(async (isRefresh = false) => { if (isRefresh) { setRefreshing(true); @@ -90,7 +100,7 @@ export function AgentDashboardConsole(): ReactElement { const d = await getAdminDashboard(); setHall(d.hall); setOverview(d.agent_overview); - setCanFinance(d.capabilities.draw_finance_risk); + setApiWarnings(d.warnings ?? []); if (d.resolved_draw != null) { setDrawId(d.resolved_draw.id); } else { @@ -110,8 +120,7 @@ export function AgentDashboardConsole(): ReactElement { void load(false); }, []); - const currency = "NPR"; - const displayCurrency = overview?.currency_code ?? currency; + const displayCurrency = overview?.currency_code ?? "NPR"; return (
@@ -144,31 +153,36 @@ export function AgentDashboardConsole(): ReactElement { ) : null} + {!loading && apiWarnings.length > 0 ? ( + + {t("notice")} + {apiWarnings.map((w) => w.message).join(" ")} + + ) : null} + {loading ? ( -
+
{Array.from({ length: 4 }).map((_, i) => ( ))}
) : overview ? (
-
+
} - hint={ - overview.latest_bet_at - ? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) }) - : t("agent.noBetToday") - } + hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)} /> } - hint={t("agent.shareRate", { rate: overview.total_share_rate })} - valueClassName={signedMoneyClass(overview.today_profit_minor, true)} + hint={`${t("agent.shareRate", { rate: overview.total_share_rate })} · ${t("todayPayoutHint", { + amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency), + })}`} />
-

+

{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}

@@ -202,19 +216,15 @@ export function AgentDashboardConsole(): ReactElement { })}

-
- + - -

{t("agent.lineMeta", { @@ -231,24 +241,21 @@ export function AgentDashboardConsole(): ReactElement { {t("agent.sevenDayTitle")} - -

- {t("agent.todayBet")} - - {formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)} - -
-
- {t("agent.todayShareProfit")} - - {formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)} - -
+ + + + +

{t("agent.shareProfitScopeHint")}

@@ -257,19 +264,19 @@ export function AgentDashboardConsole(): ReactElement { {t("agent.teamTitle")} - - - - @@ -277,7 +284,11 @@ export function AgentDashboardConsole(): ReactElement {
- ) : null} + ) : ( + + {t("agent.overviewEmpty")} + + )} - {canFinance ? ( + {canAnalytics ? ( - ) : ( - - {t("notice")} - {t("warnings.drawPermission")} - - )} + ) : !loading ? ( +

{t("warnings.analyticsUnavailable")}

+ ) : null}
); } diff --git a/src/modules/dashboard/dashboard-analytics-panel.tsx b/src/modules/dashboard/dashboard-analytics-panel.tsx index fc7f64c..8dbe338 100644 --- a/src/modules/dashboard/dashboard-analytics-panel.tsx +++ b/src/modules/dashboard/dashboard-analytics-panel.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { getAdminRequestLocale } from "@/lib/admin-locale"; -import { signedMoneyClass } from "@/lib/admin-signed-money"; +import { signedMoneyClass, signedTrendClassName } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals"; import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config"; @@ -56,16 +56,8 @@ function computeDeltaPercent(series: number[]): string | null { return `${sign} ${Math.abs(pct).toFixed(1)}%`; } -function deltaClassName(series: number[]): string { - if (series.length < 2) { - return "text-muted-foreground"; - } - 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"; +function deltaClassName(series: number[], mode: "higherBetter" | "lowerBetter" | "signed"): string { + return signedTrendClassName(series, mode); } export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnalyticsState }): ReactNode { @@ -190,13 +182,13 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal ) : null} {loading ? ( -
+
{Array.from({ length: 3 }).map((_, i) => ( ))}
) : summary ? ( -
+
+ {computeDeltaPercent(sparklines.bet)} ) : undefined @@ -229,7 +221,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal sparklineValues={sparklines.payout} deltaLabel={ computeDeltaPercent(sparklines.payout) ? ( - + {computeDeltaPercent(sparklines.payout)} ) : undefined @@ -241,8 +233,8 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal ? t("analytics.summaryShareProfit") : t("analytics.summaryProfit") } - value={formatSignedMoney(summary.approx_house_gross_minor, currency)} - valueClassName={signedMoneyClass(summary.approx_house_gross_minor, true)} + signedAmountMinor={summary.approx_house_gross_minor} + currencyCode={currency} hint={ profitScope === "share_profit" ? t("analytics.shareProfitHint") @@ -256,7 +248,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal sparklineValues={sparklines.profit} deltaLabel={ computeDeltaPercent(sparklines.profit) ? ( - + {computeDeltaPercent(sparklines.profit)} ) : undefined diff --git a/src/modules/dashboard/dashboard-console.tsx b/src/modules/dashboard/dashboard-console.tsx index bf46959..d089d74 100644 --- a/src/modules/dashboard/dashboard-console.tsx +++ b/src/modules/dashboard/dashboard-console.tsx @@ -4,7 +4,7 @@ import dynamic from "next/dynamic"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { AlertTriangle, ClipboardList, RefreshCw, Shield, Wallet } from "lucide-react"; +import { AlertTriangle, BarChart3, ClipboardList, RefreshCw, Shield, TrendingUp, Wallet } from "lucide-react"; import { getAdminDashboardByScope } from "@/api/admin-dashboard"; import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog"; @@ -18,6 +18,9 @@ import { } from "@/modules/dashboard/dashboard-analytics-panel"; import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card"; import { useDashboardAnalytics } from "@/modules/dashboard/use-dashboard-analytics"; +import { + formatDashboardMoneyMinor, +} from "@/modules/dashboard/use-dashboard-analytics"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -39,12 +42,18 @@ import type { AdminDashboardLifetimeFinance, AdminDashboardPlatformRisk, AdminDashboardResultBatchQueue, + AdminDashboardTodayFinance, + AdminDashboardWarning, } from "@/types/api/admin-dashboard"; import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance"; import type { AdminRiskPoolRow } from "@/types/api/admin-risk"; import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; // recharts 图表组件懒加载,避免 ~200KB 进入主 bundle +const DashboardKpiCard = dynamic( + () => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardKpiCard })), + { ssr: false }, +); const DashboardPanelCard = dynamic( () => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardPanelCard })), { ssr: false }, @@ -172,6 +181,8 @@ export function DashboardConsole(): ReactElement { const [lifetimeFinance, setLifetimeFinance] = useState( null, ); + const [todayFinance, setTodayFinance] = useState(null); + const [apiWarnings, setApiWarnings] = useState([]); const [platformRisk, setPlatformRisk] = useState(null); const [riskLocked, setRiskLocked] = useState(0); const [riskCap, setRiskCap] = useState(0); @@ -193,6 +204,8 @@ export function DashboardConsole(): ReactElement { setDrawPanel(null); setResultBatchQueue(null); setLifetimeFinance(null); + setTodayFinance(null); + setApiWarnings([]); setPlatformRisk(null); setDrawId(null); setRiskLocked(0); @@ -214,6 +227,8 @@ export function DashboardConsole(): ReactElement { } setResultBatchQueue(d.result_batch_queue); setLifetimeFinance(d.lifetime_finance); + setTodayFinance(d.today_finance); + setApiWarnings(d.warnings ?? []); setPlatformRisk(d.platform_risk); if (d.draw != null) { setDrawPanel(d.draw); @@ -239,7 +254,10 @@ export function DashboardConsole(): ReactElement { }, []); const currency = - lifetimeFinance?.currency_code ?? finance?.currency_code ?? null; + todayFinance?.currency_code + ?? lifetimeFinance?.currency_code + ?? finance?.currency_code + ?? null; const canFinance = capabilities?.draw_finance_risk ?? false; const platformLocked = coerceAdminMinor(platformRisk?.locked_amount); const platformCap = coerceAdminMinor(platformRisk?.cap_amount); @@ -296,6 +314,13 @@ export function DashboardConsole(): ReactElement { ) : null} + {!loading && apiWarnings.length > 0 ? ( + + {t("notice")} + {apiWarnings.map((w) => w.message).join(" ")} + + ) : null} +
+ + {canFinance && !loading && todayFinance ? ( +
+ } + hint={t("todayBusinessDateHint", { date: todayFinance.business_date })} + /> + } + hint={t("todayPayoutHint", { + amount: formatDashboardMoneyMinor(todayFinance.total_payout_minor, currency), + })} + /> + } + hint={ + lifetimeFinance + ? t("lifetimeActivityHint", { + draws: lifetimeFinance.draw_count, + days: lifetimeFinance.business_day_count, + }) + : undefined + } + value={lifetimeFinance ? undefined : "—"} + /> +
+ ) : null} +
@@ -345,16 +406,6 @@ export function DashboardConsole(): ReactElement { href="/admin/risk" title={t("riskCapUsage")} value={`${platformUsagePct.toFixed(1)}%`} - subtitle={ - platformCap > 0 - ? t("platformLockedAndCap", { - locked: formatMoneyMinor(platformLocked, currency), - cap: formatMoneyMinor(platformCap, currency), - }) - : t("platformCapNotConfigured", { - locked: formatMoneyMinor(platformLocked, currency), - }) - } actionLabel={t("occupancyDetails")} icon={} accent={ @@ -380,7 +431,7 @@ export function DashboardConsole(): ReactElement { + {label} + + {value} + +
+ ); +} + +/** 盈亏行:正绿、负红、零灰(与 {@link signedMoneyClass} 一致) */ +export function DashboardSignedStatRow({ + label, + amountMinor, + currencyCode, +}: { + label: string; + amountMinor: number; + currencyCode: string | null; +}): ReactElement { + return ( +
+ {label} + + {formatDashboardSignedMoneyMinor(amountMinor, currencyCode)} + +
+ ); +} + +/** 规模/授信等栅格指标,金额可换行 */ +export function DashboardScopeMetric({ + label, + value, +}: { + label: string; + value: string; +}): ReactElement { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + /** 财务概览区紧凑 KPI,避免 StatCard 在窄栅格内撑破布局 */ export function DashboardKpiCard({ label, @@ -187,43 +256,60 @@ export function DashboardKpiCard({ icon, accent = "primary", valueClassName, + signedAmountMinor, + currencyCode, sparklineValues, deltaLabel, }: { label: string; - value: ReactNode; + value?: ReactNode; hint?: ReactNode; icon: ReactNode; accent?: DashboardKpiAccent; /** 覆盖主数值颜色(如盈亏红绿) */ valueClassName?: string; + /** 盈亏类 KPI:自动带 +/- 与红绿 */ + signedAmountMinor?: number; + currencyCode?: string | null; sparklineValues?: number[]; deltaLabel?: ReactNode; }): ReactElement { + const resolvedValue = + typeof signedAmountMinor === "number" && currencyCode !== undefined + ? formatDashboardSignedMoneyMinor(signedAmountMinor, currencyCode) + : value; + const resolvedValueClassName = + typeof signedAmountMinor === "number" + ? signedMoneyClass(signedAmountMinor, true) + : valueClassName; + const valueTitle = + typeof resolvedValue === "string" || typeof resolvedValue === "number" + ? String(resolvedValue) + : undefined; + return (
-
+
+

{label}

{icon}
-
-

{label}

-

- {value} -

- {deltaLabel ?
{deltaLabel}
: null} -
+

+ {resolvedValue} +

+ {deltaLabel ?
{deltaLabel}
: null} {sparklineValues && sparklineValues.length >= 2 ? (
) : null} {hint ? ( -

{hint}

+

{hint}

) : null}
); @@ -477,9 +563,7 @@ export function DashboardPanelCard({

)} {subtitle && !loading ? ( -

- {subtitle} -

+
{subtitle}
) : null}
@@ -621,19 +705,48 @@ export function CapUsageBar({ const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]); if (compact) { + const lockedLabel = formatMoney(locked, currency); + const capLabel = cap > 0 ? formatMoney(cap, currency) : t("platformCapUnset"); + return ( -
+
+
+
+

+ {t("platformLockedLabel")} +

+

+ {lockedLabel} +

+
+
+

+ {t("platformCapLabel")} +

+

+ {capLabel} +

+
+
+ className="h-2 overflow-hidden rounded-full bg-muted" + role="progressbar" + aria-valuenow={pct} + aria-valuemin={0} + aria-valuemax={100} + aria-label={t("riskCapUsage")} + > +
+
); } @@ -741,6 +854,12 @@ export function FinanceStructureChart({

{t("payoutRateOfBet", { rate: payoutRate })}

+

+ {t("houseGross")} + + {formatDashboardSignedMoneyMinor(gross, currency)} + +

} />
); diff --git a/src/modules/dashboard/site-dashboard-console.tsx b/src/modules/dashboard/site-dashboard-console.tsx index 57ec288..9dacfe2 100644 --- a/src/modules/dashboard/site-dashboard-console.tsx +++ b/src/modules/dashboard/site-dashboard-console.tsx @@ -10,40 +10,43 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter" import { useTranslationRef } from "@/hooks/use-translation-ref"; import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; -import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd"; +import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd"; import { normalizeAdminLanguage } from "@/i18n"; -import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime"; -import { signedMoneyClass } from "@/lib/admin-signed-money"; +import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime"; import { cn } from "@/lib/utils"; import { useAdminProfile } from "@/stores/admin-session"; +import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card"; import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel"; -import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals"; +import { + DashboardKpiCard, + DashboardScopeMetric, + DashboardSignedStatRow, + DashboardStatRow, +} from "@/modules/dashboard/dashboard-visuals"; import { formatDashboardMoneyMinor, - formatDashboardSignedMoneyMinor, } from "@/modules/dashboard/use-dashboard-analytics"; -import type { AdminDashboardSiteOverview } from "@/types/api/admin-dashboard"; +import type { AdminDashboardSiteOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard"; import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; import { LotteryApiBizError } from "@/types/api/errors"; -function SiteMetric({ - label, - value, -}: { - label: string; - value: string; -}): ReactElement { - return ( -
-

{label}

-

{value}

-
- ); +function buildTodayBetHint( + businessDate: string, + latestBetAt: string | null, + t: (key: string, opts?: Record) => string, + formatDt: (iso: string) => string, +): string { + const dateHint = t("todayBusinessDateHint", { date: businessDate }); + if (latestBetAt) { + return `${dateHint} · ${t("site.latestBetAt", { time: formatDt(latestBetAt) })}`; + } + + return `${dateHint} · ${t("site.noBetToday")}`; } export function SiteDashboardConsole(): ReactElement { @@ -53,6 +56,7 @@ export function SiteDashboardConsole(): ReactElement { const profile = useAdminProfile(); const site = profile?.site ?? null; const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]); + const businessDateToday = useMemo(() => formatAdminBusinessDateIso(), []); const todayLabel = useMemo(() => { const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language); @@ -66,6 +70,7 @@ export function SiteDashboardConsole(): ReactElement { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const [apiWarnings, setApiWarnings] = useState([]); const [hall, setHall] = useState(null); const [drawId, setDrawId] = useState(null); const [overview, setOverview] = useState(null); @@ -78,7 +83,7 @@ export function SiteDashboardConsole(): ReactElement { [overview?.site_code, site?.code], ); - const canAnalytics = adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY]); + const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]); const load = useCallback(async (isRefresh = false) => { if (isRefresh) { @@ -92,6 +97,7 @@ export function SiteDashboardConsole(): ReactElement { const d = await getAdminDashboard(); setHall(d.hall); setOverview(d.site_overview); + setApiWarnings(d.warnings ?? []); if (d.resolved_draw != null) { setDrawId(d.resolved_draw.id); } else { @@ -144,31 +150,36 @@ export function SiteDashboardConsole(): ReactElement { ) : null} + {!loading && apiWarnings.length > 0 ? ( + + {t("notice")} + {apiWarnings.map((w) => w.message).join(" ")} + + ) : null} + {loading ? ( -
+
{Array.from({ length: 4 }).map((_, i) => ( ))}
) : overview ? (
-
+
} - hint={ - overview.latest_bet_at - ? t("site.latestBetAt", { time: formatDt(overview.latest_bet_at) }) - : t("site.noBetToday") - } + hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)} /> } - hint={t("site.profitScopeHint")} - valueClassName={signedMoneyClass(overview.today_profit_minor, true)} + hint={t("todayPayoutHint", { + amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency), + })} /> {t("site.sevenDayTitle")} - -
- {t("site.todayBet")} - - {formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)} - -
-
- {t("site.sevenDayProfit")} - - {formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)} - -
+ + + + +

{t("site.profitScopeHint")}

@@ -218,24 +226,31 @@ export function SiteDashboardConsole(): ReactElement { {t("site.scaleTitle")} - - + + {overview.top_agent_today ? ( -
- {t("site.topAgentToday", { - name: overview.top_agent_today.agent_name || overview.top_agent_today.agent_code, - amount: formatDashboardMoneyMinor( +
+

{t("site.topAgentTodayLabel")}

+

+ {overview.top_agent_today.agent_name || overview.top_agent_today.agent_code} +

+

+ {formatDashboardMoneyMinor( overview.top_agent_today.total_bet_minor, displayCurrency, - ), - })} + )} +

) : null}
- ) : null} + ) : ( + + {t("site.overviewEmpty")} + + )} { - if (rows.length === 0) { - throw new LotteryApiBizError("no_data", -1, null); - } - - if (format === "csv") { - const headers = Object.keys(rows[0]); - const lines = [ - headers.map(toCsvValue).join(","), - ...rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")), - ]; - const blob = new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8;" }); - downloadBlob(blob, `${filename}.csv`); - return; - } - - const XLSX = await import("xlsx"); - const worksheet = XLSX.utils.json_to_sheet(rows); - const workbook = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); - XLSX.writeFile(workbook, `${filename}.xlsx`); -} - function buildDailyProfitRowsAndSummary( items: AdminReportDailyProfitRow[], total: number, @@ -742,7 +675,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa }, [filteredReports, selectedKey]); const pageScopedLabel = useCallback( - (statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`, + (statKey: string) => t(`preview.stats.${statKey}`), [t], ); @@ -1233,9 +1166,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa setPage(1); } - const usesServerExport = REPORT_UI_SERVER_FULL_EXPORT.has(selectedReport.key as ReportUiKey); - - async function exportViaServer(format: ExportFormat): Promise { + async function exportReport(format: ExportFormat): Promise { if (!canExportReports) { return; } @@ -1260,10 +1191,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa const ext = job.export_format === "xlsx" ? "xlsx" : "csv"; downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`); toast.success( - t("exportServerSuccess", { + t("exportSuccess", { report: t(`items.${selectedReport.key}.title`), format: t(`formats.${format}`), - jobNo: job.job_no, }), ); } catch (err) { @@ -1273,36 +1203,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa } } - function exportPreview(format: ExportFormat): void { - if (!canExportReports) { - return; - } - if (!result || result.rows.length === 0) { - toast.info(t("empty")); - return; - } - setExporting(format); - try { - exportRows(result.rows, exportFileBase, t(`items.${selectedReport.key}.title`), format); - toast.success(t("exportSuccess", { report: t(`items.${selectedReport.key}.title`), format: t(`formats.${format}`) })); - } catch (err) { - toast.error(err instanceof LotteryApiBizError ? err.message : t("exportFailed")); - } finally { - setExporting(null); - } - } - - function exportReport(format: ExportFormat): void { - if (!canExportReports) { - return; - } - if (usesServerExport) { - void exportViaServer(format); - return; - } - exportPreview(format); - } - const renderSearchPicker = (kind: SearchKind) => { const value = kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator; @@ -1726,21 +1626,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa })}
{t(`items.${selectedReport.key}.summary`)}
- {reportTimeAxisKey(selectedReport.key) ? ( -

{t(`timeAxis.${reportTimeAxisKey(selectedReport.key)}`)}

- ) : null}
{selectedReport.fields.map(renderField)}
-
-
-
{t("filterPanel")}
-
{t("queryHint")}
-
-
+
@@ -1756,7 +1648,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa {loading ? t("querying") : t("query")} -
@@ -1770,70 +1661,34 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa ))}
- {reportDisclaimerKey(selectedReport.key) ? ( -
- {t(reportDisclaimerKey(selectedReport.key)!)} -
- ) : null} -
{t("preview.title")}
-
-
- - -
- {result && result.rows.length > 0 ? ( - <> -

{t("exportPreviewHint")}

-
- - -
- - ) : null} -
-
- -
- {t("preview.summaryScopeHint")} +
+ +
+ +