refactor: 更新管理端页面元数据,统一国际化支持,移除冗余代码

This commit is contained in:
2026-05-21 17:27:52 +08:00
parent 26feed3c4f
commit e8a5507411
77 changed files with 1669 additions and 732 deletions

View File

@@ -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,

View File

@@ -4,7 +4,10 @@ export type AdminNavSegment =
| "dashboard"
| "players"
| "draws"
| "config"
| "rules_plays"
| "rules_odds"
| "jackpot"
| "risk_cap"
| "tickets"
| "wallet"
| "risk"

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View 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>
);
}

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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}`}>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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"

View File

@@ -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")}

View File

@@ -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"

View File

@@ -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}`}>

View File

@@ -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>

View 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>
);
}

View 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>;
}

View File

@@ -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>

View File

@@ -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")}

View File

@@ -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 ? (
<>

View File

@@ -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")}