From 49a4caf01e89deb740081f5f4dc63b716d793789 Mon Sep 17 00:00:00 2001
From: kang
Date: Mon, 18 May 2026 15:08:34 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=AE=A1=E7=90=86?=
=?UTF-8?q?=E7=AB=AF=E5=A4=9A=E8=AF=AD=E8=A8=80=E4=B8=8E=E9=A3=8E=E6=8E=A7?=
=?UTF-8?q?/=E6=8A=A5=E8=A1=A8/=E5=A5=96=E6=B1=A0=E6=93=8D=E4=BD=9C?=
=?UTF-8?q?=E8=83=BD=E5=8A=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package-lock.json | 84 ++++++++
package.json | 3 +
src/api/admin-jackpot.ts | 8 +
src/api/admin-reports.ts | 16 +-
src/api/admin-risk.ts | 25 +++
.../admin/admin-language-switcher.tsx | 73 +++++++
src/components/admin/toolbar.tsx | 60 +-----
src/components/providers.tsx | 1 +
src/i18n/index.ts | 98 +++++++++
src/i18n/locales/en/audit.json | 3 +
src/i18n/locales/en/auth.json | 3 +
src/i18n/locales/en/common.json | 20 ++
src/i18n/locales/en/dashboard.json | 3 +
src/i18n/locales/en/reports.json | 3 +
src/i18n/locales/ne/audit.json | 3 +
src/i18n/locales/ne/auth.json | 3 +
src/i18n/locales/ne/common.json | 20 ++
src/i18n/locales/ne/dashboard.json | 3 +
src/i18n/locales/ne/reports.json | 3 +
src/i18n/locales/zh/audit.json | 3 +
src/i18n/locales/zh/auth.json | 3 +
src/i18n/locales/zh/common.json | 20 ++
src/i18n/locales/zh/dashboard.json | 3 +
src/i18n/locales/zh/reports.json | 3 +
.../config/doc/odds-config-doc-screen.tsx | 83 +++++++-
.../config/doc/play-config-doc-screen.tsx | 107 ++++++++++
.../config/doc/risk-cap-doc-screen.tsx | 57 ++++--
src/modules/jackpot/jackpot-pools-console.tsx | 85 +++++++-
src/modules/reports/reports-console.tsx | 43 +++-
src/modules/risk/risk-pools-console.tsx | 193 ++++++++++++++----
src/types/api/admin-jackpot.ts | 1 +
31 files changed, 918 insertions(+), 115 deletions(-)
create mode 100644 src/components/admin/admin-language-switcher.tsx
create mode 100644 src/i18n/index.ts
create mode 100644 src/i18n/locales/en/audit.json
create mode 100644 src/i18n/locales/en/auth.json
create mode 100644 src/i18n/locales/en/common.json
create mode 100644 src/i18n/locales/en/dashboard.json
create mode 100644 src/i18n/locales/en/reports.json
create mode 100644 src/i18n/locales/ne/audit.json
create mode 100644 src/i18n/locales/ne/auth.json
create mode 100644 src/i18n/locales/ne/common.json
create mode 100644 src/i18n/locales/ne/dashboard.json
create mode 100644 src/i18n/locales/ne/reports.json
create mode 100644 src/i18n/locales/zh/audit.json
create mode 100644 src/i18n/locales/zh/auth.json
create mode 100644 src/i18n/locales/zh/common.json
create mode 100644 src/i18n/locales/zh/dashboard.json
create mode 100644 src/i18n/locales/zh/reports.json
diff --git a/package-lock.json b/package-lock.json
index d99649b..f7c574a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,12 +13,15 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "i18next": "^26.2.0",
+ "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.14.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-day-picker": "^10.0.0",
"react-dom": "19.2.4",
+ "react-i18next": "^17.0.8",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
@@ -5828,6 +5831,15 @@
"node": ">=16.9.0"
}
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://mirrors.cloud.tencent.com/npm/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
@@ -5870,6 +5882,43 @@
"node": ">=18.18.0"
}
},
+ "node_modules/i18next": {
+ "version": "26.2.0",
+ "resolved": "https://mirrors.cloud.tencent.com/npm/i18next/-/i18next-26.2.0.tgz",
+ "integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/i18next-browser-languagedetector": {
+ "version": "8.2.1",
+ "resolved": "https://mirrors.cloud.tencent.com/npm/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
+ "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -8211,6 +8260,32 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.8",
+ "resolved": "https://mirrors.cloud.tencent.com/npm/react-i18next/-/react-i18next-17.0.8.tgz",
+ "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.2.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
@@ -9804,6 +9879,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://mirrors.cloud.tencent.com/npm/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
diff --git a/package.json b/package.json
index ec00b11..7e1ea2d 100644
--- a/package.json
+++ b/package.json
@@ -14,12 +14,15 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "i18next": "^26.2.0",
+ "i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.14.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-day-picker": "^10.0.0",
"react-dom": "19.2.4",
+ "react-i18next": "^17.0.8",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
diff --git a/src/api/admin-jackpot.ts b/src/api/admin-jackpot.ts
index 98e7d8d..678c529 100644
--- a/src/api/admin-jackpot.ts
+++ b/src/api/admin-jackpot.ts
@@ -22,6 +22,7 @@ export type AdminJackpotPoolUpdateBody = Partial<{
payout_rate: number;
force_trigger_draw_gap: number;
min_bet_amount: number;
+ combo_trigger_play_codes: string[];
status: number;
}>;
@@ -32,6 +33,13 @@ export async function putAdminJackpotPool(
return adminRequest.put(`${A}/jackpot/pools/${poolId}`, body);
}
+export async function postAdminJackpotManualBurst(
+ poolId: number,
+ body: { draw_id: number; amount?: number },
+): Promise<{ current_amount: number; burst_amount: number; log_id: number | null }> {
+ return adminRequest.post(`${A}/jackpot/pools/${poolId}/manual-burst`, body);
+}
+
export type AdminJackpotLogsQuery = {
page?: number;
per_page?: number;
diff --git a/src/api/admin-reports.ts b/src/api/admin-reports.ts
index 5ae66a0..325f9d0 100644
--- a/src/api/admin-reports.ts
+++ b/src/api/admin-reports.ts
@@ -1,4 +1,6 @@
-import { adminRequest } from "@/lib/admin-http";
+import { adminHttp, adminRequest } from "@/lib/admin-http";
+import { withAdminAuthHeader } from "@/lib/admin-auth";
+import { withAdminLocaleHeaders } from "@/lib/admin-locale";
import { API_V1_PREFIX } from "./paths";
@@ -21,6 +23,7 @@ export async function getAdminReportJobs(params?: {
export async function postAdminReportJob(body: {
report_type: string;
export_format?: "csv" | "xlsx";
+ parameters?: Record | null;
filter_json?: Record | null;
}): Promise {
return adminRequest.post(
@@ -28,3 +31,14 @@ export async function postAdminReportJob(body: {
body,
);
}
+
+export async function downloadAdminReportJob(jobId: number): Promise {
+ const res = await adminHttp.request(
+ withAdminAuthHeader(withAdminLocaleHeaders({
+ url: `${A}/report-jobs/${jobId}/download`,
+ method: "GET",
+ responseType: "blob",
+ })),
+ );
+ return res.data;
+}
diff --git a/src/api/admin-risk.ts b/src/api/admin-risk.ts
index 7659c80..5afb391 100644
--- a/src/api/admin-risk.ts
+++ b/src/api/admin-risk.ts
@@ -5,6 +5,7 @@ import { API_V1_PREFIX } from "./paths";
import type {
AdminRiskLockLogListData,
AdminRiskPoolListData,
+ AdminRiskPoolRow,
AdminRiskPoolShowData,
} from "@/types/api/admin-risk";
@@ -14,6 +15,8 @@ export type AdminRiskPoolListQuery = {
page?: number;
per_page?: number;
sold_out_only?: boolean;
+ high_risk_only?: boolean;
+ normalized_number?: string;
sort?: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
};
@@ -26,11 +29,33 @@ export async function getAdminRiskPools(
page: q.page,
per_page: q.per_page,
sold_out_only: q.sold_out_only === true ? 1 : undefined,
+ high_risk_only: q.high_risk_only === true ? 1 : undefined,
+ normalized_number: q.normalized_number || undefined,
sort: q.sort,
},
});
}
+export async function postAdminRiskPoolManualClose(
+ drawId: number,
+ number4d: string,
+): Promise {
+ const encoded = encodeURIComponent(number4d);
+ return adminRequest.post(
+ `${A}/draws/${drawId}/risk-pools/${encoded}/manual-close`,
+ );
+}
+
+export async function postAdminRiskPoolRecover(
+ drawId: number,
+ number4d: string,
+): Promise {
+ const encoded = encodeURIComponent(number4d);
+ return adminRequest.post(
+ `${A}/draws/${drawId}/risk-pools/${encoded}/recover`,
+ );
+}
+
export type AdminRiskLockLogQuery = {
page?: number;
per_page?: number;
diff --git a/src/components/admin/admin-language-switcher.tsx b/src/components/admin/admin-language-switcher.tsx
new file mode 100644
index 0000000..244a09e
--- /dev/null
+++ b/src/components/admin/admin-language-switcher.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { CheckIcon, GlobeIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { toast } from "sonner";
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ ADMIN_API_LOCALES,
+ ADMIN_LOCALE_LABELS,
+ applyAdminUiLocale,
+ getAdminRequestLocale,
+ type AdminApiLocale,
+} from "@/lib/admin-locale";
+
+export function AdminLanguageSwitcher() {
+ const { i18n, t } = useTranslation("common");
+ const [locale, setLocale] = useState(() =>
+ typeof document !== "undefined" ? getAdminRequestLocale() : "en",
+ );
+
+ useEffect(() => {
+ queueMicrotask(() => {
+ setLocale(getAdminRequestLocale());
+ });
+ }, []);
+
+ async function onSelectLocale(next: AdminApiLocale) {
+ applyAdminUiLocale(next);
+ await i18n.changeLanguage(next);
+ setLocale(next);
+ toast.success(`${t("language.changed", { defaultValue: "语言已切换" })}: ${ADMIN_LOCALE_LABELS[next]}`);
+ }
+
+ return (
+
+
+
+ {locale}
+
+
+
+
+ {t("language.title", { defaultValue: "界面语言" })}
+
+ {ADMIN_API_LOCALES.map((code) => (
+ void onSelectLocale(code)}
+ >
+ {locale === code ? (
+
+ ) : (
+
+ )}
+ {ADMIN_LOCALE_LABELS[code]}
+ {code}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/admin/toolbar.tsx b/src/components/admin/toolbar.tsx
index c62fd3f..080a6ce 100644
--- a/src/components/admin/toolbar.tsx
+++ b/src/components/admin/toolbar.tsx
@@ -2,16 +2,14 @@
import {
BellIcon,
- CheckIcon,
ChevronDownIcon,
- GlobeIcon,
LogOutIcon,
UserRoundIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
-import { useEffect, useState } from "react";
import { toast } from "sonner";
+import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
@@ -24,13 +22,6 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
-import {
- ADMIN_API_LOCALES,
- ADMIN_LOCALE_LABELS,
- applyAdminUiLocale,
- getAdminRequestLocale,
- type AdminApiLocale,
-} from "@/lib/admin-locale";
import {
useAdminProfile,
useAdminSessionStore,
@@ -76,17 +67,6 @@ export function ShellToolbar() {
const router = useRouter();
const adminProfile = useAdminProfile();
const clearSession = useAdminSessionStore((s) => s.clearSession);
- const [locale, setLocale] = useState(() =>
- typeof document !== "undefined"
- ? getAdminRequestLocale()
- : "zh",
- );
-
- useEffect(() => {
- queueMicrotask(() => {
- setLocale(getAdminRequestLocale());
- });
- }, []);
const displayName =
adminProfile?.nickname?.trim() ||
@@ -100,13 +80,6 @@ export function ShellToolbar() {
router.refresh();
}
- function onSelectLocale(next: AdminApiLocale) {
- applyAdminUiLocale(next);
- setLocale(next);
- toast.success(`语言:${ADMIN_LOCALE_LABELS[next]}`);
- router.refresh();
- }
-
return (
-
-
-
- 语言
-
-
-
-
- 界面语言 / Language
-
- {ADMIN_API_LOCALES.map((code) => (
- onSelectLocale(code)}
- >
- {locale === code ? (
-
- ) : (
-
- )}
- {ADMIN_LOCALE_LABELS[code]}
-
- {code}
-
-
- ))}
-
-
-
+
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
index 7d9841b..c327b1a 100644
--- a/src/components/providers.tsx
+++ b/src/components/providers.tsx
@@ -3,6 +3,7 @@
import type { ReactNode } from "react";
import { useEffect } from "react";
import { ThemeProvider } from "next-themes";
+import "@/i18n";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 0000000..053b0a3
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,98 @@
+"use client";
+
+import i18n from "i18next";
+import LanguageDetector from "i18next-browser-languagedetector";
+import { initReactI18next } from "react-i18next";
+
+import { adminHtmlLang, applyAdminUiLocale, type AdminApiLocale } from "@/lib/admin-locale";
+import enAudit from "@/i18n/locales/en/audit.json";
+import enAuth from "@/i18n/locales/en/auth.json";
+import enCommon from "@/i18n/locales/en/common.json";
+import enDashboard from "@/i18n/locales/en/dashboard.json";
+import enReports from "@/i18n/locales/en/reports.json";
+import neAudit from "@/i18n/locales/ne/audit.json";
+import neAuth from "@/i18n/locales/ne/auth.json";
+import neCommon from "@/i18n/locales/ne/common.json";
+import neDashboard from "@/i18n/locales/ne/dashboard.json";
+import neReports from "@/i18n/locales/ne/reports.json";
+import zhAudit from "@/i18n/locales/zh/audit.json";
+import zhAuth from "@/i18n/locales/zh/auth.json";
+import zhCommon from "@/i18n/locales/zh/common.json";
+import zhDashboard from "@/i18n/locales/zh/dashboard.json";
+import zhReports from "@/i18n/locales/zh/reports.json";
+
+export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const;
+export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number];
+export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "en";
+
+const namespaces = ["common", "auth", "dashboard", "reports", "audit"] as const;
+
+const resources = {
+ en: {
+ common: enCommon,
+ auth: enAuth,
+ dashboard: enDashboard,
+ reports: enReports,
+ audit: enAudit,
+ },
+ ne: {
+ common: neCommon,
+ auth: neAuth,
+ dashboard: neDashboard,
+ reports: neReports,
+ audit: neAudit,
+ },
+ zh: {
+ common: zhCommon,
+ auth: zhAuth,
+ dashboard: zhDashboard,
+ reports: zhReports,
+ audit: zhAudit,
+ },
+} satisfies Record
>>;
+
+function normalizeAdminLanguage(lang: string | undefined): AdminLanguage {
+ const base = lang?.split("-")[0]?.toLowerCase();
+ if (base === "ne") return "ne";
+ if (base === "zh") return "zh";
+ return "en";
+}
+
+function syncAdminLanguage(lang: AdminLanguage): void {
+ if (typeof document !== "undefined") {
+ document.documentElement.lang = adminHtmlLang(lang);
+ }
+ applyAdminUiLocale(lang as AdminApiLocale);
+}
+
+if (!i18n.isInitialized) {
+ void i18n
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ resources,
+ fallbackLng: ADMIN_DEFAULT_LANGUAGE,
+ supportedLngs: [...ADMIN_SUPPORTED_LANGUAGES],
+ defaultNS: "common",
+ ns: [...namespaces],
+ load: "languageOnly",
+ detection: {
+ order: ["localStorage", "navigator"],
+ caches: ["localStorage"],
+ lookupLocalStorage: "lottery_admin_ui_locale",
+ },
+ interpolation: {
+ escapeValue: false,
+ },
+ react: {
+ useSuspense: false,
+ },
+ });
+
+ syncAdminLanguage(normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language));
+ i18n.on("languageChanged", (lang) => {
+ syncAdminLanguage(normalizeAdminLanguage(lang));
+ });
+}
+
+export default i18n;
diff --git a/src/i18n/locales/en/audit.json b/src/i18n/locales/en/audit.json
new file mode 100644
index 0000000..1e45426
--- /dev/null
+++ b/src/i18n/locales/en/audit.json
@@ -0,0 +1,3 @@
+{
+ "title": "Audit Logs"
+}
diff --git a/src/i18n/locales/en/auth.json b/src/i18n/locales/en/auth.json
new file mode 100644
index 0000000..d3ab0a3
--- /dev/null
+++ b/src/i18n/locales/en/auth.json
@@ -0,0 +1,3 @@
+{
+ "title": "Login"
+}
diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json
new file mode 100644
index 0000000..6093ceb
--- /dev/null
+++ b/src/i18n/locales/en/common.json
@@ -0,0 +1,20 @@
+{
+ "language": {
+ "en": "English",
+ "ne": "नेपाली",
+ "zh": "中文",
+ "title": "Interface language",
+ "changed": "Language"
+ },
+ "app": {
+ "title": "Lottery Admin"
+ },
+ "actions": {
+ "refresh": "Refresh",
+ "download": "Download",
+ "search": "Search",
+ "apply": "Apply",
+ "loading": "Loading...",
+ "submitting": "Submitting..."
+ }
+}
diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json
new file mode 100644
index 0000000..27b0225
--- /dev/null
+++ b/src/i18n/locales/en/dashboard.json
@@ -0,0 +1,3 @@
+{
+ "title": "Dashboard"
+}
diff --git a/src/i18n/locales/en/reports.json b/src/i18n/locales/en/reports.json
new file mode 100644
index 0000000..4f60ac8
--- /dev/null
+++ b/src/i18n/locales/en/reports.json
@@ -0,0 +1,3 @@
+{
+ "title": "Reports"
+}
diff --git a/src/i18n/locales/ne/audit.json b/src/i18n/locales/ne/audit.json
new file mode 100644
index 0000000..856e3fb
--- /dev/null
+++ b/src/i18n/locales/ne/audit.json
@@ -0,0 +1,3 @@
+{
+ "title": "अडिट लग"
+}
diff --git a/src/i18n/locales/ne/auth.json b/src/i18n/locales/ne/auth.json
new file mode 100644
index 0000000..03aaae9
--- /dev/null
+++ b/src/i18n/locales/ne/auth.json
@@ -0,0 +1,3 @@
+{
+ "title": "लगइन"
+}
diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json
new file mode 100644
index 0000000..c0710cb
--- /dev/null
+++ b/src/i18n/locales/ne/common.json
@@ -0,0 +1,20 @@
+{
+ "language": {
+ "en": "English",
+ "ne": "नेपाली",
+ "zh": "中文",
+ "title": "इन्टरफेस भाषा",
+ "changed": "भाषा"
+ },
+ "app": {
+ "title": "Lottery Admin"
+ },
+ "actions": {
+ "refresh": "रिफ्रेस",
+ "download": "डाउनलोड",
+ "search": "खोज",
+ "apply": "लागू गर्नुहोस्",
+ "loading": "लोड हुँदैछ...",
+ "submitting": "पेश हुँदैछ..."
+ }
+}
diff --git a/src/i18n/locales/ne/dashboard.json b/src/i18n/locales/ne/dashboard.json
new file mode 100644
index 0000000..e4481a9
--- /dev/null
+++ b/src/i18n/locales/ne/dashboard.json
@@ -0,0 +1,3 @@
+{
+ "title": "ड्यासबोर्ड"
+}
diff --git a/src/i18n/locales/ne/reports.json b/src/i18n/locales/ne/reports.json
new file mode 100644
index 0000000..603dcec
--- /dev/null
+++ b/src/i18n/locales/ne/reports.json
@@ -0,0 +1,3 @@
+{
+ "title": "रिपोर्ट"
+}
diff --git a/src/i18n/locales/zh/audit.json b/src/i18n/locales/zh/audit.json
new file mode 100644
index 0000000..a747239
--- /dev/null
+++ b/src/i18n/locales/zh/audit.json
@@ -0,0 +1,3 @@
+{
+ "title": "审计日志"
+}
diff --git a/src/i18n/locales/zh/auth.json b/src/i18n/locales/zh/auth.json
new file mode 100644
index 0000000..7100ea4
--- /dev/null
+++ b/src/i18n/locales/zh/auth.json
@@ -0,0 +1,3 @@
+{
+ "title": "登录"
+}
diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json
new file mode 100644
index 0000000..c14be4d
--- /dev/null
+++ b/src/i18n/locales/zh/common.json
@@ -0,0 +1,20 @@
+{
+ "language": {
+ "en": "English",
+ "ne": "नेपाली",
+ "zh": "中文",
+ "title": "界面语言",
+ "changed": "语言"
+ },
+ "app": {
+ "title": "彩票后台"
+ },
+ "actions": {
+ "refresh": "刷新",
+ "download": "下载",
+ "search": "搜索",
+ "apply": "应用",
+ "loading": "加载中...",
+ "submitting": "提交中..."
+ }
+}
diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json
new file mode 100644
index 0000000..d0fd75e
--- /dev/null
+++ b/src/i18n/locales/zh/dashboard.json
@@ -0,0 +1,3 @@
+{
+ "title": "仪表盘"
+}
diff --git a/src/i18n/locales/zh/reports.json b/src/i18n/locales/zh/reports.json
new file mode 100644
index 0000000..cc07ce5
--- /dev/null
+++ b/src/i18n/locales/zh/reports.json
@@ -0,0 +1,3 @@
+{
+ "title": "报表"
+}
diff --git a/src/modules/config/doc/odds-config-doc-screen.tsx b/src/modules/config/doc/odds-config-doc-screen.tsx
index 31d6275..5a02d06 100644
--- a/src/modules/config/doc/odds-config-doc-screen.tsx
+++ b/src/modules/config/doc/odds-config-doc-screen.tsx
@@ -81,6 +81,8 @@ export function OddsConfigDocScreen() {
const [rollbackOpen, setRollbackOpen] = useState(false);
const [rollbackTarget, setRollbackTarget] = useState(null);
+ const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
+ const [activeCompareRows, setActiveCompareRows] = useState([]);
const refreshTypes = useCallback(async () => {
setLoadingTypes(true);
@@ -281,6 +283,24 @@ export function OddsConfigDocScreen() {
}
}
+ async function requestPublishConfirm() {
+ if (!detail || !isDraft) {
+ return;
+ }
+ const active = list.find((x) => x.status === "active");
+ if (active && active.id !== detail.id) {
+ try {
+ const d = await getOddsVersion(active.id);
+ setActiveCompareRows(d.items);
+ } catch {
+ setActiveCompareRows([]);
+ }
+ } else {
+ setActiveCompareRows([]);
+ }
+ setPublishConfirmOpen(true);
+ }
+
async function handleNewDraft() {
setSaving(true);
try {
@@ -343,6 +363,25 @@ export function OddsConfigDocScreen() {
setRollbackOpen(true);
}
+ const publishDiffRows = useMemo(() => {
+ if (!detail) {
+ return [];
+ }
+
+ const selectedPlay = resolvedPlayCode;
+
+ return PRIZE_SCOPE_ORDER.map((scope) => {
+ const next = draftRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
+ const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
+ return {
+ scope,
+ label: PRIZE_SCOPE_LABELS[scope],
+ oldValue: old?.odds_value ?? null,
+ newValue: next?.odds_value ?? null,
+ };
+ });
+ }, [activeCompareRows, detail, draftRows, resolvedPlayCode]);
+
const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: "全部" },
{ id: "d4", label: "4D" },
@@ -421,7 +460,7 @@ export function OddsConfigDocScreen() {
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
- onPublish={() => void handlePublish()}
+ onPublish={() => void requestPublishConfirm()}
/>
@@ -532,6 +571,48 @@ export function OddsConfigDocScreen() {
+
+
);
}
diff --git a/src/modules/config/doc/play-config-doc-screen.tsx b/src/modules/config/doc/play-config-doc-screen.tsx
index 3343a7d..48dc4f5 100644
--- a/src/modules/config/doc/play-config-doc-screen.tsx
+++ b/src/modules/config/doc/play-config-doc-screen.tsx
@@ -63,6 +63,50 @@ type PlayConfigSaveItemPayload = {
extra_config_json: unknown;
};
+type PlayBatchSwitchGroup = {
+ key: string;
+ label: string;
+ match: (row: PlayConfigItemRow) => boolean;
+};
+
+const PLAY_BATCH_SWITCH_GROUPS: PlayBatchSwitchGroup[] = [
+ {
+ key: "d2",
+ label: "2D 全局",
+ match: (row) => row.dimension === 2,
+ },
+ {
+ key: "d3",
+ label: "3D 全局",
+ match: (row) => row.dimension === 3,
+ },
+ {
+ key: "d4",
+ label: "4D 全局",
+ match: (row) => row.dimension === 4,
+ },
+ {
+ key: "big-small",
+ label: "Big / Small",
+ match: (row) => row.play_code === "big" || row.play_code === "small",
+ },
+ {
+ key: "position",
+ label: "位置类玩法",
+ match: (row) => row.category === "position",
+ },
+ {
+ key: "box",
+ label: "包号类玩法",
+ match: (row) => row.category === "box",
+ },
+ {
+ key: "jackpot",
+ label: "Jackpot",
+ match: (row) => row.category === "jackpot" || row.play_code.includes("jackpot"),
+ },
+];
+
/** 版本草稿保存 payload:直接按当前草稿快照落库。 */
function buildPlayConfigSavePayload(
draftRows: PlayConfigItemRow[],
@@ -217,6 +261,27 @@ export function PlayConfigDocScreen() {
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
}
+ function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
+ setDraftRows((prev) =>
+ prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
+ );
+ }
+
+ const batchSwitchStates = useMemo(
+ () =>
+ PLAY_BATCH_SWITCH_GROUPS.map((group) => {
+ const rows = draftRows.filter(group.match);
+ const enabledCount = rows.filter((row) => row.is_enabled).length;
+ return {
+ ...group,
+ total: rows.length,
+ enabledCount,
+ allEnabled: rows.length > 0 && enabledCount === rows.length,
+ };
+ }),
+ [draftRows],
+ );
+
async function handleSaveDraft() {
if (!detail || !isDraft) {
return;
@@ -360,6 +425,48 @@ export function PlayConfigDocScreen() {
) : null}
+ {detail ? (
+
+
+
+
批量开关
+
+ 仅修改当前草稿;保存并发布后,前台下注表格才会按新版本刷新。
+
+
+ {!isDraft ? (
+
+ 当前版本只读,请先新建草稿。
+
+ ) : null}
+
+
+ {batchSwitchStates.map((group) => (
+
+
+
{group.label}
+
+ {group.total > 0 ? `${group.enabledCount}/${group.total} 启用` : "暂无玩法"}
+
+
+
+
+ ))}
+
+
+ ) : null}
+
{error ? {error}
: null}
{loadingDetail ? (
diff --git a/src/modules/config/doc/risk-cap-doc-screen.tsx b/src/modules/config/doc/risk-cap-doc-screen.tsx
index 7bb83fd..354c024 100644
--- a/src/modules/config/doc/risk-cap-doc-screen.tsx
+++ b/src/modules/config/doc/risk-cap-doc-screen.tsx
@@ -55,6 +55,20 @@ function newRow(): DraftRiskRow {
};
}
+function isDefaultRiskRow(row: DraftRiskRow): boolean {
+ return row.cap_type === "default";
+}
+
+function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
+ return {
+ clientKey: `default-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ draw_id: null,
+ normalized_number: "0000",
+ cap_amount: amount,
+ cap_type: "default",
+ };
+}
+
export function RiskCapDocScreen() {
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState([]);
@@ -93,12 +107,12 @@ export function RiskCapDocScreen() {
}, [refreshList]);
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
- if (rows.length === 0) {
+ const defaultRow = rows.find(isDefaultRiskRow);
+ if (!defaultRow) {
setDefaultCapStr("");
return;
}
- const amounts = [...new Set(rows.map((r) => r.cap_amount))];
- setDefaultCapStr(amounts.length === 1 ? String(amounts[0]) : "");
+ setDefaultCapStr(String(defaultRow.cap_amount));
}
const loadDetail = useCallback(async (id: number) => {
@@ -177,6 +191,13 @@ export function RiskCapDocScreen() {
return;
}
for (const r of draftRows) {
+ if (isDefaultRiskRow(r)) {
+ if (r.cap_amount <= 0) {
+ toast.error("默认封顶金额必须大于 0");
+ return;
+ }
+ continue;
+ }
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
toast.error(`号码须为 4 位数字:${r.normalized_number}`);
return;
@@ -265,13 +286,16 @@ export function RiskCapDocScreen() {
}
}
- function applyDefaultCapToAll() {
+ function applyDefaultCap() {
const n = Number.parseInt(defaultCapStr, 10);
- if (!Number.isFinite(n) || n < 0) {
+ if (!Number.isFinite(n) || n <= 0) {
toast.error("请输入有效的封顶金额");
return;
}
- setDraftRows((prev) => prev.map((r) => ({ ...r, cap_amount: n })));
+ setDraftRows((prev) => {
+ const next = prev.filter((row) => !isDefaultRiskRow(row));
+ return [defaultRiskRowFromAmount(n), ...next];
+ });
setSyncOpen(false);
toast.message("已写入本地草稿,记得保存草稿");
}
@@ -279,11 +303,16 @@ export function RiskCapDocScreen() {
const occFiltered = useMemo(() => {
const q = occSearch.trim();
if (!q) {
- return draftRows;
+ return draftRows.filter((row) => !isDefaultRiskRow(row));
}
- return draftRows.filter((r) => r.normalized_number.includes(q));
+ return draftRows.filter((r) => !isDefaultRiskRow(r) && r.normalized_number.includes(q));
}, [draftRows, occSearch]);
+ const specialRows = useMemo(
+ () => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
+ [draftRows],
+ );
+
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteRiskCapVersion(row.id);
@@ -346,7 +375,7 @@ export function RiskCapDocScreen() {
默认封顶
- 将下列金额同步到当前草稿中的全部号码行(适用于统一基数快速调整)。
+ 未设置特殊封顶的号码,将使用此默认封顶模板。
@@ -391,7 +420,7 @@ export function RiskCapDocScreen() {
{loadingDetail ? (
加载明细…
- ) : draftRows.length === 0 ? (
+ ) : specialRows.length === 0 ? (
无明细行。
) : (
@@ -407,7 +436,7 @@ export function RiskCapDocScreen() {
- {draftRows.map((r, idx) => (
+ {specialRows.map(({ row: r, index: idx }) => (
{isDraft ? (
@@ -453,7 +482,7 @@ export function RiskCapDocScreen() {
type="button"
variant="ghost"
className="text-destructive"
- disabled={saving || draftRows.length <= 1}
+ disabled={saving}
onClick={() => removeRow(idx)}
>
删除
@@ -535,14 +564,14 @@ export function RiskCapDocScreen() {
同步默认封顶
- 将把当前列表中每个号码行的封顶金额统一设为 {defaultCapStr || "(空)"}。此操作仅修改草稿,确认后请保存草稿。
+ 将把默认封顶模板设为 {defaultCapStr || "(空)"}。此操作仅修改草稿,确认后请保存草稿并发布。
-
diff --git a/src/modules/jackpot/jackpot-pools-console.tsx b/src/modules/jackpot/jackpot-pools-console.tsx
index 666c1b0..39acf03 100644
--- a/src/modules/jackpot/jackpot-pools-console.tsx
+++ b/src/modules/jackpot/jackpot-pools-console.tsx
@@ -2,7 +2,11 @@
import { useCallback, useEffect, useState } from "react";
-import { getAdminJackpotPools, putAdminJackpotPool } from "@/api/admin-jackpot";
+import {
+ getAdminJackpotPools,
+ postAdminJackpotManualBurst,
+ putAdminJackpotPool,
+} from "@/api/admin-jackpot";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -27,7 +31,10 @@ type Draft = {
payout_rate: string;
force_trigger_draw_gap: string;
min_bet_amount: string;
+ combo_trigger_play_codes: string;
status: string;
+ manual_burst_draw_id: string;
+ manual_burst_amount: string;
};
function toDraft(p: AdminJackpotPoolRow): Draft {
@@ -38,7 +45,10 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
payout_rate: String(p.payout_rate),
force_trigger_draw_gap: String(p.force_trigger_draw_gap),
min_bet_amount: String(p.min_bet_amount),
+ combo_trigger_play_codes: p.combo_trigger_play_codes.join(","),
status: String(p.status),
+ manual_burst_draw_id: "",
+ manual_burst_amount: "",
};
}
@@ -47,6 +57,7 @@ export function JackpotPoolsConsole() {
const [drafts, setDrafts] = useState>({});
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState(null);
+ const [burstingId, setBurstingId] = useState(null);
const load = useCallback(async () => {
setLoading(true);
@@ -90,6 +101,10 @@ export function JackpotPoolsConsole() {
payout_rate: Number(d.payout_rate),
force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10),
min_bet_amount: Number.parseInt(d.min_bet_amount, 10),
+ combo_trigger_play_codes: d.combo_trigger_play_codes
+ .split(",")
+ .map((v) => v.trim().toLowerCase())
+ .filter(Boolean),
status: Number.parseInt(d.status, 10),
});
toast.success("已保存");
@@ -101,6 +116,34 @@ export function JackpotPoolsConsole() {
}
};
+ const manualBurst = async (p: AdminJackpotPoolRow) => {
+ const d = drafts[p.id];
+ if (!d) return;
+ const drawId = Number.parseInt(d.manual_burst_draw_id, 10);
+ if (!Number.isFinite(drawId) || drawId <= 0) {
+ toast.error("请填写有效的期号 ID");
+ return;
+ }
+
+ const amount = d.manual_burst_amount.trim()
+ ? Number.parseInt(d.manual_burst_amount, 10)
+ : undefined;
+
+ setBurstingId(p.id);
+ try {
+ await postAdminJackpotManualBurst(p.id, {
+ draw_id: drawId,
+ amount: amount !== undefined && Number.isFinite(amount) ? amount : undefined,
+ });
+ toast.success("已手动触发爆池");
+ await load();
+ } catch (e) {
+ toast.error(e instanceof LotteryApiBizError ? e.message : "手动爆池失败");
+ } finally {
+ setBurstingId(null);
+ }
+ };
+
return (
@@ -177,6 +220,16 @@ export function JackpotPoolsConsole() {
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
/>
+
+
+ updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
+ />
+
+
+
+
+
+ updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
+ />
+
+
+
+ updateDraft(p.id, { manual_burst_amount: e.target.value })}
+ />
+
+
void manualBurst(p)}
+ >
+ {burstingId === p.id ? "处理中…" : "手动爆池"}
+
+
+
);
})}
diff --git a/src/modules/reports/reports-console.tsx b/src/modules/reports/reports-console.tsx
index 1002292..64ce892 100644
--- a/src/modules/reports/reports-console.tsx
+++ b/src/modules/reports/reports-console.tsx
@@ -3,7 +3,11 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
-import { getAdminReportJobs, postAdminReportJob } from "@/api/admin-reports";
+import {
+ downloadAdminReportJob,
+ getAdminReportJobs,
+ postAdminReportJob,
+} from "@/api/admin-reports";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -30,6 +34,15 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminReportJobListData } from "@/types/api/admin-reports";
const REPORT_TYPES = [
+ { value: "draw_profit_summary", label: "期号盈亏" },
+ { value: "daily_profit_summary", label: "每日盈亏汇总" },
+ { value: "player_win_loss", label: "玩家输赢报表" },
+ { value: "wallet_transfer_report", label: "玩家转入转出报表" },
+ { value: "hot_number_risk_report", label: "热门号码风险报表" },
+ { value: "play_dimension_report", label: "玩法维度报表" },
+ { value: "sold_out_number_report", label: "售罄号码报表" },
+ { value: "rebate_commission_report", label: "佣金回水报表" },
+ { value: "audit_operation_report", label: "后台操作审计报表" },
{ value: "wallet_txns_daily", label: "钱包流水日报" },
{ value: "transfer_orders_daily", label: "转账单日报" },
] as const;
@@ -83,6 +96,7 @@ export function ReportsConsole(): React.ReactElement {
await postAdminReportJob({
report_type: reportType,
export_format: exportFormat,
+ parameters: filter_json,
filter_json,
});
toast.success("已创建导出任务");
@@ -95,6 +109,20 @@ export function ReportsConsole(): React.ReactElement {
}
}
+ async function onDownload(rowId: number): Promise {
+ try {
+ const blob = await downloadAdminReportJob(rowId);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "";
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ toast.error("下载失败");
+ }
+ }
+
const meta = data?.meta;
const lastPage = meta
? Math.max(1, meta.last_page)
@@ -194,13 +222,14 @@ export function ReportsConsole(): React.ReactElement {
格式
状态
输出
+ 下载
创建时间
{data.items.length === 0 ? (
-
+
无数据
@@ -217,6 +246,16 @@ export function ReportsConsole(): React.ReactElement {
{row.output_path ?? "—"}
+
+ void onDownload(row.id)}
+ >
+ 下载
+
+
{formatTs(row.created_at)}
diff --git a/src/modules/risk/risk-pools-console.tsx b/src/modules/risk/risk-pools-console.tsx
index d3ceefb..3a17a0e 100644
--- a/src/modules/risk/risk-pools-console.tsx
+++ b/src/modules/risk/risk-pools-console.tsx
@@ -2,11 +2,18 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
-import { getAdminRiskPools } from "@/api/admin-risk";
+import {
+ getAdminRiskPools,
+ postAdminRiskPoolManualClose,
+ postAdminRiskPoolRecover,
+} from "@/api/admin-risk";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
+import { Button } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -36,6 +43,8 @@ const SORT_OPTIONS: { value: "usage_desc" | "locked_desc" | "remaining_asc" | "n
{ value: "number_asc", label: "号码 ↑" },
];
+type RiskFilter = "all" | "sold_out" | "high_risk";
+
type RiskPoolsConsoleProps = {
drawId: number;
title: string;
@@ -52,10 +61,13 @@ export function RiskPoolsConsole({
allowSortChange = false,
}: RiskPoolsConsoleProps) {
const [sort, setSort] = useState(defaultSort);
+ const [filter, setFilter] = useState(soldOutOnly ? "sold_out" : "all");
+ const [number, setNumber] = useState("");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
+ const [actingNumber, setActingNumber] = useState(null);
const [error, setError] = useState(null);
const load = useCallback(async () => {
@@ -65,7 +77,9 @@ export function RiskPoolsConsole({
const d = await getAdminRiskPools(drawId, {
page,
per_page: perPage,
- sold_out_only: soldOutOnly,
+ sold_out_only: filter === "sold_out",
+ high_risk_only: filter === "high_risk",
+ normalized_number: number.trim(),
sort,
});
setData(d);
@@ -77,7 +91,7 @@ export function RiskPoolsConsole({
} finally {
setLoading(false);
}
- }, [drawId, page, perPage, soldOutOnly, sort]);
+ }, [drawId, filter, number, page, perPage, sort]);
useEffect(() => {
queueMicrotask(() => {
@@ -85,12 +99,77 @@ export function RiskPoolsConsole({
});
}, [load]);
+ const handleManualStatus = useCallback(
+ async (row: AdminRiskPoolRow) => {
+ setActingNumber(row.normalized_number);
+ try {
+ const updated = row.is_sold_out
+ ? await postAdminRiskPoolRecover(drawId, row.normalized_number)
+ : await postAdminRiskPoolManualClose(drawId, row.normalized_number);
+
+ setData((current) => {
+ if (!current) return current;
+
+ return {
+ ...current,
+ items: current.items.map((item) =>
+ item.normalized_number === updated.normalized_number ? updated : item,
+ ),
+ };
+ });
+ toast.success(row.is_sold_out ? "已恢复号码下注" : "已手动关闭号码下注");
+ } catch (e) {
+ toast.error(e instanceof LotteryApiBizError ? e.message : "操作失败");
+ } finally {
+ setActingNumber(null);
+ }
+ },
+ [drawId],
+ );
+
return (
{title}
- {allowSortChange ? (
-
+
+
+
+ {
+ setNumber(event.target.value.replace(/\D/g, "").slice(0, 4));
+ setPage(1);
+ }}
+ />
+
+
+
+
+ {[
+ ["all", "全部"],
+ ["sold_out", "售罄"],
+ ["high_risk", ">80%"],
+ ].map(([value, label]) => (
+ {
+ setFilter(value as RiskFilter);
+ setPage(1);
+ }}
+ >
+ {label}
+
+ ))}
+
+
+ {allowSortChange ? (
-
- ) : null}
+ ) : null}
+
{error ? {error}
: null}
@@ -132,40 +211,78 @@ export function RiskPoolsConsole({
已占用
剩余
占用比
- 售罄
- 详情
+ 状态
+ 操作
- {(data?.items ?? []).map((row: AdminRiskPoolRow) => (
-
- {row.normalized_number}
-
- {formatAdminMinorUnits(row.total_cap_amount)}
-
-
- {formatAdminMinorUnits(row.locked_amount)}
-
-
- {formatAdminMinorUnits(row.remaining_amount)}
-
-
- {row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
-
- {row.is_sold_out ? "是" : "否"}
-
-
- 查看
-
-
-
- ))}
+ {(data?.items ?? []).map((row: AdminRiskPoolRow) => {
+ const highRisk = (row.usage_ratio ?? 0) >= 0.8;
+ const acting = actingNumber === row.normalized_number;
+
+ return (
+
+ {row.normalized_number}
+
+ {formatAdminMinorUnits(row.total_cap_amount)}
+
+
+ {formatAdminMinorUnits(row.locked_amount)}
+
+
+ {formatAdminMinorUnits(row.remaining_amount)}
+
+
+ {row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
+
+
+
+ {row.is_sold_out ? "售罄" : highRisk ? "预警" : "正常"}
+
+
+
+
+ void handleManualStatus(row)}
+ >
+ {row.is_sold_out ? "恢复" : "关闭"}
+
+
+ 查看
+
+
+
+
+ );
+ })}
diff --git a/src/types/api/admin-jackpot.ts b/src/types/api/admin-jackpot.ts
index a4941c0..db9a5e2 100644
--- a/src/types/api/admin-jackpot.ts
+++ b/src/types/api/admin-jackpot.ts
@@ -7,6 +7,7 @@ export type AdminJackpotPoolRow = {
payout_rate: string;
force_trigger_draw_gap: number;
min_bet_amount: number;
+ combo_trigger_play_codes: string[];
status: number;
last_trigger_draw_id: number | null;
updated_at: string | null;