From ddedef824e156034adce687404fd7392e40bf6d9 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 25 May 2026 14:31:24 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=90=88=E5=B9=B6=E5=A4=9A?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E6=94=AF=E6=8C=81=E7=9A=84=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=90=8D=E7=A7=B0=E5=AD=97=E6=AE=B5=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=A5=96=E6=B1=A0=E6=89=8B=E5=8A=A8=E7=88=86=E5=8F=91=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E7=9A=84=E8=BF=94=E5=9B=9E=E6=95=B0=E6=8D=AE=E7=BB=93?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E5=A2=9E=E5=BC=BA=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E6=9D=83=E9=99=90=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin-config.ts | 8 +- src/api/admin-dashboard.ts | 31 ++ src/api/admin-jackpot.ts | 11 +- src/api/admin-player.ts | 8 + src/app/admin/(shell)/account/page.tsx | 5 +- .../(shell)/draws/[drawId]/risk/layout.tsx | 12 + src/app/admin/(shell)/jackpot/page.tsx | 10 +- src/app/admin/(shell)/page.tsx | 6 +- src/app/admin/(shell)/players/page.tsx | 6 +- src/app/admin/(shell)/reports/page.tsx | 6 +- src/app/admin/(shell)/risk/cap/page.tsx | 6 +- src/app/admin/(shell)/tickets/page.tsx | 6 +- src/app/admin/(shell)/wallet/player/page.tsx | 8 +- .../(shell)/wallet/transactions/page.tsx | 8 +- .../(shell)/wallet/transfer-orders/page.tsx | 8 +- src/app/globals.css | 21 +- src/components/admin/admin-page-card.tsx | 44 ++ src/components/admin/admin-page-section.tsx | 30 ++ .../admin/admin-permission-gate.tsx | 40 ++ src/components/admin/admin-section-header.tsx | 33 ++ src/components/admin/admin-sidebar.tsx | 4 +- .../admin/confirm-action-dialog.tsx | 63 +++ src/components/admin/toolbar.tsx | 12 +- src/hooks/use-confirm-action.tsx | 67 +++ src/i18n/index.ts | 2 +- src/i18n/locales/en/adminUsers.json | 1 + src/i18n/locales/en/common.json | 50 ++- src/i18n/locales/en/config.json | 53 ++- src/i18n/locales/en/dashboard.json | 70 +++- src/i18n/locales/en/draws.json | 18 + src/i18n/locales/en/jackpot.json | 10 +- src/i18n/locales/en/players.json | 10 + src/i18n/locales/en/reconcile.json | 4 + src/i18n/locales/en/risk.json | 6 + src/i18n/locales/en/tickets.json | 1 + src/i18n/locales/en/wallet.json | 6 + src/i18n/locales/ne/adminUsers.json | 1 + src/i18n/locales/ne/common.json | 50 ++- src/i18n/locales/ne/config.json | 49 ++- src/i18n/locales/ne/dashboard.json | 62 ++- src/i18n/locales/ne/draws.json | 18 + src/i18n/locales/ne/jackpot.json | 10 +- src/i18n/locales/ne/players.json | 6 + src/i18n/locales/ne/tickets.json | 1 + src/i18n/locales/zh/adminUsers.json | 11 + src/i18n/locales/zh/common.json | 50 ++- src/i18n/locales/zh/config.json | 53 ++- src/i18n/locales/zh/dashboard.json | 66 ++- src/i18n/locales/zh/draws.json | 18 + src/i18n/locales/zh/jackpot.json | 10 +- src/i18n/locales/zh/players.json | 10 + src/i18n/locales/zh/reconcile.json | 4 + src/i18n/locales/zh/risk.json | 6 + src/i18n/locales/zh/tickets.json | 1 + src/i18n/locales/zh/wallet.json | 6 + src/lib/admin-datetime.ts | 29 ++ src/lib/admin-page-title.ts | 1 + src/lib/admin-play-types.ts | 31 +- src/lib/admin-prd.ts | 112 +++++ src/modules/_config/admin-nav-icons.tsx | 13 + .../account/account-settings-console.tsx | 42 +- .../admin-roles/admin-roles-console.tsx | 91 +++-- .../admin-users/admin-users-console.tsx | 48 ++- src/modules/config/config-section.tsx | 11 +- src/modules/config/config-version-actions.tsx | 15 +- .../config/doc/odds-config-doc-screen.tsx | 30 +- .../config/doc/play-config-doc-screen.tsx | 157 +++---- src/modules/config/doc/prize-scopes.ts | 15 +- .../config/doc/rebate-config-doc-screen.tsx | 31 +- .../config/doc/risk-cap-doc-screen.tsx | 36 +- .../config/doc/wallet-config-doc-screen.tsx | 17 +- .../dashboard/dashboard-analytics-panel.tsx | 386 ++++++++++++++++++ src/modules/dashboard/dashboard-console.tsx | 175 ++++---- .../dashboard/dashboard-trend-charts.tsx | 260 ++++++++++++ src/modules/dashboard/dashboard-visuals.tsx | 58 ++- src/modules/draws/draw-detail-console.tsx | 66 ++- src/modules/draws/draw-display.ts | 8 + src/modules/draws/draw-finance-console.tsx | 11 +- src/modules/draws/draw-prd.ts | 11 +- src/modules/draws/draw-publish-console.tsx | 12 +- src/modules/draws/draw-review-console.tsx | 11 +- src/modules/draws/draws-index-console.tsx | 17 +- src/modules/jackpot/jackpot-config-screen.tsx | 19 +- src/modules/jackpot/jackpot-pools-console.tsx | 117 ++++-- .../jackpot/jackpot-records-console.tsx | 12 +- src/modules/players/players-console.tsx | 110 ++++- src/modules/reconcile/reconcile-console.tsx | 20 +- src/modules/reports/reports-console.tsx | 39 +- src/modules/risk/risk-draw-header.tsx | 20 +- src/modules/risk/risk-pools-console.tsx | 44 +- .../rules/rules-odds-config-screen.tsx | 24 +- .../settings/currency-settings-panel.tsx | 31 +- .../settings/system-settings-screen.tsx | 113 +++-- .../settlement-batch-details-console.tsx | 65 +-- .../settlement/settlement-batches-console.tsx | 60 +-- .../tickets/player-tickets-console.tsx | 1 + src/modules/wallet/wallet-console.tsx | 54 ++- src/stores/admin-session.ts | 48 ++- src/types/api/admin-config.ts | 8 +- src/types/api/admin-dashboard-analytics.ts | 56 +++ src/types/api/admin-dashboard.ts | 23 ++ 101 files changed, 3033 insertions(+), 641 deletions(-) create mode 100644 src/app/admin/(shell)/draws/[drawId]/risk/layout.tsx create mode 100644 src/components/admin/admin-page-card.tsx create mode 100644 src/components/admin/admin-page-section.tsx create mode 100644 src/components/admin/admin-permission-gate.tsx create mode 100644 src/components/admin/admin-section-header.tsx create mode 100644 src/components/admin/confirm-action-dialog.tsx create mode 100644 src/hooks/use-confirm-action.tsx create mode 100644 src/lib/admin-prd.ts create mode 100644 src/modules/dashboard/dashboard-analytics-panel.tsx create mode 100644 src/modules/dashboard/dashboard-trend-charts.tsx create mode 100644 src/types/api/admin-dashboard-analytics.ts diff --git a/src/api/admin-config.ts b/src/api/admin-config.ts index 7eab044..81f4c03 100644 --- a/src/api/admin-config.ts +++ b/src/api/admin-config.ts @@ -28,9 +28,7 @@ export async function patchAdminPlayType( body: Partial<{ is_enabled: boolean; sort_order: number; - display_name_zh: string | null; - display_name_en: string | null; - display_name_ne: string | null; + display_name: string | null; supports_multi_number: boolean; reserved_rule_json: unknown; }>, @@ -64,9 +62,7 @@ export async function putPlayConfigItems( category: string | null; dimension: number | null; bet_mode: string | null; - display_name_zh: string; - display_name_en?: string | null; - display_name_ne?: string | null; + display_name: string; is_enabled?: boolean; min_bet_amount: number; max_bet_amount: number; diff --git a/src/api/admin-dashboard.ts b/src/api/admin-dashboard.ts index 1f84641..1f1da52 100644 --- a/src/api/admin-dashboard.ts +++ b/src/api/admin-dashboard.ts @@ -3,6 +3,10 @@ import { adminRequest } from "@/lib/admin-http"; import { API_V1_PREFIX } from "./paths"; import type { AdminDashboardData } from "@/types/api/admin-dashboard"; +import type { + AdminDashboardAnalyticsData, + AdminDashboardAnalyticsQuery, +} from "@/types/api/admin-dashboard-analytics"; const A = `${API_V1_PREFIX}/admin`; @@ -10,3 +14,30 @@ const A = `${API_V1_PREFIX}/admin`; export async function getAdminDashboard(): Promise { return adminRequest.get(`${A}/dashboard`); } + +/** 仪表盘可筛选分析(区间汇总、日趋势、玩法拆解) */ +export async function getAdminDashboardAnalytics( + query: AdminDashboardAnalyticsQuery = {}, +): Promise { + const params = new URLSearchParams(); + if (query.period) { + params.set("period", query.period); + } + if (query.date_from) { + params.set("date_from", query.date_from); + } + if (query.date_to) { + params.set("date_to", query.date_to); + } + if (query.metric) { + params.set("metric", query.metric); + } + if (query.play_code) { + params.set("play_code", query.play_code); + } + const qs = params.toString(); + + return adminRequest.get( + `${A}/dashboard/analytics${qs ? `?${qs}` : ""}`, + ); +} diff --git a/src/api/admin-jackpot.ts b/src/api/admin-jackpot.ts index 678c529..7e01393 100644 --- a/src/api/admin-jackpot.ts +++ b/src/api/admin-jackpot.ts @@ -35,8 +35,15 @@ export async function putAdminJackpotPool( export async function postAdminJackpotManualBurst( poolId: number, - body: { draw_id: number; amount?: number }, -): Promise<{ current_amount: number; burst_amount: number; log_id: number | null }> { + body: { draw_id: number }, +): Promise<{ + current_amount: number; + burst_amount: number; + log_id: number | null; + winner_count: number; + draw_no: string; + wallet_credited: boolean; +}> { return adminRequest.post(`${A}/jackpot/pools/${poolId}/manual-burst`, body); } diff --git a/src/api/admin-player.ts b/src/api/admin-player.ts index 2fc828a..735715a 100644 --- a/src/api/admin-player.ts +++ b/src/api/admin-player.ts @@ -39,3 +39,11 @@ export async function putAdminPlayer( export async function deleteAdminPlayer(playerId: number): Promise { return adminRequest.delete(`${A}/players/${playerId}`); } + +export async function postAdminPlayerFreeze(playerId: number): Promise { + return adminRequest.post(`${A}/players/${playerId}/freeze`); +} + +export async function postAdminPlayerUnfreeze(playerId: number): Promise { + return adminRequest.post(`${A}/players/${playerId}/unfreeze`); +} diff --git a/src/app/admin/(shell)/account/page.tsx b/src/app/admin/(shell)/account/page.tsx index f7befcd..5a533de 100644 --- a/src/app/admin/(shell)/account/page.tsx +++ b/src/app/admin/(shell)/account/page.tsx @@ -1,10 +1,9 @@ import { Metadata } from "next"; +import { buildPageMetadata } from "@/lib/page-metadata"; import { AccountSettingsConsole } from "@/modules/account/account-settings-console"; -export const metadata: Metadata = { - title: "账号设置 - 管理后台", -}; +export const metadata: Metadata = buildPageMetadata("common", "accountSettings"); export default function AdminAccountPage() { return ; diff --git a/src/app/admin/(shell)/draws/[drawId]/risk/layout.tsx b/src/app/admin/(shell)/draws/[drawId]/risk/layout.tsx new file mode 100644 index 0000000..ec8199d --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/risk/layout.tsx @@ -0,0 +1,12 @@ +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; +import { PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd"; + +export default function AdminDrawRiskLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + {children} + ); +} diff --git a/src/app/admin/(shell)/jackpot/page.tsx b/src/app/admin/(shell)/jackpot/page.tsx index ad8330e..2a04ecd 100644 --- a/src/app/admin/(shell)/jackpot/page.tsx +++ b/src/app/admin/(shell)/jackpot/page.tsx @@ -1,5 +1,7 @@ import { Suspense } from "react"; +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; +import { PRD_JACKPOT_ACCESS_ANY } from "@/lib/admin-prd"; import { JackpotConfigScreen } from "@/modules/jackpot/jackpot-config-screen"; import { RulesPageShell } from "@/modules/rules/rules-page-shell"; import { buildPageMetadata } from "@/lib/page-metadata"; @@ -10,9 +12,11 @@ export const metadata: Metadata = buildPageMetadata("jackpot", "configTitle"); export default function AdminJackpotPage() { return ( - Loading…

}> - -
+ + Loading…

}> + +
+
); } diff --git a/src/app/admin/(shell)/page.tsx b/src/app/admin/(shell)/page.tsx index 978c2dc..c9aecc1 100644 --- a/src/app/admin/(shell)/page.tsx +++ b/src/app/admin/(shell)/page.tsx @@ -1,4 +1,6 @@ +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { PRD_DASHBOARD_ACCESS_ANY } from "@/lib/admin-prd"; import { DashboardConsole } from "@/modules/dashboard/dashboard-console"; import { buildPageMetadata } from "@/lib/page-metadata"; import type { Metadata } from "next"; @@ -8,7 +10,9 @@ export const metadata: Metadata = buildPageMetadata("dashboard", "title"); export default function AdminDashboardPage() { return ( - + + + ); } diff --git a/src/app/admin/(shell)/players/page.tsx b/src/app/admin/(shell)/players/page.tsx index a905725..3cb332c 100644 --- a/src/app/admin/(shell)/players/page.tsx +++ b/src/app/admin/(shell)/players/page.tsx @@ -1,4 +1,6 @@ +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { PRD_PLAYERS_ACCESS_ANY } from "@/lib/admin-prd"; import { PlayersConsole } from "@/modules/players/players-console"; import { buildPageMetadata } from "@/lib/page-metadata"; import type { Metadata } from "next"; @@ -8,7 +10,9 @@ export const metadata: Metadata = buildPageMetadata("players", "title"); export default function AdminPlayersPage() { return ( - + + + ); } diff --git a/src/app/admin/(shell)/reports/page.tsx b/src/app/admin/(shell)/reports/page.tsx index 8b7331e..d501b59 100644 --- a/src/app/admin/(shell)/reports/page.tsx +++ b/src/app/admin/(shell)/reports/page.tsx @@ -1,4 +1,6 @@ +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd"; import { buildPageMetadata } from "@/lib/page-metadata"; import { ReportsConsole } from "@/modules/reports/reports-console"; import type { Metadata } from "next"; @@ -8,7 +10,9 @@ export const metadata: Metadata = buildPageMetadata("reports", "title"); export default function AdminReportsPage() { return ( - + + + ); } diff --git a/src/app/admin/(shell)/risk/cap/page.tsx b/src/app/admin/(shell)/risk/cap/page.tsx index 31b44dd..447a33c 100644 --- a/src/app/admin/(shell)/risk/cap/page.tsx +++ b/src/app/admin/(shell)/risk/cap/page.tsx @@ -1,3 +1,5 @@ +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; +import { PRD_RISK_CAP_ACCESS_ANY } from "@/lib/admin-prd"; import { RiskCapDocScreen } from "@/modules/config/doc/risk-cap-doc-screen"; import { RulesPageShell } from "@/modules/rules/rules-page-shell"; import { buildPageMetadata } from "@/lib/page-metadata"; @@ -8,7 +10,9 @@ export const metadata: Metadata = buildPageMetadata("config", "nav.riskCapTitle" export default function AdminRiskCapPage() { return ( - + + + ); } diff --git a/src/app/admin/(shell)/tickets/page.tsx b/src/app/admin/(shell)/tickets/page.tsx index 3cb1a55..7fca221 100644 --- a/src/app/admin/(shell)/tickets/page.tsx +++ b/src/app/admin/(shell)/tickets/page.tsx @@ -1,4 +1,6 @@ +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { PRD_TICKETS_ACCESS_ANY } from "@/lib/admin-prd"; import { PlayerTicketsConsole } from "@/modules/tickets/player-tickets-console"; import { buildPageMetadata } from "@/lib/page-metadata"; import type { Metadata } from "next"; @@ -8,7 +10,9 @@ export const metadata: Metadata = buildPageMetadata("tickets", "title"); export default function AdminTicketsPage() { return ( - + + + ); } diff --git a/src/app/admin/(shell)/wallet/player/page.tsx b/src/app/admin/(shell)/wallet/player/page.tsx index 2a04d61..0f3af86 100644 --- a/src/app/admin/(shell)/wallet/player/page.tsx +++ b/src/app/admin/(shell)/wallet/player/page.tsx @@ -1,3 +1,5 @@ +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; +import { PRD_WALLET_PLAYER_ACCESS_ANY } from "@/lib/admin-prd"; import { PlayerWalletPanel } from "@/modules/wallet/wallet-console"; import { buildPageMetadata } from "@/lib/page-metadata"; import type { Metadata } from "next"; @@ -5,5 +7,9 @@ import type { Metadata } from "next"; export const metadata: Metadata = buildPageMetadata("wallet", "playerWalletQuery"); export default function AdminWalletPlayerPage() { - return ; + return ( + + + + ); } diff --git a/src/app/admin/(shell)/wallet/transactions/page.tsx b/src/app/admin/(shell)/wallet/transactions/page.tsx index 5dcbcd8..7adc12e 100644 --- a/src/app/admin/(shell)/wallet/transactions/page.tsx +++ b/src/app/admin/(shell)/wallet/transactions/page.tsx @@ -1,3 +1,5 @@ +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; +import { PRD_WALLET_TX_ACCESS_ANY } from "@/lib/admin-prd"; import { WalletTxnsPanel } from "@/modules/wallet/wallet-console"; import { buildPageMetadata } from "@/lib/page-metadata"; import type { Metadata } from "next"; @@ -5,5 +7,9 @@ import type { Metadata } from "next"; export const metadata: Metadata = buildPageMetadata("wallet", "walletTransactions"); export default function AdminWalletTransactionsPage() { - return ; + return ( + + + + ); } diff --git a/src/app/admin/(shell)/wallet/transfer-orders/page.tsx b/src/app/admin/(shell)/wallet/transfer-orders/page.tsx index 5565467..880821b 100644 --- a/src/app/admin/(shell)/wallet/transfer-orders/page.tsx +++ b/src/app/admin/(shell)/wallet/transfer-orders/page.tsx @@ -1,3 +1,5 @@ +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; +import { PRD_WALLET_TRANSFER_ACCESS_ANY } from "@/lib/admin-prd"; import { TransferOrdersPanel } from "@/modules/wallet/wallet-console"; import { buildPageMetadata } from "@/lib/page-metadata"; import type { Metadata } from "next"; @@ -5,5 +7,9 @@ import type { Metadata } from "next"; export const metadata: Metadata = buildPageMetadata("wallet", "transferOrders"); export default function AdminWalletTransferOrdersPage() { - return ; + return ( + + + + ); } diff --git a/src/app/globals.css b/src/app/globals.css index 8cea8b5..314d9e2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -67,11 +67,12 @@ --border: #d8e6fb; --input: #d8e6fb; --ring: #7aa7ee; - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + /* 仪表盘 / 图表:彩色序列(勿用 chroma=0 灰阶) */ + --chart-1: oklch(0.52 0.19 264); + --chart-2: oklch(0.62 0.17 162); + --chart-3: oklch(0.72 0.16 75); + --chart-4: oklch(0.56 0.22 303); + --chart-5: oklch(0.58 0.2 25); --radius: 0.625rem; --sidebar: #01266c; --sidebar-foreground: #f8fbff; @@ -102,11 +103,11 @@ --border: rgb(148 180 220 / 24%); --input: rgb(148 180 220 / 28%); --ring: #77a7ff; - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --chart-1: oklch(0.7 0.14 264); + --chart-2: oklch(0.72 0.13 162); + --chart-3: oklch(0.78 0.13 75); + --chart-4: oklch(0.68 0.17 303); + --chart-5: oklch(0.7 0.16 25); --sidebar: #01266c; --sidebar-foreground: #f8fbff; --sidebar-primary: #e60012; diff --git a/src/components/admin/admin-page-card.tsx b/src/components/admin/admin-page-card.tsx new file mode 100644 index 0000000..3b13b7a --- /dev/null +++ b/src/components/admin/admin-page-card.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +type AdminPageCardProps = { + title: string; + description?: string; + actions?: ReactNode; + children: ReactNode; + className?: string; + contentClassName?: string; + /** 页内锚点(如 #records) */ + id?: string; +}; + +/** 与列表/运营台一致的 admin-list-card 外层,用于设置、奖池等多区块页。 */ +export function AdminPageCard({ + title, + description, + actions, + children, + className, + contentClassName, + id, +}: AdminPageCardProps) { + return ( + + +
+ {title} + {description ? {description} : null} +
+ {actions ?
{actions}
: null} +
+ {children} +
+ ); +} diff --git a/src/components/admin/admin-page-section.tsx b/src/components/admin/admin-page-section.tsx new file mode 100644 index 0000000..e96c793 --- /dev/null +++ b/src/components/admin/admin-page-section.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +import { AdminSectionHeader } from "@/components/admin/admin-section-header"; +import { cn } from "@/lib/utils"; + +type AdminPageSectionProps = { + title: string; + description?: string; + actions?: ReactNode; + children: ReactNode; + className?: string; + id?: string; +}; + +/** 无 Card 的页内分区,标题样式与 ConfigSection 相同。 */ +export function AdminPageSection({ + title, + description, + actions, + children, + className, + id, +}: AdminPageSectionProps) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/components/admin/admin-permission-gate.tsx b/src/components/admin/admin-permission-gate.tsx new file mode 100644 index 0000000..801d8f9 --- /dev/null +++ b/src/components/admin/admin-permission-gate.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { useAdminProfile } from "@/stores/admin-session"; + +type AdminPermissionGateProps = { + requiredAny: readonly string[]; + children: ReactNode; + className?: string; +}; + +/** 深链进入无权限页面时展示拒绝说明,避免空白或反复 403。 */ +export function AdminPermissionGate({ + requiredAny, + children, + className, +}: AdminPermissionGateProps): React.ReactElement { + const { t } = useTranslation("common"); + const profile = useAdminProfile(); + const allowed = adminHasAnyPermission(profile?.permissions, [...requiredAny]); + + if (allowed) { + return <>{children}; + } + + return ( + + + {t("permission.deniedTitle")} + + +

{t("permission.deniedDescription")}

+
+
+ ); +} diff --git a/src/components/admin/admin-section-header.tsx b/src/components/admin/admin-section-header.tsx new file mode 100644 index 0000000..9f1fef0 --- /dev/null +++ b/src/components/admin/admin-section-header.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +type AdminSectionHeaderProps = { + title: string; + description?: string; + actions?: ReactNode; + className?: string; +}; + +/** 页内区块标题(与 ConfigSection 一致),用于 Card 内子分区或配置文档。 */ +export function AdminSectionHeader({ + title, + description, + actions, + className, +}: AdminSectionHeaderProps) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+ ); +} diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index 7bf1c53..83cf47d 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -18,7 +18,7 @@ import { SidebarRail, SidebarSeparator, } from "@/components/ui/sidebar"; -import { adminNavIconBySegment } from "@/modules/_config/admin-nav-icons"; +import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons"; import { ADMIN_BASE } from "@/modules/_config/admin-nav"; import { useAdminProfile } from "@/stores/admin-session"; @@ -82,7 +82,7 @@ export function AdminAppSidebar() { {visibleNav.map((item) => { - const Icon = adminNavIconBySegment[item.segment]; + const Icon = resolveAdminNavIcon(item.segment); return ( void; + title: string; + description: string; + confirmLabel: string; + cancelLabel: string; + confirmVariant?: "default" | "destructive"; + busy?: boolean; + onConfirm: () => void; +}; + +export function ConfirmActionDialog({ + open, + onOpenChange, + title, + description, + confirmLabel, + cancelLabel, + confirmVariant = "destructive", + busy = false, + onConfirm, +}: ConfirmActionDialogProps) { + const { t } = useTranslation("common"); + + return ( + + + + {title} + {description} + + + + + + + + ); +} diff --git a/src/components/admin/toolbar.tsx b/src/components/admin/toolbar.tsx index 852c1ff..5488f2f 100644 --- a/src/components/admin/toolbar.tsx +++ b/src/components/admin/toolbar.tsx @@ -5,7 +5,6 @@ import { LogOutIcon, UserRoundIcon, } from "lucide-react"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -67,11 +66,12 @@ export function ShellToolbar() { - - - - {t("toolbar.accountSettings")} - + router.push("/admin/account")} + > + + {t("toolbar.accountSettings")} diff --git a/src/hooks/use-confirm-action.tsx b/src/hooks/use-confirm-action.tsx new file mode 100644 index 0000000..ea9c213 --- /dev/null +++ b/src/hooks/use-confirm-action.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { ConfirmActionDialog } from "@/components/admin/confirm-action-dialog"; + +export type ConfirmActionRequest = { + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + confirmVariant?: "default" | "destructive"; + onConfirm: () => void | Promise; +}; + +export function useConfirmAction() { + const { t } = useTranslation("common"); + const [pending, setPending] = useState(null); + const [busy, setBusy] = useState(false); + + const request = useCallback((req: ConfirmActionRequest) => { + setPending(req); + }, []); + + const dismiss = useCallback(() => { + if (!busy) { + setPending(null); + } + }, [busy]); + + const confirm = useCallback(async () => { + if (!pending || busy) { + return; + } + setBusy(true); + try { + await pending.onConfirm(); + setPending(null); + } finally { + setBusy(false); + } + }, [pending, busy]); + + const ConfirmDialog = useCallback( + () => ( + { + if (!open) { + dismiss(); + } + }} + title={pending?.title ?? ""} + description={pending?.description ?? ""} + confirmLabel={pending?.confirmLabel ?? t("confirm.confirm")} + cancelLabel={pending?.cancelLabel ?? t("confirm.cancel")} + confirmVariant={pending?.confirmVariant ?? "destructive"} + busy={busy} + onConfirm={() => void confirm()} + /> + ), + [pending, busy, dismiss, confirm, t], + ); + + return { request, dismiss, busy, ConfirmDialog }; +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 2a70859..b780f04 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -110,7 +110,7 @@ const resources = { }, } satisfies Record>>; -function normalizeAdminLanguage(lang: string | undefined): AdminLanguage { +export function normalizeAdminLanguage(lang: string | undefined): AdminLanguage { const base = lang?.split("-")[0]?.toLowerCase(); if (base === "ne") return "ne"; if (base === "zh") return "zh"; diff --git a/src/i18n/locales/en/adminUsers.json b/src/i18n/locales/en/adminUsers.json index 064a803..5df98ce 100644 --- a/src/i18n/locales/en/adminUsers.json +++ b/src/i18n/locales/en/adminUsers.json @@ -159,6 +159,7 @@ "prd.rebate.view": "Commission/Rebate · View", "prd.jackpot.manage": "Jackpot Configuration · Manage", "prd.jackpot.view": "Jackpot Configuration · View", + "prd.jackpot.manual_burst": "Jackpot Manual Burst · Super Admin Only", "prd.payout.manage": "Payout Confirmation · Manage", "prd.payout.review": "Payout Confirmation · Review", "prd.payout.view": "Payout Confirmation · View", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index c5c25b8..e72613a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -26,7 +26,31 @@ "createTask": "Create task", "clear": "Clear", "done": "Done", - "exportExcel": "Export Excel" + "exportExcel": "Export Excel", + "save": "Save changes", + "updateSuccess": "Updated successfully", + "updateFailed": "Update failed", + "updatePassword": "Update password" + }, + "accountSettings": "Account settings", + "accountSettingsDesc": "Manage your profile and security settings.", + "profileSettings": "Profile", + "profileSettingsDesc": "Update your display name.", + "securitySettings": "Security", + "securitySettingsDesc": "Change your login password. Leave blank if you are not changing it.", + "fields": { + "nickname": "Nickname", + "newPassword": "New password", + "confirmPassword": "Confirm password" + }, + "placeholders": { + "nickname": "Enter nickname", + "password": "Enter new password", + "confirmPassword": "Re-enter new password" + }, + "validation": { + "required": "{{field}} is required", + "passwordMismatch": "Passwords do not match" }, "aria": { "expand": "Expand", @@ -59,7 +83,16 @@ "date": { "placeholder": "Select date", "rangePlaceholder": "Select date range", - "rangeHint": "Select a start date, then an end date. For a single day, click the same date twice. Click Done to close." + "rangeHint": "Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.", + "weekdays": { + "sunday": "Sunday", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday" + } }, "pagination": { "perPage": "Per page", @@ -76,6 +109,10 @@ "errors": { "loadFailed": "Failed to load" }, + "permission": { + "deniedTitle": "Access denied", + "deniedDescription": "Your account does not have permission to open this page. Ask an administrator to assign the required role permissions." + }, "table": { "id": "ID" }, @@ -98,6 +135,7 @@ "draws": "Draws", "rules_plays": "Play rules", "rules_odds": "Odds & rebate", + "rules": "Betting rules", "risk_cap": "Risk cap rules", "risk": "Risk center", "settlement": "Settlement", @@ -105,12 +143,18 @@ "reconcile": "Reconcile", "tickets": "Ticket list", "audit": "Audit Logs", - "settings": "Settings" + "settings": "Settings", + "account": "Account settings" }, "sidebar": { "workspace": "Workspace" }, "auth": { "checking": "Checking sign-in status…" + }, + "confirm": { + "cancel": "Cancel", + "confirm": "Confirm", + "confirmSave": "Save" } } diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index b9324a2..5f2b924 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -83,7 +83,9 @@ "outMin": "Per-order minimum from lottery wallet to main wallet", "outMax": "Per-order maximum from lottery wallet to main wallet" }, - "discard": "Discard changes" + "discard": "Discard changes", + "confirmSaveTitle": "Save wallet limits?", + "confirmSaveDescription": "This updates per-order transfer-in/out limits and immediately affects player wallet transfers." }, "system": { "title": "Draw and settlement runtime settings", @@ -99,19 +101,25 @@ "manualReview": "Require manual review for draw results", "cooldownMinutes": "Cooldown duration (minutes)", "autoSettlement": "Run settlement automatically", + "autoApprove": "Auto-approve settlement batches", + "autoPayout": "Auto-credit winnings to wallets", "playRulesHtml": "Play rules HTML (i18n)", "playRulesHtmlDesc": "Rendered on the player play-rules page per locale. Leave empty to fall back to another language or the default empty state." }, "hints": { "manualReview": "When enabled, RNG draw results enter pending review and must be published manually in admin.", "cooldownMinutes": "How long to wait after publishing before entering settling. Use 0 to settle immediately.", - "autoSettlement": "When disabled, tick will not run settlement automatically and admins must trigger it manually." + "autoSettlement": "When disabled, tick will not run settlement automatically and admins must trigger it manually.", + "autoApprove": "After cooldown ends and settlement completes, whether batches are automatically marked as approved.", + "autoPayout": "After a batch is approved, whether tick automatically credits winnings to player wallets." }, "states": { "enabled": "Enabled", "disabled": "Disabled" }, - "discard": "Discard changes" + "discard": "Discard changes", + "confirmSaveTitle": "Save system runtime parameters?", + "confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation." }, "currencies": { "title": "Currency management", @@ -173,9 +181,23 @@ }, "validation": { "minMaxInvalid": "{{playCode}}: min bet cannot exceed max bet", - "nameZhRequired": "Chinese display name is required" + "displayNameRequired": "Display name is required" }, "publishFailed": "Publish failed", + "publishDialog": { + "title": "Publish play configuration?", + "description": "New settings affect future bets. Existing tickets still settle by their saved snapshot.", + "confirm": "Confirm publish" + }, + "batchSwitchConfirmTitle": "Batch {{action}}?", + "batchSwitchConfirmDescription": "{{action}} {{count}} play types under «{{group}}» and write to the current draft.", + "batchSwitchEnable": "Enable", + "batchSwitchDisable": "Disable", + "toggleConfirmTitle": "{{action}} play {{playCode}}?", + "toggleConfirmDescription": "This calls the API immediately (not draft-only).", + "toggleEnable": "Enable", + "toggleDisable": "Disable", + "toggleInstantFailed": "Failed to apply play switch. Try again later.", "createDraftSuccess": "Created draft v{{version}}", "createDraftFailed": "Failed to create draft", "ruleSavedLocal": "Rule text was saved into the local draft. Save the draft to persist it.", @@ -191,7 +213,7 @@ "enable": "Enable", "disable": "Disable", "ruleText": "Rule text", - "displayNames": "Display names" + "editDisplayName": "Edit name" }, "locales": { "zh": "Chinese", @@ -217,8 +239,8 @@ "enablePlay": "Enable {{playCode}}" }, "nameDialog": { - "title": "Display names (i18n)", - "description": "Play {{playCode}}. Chinese is required; English and Nepali are optional. The player site picks the label by locale after publish.", + "title": "Edit display name", + "description": "Play {{playCode}}. The player site shows this label after you save and publish the draft.", "apply": "Apply to draft", "savedLocal": "Display names were saved into the local draft. Save the draft to persist them." }, @@ -228,6 +250,13 @@ "apply": "Apply to draft" } }, + "prizeScopes": { + "first": "First prize odds", + "second": "Second prize odds", + "third": "Third prize odds", + "starter": "Starter prize odds", + "consolation": "Consolation prize odds" + }, "odds": { "sectionHint": "Pick a version to edit prize-tier odds; publishing applies to new tickets immediately.", "tabs": { @@ -273,6 +302,11 @@ "publishLabel": "Publish", "publishSuccess": "Published odds version with rebate", "publishFailed": "Publish failed", + "publishDialog": { + "title": "Publish rebate/odds version?", + "description": "After publish, rebate calculation applies to new tickets.", + "confirm": "Confirm publish" + }, "createDraftSuccess": "Created draft v{{version}}", "createDraftFailed": "Failed to create draft", "deleteFailed": "Delete failed", @@ -297,6 +331,11 @@ "enterValidCapAmount": "Enter a valid cap amount" }, "publishFailed": "Publish failed", + "publishDialog": { + "title": "Publish cap configuration?", + "description": "After publish, per-number risk-pool cap limits take effect.", + "confirm": "Confirm publish" + }, "createDraftSuccess": "Created draft v{{version}}", "createDraftFailed": "Failed to create draft", "savedLocalDraft": "Saved into local draft. Save the draft to persist it.", diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json index 8302b94..06d68a0 100644 --- a/src/i18n/locales/en/dashboard.json +++ b/src/i18n/locales/en/dashboard.json @@ -2,7 +2,65 @@ "title": "Dashboard", "refresh": "Refresh", "notice": "Notice", - "todayBetTotal": "Current draw total bet", + "sections": { + "today": "Today", + "lifetime": "All-time totals", + "currentDraw": "Current draw", + "currentDrawDetail": "Current draw · {{drawNo}}", + "operations": "Operations (current draw)" + }, + "analytics": { + "title": "Financial analytics", + "periodLabel": "Period", + "metricLabel": "Metric", + "playLabel": "Play filter", + "allPlays": "All plays", + "customRange": "Custom dates", + "rangeHint": "Range {{range}}", + "selectPeriod": "Select a period", + "chartTruncated": "Trend shows {{from}} — {{to}} only ({{days}} days in full range)", + "summaryBet": "Period bet", + "summaryPayout": "Period payout", + "summaryProfit": "Period profit", + "dailyTrend": "Daily trend", + "playBreakdown": "Play breakdown", + "periodDistribution": "Period structure", + "noPlayData": "No play data in this period", + "periods": { + "today": "Today", + "last_7_days": "Last 7 days", + "last_30_days": "Last 30 days", + "this_month": "This month", + "lifetime": "All time", + "custom": "Custom" + }, + "metrics": { + "overview": "Overview", + "bet": "Bet", + "payout": "Payout", + "profit": "Profit" + } + }, + "chartLegend": { + "bet": "Bet", + "payout": "Payout", + "profit": "Profit" + }, + "playBreakdownHint": "Payout {{payout}} · Profit {{profit}}", + "viewReports": "Reports", + "lifetimeBetTotal": "Lifetime total bet", + "lifetimePayout": "Lifetime total payout", + "lifetimeProfit": "Lifetime platform profit", + "lifetimeActivityHint": "{{draws}} draws with bets · {{days}} business days", + "lifetimeDateRangeHint": "Range {{range}}", + "currentDrawBetTotal": "Draw total bet", + "currentDrawPayout": "Draw payout", + "currentDrawProfit": "Draw profit", + "drawFinanceDetails": "Draw finance details", + "todayBetTotal": "Today's total bet", + "todayPayout": "Today's payout", + "todayProfit": "Today's profit", + "todayBusinessDateHint": "Business date {{date}}", "drawNoHint": "Draw {{drawNo}}", "orderAndTicket": "{{orders}} orders · {{tickets}} items", "marginRate": "Gross margin ~{{rate}}%", @@ -21,8 +79,9 @@ "settlementOverview": "Settlement batches", "noSettlementBatches": "No settlement batches", "quickLinksTitle": "Quick links", - "currentPayout": "Current payout", - "currentProfit": "Current platform profit", + "currentPayout": "Current draw payout", + "currentProfit": "Current draw profit", + "currentDrawFinanceHint": "Charts below are for draw {{drawNo}}", "currentDraw": "Current draw", "drawSequence": "Round {{sequence}}", "drawDetails": "Draw details", @@ -64,8 +123,9 @@ "auditLogs": "Audit logs" }, "warnings": { - "drawPermission": "This account has no draw view/manage permission. Finance and risk data were not returned.", + "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.", - "loadFailed": "Failed to load. Check the API and login state." + "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/draws.json b/src/i18n/locales/en/draws.json index e333c95..2a681a6 100644 --- a/src/i18n/locales/en/draws.json +++ b/src/i18n/locales/en/draws.json @@ -144,5 +144,23 @@ "third": "3rd prize", "starter": "Starter {{index}}", "consolation": "Consolation {{index}}" + }, + "confirm": { + "manualCloseTitle": "Confirm manual close?", + "manualCloseDescription": "Players will no longer be able to bet on this draw.", + "cancelDrawTitle": "Confirm cancel draw?", + "cancelDrawDescription": "This draw will not be drawn. Ensure there is no outstanding bet risk.", + "rngDrawTitle": "Confirm RNG draw?", + "rngDrawDescription": "The system will generate draw numbers and continue the pipeline.", + "reopenTitle": "Confirm cooldown reopen?", + "reopenDescription": "Results may need re-review; displayed numbers may change.", + "runSettlementTitle": "Confirm run settlement?", + "runSettlementDescription": "A settlement batch will be created from the published result.", + "saveManualDraftTitle": "Confirm save manual draft?", + "saveManualDraftDescription": "23 numbers will be saved for review.", + "publishTitle": "Confirm publish results?", + "publishDescription": "Results become visible to players and may trigger settlement.", + "generatePlanTitle": "Confirm generate draw plan?", + "generatePlanDescription": "Future bettable draws will be created per system rules." } } diff --git a/src/i18n/locales/en/jackpot.json b/src/i18n/locales/en/jackpot.json index edfd21a..1fb4789 100644 --- a/src/i18n/locales/en/jackpot.json +++ b/src/i18n/locales/en/jackpot.json @@ -25,10 +25,14 @@ "enabled": "Enabled", "saving": "Saving…", "save": "Save", - "manualBurstDrawId": "Manual burst draw number", - "manualBurstAmount": "Burst amount (empty for all)", + "manualBurstDrawId": "Draw ID for manual burst", + "manualBurstHint": "Super admin only. Requires a settled draw with first-prize winners. Pool release follows the configured payout rate.", + "manualBurstConfirmTitle": "Confirm manual jackpot burst?", + "manualBurstConfirmDescription": "Jackpot will be split among first-prize winners for draw {{drawId}} using the payout rate. Pool balance will be reduced. This cannot be undone automatically.", "processing": "Processing…", - "manualBurst": "Manual burst", + "manualBurst": "Manual burst (super admin only)", + "manualBurstConfirm": "Confirm burst", + "cancel": "Cancel", "filter": "Filter", "drawNo": "Draw no.", "optional": "Optional", diff --git a/src/i18n/locales/en/players.json b/src/i18n/locales/en/players.json index 08c194a..7441705 100644 --- a/src/i18n/locales/en/players.json +++ b/src/i18n/locales/en/players.json @@ -29,6 +29,12 @@ "lastLogin": "Last login", "actions": "Actions", "edit": "Edit", + "freeze": "Freeze", + "unfreeze": "Unfreeze", + "freezeSuccess": "Player {{name}} frozen", + "unfreezeSuccess": "Player {{name}} unfrozen", + "freezeFailed": "Failed to freeze player", + "unfreezeFailed": "Failed to unfreeze player", "delete": "Delete", "createDialogTitle": "Create player", "editDialogTitle": "Edit player", @@ -44,6 +50,10 @@ "cancel": "Cancel", "save": "Save", "saving": "Saving…", + "confirmFreezeTitle": "Confirm freeze player?", + "confirmFreezeDescription": "Player {{name}} will not be able to place bets.", + "confirmUnfreezeTitle": "Confirm unfreeze player?", + "confirmUnfreezeDescription": "Player {{name}} will return to normal status.", "confirmDelete": "Confirm delete", "confirmDeleteDesc": "Delete player {{name}}? This action cannot be undone." } diff --git a/src/i18n/locales/en/reconcile.json b/src/i18n/locales/en/reconcile.json index 5ae8ac1..97075c4 100644 --- a/src/i18n/locales/en/reconcile.json +++ b/src/i18n/locales/en/reconcile.json @@ -13,6 +13,10 @@ "periodRequired": "Enter both reconcile start and end dates", "periodInvalid": "Invalid date range", "periodOrderInvalid": "End time must be later than or equal to start time", + "confirmCreateTitle": "Create reconcile job?", + "confirmCreateDescription": "Start a manual reconcile for the selected date range{{playerHint}}.", + "confirmCreatePlayer": " for the selected player", + "confirmCreateAllPlayers": " (all players)", "createSuccess": "Reconcile job created", "createFailed": "Failed to create job", "noCreatePermission": "Current account cannot create reconcile jobs.", diff --git a/src/i18n/locales/en/risk.json b/src/i18n/locales/en/risk.json index 55173fc..412c2ee 100644 --- a/src/i18n/locales/en/risk.json +++ b/src/i18n/locales/en/risk.json @@ -46,6 +46,12 @@ "manualCloseSuccess": "Number betting closed manually", "recoverSuccess": "Number betting recovered", "actionFailed": "Action failed", + "confirm": { + "closeTitle": "Confirm close number?", + "closeDescription": "Number {{number}} will be blocked for this draw.", + "recoverTitle": "Confirm recover number?", + "recoverDescription": "Number {{number}} will be open for betting again." + }, "detailTitle": "Risk pool details", "loadDetailFailed": "Failed to load risk pool details", "backToList": "Back to list", diff --git a/src/i18n/locales/en/tickets.json b/src/i18n/locales/en/tickets.json index 4e00dcb..436a67a 100644 --- a/src/i18n/locales/en/tickets.json +++ b/src/i18n/locales/en/tickets.json @@ -34,6 +34,7 @@ "pending_confirm": "Pending confirmation", "partial_pending_confirm": "Partially pending confirmation", "success": "Bet placed", + "pending_draw": "Awaiting draw", "failed": "Bet failed", "pending_payout": "Pending payout", "settled_win": "Settled win", diff --git a/src/i18n/locales/en/wallet.json b/src/i18n/locales/en/wallet.json index bafbd09..7c6621b 100644 --- a/src/i18n/locales/en/wallet.json +++ b/src/i18n/locales/en/wallet.json @@ -47,6 +47,12 @@ "reverseSuccess": "Reversed successfully", "manualProcessSuccess": "Manually processed successfully", "actionFailed": "Action failed", + "confirm": { + "reverseTitle": "Confirm reverse transfer?", + "reverseDescription": "Reverse order {{transferNo}}. This may affect player wallet balance.", + "manualProcessTitle": "Confirm manual process?", + "manualProcessDescription": "Mark order {{transferNo}} as manually processed without automatic wallet adjustment." + }, "txnNo": "Txn no.", "bizType": "Business type", "type": "Type", diff --git a/src/i18n/locales/ne/adminUsers.json b/src/i18n/locales/ne/adminUsers.json index 67874ef..3f7b245 100644 --- a/src/i18n/locales/ne/adminUsers.json +++ b/src/i18n/locales/ne/adminUsers.json @@ -159,6 +159,7 @@ "prd.rebate.view": "कमिसन/रिबेट · हेर्नुहोस्", "prd.jackpot.manage": "ज्याकपोट कन्फिगरेसन · व्यवस्थापन", "prd.jackpot.view": "ज्याकपोट कन्फिगरेसन · हेर्नुहोस्", + "prd.jackpot.manual_burst": "ज्याकपोट म्यानुअल बर्स्ट · सुपर एडमिन मात्र", "prd.payout.manage": "भुक्तानी पुष्टि · व्यवस्थापन", "prd.payout.review": "भुक्तानी पुष्टि · समीक्षा", "prd.payout.view": "भुक्तानी पुष्टि · हेर्नुहोस्", diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json index 5409527..7b33aaf 100644 --- a/src/i18n/locales/ne/common.json +++ b/src/i18n/locales/ne/common.json @@ -26,7 +26,31 @@ "createTask": "टास्क सिर्जना गर्नुहोस्", "clear": "खाली गर्नुहोस्", "done": "समाप्त", - "exportExcel": "Excel निर्यात" + "exportExcel": "Excel निर्यात", + "save": "परिवर्तन सुरक्षित गर्नुहोस्", + "updateSuccess": "सफलतापूर्वक अद्यावधिक भयो", + "updateFailed": "अद्यावधिक असफल भयो", + "updatePassword": "पासवर्ड अद्यावधिक गर्नुहोस्" + }, + "accountSettings": "खाता सेटिङ", + "accountSettingsDesc": "आफ्नो प्रोफाइल र सुरक्षा सेटिङ व्यवस्थापन गर्नुहोस्।", + "profileSettings": "आधारभूत प्रोफाइल", + "profileSettingsDesc": "आफ्नो प्रदर्शन नाम अद्यावधिक गर्नुहोस्।", + "securitySettings": "सुरक्षा सेटिङ", + "securitySettingsDesc": "लगइन पासवर्ड परिवर्तन गर्नुहोस्। नपरिवर्तन गर्दा खाली छोड्नुहोस्।", + "fields": { + "nickname": "उपनाम", + "newPassword": "नयाँ पासवर्ड", + "confirmPassword": "पासवर्ड पुष्टि" + }, + "placeholders": { + "nickname": "उपनाम प्रविष्ट गर्नुहोस्", + "password": "नयाँ पासवर्ड प्रविष्ट गर्नुहोस्", + "confirmPassword": "पासवर्ड फेरि प्रविष्ट गर्नुहोस्" + }, + "validation": { + "required": "{{field}} अनिवार्य छ", + "passwordMismatch": "पासवर्ड मिलेन" }, "aria": { "expand": "खोल्नुहोस्", @@ -59,7 +83,16 @@ "date": { "placeholder": "मिति छान्नुहोस्", "rangePlaceholder": "मिति दायरा छान्नुहोस्", - "rangeHint": "सुरु मिति छान्नुहोस्, त्यसपछि अन्त्य मिति। एउटै दिनका लागि सोही मितिमा दुई पटक क्लिक गर्नुहोस्। बन्द गर्न Done थिच्नुहोस्।" + "rangeHint": "सुरु मिति छान्नुहोस्, त्यसपछि अन्त्य मिति। एउटै दिनका लागि सोही मितिमा दुई पटक क्लिक गर्नुहोस्। बन्द गर्न Done थिच्नुहोस्।", + "weekdays": { + "sunday": "आइतबार", + "monday": "सोमबार", + "tuesday": "मंगलबार", + "wednesday": "बुधबार", + "thursday": "बिहिबार", + "friday": "शुक्रबार", + "saturday": "शनिबार" + } }, "pagination": { "perPage": "प्रति पृष्ठ", @@ -76,6 +109,10 @@ "errors": { "loadFailed": "लोड असफल भयो" }, + "permission": { + "deniedTitle": "पहुँच अनुमति छैन", + "deniedDescription": "यो पृष्ठ खोल्ने अनुमति तपाईंको खातामा छैन। भूमिका व्यवस्थापनबाट आवश्यक अनुमति दिन प्रशासकलाई सम्पर्क गर्नुहोस्।" + }, "table": { "id": "ID" }, @@ -98,6 +135,7 @@ "draws": "ड्रअहरू", "rules_plays": "खेल नियम", "rules_odds": "बाधा र रिबेट", + "rules": "खेल नियम", "risk_cap": "जोखिम क्याप संस्करण", "risk": "जोखिम केन्द्र", "settlement": "सेटलमेन्ट", @@ -105,12 +143,18 @@ "reconcile": "मिलान", "tickets": "टिकट सूची", "audit": "अडिट लग", - "settings": "सेटिङ" + "settings": "सेटिङ", + "account": "खाता सेटिङ" }, "sidebar": { "workspace": "कार्यस्थान" }, "auth": { "checking": "लगइन स्थिति जाँच हुँदैछ…" + }, + "confirm": { + "cancel": "रद्द", + "confirm": "पुष्टि गर्नुहोस्", + "confirmSave": "सुरक्षित गर्नुहोस्" } } diff --git a/src/i18n/locales/ne/config.json b/src/i18n/locales/ne/config.json index 5528e06..ba89bf9 100644 --- a/src/i18n/locales/ne/config.json +++ b/src/i18n/locales/ne/config.json @@ -83,7 +83,9 @@ "outMin": "लटरी वालेटबाट मुख्य वालेटमा प्रति अर्डर न्यूनतम", "outMax": "लटरी वालेटबाट मुख्य वालेटमा प्रति अर्डर अधिकतम" }, - "discard": "परिवर्तन त्याग्नुहोस्" + "discard": "परिवर्तन त्याग्नुहोस्", + "confirmSaveTitle": "वालेट सीमा सुरक्षित गर्ने?", + "confirmSaveDescription": "ट्रान्सफर-इन/आउटको प्रति अर्डर सीमा अद्यावधिक हुन्छ र खेलाडीको वालेट ट्रान्सफरमा तुरुन्त असर पर्छ।" }, "system": { "title": "ड्रअ र सेटलमेन्ट रनटाइम सेटिङ", @@ -99,19 +101,25 @@ "manualReview": "ड्रअ परिणामका लागि म्यानुअल समीक्षा चाहिने", "cooldownMinutes": "कूलडाउन अवधि (मिनेट)", "autoSettlement": "सेटलमेन्ट स्वतः चलाउने", + "autoApprove": "सेटलमेन्ट ब्याच स्वतः स्वीकृत", + "autoPayout": "जित रकम स्वतः वालेटमा जम्मा", "playRulesHtml": "खेल नियम HTML (बहुभाषी)", "playRulesHtmlDesc": "खेलाडीको नियम पृष्ठमा भाषा अनुसार HTML देखिन्छ। खाली छोड्दा अर्को भाषा वा पूर्वनिर्धारित खाली सूचना देखिन्छ।" }, "hints": { "manualReview": "सक्रिय हुँदा RNG ड्रअ परिणाम pending review मा जान्छ र एडमिनबाट म्यानुअल रूपमा प्रकाशित गर्नुपर्छ।", "cooldownMinutes": "प्रकाशनपछि settling मा जानुअघि कति समय पर्खने। 0 राखे तुरुन्त सेटलमेन्ट सुरु हुन्छ।", - "autoSettlement": "बन्द हुँदा tick ले सेटलमेन्ट स्वतः चलाउँदैन र एडमिनले म्यानुअल रूपमा ट्रिगर गर्नुपर्छ।" + "autoSettlement": "बन्द हुँदा tick ले सेटलमेन्ट स्वतः चलाउँदैन र एडमिनले म्यानुअल रूपमा ट्रिगर गर्नुपर्छ।", + "autoApprove": "कूलडाउन सकिएर सेटलमेन्ट पूरा भएपछि ब्याच स्वतः अनुमोदित हुने हो कि होइन।", + "autoPayout": "ब्याच अनुमोदित भएपछि tick ले जित रकम खेलाडीको वालेटमा स्वतः जम्मा गर्ने हो कि होइन।" }, "states": { "enabled": "सक्रिय", "disabled": "बन्द" }, - "discard": "परिवर्तन त्याग्नुहोस्" + "discard": "परिवर्तन त्याग्नुहोस्", + "confirmSaveTitle": "प्रणाली रनटाइम प्यारामिटर सुरक्षित गर्ने?", + "confirmSaveDescription": "ड्रअ समीक्षा, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट र खेल नियम प्रदर्शन अद्यावधिक हुन्छ। साइटव्यापी सञ्चालनमा असर पर्न सक्छ।" }, "currencies": { "title": "मुद्रा व्यवस्थापन", @@ -173,9 +181,23 @@ }, "validation": { "minMaxInvalid": "{{playCode}}: न्यूनतम बेट अधिकतम बेटभन्दा ठूलो हुन सक्दैन", - "nameZhRequired": "चिनियाँ प्रदर्शित नाम अनिवार्य छ" + "displayNameRequired": "प्रदर्शित नाम अनिवार्य छ" }, "publishFailed": "प्रकाशन असफल भयो", + "publishDialog": { + "title": "खेल कन्फिग प्रकाशित गर्ने?", + "description": "नयाँ सेटिङले आगामी बेटहरूमा असर गर्छ। पुराना टिकटहरू आफ्नो snapshot अनुसार नै सेटल हुन्छन्।", + "confirm": "प्रकाशन पुष्टि गर्नुहोस्" + }, + "batchSwitchConfirmTitle": "समूह {{action}} पुष्टि गर्ने?", + "batchSwitchConfirmDescription": "«{{group}}» अन्तर्गत {{count}} खेल प्रकार {{action}} गरी हालको ड्राफ्टमा लेखिनेछ।", + "batchSwitchEnable": "सक्रिय", + "batchSwitchDisable": "निष्क्रिय", + "toggleConfirmTitle": "खेल {{playCode}} {{action}} गर्ने?", + "toggleConfirmDescription": "यो तुरुन्त API मार्फत लागू हुन्छ (केवल ड्राफ्ट मात्र होइन)।", + "toggleEnable": "सक्रिय", + "toggleDisable": "निष्क्रिय", + "toggleInstantFailed": "खेल स्विच तुरुन्त लागू गर्न असफल। पछि पुनः प्रयास गर्नुहोस्।", "createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो", "createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो", "ruleSavedLocal": "नियम पाठ स्थानीय ड्राफ्टमा सुरक्षित भयो। स्थायी बनाउन ड्राफ्ट सेभ गर्नुहोस्।", @@ -191,7 +213,7 @@ "enable": "सक्रिय", "disable": "निष्क्रिय", "ruleText": "नियम पाठ", - "displayNames": "बहुभाषी नाम" + "editDisplayName": "नाम सम्पादन" }, "locales": { "zh": "चिनियाँ", @@ -228,6 +250,13 @@ "apply": "ड्राफ्टमा लागू गर्नुहोस्" } }, + "prizeScopes": { + "first": "पहिलो पुरस्कार बाधा", + "second": "दोस्रो पुरस्कार बाधा", + "third": "तेस्रो पुरस्कार बाधा", + "starter": "स्टार्टर पुरस्कार बाधा", + "consolation": "सान्त्वना पुरस्कार बाधा" + }, "odds": { "sectionHint": "संस्करण छानेर पुरस्कार-स्तर बाधा सम्पादन गर्नुहोस्; प्रकाशनपछि नयाँ टिकटमा लागू हुन्छ।", "tabs": { @@ -273,6 +302,11 @@ "publishLabel": "प्रकाशन", "publishSuccess": "रिबेटसहितको अड्स संस्करण प्रकाशित भयो", "publishFailed": "प्रकाशन असफल भयो", + "publishDialog": { + "title": "रिबेट/अड्स संस्करण प्रकाशित गर्ने?", + "description": "प्रकाशनपछि नयाँ टिकटहरूको रिबेट गणनामा असर पर्छ।", + "confirm": "प्रकाशन पुष्टि गर्नुहोस्" + }, "createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो", "createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो", "deleteFailed": "मेटाउन असफल", @@ -297,6 +331,11 @@ "enterValidCapAmount": "मान्य क्याप रकम प्रविष्ट गर्नुहोस्" }, "publishFailed": "प्रकाशन असफल भयो", + "publishDialog": { + "title": "क्याप कन्फिग प्रकाशित गर्ने?", + "description": "प्रकाशनपछि प्रत्येक नम्बरको जोखिम पूल क्याप सीमा लागू हुन्छ।", + "confirm": "प्रकाशन पुष्टि गर्नुहोस्" + }, "createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो", "createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो", "savedLocalDraft": "स्थानीय ड्राफ्टमा सुरक्षित भयो। स्थायी बनाउन ड्राफ्ट सेभ गर्नुहोस्।", diff --git a/src/i18n/locales/ne/dashboard.json b/src/i18n/locales/ne/dashboard.json index 921ebd3..36183ed 100644 --- a/src/i18n/locales/ne/dashboard.json +++ b/src/i18n/locales/ne/dashboard.json @@ -2,7 +2,65 @@ "title": "ड्यासबोर्ड", "refresh": "रिफ्रेस", "notice": "सूचना", - "todayBetTotal": "हालको ड्रअ कुल बेट", + "sections": { + "today": "आजको सारांश", + "lifetime": "ऐतिहासिक कुल", + "currentDraw": "हालको ड्रअ", + "currentDrawDetail": "हालको ड्रअ · {{drawNo}}", + "operations": "सञ्चालन (हालको ड्रअ)" + }, + "analytics": { + "title": "वित्त विश्लेषण", + "periodLabel": "अवधि", + "metricLabel": "मेट्रिक", + "playLabel": "प्ले फिल्टर", + "allPlays": "सबै प्ले", + "customRange": "मिति दायरा", + "rangeHint": "अवधि {{range}}", + "selectPeriod": "अवधि छान्नुहोस्", + "chartTruncated": "ट्रेन्ड {{from}} — {{to}} मात्र (कुल {{days}} दिन)", + "summaryBet": "अवधि बेट", + "summaryPayout": "अवधि भुक्तानी", + "summaryProfit": "अवधि नाफा", + "dailyTrend": "दैनिक ट्रेन्ड", + "playBreakdown": "प्ले विभाजन", + "periodDistribution": "अवधि संरचना", + "noPlayData": "यस अवधिमा प्ले डाटा छैन", + "periods": { + "today": "आज", + "last_7_days": "पछिल्लो ७ दिन", + "last_30_days": "पछिल्लो ३० दिन", + "this_month": "यो महिना", + "lifetime": "सबै", + "custom": "अनुकूल" + }, + "metrics": { + "overview": "सिंहावलोकन", + "bet": "बेट", + "payout": "भुक्तानी", + "profit": "नाफा" + } + }, + "chartLegend": { + "bet": "बेट", + "payout": "भुक्तानी", + "profit": "नाफा" + }, + "playBreakdownHint": "भुक्तानी {{payout}} · नाफा {{profit}}", + "viewReports": "प्रतिवेदन", + "lifetimeBetTotal": "जम्मा बेट", + "lifetimePayout": "जम्मा भुक्तानी", + "lifetimeProfit": "जम्मा प्लेटफर्म नाफा", + "lifetimeActivityHint": "{{draws}} ड्रअमा बेट · {{days}} व्यापार दिन", + "lifetimeDateRangeHint": "अवधि {{range}}", + "currentDrawBetTotal": "हालको ड्रअ बेट", + "currentDrawPayout": "हालको भुक्तानी", + "currentDrawProfit": "हालको नाफा/नोक्सान", + "drawFinanceDetails": "ड्रअ वित्त विवरण", + "todayBetTotal": "आजको कुल बेट", + "todayPayout": "आजको भुक्तानी", + "todayProfit": "आजको नाफा/नोक्सान", + "todayBusinessDateHint": "व्यापार मिति {{date}}", "drawNoHint": "ड्रअ {{drawNo}}", "orderAndTicket": "{{orders}} अर्डर · {{tickets}} वस्तु", "marginRate": "सकल मार्जिन ~{{rate}}%", @@ -64,7 +122,7 @@ "auditLogs": "अडिट लग" }, "warnings": { - "drawPermission": "यो खातासँग ड्रअ हेर्ने वा व्यवस्थापन अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।", + "drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।", "walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।", "loadFailed": "लोड असफल भयो। API र लगइन अवस्था जाँच गर्नुहोस्।" } diff --git a/src/i18n/locales/ne/draws.json b/src/i18n/locales/ne/draws.json index fba13ea..90a898e 100644 --- a/src/i18n/locales/ne/draws.json +++ b/src/i18n/locales/ne/draws.json @@ -144,5 +144,23 @@ "third": "तेस्रो पुरस्कार", "starter": "विशेष {{index}}", "consolation": "सान्त्वना {{index}}" + }, + "confirm": { + "manualCloseTitle": "म्यानुअल बन्द पुष्टि?", + "manualCloseDescription": "खेलाडीहरूले यो ड्रअमा थप दांव लगाउन सक्ने छैनन्।", + "cancelDrawTitle": "ड्रअ रद्द पुष्टि?", + "cancelDrawDescription": "यो ड्रअ खुल्ने छैन।", + "rngDrawTitle": "RNG ड्रअ पुष्टि?", + "rngDrawDescription": "प्रणालीले नतिजा सिर्जना गर्नेछ।", + "reopenTitle": "कुलडाउन पुनः खोल्ने पुष्टि?", + "reopenDescription": "नतिजा पुनः समीक्षा हुन सक्छ।", + "runSettlementTitle": "सेटलमेन्ट सुरु पुष्टि?", + "runSettlementDescription": "प्रकाशित नतिजाबाट सेटलमेन्ट ब्याच बन्नेछ।", + "saveManualDraftTitle": "म्यानुअल ड्राफ्ट सुरक्षित पुष्टि?", + "saveManualDraftDescription": "२३ नम्बर समीक्षाका लागि सुरक्षित हुनेछ।", + "publishTitle": "नतिजा प्रकाशन पुष्टि?", + "publishDescription": "खेलाडीहरूले नतिजा देख्नेछन्।", + "generatePlanTitle": "ड्रअ योजना सिर्जना पुष्टि?", + "generatePlanDescription": "भविष्यका ड्रअहरू सिर्जना हुनेछन्।" } } diff --git a/src/i18n/locales/ne/jackpot.json b/src/i18n/locales/ne/jackpot.json index 91e4286..557c9ab 100644 --- a/src/i18n/locales/ne/jackpot.json +++ b/src/i18n/locales/ne/jackpot.json @@ -25,10 +25,14 @@ "enabled": "खुला", "saving": "सुरक्षित हुँदैछ…", "save": "सुरक्षित गर्नुहोस्", - "manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ नम्बर", - "manualBurstAmount": "बर्स्ट रकम (खाली भए सबै)", + "manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ ID", + "manualBurstHint": "सुपर एडमिन मात्र। बसेको ड्रअ र प्रथम पुरस्कार विजेताहरू चाहिन्छ। पेआउट दर अनुसार वितरण हुन्छ।", + "manualBurstConfirmTitle": "म्यानुअल बर्स्ट पुष्टि गर्ने?", + "manualBurstConfirmDescription": "ड्रअ {{drawId}} का प्रथम पुरस्कार विजेताहरूलाई Jackpot वितरण गरिनेछ।", "processing": "प्रक्रियामा…", - "manualBurst": "म्यानुअल बर्स्ट", + "manualBurst": "म्यानुअल बर्स्ट (सुपर एडमिन)", + "manualBurstConfirm": "बर्स्ट पुष्टि", + "cancel": "रद्द", "filter": "फिल्टर", "drawNo": "ड्रअ नं.", "optional": "वैकल्पिक", diff --git a/src/i18n/locales/ne/players.json b/src/i18n/locales/ne/players.json index 2c795eb..42105f7 100644 --- a/src/i18n/locales/ne/players.json +++ b/src/i18n/locales/ne/players.json @@ -29,6 +29,12 @@ "lastLogin": "अन्तिम लगइन", "actions": "कार्य", "edit": "सम्पादन", + "freeze": "रोक्नुहोस्", + "unfreeze": "फुकाउनुहोस्", + "freezeSuccess": "खेलाडी {{name}} रोकियो", + "unfreezeSuccess": "खेलाडी {{name}} फुकाइयो", + "freezeFailed": "रोक्न सकिएन", + "unfreezeFailed": "फुकाउन सकिएन", "delete": "मेटाउनुहोस्", "createDialogTitle": "खेलाडी सिर्जना", "editDialogTitle": "खेलाडी सम्पादन", diff --git a/src/i18n/locales/ne/tickets.json b/src/i18n/locales/ne/tickets.json index 3ea410a..c0eb568 100644 --- a/src/i18n/locales/ne/tickets.json +++ b/src/i18n/locales/ne/tickets.json @@ -33,6 +33,7 @@ "pending_confirm": "पुष्टि बाँकी", "partial_pending_confirm": "आंशिक पुष्टि बाँकी", "success": "बेट सफल", + "pending_draw": "ड्र पर्खँदै", "failed": "बेट असफल", "pending_payout": "भुक्तानी बाँकी", "settled_win": "जित सेटल भयो", diff --git a/src/i18n/locales/zh/adminUsers.json b/src/i18n/locales/zh/adminUsers.json index b452217..e0be3d6 100644 --- a/src/i18n/locales/zh/adminUsers.json +++ b/src/i18n/locales/zh/adminUsers.json @@ -119,6 +119,16 @@ "confirmTitle": "删除角色", "confirmDescription": "确认删除角色 {{name}}?" }, + "confirmSaveRolesTitle": "确认保存管理员角色?", + "confirmSaveRolesDescription": "将更新管理员 {{name}} 的角色绑定,其后台权限会随之变化。", + "confirmSaveAccountTitle": "确认保存管理员账号?", + "confirmSaveAccountCreateDescription": "将创建新管理员账号并授予所选角色。", + "confirmSaveAccountEditDescription": "将更新管理员 {{name}} 的账号信息(含状态与密码变更)。", + "confirmSaveRolePermissionsTitle": "确认保存角色权限?", + "confirmSaveRolePermissionsDescription": "将更新角色「{{name}}」的功能权限,所有绑定该角色的管理员会立即生效。", + "confirmSaveRoleTitle": "确认保存角色信息?", + "confirmSaveRoleCreateDescription": "将创建新角色 {{name}}。", + "confirmSaveRoleEditDescription": "将更新角色 {{name}} 的名称、说明与状态。", "permissionGroups": { "all": "全部权限", "dashboard": "仪表盘", @@ -159,6 +169,7 @@ "prd.rebate.view": "佣金/回水·查看", "prd.jackpot.manage": "奖池配置·可管理", "prd.jackpot.view": "奖池配置·查看", + "prd.jackpot.manual_burst": "奖池手动爆池·仅超管", "prd.payout.manage": "派彩确认·可管理", "prd.payout.review": "派彩确认·可审核", "prd.payout.view": "派彩确认·查看", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index ce160e1..5bb9d28 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -26,7 +26,31 @@ "createTask": "创建任务", "clear": "清除", "done": "完成", - "exportExcel": "导出 Excel" + "exportExcel": "导出 Excel", + "save": "保存修改", + "updateSuccess": "更新成功", + "updateFailed": "更新失败", + "updatePassword": "更新密码" + }, + "accountSettings": "账号设置", + "accountSettingsDesc": "管理您的基本账号资料及安全设置。", + "profileSettings": "基本资料", + "profileSettingsDesc": "更新您的显示名称。", + "securitySettings": "安全设置", + "securitySettingsDesc": "修改您的登录密码。如不修改请留空。", + "fields": { + "nickname": "昵称", + "newPassword": "新密码", + "confirmPassword": "确认密码" + }, + "placeholders": { + "nickname": "请输入昵称", + "password": "请输入新密码", + "confirmPassword": "请再次输入新密码" + }, + "validation": { + "required": "请填写{{field}}", + "passwordMismatch": "两次输入的密码不一致" }, "aria": { "expand": "展开", @@ -59,7 +83,16 @@ "date": { "placeholder": "选择日期", "rangePlaceholder": "选择日期范围", - "rangeHint": "先选开始日,再选结束日(单日可对同一天点两次);点「完成」关闭面板。" + "rangeHint": "先选开始日,再选结束日(单日可对同一天点两次);点「完成」关闭面板。", + "weekdays": { + "sunday": "星期日", + "monday": "星期一", + "tuesday": "星期二", + "wednesday": "星期三", + "thursday": "星期四", + "friday": "星期五", + "saturday": "星期六" + } }, "pagination": { "perPage": "每页条数", @@ -76,6 +109,10 @@ "errors": { "loadFailed": "加载失败" }, + "permission": { + "deniedTitle": "无访问权限", + "deniedDescription": "当前账号没有访问此页面的权限。如需开通,请联系管理员在角色管理中分配相应功能权限。" + }, "table": { "id": "ID" }, @@ -98,6 +135,7 @@ "draws": "期号列表", "rules_plays": "投注规则", "rules_odds": "赔率与回水", + "rules": "投注规则", "risk_cap": "限额版本", "risk": "风控中心", "settlement": "结算", @@ -105,12 +143,18 @@ "reconcile": "对账", "tickets": "注单列表", "audit": "审计日志", - "settings": "系统设置" + "settings": "系统设置", + "account": "账号设置" }, "sidebar": { "workspace": "工作台" }, "auth": { "checking": "正在校验登录状态…" + }, + "confirm": { + "cancel": "取消", + "confirm": "确认执行", + "confirmSave": "确认保存" } } diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index 6b98801..357a6ad 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -83,7 +83,9 @@ "outMin": "彩票钱包转出主站钱包的单笔下限", "outMax": "彩票钱包转出主站钱包的单笔上限" }, - "discard": "放弃更改" + "discard": "放弃更改", + "confirmSaveTitle": "确认保存钱包限额?", + "confirmSaveDescription": "将更新转入/转出单笔限额,立即影响玩家钱包转账。" }, "system": { "title": "开奖与结算运行参数", @@ -99,19 +101,25 @@ "manualReview": "开奖结果必须人工审核", "cooldownMinutes": "冷静期时长(分钟)", "autoSettlement": "自动执行结算", + "autoApprove": "自动审核结算批次", + "autoPayout": "自动派彩入账", "playRulesHtml": "玩法规则 HTML(多语言)", "playRulesHtmlDesc": "该内容将直接在玩家端的玩法规则页面作为 HTML 渲染。按语言分别配置;留空则回退其它语言或显示默认提示。" }, "hints": { "manualReview": "开启后,RNG 开奖结果会先进入待审核,必须由后台人工发布。", "cooldownMinutes": "结果发布后等待多久再进入 settling。填 0 表示发布后直接进入结算。", - "autoSettlement": "关闭后,tick 不会自动跑结算,只能由后台手工执行。" + "autoSettlement": "关闭后,tick 不会自动跑结算,只能由后台手工执行。", + "autoApprove": "冷静期结束并跑完结算后,是否自动将批次标记为已审核。", + "autoPayout": "批次已审核后,是否由 tick 自动把中奖金额打入玩家钱包。" }, "states": { "enabled": "已开启", "disabled": "已关闭" }, - "discard": "放弃更改" + "discard": "放弃更改", + "confirmSaveTitle": "确认保存系统运行参数?", + "confirmSaveDescription": "将更新开奖审核、冷静期、自动结算/审核/派彩及玩法规则展示,可能影响全站运行。" }, "currencies": { "title": "币种管理", @@ -173,9 +181,23 @@ }, "validation": { "minMaxInvalid": "{{playCode}}:最小下注额不能大于最大下注额", - "nameZhRequired": "中文显示名称不能为空" + "displayNameRequired": "显示名称不能为空" }, "publishFailed": "发布失败", + "publishDialog": { + "title": "确认发布玩法配置?", + "description": "新配置将影响后续下注;已下注注单仍按各自快照结算。", + "confirm": "确认发布" + }, + "batchSwitchConfirmTitle": "确认批量{{action}}?", + "batchSwitchConfirmDescription": "将{{action}}「{{group}}」下 {{count}} 个玩法,并写入当前草稿。", + "batchSwitchEnable": "开启", + "batchSwitchDisable": "关闭", + "toggleConfirmTitle": "确认{{action}}玩法 {{playCode}}?", + "toggleConfirmDescription": "将立即调用接口生效(不仅限于草稿)。", + "toggleEnable": "开启", + "toggleDisable": "关闭", + "toggleInstantFailed": "玩法开关即时生效失败,请稍后重试", "createDraftSuccess": "已创建草稿 v{{version}}", "createDraftFailed": "创建草稿失败", "ruleSavedLocal": "规则文案已写入本地草稿,记得保存草稿后再发布。", @@ -191,7 +213,7 @@ "enable": "开启", "disable": "关闭", "ruleText": "规则文案", - "displayNames": "多语言名称" + "editDisplayName": "编辑名称" }, "locales": { "zh": "中文", @@ -217,8 +239,8 @@ "enablePlay": "切换 {{playCode}} 启用状态" }, "nameDialog": { - "title": "显示名称(多语言)", - "description": "玩法 {{playCode}};中文必填,英文与尼泊尔语可选。保存草稿并发布后,前台按玩家语言展示。", + "title": "编辑显示名称", + "description": "玩法 {{playCode}};保存草稿并发布后,玩家端将展示该名称。", "apply": "应用到草稿", "savedLocal": "显示名称已写入本地草稿,记得保存草稿后再发布。" }, @@ -228,6 +250,13 @@ "apply": "应用到草稿" } }, + "prizeScopes": { + "first": "头奖赔率", + "second": "二奖赔率", + "third": "三奖赔率", + "starter": "特别奖赔率", + "consolation": "安慰奖赔率" + }, "odds": { "sectionHint": "选择版本后可编辑各奖级赔率;发布后立即作用于新注单。", "tabs": { @@ -273,6 +302,11 @@ "publishLabel": "发布", "publishSuccess": "已发布带回水的赔率版本", "publishFailed": "发布失败", + "publishDialog": { + "title": "确认发布回水/赔率版本?", + "description": "发布后将影响后续新注单的回水计算。", + "confirm": "确认发布" + }, "createDraftSuccess": "已创建草稿 v{{version}}", "createDraftFailed": "创建草稿失败", "deleteFailed": "删除失败", @@ -297,6 +331,11 @@ "enterValidCapAmount": "请输入有效的封顶金额" }, "publishFailed": "发布失败", + "publishDialog": { + "title": "确认发布封顶配置?", + "description": "发布后将影响各号码的风险池封顶额度。", + "confirm": "确认发布" + }, "createDraftSuccess": "已创建草稿 v{{version}}", "createDraftFailed": "创建草稿失败", "savedLocalDraft": "已写入本地草稿,记得保存草稿后再发布。", diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json index 8dca547..91d2c02 100644 --- a/src/i18n/locales/zh/dashboard.json +++ b/src/i18n/locales/zh/dashboard.json @@ -2,7 +2,65 @@ "title": "仪表盘", "refresh": "刷新", "notice": "提示", - "todayBetTotal": "当期投注总额", + "sections": { + "today": "今日概览", + "lifetime": "历史累计", + "currentDraw": "当前期号", + "currentDrawDetail": "当期明细 · {{drawNo}}", + "operations": "运营监控(当期)" + }, + "analytics": { + "title": "财务分析", + "periodLabel": "统计区间", + "metricLabel": "指标类型", + "playLabel": "玩法筛选", + "allPlays": "全部玩法", + "customRange": "自定义日期", + "rangeHint": "区间 {{range}}", + "selectPeriod": "选择统计区间", + "chartTruncated": "趋势图仅展示最近区间 {{from}} — {{to}}(全区间共 {{days}} 天)", + "summaryBet": "区间下注", + "summaryPayout": "区间派彩", + "summaryProfit": "区间盈亏", + "dailyTrend": "每日趋势", + "playBreakdown": "玩法拆解 Top", + "periodDistribution": "区间结构对比", + "noPlayData": "该区间暂无玩法数据", + "periods": { + "today": "今日", + "last_7_days": "近 7 天", + "last_30_days": "近 30 天", + "this_month": "本月", + "lifetime": "全部历史", + "custom": "自定义" + }, + "metrics": { + "overview": "综合", + "bet": "投注", + "payout": "派彩", + "profit": "盈亏" + } + }, + "chartLegend": { + "bet": "投注", + "payout": "派彩", + "profit": "盈亏" + }, + "playBreakdownHint": "派彩 {{payout}} · 盈亏 {{profit}}", + "viewReports": "报表中心", + "lifetimeBetTotal": "累计下注", + "lifetimePayout": "累计派彩", + "lifetimeProfit": "累计平台盈亏", + "lifetimeActivityHint": "{{draws}} 期有投注 · {{days}} 个业务日", + "lifetimeDateRangeHint": "统计区间 {{range}}", + "currentDrawBetTotal": "当期投注", + "currentDrawPayout": "当期派彩", + "currentDrawProfit": "当期盈亏", + "drawFinanceDetails": "期号财务详情", + "todayBetTotal": "今日下注总额", + "todayPayout": "今日派彩", + "todayProfit": "今日盈亏", + "todayBusinessDateHint": "业务日 {{date}}", "drawNoHint": "期号 {{drawNo}}", "orderAndTicket": "{{orders}} 单 · {{tickets}} 笔", "marginRate": "毛利率约 {{rate}}%", @@ -23,6 +81,7 @@ "quickLinksTitle": "快捷入口", "currentPayout": "当期派彩", "currentProfit": "当期平台盈亏", + "currentDrawFinanceHint": "下方图表为当期 {{drawNo}}", "currentDraw": "当前期号", "drawSequence": "第 {{sequence}} 期", "drawDetails": "期号详情", @@ -64,8 +123,9 @@ "auditLogs": "审计日志" }, "warnings": { - "drawPermission": "当前账号无开奖查看/管理权限,财务与风控数据未返回。", + "drawPermission": "当前账号无开奖/仪表盘查看权限,财务与风控数据未返回。", "walletPermission": "当前账号无钱包对账查看权限,异常转账计数未返回。", - "loadFailed": "加载失败,请检查 API 与登录状态。" + "loadFailed": "加载失败,请检查 API 与登录状态。", + "apiResourceMissing": "仪表盘分析接口未注册。请在服务端执行:php artisan lottery:admin-auth-sync,或运行最新数据库迁移后重试。" } } diff --git a/src/i18n/locales/zh/draws.json b/src/i18n/locales/zh/draws.json index 55275d5..3b526a1 100644 --- a/src/i18n/locales/zh/draws.json +++ b/src/i18n/locales/zh/draws.json @@ -144,5 +144,23 @@ "third": "三奖", "starter": "特别奖 {{index}}", "consolation": "安慰奖 {{index}}" + }, + "confirm": { + "manualCloseTitle": "确认手动封盘?", + "manualCloseDescription": "封盘后玩家将无法继续对该期下注。", + "cancelDrawTitle": "确认取消期号?", + "cancelDrawDescription": "取消后该期将不再开奖,请确认无未处理注单风险。", + "rngDrawTitle": "确认 RNG 自动生成开奖?", + "rngDrawDescription": "将按系统规则生成本期开奖号码并进入后续流程。", + "reopenTitle": "确认冷静期重开?", + "reopenDescription": "重开后需重新审核/发布结果,可能影响已展示的开奖信息。", + "runSettlementTitle": "确认触发结算?", + "runSettlementDescription": "将按已发布开奖结果生成本期结算批次。", + "saveManualDraftTitle": "确认保存人工开奖草稿?", + "saveManualDraftDescription": "将写入 23 个开奖号码草稿,提交后进入审核流程。", + "publishTitle": "确认发布开奖结果?", + "publishDescription": "发布后将对玩家可见并可能触发结算,请再次核对号码。", + "generatePlanTitle": "确认批量生成期号计划?", + "generatePlanDescription": "将按系统规则补充未来可下注期号。" } } diff --git a/src/i18n/locales/zh/jackpot.json b/src/i18n/locales/zh/jackpot.json index a4cd0c1..c1a42cd 100644 --- a/src/i18n/locales/zh/jackpot.json +++ b/src/i18n/locales/zh/jackpot.json @@ -25,10 +25,16 @@ "enabled": "开启", "saving": "保存中…", "save": "保存", + "confirmSavePoolTitle": "确认保存奖池配置?", + "confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数,可能影响后续 Jackpot 行为。", "manualBurstDrawId": "手动爆池期号 ID", - "manualBurstAmount": "爆池金额(空为全部)", + "manualBurstHint": "仅超级管理员可在紧急情况下触发;须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。", + "manualBurstConfirmTitle": "确认手动爆池?", + "manualBurstConfirmDescription": "将对期号 {{drawId}} 的头奖中奖玩家按奖池派彩比例分配 Jackpot,并扣减奖池余额。此操作不可自动撤销。", "processing": "处理中…", - "manualBurst": "手动爆池", + "manualBurst": "手动触发爆池(仅超管)", + "manualBurstConfirm": "确认爆池", + "cancel": "取消", "filter": "筛选", "drawNo": "期号", "optional": "可选", diff --git a/src/i18n/locales/zh/players.json b/src/i18n/locales/zh/players.json index cbef2f9..cb08fee 100644 --- a/src/i18n/locales/zh/players.json +++ b/src/i18n/locales/zh/players.json @@ -29,6 +29,12 @@ "lastLogin": "最后登录", "actions": "操作", "edit": "编辑", + "freeze": "冻结", + "unfreeze": "解冻", + "freezeSuccess": "已冻结玩家 {{name}}", + "unfreezeSuccess": "已解冻玩家 {{name}}", + "freezeFailed": "冻结失败", + "unfreezeFailed": "解冻失败", "delete": "删除", "createDialogTitle": "新建玩家", "editDialogTitle": "编辑玩家", @@ -44,6 +50,10 @@ "cancel": "取消", "save": "保存", "saving": "保存中…", + "confirmFreezeTitle": "确认冻结玩家?", + "confirmFreezeDescription": "冻结后玩家 {{name}} 将无法下注。", + "confirmUnfreezeTitle": "确认解冻玩家?", + "confirmUnfreezeDescription": "解冻后玩家 {{name}} 将恢复正常。", "confirmDelete": "确认删除", "confirmDeleteDesc": "确定要删除玩家 {{name}} 吗?此操作不可恢复。" } diff --git a/src/i18n/locales/zh/reconcile.json b/src/i18n/locales/zh/reconcile.json index 92ebf50..f86cad0 100644 --- a/src/i18n/locales/zh/reconcile.json +++ b/src/i18n/locales/zh/reconcile.json @@ -13,6 +13,10 @@ "periodRequired": "请填写对账日期范围(开始与结束)", "periodInvalid": "日期无效,请检查所选日期", "periodOrderInvalid": "结束时间需晚于或等于开始时间", + "confirmCreateTitle": "确认创建对账任务?", + "confirmCreateDescription": "将按所选日期范围{{playerHint}}发起人工对账。", + "confirmCreatePlayer": "及指定玩家", + "confirmCreateAllPlayers": "(全量玩家)", "createSuccess": "已创建对账任务", "createFailed": "创建失败", "noCreatePermission": "当前账号无新建对账任务权限。", diff --git a/src/i18n/locales/zh/risk.json b/src/i18n/locales/zh/risk.json index 720b94c..17786b9 100644 --- a/src/i18n/locales/zh/risk.json +++ b/src/i18n/locales/zh/risk.json @@ -46,6 +46,12 @@ "manualCloseSuccess": "已手动关闭号码下注", "recoverSuccess": "已恢复号码下注", "actionFailed": "操作失败", + "confirm": { + "closeTitle": "确认关闭该号码下注?", + "closeDescription": "号码 {{number}} 在本期将被禁止下注。", + "recoverTitle": "确认恢复该号码下注?", + "recoverDescription": "号码 {{number}} 将恢复为可下注状态。" + }, "detailTitle": "风险池详情", "loadDetailFailed": "加载风险池详情失败", "backToList": "返回列表", diff --git a/src/i18n/locales/zh/tickets.json b/src/i18n/locales/zh/tickets.json index 708540a..efed0d8 100644 --- a/src/i18n/locales/zh/tickets.json +++ b/src/i18n/locales/zh/tickets.json @@ -34,6 +34,7 @@ "pending_confirm": "待确认", "partial_pending_confirm": "部分待确认", "success": "已投注成功", + "pending_draw": "待开奖", "failed": "投注失败", "pending_payout": "待派奖", "settled_win": "已中奖结算", diff --git a/src/i18n/locales/zh/wallet.json b/src/i18n/locales/zh/wallet.json index 5ad8b76..c9709f9 100644 --- a/src/i18n/locales/zh/wallet.json +++ b/src/i18n/locales/zh/wallet.json @@ -47,6 +47,12 @@ "reverseSuccess": "冲正成功", "manualProcessSuccess": "人工处理成功", "actionFailed": "操作失败", + "confirm": { + "reverseTitle": "确认冲正转账单?", + "reverseDescription": "将对单号 {{transferNo}} 执行冲正,可能影响玩家钱包余额。", + "manualProcessTitle": "确认人工处理?", + "manualProcessDescription": "将标记单号 {{transferNo}} 为已人工处理,不会自动调整钱包。" + }, "txnNo": "流水号", "bizType": "类型(业务)", "type": "类型", diff --git a/src/lib/admin-datetime.ts b/src/lib/admin-datetime.ts index 137140b..41f3886 100644 --- a/src/lib/admin-datetime.ts +++ b/src/lib/admin-datetime.ts @@ -23,6 +23,35 @@ function formatParts(date: Date, timeZone?: string): string { return `${year}-${month}-${day} ${hour}:${minute}:${second}`; } +const WEEKDAY_KEYS = [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +] as const; + +export type AdminWeekdayKey = (typeof WEEKDAY_KEYS)[number]; + +export function adminWeekdayKeyForDate(date: Date = new Date()): AdminWeekdayKey { + return WEEKDAY_KEYS[date.getDay()] ?? "sunday"; +} + +/** + * 仪表盘顶栏日期:数字日期 + i18n 星期(避免 Intl 在 ne 等语言下回退到系统中文)。 + */ +export function formatAdminCalendarToday(locale: AdminApiLocale, weekdayLabel: string): string { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const datePart = locale === "en" ? `${m}/${day}/${y}` : `${y}-${m}-${day}`; + + return `${datePart} ${weekdayLabel}`; +} + /** * 将接口返回的 ISO 时间串格式化为浏览器本地时区下的 `YYYY-MM-DD HH:mm:ss`。 */ diff --git a/src/lib/admin-page-title.ts b/src/lib/admin-page-title.ts index 500caf6..fdd69ed 100644 --- a/src/lib/admin-page-title.ts +++ b/src/lib/admin-page-title.ts @@ -29,6 +29,7 @@ const EXACT_ROUTES: Record = { "/admin/jackpot": { ns: "jackpot", key: "configTitle" }, "/admin/risk/cap": { ns: "config", key: "nav.riskCapTitle" }, "/admin/login": { ns: "auth", key: "title" }, + "/admin/account": { ns: "common", key: "accountSettings" }, }; type RoutePattern = { diff --git a/src/lib/admin-play-types.ts b/src/lib/admin-play-types.ts index 5331f0b..a622988 100644 --- a/src/lib/admin-play-types.ts +++ b/src/lib/admin-play-types.ts @@ -44,32 +44,10 @@ export function getAdminPlayTypesLoadPromise( return inflightLoad; } -function pickDisplayName(row: AdminPlayTypeRow, language: string): string | null { - const lang = language.split("-")[0]?.toLowerCase() ?? "zh"; - - if (lang === "en" && row.display_name_en?.trim()) { - return row.display_name_en.trim(); - } - if (lang === "ne" && row.display_name_ne?.trim()) { - return row.display_name_ne.trim(); - } - if (row.display_name_zh?.trim()) { - return row.display_name_zh.trim(); - } - if (row.display_name_en?.trim()) { - return row.display_name_en.trim(); - } - if (row.display_name_ne?.trim()) { - return row.display_name_ne.trim(); - } - - return null; -} - -/** 按当前语言解析玩法显示名;无配置时回退 play_code */ +/** 解析玩法显示名;无配置时回退 play_code */ export function resolveAdminPlayTypeDisplayName( playCode: string | null | undefined, - language: string, + _language?: string, row?: AdminPlayTypeRow, ): string { if (playCode == null || playCode === "") { @@ -81,13 +59,14 @@ export function resolveAdminPlayTypeDisplayName( return playCode; } - return pickDisplayName(resolved, language) ?? playCode; + const name = resolved.display_name?.trim(); + return name ? name : playCode; } /** 表格展示:显示名 + 编码(与报表筛选一致) */ export function formatAdminPlayCodeLabel( playCode: string | null | undefined, - language: string, + language?: string, ): string { if (playCode == null || playCode === "") { return "—"; diff --git a/src/lib/admin-prd.ts b/src/lib/admin-prd.ts new file mode 100644 index 0000000..3c1d52a --- /dev/null +++ b/src/lib/admin-prd.ts @@ -0,0 +1,112 @@ +/** 与 Laravel {@see AdminAuthorizationRegistry} 中 `prd.*` slug 对齐 */ + +export const PRD_ADMIN_USER_MANAGE = "prd.admin_user.manage" as const; +export const PRD_ADMIN_ROLE_MANAGE = "prd.admin_role.manage" as const; + +export const PRD_USERS_MANAGE = "prd.users.manage" as const; +export const PRD_USERS_VIEW_FINANCE = "prd.users.view_finance" as const; +export const PRD_USERS_VIEW_CS = "prd.users.view_cs" as const; +export const PRD_PLAYER_FREEZE_MANAGE = "prd.player_freeze.manage" as const; + +export const PRD_CURRENCY_MANAGE = "prd.currency.manage" as const; + +export const PRD_WALLET_RECONCILE_MANAGE = "prd.wallet_reconcile.manage" as const; +export const PRD_WALLET_RECONCILE_VIEW = "prd.wallet_reconcile.view" as const; +export const PRD_WALLET_RECONCILE_VIEW_CS = "prd.wallet_reconcile.view_cs" as const; +export const PRD_WALLET_ADJUST_MANAGE = "prd.wallet_adjust.manage" as const; + +export const PRD_DRAW_RESULT_MANAGE = "prd.draw_result.manage" as const; +export const PRD_DRAW_RESULT_VIEW = "prd.draw_result.view" as const; +export const PRD_DRAW_REOPEN_MANAGE = "prd.draw_reopen.manage" as const; + +export const PRD_PLAY_SWITCH_MANAGE = "prd.play_switch.manage" as const; +export const PRD_ODDS_MANAGE = "prd.odds.manage" as const; +export const PRD_REBATE_MANAGE = "prd.rebate.manage" as const; +export const PRD_REBATE_VIEW = "prd.rebate.view" as const; +export const PRD_RISK_CAP_MANAGE = "prd.risk_cap.manage" as const; +export const PRD_RISK_CAP_VIEW = "prd.risk_cap.view" as const; +export const PRD_JACKPOT_MANAGE = "prd.jackpot.manage" as const; +export const PRD_JACKPOT_VIEW = "prd.jackpot.view" as const; +/** 超管紧急手动爆池(产品文档 §5.13) */ +export const PRD_JACKPOT_MANUAL_BURST = "prd.jackpot.manual_burst" as const; + +export const PRD_PAYOUT_MANAGE = "prd.payout.manage" as const; +export const PRD_PAYOUT_REVIEW = "prd.payout.review" as const; +export const PRD_PAYOUT_VIEW = "prd.payout.view" as const; + +export const PRD_AUDIT_VIEW = "prd.audit.view" as const; +export const PRD_DASHBOARD_VIEW = "prd.dashboard.view" as const; +export const PRD_REPORT_VIEW = "prd.report.view" as const; +export const PRD_REPORT_EXPORT = "prd.report.export" as const; +export const PRD_TICKETS_VIEW = "prd.tickets.view" as const; +export const PRD_RISK_VIEW = "prd.risk.view" as const; +export const PRD_RISK_MANAGE = "prd.risk.manage" as const; +export const PRD_ODDS_VIEW = "prd.odds.view" as const; + +/** 钱包补单/冲正(冲正 + 手工处理) */ +export const PRD_WALLET_WRITE_ANY = [ + PRD_WALLET_ADJUST_MANAGE, + PRD_WALLET_RECONCILE_MANAGE, +] as const; + +/** 玩家列表页(与侧栏 requiredAny 一致) */ +export const PRD_PLAYERS_ACCESS_ANY = [ + PRD_USERS_MANAGE, + PRD_USERS_VIEW_FINANCE, + PRD_USERS_VIEW_CS, + PRD_PLAYER_FREEZE_MANAGE, +] as const; + +/** 注单列表页 */ +export const PRD_TICKETS_ACCESS_ANY = [PRD_TICKETS_VIEW] as const; + +/** 仪表盘 */ +export const PRD_DASHBOARD_ACCESS_ANY = [PRD_DASHBOARD_VIEW] as const; + +/** 风控中心(含期号内风控页) */ +export const PRD_RISK_ACCESS_ANY = [ + PRD_RISK_VIEW, + PRD_RISK_MANAGE, + PRD_DRAW_RESULT_VIEW, + PRD_DRAW_RESULT_MANAGE, +] as const; + +/** 报表查看 / 导出 */ +export const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const; +export const PRD_REPORTS_EXPORT_ACCESS_ANY = [PRD_REPORT_EXPORT] as const; + +/** 钱包流水 */ +export const PRD_WALLET_TX_ACCESS_ANY = [ + PRD_WALLET_RECONCILE_MANAGE, + PRD_WALLET_RECONCILE_VIEW, + PRD_WALLET_RECONCILE_VIEW_CS, +] as const; + +/** 转账订单 */ +export const PRD_WALLET_TRANSFER_ACCESS_ANY = [ + ...PRD_WALLET_TX_ACCESS_ANY, + PRD_WALLET_ADJUST_MANAGE, + PRD_USERS_MANAGE, + PRD_USERS_VIEW_FINANCE, +] as const; + +/** 单玩家钱包查询 */ +export const PRD_WALLET_PLAYER_ACCESS_ANY = [ + PRD_USERS_MANAGE, + PRD_USERS_VIEW_FINANCE, + ...PRD_WALLET_TX_ACCESS_ANY, +] as const; + +/** 赔率与回水配置页 */ +export const PRD_RULES_ODDS_ACCESS_ANY = [ + PRD_ODDS_MANAGE, + PRD_ODDS_VIEW, + PRD_REBATE_MANAGE, + PRD_REBATE_VIEW, +] as const; + +/** 封顶配置页 */ +export const PRD_RISK_CAP_ACCESS_ANY = [PRD_RISK_CAP_MANAGE, PRD_RISK_CAP_VIEW] as const; + +/** Jackpot 配置页 */ +export const PRD_JACKPOT_ACCESS_ANY = [PRD_JACKPOT_MANAGE, PRD_JACKPOT_VIEW] as const; diff --git a/src/modules/_config/admin-nav-icons.tsx b/src/modules/_config/admin-nav-icons.tsx index c23d39e..4c8eaa8 100644 --- a/src/modules/_config/admin-nav-icons.tsx +++ b/src/modules/_config/admin-nav-icons.tsx @@ -42,4 +42,17 @@ export const adminNavIconBySegment: Record settings: Settings, }; +/** 旧版 localStorage / 接口缓存中的 segment,避免首屏侧栏崩溃 */ +const legacyAdminNavIconBySegment: Record = { + config: SlidersHorizontal, +}; + +export function resolveAdminNavIcon(segment: string): LucideIcon { + return ( + adminNavIconBySegment[segment as AdminNavItem["segment"]] ?? + legacyAdminNavIconBySegment[segment] ?? + LayoutDashboard + ); +} + export { LogIn }; diff --git a/src/modules/account/account-settings-console.tsx b/src/modules/account/account-settings-console.tsx index c826ceb..be8ab9c 100644 --- a/src/modules/account/account-settings-console.tsx +++ b/src/modules/account/account-settings-console.tsx @@ -32,16 +32,16 @@ export function AccountSettingsConsole() { async function handleUpdateProfile() { if (!nickname.trim()) { - toast.error(t("validation.required", { field: t("fields.nickname", { defaultValue: "昵称" }) })); + toast.error(t("validation.required", { field: t("fields.nickname") })); return; } setLoading(true); try { await putAdminMe({ nickname: nickname.trim() }); - toast.success(t("actions.updateSuccess", { defaultValue: "更新成功" })); + toast.success(t("actions.updateSuccess")); void refreshAdminProfile(); } catch (err) { - toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed", { defaultValue: "更新失败" })); + toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed")); } finally { setLoading(false); } @@ -49,21 +49,21 @@ export function AccountSettingsConsole() { async function handleUpdatePassword() { if (!password) { - toast.error(t("validation.required", { field: t("fields.newPassword", { defaultValue: "新密码" }) })); + toast.error(t("validation.required", { field: t("fields.newPassword") })); return; } if (password !== confirmPassword) { - toast.error(t("validation.passwordMismatch", { defaultValue: "两次输入的密码不一致" })); + toast.error(t("validation.passwordMismatch")); return; } setLoading(true); try { await putAdminMe({ password }); - toast.success(t("actions.updateSuccess", { defaultValue: "更新成功" })); + toast.success(t("actions.updateSuccess")); setPassword(""); setConfirmPassword(""); } catch (err) { - toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed", { defaultValue: "更新失败" })); + toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed")); } finally { setLoading(false); } @@ -73,68 +73,68 @@ export function AccountSettingsConsole() {

- {t("accountSettings", { defaultValue: "账号设置" })} + {t("accountSettings")}

- {t("accountSettingsDesc", { defaultValue: "管理您的基本账号资料及安全设置。" })} + {t("accountSettingsDesc")}

- {t("profileSettings", { defaultValue: "基本资料" })} + {t("profileSettings")} - {t("profileSettingsDesc", { defaultValue: "更新您的显示名称。" })} + {t("profileSettingsDesc")}
- + setNickname(e.target.value)} - placeholder={t("placeholders.nickname", { defaultValue: "请输入昵称" })} + placeholder={t("placeholders.nickname")} />
- {t("securitySettings", { defaultValue: "安全设置" })} + {t("securitySettings")} - {t("securitySettingsDesc", { defaultValue: "修改您的登录密码。如不修改请留空。" })} + {t("securitySettingsDesc")}
- + setPassword(e.target.value)} - placeholder={t("placeholders.password", { defaultValue: "请输入新密码" })} + placeholder={t("placeholders.password")} />
- + setConfirmPassword(e.target.value)} - placeholder={t("placeholders.confirmPassword", { defaultValue: "请再次输入新密码" })} + placeholder={t("placeholders.confirmPassword")} />
diff --git a/src/modules/admin-roles/admin-roles-console.tsx b/src/modules/admin-roles/admin-roles-console.tsx index 66544c5..396d6fa 100644 --- a/src/modules/admin-roles/admin-roles-console.tsx +++ b/src/modules/admin-roles/admin-roles-console.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDown } from "lucide-react"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -37,7 +38,10 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { PRD_ADMIN_ROLE_MANAGE } from "@/lib/admin-prd"; import { cn } from "@/lib/utils"; +import { useAdminProfile } from "@/stores/admin-session"; import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -53,6 +57,9 @@ function permissionLabel(slug: string, fallback: string, t: (key: string) => str export function AdminRolesConsole(): React.ReactElement { const { t } = useTranslation(["adminUsers", "common"]); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); + const profile = useAdminProfile(); + const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]); const exportLabels = useExportLabels("adminRoles"); const [catalog, setCatalog] = useState(null); const [roles, setRoles] = useState([]); @@ -130,13 +137,13 @@ export function AdminRolesConsole(): React.ReactElement { }, [load]); function isDirectGroupOpen(key: string): boolean { - return directMenuExpanded[key] !== false; + return directMenuExpanded[key] === true; } function toggleDirectGroup(key: string): void { setDirectMenuExpanded((prev) => { - const wasOpen = prev[key] !== false; - return { ...prev, [key]: wasOpen ? false : true }; + const wasOpen = prev[key] === true; + return { ...prev, [key]: !wasOpen }; }); } @@ -307,9 +314,11 @@ export function AdminRolesConsole(): React.ReactElement {
{t("roleListTitle")} - + {canManageRoles ? ( + + ) : null}
{role.user_count} {role.permission_slugs.length} -
- - - -
+ {canManageRoles ? ( +
+ + + +
+ ) : ( + + )}
)) @@ -500,7 +513,20 @@ export function AdminRolesConsole(): React.ReactElement { -
@@ -540,7 +566,21 @@ export function AdminRolesConsole(): React.ReactElement { -
@@ -565,6 +605,7 @@ export function AdminRolesConsole(): React.ReactElement { + ); } diff --git a/src/modules/admin-users/admin-users-console.tsx b/src/modules/admin-users/admin-users-console.tsx index be1390b..2e7aaa6 100644 --- a/src/modules/admin-users/admin-users-console.tsx +++ b/src/modules/admin-users/admin-users-console.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -38,6 +39,8 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { PRD_ADMIN_USER_MANAGE } from "@/lib/admin-prd"; import { cn } from "@/lib/utils"; import { useAdminProfile } from "@/stores/admin-session"; import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index"; @@ -45,8 +48,10 @@ import { LotteryApiBizError } from "@/types/api/errors"; export function AdminUsersConsole(): React.ReactElement { const { t } = useTranslation(["adminUsers", "common"]); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const exportLabels = useExportLabels("adminUsers"); const profile = useAdminProfile(); + const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_USER_MANAGE]); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [keyword, setKeyword] = useState(""); @@ -310,9 +315,11 @@ export function AdminUsersConsole(): React.ReactElement {
{t("listTitle")} - + {canManageUsers ? ( + + ) : null}
@@ -411,6 +418,7 @@ export function AdminUsersConsole(): React.ReactElement { {row.effective_permissions.length}
+ {canManageUsers ? ( + ) : null} + {canManageUsers ? ( + ) : null} + {canManageUsers ? ( + ) : null}
@@ -518,7 +531,15 @@ export function AdminUsersConsole(): React.ReactElement { type="button" className="w-full shrink-0 sm:w-auto" disabled={!selectedUser || savingRoles} - onClick={() => void saveRoles()} + onClick={() => + selectedUser && + requestConfirm({ + title: t("confirmSaveRolesTitle"), + description: t("confirmSaveRolesDescription", { name: selectedUser.username }), + confirmLabel: t("confirm.confirmSave", { ns: "common" }), + onConfirm: () => saveRoles(), + }) + } > {savingRoles ? t("saving") : t("permissionDialog.saveRoles")} @@ -633,7 +654,23 @@ export function AdminUsersConsole(): React.ReactElement { > {t("actions.cancel")} -
@@ -668,6 +705,7 @@ export function AdminUsersConsole(): React.ReactElement {
+ ); } diff --git a/src/modules/config/config-section.tsx b/src/modules/config/config-section.tsx index 565b010..e03f72f 100644 --- a/src/modules/config/config-section.tsx +++ b/src/modules/config/config-section.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; +import { AdminSectionHeader } from "@/components/admin/admin-section-header"; import { cn } from "@/lib/utils"; type ConfigSectionProps = { @@ -22,15 +23,7 @@ export function ConfigSection({ }: ConfigSectionProps) { return (
-
-
-

{title}

- {description ? ( -

{description}

- ) : null} -
- {actions ?
{actions}
: null} -
+ {children}
); diff --git a/src/modules/config/config-version-actions.tsx b/src/modules/config/config-version-actions.tsx index 9b6eb67..6795d49 100644 --- a/src/modules/config/config-version-actions.tsx +++ b/src/modules/config/config-version-actions.tsx @@ -8,6 +8,8 @@ import { cn } from "@/lib/utils"; type ConfigVersionActionsProps = { isDraft: boolean; + /** 为 false 时仅保留刷新,隐藏新建/保存/发布(只读权限) */ + canManage?: boolean; loadingList?: boolean; loadingDetail?: boolean; saving?: boolean; @@ -21,6 +23,7 @@ type ConfigVersionActionsProps = { export function ConfigVersionActions({ isDraft, + canManage = true, loadingList = false, loadingDetail = false, saving = false, @@ -41,11 +44,13 @@ export function ConfigVersionActions({ {loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")} - - {isDraft ? ( + {canManage ? ( + + ) : null} + {canManage && isDraft ? ( <> ) : ( @@ -688,31 +724,13 @@ export function PlayConfigDocScreen() { {t("play.nameDialog.description", { ns: "config", playCode: namePlayCode ?? "—" })} -
-
- - setNameDraftZh(e.target.value)} - /> -
-
- - setNameDraftEn(e.target.value)} - /> -
-
- - setNameDraftNe(e.target.value)} - /> -
+
+ + setNameDraft(e.target.value)} + />
@@ -406,7 +423,7 @@ export function RiskCapDocScreen() { ( - {isDraft ? ( + {canEditDraft ? ( - {isDraft ? ( + {canEditDraft ? ( - {isDraft ? ( + {canEditDraft ? (
- {dirty && ( @@ -185,6 +197,7 @@ export function WalletConfigDocScreen({ embedded = false }: WalletConfigDocScree )}
+ ); diff --git a/src/modules/dashboard/dashboard-analytics-panel.tsx b/src/modules/dashboard/dashboard-analytics-panel.tsx new file mode 100644 index 0000000..4b5c6ad --- /dev/null +++ b/src/modules/dashboard/dashboard-analytics-panel.tsx @@ -0,0 +1,386 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; +import { format, subDays } from "date-fns"; +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"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog"; +import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money"; +import { cn } from "@/lib/utils"; +import { StatCard } 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"; + +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("zh-CN", { + style: "currency", + currency: code, + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(major); + } catch { + return formatAdminMinorUnits(minor, code, decimals); + } +} + +function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string { + if (minor === 0) { + return formatMoneyMinor(0, currencyCode); + } + const s = minor > 0 ? "+" : "−"; + return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`; +} + +export function DashboardAnalyticsPanel({ + enabled, + playOptions, +}: { + enabled: boolean; + playOptions: { code: string; label: string }[]; +}): ReactElement { + 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; + } + + 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-console.tsx b/src/modules/dashboard/dashboard-console.tsx index fcc3f3a..2aae0d2 100644 --- a/src/modules/dashboard/dashboard-console.tsx +++ b/src/modules/dashboard/dashboard-console.tsx @@ -2,24 +2,28 @@ import Link from "next/link"; import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react"; -import { format } from "date-fns"; -import { zhCN } from "date-fns/locale"; import { useTranslation } from "react-i18next"; import { AlertTriangle, ClipboardList, Diamond, FileSearch, - Gift, RefreshCw, ScrollText, Shield, Ticket, - TrendingUp, Wallet, } from "lucide-react"; import { getAdminDashboard } from "@/api/admin-dashboard"; +import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog"; +import { getAdminPlayTypes } from "@/api/admin-config"; +import { + getAdminPlayTypesLoadPromise, + getCachedAdminPlayTypes, + resolveAdminPlayTypeDisplayName, +} from "@/lib/admin-play-types"; +import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -32,9 +36,10 @@ import { ResultBatchProgress, SettlementStatusChart, SoldOutRing, - StatCard, } from "@/modules/dashboard/dashboard-visuals"; import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; +import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime"; +import { normalizeAdminLanguage } from "@/i18n"; import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money"; import { cn } from "@/lib/utils"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -69,14 +74,6 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string { } } -function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string { - if (minor === 0) { - return formatMoneyMinor(0, currencyCode); - } - const s = minor > 0 ? "+" : "−"; - return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`; -} - function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" { const raw = normalizedNumber.trim(); const digits = raw.replace(/\D/g, ""); @@ -109,18 +106,24 @@ function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPo } export function DashboardConsole(): ReactElement { - const { t } = useTranslation(["dashboard", "common"]); + const { t, i18n } = useTranslation(["dashboard", "common"]); useAdminCurrencyCatalog(); - const [todayLabel] = useState(() => format(new Date(), "yyyy-MM-dd EEEE", { locale: zhCN })); + useAdminPlayTypeCatalog(); + const todayLabel = useMemo(() => { + const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language); + const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" }); + + return formatAdminCalendarToday(locale, weekday); + }, [i18n.language, i18n.resolvedLanguage, t]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); const [hall, setHall] = useState(null); const [drawId, setDrawId] = useState(null); const [drawPanel, setDrawPanel] = useState(null); const [finance, setFinance] = useState(null); + const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null); const [pendingReview, setPendingReview] = useState(null); const [riskLocked, setRiskLocked] = useState(0); const [riskCap, setRiskCap] = useState(0); @@ -128,6 +131,26 @@ export function DashboardConsole(): ReactElement { const [soldOutBuckets, setSoldOutBuckets] = useState(null); const [abnormalTransferTotal, setAbnormalTransferTotal] = useState(null); const [hotTab, setHotTab] = useState("4D"); + const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]); + + const loadPlayOptions = useCallback(async () => { + try { + await getAdminPlayTypesLoadPromise(getAdminPlayTypes); + setPlayOptions( + getCachedAdminPlayTypes().map((item) => ({ + code: item.play_code, + label: + resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item) || item.play_code, + })), + ); + } catch { + setPlayOptions([]); + } + }, [i18n.language]); + + useEffect(() => { + void loadPlayOptions(); + }, [loadPlayOptions]); const load = useCallback(async (isRefresh = false) => { if (isRefresh) { @@ -136,8 +159,8 @@ export function DashboardConsole(): ReactElement { setLoading(true); } setError(null); - setNotice(null); setFinance(null); + setCapabilities(null); setDrawPanel(null); setPendingReview(null); setDrawId(null); @@ -155,6 +178,7 @@ export function DashboardConsole(): ReactElement { setDrawId(d.resolved_draw.id); } + setCapabilities(d.capabilities); if (d.finance != null) { setFinance(d.finance); } @@ -169,15 +193,6 @@ export function DashboardConsole(): ReactElement { setSoldOutBuckets(d.risk.sold_out_buckets); } setAbnormalTransferTotal(d.abnormal_transfer_total); - - const noticeParts: string[] = d.warnings.map((w) => w.message); - if (d.resolved_draw != null && !d.capabilities.draw_finance_risk) { - noticeParts.push(t("warnings.drawPermission")); - } - if (d.hall != null && !d.capabilities.wallet_transfer_view) { - noticeParts.push(t("warnings.walletPermission")); - } - setNotice(noticeParts.length > 0 ? noticeParts.join(" ") : null); } catch (e) { const msg = e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed"); @@ -196,6 +211,7 @@ export function DashboardConsole(): ReactElement { }, [load]); const currency = finance?.currency_code ?? null; + const canFinance = capabilities?.draw_finance_risk ?? false; const usagePct = riskCap > 0 ? (riskLocked / riskCap) * 100 : 0; const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]); @@ -218,16 +234,6 @@ export function DashboardConsole(): ReactElement { { href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: }, ]; - const kpiSkeleton = ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
- -
- ))} -
- ); - return (
@@ -242,7 +248,7 @@ export function DashboardConsole(): ReactElement { onClick={() => void load(true)} > - {t("refresh")} + {t("actions.refresh", { ns: "common" })}
@@ -254,69 +260,48 @@ export function DashboardConsole(): ReactElement { ) : null} - {notice && !error ? ( - + {!loading && capabilities && !capabilities.draw_finance_risk ? ( + {t("notice")} - {notice} + {t("warnings.drawPermission")} ) : null} - {loading ? ( - kpiSkeleton - ) : ( -
- } - /> - } - accent="destructive" - /> - 0 - ? t("marginRate", { - rate: ((finance.approx_house_gross_minor / finance.total_bet_minor) * 100).toFixed(1), - }) - : undefined} - icon={} - /> - {hall?.draw_no ?? "—"}} - hint={ - - {t("drawSequence", { sequence: hall?.sequence_no ?? "—" })} - - - {hallStatusLabel} - - - } - icon={} - accent="muted" - /> + {!loading && hall ? ( +
+
+ +
+

{t("sections.currentDraw")}

+

{hall.draw_no}

+
+ + {t("drawSequence", { sequence: hall.sequence_no ?? "—" })} + + + + {hallStatusLabel} + +
+ {drawId != null ? ( + + {t("drawFinanceDetails")} + + ) : null}
- )} + ) : null} + + + +

{t("sections.operations")}

diff --git a/src/modules/dashboard/dashboard-trend-charts.tsx b/src/modules/dashboard/dashboard-trend-charts.tsx new file mode 100644 index 0000000..ea261f6 --- /dev/null +++ b/src/modules/dashboard/dashboard-trend-charts.tsx @@ -0,0 +1,260 @@ +"use client"; + +import type { ReactElement } from "react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; +import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics"; +import type { AdminReportDailyProfitRow } from "@/types/api/admin-reports"; +import type { DashboardAnalyticsMetric } from "@/types/api/admin-dashboard-analytics"; + +type MoneyFormatter = (minor: number, currency: string | null) => string; + +function metricValue(row: AdminReportDailyProfitRow, metric: DashboardAnalyticsMetric): number { + switch (metric) { + case "bet": + return row.total_bet_minor; + case "payout": + return row.total_payout_minor; + case "profit": + return row.approx_house_gross_minor; + default: + return row.total_bet_minor; + } +} + +function playMetricValue(row: AdminDashboardAnalyticsPlayRow, metric: DashboardAnalyticsMetric): number { + switch (metric) { + case "bet": + return row.total_bet_minor; + case "payout": + return row.total_payout_minor; + case "profit": + return row.approx_house_gross_minor; + default: + return row.total_bet_minor; + } +} + +export function DailyTrendChart({ + series, + metric, + formatMoney, + currency, +}: { + series: AdminReportDailyProfitRow[]; + metric: DashboardAnalyticsMetric; + formatMoney: MoneyFormatter; + currency: string | null; +}): ReactElement { + const { t } = useTranslation("dashboard"); + + if (series.length === 0) { + return

{t("states.noData", { ns: "common" })}

; + } + + const maxBet = Math.max(...series.map((d) => d.total_bet_minor), 1); + const maxPayout = Math.max(...series.map((d) => d.total_payout_minor), 1); + const maxProfit = Math.max(...series.map((d) => Math.abs(d.approx_house_gross_minor)), 1); + const labelEvery = series.length > 14 ? Math.ceil(series.length / 7) : 1; + + const plotHeight = series.length <= 7 ? 200 : series.length <= 14 ? 220 : 240; + + return ( +
+ {metric === "overview" ? ( +
+ + + {t("chartLegend.bet")} + + + + {t("chartLegend.payout")} + + + + {t("chartLegend.profit")} + +
+ ) : null} + +
+ {series.map((day, idx) => { + const betH = (day.total_bet_minor / maxBet) * 100; + const payoutH = (day.total_payout_minor / maxPayout) * 100; + const profitRaw = day.approx_house_gross_minor; + const profitH = (Math.abs(profitRaw) / maxProfit) * 100; + const showLabel = idx % labelEvery === 0 || idx === series.length - 1; + const shortDate = day.business_date.slice(5); + + return ( +
+
+ {metric === "overview" ? ( + <> +
0 ? 4 : 0)}%` }} + /> +
0 ? 4 : 0)}%` }} + /> +
= 0 ? "bg-emerald-500/90" : "bg-amber-500/90", + )} + style={{ height: `${Math.max(profitH, profitRaw !== 0 ? 4 : 0)}%` }} + /> + + ) : ( +
= 0 ? "bg-emerald-500/90" : "bg-amber-500/90"), + metric === "bet" && "bg-primary/90", + )} + style={{ + height: `${Math.max( + (metricValue(day, metric) / (metric === "bet" ? maxBet : metric === "payout" ? maxPayout : maxProfit)) * 100, + metricValue(day, metric) !== 0 ? 6 : 0, + )}%`, + }} + /> + )} +
+ + {shortDate} + +
+ ); + })} +
+
+ ); +} + +export function PlayBreakdownChart({ + rows, + metric, + formatMoney, + currency, + playLabel, +}: { + rows: AdminDashboardAnalyticsPlayRow[]; + metric: DashboardAnalyticsMetric; + formatMoney: MoneyFormatter; + currency: string | null; + playLabel: (code: string, dimension: number) => string; +}): ReactElement { + const { t } = useTranslation("dashboard"); + + if (rows.length === 0) { + return

{t("analytics.noPlayData")}

; + } + + const max = Math.max(...rows.map((r) => Math.abs(playMetricValue(r, metric === "overview" ? "bet" : metric))), 1); + const activeMetric = metric === "overview" ? "bet" : metric; + + return ( +
    + {rows.map((row) => { + const value = playMetricValue(row, activeMetric); + const pct = (Math.abs(value) / max) * 100; + const label = playLabel(row.play_code, row.dimension); + + return ( +
  • +
    + {label} + {formatMoney(value, currency)} +
    +
    +
    = 0 ? "bg-emerald-500" : "bg-amber-500"), + activeMetric === "bet" && "bg-primary", + )} + style={{ width: `${pct}%` }} + /> +
    + {metric === "overview" ? ( +

    + {t("playBreakdownHint", { + payout: formatMoney(row.total_payout_minor, currency), + profit: formatMoney(row.approx_house_gross_minor, currency), + })} +

    + ) : null} +
  • + ); + })} +
+ ); +} + +export function PeriodCompareStrip({ + series, + formatMoney, + currency, +}: { + series: AdminReportDailyProfitRow[]; + formatMoney: MoneyFormatter; + currency: string | null; +}): ReactElement { + const { t } = useTranslation("dashboard"); + const totalBet = series.reduce((s, d) => s + d.total_bet_minor, 0); + const totalPayout = series.reduce((s, d) => s + d.total_payout_minor, 0); + const totalProfit = series.reduce((s, d) => s + d.approx_house_gross_minor, 0); + const max = Math.max(totalBet, totalPayout, Math.abs(totalProfit), 1); + + const items = [ + { key: "bet", label: t("chartLegend.bet"), value: totalBet, className: "bg-primary" }, + { key: "payout", label: t("chartLegend.payout"), value: totalPayout, className: "bg-rose-500" }, + { + key: "profit", + label: t("chartLegend.profit"), + value: totalProfit, + className: totalProfit >= 0 ? "bg-emerald-500" : "bg-amber-500", + }, + ]; + + return ( +
+ {items.map((item) => ( +
+
+ + + {item.label} + + {formatMoney(item.value, currency)} +
+
+
+
+
+ ))} +
+ ); +} diff --git a/src/modules/dashboard/dashboard-visuals.tsx b/src/modules/dashboard/dashboard-visuals.tsx index 9c44295..b591884 100644 --- a/src/modules/dashboard/dashboard-visuals.tsx +++ b/src/modules/dashboard/dashboard-visuals.tsx @@ -123,8 +123,8 @@ export function FinanceStructureChart({ const payoutRate = ((payout / bet) * 100).toFixed(1); const segments = [ - { key: "win", width: winW, className: "bg-chart-2", label: t("winPayout"), value: win }, - { key: "jackpot", width: jpW, className: "bg-chart-4", label: t("jackpotPayout"), value: jackpot }, + { key: "win", width: winW, className: "bg-emerald-500", label: t("winPayout"), value: win }, + { key: "jackpot", width: jpW, className: "bg-violet-500", label: t("jackpotPayout"), value: jackpot }, { key: "gross", width: grossW, className: "bg-primary", label: t("houseGross"), value: gross }, ].filter((s) => s.width > 0.05); @@ -176,9 +176,17 @@ export function PayoutCompositionChart({ } const winPct = (win / total) * 100; + const winColor = "oklch(0.62 0.17 162)"; + const jackpotColor = "oklch(0.56 0.22 303)"; const items = [ - { label: t("winPayout"), value: win, pct: winPct, className: "bg-chart-2" }, - { label: t("jackpotPayout"), value: jackpot, pct: 100 - winPct, className: "bg-chart-4" }, + { label: t("winPayout"), value: win, pct: winPct, className: "bg-emerald-500", color: winColor }, + { + label: t("jackpotPayout"), + value: jackpot, + pct: 100 - winPct, + className: "bg-violet-500", + color: jackpotColor, + }, ]; return ( @@ -186,7 +194,7 @@ export function PayoutCompositionChart({

{formatMoney(item.value, currency)}

-
+
))} @@ -249,12 +260,12 @@ export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactEleme export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElement { const { t } = useTranslation("dashboard"); - const entries: { key: keyof SoldOutBuckets; label: string; color: string }[] = [ - { key: "d4", label: t("soldOutBuckets.d4"), color: "var(--chart-1)" }, - { key: "d3", label: t("soldOutBuckets.d3"), color: "var(--chart-2)" }, - { key: "d2", label: t("soldOutBuckets.d2"), color: "var(--chart-3)" }, - { key: "special", label: t("soldOutBuckets.special"), color: "var(--chart-4)" }, - { key: "other", label: t("soldOutBuckets.other"), color: "var(--chart-5)" }, + const entries: { key: keyof SoldOutBuckets; label: string; color: string; swatch: string }[] = [ + { key: "d4", label: t("soldOutBuckets.d4"), color: "oklch(0.52 0.19 264)", swatch: "bg-blue-600" }, + { key: "d3", label: t("soldOutBuckets.d3"), color: "oklch(0.62 0.17 162)", swatch: "bg-emerald-500" }, + { key: "d2", label: t("soldOutBuckets.d2"), color: "oklch(0.72 0.16 75)", swatch: "bg-amber-500" }, + { key: "special", label: t("soldOutBuckets.special"), color: "oklch(0.56 0.22 303)", swatch: "bg-violet-500" }, + { key: "other", label: t("soldOutBuckets.other"), color: "oklch(0.58 0.2 25)", swatch: "bg-rose-500" }, ]; const total = entries.reduce((s, e) => s + buckets[e.key], 0); @@ -307,7 +318,7 @@ export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElem
  • - + {e.label} @@ -381,6 +392,25 @@ export function SettlementStatusChart({ const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]); const max = Math.max(...entries.map((e) => e[1])); + const barTone = (status: string): string => { + switch (status) { + case "pending_review": + return "bg-amber-500"; + case "approved": + return "bg-sky-500"; + case "paid": + case "completed": + return "bg-emerald-600"; + case "running": + return "bg-blue-500"; + case "rejected": + case "failed": + return "bg-rose-500"; + default: + return "bg-violet-500"; + } + }; + return (
      {entries.map(([status, count]) => ( @@ -391,7 +421,7 @@ export function SettlementStatusChart({
    0 ? (count / max) * 100 : 0}%` }} />
    diff --git a/src/modules/draws/draw-detail-console.tsx b/src/modules/draws/draw-detail-console.tsx index f4126b2..37a597f 100644 --- a/src/modules/draws/draw-detail-console.tsx +++ b/src/modules/draws/draw-detail-console.tsx @@ -18,6 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminDrawShowData } from "@/types/api/admin-draws"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; @@ -25,7 +26,11 @@ import { useAdminProfile } from "@/stores/admin-session"; import { cn } from "@/lib/utils"; -import { drawResultSourceLabel, drawStatusLabel } from "./draw-display"; +import { + drawResultSourceLabel, + drawStatusLabel, + hallPreviewDiffersFromDbStatus, +} from "./draw-display"; import { DrawStatusBadge } from "./draw-status-badge"; import { PRD_DRAW_REOPEN_MANAGE, @@ -58,6 +63,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [acting, setActing] = useState(null); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const load = useCallback(async () => { if (!Number.isFinite(idNum)) { @@ -120,13 +126,15 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { status={data.status} label={drawStatusLabel(data.status, t)} /> -

    - {t("hallPreviewStatusLabel")} - -

    + {hallPreviewDiffersFromDbStatus(data.status, data.hall_preview_status) ? ( +

    + {t("hallPreviewStatusLabel")} + +

    + ) : null}
  • @@ -186,7 +194,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { variant="outline" size="sm" disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)} - onClick={() => void runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum))} + onClick={() => + requestConfirm({ + title: t("confirm.manualCloseTitle"), + description: t("confirm.manualCloseDescription"), + onConfirm: () => runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum)), + }) + } > {acting === t("manualClose") ? t("processing") : t("manualClose")} @@ -195,7 +209,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { variant="outline" size="sm" disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)} - onClick={() => void runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum))} + onClick={() => + requestConfirm({ + title: t("confirm.cancelDrawTitle"), + description: t("confirm.cancelDrawDescription"), + onConfirm: () => runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum)), + }) + } > {acting === t("cancelDraw") ? t("processing") : t("cancelBeforeDraw")} @@ -204,7 +224,13 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { variant="outline" size="sm" disabled={!canManageDraw || acting !== null || data.status !== "closed"} - onClick={() => void runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum))} + onClick={() => + requestConfirm({ + title: t("confirm.rngDrawTitle"), + description: t("confirm.rngDrawDescription"), + onConfirm: () => runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum)), + }) + } > {acting === t("rngDraw") ? t("generating") : t("rngAutoGenerate")} @@ -214,7 +240,14 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { variant="destructive" size="sm" disabled={acting !== null || data.status !== "cooldown"} - onClick={() => void runAction(t("reopen"), () => postAdminReopenDraw(idNum))} + onClick={() => + requestConfirm({ + title: t("confirm.reopenTitle"), + description: t("confirm.reopenDescription"), + confirmVariant: "destructive", + onConfirm: () => runAction(t("reopen"), () => postAdminReopenDraw(idNum)), + }) + } > {acting === t("reopen") ? t("processing") : t("cooldownReopen")} @@ -224,13 +257,20 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { variant="outline" size="sm" disabled={!canRunSettlement || acting !== null || data.status !== "settling"} - onClick={() => void runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum))} + onClick={() => + requestConfirm({ + title: t("confirm.runSettlementTitle"), + description: t("confirm.runSettlementDescription"), + onConfirm: () => runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum)), + }) + } > {acting === t("runSettlement") ? t("processing") : t("runSettlement")} ) : null} +
    ); } diff --git a/src/modules/draws/draw-display.ts b/src/modules/draws/draw-display.ts index 69f5a32..8e06e51 100644 --- a/src/modules/draws/draw-display.ts +++ b/src/modules/draws/draw-display.ts @@ -3,6 +3,14 @@ type DrawTranslate = ( options?: { ns?: string; index?: number }, ) => string; +/** 大厅展示态是否与库内期号状态不同(仅 open 等 tick 修正时可能不同) */ +export function hallPreviewDiffersFromDbStatus( + dbStatus: string, + hallPreviewStatus: string, +): boolean { + return dbStatus !== hallPreviewStatus; +} + /** 期号状态文案(draws.statusOptions) */ export function drawStatusLabel(status: string, t: DrawTranslate): string { const key = `statusOptions.${status}`; diff --git a/src/modules/draws/draw-finance-console.tsx b/src/modules/draws/draw-finance-console.tsx index 10a94ed..4409b58 100644 --- a/src/modules/draws/draw-finance-console.tsx +++ b/src/modules/draws/draw-finance-console.tsx @@ -27,6 +27,7 @@ import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance import { toast } from "sonner"; import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; import { formatAdminMinorUnits } from "@/lib/money"; @@ -47,6 +48,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE const [err, setErr] = useState(null); const [loading, setLoading] = useState(true); const [settling, setSettling] = useState(false); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const load = useCallback(async () => { if (!Number.isFinite(idNum) || idNum < 1) { @@ -150,7 +152,13 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE type="button" size="sm" disabled={!canRunSettlement || settling || data.draw_status !== "settling"} - onClick={() => void runSettlement()} + onClick={() => + requestConfirm({ + title: t("confirm.runSettlementTitle"), + description: t("confirm.runSettlementDescription"), + onConfirm: () => runSettlement(), + }) + } > {settling ? t("processing") : t("runSettlement")} @@ -222,6 +230,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE )} +
    ); } diff --git a/src/modules/draws/draw-prd.ts b/src/modules/draws/draw-prd.ts index 46bcaf7..b5145ff 100644 --- a/src/modules/draws/draw-prd.ts +++ b/src/modules/draws/draw-prd.ts @@ -1,5 +1,6 @@ -/** 开奖结果发布权限 slug */ -export const PRD_DRAW_RESULT_MANAGE = "prd.draw_result.manage" as const; -export const PRD_DRAW_REOPEN_MANAGE = "prd.draw_reopen.manage" as const; -export const PRD_PAYOUT_MANAGE = "prd.payout.manage" as const; -export const PRD_PAYOUT_REVIEW = "prd.payout.review" as const; +export { + PRD_DRAW_RESULT_MANAGE, + PRD_DRAW_REOPEN_MANAGE, + PRD_PAYOUT_MANAGE, + PRD_PAYOUT_REVIEW, +} from "@/lib/admin-prd"; diff --git a/src/modules/draws/draw-publish-console.tsx b/src/modules/draws/draw-publish-console.tsx index abd55ea..9aa80d3 100644 --- a/src/modules/draws/draw-publish-console.tsx +++ b/src/modules/draws/draw-publish-console.tsx @@ -17,6 +17,7 @@ import { TableHeader, 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"; @@ -38,6 +39,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [publishing, setPublishing] = useState(false); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const load = useCallback(async () => { if (!Number.isFinite(idNum)) { @@ -184,12 +186,20 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI +
    ); } diff --git a/src/modules/draws/draw-review-console.tsx b/src/modules/draws/draw-review-console.tsx index dd9261d..e63f26d 100644 --- a/src/modules/draws/draw-review-console.tsx +++ b/src/modules/draws/draw-review-console.tsx @@ -17,6 +17,7 @@ import { TableHeader, 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"; @@ -56,6 +57,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) { const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [savingManual, setSavingManual] = useState(false); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const [manualNumbers, setManualNumbers] = useState( () => RESULT_SLOTS.map(() => ""), ); @@ -172,7 +174,13 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) { @@ -224,6 +232,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) { )} +
    ); } diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx index 07d2e90..8c793ca 100644 --- a/src/modules/draws/draws-index-console.tsx +++ b/src/modules/draws/draws-index-console.tsx @@ -30,6 +30,7 @@ import { import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { formatAdminMinorUnits } from "@/lib/money"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { cn } from "@/lib/utils"; @@ -75,6 +76,7 @@ export function DrawsIndexConsole() { const defaultCurrency = "NPR"; const profile = useAdminProfile(); const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -148,11 +150,22 @@ export function DrawsIndexConsole() { }, [load]); return ( + <> {t("statusListTitle")} {canManageDraw ? ( - ) : null} @@ -331,5 +344,7 @@ export function DrawsIndexConsole() { ) : null} + + ); } diff --git a/src/modules/jackpot/jackpot-config-screen.tsx b/src/modules/jackpot/jackpot-config-screen.tsx index 37469cc..aa145ea 100644 --- a/src/modules/jackpot/jackpot-config-screen.tsx +++ b/src/modules/jackpot/jackpot-config-screen.tsx @@ -3,10 +3,11 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; +import { AdminPageCard } from "@/components/admin/admin-page-card"; import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console"; import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console"; -/** 奖池单页:池参数 + 流水记录,避免 ConfigDocPage / 内层 Card 重复套娃。 */ +/** 奖池单页:池参数 + 流水记录,与列表/设置页共用 admin-list-card 布局。 */ export function JackpotConfigScreen() { const { t } = useTranslation("jackpot"); @@ -23,20 +24,14 @@ export function JackpotConfigScreen() { }, []); return ( -
    -
    -

    - {t("poolsSectionTitle")} -

    +
    + -
    + -
    -

    - {t("recordsSectionTitle")} -

    + -
    +
    ); } diff --git a/src/modules/jackpot/jackpot-pools-console.tsx b/src/modules/jackpot/jackpot-pools-console.tsx index df5c38e..169b239 100644 --- a/src/modules/jackpot/jackpot-pools-console.tsx +++ b/src/modules/jackpot/jackpot-pools-console.tsx @@ -8,6 +8,9 @@ import { postAdminJackpotManualBurst, putAdminJackpotPool, } from "@/api/admin-jackpot"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd"; import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -20,7 +23,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { toast } from "sonner"; +import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminJackpotPoolRow } from "@/types/api/admin-jackpot"; @@ -34,7 +46,6 @@ type Draft = { combo_trigger_play_codes: string; status: string; manual_burst_draw_id: string; - manual_burst_amount: string; }; function toDraft(p: AdminJackpotPoolRow): Draft { @@ -48,7 +59,6 @@ function toDraft(p: AdminJackpotPoolRow): Draft { combo_trigger_play_codes: p.combo_trigger_play_codes.join(","), status: String(p.status), manual_burst_draw_id: "", - manual_burst_amount: "", }; } @@ -59,11 +69,16 @@ type JackpotPoolsConsoleProps = { export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) { const { t } = useTranslation(["jackpot", "common"]); + const profile = useAdminProfile(); + const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]); + const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]); + const { request: requestConfirm, ConfirmDialog: ConfirmActionDialog } = useConfirmAction(); const [items, setItems] = useState([]); const [drafts, setDrafts] = useState>({}); const [loading, setLoading] = useState(true); const [savingId, setSavingId] = useState(null); const [burstingId, setBurstingId] = useState(null); + const [confirmBurstPoolId, setConfirmBurstPoolId] = useState(null); const load = useCallback(async () => { setLoading(true); @@ -131,22 +146,18 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro return; } - const amount = d.manual_burst_amount.trim() - ? Number.parseInt(d.manual_burst_amount, 10) - : undefined; - setBurstingId(p.id); try { - await postAdminJackpotManualBurst(p.id, { - draw_id: drawId, - amount: amount !== undefined && Number.isFinite(amount) ? amount : undefined, - }); - toast.success(t("manualBurstSuccess")); + const res = await postAdminJackpotManualBurst(p.id, { draw_id: drawId }); + toast.success( + `${t("manualBurstSuccess")} · ${res.draw_no} · ${res.winner_count} ${t("winnerCount")}`, + ); await load(); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("manualBurstFailed")); } finally { setBurstingId(null); + setConfirmBurstPoolId(null); } }; @@ -164,7 +175,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro className="space-y-4 rounded-xl border border-border/60 bg-muted/10 p-4" >

    {p.currency_code}

    -
    +
    -
    -
    - -
    + + {canManageJackpot ? ( +
    + +
    + ) : null} + {canManualBurst ? (
    -

    +

    {t("manualBurst")}

    +

    {t("manualBurstHint")}

    @@ -264,34 +290,63 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })} />
    -
    - - updateDraft(p.id, { manual_burst_amount: e.target.value })} - /> -
    + ) : null}
    ); })}
    ); + const confirmPool = confirmBurstPoolId !== null ? items.find((p) => p.id === confirmBurstPoolId) : null; + const confirmDraft = confirmPool ? drafts[confirmPool.id] : null; + + const confirmDialog = ( + !open && setConfirmBurstPoolId(null)}> + + + {t("manualBurstConfirmTitle")} + + {t("manualBurstConfirmDescription", { + drawId: confirmDraft?.manual_burst_draw_id ?? "—", + })} + + + + + + + + + ); + if (embedded) { - return poolList; + return ( + <> + {poolList} + {confirmDialog} + + + ); } return ( @@ -302,6 +357,8 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro {poolList} + {confirmDialog} + ); } diff --git a/src/modules/jackpot/jackpot-records-console.tsx b/src/modules/jackpot/jackpot-records-console.tsx index 9641824..395a931 100644 --- a/src/modules/jackpot/jackpot-records-console.tsx +++ b/src/modules/jackpot/jackpot-records-console.tsx @@ -155,8 +155,8 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol }; const filterBlock = embedded ? ( -
    -
    +
    +
    - +
    + +
    ) : ( diff --git a/src/modules/players/players-console.tsx b/src/modules/players/players-console.tsx index 9a6fd45..2c64ccf 100644 --- a/src/modules/players/players-console.tsx +++ b/src/modules/players/players-console.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -9,6 +10,8 @@ import { deleteAdminPlayer, getAdminPlayers, postAdminPlayer, + postAdminPlayerFreeze, + postAdminPlayerUnfreeze, putAdminPlayer, } from "@/api/admin-player"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; @@ -27,6 +30,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { PRD_PLAYER_FREEZE_MANAGE, PRD_USERS_MANAGE } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { Select, @@ -63,10 +67,12 @@ const PLAYER_STATUS_OPTIONS = [ export function PlayersConsole(): React.ReactElement { const { t } = useTranslation(["players", "common"]); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const exportLabels = useExportLabels("players"); const profile = useAdminProfile(); useAdminCurrencyCatalog(); - const canManagePlayers = adminHasAnyPermission(profile?.permissions, ["prd.users.manage"]); + const canManagePlayers = adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]); + const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [keyword, setKeyword] = useState(""); @@ -91,6 +97,7 @@ export function PlayersConsole(): React.ReactElement { const [deleteTarget, setDeleteTarget] = useState(null); const [deleteBusy, setDeleteBusy] = useState(false); + const [freezeBusyId, setFreezeBusyId] = useState(null); const editingPlayer = useMemo( () => items.find((p) => p.id === editingAccountId) ?? null, @@ -226,6 +233,28 @@ export function PlayersConsole(): React.ReactElement { } } + async function toggleFreeze(row: AdminPlayerRow, freeze: boolean): Promise { + setFreezeBusyId(row.id); + try { + const updated = freeze + ? await postAdminPlayerFreeze(row.id) + : await postAdminPlayerUnfreeze(row.id); + setItems((prev) => prev.map((r) => (r.id === updated.id ? updated : r))); + const name = updated.username ?? updated.site_player_id; + toast.success(freeze ? t("freezeSuccess", { name }) : t("unfreezeSuccess", { name })); + } catch (e) { + const msg = + e instanceof LotteryApiBizError + ? e.message + : freeze + ? t("freezeFailed") + : t("unfreezeFailed"); + toast.error(msg); + } finally { + setFreezeBusyId(null); + } + } + async function confirmDelete(): Promise { if (!deleteTarget) return; setDeleteBusy(true); @@ -364,26 +393,66 @@ export function PlayersConsole(): React.ReactElement { : "—"} - {canManagePlayers ? ( + {canManagePlayers || canFreezePlayers ? (
    - - + {canFreezePlayers && row.status === 0 ? ( + + ) : null} + {canFreezePlayers && row.status === 1 ? ( + + ) : null} + {canManagePlayers ? ( + <> + + + + ) : null}
    ) : ( @@ -554,6 +623,7 @@ export function PlayersConsole(): React.ReactElement {
    +
    ); } diff --git a/src/modules/reconcile/reconcile-console.tsx b/src/modules/reconcile/reconcile-console.tsx index b2637d8..0a1cbae 100644 --- a/src/modules/reconcile/reconcile-console.tsx +++ b/src/modules/reconcile/reconcile-console.tsx @@ -26,6 +26,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { cn } from "@/lib/utils"; @@ -79,6 +80,7 @@ function reconcileTypeLabel(type: string, t: (key: string) => string): string { export function ReconcileConsole(): React.ReactElement { const { t } = useTranslation(["reconcile", "common"]); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const profile = useAdminProfile(); const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]); const formatTs = useAdminDateTimeFormatter(); @@ -240,7 +242,22 @@ export function ReconcileConsole(): React.ReactElement { }} />
    -
    @@ -532,6 +549,7 @@ export function ReconcileConsole(): React.ReactElement {
    + ); } diff --git a/src/modules/reports/reports-console.tsx b/src/modules/reports/reports-console.tsx index 2ac2c34..eb0351d 100644 --- a/src/modules/reports/reports-console.tsx +++ b/src/modules/reports/reports-console.tsx @@ -38,9 +38,11 @@ import { import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk"; import { getAdminUsers } from "@/api/admin-users"; import { getAdminTransferOrders } from "@/api/admin-wallet"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd"; +import { useAdminProfile } from "@/stores/admin-session"; import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -365,6 +367,9 @@ function resultRowCount(result: ReportResult | null): number { export function ReportsConsole() { const { t, i18n } = useTranslation(["reports", "common"]); + const profile = useAdminProfile(); + const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]); + const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]); useAdminCurrencyCatalog(); useAdminPlayTypeCatalog(); const playCodeLabel = useAdminPlayCodeLabel(); @@ -446,6 +451,9 @@ export function ReportsConsole() { }, [search.open, search.query, loadSearchOptions]); const queryReport = useCallback(async () => { + if (!canViewReports) { + return; + } setLoading(true); setError(null); try { @@ -739,7 +747,7 @@ export function ReportsConsole() { } finally { setLoading(false); } - }, [filters, page, perPage, selectedReport, t]); + }, [canViewReports, filters, page, perPage, selectedReport, t]); useEffect(() => { setResult(null); @@ -766,6 +774,9 @@ export function ReportsConsole() { } function exportReport(format: ExportFormat): void { + if (!canExportReports) { + return; + } if (!result || result.rows.length === 0) { toast.info(t("empty")); return; @@ -1173,15 +1184,6 @@ export function ReportsConsole() { return (
    -
    -
    -

    {t("title")}

    -
    - - {resultRowCount(result)} {t("preview.exportableRows")} - -
    -
    @@ -1233,7 +1235,7 @@ export function ReportsConsole() {
    - - diff --git a/src/modules/risk/risk-draw-header.tsx b/src/modules/risk/risk-draw-header.tsx index 177394f..8a797c3 100644 --- a/src/modules/risk/risk-draw-header.tsx +++ b/src/modules/risk/risk-draw-header.tsx @@ -4,12 +4,13 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { getAdminDraw } from "@/api/admin-draws"; +import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "@/modules/draws/draw-display"; import { DrawStatusBadge } from "@/modules/draws/draw-status-badge"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminDrawShowData } from "@/types/api/admin-draws"; export function RiskDrawHeader({ drawId }: { drawId: number }) { - const { t } = useTranslation("risk"); + const { t } = useTranslation(["risk", "draws"]); const [draw, setDraw] = useState(null); const [error, setError] = useState(null); @@ -47,10 +48,19 @@ export function RiskDrawHeader({ drawId }: { drawId: number }) {

    {t("databaseStatus")} - - - {t("hallPreviewStatus", { status: draw.hall_preview_status })} - + + {hallPreviewDiffersFromDbStatus(draw.status, draw.hall_preview_status) ? ( + <> + {t("hallPreviewStatusLabel", { ns: "draws" })} + + + ) : null}

    ); diff --git a/src/modules/risk/risk-pools-console.tsx b/src/modules/risk/risk-pools-console.tsx index 6611513..41aa695 100644 --- a/src/modules/risk/risk-pools-console.tsx +++ b/src/modules/risk/risk-pools-console.tsx @@ -32,7 +32,11 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { PRD_DRAW_RESULT_MANAGE, PRD_RISK_MANAGE } from "@/lib/admin-prd"; +import { useAdminProfile } from "@/stores/admin-session"; import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; import { formatAdminMinorUnits } from "@/lib/money"; import { cn } from "@/lib/utils"; @@ -77,6 +81,12 @@ export function RiskPoolsConsole({ allowSortChange = false, }: RiskPoolsConsoleProps) { const { t } = useTranslation(["risk", "common"]); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); + const profile = useAdminProfile(); + const canManageRiskPools = adminHasAnyPermission(profile?.permissions, [ + PRD_RISK_MANAGE, + PRD_DRAW_RESULT_MANAGE, + ]); const pageTitle = titleKey ? t(titleKey) : (title ?? t("poolsTitle")); const exportLabels = useExportLabels("riskPools"); useAdminCurrencyCatalog(); @@ -148,6 +158,7 @@ export function RiskPoolsConsole({ ); return ( + <> {pageTitle} @@ -292,15 +303,28 @@ export function RiskPoolsConsole({
    - + {canManageRiskPools ? ( + + ) : null} + + ); } diff --git a/src/modules/rules/rules-odds-config-screen.tsx b/src/modules/rules/rules-odds-config-screen.tsx index 4d0f92a..060fc94 100644 --- a/src/modules/rules/rules-odds-config-screen.tsx +++ b/src/modules/rules/rules-odds-config-screen.tsx @@ -3,6 +3,8 @@ import { useEffect } 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"; @@ -27,14 +29,20 @@ export function RulesOddsConfigScreen() { return ( - - - - - - - - + + + + + + + + + + ); } diff --git a/src/modules/settings/currency-settings-panel.tsx b/src/modules/settings/currency-settings-panel.tsx index 659b84c..6adad9a 100644 --- a/src/modules/settings/currency-settings-panel.tsx +++ b/src/modules/settings/currency-settings-panel.tsx @@ -11,10 +11,10 @@ import { postAdminCurrency, putAdminCurrency, } from "@/api/admin-currencies"; +import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; 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 { Checkbox } from "@/components/ui/checkbox"; import { Dialog, @@ -204,16 +204,21 @@ export function CurrencySettingsPanel() { } return ( - - - {t("currencies.title", { ns: "config" })} -
    - - -
    -
    - + <> + + + + + } + >
    @@ -277,7 +282,7 @@ export function CurrencySettingsPanel() {
    -
    + @@ -385,6 +390,6 @@ export function CurrencySettingsPanel() {
    -
    + ); } diff --git a/src/modules/settings/system-settings-screen.tsx b/src/modules/settings/system-settings-screen.tsx index 6f80539..d3d16a1 100644 --- a/src/modules/settings/system-settings-screen.tsx +++ b/src/modules/settings/system-settings-screen.tsx @@ -8,8 +8,11 @@ import { getAdminSettings, updateAdminSetting, } from "@/api/admin-settings"; +import { AdminPageCard } from "@/components/admin/admin-page-card"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen"; import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; @@ -23,6 +26,8 @@ const DRAW_KEYS = { REQUIRE_MANUAL_REVIEW: "draw.require_manual_review", COOLDOWN_MINUTES: "draw.cooldown_minutes", AUTO_SETTLEMENT: "settlement.auto_run_on_tick", + AUTO_APPROVE: "settlement.auto_approve_on_tick", + AUTO_PAYOUT: "settlement.auto_payout_on_tick", } as const; const FRONTEND_GROUP = "frontend"; @@ -37,6 +42,8 @@ interface RuntimeDraft { requireManualReview: boolean; cooldownMinutes: string; autoSettlement: boolean; + autoApprove: boolean; + autoPayout: boolean; playRulesHtmlZh: string; playRulesHtmlEn: string; playRulesHtmlNe: string; @@ -116,10 +123,13 @@ function SaveActions({ export function SystemSettingsScreen() { const { t } = useTranslation(["common", "config", "adminUsers"]); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const [draft, setDraft] = useState({ requireManualReview: false, cooldownMinutes: "15", autoSettlement: true, + autoApprove: true, + autoPayout: true, playRulesHtmlZh: "", playRulesHtmlEn: "", playRulesHtmlNe: "", @@ -128,6 +138,8 @@ export function SystemSettingsScreen() { requireManualReview: false, cooldownMinutes: "15", autoSettlement: true, + autoApprove: true, + autoPayout: true, playRulesHtmlZh: "", playRulesHtmlEn: "", playRulesHtmlNe: "", @@ -155,6 +167,8 @@ export function SystemSettingsScreen() { requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false), cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15), autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true), + autoApprove: Boolean(kv[DRAW_KEYS.AUTO_APPROVE] ?? true), + autoPayout: Boolean(kv[DRAW_KEYS.AUTO_PAYOUT] ?? true), playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml), playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""), playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""), @@ -189,6 +203,8 @@ export function SystemSettingsScreen() { Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0), ); await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement); + await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove); + await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout); await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh); await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn); await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe); @@ -210,12 +226,11 @@ export function SystemSettingsScreen() { const discardLabel = t("system.discard", { ns: "config" }); return ( -
    -
    -

    - {t("system.title", { ns: "config" })} -

    - +
    +
    @@ -243,6 +258,32 @@ export function SystemSettingsScreen() {
    +
    + + updateDraft("autoApprove", value)} + leftLabel={t("system.states.disabled", { ns: "config" })} + rightLabel={t("system.states.enabled", { ns: "config" })} + /> +
    + +
    + +
    + + updateDraft("autoPayout", value)} + leftLabel={t("system.states.disabled", { ns: "config" })} + rightLabel={t("system.states.enabled", { ns: "config" })} + /> +
    + +
    +
    + -
    - -
    -

    - {t("wallet.title", { ns: "config" })} -

    + -
    - -
    -

    - {t("system.frontendConfig", { ns: "config" })} -

    + +
    +
    -
    - - void handleSave()} - onDiscard={() => { - setDraft(saved); - setDirty(false); - }} - saveLabel={saveLabel} - savingLabel={savingLabel} - discardLabel={discardLabel} - /> + + + + requestConfirm({ + title: t("system.confirmSaveTitle", { ns: "config" }), + description: t("system.confirmSaveDescription", { ns: "config" }), + confirmLabel: t("confirm.confirmSave", { ns: "common" }), + onConfirm: () => handleSave(), + }) + } + onDiscard={() => { + setDraft(saved); + setDirty(false); + }} + saveLabel={saveLabel} + savingLabel={savingLabel} + discardLabel={discardLabel} + /> + + +
    ); } diff --git a/src/modules/settlement/settlement-batch-details-console.tsx b/src/modules/settlement/settlement-batch-details-console.tsx index 060cb24..6585069 100644 --- a/src/modules/settlement/settlement-batch-details-console.tsx +++ b/src/modules/settlement/settlement-batch-details-console.tsx @@ -42,7 +42,7 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter" import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { formatAdminMinorUnits } from "@/lib/money"; import { cn } from "@/lib/utils"; -import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/modules/draws/draw-prd"; +import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import type { @@ -86,6 +86,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) { const [acting, setActing] = useState(null); const [pendingAction, setPendingAction] = useState(null); const [reviewRemark, setReviewRemark] = useState(""); + const batchCurrency = summary?.currency_code ?? "NPR"; const load = useCallback(async () => { setLoading(true); @@ -277,32 +278,38 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) { {t("endedAt")} {formatDt(summary.finished_at)}

    - - - + {canReviewSettlement ? ( + + ) : null} + {canReviewSettlement ? ( + + ) : null} + {canManagePayout ? ( + + ) : null} @@ -341,12 +348,12 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) { {r.matched_prize_tier ?? "—"} - {formatAdminMinorUnits(r.win_amount, r.currency_code ?? summary.currency_code ?? "NPR")} + {formatAdminMinorUnits(r.win_amount, r.currency_code ?? batchCurrency)} {formatAdminMinorUnits( r.jackpot_allocation_amount, - r.currency_code ?? summary.currency_code ?? "NPR", + r.currency_code ?? batchCurrency, )} diff --git a/src/modules/settlement/settlement-batches-console.tsx b/src/modules/settlement/settlement-batches-console.tsx index aea3c3e..cf4c966 100644 --- a/src/modules/settlement/settlement-batches-console.tsx +++ b/src/modules/settlement/settlement-batches-console.tsx @@ -48,7 +48,7 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { formatAdminMinorUnits } from "@/lib/money"; import { cn } from "@/lib/utils"; -import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/modules/draws/draw-prd"; +import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminSettlementBatchListData, AdminSettlementBatchRow } from "@/types/api/admin-settlement"; @@ -295,32 +295,38 @@ export function SettlementBatchesConsole() { > {t("details")} - - - + {canReviewSettlement ? ( + + ) : null} + {canReviewSettlement ? ( + + ) : null} + {canManagePayout ? ( + + ) : null}
    diff --git a/src/modules/tickets/player-tickets-console.tsx b/src/modules/tickets/player-tickets-console.tsx index 64ef15a..a252939 100644 --- a/src/modules/tickets/player-tickets-console.tsx +++ b/src/modules/tickets/player-tickets-console.tsx @@ -36,6 +36,7 @@ import { ChevronDown } from "lucide-react"; const TICKET_STATUS_OPTIONS = [ "pending_confirm", "partial_pending_confirm", + "pending_draw", "success", "failed", "pending_payout", diff --git a/src/modules/wallet/wallet-console.tsx b/src/modules/wallet/wallet-console.tsx index e8ba499..8c3dca6 100644 --- a/src/modules/wallet/wallet-console.tsx +++ b/src/modules/wallet/wallet-console.tsx @@ -36,8 +36,12 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { PRD_WALLET_WRITE_ANY } from "@/lib/admin-prd"; +import { useAdminProfile } from "@/stores/admin-session"; import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; +import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; import { formatAdminMinorUnits } from "@/lib/money"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -202,19 +206,31 @@ function walletAdminSelectDisplayedLabel( return key ? (t ? t(key) : key) : v; } -function canReverseTransferOrder(row: { status: string; can_reverse?: boolean }): boolean { - return row.can_reverse ?? row.status === "pending_reconcile"; +function canReverseTransferOrder( + row: { status: string; can_reverse?: boolean }, + canWriteWallet: boolean, +): boolean { + return canWriteWallet && (row.can_reverse ?? row.status === "pending_reconcile"); } -function canManuallyProcessTransferOrder(row: { - status: string; - can_manually_process?: boolean; -}): boolean { - return row.can_manually_process ?? ["processing", "failed", "pending_reconcile"].includes(row.status); +function canManuallyProcessTransferOrder( + row: { + status: string; + can_manually_process?: boolean; + }, + canWriteWallet: boolean, +): boolean { + return ( + canWriteWallet && + (row.can_manually_process ?? ["processing", "failed", "pending_reconcile"].includes(row.status)) + ); } export function TransferOrdersPanel(): React.ReactElement { const { t } = useTranslation(["wallet", "common"]); + const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); + const profile = useAdminProfile(); + const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]); const exportLabels = useExportLabels("walletTransferOrders"); useAdminCurrencyCatalog(); const formatTs = useAdminDateTimeFormatter(); @@ -249,10 +265,20 @@ export function TransferOrdersPanel(): React.ReactElement { }; const handleReverse = (transferNo: string) => - doAction(transferNo, () => reverseTransferOrder(transferNo), t("reverseSuccess")); + requestConfirm({ + title: t("confirm.reverseTitle"), + description: t("confirm.reverseDescription", { transferNo }), + confirmVariant: "destructive", + onConfirm: () => doAction(transferNo, () => reverseTransferOrder(transferNo), t("reverseSuccess")), + }); const handleManuallyProcess = (transferNo: string) => - doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess")); + requestConfirm({ + title: t("confirm.manualProcessTitle"), + description: t("confirm.manualProcessDescription", { transferNo }), + onConfirm: () => + doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess")), + }); const load = useCallback(async () => { setLoading(true); @@ -302,6 +328,7 @@ export function TransferOrdersPanel(): React.ReactElement { }; return ( + <> {t("transferOrders")} @@ -480,9 +507,10 @@ export function TransferOrdersPanel(): React.ReactElement { {formatTs(row.finished_at)} - {canReverseTransferOrder(row) || canManuallyProcessTransferOrder(row) ? ( + {canReverseTransferOrder(row, canWriteWallet) || + canManuallyProcessTransferOrder(row, canWriteWallet) ? (
    - {canReverseTransferOrder(row) ? ( + {canReverseTransferOrder(row, canWriteWallet) ? (