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 (
@@ -532,6 +571,48 @@ export function OddsConfigDocScreen() { + + + + + 确认启用赔率版本? + + 新赔率发布后立即影响新注单;已成功下注的订单继续按下注时赔率快照结算。 + + +
+
+ 奖项 + 当前查看值 + 发布后值 +
+ {publishDiffRows.map((row) => ( +
+ {row.label} + + {row.oldValue === null ? "—" : row.oldValue} + + {row.newValue ?? "—"} +
+ ))} +
+ + + + +
+
); } 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 })} + /> +
+ +
+ ); })} 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 ?? "—"} + + + {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]) => ( + + ))} +
+
+ {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 ? "预警" : "正常"} + + + +
+ + + 查看 + +
+
+
+ ); + })}
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;