diff --git a/package-lock.json b/package-lock.json index f7c574a..54b25bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "xlsx": "^0.18.5", "zustand": "^5.0.13" }, "devDependencies": { @@ -3148,6 +3149,14 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://mirrors.cloud.tencent.com/npm/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", @@ -3708,6 +3717,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", @@ -3849,6 +3871,15 @@ "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "license": "MIT" }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", @@ -3984,6 +4015,18 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5429,6 +5472,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", @@ -9013,6 +9065,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmmirror.com/stable-hash/-/stable-hash-0.0.5.tgz", @@ -10001,6 +10065,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", @@ -10091,6 +10173,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 7e1ea2d..fb60572 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", + "xlsx": "^0.18.5", "zustand": "^5.0.13" }, "devDependencies": { diff --git a/src/app/admin/(shell)/draws/[drawId]/risk/hot/page.tsx b/src/app/admin/(shell)/draws/[drawId]/risk/hot/page.tsx new file mode 100644 index 0000000..e0a4cd4 --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/risk/hot/page.tsx @@ -0,0 +1,17 @@ +import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console"; + +export default async function AdminDrawRiskHotPage(props: { + params: Promise<{ drawId: string }>; +}) { + const { drawId } = await props.params; + const id = Number(drawId); + + return ( + + ); +} diff --git a/src/app/admin/(shell)/draws/[drawId]/risk/occupancy/page.tsx b/src/app/admin/(shell)/draws/[drawId]/risk/occupancy/page.tsx new file mode 100644 index 0000000..54d6ab8 --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/risk/occupancy/page.tsx @@ -0,0 +1,10 @@ +import { RiskLockLogsConsole } from "@/modules/risk/risk-lock-logs-console"; + +export default async function AdminDrawRiskOccupancyPage(props: { + params: Promise<{ drawId: string }>; +}) { + const { drawId } = await props.params; + const id = Number(drawId); + + return ; +} diff --git a/src/app/admin/(shell)/draws/[drawId]/risk/page.tsx b/src/app/admin/(shell)/draws/[drawId]/risk/page.tsx new file mode 100644 index 0000000..165b0c7 --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/risk/page.tsx @@ -0,0 +1,9 @@ +import { redirect } from "next/navigation"; + +export default async function AdminDrawRiskIndexPage(props: { + params: Promise<{ drawId: string }>; +}) { + const { drawId } = await props.params; + + redirect(`/admin/draws/${drawId}/risk/occupancy`); +} diff --git a/src/app/admin/(shell)/draws/[drawId]/risk/pools/[number]/page.tsx b/src/app/admin/(shell)/draws/[drawId]/risk/pools/[number]/page.tsx new file mode 100644 index 0000000..5b908f1 --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/risk/pools/[number]/page.tsx @@ -0,0 +1,10 @@ +import { RiskPoolDetailConsole } from "@/modules/risk/risk-pool-detail-console"; + +export default async function AdminDrawRiskPoolDetailPage(props: { + params: Promise<{ drawId: string; number: string }>; +}) { + const { drawId, number } = await props.params; + const id = Number(drawId); + + return ; +} diff --git a/src/app/admin/(shell)/draws/[drawId]/risk/pools/page.tsx b/src/app/admin/(shell)/draws/[drawId]/risk/pools/page.tsx new file mode 100644 index 0000000..c4412ce --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/risk/pools/page.tsx @@ -0,0 +1,18 @@ +import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console"; + +export default async function AdminDrawRiskPoolsPage(props: { + params: Promise<{ drawId: string }>; +}) { + const { drawId } = await props.params; + const id = Number(drawId); + + return ( + + ); +} diff --git a/src/app/admin/(shell)/draws/[drawId]/risk/sold-out/page.tsx b/src/app/admin/(shell)/draws/[drawId]/risk/sold-out/page.tsx new file mode 100644 index 0000000..9a595fe --- /dev/null +++ b/src/app/admin/(shell)/draws/[drawId]/risk/sold-out/page.tsx @@ -0,0 +1,17 @@ +import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console"; + +export default async function AdminDrawRiskSoldOutPage(props: { + params: Promise<{ drawId: string }>; +}) { + const { drawId } = await props.params; + const id = Number(drawId); + + return ( + + ); +} diff --git a/src/app/admin/(shell)/risk/draws/[drawId]/hot/page.tsx b/src/app/admin/(shell)/risk/draws/[drawId]/hot/page.tsx index a579d81..000e2ee 100644 --- a/src/app/admin/(shell)/risk/draws/[drawId]/hot/page.tsx +++ b/src/app/admin/(shell)/risk/draws/[drawId]/hot/page.tsx @@ -1,17 +1,8 @@ -import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console"; +import { redirect } from "next/navigation"; export default async function AdminRiskHotPage(props: { params: Promise<{ drawId: string }>; }) { const { drawId } = await props.params; - const id = Number(drawId); - - return ( - - ); + redirect(`/admin/draws/${drawId}/risk/hot`); } diff --git a/src/app/admin/(shell)/risk/draws/[drawId]/occupancy/page.tsx b/src/app/admin/(shell)/risk/draws/[drawId]/occupancy/page.tsx index 9cbe9d9..21fabc5 100644 --- a/src/app/admin/(shell)/risk/draws/[drawId]/occupancy/page.tsx +++ b/src/app/admin/(shell)/risk/draws/[drawId]/occupancy/page.tsx @@ -1,10 +1,8 @@ -import { RiskLockLogsConsole } from "@/modules/risk/risk-lock-logs-console"; +import { redirect } from "next/navigation"; export default async function AdminRiskOccupancyPage(props: { params: Promise<{ drawId: string }>; }) { const { drawId } = await props.params; - const id = Number(drawId); - - return ; + redirect(`/admin/draws/${drawId}/risk/occupancy`); } diff --git a/src/app/admin/(shell)/risk/draws/[drawId]/pools/[number]/page.tsx b/src/app/admin/(shell)/risk/draws/[drawId]/pools/[number]/page.tsx index b6e08b0..da79436 100644 --- a/src/app/admin/(shell)/risk/draws/[drawId]/pools/[number]/page.tsx +++ b/src/app/admin/(shell)/risk/draws/[drawId]/pools/[number]/page.tsx @@ -1,10 +1,8 @@ -import { RiskPoolDetailConsole } from "@/modules/risk/risk-pool-detail-console"; +import { redirect } from "next/navigation"; export default async function AdminRiskPoolDetailPage(props: { params: Promise<{ drawId: string; number: string }>; }) { const { drawId, number } = await props.params; - const id = Number(drawId); - - return ; + redirect(`/admin/draws/${drawId}/risk/pools/${number}`); } diff --git a/src/app/admin/(shell)/risk/draws/[drawId]/pools/page.tsx b/src/app/admin/(shell)/risk/draws/[drawId]/pools/page.tsx index ecfff6b..4b2b8ea 100644 --- a/src/app/admin/(shell)/risk/draws/[drawId]/pools/page.tsx +++ b/src/app/admin/(shell)/risk/draws/[drawId]/pools/page.tsx @@ -1,18 +1,8 @@ -import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console"; +import { redirect } from "next/navigation"; export default async function AdminRiskPoolsPage(props: { params: Promise<{ drawId: string }>; }) { const { drawId } = await props.params; - const id = Number(drawId); - - return ( - - ); + redirect(`/admin/draws/${drawId}/risk/pools`); } diff --git a/src/app/admin/(shell)/risk/draws/[drawId]/sold-out/page.tsx b/src/app/admin/(shell)/risk/draws/[drawId]/sold-out/page.tsx index 51fb107..e728cc9 100644 --- a/src/app/admin/(shell)/risk/draws/[drawId]/sold-out/page.tsx +++ b/src/app/admin/(shell)/risk/draws/[drawId]/sold-out/page.tsx @@ -1,17 +1,8 @@ -import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console"; +import { redirect } from "next/navigation"; export default async function AdminRiskSoldOutPage(props: { params: Promise<{ drawId: string }>; }) { const { drawId } = await props.params; - const id = Number(drawId); - - return ( - - ); + redirect(`/admin/draws/${drawId}/risk/sold-out`); } diff --git a/src/app/admin/(shell)/risk/page.tsx b/src/app/admin/(shell)/risk/page.tsx index 846fddc..df4fcd5 100644 --- a/src/app/admin/(shell)/risk/page.tsx +++ b/src/app/admin/(shell)/risk/page.tsx @@ -1,10 +1,5 @@ -import { ModuleScaffold } from "@/components/admin/module-scaffold"; -import { RiskIndexConsole } from "@/modules/risk/risk-index-console"; +import { redirect } from "next/navigation"; export default function AdminRiskIndexPage() { - return ( - - - - ); + redirect("/admin/draws"); } diff --git a/src/app/globals.css b/src/app/globals.css index e770230..d27ab57 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -128,3 +128,56 @@ @apply font-sans; } } + +@layer components { + .admin-list-card { + @apply overflow-hidden border-border/80 shadow-sm; + } + + .admin-list-header { + @apply border-b bg-muted/20 pb-4; + } + + .admin-list-title { + @apply text-lg font-semibold tracking-tight; + } + + .admin-list-content { + @apply space-y-4; + } + + .admin-list-toolbar { + @apply flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center; + } + + .admin-list-field { + @apply flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:shrink-0; + } + + .admin-list-actions { + @apply flex shrink-0 flex-wrap gap-2; + margin-left: auto; + } + + .admin-table-shell { + @apply overflow-x-auto rounded-xl border border-border bg-background; + } + + .admin-table-toolbar { + @apply flex items-center justify-end border-b border-border bg-muted/15 px-3 py-2.5; + } + + .admin-inline-note { + @apply text-sm text-muted-foreground; + } + + [data-slot="table-cell"]:has(> [data-slot="button"]), + [data-slot="table-cell"]:has(> a) { + text-align: center; + } + + [data-slot="table-cell"] > .flex:has([data-slot="button"]), + [data-slot="table-cell"] > .flex:has(a) { + justify-content: center; + } +} diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index 50f82bd..f7d07aa 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -35,7 +35,10 @@ export function AdminAppSidebar() { const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "reports", "audit"]); const pathname = usePathname(); const profile = useAdminProfile(); - const visibleNav = useMemo(() => profile?.navigation ?? [], [profile?.navigation]); + const visibleNav = useMemo( + () => (profile?.navigation ?? []).filter((item) => item.segment !== "risk"), + [profile?.navigation], + ); return ( diff --git a/src/components/admin/admin-table-export-button.tsx b/src/components/admin/admin-table-export-button.tsx new file mode 100644 index 0000000..f148d0d --- /dev/null +++ b/src/components/admin/admin-table-export-button.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Download } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import * as XLSX from "xlsx"; + +import { Button } from "@/components/ui/button"; + +const OMIT_HEADER_TOKENS = [ + "actions", + "action", + "操作", + "下载", + "download", +] as const; + +function shouldOmitColumn(headerText: string): boolean { + const normalized = headerText.trim().toLowerCase(); + if (normalized === "") { + return true; + } + return OMIT_HEADER_TOKENS.some((token) => normalized.includes(token)); +} + +function stripOmittedColumns(table: HTMLTableElement): HTMLTableElement { + const clone = table.cloneNode(true) as HTMLTableElement; + const headerRow = clone.querySelector("thead tr"); + if (!headerRow) { + return clone; + } + + const omitIndexes = Array.from(headerRow.children) + .map((cell, index) => ({ + index, + text: cell.textContent ?? "", + })) + .filter((item) => shouldOmitColumn(item.text)) + .map((item) => item.index) + .sort((a, b) => b - a); + + if (omitIndexes.length === 0) { + return clone; + } + + clone.querySelectorAll("tr").forEach((row) => { + const cells = Array.from(row.children); + omitIndexes.forEach((index) => { + const target = cells[index]; + if (target) { + target.remove(); + } + }); + }); + + return clone; +} + +export function AdminTableExportButton({ + tableId, + filename, + sheetName = "Sheet1", + label, +}: { + tableId: string; + filename: string; + sheetName?: string; + label?: string; +}) { + const { t } = useTranslation("common"); + + const onExport = () => { + const table = document.getElementById(tableId); + if (!(table instanceof HTMLTableElement)) { + toast.error(t("errors.loadFailed", { defaultValue: "导出失败" })); + return; + } + + try { + const exportTable = stripOmittedColumns(table); + const workbook = XLSX.utils.table_to_book(exportTable, { + sheet: sheetName, + raw: true, + }); + const safeName = filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`; + XLSX.writeFile(workbook, safeName); + toast.success(t("actions.done", { defaultValue: "已导出" })); + } catch { + toast.error(t("errors.loadFailed", { defaultValue: "导出失败" })); + } + }; + + return ( + + ); +} diff --git a/src/i18n/locales/en/reconcile.json b/src/i18n/locales/en/reconcile.json index a63bf6c..ba27ac0 100644 --- a/src/i18n/locales/en/reconcile.json +++ b/src/i18n/locales/en/reconcile.json @@ -1,17 +1,13 @@ { "title": "Reconcile", "createTitle": "Create reconcile job", - "createDesc": "Abnormal flows are checked automatically by scheduled jobs. This section allows finance to trigger jobs manually: choose reconcile type and time range, and optionally fill in target references (player IDs, transfer numbers, or idempotency keys, one per line). Jobs and items are persisted for audit and future automation.", + "createDesc": "Manually check abnormal transfers by period or selected references. Scheduled reconciliation still runs automatically.", "reconcileType": "Reconcile type", "walletTransfer": "Wallet transfer (main site ⇄ lottery)", "startTime": "Start time", "endTime": "End time", - "scope": "Scope (optional)", - "scopePlaceholder": "One reference per line, for example player ID, wallet transfer number, or idempotency key.\nLeave empty to create a scoped job record without explicit refs.", - "scopeHint": "When reconciling with wallet transactions in pending_reconcile status, paste the transfer number or idempotency key above.", - "advancedToggleOpen": "Show advanced options (custom items JSON)", - "advancedToggleClose": "Hide advanced options (custom items JSON)", - "advancedJson": "Items JSON (overrides generated rows from the scope above)", + "scope": "Targets (optional)", + "scopePlaceholder": "One per line: player ID, transfer number, or main-site transaction number.\nLeave empty to check abnormal transfers in the selected period.", "createTask": "Create reconcile job", "submitting": "Submitting…", "loadFailed": "Failed to load", @@ -19,7 +15,6 @@ "periodRequired": "Enter both reconcile start and end time", "periodInvalid": "Invalid time range", "periodOrderInvalid": "End time must be later than or equal to start time", - "advancedJsonInvalid": "The advanced JSON cannot be parsed", "createSuccess": "Reconcile job created", "createFailed": "Failed to create job", "noCreatePermission": "Current account cannot create reconcile jobs.", diff --git a/src/i18n/locales/en/settlement.json b/src/i18n/locales/en/settlement.json index e6cb5e1..bd74b2f 100644 --- a/src/i18n/locales/en/settlement.json +++ b/src/i18n/locales/en/settlement.json @@ -5,15 +5,19 @@ "status": "Status", "apply": "Apply", "batchList": "Settlement batches", + "allStatuses": "All", "loadFailed": "Failed to load", "exportFailed": "Export failed", "actionSuccess": "{{name}} succeeded", "actionFailed": "{{name}} failed", "placeholderDrawNo": "For example 20260511-001", "reviewStatus": "Review status", + "totalBet": "Total bet", + "actualDeduct": "Actual deduct", "ticketCount": "Ticket count", "winCount": "Win count", "payoutTotal": "Total payout", + "platformProfit": "Profit/Loss", "jackpot": "Jackpot", "finishedAt": "Finished at", "details": "Details", @@ -33,10 +37,19 @@ "winTotal": "Win count", "payoutAmount": "Payout total", "jackpotPayout": "Jackpot payout", + "profitFormula": "Profit/Loss = actual deduct - total payout", "startedAt": "Started", "endedAt": "Ended", "runPayout": "Run payout", "exportSettlementReport": "Export settlement report", + "confirmAction": "Confirm {{name}}", + "confirmActionDesc": "Confirm settlement amounts and status for draw {{drawNo}}.", + "confirmPayoutDesc": "Running payout writes winning amounts to player lottery wallets and cannot be directly undone. Confirm result and settlement amounts first.", + "confirmAmountLine": "Actual deduct {{actual}}, payout {{payout}}, profit/loss {{profit}}.", + "reviewRemark": "Review remark", + "reviewRemarkPlaceholder": "Optional review note", + "cancel": "Cancel", + "confirm": "Confirm", "loadingSummary": "Loading summary…", "detailTitle": "Settlement details", "ticketNo": "Ticket no.", @@ -48,6 +61,10 @@ "statusOptions": { "all": "All", "running": "Running", + "pending_review": "Pending review", + "approved": "Approved", + "rejected": "Rejected", + "paid": "Paid", "completed": "Completed", "failed": "Failed" } diff --git a/src/i18n/locales/ne/reconcile.json b/src/i18n/locales/ne/reconcile.json index a2aad01..56d56a3 100644 --- a/src/i18n/locales/ne/reconcile.json +++ b/src/i18n/locales/ne/reconcile.json @@ -1,17 +1,13 @@ { "title": "मिलान", "createTitle": "म्यानुअल मिलान कार्य", - "createDesc": "असामान्य फ्लोहरू scheduled task ले स्वतः जाँच्छ। यहाँ वित्तले म्यानुअल रूपमा मिलान कार्य सुरु गर्न सक्छ: प्रकार र समय सीमा छान्नुहोस्, अनि आवश्यक परे player ID, transfer no, वा idempotency key जस्ता सन्दर्भहरू प्रति लाइन लेख्नुहोस्।", + "createDesc": "समय अवधि वा छानिएका सन्दर्भहरूबाट असामान्य ट्रान्सफर म्यानुअल रूपमा जाँच गर्नुहोस्। scheduled reconciliation स्वतः चलिरहन्छ।", "reconcileType": "मिलान प्रकार", "walletTransfer": "वालेट ट्रान्सफर (मुख्य साइट ⇄ लटरी)", "startTime": "सुरु समय", "endTime": "अन्त्य समय", - "scope": "दायरा (वैकल्पिक)", - "scopePlaceholder": "प्रति लाइन एउटा सन्दर्भ, जस्तै player ID, wallet transfer no, वा idempotency key.\nखाली छोडेमा केवल कार्य रेकर्ड सिर्जना हुन्छ।", - "scopeHint": "pending_reconcile स्थितिको वालेट कारोबारसँग मिलान गर्दा transfer no वा idempotency key माथि टाँस्नुहोस्।", - "advancedToggleOpen": "उन्नत विकल्प देखाउनुहोस् (custom items JSON)", - "advancedToggleClose": "उन्नत विकल्प लुकाउनुहोस् (custom items JSON)", - "advancedJson": "Items JSON (माथिको दायराबाट बनेका row हरूलाई override गर्छ)", + "scope": "लक्षित सन्दर्भ (वैकल्पिक)", + "scopePlaceholder": "प्रति लाइन एउटा: player ID, transfer no, वा main-site transaction no.\nखाली छोडेमा चयन गरिएको अवधिका असामान्य ट्रान्सफर जाँच हुन्छ।", "createTask": "मिलान कार्य सिर्जना", "submitting": "पेश हुँदैछ…", "loadFailed": "लोड असफल भयो", @@ -19,7 +15,6 @@ "periodRequired": "सुरु र अन्त्य समय दुवै लेख्नुहोस्", "periodInvalid": "अवैध समय दायरा", "periodOrderInvalid": "अन्त्य समय सुरु समयभन्दा पछाडि वा बराबर हुनुपर्छ", - "advancedJsonInvalid": "उन्नत JSON parse गर्न सकिएन", "createSuccess": "मिलान कार्य सिर्जना भयो", "createFailed": "कार्य सिर्जना असफल भयो", "noCreatePermission": "हालको खातासँग मिलान कार्य सिर्जना गर्ने अनुमति छैन।", diff --git a/src/i18n/locales/ne/settlement.json b/src/i18n/locales/ne/settlement.json index 6df0923..4fa031b 100644 --- a/src/i18n/locales/ne/settlement.json +++ b/src/i18n/locales/ne/settlement.json @@ -5,15 +5,19 @@ "status": "स्थिति", "apply": "लागू गर्नुहोस्", "batchList": "सेटलमेन्ट ब्याच", + "allStatuses": "सबै", "loadFailed": "लोड असफल भयो", "exportFailed": "निर्यात असफल भयो", "actionSuccess": "{{name}} सफल भयो", "actionFailed": "{{name}} असफल भयो", "placeholderDrawNo": "जस्तै 20260511-001", "reviewStatus": "समीक्षा स्थिति", + "totalBet": "कुल बेट", + "actualDeduct": "वास्तविक कटौती", "ticketCount": "टिकट संख्या", "winCount": "जित संख्या", "payoutTotal": "कुल भुक्तानी", + "platformProfit": "नाफा/घाटा", "jackpot": "Jackpot", "finishedAt": "समाप्त समय", "details": "विवरण", @@ -33,10 +37,19 @@ "winTotal": "जित संख्या", "payoutAmount": "कुल भुक्तानी", "jackpotPayout": "Jackpot भुक्तानी", + "profitFormula": "नाफा/घाटा = वास्तविक कटौती - कुल भुक्तानी", "startedAt": "सुरु", "endedAt": "समाप्त", "runPayout": "भुक्तानी चलाउनुहोस्", "exportSettlementReport": "सेटलमेन्ट रिपोर्ट निर्यात", + "confirmAction": "{{name}} पुष्टि गर्नुहोस्", + "confirmActionDesc": "ड्रअ {{drawNo}} को रकम र स्थिति पुष्टि गर्नुहोस्।", + "confirmPayoutDesc": "भुक्तानी चलाउँदा जित रकम खेलाडीको लटरी वालेटमा लेखिन्छ र सीधै उल्ट्याउन सकिँदैन। पहिले नतिजा र रकम पुष्टि गर्नुहोस्।", + "confirmAmountLine": "वास्तविक कटौती {{actual}}, भुक्तानी {{payout}}, नाफा/घाटा {{profit}}।", + "reviewRemark": "समीक्षा टिप्पणी", + "reviewRemarkPlaceholder": "वैकल्पिक समीक्षा टिप्पणी", + "cancel": "रद्द", + "confirm": "पुष्टि", "loadingSummary": "सारांश लोड हुँदैछ…", "detailTitle": "सेटलमेन्ट विवरण", "ticketNo": "टिकट नं.", @@ -48,6 +61,10 @@ "statusOptions": { "all": "सबै", "running": "चलिरहेको", + "pending_review": "समीक्षा बाँकी", + "approved": "स्वीकृत", + "rejected": "अस्वीकृत", + "paid": "भुक्तानी भयो", "completed": "सम्पन्न", "failed": "असफल" } diff --git a/src/i18n/locales/zh/draws.json b/src/i18n/locales/zh/draws.json index 9f871c7..bbc63b9 100644 --- a/src/i18n/locales/zh/draws.json +++ b/src/i18n/locales/zh/draws.json @@ -107,7 +107,11 @@ "status": "期号状态", "results": "开奖结果", "finance": "期号收支", - "review": "审核与发布" + "review": "审核与发布", + "riskOccupancy": "风控占用", + "riskHot": "热门号码", + "riskSoldOut": "售罄号码", + "riskPools": "风险池" }, "statusOptions": { "all": "不限", diff --git a/src/i18n/locales/zh/reconcile.json b/src/i18n/locales/zh/reconcile.json index ab1ba13..44e16a1 100644 --- a/src/i18n/locales/zh/reconcile.json +++ b/src/i18n/locales/zh/reconcile.json @@ -1,17 +1,13 @@ { "title": "对账", "createTitle": "人工发起对账", - "createDesc": "异常流水由定时任务自动核对。此处供财务按产品文档手动触发:选择对账类型与时间范围;可选填写待核对对象(玩家标识、划转单号或幂等键,每行一条)。任务与明细落库留痕,后续可接自动差异引擎。", + "createDesc": "用于按时间段或指定单据人工核对异常转账。系统定时对账仍会自动执行。", "reconcileType": "对账类型", "walletTransfer": "钱包划转(主站 ⇄ 彩票)", "startTime": "对账开始时间", "endTime": "对账结束时间", - "scope": "限定范围(可选)", - "scopePlaceholder": "每行一条待核对引用,例如:玩家 ID、钱包划转单号、幂等键等。\n留空表示本时间段内不额外指定单据(仅任务留痕)。", - "scopeHint": "与「钱包流水」中待对账(pending_reconcile)流水对照使用时,可将单号或幂等键粘贴至上方。", - "advancedToggleOpen": "展开高级选项(自定义明细 JSON)", - "advancedToggleClose": "收起高级选项(自定义明细 JSON)", - "advancedJson": "明细 JSON(将覆盖上方「限定范围」生成的行)", + "scope": "指定对象(可选)", + "scopePlaceholder": "每行一条:玩家 ID、划转单号或主站流水号。\n留空则核对所选时间段内的异常转账。", "createTask": "创建对账任务", "submitting": "提交中…", "loadFailed": "加载失败", @@ -19,7 +15,6 @@ "periodRequired": "请填写对账时间范围(开始与结束)", "periodInvalid": "时间无效,请检查所选日期与时间", "periodOrderInvalid": "结束时间需晚于或等于开始时间", - "advancedJsonInvalid": "高级选项中的 JSON 无法解析", "createSuccess": "已创建对账任务", "createFailed": "创建失败", "noCreatePermission": "当前账号无新建对账任务权限。", diff --git a/src/i18n/locales/zh/settlement.json b/src/i18n/locales/zh/settlement.json index 6f26a4e..2753e23 100644 --- a/src/i18n/locales/zh/settlement.json +++ b/src/i18n/locales/zh/settlement.json @@ -5,15 +5,19 @@ "status": "状态", "apply": "应用", "batchList": "结算批次", + "allStatuses": "不限", "loadFailed": "加载失败", "exportFailed": "导出失败", "actionSuccess": "{{name}}成功", "actionFailed": "{{name}}失败", "placeholderDrawNo": "如 20260511-001", "reviewStatus": "审核状态", + "totalBet": "总下注", + "actualDeduct": "总实扣", "ticketCount": "注单数", "winCount": "中奖笔数", "payoutTotal": "派彩合计", + "platformProfit": "盈亏", "jackpot": "Jackpot", "finishedAt": "完成时间", "details": "明细", @@ -33,10 +37,19 @@ "winTotal": "中奖笔数", "payoutAmount": "派彩合计", "jackpotPayout": "Jackpot 划出", + "profitFormula": "盈亏 = 总实扣 - 总派彩", "startedAt": "开始", "endedAt": "结束", "runPayout": "执行派彩", "exportSettlementReport": "导出结算报表", + "confirmAction": "确认{{name}}", + "confirmActionDesc": "请确认期号 {{drawNo}} 的结算金额和状态无误。", + "confirmPayoutDesc": "执行派彩后会把中奖金额写入玩家彩票钱包,不能直接撤回。请确认开奖结果和结算金额无误。", + "confirmAmountLine": "总实扣 {{actual}},总派彩 {{payout}},平台盈亏 {{profit}}。", + "reviewRemark": "审核备注", + "reviewRemarkPlaceholder": "可填写审核意见,非必填", + "cancel": "取消", + "confirm": "确认", "loadingSummary": "加载摘要…", "detailTitle": "注单结算明细", "ticketNo": "注单号", @@ -48,6 +61,10 @@ "statusOptions": { "all": "不限", "running": "进行中", + "pending_review": "待审核", + "approved": "已审核", + "rejected": "已驳回", + "paid": "已派奖", "completed": "已完成", "failed": "失败" } diff --git a/src/modules/admin-roles/admin-roles-console.tsx b/src/modules/admin-roles/admin-roles-console.tsx index a761587..70852c4 100644 --- a/src/modules/admin-roles/admin-roles-console.tsx +++ b/src/modules/admin-roles/admin-roles-console.tsx @@ -14,6 +14,7 @@ import { putAdminRolePermissions, } from "@/api/admin-users"; import { Badge } from "@/components/ui/badge"; +import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; @@ -268,9 +269,16 @@ export function AdminRolesConsole(): React.ReactElement { {t("createRole")} - +
+ + +
{err ?

{err}

: null} @@ -278,7 +286,7 @@ export function AdminRolesConsole(): React.ReactElement {

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

) : null}
- +
ID diff --git a/src/modules/admin-users/admin-users-console.tsx b/src/modules/admin-users/admin-users-console.tsx index 25c6451..85393fa 100644 --- a/src/modules/admin-users/admin-users-console.tsx +++ b/src/modules/admin-users/admin-users-console.tsx @@ -13,6 +13,7 @@ import { putAdminUserRoles, } from "@/api/admin-users"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; +import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -25,6 +26,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Table, TableBody, @@ -296,47 +298,61 @@ export function AdminUsersConsole(): React.ReactElement { return (
- - + +
- {t("listTitle")} + {t("listTitle")}
-
- setKeyword(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { +
+
+ + setKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setPage(1); + setQuery(keyword.trim()); + } + }} + /> +
+
+ + - + }} + > + {t("actions.search", { ns: "common" })} + + +
- + {err ?

{err}

: null} {loading && items.length === 0 ? (

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

) : null} -
-
+
+
ID diff --git a/src/modules/audit/audit-logs-console.tsx b/src/modules/audit/audit-logs-console.tsx index 4d6f8e1..2454ed6 100644 --- a/src/modules/audit/audit-logs-console.tsx +++ b/src/modules/audit/audit-logs-console.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { getAdminAuditLogs } from "@/api/admin-audit"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; +import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -66,44 +67,47 @@ export function AuditLogsConsole(): React.ReactElement { return ( - +
{t("title")}
- -
- -
-
- +
+
+ setModuleCode(e.target.value)} placeholder={t("exactMatch")} + className="w-full sm:w-40" />
-
- +
+ setActionCode(e.target.value)} placeholder={t("exactMatch")} + className="w-full sm:w-40" />
-
- +
+ setOperatorType(e.target.value)} placeholder={t("operatorTypePlaceholder")} + className="w-full sm:w-40" />
-
+
+ +
- + + {err ?

{err}

: null} {loading && !data ? (

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

@@ -126,7 +134,7 @@ export function AuditLogsConsole(): React.ReactElement { {data ? ( <>
-
+
ID diff --git a/src/modules/dashboard/dashboard-console.tsx b/src/modules/dashboard/dashboard-console.tsx index b3e789e..62d020d 100644 --- a/src/modules/dashboard/dashboard-console.tsx +++ b/src/modules/dashboard/dashboard-console.tsx @@ -546,7 +546,7 @@ export function DashboardConsole(): ReactElement { buttonVariants({ variant: "outline", size: "sm" }), "mt-1 h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5", )} - href={`/admin/risk/draws/${drawId}/occupancy`} + href={`/admin/draws/${drawId}/risk/occupancy`} > {t("occupancyDetails")} @@ -592,7 +592,7 @@ export function DashboardConsole(): ReactElement { {drawId != null ? ( {drawId != null ? ( string): string { + const key = `statusOptions.${status}`; + const translated = t(key); + return translated === key ? status : translated; +} + function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -114,12 +120,16 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) { {data.draw_no}

{t("drawDetail")}

-
- +
+

+ {t("hallPreviewStatus", { + status: drawStatusText(data.hall_preview_status, t), + })} +

diff --git a/src/modules/draws/draw-finance-console.tsx b/src/modules/draws/draw-finance-console.tsx index 925abca..ad15db5 100644 --- a/src/modules/draws/draw-finance-console.tsx +++ b/src/modules/draws/draw-finance-console.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import { getAdminDrawFinanceSummary } from "@/api/admin-draws"; import { postAdminRunDrawSettlement } from "@/api/admin-settlement"; import { Button, buttonVariants } from "@/components/ui/button"; +import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, @@ -156,7 +157,14 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE

{t("noSettlementBatches")}

) : (
-
+
+ +
+
ID diff --git a/src/modules/draws/draw-subnav.tsx b/src/modules/draws/draw-subnav.tsx index 705f433..c167031 100644 --- a/src/modules/draws/draw-subnav.tsx +++ b/src/modules/draws/draw-subnav.tsx @@ -12,6 +12,10 @@ const segments = [ { suffix: "/results", key: "results", label: "subnav.results" }, { suffix: "/finance", key: "finance", label: "subnav.finance" }, { suffix: "/review", key: "review", label: "subnav.review" }, + { suffix: "/risk/occupancy", key: "riskOccupancy", label: "subnav.riskOccupancy" }, + { suffix: "/risk/hot", key: "riskHot", label: "subnav.riskHot" }, + { suffix: "/risk/sold-out", key: "riskSoldOut", label: "subnav.riskSoldOut" }, + { suffix: "/risk/pools", key: "riskPools", label: "subnav.riskPools" }, ] as const; function isReviewTabActive(pathname: string, base: string): boolean { diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx index 99f618e..2614100 100644 --- a/src/modules/draws/draws-index-console.tsx +++ b/src/modules/draws/draws-index-console.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { getAdminDraws, postAdminGenerateDrawPlan } from "@/api/admin-draws"; import { Button, buttonVariants } from "@/components/ui/button"; +import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { Input } from "@/components/ui/input"; @@ -140,60 +141,66 @@ export function DrawsIndexConsole() { }, [load]); return ( - - - {t("statusListTitle")} + + + {t("statusListTitle")} {canManageDraw ? ( ) : null} - - {/* Grid:桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */} -
- - setDraftDrawNo(e.target.value)} - /> - -
- + +
+
+ + setDraftDrawNo(e.target.value)} + />
-
+
+ +
+ +
+
+
+
+
+
{t("drawNo")} diff --git a/src/modules/jackpot/jackpot-records-console.tsx b/src/modules/jackpot/jackpot-records-console.tsx index acc99df..b8d06af 100644 --- a/src/modules/jackpot/jackpot-records-console.tsx +++ b/src/modules/jackpot/jackpot-records-console.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; +import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -127,7 +128,15 @@ export function JackpotRecordsConsole() { {loadingP && !payouts ? (

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

) : ( -
+ <> +
+ +
+
ID @@ -155,6 +164,7 @@ export function JackpotRecordsConsole() { ))}
+ )} {payouts ? ( {t("states.loading", { ns: "common" })}

) : ( - + <> +
+ +
+
ID @@ -212,6 +230,7 @@ export function JackpotRecordsConsole() { ))}
+ )} {contribs ? ( - - + +
- {t("listTitle")} + {t("listTitle")} {canManagePlayers ? ( ) : null}
-
- setKeyword(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { +
+
+ + setKeyword(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setPage(1); + setQuery(keyword.trim()); + } + }} + /> +
+
+ + - + }} + > + {t("search")} + + +
- + {err ?

{err}

: null} {loading && items.length === 0 ? (

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

) : null} -
- +
+
ID diff --git a/src/modules/reconcile/reconcile-console.tsx b/src/modules/reconcile/reconcile-console.tsx index a7a7d2d..192b836 100644 --- a/src/modules/reconcile/reconcile-console.tsx +++ b/src/modules/reconcile/reconcile-console.tsx @@ -128,8 +128,6 @@ export function ReconcileConsole(): React.ReactElement { const [periodStartLocal, setPeriodStartLocal] = useState(""); const [periodEndLocal, setPeriodEndLocal] = useState(""); const [scopeLines, setScopeLines] = useState(""); - const [showAdvanced, setShowAdvanced] = useState(false); - const [itemsJson, setItemsJson] = useState("[]"); const [submitting, setSubmitting] = useState(false); const loadJobs = useCallback(async () => { @@ -194,25 +192,7 @@ export function ReconcileConsole(): React.ReactElement { return; } - let itemsPayload: Parameters[0]["items"]; - - if (showAdvanced) { - const trimmed = itemsJson.trim(); - if (trimmed !== "" && trimmed !== "[]") { - try { - itemsPayload = JSON.parse(trimmed) as NonNullable< - Parameters[0]["items"] - >; - } catch { - toast.error(t("advancedJsonInvalid")); - return; - } - } - } - - if (itemsPayload === undefined) { - itemsPayload = scopeLinesToItems(scopeLines); - } + const itemsPayload = scopeLinesToItems(scopeLines); setSubmitting(true); try { @@ -225,9 +205,6 @@ export function ReconcileConsole(): React.ReactElement { toast.success(t("createSuccess")); setPage(1); setScopeLines(""); - if (showAdvanced) { - setItemsJson("[]"); - } await loadJobs(); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("createFailed")); @@ -240,17 +217,16 @@ export function ReconcileConsole(): React.ReactElement { const im = items?.meta; return ( -
+
{canCreate ? ( - - - {t("createTitle")} - - {t("createDesc")} - + + + {t("createTitle")} + {t("createDesc")} - -
+ +
+
-
-
+
setPeriodEndLocal(e.target.value)} />
+
@@ -299,66 +277,36 @@ export function ReconcileConsole(): React.ReactElement { id="rc-scope" value={scopeLines} onChange={(e) => setScopeLines(e.target.value)} - rows={5} + rows={3} placeholder={t("scopePlaceholder")} - className="min-h-[100px] text-sm" + className="min-h-20 text-sm" /> -

- {t("scopeHint")} -

-
- - {showAdvanced ? ( -
- -