feat: 增加管理端多语言与风控/报表/奖池操作能力
This commit is contained in:
84
package-lock.json
generated
84
package-lock.json
generated
@@ -13,12 +13,15 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"i18next": "^26.2.0",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "^10.0.0",
|
"react-day-picker": "^10.0.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
@@ -5828,6 +5831,15 @@
|
|||||||
"node": ">=16.9.0"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -5870,6 +5882,43 @@
|
|||||||
"node": ">=18.18.0"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@@ -8211,6 +8260,32 @@
|
|||||||
"react": "^19.2.4"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -9804,6 +9879,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
|||||||
@@ -14,12 +14,15 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"i18next": "^26.2.0",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "^10.0.0",
|
"react-day-picker": "^10.0.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type AdminJackpotPoolUpdateBody = Partial<{
|
|||||||
payout_rate: number;
|
payout_rate: number;
|
||||||
force_trigger_draw_gap: number;
|
force_trigger_draw_gap: number;
|
||||||
min_bet_amount: number;
|
min_bet_amount: number;
|
||||||
|
combo_trigger_play_codes: string[];
|
||||||
status: number;
|
status: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -32,6 +33,13 @@ export async function putAdminJackpotPool(
|
|||||||
return adminRequest.put<AdminJackpotPoolRow>(`${A}/jackpot/pools/${poolId}`, body);
|
return adminRequest.put<AdminJackpotPoolRow>(`${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 = {
|
export type AdminJackpotLogsQuery = {
|
||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
|
|||||||
@@ -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";
|
import { API_V1_PREFIX } from "./paths";
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ export async function getAdminReportJobs(params?: {
|
|||||||
export async function postAdminReportJob(body: {
|
export async function postAdminReportJob(body: {
|
||||||
report_type: string;
|
report_type: string;
|
||||||
export_format?: "csv" | "xlsx";
|
export_format?: "csv" | "xlsx";
|
||||||
|
parameters?: Record<string, unknown> | null;
|
||||||
filter_json?: Record<string, unknown> | null;
|
filter_json?: Record<string, unknown> | null;
|
||||||
}): Promise<AdminReportJobCreateResponse> {
|
}): Promise<AdminReportJobCreateResponse> {
|
||||||
return adminRequest.post<AdminReportJobCreateResponse>(
|
return adminRequest.post<AdminReportJobCreateResponse>(
|
||||||
@@ -28,3 +31,14 @@ export async function postAdminReportJob(body: {
|
|||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadAdminReportJob(jobId: number): Promise<Blob> {
|
||||||
|
const res = await adminHttp.request<Blob>(
|
||||||
|
withAdminAuthHeader(withAdminLocaleHeaders({
|
||||||
|
url: `${A}/report-jobs/${jobId}/download`,
|
||||||
|
method: "GET",
|
||||||
|
responseType: "blob",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { API_V1_PREFIX } from "./paths";
|
|||||||
import type {
|
import type {
|
||||||
AdminRiskLockLogListData,
|
AdminRiskLockLogListData,
|
||||||
AdminRiskPoolListData,
|
AdminRiskPoolListData,
|
||||||
|
AdminRiskPoolRow,
|
||||||
AdminRiskPoolShowData,
|
AdminRiskPoolShowData,
|
||||||
} from "@/types/api/admin-risk";
|
} from "@/types/api/admin-risk";
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ export type AdminRiskPoolListQuery = {
|
|||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
sold_out_only?: boolean;
|
sold_out_only?: boolean;
|
||||||
|
high_risk_only?: boolean;
|
||||||
|
normalized_number?: string;
|
||||||
sort?: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
|
sort?: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,11 +29,33 @@ export async function getAdminRiskPools(
|
|||||||
page: q.page,
|
page: q.page,
|
||||||
per_page: q.per_page,
|
per_page: q.per_page,
|
||||||
sold_out_only: q.sold_out_only === true ? 1 : undefined,
|
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,
|
sort: q.sort,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function postAdminRiskPoolManualClose(
|
||||||
|
drawId: number,
|
||||||
|
number4d: string,
|
||||||
|
): Promise<AdminRiskPoolRow> {
|
||||||
|
const encoded = encodeURIComponent(number4d);
|
||||||
|
return adminRequest.post<AdminRiskPoolRow>(
|
||||||
|
`${A}/draws/${drawId}/risk-pools/${encoded}/manual-close`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAdminRiskPoolRecover(
|
||||||
|
drawId: number,
|
||||||
|
number4d: string,
|
||||||
|
): Promise<AdminRiskPoolRow> {
|
||||||
|
const encoded = encodeURIComponent(number4d);
|
||||||
|
return adminRequest.post<AdminRiskPoolRow>(
|
||||||
|
`${A}/draws/${drawId}/risk-pools/${encoded}/recover`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type AdminRiskLockLogQuery = {
|
export type AdminRiskLockLogQuery = {
|
||||||
page?: number;
|
page?: number;
|
||||||
per_page?: number;
|
per_page?: number;
|
||||||
|
|||||||
73
src/components/admin/admin-language-switcher.tsx
Normal file
73
src/components/admin/admin-language-switcher.tsx
Normal file
@@ -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<AdminApiLocale>(() =>
|
||||||
|
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 (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="inline-flex h-9 items-center gap-2 rounded-lg border border-border bg-background px-3 text-sm text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
<GlobeIcon className="size-4 stroke-[1.75]" aria-hidden />
|
||||||
|
<span className="font-medium uppercase">{locale}</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
|
{t("language.title", { defaultValue: "界面语言" })}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{ADMIN_API_LOCALES.map((code) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={code}
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => void onSelectLocale(code)}
|
||||||
|
>
|
||||||
|
{locale === code ? (
|
||||||
|
<CheckIcon className="size-4 opacity-100" />
|
||||||
|
) : (
|
||||||
|
<span className="size-4 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="flex-1">{ADMIN_LOCALE_LABELS[code]}</span>
|
||||||
|
<span className="text-xs text-muted-foreground uppercase">{code}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,16 +2,14 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BellIcon,
|
BellIcon,
|
||||||
CheckIcon,
|
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
GlobeIcon,
|
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
UserRoundIcon,
|
UserRoundIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -24,13 +22,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
|
||||||
ADMIN_API_LOCALES,
|
|
||||||
ADMIN_LOCALE_LABELS,
|
|
||||||
applyAdminUiLocale,
|
|
||||||
getAdminRequestLocale,
|
|
||||||
type AdminApiLocale,
|
|
||||||
} from "@/lib/admin-locale";
|
|
||||||
import {
|
import {
|
||||||
useAdminProfile,
|
useAdminProfile,
|
||||||
useAdminSessionStore,
|
useAdminSessionStore,
|
||||||
@@ -76,17 +67,6 @@ export function ShellToolbar() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const adminProfile = useAdminProfile();
|
const adminProfile = useAdminProfile();
|
||||||
const clearSession = useAdminSessionStore((s) => s.clearSession);
|
const clearSession = useAdminSessionStore((s) => s.clearSession);
|
||||||
const [locale, setLocale] = useState<AdminApiLocale>(() =>
|
|
||||||
typeof document !== "undefined"
|
|
||||||
? getAdminRequestLocale()
|
|
||||||
: "zh",
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setLocale(getAdminRequestLocale());
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const displayName =
|
const displayName =
|
||||||
adminProfile?.nickname?.trim() ||
|
adminProfile?.nickname?.trim() ||
|
||||||
@@ -100,13 +80,6 @@ export function ShellToolbar() {
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectLocale(next: AdminApiLocale) {
|
|
||||||
applyAdminUiLocale(next);
|
|
||||||
setLocale(next);
|
|
||||||
toast.success(`语言:${ADMIN_LOCALE_LABELS[next]}`);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -128,36 +101,7 @@ export function ShellToolbar() {
|
|||||||
|
|
||||||
<Separator orientation="vertical" className="mx-0.5 h-7" />
|
<Separator orientation="vertical" className="mx-0.5 h-7" />
|
||||||
|
|
||||||
<DropdownMenu>
|
<AdminLanguageSwitcher />
|
||||||
<DropdownMenuTrigger className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
|
|
||||||
<GlobeIcon className="size-5 stroke-[1.75]" aria-hidden />
|
|
||||||
<span className="sr-only">语言</span>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="min-w-[10rem]">
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
||||||
界面语言 / Language
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{ADMIN_API_LOCALES.map((code) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={code}
|
|
||||||
className="gap-2"
|
|
||||||
onClick={() => onSelectLocale(code)}
|
|
||||||
>
|
|
||||||
{locale === code ? (
|
|
||||||
<CheckIcon className="size-4 opacity-100" />
|
|
||||||
) : (
|
|
||||||
<span className="size-4 shrink-0" aria-hidden />
|
|
||||||
)}
|
|
||||||
<span className="flex-1">{ADMIN_LOCALE_LABELS[code]}</span>
|
|
||||||
<span className="text-xs text-muted-foreground uppercase">
|
|
||||||
{code}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Separator orientation="vertical" className="mx-0.5 h-7" />
|
<Separator orientation="vertical" className="mx-0.5 h-7" />
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import "@/i18n";
|
||||||
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|||||||
98
src/i18n/index.ts
Normal file
98
src/i18n/index.ts
Normal file
@@ -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<AdminLanguage, Record<(typeof namespaces)[number], Record<string, unknown>>>;
|
||||||
|
|
||||||
|
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;
|
||||||
3
src/i18n/locales/en/audit.json
Normal file
3
src/i18n/locales/en/audit.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "Audit Logs"
|
||||||
|
}
|
||||||
3
src/i18n/locales/en/auth.json
Normal file
3
src/i18n/locales/en/auth.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "Login"
|
||||||
|
}
|
||||||
20
src/i18n/locales/en/common.json
Normal file
20
src/i18n/locales/en/common.json
Normal file
@@ -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..."
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/i18n/locales/en/dashboard.json
Normal file
3
src/i18n/locales/en/dashboard.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "Dashboard"
|
||||||
|
}
|
||||||
3
src/i18n/locales/en/reports.json
Normal file
3
src/i18n/locales/en/reports.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "Reports"
|
||||||
|
}
|
||||||
3
src/i18n/locales/ne/audit.json
Normal file
3
src/i18n/locales/ne/audit.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "अडिट लग"
|
||||||
|
}
|
||||||
3
src/i18n/locales/ne/auth.json
Normal file
3
src/i18n/locales/ne/auth.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "लगइन"
|
||||||
|
}
|
||||||
20
src/i18n/locales/ne/common.json
Normal file
20
src/i18n/locales/ne/common.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"language": {
|
||||||
|
"en": "English",
|
||||||
|
"ne": "नेपाली",
|
||||||
|
"zh": "中文",
|
||||||
|
"title": "इन्टरफेस भाषा",
|
||||||
|
"changed": "भाषा"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"title": "Lottery Admin"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"refresh": "रिफ्रेस",
|
||||||
|
"download": "डाउनलोड",
|
||||||
|
"search": "खोज",
|
||||||
|
"apply": "लागू गर्नुहोस्",
|
||||||
|
"loading": "लोड हुँदैछ...",
|
||||||
|
"submitting": "पेश हुँदैछ..."
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/i18n/locales/ne/dashboard.json
Normal file
3
src/i18n/locales/ne/dashboard.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "ड्यासबोर्ड"
|
||||||
|
}
|
||||||
3
src/i18n/locales/ne/reports.json
Normal file
3
src/i18n/locales/ne/reports.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "रिपोर्ट"
|
||||||
|
}
|
||||||
3
src/i18n/locales/zh/audit.json
Normal file
3
src/i18n/locales/zh/audit.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "审计日志"
|
||||||
|
}
|
||||||
3
src/i18n/locales/zh/auth.json
Normal file
3
src/i18n/locales/zh/auth.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "登录"
|
||||||
|
}
|
||||||
20
src/i18n/locales/zh/common.json
Normal file
20
src/i18n/locales/zh/common.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"language": {
|
||||||
|
"en": "English",
|
||||||
|
"ne": "नेपाली",
|
||||||
|
"zh": "中文",
|
||||||
|
"title": "界面语言",
|
||||||
|
"changed": "语言"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"title": "彩票后台"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"refresh": "刷新",
|
||||||
|
"download": "下载",
|
||||||
|
"search": "搜索",
|
||||||
|
"apply": "应用",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"submitting": "提交中..."
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/i18n/locales/zh/dashboard.json
Normal file
3
src/i18n/locales/zh/dashboard.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "仪表盘"
|
||||||
|
}
|
||||||
3
src/i18n/locales/zh/reports.json
Normal file
3
src/i18n/locales/zh/reports.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"title": "报表"
|
||||||
|
}
|
||||||
@@ -81,6 +81,8 @@ export function OddsConfigDocScreen() {
|
|||||||
|
|
||||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||||
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
|
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
|
||||||
|
const [publishConfirmOpen, setPublishConfirmOpen] = useState(false);
|
||||||
|
const [activeCompareRows, setActiveCompareRows] = useState<OddsItemRow[]>([]);
|
||||||
|
|
||||||
const refreshTypes = useCallback(async () => {
|
const refreshTypes = useCallback(async () => {
|
||||||
setLoadingTypes(true);
|
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() {
|
async function handleNewDraft() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -343,6 +363,25 @@ export function OddsConfigDocScreen() {
|
|||||||
setRollbackOpen(true);
|
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 }[] = [
|
const catTabs: { id: CatTab; label: string }[] = [
|
||||||
{ id: "all", label: "全部" },
|
{ id: "all", label: "全部" },
|
||||||
{ id: "d4", label: "4D" },
|
{ id: "d4", label: "4D" },
|
||||||
@@ -421,7 +460,7 @@ export function OddsConfigDocScreen() {
|
|||||||
onRefresh={() => void refreshList()}
|
onRefresh={() => void refreshList()}
|
||||||
onNewDraft={() => void handleNewDraft()}
|
onNewDraft={() => void handleNewDraft()}
|
||||||
onSaveDraft={() => void handleSave()}
|
onSaveDraft={() => void handleSave()}
|
||||||
onPublish={() => void handlePublish()}
|
onPublish={() => void requestPublishConfirm()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -532,6 +571,48 @@ export function OddsConfigDocScreen() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
|
||||||
|
<DialogContent showCloseButton className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认启用赔率版本?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
新赔率发布后立即影响新注单;已成功下注的订单继续按下注时赔率快照结算。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<div className="grid grid-cols-3 border-b bg-muted/40 px-3 py-2 text-sm font-medium">
|
||||||
|
<span>奖项</span>
|
||||||
|
<span className="text-right">当前查看值</span>
|
||||||
|
<span className="text-right">发布后值</span>
|
||||||
|
</div>
|
||||||
|
{publishDiffRows.map((row) => (
|
||||||
|
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
|
||||||
|
<span>{row.label}</span>
|
||||||
|
<span className="text-right font-mono tabular-nums">
|
||||||
|
{row.oldValue === null ? "—" : row.oldValue}
|
||||||
|
</span>
|
||||||
|
<span className="text-right font-mono tabular-nums">{row.newValue ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setPublishConfirmOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setPublishConfirmOpen(false);
|
||||||
|
void handlePublish();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认发布
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,50 @@ type PlayConfigSaveItemPayload = {
|
|||||||
extra_config_json: unknown;
|
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:直接按当前草稿快照落库。 */
|
/** 版本草稿保存 payload:直接按当前草稿快照落库。 */
|
||||||
function buildPlayConfigSavePayload(
|
function buildPlayConfigSavePayload(
|
||||||
draftRows: PlayConfigItemRow[],
|
draftRows: PlayConfigItemRow[],
|
||||||
@@ -217,6 +261,27 @@ export function PlayConfigDocScreen() {
|
|||||||
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
|
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() {
|
async function handleSaveDraft() {
|
||||||
if (!detail || !isDraft) {
|
if (!detail || !isDraft) {
|
||||||
return;
|
return;
|
||||||
@@ -360,6 +425,48 @@ export function PlayConfigDocScreen() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{detail ? (
|
||||||
|
<div className="rounded-xl border bg-muted/20 p-3">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">批量开关</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
仅修改当前草稿;保存并发布后,前台下注表格才会按新版本刷新。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!isDraft ? (
|
||||||
|
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
当前版本只读,请先新建草稿。
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{batchSwitchStates.map((group) => (
|
||||||
|
<div
|
||||||
|
key={group.key}
|
||||||
|
className="flex items-center gap-2 rounded-lg border bg-background px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-[92px]">
|
||||||
|
<p className="text-sm font-medium">{group.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{group.total > 0 ? `${group.enabledCount}/${group.total} 启用` : "暂无玩法"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={group.allEnabled ? "secondary" : "outline"}
|
||||||
|
disabled={!isDraft || saving || group.total === 0}
|
||||||
|
onClick={() => applyBatchSwitch(group, !group.allEnabled)}
|
||||||
|
>
|
||||||
|
{group.allEnabled ? "关闭" : "开启"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
|
||||||
{loadingDetail ? (
|
{loadingDetail ? (
|
||||||
|
|||||||
@@ -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() {
|
export function RiskCapDocScreen() {
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||||
@@ -93,12 +107,12 @@ export function RiskCapDocScreen() {
|
|||||||
}, [refreshList]);
|
}, [refreshList]);
|
||||||
|
|
||||||
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
function syncDefaultCapFromRows(rows: DraftRiskRow[]) {
|
||||||
if (rows.length === 0) {
|
const defaultRow = rows.find(isDefaultRiskRow);
|
||||||
|
if (!defaultRow) {
|
||||||
setDefaultCapStr("");
|
setDefaultCapStr("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const amounts = [...new Set(rows.map((r) => r.cap_amount))];
|
setDefaultCapStr(String(defaultRow.cap_amount));
|
||||||
setDefaultCapStr(amounts.length === 1 ? String(amounts[0]) : "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDetail = useCallback(async (id: number) => {
|
const loadDetail = useCallback(async (id: number) => {
|
||||||
@@ -177,6 +191,13 @@ export function RiskCapDocScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const r of draftRows) {
|
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)) {
|
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
|
||||||
toast.error(`号码须为 4 位数字:${r.normalized_number}`);
|
toast.error(`号码须为 4 位数字:${r.normalized_number}`);
|
||||||
return;
|
return;
|
||||||
@@ -265,13 +286,16 @@ export function RiskCapDocScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDefaultCapToAll() {
|
function applyDefaultCap() {
|
||||||
const n = Number.parseInt(defaultCapStr, 10);
|
const n = Number.parseInt(defaultCapStr, 10);
|
||||||
if (!Number.isFinite(n) || n < 0) {
|
if (!Number.isFinite(n) || n <= 0) {
|
||||||
toast.error("请输入有效的封顶金额");
|
toast.error("请输入有效的封顶金额");
|
||||||
return;
|
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);
|
setSyncOpen(false);
|
||||||
toast.message("已写入本地草稿,记得保存草稿");
|
toast.message("已写入本地草稿,记得保存草稿");
|
||||||
}
|
}
|
||||||
@@ -279,11 +303,16 @@ export function RiskCapDocScreen() {
|
|||||||
const occFiltered = useMemo(() => {
|
const occFiltered = useMemo(() => {
|
||||||
const q = occSearch.trim();
|
const q = occSearch.trim();
|
||||||
if (!q) {
|
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]);
|
}, [draftRows, occSearch]);
|
||||||
|
|
||||||
|
const specialRows = useMemo(
|
||||||
|
() => draftRows.map((row, index) => ({ row, index })).filter(({ row }) => !isDefaultRiskRow(row)),
|
||||||
|
[draftRows],
|
||||||
|
);
|
||||||
|
|
||||||
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
||||||
try {
|
try {
|
||||||
await deleteRiskCapVersion(row.id);
|
await deleteRiskCapVersion(row.id);
|
||||||
@@ -346,7 +375,7 @@ export function RiskCapDocScreen() {
|
|||||||
<section className="space-y-3 rounded-lg border bg-muted/20 p-4">
|
<section className="space-y-3 rounded-lg border bg-muted/20 p-4">
|
||||||
<h3 className="text-sm font-medium">默认封顶</h3>
|
<h3 className="text-sm font-medium">默认封顶</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
将下列金额同步到当前草稿中的<strong>全部号码行</strong>(适用于统一基数快速调整)。
|
未设置特殊封顶的号码,将使用此默认封顶模板。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
@@ -391,7 +420,7 @@ export function RiskCapDocScreen() {
|
|||||||
</div>
|
</div>
|
||||||
{loadingDetail ? (
|
{loadingDetail ? (
|
||||||
<p className="text-sm text-muted-foreground">加载明细…</p>
|
<p className="text-sm text-muted-foreground">加载明细…</p>
|
||||||
) : draftRows.length === 0 ? (
|
) : specialRows.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">无明细行。</p>
|
<p className="text-sm text-muted-foreground">无明细行。</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto rounded-md border">
|
<div className="overflow-x-auto rounded-md border">
|
||||||
@@ -407,7 +436,7 @@ export function RiskCapDocScreen() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{draftRows.map((r, idx) => (
|
{specialRows.map(({ row: r, index: idx }) => (
|
||||||
<TableRow key={r.clientKey}>
|
<TableRow key={r.clientKey}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
@@ -453,7 +482,7 @@ export function RiskCapDocScreen() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
disabled={saving || draftRows.length <= 1}
|
disabled={saving}
|
||||||
onClick={() => removeRow(idx)}
|
onClick={() => removeRow(idx)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -535,14 +564,14 @@ export function RiskCapDocScreen() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>同步默认封顶</DialogTitle>
|
<DialogTitle>同步默认封顶</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
将把当前列表中每个号码行的封顶金额统一设为 {defaultCapStr || "(空)"}。此操作仅修改草稿,确认后请保存草稿。
|
将把默认封顶模板设为 {defaultCapStr || "(空)"}。此操作仅修改草稿,确认后请保存草稿并发布。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
|
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={applyDefaultCapToAll}>
|
<Button type="button" onClick={applyDefaultCap}>
|
||||||
确认
|
确认
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -27,7 +31,10 @@ type Draft = {
|
|||||||
payout_rate: string;
|
payout_rate: string;
|
||||||
force_trigger_draw_gap: string;
|
force_trigger_draw_gap: string;
|
||||||
min_bet_amount: string;
|
min_bet_amount: string;
|
||||||
|
combo_trigger_play_codes: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
manual_burst_draw_id: string;
|
||||||
|
manual_burst_amount: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toDraft(p: AdminJackpotPoolRow): Draft {
|
function toDraft(p: AdminJackpotPoolRow): Draft {
|
||||||
@@ -38,7 +45,10 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
|
|||||||
payout_rate: String(p.payout_rate),
|
payout_rate: String(p.payout_rate),
|
||||||
force_trigger_draw_gap: String(p.force_trigger_draw_gap),
|
force_trigger_draw_gap: String(p.force_trigger_draw_gap),
|
||||||
min_bet_amount: String(p.min_bet_amount),
|
min_bet_amount: String(p.min_bet_amount),
|
||||||
|
combo_trigger_play_codes: p.combo_trigger_play_codes.join(","),
|
||||||
status: String(p.status),
|
status: String(p.status),
|
||||||
|
manual_burst_draw_id: "",
|
||||||
|
manual_burst_amount: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +57,7 @@ export function JackpotPoolsConsole() {
|
|||||||
const [drafts, setDrafts] = useState<Record<number, Draft>>({});
|
const [drafts, setDrafts] = useState<Record<number, Draft>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [savingId, setSavingId] = useState<number | null>(null);
|
const [savingId, setSavingId] = useState<number | null>(null);
|
||||||
|
const [burstingId, setBurstingId] = useState<number | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -90,6 +101,10 @@ export function JackpotPoolsConsole() {
|
|||||||
payout_rate: Number(d.payout_rate),
|
payout_rate: Number(d.payout_rate),
|
||||||
force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10),
|
force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10),
|
||||||
min_bet_amount: Number.parseInt(d.min_bet_amount, 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),
|
status: Number.parseInt(d.status, 10),
|
||||||
});
|
});
|
||||||
toast.success("已保存");
|
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 (
|
return (
|
||||||
<ModuleScaffold>
|
<ModuleScaffold>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -177,6 +220,16 @@ export function JackpotPoolsConsole() {
|
|||||||
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={`combo-${p.id}`}>组合触发玩法(逗号分隔)</Label>
|
||||||
|
<Input
|
||||||
|
id={`combo-${p.id}`}
|
||||||
|
className="font-mono"
|
||||||
|
value={d.combo_trigger_play_codes}
|
||||||
|
placeholder="straight,ibox"
|
||||||
|
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>开关</Label>
|
<Label>开关</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -198,6 +251,36 @@ export function JackpotPoolsConsole() {
|
|||||||
{savingId === p.id ? "保存中…" : "保存"}
|
{savingId === p.id ? "保存中…" : "保存"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={`burst-draw-${p.id}`}>手动爆池期号 ID</Label>
|
||||||
|
<Input
|
||||||
|
id={`burst-draw-${p.id}`}
|
||||||
|
className="font-mono"
|
||||||
|
value={d.manual_burst_draw_id}
|
||||||
|
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={`burst-amount-${p.id}`}>爆池金额(空为全部)</Label>
|
||||||
|
<Input
|
||||||
|
id={`burst-amount-${p.id}`}
|
||||||
|
className="font-mono"
|
||||||
|
value={d.manual_burst_amount}
|
||||||
|
onChange={(e) => updateDraft(p.id, { manual_burst_amount: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={burstingId === p.id}
|
||||||
|
onClick={() => void manualBurst(p)}
|
||||||
|
>
|
||||||
|
{burstingId === p.id ? "处理中…" : "手动爆池"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
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 { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -30,6 +34,15 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
|||||||
import type { AdminReportJobListData } from "@/types/api/admin-reports";
|
import type { AdminReportJobListData } from "@/types/api/admin-reports";
|
||||||
|
|
||||||
const REPORT_TYPES = [
|
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: "wallet_txns_daily", label: "钱包流水日报" },
|
||||||
{ value: "transfer_orders_daily", label: "转账单日报" },
|
{ value: "transfer_orders_daily", label: "转账单日报" },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -83,6 +96,7 @@ export function ReportsConsole(): React.ReactElement {
|
|||||||
await postAdminReportJob({
|
await postAdminReportJob({
|
||||||
report_type: reportType,
|
report_type: reportType,
|
||||||
export_format: exportFormat,
|
export_format: exportFormat,
|
||||||
|
parameters: filter_json,
|
||||||
filter_json,
|
filter_json,
|
||||||
});
|
});
|
||||||
toast.success("已创建导出任务");
|
toast.success("已创建导出任务");
|
||||||
@@ -95,6 +109,20 @@ export function ReportsConsole(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onDownload(rowId: number): Promise<void> {
|
||||||
|
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 meta = data?.meta;
|
||||||
const lastPage = meta
|
const lastPage = meta
|
||||||
? Math.max(1, meta.last_page)
|
? Math.max(1, meta.last_page)
|
||||||
@@ -194,13 +222,14 @@ export function ReportsConsole(): React.ReactElement {
|
|||||||
<TableHead>格式</TableHead>
|
<TableHead>格式</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead>输出</TableHead>
|
<TableHead>输出</TableHead>
|
||||||
|
<TableHead>下载</TableHead>
|
||||||
<TableHead>创建时间</TableHead>
|
<TableHead>创建时间</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-muted-foreground">
|
<TableCell colSpan={8} className="text-muted-foreground">
|
||||||
无数据
|
无数据
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -217,6 +246,16 @@ export function ReportsConsole(): React.ReactElement {
|
|||||||
<TableCell className="max-w-[12rem] truncate text-xs text-muted-foreground">
|
<TableCell className="max-w-[12rem] truncate text-xs text-muted-foreground">
|
||||||
{row.output_path ?? "—"}
|
{row.output_path ?? "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void onDownload(row.id)}
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||||
{formatTs(row.created_at)}
|
{formatTs(row.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -2,11 +2,18 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -36,6 +43,8 @@ const SORT_OPTIONS: { value: "usage_desc" | "locked_desc" | "remaining_asc" | "n
|
|||||||
{ value: "number_asc", label: "号码 ↑" },
|
{ value: "number_asc", label: "号码 ↑" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type RiskFilter = "all" | "sold_out" | "high_risk";
|
||||||
|
|
||||||
type RiskPoolsConsoleProps = {
|
type RiskPoolsConsoleProps = {
|
||||||
drawId: number;
|
drawId: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -52,10 +61,13 @@ export function RiskPoolsConsole({
|
|||||||
allowSortChange = false,
|
allowSortChange = false,
|
||||||
}: RiskPoolsConsoleProps) {
|
}: RiskPoolsConsoleProps) {
|
||||||
const [sort, setSort] = useState(defaultSort);
|
const [sort, setSort] = useState(defaultSort);
|
||||||
|
const [filter, setFilter] = useState<RiskFilter>(soldOutOnly ? "sold_out" : "all");
|
||||||
|
const [number, setNumber] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(25);
|
const [perPage, setPerPage] = useState(25);
|
||||||
const [data, setData] = useState<AdminRiskPoolListData | null>(null);
|
const [data, setData] = useState<AdminRiskPoolListData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actingNumber, setActingNumber] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -65,7 +77,9 @@ export function RiskPoolsConsole({
|
|||||||
const d = await getAdminRiskPools(drawId, {
|
const d = await getAdminRiskPools(drawId, {
|
||||||
page,
|
page,
|
||||||
per_page: perPage,
|
per_page: perPage,
|
||||||
sold_out_only: soldOutOnly,
|
sold_out_only: filter === "sold_out",
|
||||||
|
high_risk_only: filter === "high_risk",
|
||||||
|
normalized_number: number.trim(),
|
||||||
sort,
|
sort,
|
||||||
});
|
});
|
||||||
setData(d);
|
setData(d);
|
||||||
@@ -77,7 +91,7 @@ export function RiskPoolsConsole({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [drawId, page, perPage, soldOutOnly, sort]);
|
}, [drawId, filter, number, page, perPage, sort]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -85,12 +99,77 @@ export function RiskPoolsConsole({
|
|||||||
});
|
});
|
||||||
}, [load]);
|
}, [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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="space-y-2">
|
<CardHeader className="space-y-2">
|
||||||
<CardTitle className="text-lg">{title}</CardTitle>
|
<CardTitle className="text-lg">{title}</CardTitle>
|
||||||
{allowSortChange ? (
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="flex max-w-xs flex-col gap-2 sm:flex-row sm:items-end">
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="risk-pool-number">搜索号码</Label>
|
||||||
|
<Input
|
||||||
|
id="risk-pool-number"
|
||||||
|
value={number}
|
||||||
|
maxLength={4}
|
||||||
|
placeholder="如 8888"
|
||||||
|
className="h-9 w-32 font-mono"
|
||||||
|
onChange={(event) => {
|
||||||
|
setNumber(event.target.value.replace(/\D/g, "").slice(0, 4));
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>风险筛选</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
["all", "全部"],
|
||||||
|
["sold_out", "售罄"],
|
||||||
|
["high_risk", ">80%"],
|
||||||
|
].map(([value, label]) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={filter === value ? "default" : "outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setFilter(value as RiskFilter);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{allowSortChange ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="risk-pool-sort">排序</Label>
|
<Label htmlFor="risk-pool-sort">排序</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -114,8 +193,8 @@ export function RiskPoolsConsole({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
@@ -132,40 +211,78 @@ export function RiskPoolsConsole({
|
|||||||
<TableHead className="text-right">已占用</TableHead>
|
<TableHead className="text-right">已占用</TableHead>
|
||||||
<TableHead className="text-right">剩余</TableHead>
|
<TableHead className="text-right">剩余</TableHead>
|
||||||
<TableHead className="text-right">占用比</TableHead>
|
<TableHead className="text-right">占用比</TableHead>
|
||||||
<TableHead>售罄</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead className="text-right">详情</TableHead>
|
<TableHead className="text-right">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{(data?.items ?? []).map((row: AdminRiskPoolRow) => (
|
{(data?.items ?? []).map((row: AdminRiskPoolRow) => {
|
||||||
<TableRow key={row.normalized_number}>
|
const highRisk = (row.usage_ratio ?? 0) >= 0.8;
|
||||||
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
|
const acting = actingNumber === row.normalized_number;
|
||||||
<TableCell className="text-right text-sm tabular-nums">
|
|
||||||
{formatAdminMinorUnits(row.total_cap_amount)}
|
return (
|
||||||
</TableCell>
|
<TableRow
|
||||||
<TableCell className="text-right text-sm tabular-nums">
|
key={row.normalized_number}
|
||||||
{formatAdminMinorUnits(row.locked_amount)}
|
className={cn(
|
||||||
</TableCell>
|
row.is_sold_out
|
||||||
<TableCell className="text-right text-sm tabular-nums">
|
? "bg-red-50/90 hover:bg-red-50 dark:bg-red-950/25 dark:hover:bg-red-950/35"
|
||||||
{formatAdminMinorUnits(row.remaining_amount)}
|
: highRisk
|
||||||
</TableCell>
|
? "bg-orange-50/90 hover:bg-orange-50 dark:bg-orange-950/25 dark:hover:bg-orange-950/35"
|
||||||
<TableCell className="text-right text-sm tabular-nums">
|
: null,
|
||||||
{row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
|
)}
|
||||||
</TableCell>
|
>
|
||||||
<TableCell>{row.is_sold_out ? "是" : "否"}</TableCell>
|
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right text-sm tabular-nums">
|
||||||
<Link
|
{formatAdminMinorUnits(row.total_cap_amount)}
|
||||||
href={`/admin/risk/draws/${drawId}/pools/${row.normalized_number}`}
|
</TableCell>
|
||||||
className={cn(
|
<TableCell className="text-right text-sm tabular-nums">
|
||||||
buttonVariants({ variant: "link", size: "sm" }),
|
{formatAdminMinorUnits(row.locked_amount)}
|
||||||
"h-auto p-0",
|
</TableCell>
|
||||||
)}
|
<TableCell className="text-right text-sm tabular-nums">
|
||||||
>
|
{formatAdminMinorUnits(row.remaining_amount)}
|
||||||
查看
|
</TableCell>
|
||||||
</Link>
|
<TableCell className="text-right text-sm tabular-nums">
|
||||||
</TableCell>
|
{row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
|
||||||
</TableRow>
|
</TableCell>
|
||||||
))}
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-6 items-center rounded px-2 text-xs font-medium",
|
||||||
|
row.is_sold_out
|
||||||
|
? "bg-red-600 text-white"
|
||||||
|
: highRisk
|
||||||
|
? "bg-orange-500 text-white"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.is_sold_out ? "售罄" : highRisk ? "预警" : "正常"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={row.is_sold_out ? "outline" : "destructive"}
|
||||||
|
disabled={acting}
|
||||||
|
onClick={() => void handleManualStatus(row)}
|
||||||
|
>
|
||||||
|
{row.is_sold_out ? "恢复" : "关闭"}
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
href={`/admin/risk/draws/${drawId}/pools/${row.normalized_number}`}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "link", size: "sm" }),
|
||||||
|
"h-8 px-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type AdminJackpotPoolRow = {
|
|||||||
payout_rate: string;
|
payout_rate: string;
|
||||||
force_trigger_draw_gap: number;
|
force_trigger_draw_gap: number;
|
||||||
min_bet_amount: number;
|
min_bet_amount: number;
|
||||||
|
combo_trigger_play_codes: string[];
|
||||||
status: number;
|
status: number;
|
||||||
last_trigger_draw_id: number | null;
|
last_trigger_draw_id: number | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user