refactor: 更新管理端页面元数据,统一国际化支持,移除冗余代码
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminRolesConsole } from "@/modules/admin-roles/admin-roles-console";
|
||||
import { adminRolesModuleMeta } from "@/modules/admin-roles/meta";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: adminRolesModuleMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("adminRoles", "title");
|
||||
|
||||
export default function AdminRolesPage() {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminUsersConsole } from "@/modules/admin-users/admin-users-console";
|
||||
import { adminUsersModuleMeta } from "@/modules/admin-users/meta";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: adminUsersModuleMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("adminUsers", "title");
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { auditLogsModuleMeta } from "@/modules/audit/meta";
|
||||
import { AuditLogsConsole } from "@/modules/audit/audit-logs-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: auditLogsModuleMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("audit", "title");
|
||||
|
||||
export default function AdminAuditLogsPage() {
|
||||
return (
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { JackpotConfigScreen } from "@/modules/jackpot/jackpot-config-screen";
|
||||
import { jackpotModuleMeta } from "@/modules/jackpot/meta";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: jackpotModuleMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminConfigJackpotPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<p className="text-sm text-muted-foreground">Loading…</p>}
|
||||
>
|
||||
<JackpotConfigScreen />
|
||||
</Suspense>
|
||||
);
|
||||
export default function AdminConfigJackpotRedirectPage() {
|
||||
redirect("/admin/jackpot");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/** 旧路径保留跳转,避免书签失效 */
|
||||
export default function AdminConfigJackpotRecordsPage() {
|
||||
redirect("/admin/config/jackpot#records");
|
||||
export default function AdminConfigJackpotRecordsRedirectPage() {
|
||||
redirect("/admin/jackpot#records");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ConfigWorkspaceShell } from "@/modules/config/config-workspace-shell";
|
||||
|
||||
/** 配置总览仅展示卡片入口,不再挂载横向子导航。 */
|
||||
export default function AdminConfigLayout({ children }: { children: ReactNode }) {
|
||||
return <ConfigWorkspaceShell>{children}</ConfigWorkspaceShell>;
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
|
||||
import { configOddsMeta } from "@/modules/config/meta";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configOddsMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminConfigOddsPage() {
|
||||
return <OddsConfigDocScreen />;
|
||||
export default function AdminConfigOddsRedirectPage() {
|
||||
redirect("/admin/rules/odds");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { configHubMeta } from "@/modules/config/meta";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ConfigHubScreen } from "@/modules/config/config-hub-screen";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configHubMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("config", "hub.title");
|
||||
|
||||
export default function AdminConfigHubPage() {
|
||||
redirect("/admin/config/plays");
|
||||
return <ConfigHubScreen />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { PlayConfigDocScreen } from "@/modules/config/doc/play-config-doc-screen";
|
||||
import { configPlayConfigMeta } from "@/modules/config/meta";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configPlayConfigMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminConfigPlaysPage() {
|
||||
return <PlayConfigDocScreen />;
|
||||
export default function AdminConfigPlaysRedirectPage() {
|
||||
redirect("/admin/rules/plays");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen";
|
||||
import { configRebateMeta } from "@/modules/config/meta";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configRebateMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminConfigRebateDedicatedPage() {
|
||||
return <RebateConfigDocScreen />;
|
||||
export default function AdminConfigRebateRedirectPage() {
|
||||
redirect("/admin/rules/odds#rebate");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { configRiskCapMeta } from "@/modules/config/meta";
|
||||
import { RiskCapDocScreen } from "@/modules/config/doc/risk-cap-doc-screen";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: configRiskCapMeta.title,
|
||||
};
|
||||
|
||||
export default function AdminConfigRiskCapPage() {
|
||||
return <RiskCapDocScreen />;
|
||||
export default function AdminConfigRiskCapRedirectPage() {
|
||||
redirect("/admin/risk/cap");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { CurrencyManagementScreen } from "@/modules/settings/currency-management-screen";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "币种管理",
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("config", "currencies.title");
|
||||
|
||||
export default function AdminCurrenciesPage() {
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { DrawFinanceConsole } from "@/modules/draws/draw-finance-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "期号收支",
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("draws", "subnav.finance");
|
||||
|
||||
export default async function AdminDrawFinancePage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { DrawPublishConsole } from "@/modules/draws/draw-publish-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "开奖结果发布",
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("draws", "publishTitle");
|
||||
|
||||
export default async function AdminDrawPublishBatchPage(props: {
|
||||
params: Promise<{ drawId: string; batchId: string }>;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { drawsModuleMeta } from "@/modules/draws/meta";
|
||||
import { DrawsIndexConsole } from "@/modules/draws/draws-index-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: drawsModuleMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("draws", "statusListTitle");
|
||||
|
||||
export default function AdminDrawsPage() {
|
||||
return (
|
||||
|
||||
18
src/app/admin/(shell)/jackpot/page.tsx
Normal file
18
src/app/admin/(shell)/jackpot/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { JackpotConfigScreen } from "@/modules/jackpot/jackpot-config-screen";
|
||||
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("jackpot", "configTitle");
|
||||
|
||||
export default function AdminJackpotPage() {
|
||||
return (
|
||||
<RulesPageShell>
|
||||
<Suspense fallback={<p className="text-sm text-muted-foreground">Loading…</p>}>
|
||||
<JackpotConfigScreen />
|
||||
</Suspense>
|
||||
</RulesPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminJackpotPoolsRedirectPage() {
|
||||
redirect("/admin/config/jackpot");
|
||||
redirect("/admin/jackpot");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminJackpotRecordsRedirectPage() {
|
||||
redirect("/admin/config/jackpot#records");
|
||||
redirect("/admin/jackpot#records");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "仪表盘",
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("dashboard", "title");
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { playersModuleMeta } from "@/modules/players/meta";
|
||||
import { PlayersConsole } from "@/modules/players/players-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: playersModuleMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("players", "title");
|
||||
|
||||
export default function AdminPlayersPage() {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { reconcileModuleMeta } from "@/modules/reconcile/meta";
|
||||
import { ReconcileConsole } from "@/modules/reconcile/reconcile-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: reconcileModuleMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("reconcile", "title");
|
||||
|
||||
export default function AdminReconcilePage() {
|
||||
return (
|
||||
|
||||
14
src/app/admin/(shell)/risk/cap/page.tsx
Normal file
14
src/app/admin/(shell)/risk/cap/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { RiskCapDocScreen } from "@/modules/config/doc/risk-cap-doc-screen";
|
||||
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("config", "nav.riskCapTitle");
|
||||
|
||||
export default function AdminRiskCapPage() {
|
||||
return (
|
||||
<RulesPageShell>
|
||||
<RiskCapDocScreen />
|
||||
</RulesPageShell>
|
||||
);
|
||||
}
|
||||
9
src/app/admin/(shell)/rules/odds/page.tsx
Normal file
9
src/app/admin/(shell)/rules/odds/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { RulesOddsConfigScreen } from "@/modules/rules/rules-odds-config-screen";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("config", "nav.rulesOddsTitle");
|
||||
|
||||
export default function AdminRulesOddsPage() {
|
||||
return <RulesOddsConfigScreen />;
|
||||
}
|
||||
14
src/app/admin/(shell)/rules/plays/page.tsx
Normal file
14
src/app/admin/(shell)/rules/plays/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { PlayConfigDocScreen } from "@/modules/config/doc/play-config-doc-screen";
|
||||
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("config", "nav.rulesPlaysTitle");
|
||||
|
||||
export default function AdminRulesPlaysPage() {
|
||||
return (
|
||||
<RulesPageShell>
|
||||
<PlayConfigDocScreen />
|
||||
</RulesPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "币种管理",
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("config", "currencies.title");
|
||||
|
||||
export default function AdminCurrencySettingsPage() {
|
||||
redirect("/admin/currencies");
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { settingsModuleMeta } from "@/modules/settings/meta";
|
||||
import { SystemSettingsScreen } from "@/modules/settings/system-settings-screen";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: settingsModuleMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("common", "nav.settings");
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InvalidSettlementBatchId } from "@/modules/settlement/invalid-settlement-batch-id";
|
||||
import { SettlementBatchDetailsConsole } from "@/modules/settlement/settlement-batch-details-console";
|
||||
import { settlementModuleMeta } from "@/modules/settlement/meta";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `结算明细 · ${settlementModuleMeta.title}`,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("settlement", "details");
|
||||
|
||||
export default async function AdminSettlementBatchDetailsPage(props: {
|
||||
params: Promise<{ batchId: string }>;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { SettlementBatchesConsole } from "@/modules/settlement/settlement-batches-console";
|
||||
import { settlementModuleMeta } from "@/modules/settlement/meta";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: settlementModuleMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("settlement", "batchList");
|
||||
|
||||
export default function AdminSettlementBatchesPage() {
|
||||
return <SettlementBatchesConsole />;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { ticketsModuleMeta } from "@/modules/tickets/meta";
|
||||
import { PlayerTicketsConsole } from "@/modules/tickets/player-tickets-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: ticketsModuleMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("tickets", "title");
|
||||
|
||||
export default function AdminTicketsPage() {
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { walletModuleMeta } from "@/modules/wallet/meta";
|
||||
import { PlayerWalletPanel } from "@/modules/wallet/wallet-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${walletModuleMeta.title} · 玩家钱包`,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("wallet", "playerWalletQuery");
|
||||
|
||||
export default function AdminWalletPlayerPage() {
|
||||
return <PlayerWalletPanel />;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { walletModuleMeta } from "@/modules/wallet/meta";
|
||||
import { WalletTxnsPanel } from "@/modules/wallet/wallet-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${walletModuleMeta.title} · 钱包流水`,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("wallet", "walletTransactions");
|
||||
|
||||
export default function AdminWalletTransactionsPage() {
|
||||
return <WalletTxnsPanel />;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { walletModuleMeta } from "@/modules/wallet/meta";
|
||||
import { TransferOrdersPanel } from "@/modules/wallet/wallet-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${walletModuleMeta.title} · 转账单`,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("wallet", "transferOrders");
|
||||
|
||||
export default function AdminWalletTransferOrdersPage() {
|
||||
return <TransferOrdersPanel />;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { LoginForm } from "@/components/admin/login-form";
|
||||
import { authModuleMeta } from "@/modules/auth/meta";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: authModuleMeta.title,
|
||||
};
|
||||
export const metadata: Metadata = buildPageMetadata("auth", "title");
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
return <LoginForm />;
|
||||
|
||||
@@ -16,10 +16,10 @@ const geistMono = Geist_Mono({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s · 彩票后台",
|
||||
default: "彩票后台",
|
||||
template: "%s · Lottery Admin",
|
||||
default: "Lottery Admin",
|
||||
},
|
||||
description: "彩票后台管理端",
|
||||
description: "Lottery administration console",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -29,7 +29,10 @@ const NAV_TRANSLATION_KEYS: Record<string, string> = {
|
||||
currencies: "currencies",
|
||||
wallet: "wallet",
|
||||
draws: "draws",
|
||||
config: "config",
|
||||
rules_plays: "rules_plays",
|
||||
rules_odds: "rules_odds",
|
||||
jackpot: "jackpot",
|
||||
risk_cap: "risk_cap",
|
||||
risk: "risk",
|
||||
settlement: "settlement",
|
||||
reconcile: "reconcile",
|
||||
@@ -38,6 +41,15 @@ const NAV_TRANSLATION_KEYS: Record<string, string> = {
|
||||
settings: "settings",
|
||||
};
|
||||
|
||||
const RULES_ROUTE_LABELS: Record<string, string> = {
|
||||
plays: "nav.items.plays",
|
||||
odds: "nav.rulesOddsTitle",
|
||||
};
|
||||
|
||||
const RISK_ROUTE_LABELS: Record<string, string> = {
|
||||
cap: "nav.riskCapTitle",
|
||||
};
|
||||
|
||||
const SETTINGS_ROUTE_LABELS: Record<string, string> = {
|
||||
currencies: "currencies.title",
|
||||
};
|
||||
@@ -76,9 +88,16 @@ export function AdminBreadcrumb() {
|
||||
if (pathname !== ADMIN_BASE) {
|
||||
const businessSegment = segments[1];
|
||||
if (businessSegment) {
|
||||
const navItem = navItems.find((item) => {
|
||||
return item.segment === businessSegment || item.href.includes(businessSegment);
|
||||
});
|
||||
const navItem = navItems
|
||||
.filter(
|
||||
(item) =>
|
||||
pathname === item.href ||
|
||||
pathname.startsWith(`${item.href}/`) ||
|
||||
(item.activeMatchPrefix != null &&
|
||||
(pathname === item.activeMatchPrefix ||
|
||||
pathname.startsWith(`${item.activeMatchPrefix}/`))),
|
||||
)
|
||||
.sort((a, b) => b.href.length - a.href.length)[0];
|
||||
|
||||
if (navItem && navItem.href !== ADMIN_BASE) {
|
||||
const translatedNavLabel =
|
||||
@@ -104,11 +123,23 @@ export function AdminBreadcrumb() {
|
||||
});
|
||||
}
|
||||
|
||||
if (segments.length > 2) {
|
||||
const navCoversPath =
|
||||
navItem != null &&
|
||||
(pathname === navItem.href || pathname.startsWith(`${navItem.href}/`));
|
||||
|
||||
if (segments.length > 2 && !navCoversPath) {
|
||||
const subSegment = segments[2];
|
||||
let subLabel = "";
|
||||
if (businessSegment === "config" && subSegment) {
|
||||
subLabel = t(`nav.items.${subSegment}`, { ns: "config", defaultValue: titleCase(subSegment) });
|
||||
if (businessSegment === "rules" && subSegment) {
|
||||
const key = RULES_ROUTE_LABELS[subSegment];
|
||||
subLabel = key
|
||||
? t(key, { ns: "config", defaultValue: titleCase(subSegment) })
|
||||
: titleCase(subSegment);
|
||||
} else if (businessSegment === "risk" && subSegment) {
|
||||
const key = RISK_ROUTE_LABELS[subSegment];
|
||||
subLabel = key
|
||||
? t(key, { ns: "config", defaultValue: titleCase(subSegment) })
|
||||
: titleCase(subSegment);
|
||||
} else if (businessSegment === "settings" && subSegment) {
|
||||
subLabel = t(SETTINGS_ROUTE_LABELS[subSegment] ?? `settings.${subSegment}`, {
|
||||
ns: "config",
|
||||
|
||||
28
src/components/admin/admin-document-title.tsx
Normal file
28
src/components/admin/admin-document-title.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { resolveAdminPageTitle } from "@/lib/admin-page-title";
|
||||
|
||||
/** 随界面语言更新 `document.title`(弥补 Next 静态 metadata 无法按用户语言切换)。 */
|
||||
export function AdminDocumentTitle() {
|
||||
const pathname = usePathname();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
const spec = resolveAdminPageTitle(pathname);
|
||||
const appTitle = t("app.title", { ns: "common" });
|
||||
|
||||
if (!spec) {
|
||||
document.title = appTitle;
|
||||
return;
|
||||
}
|
||||
|
||||
const pageTitle = t(spec.key, { ns: spec.ns, ...(spec.params ?? {}) });
|
||||
document.title = `${pageTitle} · ${appTitle}`;
|
||||
}, [pathname, i18n.language, t]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
||||
import { AdminAppSidebar } from "@/components/admin/admin-sidebar";
|
||||
import { ShellToolbar } from "@/components/admin/toolbar";
|
||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||
import { AdminDocumentTitle } from "@/components/admin/admin-document-title";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
@@ -15,6 +16,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
export function AdminShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider defaultOpen>
|
||||
<AdminDocumentTitle />
|
||||
<AdminAppSidebar />
|
||||
<SidebarInset className="min-w-0 overflow-x-clip max-md:overflow-x-clip">
|
||||
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card/90 px-4 shadow-[0_1px_0_rgb(216_230_251_/_45%)] backdrop-blur-md">
|
||||
|
||||
@@ -72,7 +72,7 @@ export function AdminTableExportButton({
|
||||
const onExport = () => {
|
||||
const table = document.getElementById(tableId);
|
||||
if (!(table instanceof HTMLTableElement)) {
|
||||
toast.error(t("errors.loadFailed", { defaultValue: "导出失败" }));
|
||||
toast.error(t("toast.exportFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,16 +84,16 @@ export function AdminTableExportButton({
|
||||
});
|
||||
const safeName = filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`;
|
||||
XLSX.writeFile(workbook, safeName);
|
||||
toast.success(t("actions.done", { defaultValue: "已导出" }));
|
||||
toast.success(t("toast.exportDone"));
|
||||
} catch {
|
||||
toast.error(t("errors.loadFailed", { defaultValue: "导出失败" }));
|
||||
toast.error(t("toast.exportFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={onExport}>
|
||||
<Download className="size-4" />
|
||||
{label ?? t("actions.exportExcel", { defaultValue: "导出 Excel" })}
|
||||
{label ?? t("actions.exportExcel")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
37
src/hooks/use-export-labels.ts
Normal file
37
src/hooks/use-export-labels.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/** 与 `common.export.*` 键一致 */
|
||||
export type ExportLabelKey =
|
||||
| "drawsList"
|
||||
| "drawFinance"
|
||||
| "adminRoles"
|
||||
| "adminUsers"
|
||||
| "players"
|
||||
| "walletTransferOrders"
|
||||
| "walletTransactions"
|
||||
| "playerWallets"
|
||||
| "tickets"
|
||||
| "settlementBatches"
|
||||
| "jackpotPayouts"
|
||||
| "jackpotContributions"
|
||||
| "riskLockLogs"
|
||||
| "riskPools"
|
||||
| "riskIndex"
|
||||
| "riskPoolDetail"
|
||||
| "auditLogs"
|
||||
| "currencies";
|
||||
|
||||
export function useExportLabels(key: ExportLabelKey, params?: Record<string, string>) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
filename: t(`export.${key}.filename`, params),
|
||||
sheetName: t(`export.${key}.sheetName`, params),
|
||||
}),
|
||||
[key, params, t],
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"download": "Download",
|
||||
"search": "Search",
|
||||
"apply": "Apply",
|
||||
"reset": "Reset",
|
||||
"loading": "Loading...",
|
||||
"submitting": "Submitting...",
|
||||
"logout": "Log out",
|
||||
@@ -24,7 +25,36 @@
|
||||
"create": "Create",
|
||||
"createTask": "Create task",
|
||||
"clear": "Clear",
|
||||
"done": "Done"
|
||||
"done": "Done",
|
||||
"exportExcel": "Export Excel"
|
||||
},
|
||||
"aria": {
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse"
|
||||
},
|
||||
"export": {
|
||||
"drawsList": { "filename": "draws-list", "sheetName": "Draws" },
|
||||
"drawFinance": { "filename": "draw-finance-{{drawNo}}", "sheetName": "Draw finance" },
|
||||
"adminRoles": { "filename": "admin-roles", "sheetName": "Roles" },
|
||||
"adminUsers": { "filename": "admin-users", "sheetName": "Admin users" },
|
||||
"players": { "filename": "players", "sheetName": "Players" },
|
||||
"walletTransferOrders": { "filename": "transfer-orders", "sheetName": "Transfer orders" },
|
||||
"walletTransactions": { "filename": "wallet-transactions", "sheetName": "Transactions" },
|
||||
"playerWallets": { "filename": "player-wallets", "sheetName": "Player wallets" },
|
||||
"tickets": { "filename": "tickets", "sheetName": "Tickets" },
|
||||
"settlementBatches": { "filename": "settlement-batches", "sheetName": "Settlement" },
|
||||
"jackpotPayouts": { "filename": "jackpot-payouts", "sheetName": "Jackpot payouts" },
|
||||
"jackpotContributions": { "filename": "jackpot-contributions", "sheetName": "Contributions" },
|
||||
"riskLockLogs": { "filename": "risk-lock-logs", "sheetName": "Risk lock logs" },
|
||||
"riskPools": { "filename": "risk-pools", "sheetName": "Risk pools" },
|
||||
"riskIndex": { "filename": "risk-draws", "sheetName": "Risk center" },
|
||||
"riskPoolDetail": { "filename": "risk-pool-{{number}}", "sheetName": "Risk pool detail" },
|
||||
"auditLogs": { "filename": "audit-logs", "sheetName": "Audit logs" },
|
||||
"currencies": { "filename": "currencies", "sheetName": "Currencies" }
|
||||
},
|
||||
"toast": {
|
||||
"exportDone": "Exported",
|
||||
"exportFailed": "Export failed"
|
||||
},
|
||||
"date": {
|
||||
"placeholder": "Select date",
|
||||
@@ -65,8 +95,10 @@
|
||||
"currencies": "Currencies",
|
||||
"wallet": "Wallet",
|
||||
"draws": "Draws",
|
||||
"config": "Configuration",
|
||||
"risk": "Risk",
|
||||
"rules_plays": "Play rules",
|
||||
"rules_odds": "Odds & rebate",
|
||||
"risk_cap": "Risk cap rules",
|
||||
"risk": "Risk center",
|
||||
"settlement": "Settlement",
|
||||
"jackpot": "Jackpot",
|
||||
"reconcile": "Reconcile",
|
||||
|
||||
@@ -13,7 +13,23 @@
|
||||
"rebate": "Commission / rebate",
|
||||
"jackpot": "Jackpot pool",
|
||||
"risk-cap": "Payout caps"
|
||||
}
|
||||
},
|
||||
"rulesPlaysTitle": "Play rules",
|
||||
"rulesOddsTitle": "Odds & rebate",
|
||||
"rulesOddsDescription": "Odds matrix and rebate rates on one page, sharing the same odds version line.",
|
||||
"riskCapTitle": "Risk cap rules"
|
||||
},
|
||||
"hub": {
|
||||
"title": "Operations configuration",
|
||||
"description": "Jump to play rules, odds & rebate, jackpot, and risk cap by domain. The sidebar provides direct links; this page is an overview.",
|
||||
"playsTitle": "Play rules",
|
||||
"playsDesc": "Play switches, limits, and rule copy",
|
||||
"oddsTitle": "Odds & rebate",
|
||||
"oddsDesc": "Odds matrix and rebate rates in one version stream",
|
||||
"jackpotTitle": "Jackpot",
|
||||
"jackpotDesc": "Pool parameters and ledger records",
|
||||
"riskCapTitle": "Risk cap rules",
|
||||
"riskCapDesc": "Per-number payout caps and occupancy"
|
||||
},
|
||||
"versionStatus": {
|
||||
"active": "Active",
|
||||
@@ -198,6 +214,7 @@
|
||||
}
|
||||
},
|
||||
"odds": {
|
||||
"sectionHint": "Pick a version to edit prize-tier odds; publishing applies to new tickets immediately.",
|
||||
"tabs": {
|
||||
"all": "All"
|
||||
},
|
||||
@@ -235,6 +252,8 @@
|
||||
}
|
||||
},
|
||||
"rebate": {
|
||||
"sectionHint": "Rebate rates are stored in the odds version; select or create an odds draft in the section above first.",
|
||||
"embeddedVersionHint": "Rebate shares the odds version line—switch versions in the Odds section above.",
|
||||
"sheetDescription": "Rebate is stored in the odds draft version and shares the same version set as odds.",
|
||||
"publishLabel": "Publish",
|
||||
"publishSuccess": "Published odds version with rebate",
|
||||
|
||||
@@ -3,11 +3,26 @@
|
||||
"refresh": "Refresh",
|
||||
"notice": "Notice",
|
||||
"todayBetTotal": "Current draw total bet",
|
||||
"currentDrawFinanceSummary": "Finance summary for the current hall draw",
|
||||
"drawNoHint": "Draw {{drawNo}}",
|
||||
"orderAndTicket": "{{orders}} orders · {{tickets}} items",
|
||||
"marginRate": "Gross margin ~{{rate}}%",
|
||||
"financeStructure": "Bet fund structure",
|
||||
"payoutComposition": "Payout composition",
|
||||
"winPayout": "Win payout",
|
||||
"jackpotPayout": "Jackpot payout",
|
||||
"houseGross": "House gross",
|
||||
"payoutRateOfBet": "Payout / bet {{rate}}%",
|
||||
"noFinanceActivity": "No bets this draw",
|
||||
"noPayoutYet": "No payout this draw",
|
||||
"resultBatches": "Result batch progress",
|
||||
"batchPending": "Pending review",
|
||||
"batchPublished": "Published",
|
||||
"batchTotal": "Total batches",
|
||||
"settlementOverview": "Settlement batches",
|
||||
"noSettlementBatches": "No settlement batches",
|
||||
"quickLinksTitle": "Quick links",
|
||||
"currentPayout": "Current payout",
|
||||
"payoutSummary": "Winning payout + Jackpot",
|
||||
"currentProfit": "Current platform profit",
|
||||
"profitFormula": "Bet - payout (approx.)",
|
||||
"currentDraw": "Current draw",
|
||||
"drawSequence": "Round {{sequence}}",
|
||||
"drawDetails": "Draw details",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"download": "डाउनलोड",
|
||||
"search": "खोज",
|
||||
"apply": "लागू गर्नुहोस्",
|
||||
"reset": "रिसेट",
|
||||
"loading": "लोड हुँदैछ...",
|
||||
"submitting": "पेश हुँदैछ...",
|
||||
"logout": "लगआउट",
|
||||
@@ -24,7 +25,36 @@
|
||||
"create": "सिर्जना गर्नुहोस्",
|
||||
"createTask": "टास्क सिर्जना गर्नुहोस्",
|
||||
"clear": "खाली गर्नुहोस्",
|
||||
"done": "समाप्त"
|
||||
"done": "समाप्त",
|
||||
"exportExcel": "Excel निर्यात"
|
||||
},
|
||||
"aria": {
|
||||
"expand": "खोल्नुहोस्",
|
||||
"collapse": "बन्द गर्नुहोस्"
|
||||
},
|
||||
"export": {
|
||||
"drawsList": { "filename": "draw-suchi", "sheetName": "Draw" },
|
||||
"drawFinance": { "filename": "draw-arth-{{drawNo}}", "sheetName": "Draw finance" },
|
||||
"adminRoles": { "filename": "bhumika-suchi", "sheetName": "Bhumika" },
|
||||
"adminUsers": { "filename": "admin-prayogkarta", "sheetName": "Admin users" },
|
||||
"players": { "filename": "khiladi-suchi", "sheetName": "Khiladi" },
|
||||
"walletTransferOrders": { "filename": "transfer-ades", "sheetName": "Transfer" },
|
||||
"walletTransactions": { "filename": "wallet-len-den", "sheetName": "Len-den" },
|
||||
"playerWallets": { "filename": "khiladi-wallet", "sheetName": "Wallet" },
|
||||
"tickets": { "filename": "ticket-suchi", "sheetName": "Ticket" },
|
||||
"settlementBatches": { "filename": "settlement-batch", "sheetName": "Settlement" },
|
||||
"jackpotPayouts": { "filename": "jackpot-bhugtan", "sheetName": "Bhugtan" },
|
||||
"jackpotContributions": { "filename": "jackpot-yogdan", "sheetName": "Yogdan" },
|
||||
"riskLockLogs": { "filename": "jokhim-lock", "sheetName": "Lock log" },
|
||||
"riskPools": { "filename": "jokhim-pool", "sheetName": "Risk pool" },
|
||||
"riskIndex": { "filename": "jokhim-draw", "sheetName": "Jokhim" },
|
||||
"riskPoolDetail": { "filename": "pool-{{number}}", "sheetName": "Pool detail" },
|
||||
"auditLogs": { "filename": "audit-log", "sheetName": "Audit" },
|
||||
"currencies": { "filename": "mudra", "sheetName": "Mudra" }
|
||||
},
|
||||
"toast": {
|
||||
"exportDone": "निर्यात भयो",
|
||||
"exportFailed": "निर्यात असफल"
|
||||
},
|
||||
"date": {
|
||||
"placeholder": "मिति छान्नुहोस्",
|
||||
@@ -65,8 +95,10 @@
|
||||
"currencies": "मुद्रा व्यवस्थापन",
|
||||
"wallet": "वालेट",
|
||||
"draws": "ड्रअहरू",
|
||||
"config": "कन्फिगरेसन",
|
||||
"risk": "जोखिम",
|
||||
"rules_plays": "खेल नियम",
|
||||
"rules_odds": "बाधा र रिबेट",
|
||||
"risk_cap": "जोखिम क्याप संस्करण",
|
||||
"risk": "जोखिम केन्द्र",
|
||||
"settlement": "सेटलमेन्ट",
|
||||
"jackpot": "Jackpot",
|
||||
"reconcile": "मिलान",
|
||||
|
||||
@@ -13,7 +13,23 @@
|
||||
"rebate": "कमिसन / रिबेट",
|
||||
"jackpot": "Jackpot पूल",
|
||||
"risk-cap": "पेमेन्ट क्याप"
|
||||
}
|
||||
},
|
||||
"rulesPlaysTitle": "खेल नियम",
|
||||
"rulesOddsTitle": "बाधा र रिबेट",
|
||||
"rulesOddsDescription": "बाधा म्याट्रिक्स र रिबेट दर एउटै पृष्ठमा, एउटै बाधा संस्करण लाइनमा।",
|
||||
"riskCapTitle": "जोखिम क्याप संस्करण"
|
||||
},
|
||||
"hub": {
|
||||
"title": "सञ्चालन कन्फिगरेसन अवलोकन",
|
||||
"description": "खेल नियम, बाधा/रिबेट, Jackpot र क्याप क्षेत्रअनुसार खोल्नुहोस्। साइडबारबाट सिधा जान सकिन्छ; यो पृष्ठ सारांश हो।",
|
||||
"playsTitle": "खेल नियम",
|
||||
"playsDesc": "खेल स्विच, सीमा र नियम पाठ",
|
||||
"oddsTitle": "बाधा र रिबेट",
|
||||
"oddsDesc": "बाधा म्याट्रिक्स र रिबेट, एउटै संस्करण",
|
||||
"jackpotTitle": "Jackpot",
|
||||
"jackpotDesc": "पूल प्यारामिटर र लेजर",
|
||||
"riskCapTitle": "जोखिम क्याप",
|
||||
"riskCapDesc": "नम्बर क्याप र ओगट उपस्थिति"
|
||||
},
|
||||
"versionStatus": {
|
||||
"active": "सक्रिय",
|
||||
@@ -198,6 +214,7 @@
|
||||
}
|
||||
},
|
||||
"odds": {
|
||||
"sectionHint": "संस्करण छानेर पुरस्कार-स्तर बाधा सम्पादन गर्नुहोस्; प्रकाशनपछि नयाँ टिकटमा लागू हुन्छ।",
|
||||
"tabs": {
|
||||
"all": "सबै"
|
||||
},
|
||||
@@ -235,6 +252,8 @@
|
||||
}
|
||||
},
|
||||
"rebate": {
|
||||
"sectionHint": "रिबेट दर अड्स संस्करणमा लेखिन्छ; पहिले माथिको «बाधा» खण्डमा ड्राफ्ट छान्नुहोस्।",
|
||||
"embeddedVersionHint": "रिबेट माथिको बाधा संस्करण लाइन साझा गर्छ—संस्करण त्यहीँबाट बदल्नुहोस्।",
|
||||
"sheetDescription": "रिबेट अड्स ड्राफ्ट संस्करणमा राखिन्छ र अड्ससँग एउटै संस्करण सेट साझा गर्छ।",
|
||||
"publishLabel": "प्रकाशन",
|
||||
"publishSuccess": "रिबेटसहितको अड्स संस्करण प्रकाशित भयो",
|
||||
|
||||
@@ -3,11 +3,26 @@
|
||||
"refresh": "रिफ्रेस",
|
||||
"notice": "सूचना",
|
||||
"todayBetTotal": "हालको ड्रअ कुल बेट",
|
||||
"currentDrawFinanceSummary": "हालको हल ड्रअको वित्तीय सारांश",
|
||||
"drawNoHint": "ड्रअ {{drawNo}}",
|
||||
"orderAndTicket": "{{orders}} अर्डर · {{tickets}} वस्तु",
|
||||
"marginRate": "सकल मार्जिन ~{{rate}}%",
|
||||
"financeStructure": "बेट कोष संरचना",
|
||||
"payoutComposition": "भुक्तानी संरचना",
|
||||
"winPayout": "जित भुक्तानी",
|
||||
"jackpotPayout": "Jackpot भुक्तानी",
|
||||
"houseGross": "प्लेटफर्म बाँकी",
|
||||
"payoutRateOfBet": "भुक्तानी/बेट {{rate}}%",
|
||||
"noFinanceActivity": "यस ड्रअमा बेट छैन",
|
||||
"noPayoutYet": "यस ड्रअमा भुक्तानी छैन",
|
||||
"resultBatches": "परिणाम ब्याच प्रगति",
|
||||
"batchPending": "समीक्षा बाँकी",
|
||||
"batchPublished": "प्रकाशित",
|
||||
"batchTotal": "कुल ब्याच",
|
||||
"settlementOverview": "सेटलमेन्ट ब्याच",
|
||||
"noSettlementBatches": "सेटलमेन्ट ब्याच छैन",
|
||||
"quickLinksTitle": "छिटो लिङ्क",
|
||||
"currentPayout": "हालको भुक्तानी",
|
||||
"payoutSummary": "जितेको भुक्तानी + Jackpot",
|
||||
"currentProfit": "हालको प्लेटफर्म नाफा",
|
||||
"profitFormula": "बेट - भुक्तानी (अनुमानित)",
|
||||
"currentDraw": "हालको ड्रअ",
|
||||
"drawSequence": "राउन्ड {{sequence}}",
|
||||
"drawDetails": "ड्रअ विवरण",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"download": "下载",
|
||||
"search": "搜索",
|
||||
"apply": "应用",
|
||||
"reset": "重置",
|
||||
"loading": "加载中...",
|
||||
"submitting": "提交中...",
|
||||
"logout": "退出登录",
|
||||
@@ -24,7 +25,36 @@
|
||||
"create": "创建",
|
||||
"createTask": "创建任务",
|
||||
"clear": "清除",
|
||||
"done": "完成"
|
||||
"done": "完成",
|
||||
"exportExcel": "导出 Excel"
|
||||
},
|
||||
"aria": {
|
||||
"expand": "展开",
|
||||
"collapse": "收起"
|
||||
},
|
||||
"export": {
|
||||
"drawsList": { "filename": "期号列表", "sheetName": "期号列表" },
|
||||
"drawFinance": { "filename": "期号收支-{{drawNo}}", "sheetName": "期号收支" },
|
||||
"adminRoles": { "filename": "角色列表", "sheetName": "角色列表" },
|
||||
"adminUsers": { "filename": "后台用户列表", "sheetName": "后台用户" },
|
||||
"players": { "filename": "玩家列表", "sheetName": "玩家列表" },
|
||||
"walletTransferOrders": { "filename": "钱包转账订单", "sheetName": "转账订单" },
|
||||
"walletTransactions": { "filename": "钱包流水", "sheetName": "钱包流水" },
|
||||
"playerWallets": { "filename": "玩家钱包", "sheetName": "玩家钱包" },
|
||||
"tickets": { "filename": "注单列表", "sheetName": "注单列表" },
|
||||
"settlementBatches": { "filename": "结算批次", "sheetName": "结算批次" },
|
||||
"jackpotPayouts": { "filename": "奖池派彩记录", "sheetName": "奖池派彩" },
|
||||
"jackpotContributions": { "filename": "奖池注入记录", "sheetName": "奖池注入" },
|
||||
"riskLockLogs": { "filename": "风险占用流水", "sheetName": "风险占用流水" },
|
||||
"riskPools": { "filename": "风险池", "sheetName": "风险池" },
|
||||
"riskIndex": { "filename": "风控中心期号列表", "sheetName": "风控中心" },
|
||||
"riskPoolDetail": { "filename": "风险池详情-{{number}}", "sheetName": "风险池详情" },
|
||||
"auditLogs": { "filename": "审计日志", "sheetName": "审计日志" },
|
||||
"currencies": { "filename": "币种管理", "sheetName": "币种管理" }
|
||||
},
|
||||
"toast": {
|
||||
"exportDone": "已导出",
|
||||
"exportFailed": "导出失败"
|
||||
},
|
||||
"date": {
|
||||
"placeholder": "选择日期",
|
||||
@@ -65,8 +95,10 @@
|
||||
"currencies": "币种管理",
|
||||
"wallet": "钱包流水",
|
||||
"draws": "期号列表",
|
||||
"config": "运营配置",
|
||||
"risk": "风控",
|
||||
"rules_plays": "投注规则",
|
||||
"rules_odds": "赔率与回水",
|
||||
"risk_cap": "限额版本",
|
||||
"risk": "风控中心",
|
||||
"settlement": "结算",
|
||||
"jackpot": "奖池",
|
||||
"reconcile": "对账",
|
||||
|
||||
@@ -13,7 +13,23 @@
|
||||
"rebate": "佣金 / 回水",
|
||||
"jackpot": "奖池配置",
|
||||
"risk-cap": "赔付封顶"
|
||||
}
|
||||
},
|
||||
"rulesPlaysTitle": "投注规则",
|
||||
"rulesOddsTitle": "赔率与回水",
|
||||
"rulesOddsDescription": "赔率矩阵与回水比例在同一页维护,共用赔率版本线。",
|
||||
"riskCapTitle": "限额版本"
|
||||
},
|
||||
"hub": {
|
||||
"title": "运营配置总览",
|
||||
"description": "按业务域进入玩法、赔率回水、奖池与限额配置。侧栏已提供直达入口,本页为汇总导航。",
|
||||
"playsTitle": "投注规则",
|
||||
"playsDesc": "玩法开关、限额与规则说明",
|
||||
"oddsTitle": "赔率与回水",
|
||||
"oddsDesc": "赔率矩阵与回水比例,版本一体发布",
|
||||
"jackpotTitle": "奖池",
|
||||
"jackpotDesc": "奖池参数与进账流水",
|
||||
"riskCapTitle": "限额版本",
|
||||
"riskCapDesc": "号码赔付封顶与占用视图"
|
||||
},
|
||||
"versionStatus": {
|
||||
"active": "生效中",
|
||||
@@ -198,6 +214,7 @@
|
||||
}
|
||||
},
|
||||
"odds": {
|
||||
"sectionHint": "选择版本后可编辑各奖级赔率;发布后立即作用于新注单。",
|
||||
"tabs": {
|
||||
"all": "全部"
|
||||
},
|
||||
@@ -235,6 +252,8 @@
|
||||
}
|
||||
},
|
||||
"rebate": {
|
||||
"sectionHint": "回水比例写入赔率版本;请先在上方选择或创建赔率草稿。",
|
||||
"embeddedVersionHint": "回水与上方赔率共用版本线,请在「赔率」区块切换版本。",
|
||||
"sheetDescription": "回水配置存放在赔率草稿版本中,与赔率共用同一套版本记录。",
|
||||
"publishLabel": "发布",
|
||||
"publishSuccess": "已发布带回水的赔率版本",
|
||||
|
||||
@@ -3,11 +3,26 @@
|
||||
"refresh": "刷新",
|
||||
"notice": "提示",
|
||||
"todayBetTotal": "当期投注总额",
|
||||
"currentDrawFinanceSummary": "当前大厅期财务汇总",
|
||||
"drawNoHint": "期号 {{drawNo}}",
|
||||
"orderAndTicket": "{{orders}} 单 · {{tickets}} 笔",
|
||||
"marginRate": "毛利率约 {{rate}}%",
|
||||
"financeStructure": "投注资金结构",
|
||||
"payoutComposition": "派彩构成",
|
||||
"winPayout": "中奖派彩",
|
||||
"jackpotPayout": "奖池派彩",
|
||||
"houseGross": "平台结余",
|
||||
"payoutRateOfBet": "派彩占投注 {{rate}}%",
|
||||
"noFinanceActivity": "本期暂无投注",
|
||||
"noPayoutYet": "本期暂无派彩",
|
||||
"resultBatches": "开奖批次进度",
|
||||
"batchPending": "待审核",
|
||||
"batchPublished": "已发布",
|
||||
"batchTotal": "批次合计",
|
||||
"settlementOverview": "结算批次分布",
|
||||
"noSettlementBatches": "暂无结算批次",
|
||||
"quickLinksTitle": "快捷入口",
|
||||
"currentPayout": "当期派彩",
|
||||
"payoutSummary": "中奖派彩 + 奖池",
|
||||
"currentProfit": "当期平台盈亏",
|
||||
"profitFormula": "投注 − 派彩(近似)",
|
||||
"currentDraw": "当前期号",
|
||||
"drawSequence": "第 {{sequence}} 期",
|
||||
"drawDetails": "期号详情",
|
||||
|
||||
105
src/lib/admin-page-title.ts
Normal file
105
src/lib/admin-page-title.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export type PageTitleSpec = {
|
||||
ns: string;
|
||||
key: string;
|
||||
params?: Record<string, string>;
|
||||
};
|
||||
|
||||
const EXACT_ROUTES: Record<string, PageTitleSpec> = {
|
||||
"/admin": { ns: "dashboard", key: "title" },
|
||||
"/admin/players": { ns: "players", key: "title" },
|
||||
"/admin/draws": { ns: "draws", key: "statusListTitle" },
|
||||
"/admin/tickets": { ns: "tickets", key: "title" },
|
||||
"/admin/settlement-batches": { ns: "settlement", key: "batchList" },
|
||||
"/admin/reconcile": { ns: "reconcile", key: "title" },
|
||||
"/admin/audit-logs": { ns: "audit", key: "title" },
|
||||
"/admin/admin-users": { ns: "adminUsers", key: "title" },
|
||||
"/admin/admin-roles": { ns: "adminRoles", key: "title" },
|
||||
"/admin/wallet": { ns: "wallet", key: "title" },
|
||||
"/admin/wallet/transactions": { ns: "wallet", key: "walletTransactions" },
|
||||
"/admin/wallet/transfer-orders": { ns: "wallet", key: "transferOrders" },
|
||||
"/admin/wallet/player": { ns: "wallet", key: "playerWalletQuery" },
|
||||
"/admin/risk": { ns: "risk", key: "center" },
|
||||
"/admin/settings": { ns: "common", key: "nav.settings" },
|
||||
"/admin/settings/currencies": { ns: "config", key: "currencies.title" },
|
||||
"/admin/currencies": { ns: "config", key: "currencies.title" },
|
||||
"/admin/config": { ns: "config", key: "hub.title" },
|
||||
"/admin/rules/plays": { ns: "config", key: "nav.rulesPlaysTitle" },
|
||||
"/admin/rules/odds": { ns: "config", key: "nav.rulesOddsTitle" },
|
||||
"/admin/jackpot": { ns: "jackpot", key: "configTitle" },
|
||||
"/admin/risk/cap": { ns: "config", key: "nav.riskCapTitle" },
|
||||
"/admin/login": { ns: "auth", key: "title" },
|
||||
};
|
||||
|
||||
type RoutePattern = {
|
||||
test: (pathname: string) => boolean;
|
||||
resolve: (pathname: string) => PageTitleSpec;
|
||||
};
|
||||
|
||||
const ROUTE_PATTERNS: RoutePattern[] = [
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/finance$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.finance" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/results$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.results" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/review$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.review" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/publish\/\d+$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "publishTitle" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/risk\/occupancy$/.test(p) || /^\/admin\/risk\/draws\/\d+\/occupancy$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.riskOccupancy" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/risk\/hot$/.test(p) || /^\/admin\/risk\/draws\/\d+\/hot$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.riskHot" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/risk\/sold-out$/.test(p) || /^\/admin\/risk\/draws\/\d+\/sold-out$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.riskSoldOut" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/risk\/pools$/.test(p) || /^\/admin\/risk\/draws\/\d+\/pools$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.riskPools" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/risk\/pools\/[^/]+$/.test(p) || /^\/admin\/risk\/draws\/\d+\/pools\/[^/]+$/.test(p),
|
||||
resolve: (p) => {
|
||||
const number = decodeURIComponent(p.split("/").pop() ?? "");
|
||||
return { ns: "risk", key: "numberTitle", params: { number } };
|
||||
},
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/risk$/.test(p),
|
||||
resolve: () => ({ ns: "risk", key: "title" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "drawDetail" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/settlement-batches\/\d+\/details$/.test(p),
|
||||
resolve: () => ({ ns: "settlement", key: "details" }),
|
||||
},
|
||||
];
|
||||
|
||||
/** 根据路径解析浏览器标题用的 i18n 规格。 */
|
||||
export function resolveAdminPageTitle(pathname: string): PageTitleSpec | null {
|
||||
if (EXACT_ROUTES[pathname]) {
|
||||
return EXACT_ROUTES[pathname];
|
||||
}
|
||||
|
||||
for (const pattern of ROUTE_PATTERNS) {
|
||||
if (pattern.test(pathname)) {
|
||||
return pattern.resolve(pathname);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
57
src/lib/page-metadata.ts
Normal file
57
src/lib/page-metadata.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import enAdminRoles from "@/i18n/locales/en/adminRoles.json";
|
||||
import enAdminUsers from "@/i18n/locales/en/adminUsers.json";
|
||||
import enAudit from "@/i18n/locales/en/audit.json";
|
||||
import enAuth from "@/i18n/locales/en/auth.json";
|
||||
import enConfig from "@/i18n/locales/en/config.json";
|
||||
import enDashboard from "@/i18n/locales/en/dashboard.json";
|
||||
import enDraws from "@/i18n/locales/en/draws.json";
|
||||
import enJackpot from "@/i18n/locales/en/jackpot.json";
|
||||
import enPlayers from "@/i18n/locales/en/players.json";
|
||||
import enReconcile from "@/i18n/locales/en/reconcile.json";
|
||||
import enRisk from "@/i18n/locales/en/risk.json";
|
||||
import enSettlement from "@/i18n/locales/en/settlement.json";
|
||||
import enCommon from "@/i18n/locales/en/common.json";
|
||||
import enTickets from "@/i18n/locales/en/tickets.json";
|
||||
import enWallet from "@/i18n/locales/en/wallet.json";
|
||||
|
||||
const EN_FLAT: Record<string, Record<string, unknown>> = {
|
||||
dashboard: enDashboard,
|
||||
players: enPlayers,
|
||||
draws: enDraws,
|
||||
tickets: enTickets,
|
||||
settlement: enSettlement,
|
||||
reconcile: enReconcile,
|
||||
audit: enAudit,
|
||||
adminUsers: enAdminUsers,
|
||||
adminRoles: enAdminRoles,
|
||||
wallet: enWallet,
|
||||
risk: enRisk,
|
||||
jackpot: enJackpot,
|
||||
config: enConfig,
|
||||
common: enCommon,
|
||||
auth: enAuth,
|
||||
};
|
||||
|
||||
function getByPath(obj: Record<string, unknown>, path: string): string | undefined {
|
||||
const parts = path.split(".");
|
||||
let cur: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (cur == null || typeof cur !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
cur = (cur as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof cur === "string" ? cur : undefined;
|
||||
}
|
||||
|
||||
/** SSR 默认英文标题;客户端由 {@link AdminDocumentTitle} 按用户语言覆盖。 */
|
||||
export function buildPageMetadata(ns: string, key: string): Metadata {
|
||||
const bundle = EN_FLAT[ns];
|
||||
const title = bundle ? getByPath(bundle as Record<string, unknown>, key) : undefined;
|
||||
|
||||
return {
|
||||
title: title ?? key,
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,10 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
||||
dashboard: LayoutDashboard,
|
||||
players: Users,
|
||||
draws: CalendarClock,
|
||||
config: SlidersHorizontal,
|
||||
rules_plays: SlidersHorizontal,
|
||||
rules_odds: SlidersHorizontal,
|
||||
jackpot: CircleDollarSign,
|
||||
risk_cap: ShieldAlert,
|
||||
tickets: Ticket,
|
||||
wallet: Wallet,
|
||||
risk: ShieldAlert,
|
||||
|
||||
@@ -4,7 +4,10 @@ export type AdminNavSegment =
|
||||
| "dashboard"
|
||||
| "players"
|
||||
| "draws"
|
||||
| "config"
|
||||
| "rules_plays"
|
||||
| "rules_odds"
|
||||
| "jackpot"
|
||||
| "risk_cap"
|
||||
| "tickets"
|
||||
| "wallet"
|
||||
| "risk"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -52,6 +53,7 @@ function permissionLabel(slug: string, fallback: string, t: (key: string) => str
|
||||
|
||||
export function AdminRolesConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["adminUsers", "common"]);
|
||||
const exportLabels = useExportLabels("adminRoles");
|
||||
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
||||
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -312,8 +314,8 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="admin-roles-table"
|
||||
filename="角色列表"
|
||||
sheetName="角色列表"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
@@ -429,7 +431,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
type="button"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => toggleDirectGroup(group.key)}
|
||||
aria-label={isOpen ? "收起" : "展开"}
|
||||
aria-label={isOpen ? t("aria.collapse", { ns: "common" }) : t("aria.expand", { ns: "common" })}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -44,6 +45,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function AdminUsersConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["adminUsers", "common"]);
|
||||
const exportLabels = useExportLabels("adminUsers");
|
||||
const profile = useAdminProfile();
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
@@ -334,8 +336,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="admin-users-table"
|
||||
filename="后台用户列表"
|
||||
sheetName="后台用户"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
@@ -24,6 +25,7 @@ import type { AdminAuditLogListData } from "@/types/api/admin-audit";
|
||||
|
||||
export function AuditLogsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["audit", "common"]);
|
||||
const exportLabels = useExportLabels("auditLogs");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminAuditLogListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -110,8 +112,8 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="audit-logs-table"
|
||||
filename="审计日志"
|
||||
sheetName="审计日志"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -137,7 +139,7 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("actions.reset", { ns: "common", defaultValue: "重置" })}
|
||||
{t("actions.reset", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
77
src/modules/config/config-hub-screen.tsx
Normal file
77
src/modules/config/config-hub-screen.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
|
||||
type HubCard = {
|
||||
href: string;
|
||||
titleKey: string;
|
||||
descKey: string;
|
||||
requiredAny: readonly string[];
|
||||
};
|
||||
|
||||
const HUB_CARDS: HubCard[] = [
|
||||
{
|
||||
href: "/admin/rules/plays",
|
||||
titleKey: "hub.playsTitle",
|
||||
descKey: "hub.playsDesc",
|
||||
requiredAny: ["prd.play_switch.manage", "prd.odds.manage"],
|
||||
},
|
||||
{
|
||||
href: "/admin/rules/odds",
|
||||
titleKey: "hub.oddsTitle",
|
||||
descKey: "hub.oddsDesc",
|
||||
requiredAny: ["prd.odds.manage", "prd.rebate.manage", "prd.rebate.view"],
|
||||
},
|
||||
{
|
||||
href: "/admin/jackpot",
|
||||
titleKey: "hub.jackpotTitle",
|
||||
descKey: "hub.jackpotDesc",
|
||||
requiredAny: ["prd.jackpot.manage", "prd.jackpot.view"],
|
||||
},
|
||||
{
|
||||
href: "/admin/risk/cap",
|
||||
titleKey: "hub.riskCapTitle",
|
||||
descKey: "hub.riskCapDesc",
|
||||
requiredAny: ["prd.risk_cap.manage", "prd.risk_cap.view"],
|
||||
},
|
||||
];
|
||||
|
||||
export function ConfigHubScreen() {
|
||||
const { t } = useTranslation("config");
|
||||
const profile = useAdminProfile();
|
||||
const visible = HUB_CARDS.filter((card) =>
|
||||
adminHasAnyPermission(profile?.permissions, card.requiredAny),
|
||||
);
|
||||
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<div className="mb-6 max-w-2xl">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{t("hub.title")}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("hub.description")}</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{visible.map((card) => (
|
||||
<Link key={card.href} href={card.href} className="group block">
|
||||
<Card className="h-full transition-colors hover:border-primary/40 hover:bg-muted/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
{t(card.titleKey)}
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
|
||||
</CardTitle>
|
||||
<CardDescription>{t(card.descKey)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent />
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
@@ -158,9 +158,11 @@ export function ConfigVersionSwitcher({
|
||||
<SheetTitle className="text-base font-semibold tracking-tight text-foreground">
|
||||
{resolvedSheetTitle}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="max-w-[320px] text-sm leading-relaxed text-muted-foreground">
|
||||
{resolvedSheetDescription}
|
||||
</SheetDescription>
|
||||
{resolvedSheetDescription ? (
|
||||
<SheetDescription className="max-w-[320px] text-sm leading-relaxed text-muted-foreground">
|
||||
{resolvedSheetDescription}
|
||||
</SheetDescription>
|
||||
) : null}
|
||||
</SheetHeader>
|
||||
</div>
|
||||
<div className="border-b border-border/60 bg-card px-4 py-3">
|
||||
|
||||
@@ -61,7 +61,12 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
|
||||
return types.filter((t) => t.dimension === dim);
|
||||
}
|
||||
|
||||
export function OddsConfigDocScreen() {
|
||||
type OddsConfigDocScreenProps = {
|
||||
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) {
|
||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
@@ -395,10 +400,7 @@ export function OddsConfigDocScreen() {
|
||||
{ id: "d2", label: "2D" },
|
||||
];
|
||||
|
||||
return (
|
||||
<ConfigDocPage
|
||||
title={t("nav.items.odds", { ns: "config" })}
|
||||
filters={
|
||||
const filtersBlock = (
|
||||
<div className="space-y-4 rounded-xl border border-border/60 bg-card p-4">
|
||||
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
||||
{catTabs.map((tab) => (
|
||||
@@ -427,8 +429,9 @@ export function OddsConfigDocScreen() {
|
||||
)}
|
||||
</ConfigChipGroup>
|
||||
</div>
|
||||
}
|
||||
toolbar={
|
||||
);
|
||||
|
||||
const toolbarBlock = (
|
||||
<ConfigDocToolbar
|
||||
switcher={
|
||||
<ConfigVersionSwitcher
|
||||
@@ -437,7 +440,7 @@ export function OddsConfigDocScreen() {
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loadingList}
|
||||
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription={t("odds.sheetDescription", { ns: "config" })}
|
||||
sheetDescription={embedded ? undefined : t("odds.sheetDescription", { ns: "config" })}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onRollbackVersion={requestRollback}
|
||||
rollbackBusy={saving}
|
||||
@@ -456,29 +459,31 @@ export function OddsConfigDocScreen() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
context={
|
||||
detail ? (
|
||||
<ConfigContextBanner emphasis={!isDraft}>
|
||||
{t("odds.activeVersionPrefix", { ns: "config" })}
|
||||
{activeHead ? (
|
||||
<>
|
||||
v{activeHead.version_no}
|
||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
{!isDraft ? (
|
||||
<>
|
||||
{" "}
|
||||
— <ConfigContextEmphasis>{t("odds.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
||||
</>
|
||||
) : null}
|
||||
</ConfigContextBanner>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
);
|
||||
|
||||
const contextBlock =
|
||||
embedded || !detail ? null : (
|
||||
<ConfigContextBanner emphasis={!isDraft}>
|
||||
{t("odds.activeVersionPrefix", { ns: "config" })}
|
||||
{activeHead ? (
|
||||
<>
|
||||
v{activeHead.version_no}
|
||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
{!isDraft ? (
|
||||
<>
|
||||
{" "}
|
||||
— <ConfigContextEmphasis>{t("odds.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
||||
</>
|
||||
) : null}
|
||||
</ConfigContextBanner>
|
||||
);
|
||||
|
||||
const mainBlock = (
|
||||
<>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
{loadingDetail || loadingTypes ? (
|
||||
@@ -489,7 +494,7 @@ export function OddsConfigDocScreen() {
|
||||
<div className="grid min-h-[420px] gap-4 max-w-md">
|
||||
{PRIZE_SCOPE_ORDER.map((scope) => {
|
||||
const row = scopeRows[scope];
|
||||
const hint = PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
||||
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
||||
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
|
||||
return (
|
||||
<div key={scope} className="grid gap-1">
|
||||
@@ -517,13 +522,15 @@ export function OddsConfigDocScreen() {
|
||||
{row.odds_value}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{t("odds.multiplier", {
|
||||
ns: "config",
|
||||
value: oddsMultiplierLabel(row.odds_value),
|
||||
currency: row.currency_code,
|
||||
})}
|
||||
</span>
|
||||
{!embedded ? (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{t("odds.multiplier", {
|
||||
ns: "config",
|
||||
value: oddsMultiplierLabel(row.odds_value),
|
||||
currency: row.currency_code,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
|
||||
@@ -547,11 +554,17 @@ export function OddsConfigDocScreen() {
|
||||
{rebatePercentUi}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
||||
{!embedded ? (
|
||||
<p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
@@ -612,6 +625,30 @@ export function OddsConfigDocScreen() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{filtersBlock}
|
||||
{toolbarBlock}
|
||||
{contextBlock}
|
||||
{mainBlock}
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigDocPage
|
||||
title={t("nav.items.odds", { ns: "config" })}
|
||||
filters={filtersBlock}
|
||||
toolbar={toolbarBlock}
|
||||
context={contextBlock}
|
||||
>
|
||||
{mainBlock}
|
||||
{dialogs}
|
||||
</ConfigDocPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPl
|
||||
return hit ? rateToPercentUi(String(hit.rebate_rate)) : "0";
|
||||
}
|
||||
|
||||
export function RebateConfigDocScreen() {
|
||||
type RebateConfigDocScreenProps = {
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScreenProps) {
|
||||
const { t } = useTranslation(["config", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
@@ -266,60 +270,59 @@ export function RebateConfigDocScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigDocPage
|
||||
title={t("nav.items.rebate", { ns: "config" })}
|
||||
toolbar={
|
||||
<ConfigDocToolbar
|
||||
switcher={
|
||||
<ConfigVersionSwitcher
|
||||
versions={listRows}
|
||||
selectedId={selectedId}
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loading}
|
||||
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<ConfigVersionActions
|
||||
isDraft={isDraft}
|
||||
loadingList={loading}
|
||||
loadingDetail={loadingDetail}
|
||||
saving={saving}
|
||||
publishLabel={t("rebate.publishLabel", { ns: "config" })}
|
||||
onRefresh={() => void refreshList()}
|
||||
onNewDraft={() => void handleNewDraft()}
|
||||
onSaveDraft={() => void handleSave()}
|
||||
onPublish={() => void handlePublish()}
|
||||
/>
|
||||
}
|
||||
const toolbarBlock = embedded ? null : (
|
||||
<ConfigDocToolbar
|
||||
switcher={
|
||||
<ConfigVersionSwitcher
|
||||
versions={listRows}
|
||||
selectedId={selectedId}
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loading}
|
||||
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
/>
|
||||
}
|
||||
context={
|
||||
detail ? (
|
||||
<ConfigContextBanner emphasis={!isDraft}>
|
||||
{t("rebate.editingVersion", {
|
||||
ns: "config",
|
||||
version: detail.version_no,
|
||||
status:
|
||||
detail.status === "draft"
|
||||
? t("versionStatus.draft", { ns: "config" })
|
||||
: detail.status === "active"
|
||||
? t("versionStatus.active", { ns: "config" })
|
||||
: t("versionStatus.archived", { ns: "config" }),
|
||||
})}
|
||||
{!isDraft ? (
|
||||
<>
|
||||
{" "}
|
||||
— <ConfigContextEmphasis>{t("rebate.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
||||
</>
|
||||
) : null}
|
||||
</ConfigContextBanner>
|
||||
) : null
|
||||
actions={
|
||||
<ConfigVersionActions
|
||||
isDraft={isDraft}
|
||||
loadingList={loading}
|
||||
loadingDetail={loadingDetail}
|
||||
saving={saving}
|
||||
publishLabel={t("rebate.publishLabel", { ns: "config" })}
|
||||
onRefresh={() => void refreshList()}
|
||||
onNewDraft={() => void handleNewDraft()}
|
||||
onSaveDraft={() => void handleSave()}
|
||||
onPublish={() => void handlePublish()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
/>
|
||||
);
|
||||
|
||||
const contextBlock =
|
||||
embedded || !detail ? null : (
|
||||
<ConfigContextBanner emphasis={!isDraft}>
|
||||
{t("rebate.editingVersion", {
|
||||
ns: "config",
|
||||
version: detail.version_no,
|
||||
status:
|
||||
detail.status === "draft"
|
||||
? t("versionStatus.draft", { ns: "config" })
|
||||
: detail.status === "active"
|
||||
? t("versionStatus.active", { ns: "config" })
|
||||
: t("versionStatus.archived", { ns: "config" }),
|
||||
})}
|
||||
{!isDraft ? (
|
||||
<>
|
||||
{" "}
|
||||
— <ConfigContextEmphasis>{t("rebate.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
||||
</>
|
||||
) : null}
|
||||
</ConfigContextBanner>
|
||||
);
|
||||
|
||||
const fieldsBlock = (
|
||||
<>
|
||||
<div className="grid gap-5 sm:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("rebate.fields.d2", { ns: "config" })}</Label>
|
||||
@@ -386,16 +389,37 @@ export function RebateConfigDocScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!embedded ? (
|
||||
<div className="grid gap-1 text-sm">
|
||||
<span className="text-muted-foreground">{t("rebate.effectiveTime", { ns: "config" })}</span>
|
||||
<span className="font-mono text-sm">
|
||||
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading || loadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{contextBlock}
|
||||
{fieldsBlock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigDocPage
|
||||
title={t("nav.items.rebate", { ns: "config" })}
|
||||
toolbar={toolbarBlock}
|
||||
context={contextBlock}
|
||||
>
|
||||
{fieldsBlock}
|
||||
</ConfigDocPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,10 +24,21 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
CapUsageBar,
|
||||
FinanceStructureChart,
|
||||
HotUsageBars,
|
||||
PayoutCompositionChart,
|
||||
ResultBatchProgress,
|
||||
SettlementStatusChart,
|
||||
SoldOutRing,
|
||||
StatCard,
|
||||
} from "@/modules/dashboard/dashboard-visuals";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDashboardDrawPanel } from "@/types/api/admin-dashboard";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
|
||||
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||
@@ -66,7 +77,6 @@ function formatSignedMoneyMinor(minor: number, currencyCode: string | null): str
|
||||
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
|
||||
}
|
||||
|
||||
/** Aligned with the bucket dimensions used by AdminDashboardSnapshotBuilder::soldOutBucketKey. */
|
||||
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
|
||||
const raw = normalizedNumber.trim();
|
||||
const digits = raw.replace(/\D/g, "");
|
||||
@@ -98,156 +108,6 @@ function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPo
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function RiskSemiGauge({ pct }: { pct: number }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const v = Math.min(100, Math.max(0, pct));
|
||||
const r = 76;
|
||||
const arcLen = Math.PI * r;
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex w-full max-w-[220px] flex-col items-center">
|
||||
<svg viewBox="0 0 200 118" className="w-full" aria-hidden>
|
||||
<path
|
||||
d="M 24 100 A 76 76 0 0 1 176 100"
|
||||
fill="none"
|
||||
stroke="oklch(0.93 0.01 260)"
|
||||
strokeWidth="14"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 24 100 A 76 76 0 0 1 176 100"
|
||||
fill="none"
|
||||
stroke="oklch(0.55 0.22 25)"
|
||||
strokeWidth="14"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={arcLen}
|
||||
strokeDashoffset={arcLen * (1 - v / 100)}
|
||||
className="transition-[stroke-dashoffset] duration-500 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1 text-center">
|
||||
<p className="text-lg font-bold tabular-nums text-[#1a365d]">{v.toFixed(2)}%</p>
|
||||
<p className="text-[11px] leading-tight text-muted-foreground">{t("capUsage")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HotBarChart({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const maxU = Math.max(0.0001, ...rows.map((r) => r.usage_ratio ?? 0));
|
||||
|
||||
return (
|
||||
<div className="flex h-52 flex-col">
|
||||
<div className="relative flex h-[168px] min-h-[168px] w-full items-stretch gap-1.5 border-b border-slate-200/90 pb-0.5 pl-7">
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-6 left-0 top-2 w-6 rotate-180 text-[10px] leading-tight text-muted-foreground [writing-mode:vertical-rl]"
|
||||
aria-hidden
|
||||
>
|
||||
{t("capUsage")}
|
||||
</span>
|
||||
{rows.length === 0 ? (
|
||||
<p className="w-full pb-6 text-center text-sm text-muted-foreground">{t("noPoolData")}</p>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const u = row.usage_ratio ?? 0;
|
||||
const h = Math.max(8, (u / maxU) * 100);
|
||||
return (
|
||||
<div
|
||||
key={row.normalized_number}
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col items-stretch gap-1"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-end">
|
||||
<div
|
||||
className="mx-auto w-full max-w-[2.25rem] rounded-t-sm bg-[#c41e3a]/90 shadow-sm transition-all"
|
||||
style={{ height: `${h}%`, minHeight: 6 }}
|
||||
title={`${row.normalized_number}: ${(u * 100).toFixed(1)}%`}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-center font-mono text-[10px] text-[#1a365d]">
|
||||
{row.normalized_number.trim()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">{t("numbersByUsage")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SoldOutDonut({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const entries: { key: keyof SoldOutBuckets; label: string; color: string }[] = [
|
||||
{ key: "d4", label: t("soldOutBuckets.d4"), color: "oklch(0.32 0.08 260)" },
|
||||
{ key: "d3", label: t("soldOutBuckets.d3"), color: "oklch(0.48 0.12 250)" },
|
||||
{ key: "d2", label: t("soldOutBuckets.d2"), color: "oklch(0.78 0.14 95)" },
|
||||
{ key: "special", label: t("soldOutBuckets.special"), color: "oklch(0.55 0.22 25)" },
|
||||
{ key: "other", label: t("soldOutBuckets.other"), color: "oklch(0.62 0.16 145)" },
|
||||
];
|
||||
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<p>{t("noSoldOutNumbers")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let acc = 0;
|
||||
const parts = entries
|
||||
.filter((e) => buckets[e.key] > 0)
|
||||
.map((e) => {
|
||||
const frac = buckets[e.key] / total;
|
||||
const start = acc;
|
||||
acc += frac;
|
||||
return { ...e, frac, start };
|
||||
});
|
||||
|
||||
const gradientStops =
|
||||
parts.length === 1
|
||||
? `${parts[0].color} 0deg 360deg`
|
||||
: parts
|
||||
.map((p) => {
|
||||
const a0 = p.start * 360;
|
||||
const a1 = (p.start + p.frac) * 360;
|
||||
return `${p.color} ${a0}deg ${a1}deg`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative mx-auto size-44 shrink-0">
|
||||
<div
|
||||
className="size-full rounded-full"
|
||||
style={{
|
||||
background: `conic-gradient(from -90deg, ${gradientStops})`,
|
||||
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<p className="text-2xl font-bold tabular-nums text-[#1a365d]">{total}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t("soldOutTotal")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-2 text-sm">
|
||||
{entries.map((e) => (
|
||||
<li key={e.key} className="flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-2.5 shrink-0 rounded-sm" style={{ background: e.color }} />
|
||||
<span className="text-muted-foreground">{e.label}</span>
|
||||
</span>
|
||||
<span className="font-medium tabular-nums text-[#1a365d]">{buckets[e.key]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardConsole(): ReactElement {
|
||||
const { t } = useTranslation(["dashboard", "common"]);
|
||||
useAdminCurrencyCatalog();
|
||||
@@ -259,6 +119,7 @@ export function DashboardConsole(): ReactElement {
|
||||
|
||||
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
|
||||
const [drawId, setDrawId] = useState<number | null>(null);
|
||||
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
|
||||
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||
const [pendingReview, setPendingReview] = useState<number | null>(null);
|
||||
const [riskLocked, setRiskLocked] = useState(0);
|
||||
@@ -277,6 +138,7 @@ export function DashboardConsole(): ReactElement {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setFinance(null);
|
||||
setDrawPanel(null);
|
||||
setPendingReview(null);
|
||||
setDrawId(null);
|
||||
setRiskLocked(0);
|
||||
@@ -297,6 +159,7 @@ export function DashboardConsole(): ReactElement {
|
||||
setFinance(d.finance);
|
||||
}
|
||||
if (d.draw != null) {
|
||||
setDrawPanel(d.draw);
|
||||
setPendingReview(d.draw.result_batch_counts.pending_review);
|
||||
}
|
||||
if (d.risk != null) {
|
||||
@@ -326,10 +189,10 @@ export function DashboardConsole(): ReactElement {
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load(false);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
const currency = finance?.currency_code ?? null;
|
||||
@@ -355,19 +218,26 @@ export function DashboardConsole(): ReactElement {
|
||||
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-5" /> },
|
||||
];
|
||||
|
||||
const kpiSkeleton = (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-border/80 bg-card p-5 shadow-sm">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-[#1a365d]">{t("title")}</h1>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t("title")}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{todayLabel}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-300"
|
||||
disabled={loading || refreshing}
|
||||
onClick={() => void load(true)}
|
||||
>
|
||||
@@ -391,181 +261,131 @@ export function DashboardConsole(): ReactElement {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* Row 1 - Core finance KPI */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm"
|
||||
>
|
||||
{loading ? (
|
||||
kpiSkeleton
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label={t("todayBetTotal")}
|
||||
value={finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
|
||||
hint={hall?.draw_no ? t("drawNoHint", { drawNo: hall.draw_no }) : undefined}
|
||||
icon={<Wallet className="size-5" aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("currentPayout")}
|
||||
value={finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"}
|
||||
hint={
|
||||
finance
|
||||
? t("orderAndTicket", {
|
||||
orders: finance.order_count.toLocaleString("zh-CN"),
|
||||
tickets: finance.ticket_item_count.toLocaleString("zh-CN"),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Gift className="size-5" aria-hidden />}
|
||||
accent="destructive"
|
||||
/>
|
||||
<StatCard
|
||||
label={t("currentProfit")}
|
||||
value={finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"}
|
||||
hint={finance && finance.total_bet_minor > 0
|
||||
? t("marginRate", {
|
||||
rate: ((finance.approx_house_gross_minor / finance.total_bet_minor) * 100).toFixed(1),
|
||||
})
|
||||
: undefined}
|
||||
icon={<TrendingUp className="size-5" aria-hidden />}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("currentDraw")}
|
||||
value={<span className="font-mono text-primary">{hall?.draw_no ?? "—"}</span>}
|
||||
hint={
|
||||
<span className="inline-flex flex-wrap items-center gap-2">
|
||||
<span>{t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{hallStatusLabel}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
icon={<Ticket className="size-5" aria-hidden />}
|
||||
accent="muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("financeStructure")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-36 w-full" />
|
||||
) : finance ? (
|
||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("payoutComposition")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-36 w-full" />
|
||||
) : finance ? (
|
||||
<PayoutCompositionChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||
) : (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm lg:col-span-2 xl:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("riskCapUsage")}</CardTitle>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/risk/occupancy`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 px-2 text-xs",
|
||||
)}
|
||||
>
|
||||
{t("occupancyDetails")}
|
||||
</Link>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#c41e3a] text-white shadow-md">
|
||||
<Wallet className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-600">{t("todayBetTotal")}</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
|
||||
{finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
|
||||
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
|
||||
{t("currentDrawFinanceSummary")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#2563eb] text-white shadow-md">
|
||||
<Gift className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-600">{t("currentPayout")}</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
|
||||
{finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
|
||||
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
|
||||
{t("payoutSummary")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#1a365d] text-white shadow-md">
|
||||
<TrendingUp className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-600">{t("currentProfit")}</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
|
||||
{finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"}
|
||||
</p>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
|
||||
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
|
||||
{t("profitFormula")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<CapUsageBar
|
||||
locked={riskLocked}
|
||||
cap={riskCap}
|
||||
usagePct={usagePct}
|
||||
formatMoney={formatMoneyMinor}
|
||||
currency={currency}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 2 - Draw / betting / risk */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#1a365d] text-white shadow-md">
|
||||
<Ticket className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-600">{t("currentDraw")}</p>
|
||||
<p className="mt-1 font-mono text-2xl font-bold text-[#c41e3a]">{hall?.draw_no ?? "—"}</p>
|
||||
<p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}</span>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
isOpenLike ? "bg-emerald-500" : "bg-slate-400",
|
||||
)}
|
||||
/>
|
||||
{hallStatusLabel}
|
||||
</span>
|
||||
</p>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"mt-2 h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
href={`/admin/draws/${drawId}`}
|
||||
>
|
||||
{t("drawDetails")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#1a365d] text-white shadow-md">
|
||||
<Wallet className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-600">{t("ticketCount")}</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
|
||||
{finance != null ? finance.ticket_item_count.toLocaleString("zh-CN") : "—"}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("relatedBetAmount")}{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg bg-[#1a365d] text-white shadow-md">
|
||||
<Shield className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-center sm:text-left">
|
||||
<p className="text-sm font-medium text-slate-600">{t("riskCapUsage")}</p>
|
||||
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
|
||||
{t("lockedAndCap", {
|
||||
locked: formatMoneyMinor(riskLocked, currency),
|
||||
cap: formatMoneyMinor(riskCap, currency),
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<RiskSemiGauge pct={usagePct} />
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"mt-1 h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
href={`/admin/draws/${drawId}/risk/occupancy`}
|
||||
>
|
||||
{t("occupancyDetails")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 3 - Charts */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card className="border-slate-200/90 shadow-sm">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-2 space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-[#1a365d]">{t("hotNumbersTop10")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div role="tablist" aria-label={t("playDimension")} className="flex gap-1 border-b border-transparent">
|
||||
<CardTitle className="text-base">{t("hotNumbersTop10")}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div role="tablist" aria-label={t("playDimension")} className="flex gap-1">
|
||||
{([
|
||||
{ value: "4D", label: t("tabs.4d") },
|
||||
{ value: "3D", label: t("tabs.3d") },
|
||||
@@ -578,10 +398,10 @@ export function DashboardConsole(): ReactElement {
|
||||
role="tab"
|
||||
aria-selected={hotTab === tab.value}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 px-2.5 py-1 text-sm font-medium transition-colors",
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
hotTab === tab.value
|
||||
? "border-[#c41e3a] text-[#c41e3a]"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
onClick={() => setHotTab(tab.value)}
|
||||
>
|
||||
@@ -592,10 +412,7 @@ export function DashboardConsole(): ReactElement {
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/risk/hot`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("actions.viewAll", { ns: "common" })}
|
||||
</Link>
|
||||
@@ -603,26 +420,17 @@ export function DashboardConsole(): ReactElement {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
) : (
|
||||
<HotBarChart rows={hotRows} />
|
||||
)}
|
||||
{loading ? <Skeleton className="h-64 w-full" /> : <HotUsageBars rows={hotRows} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200/90 shadow-sm">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold text-[#1a365d]">{t("soldOutDistribution")}</CardTitle>
|
||||
</div>
|
||||
<CardTitle className="text-base">{t("soldOutDistribution")}</CardTitle>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/risk/sold-out`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("actions.viewAll", { ns: "common" })}
|
||||
</Link>
|
||||
@@ -630,76 +438,115 @@ export function DashboardConsole(): ReactElement {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-52 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
) : soldOutBuckets ? (
|
||||
<SoldOutDonut buckets={soldOutBuckets} />
|
||||
<SoldOutRing buckets={soldOutBuckets} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Row 4 - To-do */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("resultBatches")}</CardTitle>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("drawDetails")}
|
||||
</Link>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
) : drawPanel ? (
|
||||
<ResultBatchProgress draw={drawPanel} />
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-base">{t("settlementOverview")}</CardTitle>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href="/admin/settlement-batches"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
||||
>
|
||||
{t("actions.viewAll", { ns: "common" })}
|
||||
</Link>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
) : finance ? (
|
||||
<SettlementStatusChart finance={finance} />
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-border/80 bg-card p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#c41e3a] text-white shadow-md">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-sm">
|
||||
<ClipboardList className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600">{t("pendingReviewResults")}</p>
|
||||
<p className="mt-1 text-4xl font-bold tabular-nums text-[#c41e3a]">
|
||||
{pendingReview ?? "—"}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("pendingReviewResults")}</p>
|
||||
<p className="mt-1 text-4xl font-bold tabular-nums text-destructive">{pendingReview ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "default" }),
|
||||
"shrink-0 border-[#c41e3a] text-[#c41e3a] hover:bg-[#c41e3a]/5",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "shrink-0")}
|
||||
>
|
||||
{t("actions.reviewNow", { ns: "common" })}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-border/80 bg-card p-5 shadow-sm sm:flex-row sm:items-center">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-[#c41e3a] text-white shadow-md">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-amber-500 text-white shadow-sm">
|
||||
<AlertTriangle className="size-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-600">{t("abnormalTransferOrders")}</p>
|
||||
<p className="mt-1 text-4xl font-bold tabular-nums text-[#c41e3a]">
|
||||
{abnormalTransferTotal ?? "—"}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("abnormalTransferOrders")}</p>
|
||||
<p className="mt-1 text-4xl font-bold tabular-nums text-amber-600">{abnormalTransferTotal ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/wallet/transfer-orders"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "default" }),
|
||||
"shrink-0 border-[#c41e3a] text-[#c41e3a] hover:bg-[#c41e3a]/5",
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "shrink-0")}
|
||||
>
|
||||
{t("viewTransferOrders")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5 - Quick links */}
|
||||
<Card className="border-slate-200/90 shadow-sm">
|
||||
<CardContent className="flex flex-wrap justify-center gap-3 py-6 sm:gap-6">
|
||||
<Card className="border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("quickLinksTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap justify-center gap-3 py-2 sm:gap-5">
|
||||
{quickLinks.map((q) => (
|
||||
<Link
|
||||
key={q.label}
|
||||
href={q.href}
|
||||
className="flex w-[5.5rem] flex-col items-center gap-2 rounded-lg border border-transparent p-2 text-center text-xs font-medium text-[#1a365d] transition-colors hover:border-slate-200 hover:bg-slate-50 sm:w-24"
|
||||
className="flex w-24 flex-col items-center gap-2 rounded-lg border border-transparent p-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex size-11 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-800 shadow-sm">
|
||||
<span className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-sm">
|
||||
{q.icon}
|
||||
</span>
|
||||
{q.label}
|
||||
@@ -707,7 +554,6 @@ export function DashboardConsole(): ReactElement {
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
402
src/modules/dashboard/dashboard-visuals.tsx
Normal file
402
src/modules/dashboard/dashboard-visuals.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
|
||||
import type {
|
||||
AdminDashboardDrawPanel,
|
||||
AdminDashboardSoldOutBuckets,
|
||||
} from "@/types/api/admin-dashboard";
|
||||
|
||||
export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
|
||||
|
||||
type MoneyFormatter = (minor: number, currency: string | null) => string;
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon,
|
||||
accent = "primary",
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
icon: ReactNode;
|
||||
accent?: "primary" | "destructive" | "muted";
|
||||
}): ReactElement {
|
||||
const accentClass =
|
||||
accent === "destructive"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: accent === "muted"
|
||||
? "bg-muted text-foreground"
|
||||
: "bg-primary text-primary-foreground";
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/80 bg-card p-5 shadow-sm">
|
||||
<div className="flex gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-11 shrink-0 items-center justify-center rounded-lg shadow-sm",
|
||||
accentClass,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">{value}</p>
|
||||
{hint ? <div className="mt-2 text-xs text-muted-foreground">{hint}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapUsageBar({
|
||||
locked,
|
||||
cap,
|
||||
usagePct,
|
||||
formatMoney,
|
||||
currency,
|
||||
}: {
|
||||
locked: number;
|
||||
cap: number;
|
||||
usagePct: number;
|
||||
formatMoney: MoneyFormatter;
|
||||
currency: string | null;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const pct = Math.min(100, Math.max(0, usagePct));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{t("riskCapUsage")}</span>
|
||||
<span className="text-2xl font-bold tabular-nums text-foreground">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-500",
|
||||
pct >= 90 ? "bg-destructive" : pct >= 70 ? "bg-amber-500" : "bg-primary",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs tabular-nums text-muted-foreground">
|
||||
{t("lockedAndCap", {
|
||||
locked: formatMoney(locked, currency),
|
||||
cap: formatMoney(cap, currency),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FinanceStructureChart({
|
||||
finance,
|
||||
formatMoney,
|
||||
}: {
|
||||
finance: AdminDrawFinanceSummaryData;
|
||||
formatMoney: MoneyFormatter;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const currency = finance.currency_code;
|
||||
const bet = finance.total_bet_minor;
|
||||
const win = finance.total_win_payout_minor;
|
||||
const jackpot = finance.total_jackpot_win_minor;
|
||||
const payout = finance.total_payout_minor;
|
||||
const gross = finance.approx_house_gross_minor;
|
||||
|
||||
if (bet <= 0) {
|
||||
return <p className="py-8 text-center text-sm text-muted-foreground">{t("noFinanceActivity")}</p>;
|
||||
}
|
||||
|
||||
const winW = (win / bet) * 100;
|
||||
const jpW = (jackpot / bet) * 100;
|
||||
const grossW = Math.max(0, (gross / bet) * 100);
|
||||
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: "gross", width: grossW, className: "bg-primary", label: t("houseGross"), value: gross },
|
||||
].filter((s) => s.width > 0.05);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex h-10 overflow-hidden rounded-lg ring-1 ring-border/60">
|
||||
{segments.map((s) => (
|
||||
<div
|
||||
key={s.key}
|
||||
className={cn("min-w-[2px] transition-all", s.className)}
|
||||
style={{ width: `${s.width}%` }}
|
||||
title={`${s.label}: ${formatMoney(s.value, currency)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("payoutRateOfBet", { rate: payoutRate })}
|
||||
</p>
|
||||
<ul className="grid gap-2 sm:grid-cols-3">
|
||||
{segments.map((s) => (
|
||||
<li key={s.key} className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2">
|
||||
<span className={cn("size-2.5 shrink-0 rounded-sm", s.className)} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
<p className="truncate text-sm font-semibold tabular-nums">{formatMoney(s.value, currency)}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PayoutCompositionChart({
|
||||
finance,
|
||||
formatMoney,
|
||||
}: {
|
||||
finance: AdminDrawFinanceSummaryData;
|
||||
formatMoney: MoneyFormatter;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const currency = finance.currency_code;
|
||||
const win = finance.total_win_payout_minor;
|
||||
const jackpot = finance.total_jackpot_win_minor;
|
||||
const total = win + jackpot;
|
||||
|
||||
if (total <= 0) {
|
||||
return <p className="py-8 text-center text-sm text-muted-foreground">{t("noPayoutYet")}</p>;
|
||||
}
|
||||
|
||||
const winPct = (win / total) * 100;
|
||||
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" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div
|
||||
className="relative mx-auto size-36 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: `conic-gradient(from -90deg, var(--chart-2) 0deg ${winPct * 3.6}deg, var(--chart-4) ${winPct * 3.6}deg 360deg)`,
|
||||
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
}}
|
||||
/>
|
||||
<ul className="min-w-0 flex-1 space-y-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.label}>
|
||||
<div className="mb-1 flex justify-between gap-2 text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className={cn("size-2.5 rounded-sm", item.className)} />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{item.pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold tabular-nums">{formatMoney(item.value, currency)}</p>
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn("h-full rounded-full", item.className)} style={{ width: `${item.pct}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("noPoolData")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{rows.map((row) => {
|
||||
const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100));
|
||||
return (
|
||||
<li key={row.normalized_number}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-mono font-medium text-foreground">
|
||||
{row.normalized_number.trim()}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{pct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
pct >= 95 ? "bg-destructive" : pct >= 80 ? "bg-amber-500" : "bg-primary",
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
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 total = entries.reduce((s, e) => s + buckets[e.key], 0);
|
||||
|
||||
if (total === 0) {
|
||||
return <p className="py-10 text-center text-sm text-muted-foreground">{t("noSoldOutNumbers")}</p>;
|
||||
}
|
||||
|
||||
let acc = 0;
|
||||
const parts = entries
|
||||
.filter((e) => buckets[e.key] > 0)
|
||||
.map((e) => {
|
||||
const frac = buckets[e.key] / total;
|
||||
const start = acc;
|
||||
acc += frac;
|
||||
return { ...e, frac, start };
|
||||
});
|
||||
|
||||
const gradientStops =
|
||||
parts.length === 1
|
||||
? `${parts[0].color} 0deg 360deg`
|
||||
: parts
|
||||
.map((p) => {
|
||||
const a0 = p.start * 360;
|
||||
const a1 = (p.start + p.frac) * 360;
|
||||
return `${p.color} ${a0}deg ${a1}deg`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative mx-auto size-40 shrink-0">
|
||||
<div
|
||||
className="size-full rounded-full"
|
||||
style={{
|
||||
background: `conic-gradient(from -90deg, ${gradientStops})`,
|
||||
mask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
WebkitMask: "radial-gradient(farthest-side, transparent 58%, #000 59%)",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p className="text-3xl font-bold tabular-nums">{total}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("soldOutTotal")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="min-w-0 flex-1 space-y-2">
|
||||
{entries.map((e) => {
|
||||
const count = buckets[e.key];
|
||||
const pct = total > 0 ? (count / total) * 100 : 0;
|
||||
return (
|
||||
<li key={e.key}>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="size-2.5 rounded-sm" style={{ background: e.color }} />
|
||||
{e.label}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{count}
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">({pct.toFixed(0)}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: e.color }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel }): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { total, pending_review, published } = draw.result_batch_counts;
|
||||
const pendingW = total > 0 ? (pending_review / total) * 100 : 0;
|
||||
const publishedW = total > 0 ? (published / total) * 100 : 0;
|
||||
const otherW = Math.max(0, 100 - pendingW - publishedW);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-muted">
|
||||
{pendingW > 0 ? (
|
||||
<div className="bg-amber-500" style={{ width: `${pendingW}%` }} title={t("batchPending")} />
|
||||
) : null}
|
||||
{publishedW > 0 ? (
|
||||
<div className="bg-emerald-600" style={{ width: `${publishedW}%` }} title={t("batchPublished")} />
|
||||
) : null}
|
||||
{otherW > 0 ? <div className="bg-muted-foreground/30" style={{ width: `${otherW}%` }} /> : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
|
||||
<p className="text-2xl font-bold tabular-nums text-amber-600">{pending_review}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("batchPending")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
|
||||
<p className="text-2xl font-bold tabular-nums text-emerald-600">{published}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("batchPublished")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
|
||||
<p className="text-2xl font-bold tabular-nums">{total}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("batchTotal")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettlementStatusChart({
|
||||
finance,
|
||||
}: {
|
||||
finance: AdminDrawFinanceSummaryData;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const batches = finance.settlement_batches ?? [];
|
||||
|
||||
if (batches.length === 0) {
|
||||
return <p className="py-6 text-center text-sm text-muted-foreground">{t("noSettlementBatches")}</p>;
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const b of batches) {
|
||||
counts.set(b.status, (counts.get(b.status) ?? 0) + 1);
|
||||
}
|
||||
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const max = Math.max(...entries.map((e) => e[1]));
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{entries.map(([status, count]) => (
|
||||
<li key={status}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<AdminStatusBadge status={status}>{status}</AdminStatusBadge>
|
||||
<span className="text-sm font-medium tabular-nums">{count}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/80"
|
||||
style={{ width: `${max > 0 ? (count / max) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
|
||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
|
||||
|
||||
function drawStatusText(status: string, t: (key: string) => string): string {
|
||||
@@ -43,6 +45,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
PRD_PAYOUT_REVIEW,
|
||||
]);
|
||||
const [data, setData] = useState<AdminDrawFinanceSummaryData | null>(null);
|
||||
const exportLabels = useExportLabels("drawFinance", { drawNo: data?.draw_no ?? drawId });
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [settling, setSettling] = useState(false);
|
||||
@@ -170,8 +173,8 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId={`draw-finance-table-${drawId}`}
|
||||
filename={`期号收支-${data.draw_no}`}
|
||||
sheetName="期号收支"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
</div>
|
||||
<Table id={`draw-finance-table-${drawId}`}>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
@@ -65,6 +66,7 @@ function drawAdminStatusSelectLabel(raw: unknown, t: (key: string) => string): s
|
||||
|
||||
export function DrawsIndexConsole() {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const exportLabels = useExportLabels("drawsList");
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const profile = useAdminProfile();
|
||||
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
|
||||
@@ -198,8 +200,8 @@ export function DrawsIndexConsole() {
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="draws-index-table"
|
||||
filename="期号列表"
|
||||
sheetName="期号列表"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -27,15 +27,11 @@ export function JackpotConfigScreen() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigDocPage title={t("configTitle")} description={t("pageDescription")}>
|
||||
<ConfigSection title={t("poolsSectionTitle")} description={t("poolsSectionDescription")}>
|
||||
<ConfigDocPage title={t("configTitle")}>
|
||||
<ConfigSection title={t("poolsSectionTitle")}>
|
||||
<JackpotPoolsConsole embedded />
|
||||
</ConfigSection>
|
||||
<ConfigSection
|
||||
id="jackpot-records"
|
||||
title={t("recordsSectionTitle")}
|
||||
description={t("recordsSectionDescription")}
|
||||
>
|
||||
<ConfigSection id="jackpot-records" title={t("recordsSectionTitle")}>
|
||||
<JackpotRecordsConsole embedded />
|
||||
</ConfigSection>
|
||||
</ConfigDocPage>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { toast } from "sonner";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminJackpotPoolRow } from "@/types/api/admin-jackpot";
|
||||
@@ -167,14 +166,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
const d = drafts[p.id] ?? toDraft(p);
|
||||
return (
|
||||
<div key={p.id} className="space-y-4 rounded-lg border border-border p-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("displayBalance", {
|
||||
amount: formatAdminMinorUnits(p.current_amount, p.currency_code),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`amt-${p.id}`}>{t("currentAmount")}</Label>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
@@ -33,6 +34,8 @@ type JackpotRecordsConsoleProps = {
|
||||
|
||||
export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsoleProps) {
|
||||
const { t } = useTranslation(["jackpot", "common"]);
|
||||
const payoutExport = useExportLabels("jackpotPayouts");
|
||||
const contributionExport = useExportLabels("jackpotContributions");
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [drawNo, setDrawNo] = useState("");
|
||||
const [appliedDrawNo, setAppliedDrawNo] = useState("");
|
||||
@@ -105,36 +108,71 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
return translated === key ? value : translated;
|
||||
};
|
||||
|
||||
const filterBlock = embedded ? (
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex max-w-xs flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
|
||||
<Input
|
||||
id="jk-draw"
|
||||
className="font-mono"
|
||||
value={drawNo}
|
||||
onChange={(e) => setDrawNo(e.target.value)}
|
||||
placeholder={t("optional")}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={applyDraw}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("filter")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex max-w-xs flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
|
||||
<Input
|
||||
id="jk-draw"
|
||||
className="font-mono"
|
||||
value={drawNo}
|
||||
onChange={(e) => setDrawNo(e.target.value)}
|
||||
placeholder={t("optional")}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={applyDraw}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const payoutHeader = embedded ? (
|
||||
<p className="mb-3 text-sm font-semibold">{t("payoutRecords")}</p>
|
||||
) : (
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("payoutRecords")}</CardTitle>
|
||||
</CardHeader>
|
||||
);
|
||||
|
||||
const contributionHeader = embedded ? (
|
||||
<p className="mb-3 text-sm font-semibold">{t("contributionRecords")}</p>
|
||||
) : (
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("contributionRecords")}</CardTitle>
|
||||
</CardHeader>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("filter")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex max-w-xs flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
|
||||
<Input
|
||||
id="jk-draw"
|
||||
className="font-mono"
|
||||
value={drawNo}
|
||||
onChange={(e) => setDrawNo(e.target.value)}
|
||||
placeholder={t("optional")}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={applyDraw}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{filterBlock}
|
||||
|
||||
{err ? <p className="text-destructive mb-4 text-sm">{err}</p> : null}
|
||||
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("payoutRecords")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Card className={embedded ? "mb-6 border-border/60 shadow-none" : "mb-8"}>
|
||||
{!embedded ? payoutHeader : null}
|
||||
<CardContent className={embedded ? "p-0" : undefined}>
|
||||
{embedded ? payoutHeader : null}
|
||||
{loadingP && !payouts ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
@@ -142,8 +180,8 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId="jackpot-payout-table"
|
||||
filename="奖池派彩记录"
|
||||
sheetName="奖池派彩"
|
||||
filename={payoutExport.filename}
|
||||
sheetName={payoutExport.sheetName}
|
||||
/>
|
||||
</div>
|
||||
<Table id="jackpot-payout-table">
|
||||
@@ -194,11 +232,10 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("contributionRecords")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Card className={embedded ? "border-border/60 shadow-none" : undefined}>
|
||||
{!embedded ? contributionHeader : null}
|
||||
<CardContent className={embedded ? "p-0" : undefined}>
|
||||
{embedded ? contributionHeader : null}
|
||||
{loadingC && !contribs ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
@@ -206,8 +243,8 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId="jackpot-contribution-table"
|
||||
filename="奖池注入记录"
|
||||
sheetName="奖池注入"
|
||||
filename={contributionExport.filename}
|
||||
sheetName={contributionExport.sheetName}
|
||||
/>
|
||||
</div>
|
||||
<Table id="jackpot-contribution-table">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -62,6 +63,7 @@ const PLAYER_STATUS_OPTIONS = [
|
||||
|
||||
export function PlayersConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["players", "common"]);
|
||||
const exportLabels = useExportLabels("players");
|
||||
const profile = useAdminProfile();
|
||||
useAdminCurrencyCatalog();
|
||||
const canManagePlayers = adminHasAnyPermission(profile?.permissions, ["prd.users.manage"]);
|
||||
@@ -275,8 +277,8 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="players-table"
|
||||
filename="玩家列表"
|
||||
sheetName="玩家列表"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
@@ -47,6 +48,7 @@ const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
|
||||
export function RiskIndexConsole() {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskIndex");
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminDrawListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -158,8 +160,8 @@ export function RiskIndexConsole() {
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="risk-index-table"
|
||||
filename="风控中心期号列表"
|
||||
sheetName="风控中心"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => applySearch()}>
|
||||
{t("search")}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
|
||||
@@ -51,6 +52,7 @@ function riskActionLabel(
|
||||
|
||||
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskLockLogs");
|
||||
useAdminCurrencyCatalog();
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -139,8 +141,8 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId={`risk-lock-logs-table-${drawId}`}
|
||||
filename="风险占用流水"
|
||||
sheetName="风险占用流水"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -32,6 +33,7 @@ export function RiskPoolDetailConsole({
|
||||
number4d: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskPoolDetail", { number: number4d });
|
||||
useAdminCurrencyCatalog();
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -149,8 +151,8 @@ export function RiskPoolDetailConsole({
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId={`risk-pool-detail-table-${drawId}-${number4d}`}
|
||||
filename={`风险池详情-${number4d}`}
|
||||
sheetName="风险池详情"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
</div>
|
||||
<Table id={`risk-pool-detail-table-${drawId}-${number4d}`}>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -72,6 +73,7 @@ export function RiskPoolsConsole({
|
||||
allowSortChange = false,
|
||||
}: RiskPoolsConsoleProps) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const exportLabels = useExportLabels("riskPools");
|
||||
useAdminCurrencyCatalog();
|
||||
const [sort, setSort] = useState(defaultSort);
|
||||
const [filter, setFilter] = useState<RiskFilter>(soldOutOnly ? "sold_out" : "all");
|
||||
@@ -214,8 +216,8 @@ export function RiskPoolsConsole({
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId={`risk-pools-table-${drawId}`}
|
||||
filename={title}
|
||||
sheetName="风险池"
|
||||
filename={title ?? exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
40
src/modules/rules/rules-odds-config-screen.tsx
Normal file
40
src/modules/rules/rules-odds-config-screen.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
||||
import { ConfigSection } from "@/modules/config/config-section";
|
||||
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
|
||||
import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen";
|
||||
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
||||
|
||||
/** 赔率与回水:共用赔率版本线,单页上下分区。 */
|
||||
export function RulesOddsConfigScreen() {
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
useEffect(() => {
|
||||
const scrollToRebate = () => {
|
||||
if (window.location.hash !== "#rebate") {
|
||||
return;
|
||||
}
|
||||
document.getElementById("rebate")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
scrollToRebate();
|
||||
window.addEventListener("hashchange", scrollToRebate);
|
||||
return () => window.removeEventListener("hashchange", scrollToRebate);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RulesPageShell>
|
||||
<ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="space-y-10">
|
||||
<ConfigSection title={t("nav.items.odds")}>
|
||||
<OddsConfigDocScreen embedded />
|
||||
</ConfigSection>
|
||||
<ConfigSection id="rebate" title={t("nav.items.rebate")}>
|
||||
<RebateConfigDocScreen embedded />
|
||||
</ConfigSection>
|
||||
</ConfigDocPage>
|
||||
</RulesPageShell>
|
||||
);
|
||||
}
|
||||
8
src/modules/rules/rules-page-shell.tsx
Normal file
8
src/modules/rules/rules-page-shell.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
|
||||
/** 规则类配置页:仅内容区,无运营配置顶栏。 */
|
||||
export function RulesPageShell({ children }: { children: ReactNode }) {
|
||||
return <ModuleScaffold>{children}</ModuleScaffold>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -64,6 +65,7 @@ function toFormState(row: AdminCurrencyRow): CurrencyFormState {
|
||||
|
||||
export function CurrencySettingsPanel() {
|
||||
const { t } = useTranslation(["config", "adminUsers"]);
|
||||
const exportLabels = useExportLabels("currencies");
|
||||
const profile = useAdminProfile();
|
||||
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.currency.manage"]);
|
||||
const [items, setItems] = useState<AdminCurrencyRow[]>([]);
|
||||
@@ -210,7 +212,8 @@ export function CurrencySettingsPanel() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminTableExportButton tableId="admin-currencies-table" filename="币种管理" sheetName="币种管理" />
|
||||
<AdminTableExportButton tableId="admin-currencies-table" filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName} />
|
||||
<Button onClick={openCreate}>{t("currencies.actions.create", { ns: "config" })}</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -85,6 +86,7 @@ function settlementReviewStatusText(value: string | null, t: (key: string) => st
|
||||
|
||||
export function SettlementBatchesConsole() {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const exportLabels = useExportLabels("settlementBatches");
|
||||
const profile = useAdminProfile();
|
||||
useAdminCurrencyCatalog();
|
||||
const canReviewSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_REVIEW]);
|
||||
@@ -220,8 +222,8 @@ export function SettlementBatchesConsole() {
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="settlement-batches-table"
|
||||
filename="结算批次"
|
||||
sheetName="结算批次"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" className="xl:shrink-0" onClick={applyFilters}>
|
||||
{t("apply")}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminTicketItems } from "@/api/admin-tickets";
|
||||
@@ -74,11 +75,12 @@ function ticketStatusSummary(statuses: string[], t: (key: string) => string): st
|
||||
return ticketStatusText(statuses[0], t);
|
||||
}
|
||||
|
||||
return t("statusSelectedCount", { count: statuses.length, defaultValue: `已选 ${statuses.length} 项` });
|
||||
return t("statusSelectedCount", { count: statuses.length });
|
||||
}
|
||||
|
||||
export function PlayerTicketsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const exportLabels = useExportLabels("tickets");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [draft, setDraft] = useState<TicketFilters>(emptyTicketFilters);
|
||||
const [applied, setApplied] = useState<TicketFilters>(emptyTicketFilters);
|
||||
@@ -238,8 +240,8 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="tickets-table"
|
||||
filename="注单列表"
|
||||
sheetName="注单列表"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
@@ -259,7 +261,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
{t("playerId")}:<span className="font-mono">{applied.playerQuery}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t("allTickets", { defaultValue: "全部注单" })}</span>
|
||||
<span>{t("allTickets")}</span>
|
||||
)}
|
||||
{applied.drawNo ? (
|
||||
<>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
@@ -214,6 +215,7 @@ function canManuallyProcessTransferOrder(row: {
|
||||
|
||||
export function TransferOrdersPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const exportLabels = useExportLabels("walletTransferOrders");
|
||||
useAdminCurrencyCatalog();
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
|
||||
@@ -401,8 +403,8 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="wallet-transfer-orders-table"
|
||||
filename="钱包转账订单"
|
||||
sheetName="转账订单"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("search")}
|
||||
@@ -535,6 +537,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
|
||||
export function WalletTxnsPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const exportLabels = useExportLabels("walletTransactions");
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -725,8 +728,8 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="wallet-transactions-table"
|
||||
filename="钱包流水"
|
||||
sheetName="钱包流水"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("search")}
|
||||
@@ -824,6 +827,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
|
||||
export function PlayerWalletPanel(): React.ReactElement {
|
||||
const { t } = useTranslation(["wallet", "common"]);
|
||||
const exportLabels = useExportLabels("playerWallets");
|
||||
useAdminCurrencyCatalog();
|
||||
const [playerId, setPlayerId] = useState("");
|
||||
const [result, setResult] = useState<AdminPlayerWalletsData | null>(null);
|
||||
@@ -870,8 +874,8 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
</div>
|
||||
<AdminTableExportButton
|
||||
tableId="player-wallet-table"
|
||||
filename="玩家钱包"
|
||||
sheetName="玩家钱包"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" onClick={() => void query()} disabled={loading}>
|
||||
{loading ? t("querying") : t("query")}
|
||||
|
||||
Reference in New Issue
Block a user