feat: 统一管理端多语言、配置与票据/结算页面重构

This commit is contained in:
2026-05-20 16:27:06 +08:00
parent 37b13278ef
commit 08a11a1589
81 changed files with 2059 additions and 490 deletions

View File

@@ -8,7 +8,15 @@ const A = `${API_V1_PREFIX}/admin`;
export async function getAdminPlayerTicketItems( export async function getAdminPlayerTicketItems(
playerId: number, playerId: number,
params?: { page?: number; per_page?: number; draw_no?: string }, params?: {
page?: number;
per_page?: number;
draw_no?: string;
status?: string[];
number?: string;
start_date?: string;
end_date?: string;
},
): Promise<AdminPlayerTicketItemsData> { ): Promise<AdminPlayerTicketItemsData> {
return adminRequest.get<AdminPlayerTicketItemsData>( return adminRequest.get<AdminPlayerTicketItemsData>(
`${A}/players/${playerId}/ticket-items`, `${A}/players/${playerId}/ticket-items`,

25
src/api/admin-tickets.ts Normal file
View File

@@ -0,0 +1,25 @@
import { adminRequest } from "@/lib/admin-http";
import { API_V1_PREFIX } from "./paths";
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
const A = `${API_V1_PREFIX}/admin`;
export type TicketItemsListQuery = {
page?: number;
per_page?: number;
player_id?: number;
player_account?: string;
draw_no?: string;
status?: string[];
number?: string;
start_date?: string;
end_date?: string;
};
export async function getAdminTicketItems(
q: TicketItemsListQuery = {},
): Promise<AdminTicketItemsData> {
return adminRequest.get<AdminTicketItemsData>(`${A}/tickets`, { params: q });
}

View File

@@ -22,6 +22,7 @@ export {
getAdminDraws, getAdminDraws,
postAdminPublishResultBatch, postAdminPublishResultBatch,
} from "@/api/admin-draws"; } from "@/api/admin-draws";
export { getAdminTicketItems } from "@/api/admin-tickets";
export { getAdminPlayerTicketItems } from "@/api/admin-player-tickets"; export { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
export type { export type {
AdminAuthCaptchaResponse, AdminAuthCaptchaResponse,

View File

@@ -4,17 +4,13 @@ import { jackpotModuleMeta } from "@/modules/jackpot/meta";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: `Jackpot 记录 · ${jackpotModuleMeta.title}`, title: jackpotModuleMeta.title,
}; };
export default function AdminConfigJackpotRecordsPage() { export default function AdminConfigJackpotRecordsPage() {
return ( return (
<div className="w-full max-w-none px-1"> <div className="w-full max-w-none px-1">
<JackpotSubNav /> <JackpotSubNav />
<div className="mx-auto mb-6 max-w-5xl">
<h1 className="text-lg font-semibold tracking-tight">Jackpot </h1>
<p className="mt-1 text-sm text-muted-foreground"></p>
</div>
<JackpotRecordsConsole /> <JackpotRecordsConsole />
</div> </div>
); );

View File

@@ -1,11 +1,10 @@
import { configWalletMeta } from "@/modules/config/meta"; import { redirect } from "next/navigation";
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
title: configWalletMeta.title, title: "系统设置",
}; };
export default function AdminConfigWalletPage() { export default function AdminConfigWalletPage() {
return <WalletConfigDocScreen />; redirect("/admin/settings");
} }

View File

@@ -1,5 +1,6 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold"; import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { settingsModuleMeta } from "@/modules/settings/meta"; import { settingsModuleMeta } from "@/modules/settings/meta";
import { SystemSettingsScreen } from "@/modules/settings/system-settings-screen";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -9,13 +10,7 @@ export const metadata: Metadata = {
export default function AdminSettingsPage() { export default function AdminSettingsPage() {
return ( return (
<ModuleScaffold> <ModuleScaffold>
<p className="text-sm text-zinc-500 dark:text-zinc-400"> <SystemSettingsScreen />
{" "}
<code className="rounded bg-zinc-100 px-1 py-0.5 font-mono text-xs dark:bg-zinc-800">
src/modules/settings
</code>{" "}
</p>
</ModuleScaffold> </ModuleScaffold>
); );
} }

View File

@@ -1,3 +1,4 @@
import { InvalidSettlementBatchId } from "@/modules/settlement/invalid-settlement-batch-id";
import { SettlementBatchDetailsConsole } from "@/modules/settlement/settlement-batch-details-console"; import { SettlementBatchDetailsConsole } from "@/modules/settlement/settlement-batch-details-console";
import { settlementModuleMeta } from "@/modules/settlement/meta"; import { settlementModuleMeta } from "@/modules/settlement/meta";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -12,7 +13,7 @@ export default async function AdminSettlementBatchDetailsPage(props: {
const { batchId } = await props.params; const { batchId } = await props.params;
const id = Number.parseInt(batchId, 10); const id = Number.parseInt(batchId, 10);
if (!Number.isFinite(id) || id < 1) { if (!Number.isFinite(id) || id < 1) {
return <p className="text-destructive text-sm"> ID</p>; return <InvalidSettlementBatchId />;
} }
return <SettlementBatchDetailsConsole batchId={id} />; return <SettlementBatchDetailsConsole batchId={id} />;

View File

@@ -151,7 +151,11 @@
} }
.admin-list-field { .admin-list-field {
@apply flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:shrink-0; @apply flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:shrink-0 sm:gap-1.5;
}
.admin-list-field > label {
@apply w-auto min-w-0 shrink-0 whitespace-nowrap;
} }
.admin-list-actions { .admin-list-actions {

View File

@@ -19,7 +19,7 @@ export const metadata: Metadata = {
template: "%s · 彩票后台", template: "%s · 彩票后台",
default: "彩票后台", default: "彩票后台",
}, },
description: "Lottery 管理端", description: "彩票后台管理端",
}; };
export default function RootLayout({ export default function RootLayout({

View File

@@ -21,6 +21,23 @@ const DRAW_ROUTE_LABELS: Record<string, string> = {
results: "Results", results: "Results",
}; };
const NAV_TRANSLATION_KEYS: Record<string, string> = {
dashboard: "dashboard",
admin_users: "admin_users",
admin_roles: "admin_roles",
players: "players",
wallet: "wallet",
draws: "draws",
config: "config",
risk: "risk",
settlement: "settlement",
reconcile: "reconcile",
tickets: "tickets",
reports: "reports",
audit: "audit",
settings: "settings",
};
function titleCase(value: string): string { function titleCase(value: string): string {
return value return value
.split("-") .split("-")
@@ -60,22 +77,24 @@ export function AdminBreadcrumb() {
}); });
if (navItem && navItem.href !== ADMIN_BASE) { if (navItem && navItem.href !== ADMIN_BASE) {
const navLabelMap: Record<string, string> = { const translatedNavLabel =
dashboard: t("title", { ns: "dashboard" }), NAV_TRANSLATION_KEYS[navItem.segment] != null
reports: t("title", { ns: "reports" }), ? t(`nav.${NAV_TRANSLATION_KEYS[navItem.segment]}`, {
"audit-logs": t("title", { ns: "audit" }), ns: "common",
}; defaultValue: navItem.label,
})
: navItem.label;
breadcrumbs.push({ breadcrumbs.push({
label: label: translatedNavLabel,
navItem.segment === "draws"
? "Draws"
: navLabelMap[navItem.segment] ?? navItem.label,
href: navItem.href, href: navItem.href,
isCurrent: pathname === navItem.href || segments.length === 2, isCurrent: pathname === navItem.href || segments.length === 2,
}); });
} else { } else {
breadcrumbs.push({ breadcrumbs.push({
label: titleCase(businessSegment), label: t(`nav.${businessSegment}`, {
ns: "common",
defaultValue: titleCase(businessSegment),
}),
href: `${ADMIN_BASE}/${businessSegment}`, href: `${ADMIN_BASE}/${businessSegment}`,
isCurrent: segments.length === 2, isCurrent: segments.length === 2,
}); });

View File

@@ -20,6 +20,13 @@ import {
getAdminRequestLocale, getAdminRequestLocale,
type AdminApiLocale, type AdminApiLocale,
} from "@/lib/admin-locale"; } from "@/lib/admin-locale";
import { cn } from "@/lib/utils";
const LOCALE_FLAGS: Record<AdminApiLocale, string> = {
zh: "🇨🇳",
en: "🇺🇸",
ne: "🇳🇵",
};
export function AdminLanguageSwitcher() { export function AdminLanguageSwitcher() {
const { i18n, t } = useTranslation("common"); const { i18n, t } = useTranslation("common");
@@ -44,32 +51,60 @@ export function AdminLanguageSwitcher() {
); );
} }
const currentFlag = LOCALE_FLAGS[locale];
return ( return (
<DropdownMenu> <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"> <DropdownMenuTrigger className="inline-flex h-8 items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2 text-left text-slate-700 shadow-[0_1px_2px_rgba(15,23,42,0.04)] outline-none transition hover:border-slate-300 hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-ring">
<GlobeIcon className="size-4 stroke-[1.75]" aria-hidden /> <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs">
<span className="font-medium uppercase">{locale}</span> {currentFlag}
</span>
<GlobeIcon
className="size-4 shrink-0 stroke-[1.75] text-slate-400 sm:hidden"
aria-hidden
/>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[12rem]"> <DropdownMenuContent
<DropdownMenuGroup> align="end"
<DropdownMenuLabel className="text-xs text-muted-foreground"> className="w-[188px] overflow-hidden rounded-[20px] border border-slate-200 bg-white p-1 shadow-[0_16px_40px_rgba(15,23,42,0.12)]"
>
<DropdownMenuGroup className="space-y-0.5">
<DropdownMenuLabel className="sr-only">
{t("language.title")} {t("language.title")}
</DropdownMenuLabel> </DropdownMenuLabel>
{ADMIN_API_LOCALES.map((code) => ( {ADMIN_API_LOCALES.map((code) => {
const active = locale === code;
return (
<DropdownMenuItem <DropdownMenuItem
key={code} key={code}
className="gap-2" className={cn(
"flex min-h-[42px] items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 text-slate-700 outline-none transition",
active
? "border-rose-100 bg-rose-50 text-rose-600"
: "hover:bg-slate-50 focus:bg-slate-50",
)}
onClick={() => void onSelectLocale(code)} onClick={() => void onSelectLocale(code)}
> >
{locale === code ? ( <span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-white text-lg shadow-[inset_0_0_0_1px_rgba(148,163,184,0.16)]">
<CheckIcon className="size-4 opacity-100" /> {LOCALE_FLAGS[code]}
) : ( </span>
<span className="size-4 shrink-0" aria-hidden /> <span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[14px] font-semibold leading-5",
active ? "text-rose-600" : "text-slate-800",
)} )}
<span className="flex-1">{ADMIN_LOCALE_LABELS[code]}</span> >
<span className="text-xs text-muted-foreground uppercase">{code}</span> {ADMIN_LOCALE_LABELS[code]}
</span>
</span>
<span className="flex w-3 shrink-0 justify-end">
{active ? <CheckIcon className="size-3.5 text-rose-500" /> : null}
</span>
</DropdownMenuItem> </DropdownMenuItem>
))} );
})}
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -16,7 +16,7 @@ export function AdminShell({ children }: { children: ReactNode }) {
return ( return (
<SidebarProvider defaultOpen> <SidebarProvider defaultOpen>
<AdminAppSidebar /> <AdminAppSidebar />
<SidebarInset className="max-md:overflow-x-hidden"> <SidebarInset className="min-w-0 overflow-x-hidden max-md:overflow-x-hidden">
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card/90 px-4 shadow-[0_1px_0_rgb(216_230_251_/_45%)] backdrop-blur-md"> <header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card/90 px-4 shadow-[0_1px_0_rgb(216_230_251_/_45%)] backdrop-blur-md">
<SidebarTrigger /> <SidebarTrigger />
<Separator orientation="vertical" className="mr-1.5 h-4" /> <Separator orientation="vertical" className="mr-1.5 h-4" />
@@ -25,7 +25,7 @@ export function AdminShell({ children }: { children: ReactNode }) {
<ShellToolbar /> <ShellToolbar />
</div> </div>
</header> </header>
<div className="flex flex-1 flex-col px-4 pt-4 pb-6 md:px-5 md:pt-4 md:pb-6"> <div className="flex min-w-0 flex-1 flex-col overflow-x-hidden px-4 pt-4 pb-6 md:px-5 md:pt-4 md:pb-6">
{children} {children}
</div> </div>
</SidebarInset> </SidebarInset>

View File

@@ -91,7 +91,7 @@ export function AdminTableExportButton({
}; };
return ( return (
<Button type="button" size="sm" variant="secondary" className="h-8 px-3" onClick={onExport}> <Button type="button" size="sm" variant="secondary" onClick={onExport}>
<Download className="size-4" /> <Download className="size-4" />
{label ?? t("actions.exportExcel", { defaultValue: "导出 Excel" })} {label ?? t("actions.exportExcel", { defaultValue: "导出 Excel" })}
</Button> </Button>

View File

@@ -9,5 +9,5 @@ type ModuleScaffoldProps = {
/** 内容区容器;模块标题由侧栏导航体现,此处不再重复大标题与说明。 */ /** 内容区容器;模块标题由侧栏导航体现,此处不再重复大标题与说明。 */
export function ModuleScaffold({ children, className }: ModuleScaffoldProps) { export function ModuleScaffold({ children, className }: ModuleScaffoldProps) {
return <div className={cn("mx-auto w-full max-w-none", className)}>{children}</div>; return <div className={cn("mx-auto w-full max-w-none min-w-0", className)}>{children}</div>;
} }

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
BellIcon,
ChevronDownIcon, ChevronDownIcon,
LogOutIcon, LogOutIcon,
UserRoundIcon, UserRoundIcon,
@@ -11,8 +10,6 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher"; import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -22,47 +19,10 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { import {
useAdminProfile, useAdminProfile,
useAdminSessionStore, useAdminSessionStore,
} from "@/stores/admin-session"; } from "@/stores/admin-session";
import type { AdminProfile } from "@/types/api/admin-auth";
/** 暂未接入通知中心时的占位未读数(与设计稿一致可改为接口数据) */
const NOTIFICATION_PLACEHOLDER_COUNT = 6;
function initialsFromProfile(profile: AdminProfile | null): string {
if (!profile) {
return "—";
}
const s = (profile.nickname?.trim() || profile.username?.trim() || "").trim();
if (!s) {
return "?";
}
const runes = Array.from(s);
if (runes.length === 1) {
return runes[0].toUpperCase();
}
const cp = runes[0].codePointAt(0);
const isCjk =
cp !== undefined &&
((cp >= 0x4e00 && cp <= 0x9fff) ||
(cp >= 0x3400 && cp <= 0x4dbf) ||
(cp >= 0xf900 && cp <= 0xfaff));
if (isCjk) {
return runes.slice(0, 2).join("");
}
const parts = s.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
const a = parts[0][0];
const b = parts[1][0];
return `${a}${b}`.toUpperCase();
}
return s.slice(0, 2).toUpperCase();
}
export function ShellToolbar() { export function ShellToolbar() {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
@@ -84,40 +44,14 @@ export function ShellToolbar() {
return ( return (
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<Button
type="button"
variant="ghost"
size="icon"
className="relative shrink-0 text-primary hover:text-primary"
aria-label={t("toolbar.notifications")}
title={t("toolbar.notifications")}
onClick={() => toast.message(t("toolbar.notificationsComingSoon"))}
>
<BellIcon className="size-5 stroke-[1.75]" />
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-white shadow-sm ring-2 ring-background">
{NOTIFICATION_PLACEHOLDER_COUNT > 99
? "99+"
: NOTIFICATION_PLACEHOLDER_COUNT}
</span>
</Button>
<Separator orientation="vertical" className="mx-0.5 h-7" />
<AdminLanguageSwitcher /> <AdminLanguageSwitcher />
<Separator orientation="vertical" className="mx-0.5 h-7" />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger className="flex max-w-[min(100vw-8rem,14rem)] items-center gap-2 rounded-lg px-1.5 py-1 text-left outline-none hover:bg-muted/80 focus-visible:ring-2 focus-visible:ring-ring sm:max-w-[16rem]"> <DropdownMenuTrigger className="flex max-w-[min(100vw-8rem,14rem)] items-center gap-2 rounded-lg px-2 py-1.5 text-left outline-none hover:bg-muted/80 focus-visible:ring-2 focus-visible:ring-ring sm:max-w-[16rem]">
<Avatar size="sm" className="ring-1 ring-border"> <span className="min-w-0 flex-1 truncate text-sm font-semibold leading-tight">
<AvatarFallback className="bg-muted text-xs font-medium text-foreground">
{initialsFromProfile(adminProfile)}
</AvatarFallback>
</Avatar>
<span className="hidden min-w-0 flex-1 truncate text-sm font-semibold leading-tight sm:block">
{displayName} {displayName}
</span> </span>
<ChevronDownIcon className="hidden size-4 shrink-0 text-muted-foreground sm:block" /> <ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[12rem]"> <DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuGroup> <DropdownMenuGroup>

View File

@@ -2,8 +2,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 i18n from "@/i18n";
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";
@@ -16,7 +15,10 @@ type ProvidersProps = {
function AdminSessionHydrator() { function AdminSessionHydrator() {
useEffect(() => { useEffect(() => {
hydrateAdminUiLocale(); const locale = hydrateAdminUiLocale();
if (locale && i18n.resolvedLanguage !== locale) {
void i18n.changeLanguage(locale);
}
useAdminSessionStore.getState().rehydrate(); useAdminSessionStore.getState().rehydrate();
}, []); }, []);
@@ -25,12 +27,10 @@ function AdminSessionHydrator() {
export function Providers({ children }: ProvidersProps) { export function Providers({ children }: ProvidersProps) {
return ( return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider> <TooltipProvider>
<AdminSessionHydrator /> <AdminSessionHydrator />
{children} {children}
<Toaster /> <Toaster />
</TooltipProvider> </TooltipProvider>
</ThemeProvider>
); );
} }

View File

@@ -307,7 +307,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "relative flex w-full min-w-0 flex-1 flex-col bg-background md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className className
)} )}
{...props} {...props}

View File

@@ -1,15 +1,39 @@
"use client" "use client";
import { useTheme } from "next-themes" import { useEffect, useState } from "react";
import { Toaster as Sonner, type ToasterProps } from "sonner" import {
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react" CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const [theme, setTheme] = useState<ToasterProps["theme"]>("light");
useEffect(() => {
if (typeof window === "undefined") {
return undefined;
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const syncTheme = () => {
setTheme(mediaQuery.matches ? "dark" : "light");
};
syncTheme();
mediaQuery.addEventListener("change", syncTheme);
return () => {
mediaQuery.removeEventListener("change", syncTheme);
};
}, []);
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme}
className="toaster group" className="toaster group"
icons={{ icons={{
success: ( success: (
@@ -43,7 +67,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
}} }}
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import i18n from "i18next"; import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import { adminHtmlLang, applyAdminUiLocale, type AdminApiLocale } from "@/lib/admin-locale"; import { adminHtmlLang, applyAdminUiLocale, type AdminApiLocale } from "@/lib/admin-locale";
@@ -53,7 +52,7 @@ import zhWallet from "@/i18n/locales/zh/wallet.json";
export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const; export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const;
export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number]; export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number];
export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "en"; export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "zh";
const namespaces = ["common", "auth", "dashboard", "reports", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "wallet", "adminUsers", "config"] as const; const namespaces = ["common", "auth", "dashboard", "reports", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "wallet", "adminUsers", "config"] as const;
@@ -118,6 +117,14 @@ function normalizeAdminLanguage(lang: string | undefined): AdminLanguage {
return "en"; return "en";
} }
function getInitialAdminLanguage(): AdminLanguage {
if (typeof document === "undefined") {
return ADMIN_DEFAULT_LANGUAGE;
}
return normalizeAdminLanguage(document.documentElement.lang);
}
function syncAdminLanguage(lang: AdminLanguage): void { function syncAdminLanguage(lang: AdminLanguage): void {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
document.documentElement.lang = adminHtmlLang(lang); document.documentElement.lang = adminHtmlLang(lang);
@@ -127,20 +134,15 @@ function syncAdminLanguage(lang: AdminLanguage): void {
if (!i18n.isInitialized) { if (!i18n.isInitialized) {
void i18n void i18n
.use(LanguageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
resources, resources,
lng: getInitialAdminLanguage(),
fallbackLng: ADMIN_DEFAULT_LANGUAGE, fallbackLng: ADMIN_DEFAULT_LANGUAGE,
supportedLngs: [...ADMIN_SUPPORTED_LANGUAGES], supportedLngs: [...ADMIN_SUPPORTED_LANGUAGES],
defaultNS: "common", defaultNS: "common",
ns: [...namespaces], ns: [...namespaces],
load: "languageOnly", load: "languageOnly",
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
lookupLocalStorage: "lottery_admin_ui_locale",
},
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
}, },

View File

@@ -14,6 +14,17 @@
"saveAccountFailed": "Failed to save account", "saveAccountFailed": "Failed to save account",
"deleteSuccess": "Deleted {{name}}", "deleteSuccess": "Deleted {{name}}",
"deleteFailed": "Delete failed", "deleteFailed": "Delete failed",
"roleLoadFailed": "Failed to load role list",
"roleListTitle": "Role Management",
"createRole": "Create role",
"roleCreateSuccess": "Created role {{name}}",
"roleUpdateSuccess": "Updated role {{name}}",
"roleSaveFailed": "Failed to save role",
"roleDeleteSuccess": "Deleted role {{name}}",
"roleDeleteFailed": "Failed to delete role",
"rolePermissionSaveSuccess": "Role permissions updated",
"rolePermissionSaveFailed": "Failed to save role permissions",
"roleFormRequired": "Role name and slug are required",
"allPermissions": "All permissions", "allPermissions": "All permissions",
"saveRoleSuccess": "Updated roles for {{name}}", "saveRoleSuccess": "Updated roles for {{name}}",
"saveRoleFailed": "Failed to save roles", "saveRoleFailed": "Failed to save roles",
@@ -36,6 +47,10 @@
"enabled": "Enabled", "enabled": "Enabled",
"disabled": "Disabled" "disabled": "Disabled"
}, },
"roleType": {
"system": "System",
"custom": "Custom"
},
"actions": { "actions": {
"permissions": "Assign roles", "permissions": "Assign roles",
"edit": "Edit", "edit": "Edit",
@@ -43,6 +58,18 @@
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save" "save": "Save"
}, },
"roleTable": {
"name": "Role",
"slug": "Role Code",
"type": "Type",
"status": "Status",
"users": "Users",
"permissions": "Permission count",
"actions": "Actions"
},
"roleActions": {
"permissions": "Permissions"
},
"permissionDialog": { "permissionDialog": {
"title": "Assign roles", "title": "Assign roles",
"rolesTitle": "Roles", "rolesTitle": "Roles",
@@ -51,6 +78,18 @@
"selectedRoles": "Selected roles:", "selectedRoles": "Selected roles:",
"saveRoles": "Save roles" "saveRoles": "Save roles"
}, },
"rolePermissionDialog": {
"title": "Role Permissions"
},
"roleDialog": {
"createTitle": "Create Role",
"editTitle": "Edit Role",
"description": "Roles group backend function permissions and are then assigned to admin accounts.",
"slug": "Role code",
"name": "Role name",
"descriptionLabel": "Role description",
"status": "Status"
},
"accountDialog": { "accountDialog": {
"createTitle": "Create admin", "createTitle": "Create admin",
"editTitle": "Edit account", "editTitle": "Edit account",
@@ -75,5 +114,60 @@
"rowActionTitle": "Delete this admin", "rowActionTitle": "Delete this admin",
"confirmTitle": "Confirm deletion", "confirmTitle": "Confirm deletion",
"confirmDescription": "Delete admin {{name}}? This action cannot be undone." "confirmDescription": "Delete admin {{name}}? This action cannot be undone."
},
"roleDelete": {
"confirmTitle": "Delete Role",
"confirmDescription": "Delete role {{name}}?"
},
"permissionGroups": {
"all": "All Permissions",
"dashboard": "Dashboard",
"admin_users": "Admin Users",
"admin_roles": "Role Management",
"players": "Players",
"wallet": "Wallet",
"draws": "Draws",
"config": "Configuration",
"risk": "Risk",
"settlement": "Settlement",
"jackpot": "Jackpot",
"reconcile": "Reconcile",
"tickets": "Tickets",
"reports": "Reports",
"audit": "Audit Logs",
"settings": "Settings"
},
"permissionNames": {
"prd.admin_user.manage": "Admin Users · Manage",
"prd.admin_role.manage": "Role Management · Manage",
"prd.users.manage": "Players · Manage",
"prd.users.view_finance": "Players · View Finance",
"prd.users.view_cs": "Players · View Customer Service Cases",
"prd.player_freeze.manage": "Freeze/Unfreeze Player · Manage",
"prd.wallet_reconcile.manage": "Wallet Reconcile · Manage",
"prd.wallet_reconcile.view": "Wallet Reconcile · View",
"prd.wallet_reconcile.view_cs": "Wallet Reconcile · Customer Service View",
"prd.wallet_adjust.manage": "Adjustment/Reversal · Manage",
"prd.draw_result.manage": "Draw Results Entry · Manage",
"prd.draw_result.view": "Draw Results · View",
"prd.draw_reopen.manage": "Draw Reopen · Manage",
"prd.play_switch.manage": "Play Switches · Manage",
"prd.odds.manage": "Odds Configuration · Manage",
"prd.risk_cap.manage": "Risk Caps · Manage",
"prd.risk_cap.view": "Risk Caps · View",
"prd.rebate.manage": "Commission/Rebate · Manage",
"prd.rebate.view": "Commission/Rebate · View",
"prd.jackpot.manage": "Jackpot Configuration · Manage",
"prd.jackpot.view": "Jackpot Configuration · View",
"prd.payout.manage": "Payout Confirmation · Manage",
"prd.payout.review": "Payout Confirmation · Review",
"prd.payout.view": "Payout Confirmation · View",
"prd.report.all": "Reports · All",
"prd.report.risk": "Reports · Risk",
"prd.report.finance": "Reports · Finance",
"prd.report.player": "Reports · Single Player",
"prd.audit.all": "Audit Logs · All",
"prd.audit.self": "Audit Logs · Related to Self",
"prd.audit.finance": "Audit Logs · Finance Related"
} }
} }

View File

@@ -46,6 +46,9 @@
"errors": { "errors": {
"loadFailed": "Failed to load" "loadFailed": "Failed to load"
}, },
"table": {
"id": "ID"
},
"toolbar": { "toolbar": {
"defaultAdmin": "Administrator", "defaultAdmin": "Administrator",
"notifications": "Notifications", "notifications": "Notifications",
@@ -57,6 +60,7 @@
"home": "Home", "home": "Home",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"admin_users": "Admin Users", "admin_users": "Admin Users",
"admin_roles": "Role Management",
"players": "Players", "players": "Players",
"wallet": "Wallet", "wallet": "Wallet",
"draws": "Draws", "draws": "Draws",
@@ -65,7 +69,7 @@
"settlement": "Settlement", "settlement": "Settlement",
"jackpot": "Jackpot", "jackpot": "Jackpot",
"reconcile": "Reconcile", "reconcile": "Reconcile",
"tickets": "Tickets", "tickets": "Ticket list",
"reports": "Reports", "reports": "Reports",
"audit": "Audit Logs", "audit": "Audit Logs",
"settings": "Settings" "settings": "Settings"

View File

@@ -5,15 +5,14 @@
"sidebarTitle": "Operations configuration", "sidebarTitle": "Operations configuration",
"groups": { "groups": {
"betting": "Betting and display", "betting": "Betting and display",
"risk_wallet": "Risk and funds" "risk": "Risk control"
}, },
"items": { "items": {
"plays": "Play types and limits", "plays": "Play types and limits",
"odds": "Odds", "odds": "Odds",
"rebate": "Commission / rebate", "rebate": "Commission / rebate",
"jackpot": "Jackpot pool", "jackpot": "Jackpot pool",
"risk-cap": "Payout caps", "risk-cap": "Payout caps"
"wallet": "Wallet thresholds"
} }
}, },
"versionStatus": { "versionStatus": {
@@ -70,6 +69,31 @@
}, },
"discard": "Discard changes" "discard": "Discard changes"
}, },
"system": {
"title": "Draw and settlement runtime settings",
"runtimeTitle": "Global runtime parameters",
"runtimeIntro1": "This area stores global system parameters that do not belong to play, odds, or risk-control versions. They directly affect wallet transfers, job switches, and runtime policy.",
"runtimeIntro2": "Play, odds, rebate, and cap management stay under operations configuration. System settings only carry cross-module runtime parameters to avoid overlapping responsibilities in admin.",
"description": "Controls review flow after RNG draw generation, cooldown duration, and automatic settlement behavior. These are global runtime policies and do not belong to versioned operations config.",
"loadFailed": "Failed to load system settings",
"saveSuccess": "System settings saved",
"saveFailed": "Failed to save system settings",
"fields": {
"manualReview": "Require manual review for draw results",
"cooldownMinutes": "Cooldown duration (minutes)",
"autoSettlement": "Run settlement automatically"
},
"hints": {
"manualReview": "When enabled, RNG draw results enter pending review and must be published manually in admin.",
"cooldownMinutes": "How long to wait after publishing before entering settling. Use 0 to settle immediately.",
"autoSettlement": "When disabled, tick will not run settlement automatically and admins must trigger it manually."
},
"states": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"discard": "Discard changes"
},
"play": { "play": {
"batchGroups": { "batchGroups": {
"d2": "2D Global", "d2": "2D Global",
@@ -79,6 +103,164 @@
"position": "Position Plays", "position": "Position Plays",
"box": "Box Plays", "box": "Box Plays",
"jackpot": "Jackpot" "jackpot": "Jackpot"
},
"validation": {
"minMaxInvalid": "{{playCode}}: min bet cannot exceed max bet"
},
"publishFailed": "Publish failed",
"createDraftSuccess": "Created draft v{{version}}",
"createDraftFailed": "Failed to create draft",
"ruleSavedLocal": "Rule text was saved into the local draft. Save the draft to persist it.",
"deleteFailed": "Delete failed",
"activeVersion": "Active version v{{version}}",
"readOnlyHint": "Limits and rules are read-only. Create a draft first.",
"batchSwitchesTitle": "Batch switches",
"batchSwitchesDesc": "Only updates the current draft. The player betting table refreshes after save and publish.",
"readOnlyDraftHint": "Current version is read-only. Create a draft first.",
"batchEnabledCount": "{{enabledCount}}/{{total}} enabled",
"noPlayTypes": "No play types",
"actions": {
"enable": "Enable",
"disable": "Disable",
"ruleText": "Rule text"
},
"table": {
"playCode": "Play code",
"category": "Category",
"status": "Status",
"displayName": "Display name",
"order": "Order",
"minBet": "Min bet",
"maxBet": "Max bet",
"actions": "Actions"
},
"states": {
"enabled": "Enabled",
"disabled": "Disabled",
"readOnly": "Read only"
},
"aria": {
"enablePlay": "Enable {{playCode}}"
},
"ruleDialog": {
"title": "Rule text (Chinese)",
"description": "Play {{playCode}}. Changes stay in the draft until you save and publish it.",
"fieldLabel": "rule_text_zh",
"apply": "Apply to draft"
}
},
"odds": {
"tabs": {
"all": "All"
},
"category": "Category",
"playType": "Play type",
"noPlayTypes": "No play types in this category.",
"sheetDescription": "Choose a version to view here. Non-draft versions can be rolled back into a new draft.",
"activeVersionPrefix": "Active version:",
"readOnlyHint": "This version is read-only. Create a draft before editing odds.",
"loadingDetails": "Loading details…",
"multiplier": "Multiplier x{{value}} · {{currency}}",
"missingScopeRow": "Missing {{scope}} row. Check seed or version data.",
"rebateRate": "Rebate rate (%)",
"rebateRateHint": "Writes rebate_rate to all prize scopes under this play type.",
"publishFailed": "Publish failed",
"createDraftSuccess": "Created draft v{{version}}",
"createDraftFailed": "Failed to create draft",
"rollbackSuccess": "Cloned v{{fromVersion}} into new draft v{{version}}",
"rollbackFailed": "Rollback failed",
"deleteFailed": "Delete failed",
"rollbackDialog": {
"title": "Confirm rollback",
"description": "A new draft will be cloned from version v{{version}}. The active version will not be overwritten directly.",
"confirm": "Confirm rollback"
},
"publishDialog": {
"title": "Publish odds version?",
"description": "New odds affect new tickets immediately. Existing successful tickets still settle by their saved odds snapshot.",
"confirm": "Confirm publish",
"columns": {
"prizeScope": "Prize scope",
"currentActive": "Current active",
"afterPublish": "After publish"
}
}
},
"rebate": {
"sheetDescription": "Rebate is stored in the odds draft version and shares the same version set as odds.",
"publishLabel": "Publish",
"publishSuccess": "Published odds version with rebate",
"publishFailed": "Publish failed",
"createDraftSuccess": "Created draft v{{version}}",
"createDraftFailed": "Failed to create draft",
"deleteFailed": "Delete failed",
"editingVersion": "Editing version v{{version}} · {{status}}",
"readOnlyHint": "Create a draft before editing rebate.",
"fields": {
"d2": "2D rebate rate (%)",
"d3": "3D rebate rate (%)",
"d4": "4D rebate rate (%)"
},
"winEnjoy": {
"label": "Apply rebate on winning tickets",
"description": "Placeholder field. It can later be aligned with risk and settlement rules and persisted."
},
"effectiveTime": "Effective time (current active odds version)"
},
"riskCap": {
"validation": {
"requireAtLeastOne": "At least one cap row is required",
"defaultGreaterThanZero": "Default cap amount must be greater than 0",
"numberMustBe4Digits": "Number must be 4 digits: {{number}}",
"enterValidCapAmount": "Enter a valid cap amount"
},
"publishFailed": "Publish failed",
"createDraftSuccess": "Created draft v{{version}}",
"createDraftFailed": "Failed to create draft",
"savedLocalDraft": "Saved into local draft. Save the draft to persist it.",
"deleteFailed": "Delete failed",
"effectiveAt": "Effective at: {{value}}",
"note": "Note: {{value}}",
"readOnlyHint": "Read only. Create a draft first.",
"readOnly": "Read only",
"defaultCap": {
"title": "Default cap",
"description": "Numbers without a special cap use this default cap template.",
"fieldLabel": "Cap amount (minor unit)"
},
"specialCaps": {
"title": "Special caps"
},
"loadingDetails": "Loading details…",
"noDetailRows": "No detail rows.",
"table": {
"number": "Number",
"capAmount": "Cap amount",
"used": "Used",
"remaining": "Remaining",
"soldOut": "Sold out",
"ratio": "Ratio",
"actions": "Actions"
},
"occupancy": {
"title": "All number occupancy",
"description": "Placeholder view: filters and exports still need ticket-summary integration. Data below still comes from the current draft list.",
"searchLabel": "Search number",
"searchPlaceholder": "e.g. 8888",
"filterPending": "Sold-out / high-risk preset filter is pending integration",
"exportPending": "CSV export is pending integration"
},
"actions": {
"update": "Update",
"addSpecialCap": "+ Add special cap",
"filterPresets": "Filter presets…",
"exportCsv": "Export CSV",
"close": "Close"
},
"syncDialog": {
"title": "Sync default cap",
"description": "The default cap template will be set to {{value}}. This only changes the draft. Save and publish after confirming.",
"confirm": "Confirm"
} }
} }
} }

View File

@@ -4,7 +4,7 @@
"loadFailed": "Failed to load", "loadFailed": "Failed to load",
"saveSuccess": "Saved", "saveSuccess": "Saved",
"saveFailed": "Save failed", "saveFailed": "Save failed",
"invalidDrawId": "Enter a valid draw ID", "invalidDrawId": "Enter a valid draw number",
"manualBurstSuccess": "Jackpot burst triggered manually", "manualBurstSuccess": "Jackpot burst triggered manually",
"manualBurstFailed": "Manual burst failed", "manualBurstFailed": "Manual burst failed",
"noPoolData": "No pool data", "noPoolData": "No pool data",
@@ -21,7 +21,7 @@
"enabled": "Enabled", "enabled": "Enabled",
"saving": "Saving…", "saving": "Saving…",
"save": "Save", "save": "Save",
"manualBurstDrawId": "Manual burst draw ID", "manualBurstDrawId": "Manual burst draw number",
"manualBurstAmount": "Burst amount (empty for all)", "manualBurstAmount": "Burst amount (empty for all)",
"processing": "Processing…", "processing": "Processing…",
"manualBurst": "Manual burst", "manualBurst": "Manual burst",
@@ -31,12 +31,22 @@
"apply": "Apply", "apply": "Apply",
"payoutRecords": "Jackpot payout records", "payoutRecords": "Jackpot payout records",
"contributionRecords": "Jackpot contribution records", "contributionRecords": "Jackpot contribution records",
"recordsPage": {
"title": "Jackpot records",
"description": "Payout records and pool contribution flows"
},
"subnavLabel": "Jackpot sub navigation", "subnavLabel": "Jackpot sub navigation",
"subnavPools": "Pool configuration", "subnavPools": "Pool configuration",
"subnavRecords": "Records", "subnavRecords": "Records",
"payoutLoadFailed": "Failed to load payout records", "payoutLoadFailed": "Failed to load payout records",
"contributionLoadFailed": "Failed to load contribution records", "contributionLoadFailed": "Failed to load contribution records",
"trigger": "Trigger", "trigger": "Trigger",
"triggerTypes": {
"threshold": "Threshold reached",
"forced_gap": "Forced by draw gap",
"play_combo": "Triggered by play combo",
"manual": "Manual trigger"
},
"payoutAmount": "Payout amount", "payoutAmount": "Payout amount",
"winnerCount": "Winner count", "winnerCount": "Winner count",
"time": "Time", "time": "Time",

View File

@@ -18,6 +18,17 @@
"createdAt": "Created at", "createdAt": "Created at",
"id": "ID", "id": "ID",
"empty": "No data", "empty": "No data",
"formatOptions": {
"csv": "CSV",
"xlsx": "Excel"
},
"statusOptions": {
"pending": "Pending",
"queued": "Queued",
"running": "Running",
"completed": "Completed",
"failed": "Failed"
},
"reportTypes": { "reportTypes": {
"draw_profit_summary": "Draw profit summary", "draw_profit_summary": "Draw profit summary",
"daily_profit_summary": "Daily profit summary", "daily_profit_summary": "Daily profit summary",

View File

@@ -58,6 +58,7 @@
"matchedTier": "Matched tier", "matchedTier": "Matched tier",
"regularPayout": "Regular payout", "regularPayout": "Regular payout",
"loadingDetails": "Loading details…", "loadingDetails": "Loading details…",
"invalidBatchId": "Invalid settlement batch number",
"statusOptions": { "statusOptions": {
"all": "All", "all": "All",
"running": "Running", "running": "Running",
@@ -67,5 +68,10 @@
"paid": "Paid", "paid": "Paid",
"completed": "Completed", "completed": "Completed",
"failed": "Failed" "failed": "Failed"
},
"reviewStatusOptions": {
"pending": "Pending review",
"approved": "Approved",
"rejected": "Rejected"
} }
} }

View File

@@ -1,19 +1,43 @@
{ {
"title": "Tickets", "title": "Ticket list",
"playerTicketQuery": "Player ticket query", "playerTicketQuery": "Ticket query",
"playerId": "Player ID", "playerId": "Player ID / account",
"invalidPlayerId": "Enter a valid player ID", "invalidPlayerId": "Enter a valid player ID or account",
"drawNoOptional": "Draw no. (optional)", "playerIdPlaceholder": "Leave blank for all tickets; enter player ID or account",
"drawNoOptional": "Draw number (optional)",
"drawNoPlaceholder": "For example 20260520-001", "drawNoPlaceholder": "For example 20260520-001",
"numberKeyword": "Number / ticket / order",
"numberKeywordPlaceholder": "Search by number, ticket no. or order no.",
"placedDateRange": "Placed date range",
"query": "Query", "query": "Query",
"resetFilters": "Reset filters",
"refreshCurrentPage": "Refresh current page",
"loadFailed": "Failed to load", "loadFailed": "Failed to load",
"ticketNo": "Ticket no.", "ticketNo": "Ticket no.",
"player": "Player",
"orderNo": "Order no.", "orderNo": "Order no.",
"drawNo": "Draw no.", "drawNo": "Draw no.",
"playCode": "Play", "playCode": "Play",
"number": "Number", "number": "Number",
"betAmount": "Bet amount",
"actualDeduct": "Actual deduct", "actualDeduct": "Actual deduct",
"status": "Status", "status": "Status",
"failReason": "Fail reason", "failReason": "Fail reason",
"winAmount": "Win amount" "winAmount": "Win amount",
"placedAt": "Placed at",
"updatedAt": "Updated at",
"statusFilterLabel": "Status filter",
"statusHint": "Multiple selection supported. Leave empty for all statuses.",
"statusSelectedCount": "{{count}} selected",
"statusOptions": {
"all": "All",
"pending_confirm": "Pending confirmation",
"partial_pending_confirm": "Partially pending confirmation",
"success": "Bet placed",
"failed": "Bet failed",
"pending_payout": "Pending payout",
"settled_win": "Settled win",
"settled_lose": "Settled loss"
},
"allTickets": "All tickets"
} }

View File

@@ -14,6 +14,17 @@
"saveAccountFailed": "खाता सुरक्षित गर्न असफल", "saveAccountFailed": "खाता सुरक्षित गर्न असफल",
"deleteSuccess": "{{name}} मेटाइयो", "deleteSuccess": "{{name}} मेटाइयो",
"deleteFailed": "मेटाउन असफल", "deleteFailed": "मेटाउन असफल",
"roleLoadFailed": "भूमिका सूची लोड असफल भयो",
"roleListTitle": "भूमिका व्यवस्थापन",
"createRole": "भूमिका थप्नुहोस्",
"roleCreateSuccess": "भूमिका {{name}} सिर्जना भयो",
"roleUpdateSuccess": "भूमिका {{name}} अपडेट भयो",
"roleSaveFailed": "भूमिका सुरक्षित गर्न असफल",
"roleDeleteSuccess": "भूमिका {{name}} मेटाइयो",
"roleDeleteFailed": "भूमिका मेटाउन असफल",
"rolePermissionSaveSuccess": "भूमिका अनुमति अद्यावधिक भयो",
"rolePermissionSaveFailed": "भूमिका अनुमति सुरक्षित गर्न असफल",
"roleFormRequired": "भूमिका नाम र slug अनिवार्य छन्",
"allPermissions": "सबै अनुमति", "allPermissions": "सबै अनुमति",
"saveRoleSuccess": "{{name}} को भूमिका अपडेट भयो", "saveRoleSuccess": "{{name}} को भूमिका अपडेट भयो",
"saveRoleFailed": "भूमिका सुरक्षित गर्न असफल", "saveRoleFailed": "भूमिका सुरक्षित गर्न असफल",
@@ -36,6 +47,10 @@
"enabled": "सक्रिय", "enabled": "सक्रिय",
"disabled": "निष्क्रिय" "disabled": "निष्क्रिय"
}, },
"roleType": {
"system": "सिस्टम",
"custom": "अनुकूलित"
},
"actions": { "actions": {
"permissions": "भूमिका तोक्नुहोस्", "permissions": "भूमिका तोक्नुहोस्",
"edit": "सम्पादन", "edit": "सम्पादन",
@@ -43,6 +58,18 @@
"cancel": "रद्द गर्नुहोस्", "cancel": "रद्द गर्नुहोस्",
"save": "सेभ गर्नुहोस्" "save": "सेभ गर्नुहोस्"
}, },
"roleTable": {
"name": "भूमिका",
"slug": "角色 कोड",
"type": "प्रकार",
"status": "स्थिति",
"users": "सम्बन्धित प्रयोगकर्ता",
"permissions": "अनुमति संख्या",
"actions": "कार्य"
},
"roleActions": {
"permissions": "अनुमति"
},
"permissionDialog": { "permissionDialog": {
"title": "भूमिका तोक्नुहोस्", "title": "भूमिका तोक्नुहोस्",
"rolesTitle": "भूमिका", "rolesTitle": "भूमिका",
@@ -51,6 +78,18 @@
"selectedRoles": "हाल छनोट गरिएका भूमिका:", "selectedRoles": "हाल छनोट गरिएका भूमिका:",
"saveRoles": "भूमिका सेभ गर्नुहोस्" "saveRoles": "भूमिका सेभ गर्नुहोस्"
}, },
"rolePermissionDialog": {
"title": "भूमिका अनुमति"
},
"roleDialog": {
"createTitle": "भूमिका थप्नुहोस्",
"editTitle": "भूमिका सम्पादन",
"description": "भूमिकाले ब्याकएन्ड कार्य अनुमति समेट्छ र पछि प्रशासक खातालाई बाँडिन्छ।",
"slug": "भूमिका कोड",
"name": "भूमिका नाम",
"descriptionLabel": "भूमिका विवरण",
"status": "स्थिति"
},
"accountDialog": { "accountDialog": {
"createTitle": "प्रशासक सिर्जना", "createTitle": "प्रशासक सिर्जना",
"editTitle": "खाता सम्पादन", "editTitle": "खाता सम्पादन",
@@ -75,5 +114,60 @@
"rowActionTitle": "यो प्रशासक मेटाउनुहोस्", "rowActionTitle": "यो प्रशासक मेटाउनुहोस्",
"confirmTitle": "मेटाउने पुष्टि", "confirmTitle": "मेटाउने पुष्टि",
"confirmDescription": "प्रशासक {{name}} मेटाउने? यो कार्य फिर्ता लिन सकिँदैन।" "confirmDescription": "प्रशासक {{name}} मेटाउने? यो कार्य फिर्ता लिन सकिँदैन।"
},
"roleDelete": {
"confirmTitle": "भूमिका मेटाउने पुष्टि",
"confirmDescription": "भूमिका {{name}} मेटाउने?"
},
"permissionGroups": {
"all": "सबै अनुमति",
"dashboard": "ड्यासबोर्ड",
"admin_users": "प्रशासक सूची",
"admin_roles": "भूमिका व्यवस्थापन",
"players": "खेलाडी सूची",
"wallet": "वालेट",
"draws": "ड्रअ सूची",
"config": "कन्फिगरेसन",
"risk": "जोखिम",
"settlement": "सेटलमेन्ट",
"jackpot": "ज्याकपोट",
"reconcile": "मिलान",
"tickets": "टिकटहरू",
"reports": "रिपोर्टहरू",
"audit": "अडिट लग",
"settings": "सेटिङ"
},
"permissionNames": {
"prd.admin_user.manage": "प्रशासक सूची · व्यवस्थापन",
"prd.admin_role.manage": "भूमिका व्यवस्थापन · व्यवस्थापन",
"prd.users.manage": "खेलाडी व्यवस्थापन · व्यवस्थापन",
"prd.users.view_finance": "खेलाडी व्यवस्थापन · वित्त हेर्नुहोस्",
"prd.users.view_cs": "खेलाडी व्यवस्थापन · ग्राहक सेवा एकल प्रयोगकर्ता",
"prd.player_freeze.manage": "खेलाडी रोक्ने/फुकाउने · व्यवस्थापन",
"prd.wallet_reconcile.manage": "वालेट मिलान · व्यवस्थापन",
"prd.wallet_reconcile.view": "वालेट मिलान · हेर्नुहोस्",
"prd.wallet_reconcile.view_cs": "वालेट मिलान · ग्राहक सेवा दृश्य",
"prd.wallet_adjust.manage": "समायोजन/रिभर्सल · व्यवस्थापन",
"prd.draw_result.manage": "ड्रअ परिणाम प्रविष्टि · व्यवस्थापन",
"prd.draw_result.view": "ड्रअ परिणाम · हेर्नुहोस्",
"prd.draw_reopen.manage": "ड्रअ पुनःखोल्ने · व्यवस्थापन",
"prd.play_switch.manage": "प्ले स्विच · व्यवस्थापन",
"prd.odds.manage": "ओड्स कन्फिगरेसन · व्यवस्थापन",
"prd.risk_cap.manage": "जोखिम सीमा · व्यवस्थापन",
"prd.risk_cap.view": "जोखिम सीमा · हेर्नुहोस्",
"prd.rebate.manage": "कमिसन/रिबेट · व्यवस्थापन",
"prd.rebate.view": "कमिसन/रिबेट · हेर्नुहोस्",
"prd.jackpot.manage": "ज्याकपोट कन्फिगरेसन · व्यवस्थापन",
"prd.jackpot.view": "ज्याकपोट कन्फिगरेसन · हेर्नुहोस्",
"prd.payout.manage": "भुक्तानी पुष्टि · व्यवस्थापन",
"prd.payout.review": "भुक्तानी पुष्टि · समीक्षा",
"prd.payout.view": "भुक्तानी पुष्टि · हेर्नुहोस्",
"prd.report.all": "रिपोर्ट · सबै",
"prd.report.risk": "रिपोर्ट · जोखिम",
"prd.report.finance": "रिपोर्ट · वित्त",
"prd.report.player": "रिपोर्ट · एकल खेलाडी",
"prd.audit.all": "अडिट लग · सबै",
"prd.audit.self": "अडिट लग · आफूसँग सम्बन्धित",
"prd.audit.finance": "अडिट लग · वित्त सम्बन्धित"
} }
} }

View File

@@ -46,6 +46,9 @@
"errors": { "errors": {
"loadFailed": "लोड असफल भयो" "loadFailed": "लोड असफल भयो"
}, },
"table": {
"id": "ID"
},
"toolbar": { "toolbar": {
"defaultAdmin": "प्रशासक", "defaultAdmin": "प्रशासक",
"notifications": "सूचना", "notifications": "सूचना",
@@ -57,6 +60,7 @@
"home": "गृह", "home": "गृह",
"dashboard": "ड्यासबोर्ड", "dashboard": "ड्यासबोर्ड",
"admin_users": "प्रशासक सूची", "admin_users": "प्रशासक सूची",
"admin_roles": "भूमिका व्यवस्थापन",
"players": "खेलाडी सूची", "players": "खेलाडी सूची",
"wallet": "वालेट", "wallet": "वालेट",
"draws": "ड्रअहरू", "draws": "ड्रअहरू",
@@ -65,7 +69,7 @@
"settlement": "सेटलमेन्ट", "settlement": "सेटलमेन्ट",
"jackpot": "Jackpot", "jackpot": "Jackpot",
"reconcile": "मिलान", "reconcile": "मिलान",
"tickets": "टिकटहरू", "tickets": "टिकट सूची",
"reports": "रिपोर्टहरू", "reports": "रिपोर्टहरू",
"audit": "अडिट लग", "audit": "अडिट लग",
"settings": "सेटिङ" "settings": "सेटिङ"

View File

@@ -5,15 +5,14 @@
"sidebarTitle": "सञ्चालन कन्फिगरेसन", "sidebarTitle": "सञ्चालन कन्फिगरेसन",
"groups": { "groups": {
"betting": "बेटिङ र प्रदर्शन", "betting": "बेटिङ र प्रदर्शन",
"risk_wallet": "जोखिम र कोष" "risk": "जोखिम नियन्त्रण"
}, },
"items": { "items": {
"plays": "खेल प्रकार र सीमा", "plays": "खेल प्रकार र सीमा",
"odds": "अड्स", "odds": "अड्स",
"rebate": "कमिसन / रिबेट", "rebate": "कमिसन / रिबेट",
"jackpot": "Jackpot पूल", "jackpot": "Jackpot पूल",
"risk-cap": "पेमेन्ट क्याप", "risk-cap": "पेमेन्ट क्याप"
"wallet": "वालेट थ्रेसहोल्ड"
} }
}, },
"versionStatus": { "versionStatus": {
@@ -70,6 +69,31 @@
}, },
"discard": "परिवर्तन त्याग्नुहोस्" "discard": "परिवर्तन त्याग्नुहोस्"
}, },
"system": {
"title": "ड्रअ र सेटलमेन्ट रनटाइम सेटिङ",
"runtimeTitle": "ग्लोबल रनटाइम प्यारामिटर",
"runtimeIntro1": "यहाँ खेल प्रकार, अड्स वा जोखिम संस्करणमा नपर्ने ग्लोबल प्रणाली प्यारामिटर राखिन्छ। यसले वालेट ट्रान्सफर, कार्य स्विच र प्रणाली सञ्चालन नीतिमा सीधा असर गर्छ।",
"runtimeIntro2": "खेल प्रकार, अड्स, रिबेट र क्याप अझै पनि सञ्चालन कन्फिगरेसनमै रहन्छन्। प्रणाली सेटिङले मात्र क्रस-मोड्युल रनटाइम प्यारामिटर सम्हाल्छ ताकि प्रशासनिक जिम्मेवारी नदोहोरोस्।",
"description": "RNG ड्रअपछि समीक्षा प्रवाह, कूलडाउन अवधि र स्वचालित सेटलमेन्ट व्यवहार नियन्त्रण गर्छ। यी ग्लोबल रनटाइम नीति हुन् र संस्करणयुक्त सञ्चालन कन्फिगरेसनमा पर्दैनन्।",
"loadFailed": "प्रणाली सेटिङ लोड असफल भयो",
"saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो",
"saveFailed": "प्रणाली सेटिङ सुरक्षित गर्न असफल",
"fields": {
"manualReview": "ड्रअ परिणामका लागि म्यानुअल समीक्षा चाहिने",
"cooldownMinutes": "कूलडाउन अवधि (मिनेट)",
"autoSettlement": "सेटलमेन्ट स्वतः चलाउने"
},
"hints": {
"manualReview": "सक्रिय हुँदा RNG ड्रअ परिणाम pending review मा जान्छ र एडमिनबाट म्यानुअल रूपमा प्रकाशित गर्नुपर्छ।",
"cooldownMinutes": "प्रकाशनपछि settling मा जानुअघि कति समय पर्खने। 0 राखे तुरुन्त सेटलमेन्ट सुरु हुन्छ।",
"autoSettlement": "बन्द हुँदा tick ले सेटलमेन्ट स्वतः चलाउँदैन र एडमिनले म्यानुअल रूपमा ट्रिगर गर्नुपर्छ।"
},
"states": {
"enabled": "सक्रिय",
"disabled": "बन्द"
},
"discard": "परिवर्तन त्याग्नुहोस्"
},
"play": { "play": {
"batchGroups": { "batchGroups": {
"d2": "2D ग्लोबल", "d2": "2D ग्लोबल",
@@ -79,6 +103,164 @@
"position": "स्थिति खेलहरू", "position": "स्थिति खेलहरू",
"box": "बक्स खेलहरू", "box": "बक्स खेलहरू",
"jackpot": "Jackpot" "jackpot": "Jackpot"
},
"validation": {
"minMaxInvalid": "{{playCode}}: न्यूनतम बेट अधिकतम बेटभन्दा ठूलो हुन सक्दैन"
},
"publishFailed": "प्रकाशन असफल भयो",
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
"ruleSavedLocal": "नियम पाठ स्थानीय ड्राफ्टमा सुरक्षित भयो। स्थायी बनाउन ड्राफ्ट सेभ गर्नुहोस्।",
"deleteFailed": "मेटाउन असफल",
"activeVersion": "हाल सक्रिय संस्करण v{{version}}",
"readOnlyHint": "सीमा र नियमहरू केवल पढ्न मिल्ने छन्। पहिले ड्राफ्ट बनाउनुहोस्।",
"batchSwitchesTitle": "समूह स्विचहरू",
"batchSwitchesDesc": "यसले हालको ड्राफ्ट मात्र अपडेट गर्छ। सेभ र प्रकाशित गरेपछि खेलाडीको बेटिङ तालिका रिफ्रेस हुन्छ।",
"readOnlyDraftHint": "हालको संस्करण केवल पढ्न मिल्ने छ। पहिले ड्राफ्ट बनाउनुहोस्।",
"batchEnabledCount": "{{enabledCount}}/{{total}} सक्रिय",
"noPlayTypes": "खेल प्रकार छैन",
"actions": {
"enable": "सक्रिय",
"disable": "निष्क्रिय",
"ruleText": "नियम पाठ"
},
"table": {
"playCode": "खेल कोड",
"category": "श्रेणी",
"status": "स्थिति",
"displayName": "प्रदर्शित नाम",
"order": "क्रम",
"minBet": "न्यूनतम बेट",
"maxBet": "अधिकतम बेट",
"actions": "कार्य"
},
"states": {
"enabled": "सक्रिय",
"disabled": "बन्द",
"readOnly": "केवल पढ्न मिल्ने"
},
"aria": {
"enablePlay": "{{playCode}} सक्रिय गर्ने"
},
"ruleDialog": {
"title": "नियम पाठ (Chinese)",
"description": "खेल {{playCode}}। परिवर्तनहरू सेभ र प्रकाशित नगरेसम्म ड्राफ्टमै रहन्छन्।",
"fieldLabel": "rule_text_zh",
"apply": "ड्राफ्टमा लागू गर्नुहोस्"
}
},
"odds": {
"tabs": {
"all": "सबै"
},
"category": "श्रेणी",
"playType": "खेल प्रकार",
"noPlayTypes": "यस श्रेणीमा खेल प्रकार छैन।",
"sheetDescription": "यहाँ हेर्नका लागि एउटा संस्करण छान्नुहोस्। गैर-ड्राफ्ट संस्करणलाई नयाँ ड्राफ्टमा रोलब्याक गर्न सकिन्छ।",
"activeVersionPrefix": "हाल सक्रिय संस्करण:",
"readOnlyHint": "यो संस्करण केवल पढ्न मिल्ने छ। अड्स सम्पादन गर्नुअघि ड्राफ्ट बनाउनुहोस्।",
"loadingDetails": "विवरण लोड हुँदैछ…",
"multiplier": "गुणक x{{value}} · {{currency}}",
"missingScopeRow": "{{scope}} को row हराइरहेको छ। seed वा version data जाँच गर्नुहोस्।",
"rebateRate": "रिबेट दर (%)",
"rebateRateHint": "यसले यो खेल प्रकारअन्तर्गत सबै prize scope मा rebate_rate लेख्छ।",
"publishFailed": "प्रकाशन असफल भयो",
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
"rollbackSuccess": "v{{fromVersion}} बाट नयाँ ड्राफ्ट v{{version}} क्लोन गरियो",
"rollbackFailed": "रोलब्याक असफल भयो",
"deleteFailed": "मेटाउन असफल",
"rollbackDialog": {
"title": "रोलब्याक पुष्टि गर्नुहोस्",
"description": "संस्करण v{{version}} बाट नयाँ ड्राफ्ट क्लोन हुनेछ। हाल सक्रिय संस्करण प्रत्यक्ष रूपमा ओभरराइट हुँदैन।",
"confirm": "रोलब्याक पुष्टि गर्नुहोस्"
},
"publishDialog": {
"title": "अड्स संस्करण प्रकाशित गर्ने?",
"description": "नयाँ अड्सले तुरुन्तै नयाँ टिकटहरूमा असर गर्छ। सफल भइसकेका टिकटहरू आफ्नो सुरक्षित odds snapshot अनुसार नै सेटल हुन्छन्।",
"confirm": "प्रकाशन पुष्टि गर्नुहोस्",
"columns": {
"prizeScope": "पुरस्कार दायरा",
"currentActive": "हाल सक्रिय",
"afterPublish": "प्रकाशनपछि"
}
}
},
"rebate": {
"sheetDescription": "रिबेट अड्स ड्राफ्ट संस्करणमा राखिन्छ र अड्ससँग एउटै संस्करण सेट साझा गर्छ।",
"publishLabel": "प्रकाशन",
"publishSuccess": "रिबेटसहितको अड्स संस्करण प्रकाशित भयो",
"publishFailed": "प्रकाशन असफल भयो",
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
"deleteFailed": "मेटाउन असफल",
"editingVersion": "सम्पादन भइरहेको संस्करण v{{version}} · {{status}}",
"readOnlyHint": "रिबेट सम्पादन गर्नुअघि ड्राफ्ट बनाउनुहोस्।",
"fields": {
"d2": "2D रिबेट दर (%)",
"d3": "3D रिबेट दर (%)",
"d4": "4D रिबेट दर (%)"
},
"winEnjoy": {
"label": "जितेका टिकटहरूमा पनि रिबेट लागू गर्ने",
"description": "यो placeholder field हो। पछि risk र settlement नियमसँग मिलाएर स्थायी रूपमा राख्न सकिन्छ।"
},
"effectiveTime": "लागू समय (हाल सक्रिय अड्स संस्करण)"
},
"riskCap": {
"validation": {
"requireAtLeastOne": "कम्तीमा एक क्याप row आवश्यक छ",
"defaultGreaterThanZero": "पूर्वनिर्धारित क्याप रकम 0 भन्दा ठूलो हुनुपर्छ",
"numberMustBe4Digits": "नम्बर 4 अङ्कको हुनुपर्छ: {{number}}",
"enterValidCapAmount": "मान्य क्याप रकम प्रविष्ट गर्नुहोस्"
},
"publishFailed": "प्रकाशन असफल भयो",
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
"savedLocalDraft": "स्थानीय ड्राफ्टमा सुरक्षित भयो। स्थायी बनाउन ड्राफ्ट सेभ गर्नुहोस्।",
"deleteFailed": "मेटाउन असफल",
"effectiveAt": "लागू समय: {{value}}",
"note": "टिप्पणी: {{value}}",
"readOnlyHint": "केवल पढ्न मिल्ने। पहिले ड्राफ्ट बनाउनुहोस्।",
"readOnly": "केवल पढ्न मिल्ने",
"defaultCap": {
"title": "पूर्वनिर्धारित क्याप",
"description": "विशेष क्याप नभएका नम्बरहरूमा यही पूर्वनिर्धारित क्याप टेम्प्लेट लागू हुन्छ।",
"fieldLabel": "क्याप रकम (सानो एकाइ)"
},
"specialCaps": {
"title": "विशेष क्यापहरू"
},
"loadingDetails": "विवरण लोड हुँदैछ…",
"noDetailRows": "विवरण row छैन।",
"table": {
"number": "नम्बर",
"capAmount": "क्याप रकम",
"used": "प्रयोग भएको",
"remaining": "बाँकी",
"soldOut": "सोल्ड आउट",
"ratio": "अनुपात",
"actions": "कार्य"
},
"occupancy": {
"title": "सबै नम्बर occupancy",
"description": "यो placeholder दृश्य हो। filter र export ले ticket-summary एकीकरण अझै चाहिन्छ। तलको data अहिले पनि हालको ड्राफ्ट सूचीबाट आउँछ।",
"searchLabel": "नम्बर खोज्नुहोस्",
"searchPlaceholder": "जस्तै 8888",
"filterPending": "Sold-out / high-risk preset filter अझै एकीकृत भएको छैन",
"exportPending": "CSV export अझै एकीकृत भएको छैन"
},
"actions": {
"update": "अपडेट",
"addSpecialCap": "+ विशेष क्याप थप्नुहोस्",
"filterPresets": "प्रिसेट फिल्टर…",
"exportCsv": "CSV निर्यात",
"close": "बन्द"
},
"syncDialog": {
"title": "पूर्वनिर्धारित क्याप मिलाउनुहोस्",
"description": "पूर्वनिर्धारित क्याप टेम्प्लेट {{value}} मा सेट हुनेछ। यसले ड्राफ्ट मात्र बदल्छ। पुष्टि पछि सेभ र प्रकाशित गर्नुहोस्।",
"confirm": "पुष्टि"
} }
} }
} }

View File

@@ -4,7 +4,7 @@
"loadFailed": "लोड असफल भयो", "loadFailed": "लोड असफल भयो",
"saveSuccess": "सुरक्षित भयो", "saveSuccess": "सुरक्षित भयो",
"saveFailed": "सुरक्षित गर्न असफल", "saveFailed": "सुरक्षित गर्न असफल",
"invalidDrawId": "मान्य ड्रअ ID लेख्नुहोस्", "invalidDrawId": "मान्य ड्रअ नम्बर लेख्नुहोस्",
"manualBurstSuccess": "Jackpot म्यानुअल रूपमा ट्रिगर भयो", "manualBurstSuccess": "Jackpot म्यानुअल रूपमा ट्रिगर भयो",
"manualBurstFailed": "म्यानुअल बर्स्ट असफल भयो", "manualBurstFailed": "म्यानुअल बर्स्ट असफल भयो",
"noPoolData": "पूल डाटा छैन", "noPoolData": "पूल डाटा छैन",
@@ -21,7 +21,7 @@
"enabled": "खुला", "enabled": "खुला",
"saving": "सुरक्षित हुँदैछ…", "saving": "सुरक्षित हुँदैछ…",
"save": "सुरक्षित गर्नुहोस्", "save": "सुरक्षित गर्नुहोस्",
"manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ ID", "manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ नम्बर",
"manualBurstAmount": "बर्स्ट रकम (खाली भए सबै)", "manualBurstAmount": "बर्स्ट रकम (खाली भए सबै)",
"processing": "प्रक्रियामा…", "processing": "प्रक्रियामा…",
"manualBurst": "म्यानुअल बर्स्ट", "manualBurst": "म्यानुअल बर्स्ट",
@@ -31,12 +31,22 @@
"apply": "लागू गर्नुहोस्", "apply": "लागू गर्नुहोस्",
"payoutRecords": "Jackpot भुक्तानी रेकर्ड", "payoutRecords": "Jackpot भुक्तानी रेकर्ड",
"contributionRecords": "Jackpot योगदान रेकर्ड", "contributionRecords": "Jackpot योगदान रेकर्ड",
"recordsPage": {
"title": "Jackpot रेकर्ड",
"description": "भुक्तानी रेकर्ड र पूल योगदान प्रवाह"
},
"subnavLabel": "Jackpot उपनेभिगेसन", "subnavLabel": "Jackpot उपनेभिगेसन",
"subnavPools": "पूल कन्फिगरेसन", "subnavPools": "पूल कन्फिगरेसन",
"subnavRecords": "रेकर्ड", "subnavRecords": "रेकर्ड",
"payoutLoadFailed": "भुक्तानी रेकर्ड लोड असफल भयो", "payoutLoadFailed": "भुक्तानी रेकर्ड लोड असफल भयो",
"contributionLoadFailed": "योगदान रेकर्ड लोड असफल भयो", "contributionLoadFailed": "योगदान रेकर्ड लोड असफल भयो",
"trigger": "ट्रिगर", "trigger": "ट्रिगर",
"triggerTypes": {
"threshold": "सीमा पुगेपछि",
"forced_gap": "लगातार नफुट्दा जबरजस्ती ट्रिगर",
"play_combo": "निर्दिष्ट प्ले कम्बो ट्रिगर",
"manual": "म्यानुअल ट्रिगर"
},
"payoutAmount": "भुक्तानी रकम", "payoutAmount": "भुक्तानी रकम",
"winnerCount": "विजेता संख्या", "winnerCount": "विजेता संख्या",
"time": "समय", "time": "समय",

View File

@@ -3,7 +3,7 @@
"createExport": "निर्यात सिर्जना", "createExport": "निर्यात सिर्जना",
"reportType": "रिपोर्ट प्रकार", "reportType": "रिपोर्ट प्रकार",
"exportFormat": "निर्यात ढाँचा", "exportFormat": "निर्यात ढाँचा",
"filterJson": "filter_json (वैकल्पिक)", "filterJson": "फिल्टर JSON (वैकल्पिक)",
"parseFilterFailed": "फिल्टर JSON पार्स गर्न सकिएन", "parseFilterFailed": "फिल्टर JSON पार्स गर्न सकिएन",
"createSuccess": "निर्यात कार्य सिर्जना भयो", "createSuccess": "निर्यात कार्य सिर्जना भयो",
"createFailed": "कार्य सिर्जना असफल भयो", "createFailed": "कार्य सिर्जना असफल भयो",
@@ -18,6 +18,17 @@
"createdAt": "सिर्जना समय", "createdAt": "सिर्जना समय",
"id": "ID", "id": "ID",
"empty": "डाटा छैन", "empty": "डाटा छैन",
"formatOptions": {
"csv": "CSV",
"xlsx": "Excel"
},
"statusOptions": {
"pending": "पेन्डिङ",
"queued": "पर्खाइमा",
"running": "चल्दैछ",
"completed": "सम्पन्न",
"failed": "असफल"
},
"reportTypes": { "reportTypes": {
"draw_profit_summary": "ड्रअ नाफा सारांश", "draw_profit_summary": "ड्रअ नाफा सारांश",
"daily_profit_summary": "दैनिक नाफा सारांश", "daily_profit_summary": "दैनिक नाफा सारांश",

View File

@@ -58,6 +58,7 @@
"matchedTier": "मिलेको स्तर", "matchedTier": "मिलेको स्तर",
"regularPayout": "सामान्य भुक्तानी", "regularPayout": "सामान्य भुक्तानी",
"loadingDetails": "विवरण लोड हुँदैछ…", "loadingDetails": "विवरण लोड हुँदैछ…",
"invalidBatchId": "अमान्य सेटलमेन्ट ब्याच नम्बर",
"statusOptions": { "statusOptions": {
"all": "सबै", "all": "सबै",
"running": "चलिरहेको", "running": "चलिरहेको",
@@ -67,5 +68,10 @@
"paid": "भुक्तानी भयो", "paid": "भुक्तानी भयो",
"completed": "सम्पन्न", "completed": "सम्पन्न",
"failed": "असफल" "failed": "असफल"
},
"reviewStatusOptions": {
"pending": "समीक्षा बाँकी",
"approved": "स्वीकृत",
"rejected": "अस्वीकृत"
} }
} }

View File

@@ -1,19 +1,42 @@
{ {
"title": "टिकट", "title": "टिकट सूची",
"playerTicketQuery": "खेलाडी टिकट खोज", "playerTicketQuery": "टिकट खोज",
"playerId": "खेलाडी ID", "playerId": "खेलाडी ID / खाता",
"invalidPlayerId": "मान्य खेलाडी ID लेख्नुहोस्", "invalidPlayerId": "मान्य खेलाडी ID वा खाता लेख्नुहोस्",
"drawNoOptional": "ड्रअ नं. (वैकल्पिक)", "playerIdPlaceholder": "सबै देखाउन खाली छोड्नुहोस्; ID वा खाता लेख्नुहोस्",
"drawNoOptional": "ड्रअ नम्बर (वैकल्पिक)",
"drawNoPlaceholder": "जस्तै 20260520-001", "drawNoPlaceholder": "जस्तै 20260520-001",
"numberKeyword": "नम्बर / टिकट / अर्डर",
"numberKeywordPlaceholder": "नम्बर, टिकट नं. वा अर्डर नं. बाट खोज्नुहोस्",
"placedDateRange": "बेट गरिएको मिति दायरा",
"query": "खोज", "query": "खोज",
"resetFilters": "फिल्टर रिसेट",
"refreshCurrentPage": "हालको पृष्ठ रिफ्रेस",
"loadFailed": "लोड असफल भयो", "loadFailed": "लोड असफल भयो",
"ticketNo": "टिकट नं.", "ticketNo": "टिकट नं.",
"player": "खेलाडी",
"orderNo": "अर्डर नं.", "orderNo": "अर्डर नं.",
"drawNo": "ड्रअ नं.", "drawNo": "ड्रअ नं.",
"playCode": "प्ले", "playCode": "प्ले",
"number": "नम्बर", "number": "नम्बर",
"betAmount": "बेट रकम",
"actualDeduct": "कटौती", "actualDeduct": "कटौती",
"status": "स्थिति", "status": "स्थिति",
"failReason": "असफल कारण", "failReason": "असफल कारण",
"winAmount": "जित रकम" "winAmount": "जित रकम",
"placedAt": "बेट समय",
"updatedAt": "अपडेट समय",
"statusFilterLabel": "स्थिति फिल्टर",
"statusHint": "धेरै चयन गर्न सकिन्छ। खाली छोडे सबै स्थिति देखिन्छ।",
"statusOptions": {
"all": "सबै",
"pending_confirm": "पुष्टि बाँकी",
"partial_pending_confirm": "आंशिक पुष्टि बाँकी",
"success": "बेट सफल",
"failed": "बेट असफल",
"pending_payout": "भुक्तानी बाँकी",
"settled_win": "जित सेटल भयो",
"settled_lose": "हार सेटल भयो"
},
"allTickets": "सबै टिकट"
} }

View File

@@ -60,7 +60,7 @@
}, },
"roleTable": { "roleTable": {
"name": "角色", "name": "角色",
"slug": "标识", "slug": "角色编码",
"type": "类型", "type": "类型",
"status": "状态", "status": "状态",
"users": "关联用户", "users": "关联用户",
@@ -85,7 +85,7 @@
"createTitle": "新增角色", "createTitle": "新增角色",
"editTitle": "编辑角色", "editTitle": "编辑角色",
"description": "角色用于归拢后台功能权限,再分配给管理员账号。", "description": "角色用于归拢后台功能权限,再分配给管理员账号。",
"slug": "角色标识", "slug": "角色编码",
"name": "角色名称", "name": "角色名称",
"descriptionLabel": "角色说明", "descriptionLabel": "角色说明",
"status": "状态" "status": "状态"
@@ -118,5 +118,56 @@
"roleDelete": { "roleDelete": {
"confirmTitle": "删除角色", "confirmTitle": "删除角色",
"confirmDescription": "确认删除角色 {{name}}" "confirmDescription": "确认删除角色 {{name}}"
},
"permissionGroups": {
"all": "全部权限",
"dashboard": "仪表盘",
"admin_users": "管理列表",
"admin_roles": "角色管理",
"players": "玩家列表",
"wallet": "钱包流水",
"draws": "期号列表",
"config": "运营配置",
"risk": "风控",
"settlement": "结算",
"jackpot": "奖池",
"reconcile": "对账",
"tickets": "玩家注单",
"reports": "报表导出",
"audit": "审计日志",
"settings": "系统设置"
},
"permissionNames": {
"prd.admin_user.manage": "管理员列表·可管理",
"prd.admin_role.manage": "角色管理·可管理",
"prd.users.manage": "用户管理·可管理",
"prd.users.view_finance": "用户管理·财务查看",
"prd.users.view_cs": "用户管理·客服单用户",
"prd.player_freeze.manage": "冻结/解冻玩家·可管理",
"prd.wallet_reconcile.manage": "钱包对账·可管理",
"prd.wallet_reconcile.view": "钱包对账·查看",
"prd.wallet_reconcile.view_cs": "钱包对账·客服单用户",
"prd.wallet_adjust.manage": "补单/冲正·可管理",
"prd.draw_result.manage": "开奖结果录入·可管理",
"prd.draw_result.view": "开奖结果·查看",
"prd.draw_reopen.manage": "开奖结果重开·可管理",
"prd.play_switch.manage": "玩法开关·可管理",
"prd.odds.manage": "赔率配置·可管理",
"prd.risk_cap.manage": "封顶配置·可管理",
"prd.risk_cap.view": "封顶配置·查看",
"prd.rebate.manage": "佣金/回水·可管理",
"prd.rebate.view": "佣金/回水·查看",
"prd.jackpot.manage": "奖池配置·可管理",
"prd.jackpot.view": "奖池配置·查看",
"prd.payout.manage": "派彩确认·可管理",
"prd.payout.review": "派彩确认·可审核",
"prd.payout.view": "派彩确认·查看",
"prd.report.all": "报表·全部",
"prd.report.risk": "报表·风控",
"prd.report.finance": "报表·财务",
"prd.report.player": "报表·单用户",
"prd.audit.all": "审计日志·全部",
"prd.audit.self": "审计日志·自身相关",
"prd.audit.finance": "审计日志·资金相关"
} }
} }

View File

@@ -1,10 +1,10 @@
{ {
"title": "审计日志", "title": "审计日志",
"moduleCode": "模块编码", "moduleCode": "模块",
"actionCode": "动作编码", "actionCode": "动作",
"operatorType": "操作者类型", "operatorType": "操作者类型",
"exactMatch": "精确匹配", "exactMatch": "请输入完整名称",
"operatorTypePlaceholder": "如 admin / system", "operatorTypePlaceholder": "如管理员、系统",
"operator": "操作者", "operator": "操作者",
"module": "模块", "module": "模块",
"action": "动作", "action": "动作",

View File

@@ -46,6 +46,9 @@
"errors": { "errors": {
"loadFailed": "加载失败" "loadFailed": "加载失败"
}, },
"table": {
"id": "ID"
},
"toolbar": { "toolbar": {
"defaultAdmin": "管理员", "defaultAdmin": "管理员",
"notifications": "通知", "notifications": "通知",
@@ -64,9 +67,9 @@
"config": "运营配置", "config": "运营配置",
"risk": "风控", "risk": "风控",
"settlement": "结算", "settlement": "结算",
"jackpot": "Jackpot", "jackpot": "奖池",
"reconcile": "对账", "reconcile": "对账",
"tickets": "玩家注单", "tickets": "注单列表",
"reports": "报表导出", "reports": "报表导出",
"audit": "审计日志", "audit": "审计日志",
"settings": "系统设置" "settings": "系统设置"

View File

@@ -5,15 +5,14 @@
"sidebarTitle": "运营配置导航", "sidebarTitle": "运营配置导航",
"groups": { "groups": {
"betting": "投注与展示", "betting": "投注与展示",
"risk_wallet": "风控与资金" "risk": "风控"
}, },
"items": { "items": {
"plays": "玩法与限额", "plays": "玩法与限额",
"odds": "赔率", "odds": "赔率",
"rebate": "佣金 / 回水", "rebate": "佣金 / 回水",
"jackpot": "Jackpot 奖池", "jackpot": "奖池配置",
"risk-cap": "赔付封顶", "risk-cap": "赔付封顶"
"wallet": "钱包阈值"
} }
}, },
"versionStatus": { "versionStatus": {
@@ -70,6 +69,31 @@
}, },
"discard": "放弃更改" "discard": "放弃更改"
}, },
"system": {
"title": "开奖与结算运行参数",
"runtimeTitle": "全局运行参数",
"runtimeIntro1": "这里放不属于玩法版本、赔率版本、风控版本的全局系统参数。它们会直接影响钱包转账、任务开关或系统运行策略。",
"runtimeIntro2": "玩法、赔率、回水、封顶仍然统一放在“运营配置”里管理;系统设置只承接跨模块的运行参数,避免后台入口职责重叠。",
"description": "用于控制 RNG 开奖后的审核流转、冷静期时长和系统自动结算行为。这些参数属于全局运行策略,不跟随玩法/赔率版本发布。",
"loadFailed": "系统设置加载失败",
"saveSuccess": "系统设置已保存",
"saveFailed": "系统设置保存失败",
"fields": {
"manualReview": "开奖结果必须人工审核",
"cooldownMinutes": "冷静期时长(分钟)",
"autoSettlement": "自动执行结算"
},
"hints": {
"manualReview": "开启后RNG 开奖结果会先进入待审核,必须由后台人工发布。",
"cooldownMinutes": "结果发布后等待多久再进入 settling。填 0 表示发布后直接进入结算。",
"autoSettlement": "关闭后tick 不会自动跑结算,只能由后台手工执行。"
},
"states": {
"enabled": "已开启",
"disabled": "已关闭"
},
"discard": "放弃更改"
},
"play": { "play": {
"batchGroups": { "batchGroups": {
"d2": "2D 全局", "d2": "2D 全局",
@@ -78,7 +102,165 @@
"big-small": "Big / Small", "big-small": "Big / Small",
"position": "位置类玩法", "position": "位置类玩法",
"box": "包号类玩法", "box": "包号类玩法",
"jackpot": "Jackpot" "jackpot": "奖池"
},
"validation": {
"minMaxInvalid": "{{playCode}}:最小下注额不能大于最大下注额"
},
"publishFailed": "发布失败",
"createDraftSuccess": "已创建草稿 v{{version}}",
"createDraftFailed": "创建草稿失败",
"ruleSavedLocal": "规则文案已写入本地草稿,记得保存草稿后再发布。",
"deleteFailed": "删除失败",
"activeVersion": "当前生效版本 v{{version}}",
"readOnlyHint": "当前限额与规则为只读,请先创建草稿。",
"batchSwitchesTitle": "批量开关",
"batchSwitchesDesc": "这里只会修改当前草稿;保存并发布后,玩家下注表会按新配置刷新。",
"readOnlyDraftHint": "当前版本为只读,请先创建草稿。",
"batchEnabledCount": "{{enabledCount}}/{{total}} 已开启",
"noPlayTypes": "暂无玩法",
"actions": {
"enable": "开启",
"disable": "关闭",
"ruleText": "规则文案"
},
"table": {
"playCode": "玩法编码",
"category": "分类",
"status": "状态",
"displayName": "显示名称",
"order": "排序",
"minBet": "最小下注",
"maxBet": "最大下注",
"actions": "操作"
},
"states": {
"enabled": "开启",
"disabled": "关闭",
"readOnly": "只读"
},
"aria": {
"enablePlay": "切换 {{playCode}} 启用状态"
},
"ruleDialog": {
"title": "规则文案(中文)",
"description": "玩法 {{playCode}};修改内容只会暂存到草稿,保存并发布后才会生效。",
"fieldLabel": "中文规则文案",
"apply": "应用到草稿"
}
},
"odds": {
"tabs": {
"all": "全部"
},
"category": "分类",
"playType": "玩法类型",
"noPlayTypes": "该分类下暂无玩法。",
"sheetDescription": "选择一个版本在此查看;非草稿版本可以回滚成新的草稿。",
"activeVersionPrefix": "当前生效版本:",
"readOnlyHint": "当前版本为只读,请先创建草稿后再修改赔率。",
"loadingDetails": "正在加载详情…",
"multiplier": "倍数 x{{value}} · {{currency}}",
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
"rebateRate": "回水比例 (%)",
"rebateRateHint": "会把 rebate_rate 写入该玩法下所有奖级范围。",
"publishFailed": "发布失败",
"createDraftSuccess": "已创建草稿 v{{version}}",
"createDraftFailed": "创建草稿失败",
"rollbackSuccess": "已从 v{{fromVersion}} 克隆出新草稿 v{{version}}",
"rollbackFailed": "回滚失败",
"deleteFailed": "删除失败",
"rollbackDialog": {
"title": "确认回滚",
"description": "系统会基于版本 v{{version}} 克隆出新的草稿,不会直接覆盖当前生效版本。",
"confirm": "确认回滚"
},
"publishDialog": {
"title": "确认发布赔率版本?",
"description": "新赔率会立即影响后续新注单;已成功下注的历史注单仍按各自保存的赔率快照结算。",
"confirm": "确认发布",
"columns": {
"prizeScope": "奖级范围",
"currentActive": "当前生效",
"afterPublish": "发布后"
}
}
},
"rebate": {
"sheetDescription": "回水配置存放在赔率草稿版本中,与赔率共用同一套版本记录。",
"publishLabel": "发布",
"publishSuccess": "已发布带回水的赔率版本",
"publishFailed": "发布失败",
"createDraftSuccess": "已创建草稿 v{{version}}",
"createDraftFailed": "创建草稿失败",
"deleteFailed": "删除失败",
"editingVersion": "当前编辑版本 v{{version}} · {{status}}",
"readOnlyHint": "修改回水前请先创建草稿。",
"fields": {
"d2": "2D 回水比例 (%)",
"d3": "3D 回水比例 (%)",
"d4": "4D 回水比例 (%)"
},
"winEnjoy": {
"label": "中奖注单也应用回水",
"description": "这是预留字段,后续可和风控、结算规则对齐后再真正落库存储。"
},
"effectiveTime": "生效时间(当前赔率生效版本)"
},
"riskCap": {
"validation": {
"requireAtLeastOne": "至少需要一条封顶配置",
"defaultGreaterThanZero": "默认封顶金额必须大于 0",
"numberMustBe4Digits": "号码必须为 4 位数字:{{number}}",
"enterValidCapAmount": "请输入有效的封顶金额"
},
"publishFailed": "发布失败",
"createDraftSuccess": "已创建草稿 v{{version}}",
"createDraftFailed": "创建草稿失败",
"savedLocalDraft": "已写入本地草稿,记得保存草稿后再发布。",
"deleteFailed": "删除失败",
"effectiveAt": "生效时间:{{value}}",
"note": "备注:{{value}}",
"readOnlyHint": "当前为只读,请先创建草稿。",
"readOnly": "只读",
"defaultCap": {
"title": "默认封顶",
"description": "没有单独特殊封顶的号码,统一使用这条默认封顶模板。",
"fieldLabel": "封顶金额(最小单位)"
},
"specialCaps": {
"title": "特殊封顶"
},
"loadingDetails": "正在加载详情…",
"noDetailRows": "暂无明细行。",
"table": {
"number": "号码",
"capAmount": "封顶金额",
"used": "已占用",
"remaining": "剩余额度",
"soldOut": "售罄",
"ratio": "占比",
"actions": "操作"
},
"occupancy": {
"title": "全号码占用视图",
"description": "这里还是占位视图,筛选和导出后续还需要接入真实注单汇总;下方数据目前仍来自当前草稿列表。",
"searchLabel": "搜索号码",
"searchPlaceholder": "例如 8888",
"filterPending": "售罄 / 高风险预设筛选尚未接入",
"exportPending": "CSV 导出尚未接入"
},
"actions": {
"update": "更新",
"addSpecialCap": "+ 新增特殊封顶",
"filterPresets": "筛选预设…",
"exportCsv": "导出 CSV",
"close": "关闭"
},
"syncDialog": {
"title": "同步默认封顶",
"description": "默认封顶模板将被设为 {{value}}。这次只会修改草稿,确认后仍需保存并发布。",
"confirm": "确认"
} }
} }
} }

View File

@@ -5,7 +5,7 @@
"todayBetTotal": "当期投注总额", "todayBetTotal": "当期投注总额",
"currentDrawFinanceSummary": "当前大厅期财务汇总", "currentDrawFinanceSummary": "当前大厅期财务汇总",
"currentPayout": "当期派彩", "currentPayout": "当期派彩",
"payoutSummary": "中奖派彩 + Jackpot", "payoutSummary": "中奖派彩 + 奖池",
"currentProfit": "当期平台盈亏", "currentProfit": "当期平台盈亏",
"profitFormula": "投注 派彩(近似)", "profitFormula": "投注 派彩(近似)",
"currentDraw": "当前期号", "currentDraw": "当前期号",

View File

@@ -1,6 +1,6 @@
{ {
"title": "奖池", "title": "奖池",
"configTitle": "Jackpot 奖池配置", "configTitle": "奖池配置",
"loadFailed": "加载失败", "loadFailed": "加载失败",
"saveSuccess": "已保存", "saveSuccess": "已保存",
"saveFailed": "保存失败", "saveFailed": "保存失败",
@@ -29,14 +29,24 @@
"drawNo": "期号", "drawNo": "期号",
"optional": "可选", "optional": "可选",
"apply": "应用", "apply": "应用",
"payoutRecords": "Jackpot 派彩记录", "payoutRecords": "奖池派彩记录",
"contributionRecords": "Jackpot 蓄水记录", "contributionRecords": "奖池蓄水记录",
"subnavLabel": "Jackpot 子导航", "recordsPage": {
"title": "奖池记录",
"description": "派彩记录与奖池蓄水流水"
},
"subnavLabel": "奖池子导航",
"subnavPools": "奖池配置", "subnavPools": "奖池配置",
"subnavRecords": "记录", "subnavRecords": "记录",
"payoutLoadFailed": "派彩记录加载失败", "payoutLoadFailed": "派彩记录加载失败",
"contributionLoadFailed": "蓄水记录加载失败", "contributionLoadFailed": "蓄水记录加载失败",
"trigger": "触发", "trigger": "触发",
"triggerTypes": {
"threshold": "达到爆池阈值",
"forced_gap": "连续未爆强制触发",
"play_combo": "指定玩法组合触发",
"manual": "手动触发"
},
"payoutAmount": "派彩额", "payoutAmount": "派彩额",
"winnerCount": "中奖人数", "winnerCount": "中奖人数",
"time": "时间", "time": "时间",

View File

@@ -3,7 +3,7 @@
"createExport": "新建导出", "createExport": "新建导出",
"reportType": "报表类型", "reportType": "报表类型",
"exportFormat": "导出格式", "exportFormat": "导出格式",
"filterJson": "filter_json(可选)", "filterJson": "筛选条件 JSON(可选)",
"parseFilterFailed": "筛选 JSON 无法解析", "parseFilterFailed": "筛选 JSON 无法解析",
"createSuccess": "已创建导出任务", "createSuccess": "已创建导出任务",
"createFailed": "创建失败", "createFailed": "创建失败",
@@ -18,6 +18,17 @@
"createdAt": "创建时间", "createdAt": "创建时间",
"id": "ID", "id": "ID",
"empty": "无数据", "empty": "无数据",
"formatOptions": {
"csv": "CSV",
"xlsx": "Excel"
},
"statusOptions": {
"pending": "待处理",
"queued": "排队中",
"running": "执行中",
"completed": "已完成",
"failed": "失败"
},
"reportTypes": { "reportTypes": {
"draw_profit_summary": "期号盈亏", "draw_profit_summary": "期号盈亏",
"daily_profit_summary": "每日盈亏汇总", "daily_profit_summary": "每日盈亏汇总",

View File

@@ -18,7 +18,7 @@
"winCount": "中奖笔数", "winCount": "中奖笔数",
"payoutTotal": "派彩合计", "payoutTotal": "派彩合计",
"platformProfit": "盈亏", "platformProfit": "盈亏",
"jackpot": "Jackpot", "jackpot": "奖池",
"finishedAt": "完成时间", "finishedAt": "完成时间",
"details": "明细", "details": "明细",
"approve": "审核通过", "approve": "审核通过",
@@ -36,7 +36,7 @@
"ticketTotal": "注单数", "ticketTotal": "注单数",
"winTotal": "中奖笔数", "winTotal": "中奖笔数",
"payoutAmount": "派彩合计", "payoutAmount": "派彩合计",
"jackpotPayout": "Jackpot 划出", "jackpotPayout": "奖池划出",
"profitFormula": "盈亏 = 总实扣 - 总派彩", "profitFormula": "盈亏 = 总实扣 - 总派彩",
"startedAt": "开始", "startedAt": "开始",
"endedAt": "结束", "endedAt": "结束",
@@ -58,6 +58,7 @@
"matchedTier": "匹配档", "matchedTier": "匹配档",
"regularPayout": "常规派彩", "regularPayout": "常规派彩",
"loadingDetails": "加载明细…", "loadingDetails": "加载明细…",
"invalidBatchId": "无效的结算批次编号",
"statusOptions": { "statusOptions": {
"all": "不限", "all": "不限",
"running": "进行中", "running": "进行中",
@@ -67,5 +68,10 @@
"paid": "已派奖", "paid": "已派奖",
"completed": "已完成", "completed": "已完成",
"failed": "失败" "failed": "失败"
},
"reviewStatusOptions": {
"pending": "待审核",
"approved": "已通过",
"rejected": "已驳回"
} }
} }

View File

@@ -1,19 +1,43 @@
{ {
"title": "注单", "title": "注单列表",
"playerTicketQuery": "玩家注单查询", "playerTicketQuery": "注单查询",
"playerId": "玩家 ID", "playerId": "玩家 ID / 账号",
"invalidPlayerId": "请输入有效玩家 ID", "invalidPlayerId": "请输入有效玩家 ID 或账号",
"drawNoOptional": "期号 draw_no可选", "playerIdPlaceholder": "留空显示全部,可输入玩家 ID 或账号",
"drawNoOptional": "期号(可选)",
"drawNoPlaceholder": "如 20260520-001", "drawNoPlaceholder": "如 20260520-001",
"numberKeyword": "号码 / 注单号 / 订单号",
"numberKeywordPlaceholder": "支持按号码、注单号、订单号搜索",
"placedDateRange": "下单日期范围",
"query": "查询", "query": "查询",
"resetFilters": "重置筛选",
"refreshCurrentPage": "刷新当前页",
"loadFailed": "加载失败", "loadFailed": "加载失败",
"ticketNo": "注单号", "ticketNo": "注单号",
"player": "玩家",
"orderNo": "订单号", "orderNo": "订单号",
"drawNo": "期号", "drawNo": "期号",
"playCode": "玩法", "playCode": "玩法",
"number": "号码", "number": "号码",
"betAmount": "下注",
"actualDeduct": "实扣", "actualDeduct": "实扣",
"status": "状态", "status": "状态",
"failReason": "失败原因", "failReason": "失败原因",
"winAmount": "中奖" "winAmount": "中奖",
"placedAt": "下单时间",
"updatedAt": "更新时间",
"statusFilterLabel": "状态筛选",
"statusHint": "可多选,留空表示全部状态",
"statusSelectedCount": "已选 {{count}} 项",
"statusOptions": {
"all": "全部",
"pending_confirm": "待确认",
"partial_pending_confirm": "部分待确认",
"success": "已投注成功",
"failed": "投注失败",
"pending_payout": "待派奖",
"settled_win": "已中奖结算",
"settled_lose": "已未中奖结算"
},
"allTickets": "全部注单"
} }

View File

@@ -100,15 +100,18 @@ export function applyAdminUiLocale(loc: AdminApiLocale): void {
} }
/** 启动时从 localStorage 恢复(应在客户端尽早调用一次) */ /** 启动时从 localStorage 恢复(应在客户端尽早调用一次) */
export function hydrateAdminUiLocale(): void { export function hydrateAdminUiLocale(): AdminApiLocale | null {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return; return null;
} }
const stored = readStoredUiLocale(); const stored = readStoredUiLocale();
if (stored) { if (stored) {
setAdminRequestLocale(stored); setAdminRequestLocale(stored);
document.documentElement.lang = adminHtmlLang(stored); document.documentElement.lang = adminHtmlLang(stored);
return stored;
} }
return null;
} }
/** 供 `admin-http``X-Locale` + `Accept-Language` */ /** 供 `admin-http``X-Locale` + `Accept-Language` */

View File

@@ -38,6 +38,16 @@ import { cn } from "@/lib/utils";
import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index"; import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
function permissionGroupLabel(key: string, fallback: string, t: (key: string) => string): string {
const translated = t(`permissionGroups.${key}`);
return translated === `permissionGroups.${key}` ? fallback : translated;
}
function permissionLabel(slug: string, fallback: string, t: (key: string) => string): string {
const translated = t(`permissionNames.${slug}`);
return translated === `permissionNames.${slug}` ? fallback : translated;
}
export function AdminRolesConsole(): React.ReactElement { export function AdminRolesConsole(): React.ReactElement {
const { t } = useTranslation(["adminUsers", "common"]); const { t } = useTranslation(["adminUsers", "common"]);
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null); const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
@@ -289,7 +299,7 @@ export function AdminRolesConsole(): React.ReactElement {
<Table id="admin-roles-table"> <Table id="admin-roles-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-16">ID</TableHead> <TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("roleTable.name")}</TableHead> <TableHead>{t("roleTable.name")}</TableHead>
<TableHead>{t("roleTable.slug")}</TableHead> <TableHead>{t("roleTable.slug")}</TableHead>
<TableHead>{t("roleTable.type")}</TableHead> <TableHead>{t("roleTable.type")}</TableHead>
@@ -376,7 +386,7 @@ export function AdminRolesConsole(): React.ReactElement {
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12"> <DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
<DialogTitle className="text-base">{t("rolePermissionDialog.title")}</DialogTitle> <DialogTitle className="text-base">{t("rolePermissionDialog.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
{selectedRole ? `${selectedRole.name} · ${selectedRole.slug}` : null} {selectedRole ? selectedRole.name : null}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/25 px-5 py-4"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/25 px-5 py-4">
@@ -401,7 +411,9 @@ export function AdminRolesConsole(): React.ReactElement {
isOpen && "rotate-180", isOpen && "rotate-180",
)} )}
/> />
<span className="min-w-0 flex-1 text-base font-semibold leading-none">{group.label}</span> <span className="min-w-0 flex-1 text-base font-semibold leading-none">
{permissionGroupLabel(group.key, group.label, t)}
</span>
<span className="shrink-0 rounded-full bg-muted px-2.5 py-1 tabular-nums text-xs font-medium text-muted-foreground"> <span className="shrink-0 rounded-full bg-muted px-2.5 py-1 tabular-nums text-xs font-medium text-muted-foreground">
{selectedCount}/{group.permissions.length} {selectedCount}/{group.permissions.length}
</span> </span>
@@ -424,7 +436,7 @@ export function AdminRolesConsole(): React.ReactElement {
} }
/> />
<span className="min-w-0 whitespace-normal break-words font-medium leading-6 text-foreground"> <span className="min-w-0 whitespace-normal break-words font-medium leading-6 text-foreground">
{permission.name} {permissionLabel(permission.slug, permission.name, t)}
</span> </span>
</label> </label>
))} ))}

View File

@@ -1,5 +1,5 @@
export const adminRolesModuleMeta = { export const adminRolesModuleMeta = {
segment: "admin_roles", segment: "admin_roles",
title: "Roles", title: "角色管理",
description: "", description: "",
} as const; } as const;

View File

@@ -78,6 +78,10 @@ export function AdminUsersConsole(): React.ReactElement {
() => items.find((u) => u.id === selectedId) ?? null, () => items.find((u) => u.id === selectedId) ?? null,
[items, selectedId], [items, selectedId],
); );
const roleNameBySlug = useMemo(
() => new Map((catalog?.roles ?? []).map((role) => [role.slug, role.name])),
[catalog],
);
const selectClassName = cn( const selectClassName = cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors", "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
@@ -355,7 +359,7 @@ export function AdminUsersConsole(): React.ReactElement {
<Table id="admin-users-table"> <Table id="admin-users-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-16">ID</TableHead> <TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("table.account")}</TableHead> <TableHead>{t("table.account")}</TableHead>
<TableHead>{t("table.nickname")}</TableHead> <TableHead>{t("table.nickname")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead> <TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
@@ -403,7 +407,7 @@ export function AdminUsersConsole(): React.ReactElement {
) : ( ) : (
row.roles.map((slug) => ( row.roles.map((slug) => (
<Badge key={slug} variant="secondary"> <Badge key={slug} variant="secondary">
{slug} {roleNameBySlug.get(slug) ?? slug}
</Badge> </Badge>
)) ))
)} )}
@@ -497,7 +501,7 @@ export function AdminUsersConsole(): React.ReactElement {
/> />
<span className="space-y-0.5"> <span className="space-y-0.5">
<span className="block leading-none font-medium">{role.name}</span> <span className="block leading-none font-medium">{role.name}</span>
<span className="text-xs text-muted-foreground">{role.slug}</span> <span className="text-xs text-muted-foreground">{role.description ?? ""}</span>
</span> </span>
</label> </label>
); );
@@ -605,7 +609,7 @@ export function AdminUsersConsole(): React.ReactElement {
/> />
<span className="space-y-0.5"> <span className="space-y-0.5">
<span className="block leading-none font-medium">{role.name}</span> <span className="block leading-none font-medium">{role.name}</span>
<span className="text-xs text-muted-foreground">{role.slug}</span> <span className="text-xs text-muted-foreground">{role.description ?? ""}</span>
</span> </span>
</label> </label>
); );

View File

@@ -1,5 +1,5 @@
export const adminUsersModuleMeta = { export const adminUsersModuleMeta = {
segment: "admin_users", segment: "admin_users",
title: "Admins", title: "管理员列表",
description: "", description: "",
} as const; } as const;

View File

@@ -66,43 +66,48 @@ export function AuditLogsConsole(): React.ReactElement {
const meta = data?.meta; const meta = data?.meta;
return ( return (
<Card className="w-full max-w-none"> <Card className="admin-list-card w-full max-w-none">
<CardHeader className="flex flex-col gap-4"> <CardHeader className="admin-list-header flex flex-col gap-5">
<div> <CardTitle className="admin-list-title">{t("title")}</CardTitle>
<CardTitle>{t("title")}</CardTitle> <div className="grid gap-3 lg:grid-cols-3">
</div> <div className="flex min-w-0 items-center gap-2">
<div className="admin-list-toolbar"> <Label htmlFor="aud-mod" className="shrink-0 whitespace-nowrap">
<div className="admin-list-field"> {t("moduleCode")}
<Label htmlFor="aud-mod" className="sm:w-20 sm:shrink-0">{t("moduleCode")}</Label> </Label>
<Input <Input
id="aud-mod" id="aud-mod"
value={moduleCode} value={moduleCode}
onChange={(e) => setModuleCode(e.target.value)} onChange={(e) => setModuleCode(e.target.value)}
placeholder={t("exactMatch")} placeholder={t("exactMatch")}
className="w-full sm:w-40" className="w-full"
/> />
</div> </div>
<div className="admin-list-field"> <div className="flex min-w-0 items-center gap-2">
<Label htmlFor="aud-act" className="sm:w-20 sm:shrink-0">{t("actionCode")}</Label> <Label htmlFor="aud-act" className="shrink-0 whitespace-nowrap">
{t("actionCode")}
</Label>
<Input <Input
id="aud-act" id="aud-act"
value={actionCode} value={actionCode}
onChange={(e) => setActionCode(e.target.value)} onChange={(e) => setActionCode(e.target.value)}
placeholder={t("exactMatch")} placeholder={t("exactMatch")}
className="w-full sm:w-40" className="w-full"
/> />
</div> </div>
<div className="admin-list-field"> <div className="flex min-w-0 items-center gap-2">
<Label htmlFor="aud-op" className="sm:w-20 sm:shrink-0">{t("operatorType")}</Label> <Label htmlFor="aud-op" className="shrink-0 whitespace-nowrap">
{t("operatorType")}
</Label>
<Input <Input
id="aud-op" id="aud-op"
value={operatorType} value={operatorType}
onChange={(e) => setOperatorType(e.target.value)} onChange={(e) => setOperatorType(e.target.value)}
placeholder={t("operatorTypePlaceholder")} placeholder={t("operatorTypePlaceholder")}
className="w-full sm:w-40" className="w-full"
/> />
</div> </div>
<div className="admin-list-actions"> </div>
<div className="flex flex-wrap justify-end gap-2">
<AdminTableExportButton <AdminTableExportButton
tableId="audit-logs-table" tableId="audit-logs-table"
filename="审计日志" filename="审计日志"
@@ -119,13 +124,24 @@ export function AuditLogsConsole(): React.ReactElement {
> >
{t("actions.search", { ns: "common" })} {t("actions.search", { ns: "common" })}
</Button> </Button>
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}> <Button
{t("actions.refresh", { ns: "common" })} type="button"
variant="secondary"
onClick={() => {
setModuleCode("");
setActionCode("");
setOperatorType("");
setAppliedModule("");
setAppliedAction("");
setAppliedOpType("");
setPage(1);
}}
>
{t("actions.reset", { ns: "common", defaultValue: "重置" })}
</Button> </Button>
</div> </div>
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="admin-list-content">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null} {err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? ( {loading && !data ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
@@ -137,7 +153,7 @@ export function AuditLogsConsole(): React.ReactElement {
<Table id="audit-logs-table"> <Table id="audit-logs-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-20">ID</TableHead> <TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("operator")}</TableHead> <TableHead>{t("operator")}</TableHead>
<TableHead>{t("module")}</TableHead> <TableHead>{t("module")}</TableHead>
<TableHead>{t("action")}</TableHead> <TableHead>{t("action")}</TableHead>

View File

@@ -1,5 +1,5 @@
export const auditLogsModuleMeta = { export const auditLogsModuleMeta = {
segment: "audit-logs", segment: "audit-logs",
title: "Audit Logs", title: "审计日志",
description: "", description: "",
} as const; } as const;

View File

@@ -1,5 +1,5 @@
export const authModuleMeta = { export const authModuleMeta = {
segment: "login", segment: "login",
title: "Login", title: "登录",
description: "", description: "",
} as const; } as const;

View File

@@ -34,16 +34,12 @@ export const CONFIG_NAV_GROUPS: readonly ConfigNavGroup[] = [
], ],
}, },
{ {
id: "risk_wallet", id: "risk",
items: [ items: [
{ {
href: "/admin/config/risk-cap", href: "/admin/config/risk-cap",
key: "risk-cap", key: "risk-cap",
}, },
{
href: "/admin/config/wallet",
key: "wallet",
},
], ],
}, },
] as const; ] as const;

View File

@@ -11,7 +11,6 @@ const LINKS: { href: string; key: string; match?: "exact" | "prefix" }[] = [
{ href: "/admin/config/odds", key: "odds" }, { href: "/admin/config/odds", key: "odds" },
{ href: "/admin/config/rebate", key: "rebate" }, { href: "/admin/config/rebate", key: "rebate" },
{ href: "/admin/config/risk-cap", key: "risk-cap" }, { href: "/admin/config/risk-cap", key: "risk-cap" },
{ href: "/admin/config/wallet", key: "wallet" },
]; ];
function linkActive(pathname: string, href: string, match: "exact" | "prefix"): boolean { function linkActive(pathname: string, href: string, match: "exact" | "prefix"): boolean {

View File

@@ -276,7 +276,7 @@ export function OddsConfigDocScreen() {
void refreshList(); void refreshList();
setSelectedId(String(d.id)); setSelectedId(String(d.id));
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.publishFailed", { ns: "config" }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -308,13 +308,13 @@ export function OddsConfigDocScreen() {
reason: `draft ${new Date().toISOString()}`, reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null, clone_from_version_id: active?.id ?? null,
}); });
toast.success(`Created draft v${d.version_no}`); toast.success(t("odds.createDraftSuccess", { ns: "config", version: d.version_no }));
await refreshList(); await refreshList();
setSelectedId(String(d.id)); setSelectedId(String(d.id));
setDetail(d); setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it }))); setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.createDraftFailed", { ns: "config" }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -330,7 +330,13 @@ export function OddsConfigDocScreen() {
reason: `rollback from v${rollbackTarget.version_no}`, reason: `rollback from v${rollbackTarget.version_no}`,
clone_from_version_id: rollbackTarget.id, clone_from_version_id: rollbackTarget.id,
}); });
toast.success(`Cloned v${rollbackTarget.version_no} into new draft v${d.version_no}`); toast.success(
t("odds.rollbackSuccess", {
ns: "config",
fromVersion: rollbackTarget.version_no,
version: d.version_no,
}),
);
await refreshList(); await refreshList();
setSelectedId(String(d.id)); setSelectedId(String(d.id));
setDetail(d); setDetail(d);
@@ -338,7 +344,7 @@ export function OddsConfigDocScreen() {
setRollbackOpen(false); setRollbackOpen(false);
setRollbackTarget(null); setRollbackTarget(null);
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Rollback failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.rollbackFailed", { ns: "config" }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -352,7 +358,7 @@ export function OddsConfigDocScreen() {
toast.success(t("versionSwitcher.delete", { ns: "config" })); toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList(); await refreshList();
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.deleteFailed", { ns: "config" }));
throw e; throw e;
} }
} }
@@ -382,7 +388,7 @@ export function OddsConfigDocScreen() {
}, [activeCompareRows, detail, draftRows, resolvedPlayCode]); }, [activeCompareRows, detail, draftRows, resolvedPlayCode]);
const catTabs: { id: CatTab; label: string }[] = [ const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: "All" }, { id: "all", label: t("odds.tabs.all", { ns: "config" }) },
{ id: "d4", label: "4D" }, { id: "d4", label: "4D" },
{ id: "d3", label: "3D" }, { id: "d3", label: "3D" },
{ id: "d2", label: "2D" }, { id: "d2", label: "2D" },
@@ -395,7 +401,7 @@ export function OddsConfigDocScreen() {
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<span className="text-base text-muted-foreground self-center mr-2">Category</span> <span className="text-base text-muted-foreground self-center mr-2">{t("odds.category", { ns: "config" })}</span>
{catTabs.map((t) => ( {catTabs.map((t) => (
<Button <Button
key={t.id} key={t.id}
@@ -410,10 +416,10 @@ export function OddsConfigDocScreen() {
</div> </div>
<div className="space-y-2 min-h-[96px]"> <div className="space-y-2 min-h-[96px]">
<p className="text-base text-muted-foreground">Play Type</p> <p className="text-base text-muted-foreground">{t("odds.playType", { ns: "config" })}</p>
<div className="flex flex-wrap gap-2 min-h-[44px]"> <div className="flex flex-wrap gap-2 min-h-[44px]">
{filteredTypes.length === 0 ? ( {filteredTypes.length === 0 ? (
<span className="text-base text-muted-foreground">No play types in this category.</span> <span className="text-base text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
) : ( ) : (
filteredTypes.map((t) => ( filteredTypes.map((t) => (
<Button <Button
@@ -443,7 +449,7 @@ export function OddsConfigDocScreen() {
onSelectedIdChange={setSelectedId} onSelectedIdChange={setSelectedId}
loading={loadingList} loading={loadingList}
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`} sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription="Choose a version to view here. Non-draft versions can be rolled back into a new draft." sheetDescription={t("odds.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion} onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback} onRollbackVersion={requestRollback}
rollbackBusy={saving} rollbackBusy={saving}
@@ -465,7 +471,7 @@ export function OddsConfigDocScreen() {
{detail ? ( {detail ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Active version: {t("odds.activeVersionPrefix", { ns: "config" })}
{activeHead ? ( {activeHead ? (
<> <>
v{activeHead.version_no} v{activeHead.version_no}
@@ -475,7 +481,7 @@ export function OddsConfigDocScreen() {
"—" "—"
)} )}
{!isDraft ? ( {!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> - This version is read-only. Create a draft before editing odds.</span> <span className="text-amber-600 dark:text-amber-400"> - {t("odds.readOnlyHint", { ns: "config" })}</span>
) : null} ) : null}
</p> </p>
) : null} ) : null}
@@ -484,7 +490,7 @@ export function OddsConfigDocScreen() {
{loadingDetail || loadingTypes ? ( {loadingDetail || loadingTypes ? (
<div className="flex min-h-[420px] items-center"> <div className="flex min-h-[420px] items-center">
<p className="text-base text-muted-foreground">Loading details</p> <p className="text-base text-muted-foreground">{t("odds.loadingDetails", { ns: "config" })}</p>
</div> </div>
) : resolvedPlayCode ? ( ) : resolvedPlayCode ? (
<div className="grid min-h-[420px] gap-4 max-w-md"> <div className="grid min-h-[420px] gap-4 max-w-md">
@@ -519,17 +525,21 @@ export function OddsConfigDocScreen() {
</ConfigReadonlyValue> </ConfigReadonlyValue>
)} )}
<span className="text-sm text-muted-foreground tabular-nums"> <span className="text-sm text-muted-foreground tabular-nums">
Multiplier x{oddsMultiplierLabel(row.odds_value)} · {row.currency_code} {t("odds.multiplier", {
ns: "config",
value: oddsMultiplierLabel(row.odds_value),
currency: row.currency_code,
})}
</span> </span>
</div> </div>
) : ( ) : (
<p className="text-sm text-destructive">Missing {scope} row. Check seed or version data.</p> <p className="text-sm text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
)} )}
</div> </div>
); );
})} })}
<div className="grid gap-1 pt-2 border-t"> <div className="grid gap-1 pt-2 border-t">
<Label>Rebate Rate (%)</Label> <Label>{t("odds.rebateRate", { ns: "config" })}</Label>
{isDraft ? ( {isDraft ? (
<Input <Input
type="text" type="text"
@@ -544,7 +554,7 @@ export function OddsConfigDocScreen() {
{rebatePercentUi} {rebatePercentUi}
</ConfigReadonlyValue> </ConfigReadonlyValue>
)} )}
<p className="text-sm text-muted-foreground">Writes rebate_rate to all prize scopes under this play type.</p> <p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -554,9 +564,9 @@ export function OddsConfigDocScreen() {
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}> <Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
<DialogContent showCloseButton className="sm:max-w-md"> <DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Confirm rollback</DialogTitle> <DialogTitle>{t("odds.rollbackDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription> <DialogDescription>
A new draft will be cloned from version v{rollbackTarget?.version_no}. The active version will not be overwritten directly. {t("odds.rollbackDialog.description", { ns: "config", version: rollbackTarget?.version_no ?? "—" })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -564,7 +574,7 @@ export function OddsConfigDocScreen() {
{t("actions.cancel", { ns: "adminUsers" })} {t("actions.cancel", { ns: "adminUsers" })}
</Button> </Button>
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}> <Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
Confirm rollback {t("odds.rollbackDialog.confirm", { ns: "config" })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -573,16 +583,16 @@ export function OddsConfigDocScreen() {
<Dialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}> <Dialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<DialogContent showCloseButton className="sm:max-w-lg"> <DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Publish odds version?</DialogTitle> <DialogTitle>{t("odds.publishDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription> <DialogDescription>
New odds affect new tickets immediately. Existing successful tickets still settle by their saved odds snapshot. {t("odds.publishDialog.description", { ns: "config" })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="rounded-lg border"> <div className="rounded-lg border">
<div className="grid grid-cols-3 border-b bg-muted/40 px-3 py-2 text-sm font-medium"> <div className="grid grid-cols-3 border-b bg-muted/40 px-3 py-2 text-sm font-medium">
<span>Prize Scope</span> <span>{t("odds.publishDialog.columns.prizeScope", { ns: "config" })}</span>
<span className="text-right">Current Active</span> <span className="text-right">{t("odds.publishDialog.columns.currentActive", { ns: "config" })}</span>
<span className="text-right">After Publish</span> <span className="text-right">{t("odds.publishDialog.columns.afterPublish", { ns: "config" })}</span>
</div> </div>
{publishDiffRows.map((row) => ( {publishDiffRows.map((row) => (
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm"> <div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
@@ -606,7 +616,7 @@ export function OddsConfigDocScreen() {
void handlePublish(); void handlePublish();
}} }}
> >
Confirm publish {t("odds.publishDialog.confirm", { ns: "config" })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -284,7 +284,7 @@ export function PlayConfigDocScreen() {
const payload = buildPlayConfigSavePayload(draftRows); const payload = buildPlayConfigSavePayload(draftRows);
for (const r of payload) { for (const r of payload) {
if (r.min_bet_amount > r.max_bet_amount) { if (r.min_bet_amount > r.max_bet_amount) {
toast.error(`${r.play_code}: min_bet_amount cannot exceed max_bet_amount`); toast.error(t("play.validation.minMaxInvalid", { ns: "config", playCode: r.play_code }));
return; return;
} }
} }
@@ -315,7 +315,7 @@ export function PlayConfigDocScreen() {
void refreshList(); void refreshList();
setSelectedId(String(d.id)); setSelectedId(String(d.id));
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("play.publishFailed", { ns: "config" }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -329,14 +329,14 @@ export function PlayConfigDocScreen() {
reason: `draft ${new Date().toISOString()}`, reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null, clone_from_version_id: active?.id ?? null,
}); });
toast.success(`Created draft v${d.version_no}`); toast.success(t("play.createDraftSuccess", { ns: "config", version: d.version_no }));
setCreatingDraftId(String(d.id)); setCreatingDraftId(String(d.id));
setSelectedId(String(d.id)); setSelectedId(String(d.id));
setDetail(d); setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it }))); setDraftRows(d.items.map((it) => ({ ...it })));
void refreshList(); void refreshList();
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("play.createDraftFailed", { ns: "config" }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -356,7 +356,7 @@ export function PlayConfigDocScreen() {
updateConfigRow(rulePlayCode, { rule_text_zh: ruleDraftZh.trim() || null }); updateConfigRow(rulePlayCode, { rule_text_zh: ruleDraftZh.trim() || null });
setRuleDialogOpen(false); setRuleDialogOpen(false);
setRulePlayCode(null); setRulePlayCode(null);
toast.message("Rule text saved into the local draft. Save the draft to persist it."); toast.message(t("play.ruleSavedLocal", { ns: "config" }));
} }
const activeHead = list.find((x) => x.status === "active"); const activeHead = list.find((x) => x.status === "active");
@@ -367,7 +367,7 @@ export function PlayConfigDocScreen() {
toast.success(t("versionSwitcher.delete", { ns: "config" })); toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList(); await refreshList();
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("play.deleteFailed", { ns: "config" }));
throw e; throw e;
} }
} }
@@ -407,14 +407,14 @@ export function PlayConfigDocScreen() {
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{activeHead ? ( {activeHead ? (
<> <>
Active version v{activeHead.version_no} {t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
{activeHead.effective_at ? ` · ${activeHead.effective_at}` : ""} {activeHead.effective_at ? ` · ${activeHead.effective_at}` : ""}
</> </>
) : null} ) : null}
{!isDraft ? ( {!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> <span className="text-amber-600 dark:text-amber-400">
{activeHead ? " — " : ""} {activeHead ? " — " : ""}
Limits and rules are read-only. Create a draft first. {t("play.readOnlyHint", { ns: "config" })}
</span> </span>
) : null} ) : null}
</p> </p>
@@ -424,14 +424,14 @@ export function PlayConfigDocScreen() {
<div className="rounded-xl border bg-muted/20 p-3"> <div className="rounded-xl border bg-muted/20 p-3">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div> <div>
<p className="text-sm font-medium">Batch switches</p> <p className="text-sm font-medium">{t("play.batchSwitchesTitle", { ns: "config" })}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Only updates the current draft. The player betting table refreshes after save and publish. {t("play.batchSwitchesDesc", { ns: "config" })}
</p> </p>
</div> </div>
{!isDraft ? ( {!isDraft ? (
<span className="text-xs text-amber-600 dark:text-amber-400"> <span className="text-xs text-amber-600 dark:text-amber-400">
Current version is read-only. Create a draft first. {t("play.readOnlyDraftHint", { ns: "config" })}
</span> </span>
) : null} ) : null}
</div> </div>
@@ -444,7 +444,13 @@ export function PlayConfigDocScreen() {
<div className="min-w-[92px]"> <div className="min-w-[92px]">
<p className="text-sm font-medium">{group.label}</p> <p className="text-sm font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{group.total > 0 ? `${group.enabledCount}/${group.total} enabled` : "No play types"} {group.total > 0
? t("play.batchEnabledCount", {
ns: "config",
enabledCount: group.enabledCount,
total: group.total,
})
: t("play.noPlayTypes", { ns: "config" })}
</p> </p>
</div> </div>
<Button <Button
@@ -454,7 +460,9 @@ export function PlayConfigDocScreen() {
disabled={!isDraft || saving || group.total === 0} disabled={!isDraft || saving || group.total === 0}
onClick={() => applyBatchSwitch(group, !group.allEnabled)} onClick={() => applyBatchSwitch(group, !group.allEnabled)}
> >
{group.allEnabled ? "Disable" : "Enable"} {group.allEnabled
? t("play.actions.disable", { ns: "config" })
: t("play.actions.enable", { ns: "config" })}
</Button> </Button>
</div> </div>
))} ))}
@@ -471,14 +479,14 @@ export function PlayConfigDocScreen() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="text-center">Play Code</TableHead> <TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
<TableHead className="w-[100px] text-center">Category</TableHead> <TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
<TableHead className="w-[88px] text-center">Status</TableHead> <TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
<TableHead className="min-w-[120px] text-center">Display Name</TableHead> <TableHead className="min-w-[120px] text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
<TableHead className="w-[120px] text-center">Order</TableHead> <TableHead className="w-[120px] text-center">{t("play.table.order", { ns: "config" })}</TableHead>
<TableHead className="w-[110px] text-center">Min Bet</TableHead> <TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
<TableHead className="w-[110px] text-center">Max Bet</TableHead> <TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
<TableHead className="w-[140px] text-center">Actions</TableHead> <TableHead className="w-[140px] text-center">{t("play.table.actions", { ns: "config" })}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -494,11 +502,13 @@ export function PlayConfigDocScreen() {
onCheckedChange={(v) => { onCheckedChange={(v) => {
updateConfigRow(row.play_code, { is_enabled: v === true }); updateConfigRow(row.play_code, { is_enabled: v === true });
}} }}
aria-label={`Enable ${row.play_code}`} aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
/> />
) : ( ) : (
<ConfigReadonlyValue className="justify-center"> <ConfigReadonlyValue className="justify-center">
{row.is_enabled ? "Enabled" : "Disabled"} {row.is_enabled
? t("play.states.enabled", { ns: "config" })
: t("play.states.disabled", { ns: "config" })}
</ConfigReadonlyValue> </ConfigReadonlyValue>
)} )}
</TableCell> </TableCell>
@@ -588,10 +598,10 @@ export function PlayConfigDocScreen() {
disabled={saving} disabled={saving}
onClick={() => openRuleEditor(row.play_code)} onClick={() => openRuleEditor(row.play_code)}
> >
Rule Text {t("play.actions.ruleText", { ns: "config" })}
</Button> </Button>
) : ( ) : (
<span className="text-sm text-muted-foreground">Read only</span> <span className="text-sm text-muted-foreground">{t("play.states.readOnly", { ns: "config" })}</span>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -605,13 +615,13 @@ export function PlayConfigDocScreen() {
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}> <Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
<DialogContent showCloseButton className="sm:max-w-lg"> <DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Rule Text (Chinese)</DialogTitle> <DialogTitle>{t("play.ruleDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription> <DialogDescription>
Play {rulePlayCode ?? "—"}; changes are only stored in the draft until you save and publish it. {t("play.ruleDialog.description", { ns: "config", playCode: rulePlayCode ?? "—" })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="rule-zh">rule_text_zh</Label> <Label htmlFor="rule-zh">{t("play.ruleDialog.fieldLabel", { ns: "config" })}</Label>
<textarea <textarea
id="rule-zh" id="rule-zh"
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none" className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
@@ -624,7 +634,7 @@ export function PlayConfigDocScreen() {
{t("actions.cancel", { ns: "adminUsers" })} {t("actions.cancel", { ns: "adminUsers" })}
</Button> </Button>
<Button type="button" onClick={saveRuleZh}> <Button type="button" onClick={saveRuleZh}>
Apply to Draft {t("play.ruleDialog.apply", { ns: "config" })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -218,11 +218,11 @@ export function RebateConfigDocScreen() {
setP2(inferPercentFrom(2, rows, types)); setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types)); setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types)); setP4(inferPercentFrom(4, rows, types));
toast.success("Published odds version with rebate"); toast.success(t("rebate.publishSuccess", { ns: "config" }));
void refreshList(); void refreshList();
setSelectedId(String(d.id)); setSelectedId(String(d.id));
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.publishFailed", { ns: "config" }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -236,7 +236,7 @@ export function RebateConfigDocScreen() {
reason: `rebate draft ${new Date().toISOString()}`, reason: `rebate draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null, clone_from_version_id: active?.id ?? null,
}); });
toast.success(`Created draft v${d.version_no}`); toast.success(t("rebate.createDraftSuccess", { ns: "config", version: d.version_no }));
await refreshList(); await refreshList();
setSelectedId(String(d.id)); setSelectedId(String(d.id));
const rows = d.items.map((it) => ({ ...it })); const rows = d.items.map((it) => ({ ...it }));
@@ -246,7 +246,7 @@ export function RebateConfigDocScreen() {
setP3(inferPercentFrom(3, rows, types)); setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types)); setP4(inferPercentFrom(4, rows, types));
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.createDraftFailed", { ns: "config" }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -260,7 +260,7 @@ export function RebateConfigDocScreen() {
toast.success(t("versionSwitcher.delete", { ns: "config" })); toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList(); await refreshList();
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.deleteFailed", { ns: "config" }));
throw e; throw e;
} }
} }
@@ -278,7 +278,7 @@ export function RebateConfigDocScreen() {
onSelectedIdChange={setSelectedId} onSelectedIdChange={setSelectedId}
loading={loading} loading={loading}
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`} sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
sheetDescription="Rebate is stored in the odds draft version and shares the same version set as odds." sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
onDeleteVersion={handleDeleteVersion} onDeleteVersion={handleDeleteVersion}
className="w-auto min-w-0" className="w-auto min-w-0"
/> />
@@ -288,7 +288,7 @@ export function RebateConfigDocScreen() {
loadingList={loading} loadingList={loading}
loadingDetail={loadingDetail} loadingDetail={loadingDetail}
saving={saving} saving={saving}
publishLabel="Publish" publishLabel={t("rebate.publishLabel", { ns: "config" })}
onRefresh={() => void refreshList()} onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()} onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()} onSaveDraft={() => void handleSave()}
@@ -297,9 +297,18 @@ export function RebateConfigDocScreen() {
{detail ? ( {detail ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Editing version v{detail.version_no} · {detail.status === "draft" ? "Draft" : detail.status === "active" ? "Active" : "Archived"} {t("rebate.editingVersion", {
ns: "config",
version: detail.version_no,
status:
detail.status === "draft"
? t("versionStatus.draft", { ns: "config" })
: detail.status === "active"
? t("versionStatus.active", { ns: "config" })
: t("versionStatus.archived", { ns: "config" }),
})}
{!isDraft ? ( {!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> - Create a draft before editing rebate.</span> <span className="text-amber-600 dark:text-amber-400"> - {t("rebate.readOnlyHint", { ns: "config" })}</span>
) : null} ) : null}
</p> </p>
) : null} ) : null}
@@ -307,7 +316,7 @@ export function RebateConfigDocScreen() {
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>2D Rebate Rate (%)</Label> <Label>{t("rebate.fields.d2", { ns: "config" })}</Label>
{isDraft ? ( {isDraft ? (
<Input <Input
type="number" type="number"
@@ -323,7 +332,7 @@ export function RebateConfigDocScreen() {
)} )}
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>3D Rebate Rate (%)</Label> <Label>{t("rebate.fields.d3", { ns: "config" })}</Label>
{isDraft ? ( {isDraft ? (
<Input <Input
type="number" type="number"
@@ -339,7 +348,7 @@ export function RebateConfigDocScreen() {
)} )}
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>4D Rebate Rate (%)</Label> <Label>{t("rebate.fields.d4", { ns: "config" })}</Label>
{isDraft ? ( {isDraft ? (
<Input <Input
type="number" type="number"
@@ -357,19 +366,25 @@ export function RebateConfigDocScreen() {
</div> </div>
<div className="flex items-start gap-3 rounded-lg border bg-muted/30 p-4"> <div className="flex items-start gap-3 rounded-lg border bg-muted/30 p-4">
<Checkbox id="win-enjoy" checked aria-disabled disabled aria-label="Apply rebate on winning tickets" /> <Checkbox
id="win-enjoy"
checked
aria-disabled
disabled
aria-label={t("rebate.winEnjoy.label", { ns: "config" })}
/>
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="win-enjoy" className="font-medium leading-snug"> <Label htmlFor="win-enjoy" className="font-medium leading-snug">
Apply rebate on winning tickets {t("rebate.winEnjoy.label", { ns: "config" })}
</Label> </Label>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Placeholder field. It can later be aligned with risk and settlement rules and persisted. {t("rebate.winEnjoy.description", { ns: "config" })}
</p> </p>
</div> </div>
</div> </div>
<div className="grid gap-1 text-sm"> <div className="grid gap-1 text-sm">
<span className="text-muted-foreground">Effective Time (current active odds version)</span> <span className="text-muted-foreground">{t("rebate.effectiveTime", { ns: "config" })}</span>
<span className="font-mono text-sm"> <span className="font-mono text-sm">
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"} {activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
</span> </span>

View File

@@ -189,19 +189,19 @@ export function RiskCapDocScreen() {
return; return;
} }
if (draftRows.length === 0) { if (draftRows.length === 0) {
toast.error("At least one cap row is required"); toast.error(t("riskCap.validation.requireAtLeastOne", { ns: "config" }));
return; return;
} }
for (const r of draftRows) { for (const r of draftRows) {
if (isDefaultRiskRow(r)) { if (isDefaultRiskRow(r)) {
if (r.cap_amount <= 0) { if (r.cap_amount <= 0) {
toast.error("Default cap amount must be greater than 0"); toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
return; return;
} }
continue; continue;
} }
if (!/^[0-9]{4}$/.test(r.normalized_number)) { if (!/^[0-9]{4}$/.test(r.normalized_number)) {
toast.error(`Number must be 4 digits: ${r.normalized_number}`); toast.error(t("riskCap.validation.numberMustBe4Digits", { ns: "config", number: r.normalized_number }));
return; return;
} }
} }
@@ -254,7 +254,7 @@ export function RiskCapDocScreen() {
void refreshList(); void refreshList();
setSelectedId(String(d.id)); setSelectedId(String(d.id));
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.publishFailed", { ns: "config" }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -268,7 +268,7 @@ export function RiskCapDocScreen() {
reason: `draft ${new Date().toISOString()}`, reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null, clone_from_version_id: active?.id ?? null,
}); });
toast.success(`Created draft v${d.version_no}`); toast.success(t("riskCap.createDraftSuccess", { ns: "config", version: d.version_no }));
await refreshList(); await refreshList();
setSelectedId(String(d.id)); setSelectedId(String(d.id));
setDetail(d); setDetail(d);
@@ -282,7 +282,7 @@ export function RiskCapDocScreen() {
setDraftRows(nd); setDraftRows(nd);
syncDefaultCapFromRows(nd); syncDefaultCapFromRows(nd);
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.createDraftFailed", { ns: "config" }));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -291,7 +291,7 @@ export function RiskCapDocScreen() {
function applyDefaultCap() { 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("Enter a valid cap amount"); toast.error(t("riskCap.validation.enterValidCapAmount", { ns: "config" }));
return; return;
} }
setDraftRows((prev) => { setDraftRows((prev) => {
@@ -299,7 +299,7 @@ export function RiskCapDocScreen() {
return [defaultRiskRowFromAmount(n), ...next]; return [defaultRiskRowFromAmount(n), ...next];
}); });
setSyncOpen(false); setSyncOpen(false);
toast.message("Saved into local draft. Save the draft to persist it."); toast.message(t("riskCap.savedLocalDraft", { ns: "config" }));
} }
const occFiltered = useMemo(() => { const occFiltered = useMemo(() => {
@@ -321,7 +321,7 @@ export function RiskCapDocScreen() {
toast.success(t("versionSwitcher.delete", { ns: "config" })); toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList(); await refreshList();
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed"); toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.deleteFailed", { ns: "config" }));
throw e; throw e;
} }
} }
@@ -364,9 +364,9 @@ export function RiskCapDocScreen() {
{detail ? ( {detail ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Effective at: {detail.effective_at ? formatDt(detail.effective_at) : "—"} · Note: {detail.reason ?? "—"} {t("riskCap.effectiveAt", { ns: "config", value: detail.effective_at ? formatDt(detail.effective_at) : "—" })} · {t("riskCap.note", { ns: "config", value: detail.reason ?? "—" })}
{!isDraft ? ( {!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> - Read only. Create a draft first.</span> <span className="text-amber-600 dark:text-amber-400"> - {t("riskCap.readOnlyHint", { ns: "config" })}</span>
) : null} ) : null}
</p> </p>
) : null} ) : null}
@@ -375,13 +375,13 @@ export function RiskCapDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null} {error ? <p className="text-sm text-destructive">{error}</p> : null}
<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">Default Cap</h3> <h3 className="text-sm font-medium">{t("riskCap.defaultCap.title", { ns: "config" })}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Numbers without a special cap use this default cap template. {t("riskCap.defaultCap.description", { ns: "config" })}
</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">
<Label htmlFor="default-cap">Cap Amount (minor unit)</Label> <Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
{isDraft ? ( {isDraft ? (
<Input <Input
id="default-cap" id="default-cap"
@@ -400,7 +400,7 @@ export function RiskCapDocScreen() {
</div> </div>
{isDraft ? ( {isDraft ? (
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}> <Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
Update {t("riskCap.actions.update", { ns: "config" })}
</Button> </Button>
) : null} ) : null}
</div> </div>
@@ -408,7 +408,7 @@ export function RiskCapDocScreen() {
<section className="space-y-3"> <section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium">Special Caps</h3> <h3 className="text-sm font-medium">{t("riskCap.specialCaps.title", { ns: "config" })}</h3>
{isDraft ? ( {isDraft ? (
<Button <Button
type="button" type="button"
@@ -416,25 +416,25 @@ export function RiskCapDocScreen() {
disabled={saving} disabled={saving}
onClick={() => setDraftRows((prev) => [...prev, newRow()])} onClick={() => setDraftRows((prev) => [...prev, newRow()])}
> >
+ Add Special Cap {t("riskCap.actions.addSpecialCap", { ns: "config" })}
</Button> </Button>
) : null} ) : null}
</div> </div>
{loadingDetail ? ( {loadingDetail ? (
<p className="text-sm text-muted-foreground">Loading details</p> <p className="text-sm text-muted-foreground">{t("riskCap.loadingDetails", { ns: "config" })}</p>
) : specialRows.length === 0 ? ( ) : specialRows.length === 0 ? (
<p className="text-sm text-muted-foreground">No detail rows.</p> <p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
) : ( ) : (
<div className="overflow-x-auto rounded-md border"> <div className="overflow-x-auto rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[110px]">Number</TableHead> <TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="w-[140px]">Cap Amount</TableHead> <TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
<TableHead className="w-[90px] text-right">Used</TableHead> <TableHead className="w-[90px] text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead>
<TableHead className="w-[90px] text-right">Remaining</TableHead> <TableHead className="w-[90px] text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
<TableHead className="w-[72px] text-center">Sold Out</TableHead> <TableHead className="w-[72px] text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
<TableHead className="w-[160px]">Actions</TableHead> <TableHead className="w-[160px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -490,7 +490,7 @@ export function RiskCapDocScreen() {
{t("actions.delete", { ns: "adminUsers" })} {t("actions.delete", { ns: "adminUsers" })}
</Button> </Button>
) : ( ) : (
<span className="text-sm text-muted-foreground">Read only</span> <span className="text-sm text-muted-foreground">{t("riskCap.readOnly", { ns: "config" })}</span>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -502,42 +502,42 @@ export function RiskCapDocScreen() {
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h3 className="text-sm font-medium">All Number Occupancy</h3> <h3 className="text-sm font-medium">{t("riskCap.occupancy.title", { ns: "config" })}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Placeholder view: filters and exports still need ticket-summary integration. Data below still comes from the current draft list. {t("riskCap.occupancy.description", { ns: "config" })}
</p> </p>
<div className="flex flex-wrap gap-3 items-end"> <div className="flex flex-wrap gap-3 items-end">
<div className="grid gap-1"> <div className="grid gap-1">
<Label htmlFor="occ-search">Search Number</Label> <Label htmlFor="occ-search">{t("riskCap.occupancy.searchLabel", { ns: "config" })}</Label>
<Input <Input
id="occ-search" id="occ-search"
className="w-[140px] font-mono" className="w-[140px] font-mono"
placeholder="e.g. 8888" placeholder={t("riskCap.occupancy.searchPlaceholder", { ns: "config" })}
value={occSearch} value={occSearch}
onChange={(e) => setOccSearch(e.target.value)} onChange={(e) => setOccSearch(e.target.value)}
/> />
</div> </div>
<Button type="button" variant="outline" onClick={() => toast.message("Sold-out / high-risk preset filter is pending integration")}> <Button type="button" variant="outline" onClick={() => toast.message(t("riskCap.occupancy.filterPending", { ns: "config" }))}>
Filter Presets {t("riskCap.actions.filterPresets", { ns: "config" })}
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => toast.message("CSV export is pending integration")} onClick={() => toast.message(t("riskCap.occupancy.exportPending", { ns: "config" }))}
> >
Export CSV {t("riskCap.actions.exportCsv", { ns: "config" })}
</Button> </Button>
</div> </div>
<div className="overflow-x-auto rounded-md border"> <div className="overflow-x-auto rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Number</TableHead> <TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="text-right">Used</TableHead> <TableHead className="text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead>
<TableHead className="text-right">Remaining</TableHead> <TableHead className="text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
<TableHead className="text-right">Ratio</TableHead> <TableHead className="text-right">{t("riskCap.table.ratio", { ns: "config" })}</TableHead>
<TableHead className="text-center">Sold Out</TableHead> <TableHead className="text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
<TableHead className="w-[140px]">Actions</TableHead> <TableHead className="w-[140px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -550,7 +550,7 @@ export function RiskCapDocScreen() {
<TableCell className="text-center text-muted-foreground"></TableCell> <TableCell className="text-center text-muted-foreground"></TableCell>
<TableCell> <TableCell>
<Button type="button" variant="ghost" disabled> <Button type="button" variant="ghost" disabled>
Close {t("riskCap.actions.close", { ns: "config" })}
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -564,9 +564,9 @@ export function RiskCapDocScreen() {
<Dialog open={syncOpen} onOpenChange={setSyncOpen}> <Dialog open={syncOpen} onOpenChange={setSyncOpen}>
<DialogContent showCloseButton className="sm:max-w-md"> <DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Sync Default Cap</DialogTitle> <DialogTitle>{t("riskCap.syncDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription> <DialogDescription>
The default cap template will be set to {defaultCapStr || "(empty)"}. This only changes the draft. Save and publish after confirming. {t("riskCap.syncDialog.description", { ns: "config", value: defaultCapStr || "(empty)" })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -574,7 +574,7 @@ export function RiskCapDocScreen() {
{t("actions.cancel", { ns: "adminUsers" })} {t("actions.cancel", { ns: "adminUsers" })}
</Button> </Button>
<Button type="button" onClick={applyDefaultCap}> <Button type="button" onClick={applyDefaultCap}>
Confirm {t("riskCap.syncDialog.confirm", { ns: "config" })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -1,29 +1,29 @@
export const configHubMeta = { export const configHubMeta = {
title: "Configuration Center", title: "运营配置",
description: "Manage play catalogs, odds, rebates, and risk caps with draft, publish, and activation stages.", description: "管理玩法目录、赔率、返水与风控限额,支持草稿、发布与生效流程。",
} as const; } as const;
export const configPlayConfigMeta = { export const configPlayConfigMeta = {
title: "Play Configuration", title: "玩法配置",
description: "Manage play switches, limits, and rule text. Catalog changes directly affect betting entry points.", description: "管理玩法开关、限额与规则说明,目录变更会直接影响投注入口。",
} as const; } as const;
export const configOddsMeta = { export const configOddsMeta = {
title: "Odds Configuration", title: "赔率配置",
description: "Manage odds, rebates, and commissions. Verify ranges and currency before publishing.", description: "管理赔率、返水与佣金,发布前需确认区间与币种配置。",
} as const; } as const;
export const configRebateMeta = { export const configRebateMeta = {
title: "Commission / Rebate", title: "返水与佣金",
description: "Batch-adjust rebate rates from the odds draft, suitable for dimension-wide updates.", description: "基于赔率草稿批量调整返水比例,适合按维度统一更新。",
} as const; } as const;
export const configRiskCapMeta = { export const configRiskCapMeta = {
title: "Risk Caps", title: "风控限额",
description: "Manage number cap versions and risk pool thresholds. Confirm number scope and draw before publishing.", description: "管理号码限额版本与风控池阈值,发布前需确认号码范围与期号。",
} as const; } as const;
export const configWalletMeta = { export const configWalletMeta = {
title: "Wallet Configuration", title: "钱包配置",
description: "Manage wallet thresholds and transfer policies.", description: "管理钱包阈值与划转策略参数。",
} as const; } as const;

View File

@@ -1,5 +1,5 @@
export const dashboardModuleMeta = { export const dashboardModuleMeta = {
segment: "dashboard", segment: "dashboard",
title: "Dashboard", title: "仪表盘",
description: "", description: "",
} as const; } as const;

View File

@@ -26,6 +26,12 @@ import { toast } from "sonner";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd"; import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "./draw-prd";
function drawStatusText(status: string, t: (key: string) => string): string {
const key = `statusOptions.${status}`;
const translated = t(key);
return translated === key ? status : translated;
}
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement { export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation(["draws", "common"]); const { t } = useTranslation(["draws", "common"]);
const idNum = Number(drawId); const idNum = Number(drawId);
@@ -98,7 +104,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
</div> </div>
<div> <div>
<span className="text-muted-foreground">{t("status")}</span> <span className="text-muted-foreground">{t("status")}</span>
<p>{data.draw_status}</p> <p>{drawStatusText(data.draw_status, t)}</p>
</div> </div>
<div> <div>
<span className="text-muted-foreground">{t("orderAndItemCount")}</span> <span className="text-muted-foreground">{t("orderAndItemCount")}</span>
@@ -167,7 +173,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
<Table id={`draw-finance-table-${drawId}`}> <Table id={`draw-finance-table-${drawId}`}>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-20">ID</TableHead> <TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("status")}</TableHead> <TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("ticketCount")}</TableHead> <TableHead className="text-right">{t("ticketCount")}</TableHead>
<TableHead className="text-right">{t("winCount")}</TableHead> <TableHead className="text-right">{t("winCount")}</TableHead>
@@ -180,7 +186,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
{data.settlement_batches.map((b) => ( {data.settlement_batches.map((b) => (
<TableRow key={b.id}> <TableRow key={b.id}>
<TableCell className="font-mono text-xs">{b.id}</TableCell> <TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell className="text-xs">{b.status}</TableCell> <TableCell className="text-xs">{drawStatusText(b.status, t)}</TableCell>
<TableCell className="text-right tabular-nums text-xs"> <TableCell className="text-right tabular-nums text-xs">
{b.total_ticket_count} {b.total_ticket_count}
</TableCell> </TableCell>

View File

@@ -1,5 +1,5 @@
export const drawsModuleMeta = { export const drawsModuleMeta = {
segment: "draws", segment: "draws",
title: "Draws", title: "期号管理",
description: "", description: "",
} as const; } as const;

View File

@@ -95,8 +95,19 @@ export function JackpotRecordsConsole() {
setCPage(1); setCPage(1);
}; };
const triggerTypeText = (value: string) => {
const key = `triggerTypes.${value}`;
const translated = t(key);
return translated === key ? value : translated;
};
return ( return (
<ModuleScaffold> <ModuleScaffold>
<div className="mb-6">
<h1 className="text-lg font-semibold tracking-tight">{t("recordsPage.title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t("recordsPage.description")}</p>
</div>
<Card className="mb-6"> <Card className="mb-6">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">{t("filter")}</CardTitle> <CardTitle className="text-base">{t("filter")}</CardTitle>
@@ -132,14 +143,14 @@ export function JackpotRecordsConsole() {
<div className="admin-table-toolbar"> <div className="admin-table-toolbar">
<AdminTableExportButton <AdminTableExportButton
tableId="jackpot-payout-table" tableId="jackpot-payout-table"
filename="Jackpot派彩记录" filename="奖池派彩记录"
sheetName="Jackpot派彩" sheetName="奖池派彩"
/> />
</div> </div>
<Table id="jackpot-payout-table"> <Table id="jackpot-payout-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>ID</TableHead> <TableHead>{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("drawNo")}</TableHead> <TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("trigger")}</TableHead> <TableHead>{t("trigger")}</TableHead>
<TableHead className="text-right">{t("payoutAmount")}</TableHead> <TableHead className="text-right">{t("payoutAmount")}</TableHead>
@@ -152,7 +163,7 @@ export function JackpotRecordsConsole() {
<TableRow key={r.id}> <TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id}</TableCell> <TableCell className="font-mono text-xs">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell> <TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{r.trigger_type}</TableCell> <TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums"> <TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")} {formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
</TableCell> </TableCell>
@@ -196,14 +207,14 @@ export function JackpotRecordsConsole() {
<div className="admin-table-toolbar"> <div className="admin-table-toolbar">
<AdminTableExportButton <AdminTableExportButton
tableId="jackpot-contribution-table" tableId="jackpot-contribution-table"
filename="Jackpot注入记录" filename="奖池注入记录"
sheetName="Jackpot注入" sheetName="奖池注入"
/> />
</div> </div>
<Table id="jackpot-contribution-table"> <Table id="jackpot-contribution-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>ID</TableHead> <TableHead>{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("drawNo")}</TableHead> <TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("ticketNo")}</TableHead> <TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("player")}</TableHead> <TableHead>{t("player")}</TableHead>

View File

@@ -1,4 +1,4 @@
export const jackpotModuleMeta = { export const jackpotModuleMeta = {
title: "Jackpot", title: "奖池记录",
description: "", description: "",
} as const; } as const;

View File

@@ -1,5 +1,5 @@
export const playersModuleMeta = { export const playersModuleMeta = {
segment: "players", segment: "players",
title: "Players", title: "玩家列表",
description: "", description: "",
} as const; } as const;

View File

@@ -309,7 +309,7 @@ export function PlayersConsole(): React.ReactElement {
<Table id="players-table"> <Table id="players-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-16">ID</TableHead> <TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("site")}</TableHead> <TableHead>{t("site")}</TableHead>
<TableHead>{t("sitePlayerId")}</TableHead> <TableHead>{t("sitePlayerId")}</TableHead>
<TableHead>{t("username")}</TableHead> <TableHead>{t("username")}</TableHead>

View File

@@ -1,5 +1,5 @@
export const reconcileModuleMeta = { export const reconcileModuleMeta = {
segment: "reconcile", segment: "reconcile",
title: "Reconcile", title: "对账中心",
description: "", description: "",
} as const; } as const;

View File

@@ -309,7 +309,7 @@ export function ReconcileConsole(): React.ReactElement {
<Table id="reconcile-jobs-table"> <Table id="reconcile-jobs-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-24">ID</TableHead> <TableHead className="w-24">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("jobNo")}</TableHead> <TableHead>{t("jobNo")}</TableHead>
<TableHead>{t("type")}</TableHead> <TableHead>{t("type")}</TableHead>
<TableHead>{t("status")}</TableHead> <TableHead>{t("status")}</TableHead>
@@ -398,7 +398,7 @@ export function ReconcileConsole(): React.ReactElement {
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}> <Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-20">ID</TableHead> <TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("sideARef")}</TableHead> <TableHead>{t("sideARef")}</TableHead>
<TableHead>{t("sideBRef")}</TableHead> <TableHead>{t("sideBRef")}</TableHead>
<TableHead>{t("differenceAmount")}</TableHead> <TableHead>{t("differenceAmount")}</TableHead>

View File

@@ -1,5 +1,5 @@
export const reportsModuleMeta = { export const reportsModuleMeta = {
segment: "reports", segment: "reports",
title: "Reports", title: "报表导出",
description: "", description: "",
} as const; } as const;

View File

@@ -129,6 +129,9 @@ export function ReportsConsole(): React.ReactElement {
const lastPage = meta const lastPage = meta
? Math.max(1, meta.last_page) ? Math.max(1, meta.last_page)
: 1; : 1;
const reportFormatLabel = (value: string) =>
t(`formatOptions.${value}`, { defaultValue: value.toUpperCase() });
const reportStatusLabel = (value: string) => t(`statusOptions.${value}`, { defaultValue: value });
return ( return (
<div className="flex w-full max-w-none flex-col gap-8"> <div className="flex w-full max-w-none flex-col gap-8">
@@ -175,8 +178,8 @@ export function ReportsConsole(): React.ReactElement {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="csv">CSV</SelectItem> <SelectItem value="csv">{t("formatOptions.csv")}</SelectItem>
<SelectItem value="xlsx">XLSX</SelectItem> <SelectItem value="xlsx">{t("formatOptions.xlsx")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -245,9 +248,9 @@ export function ReportsConsole(): React.ReactElement {
defaultValue: row.report_type, defaultValue: row.report_type,
})} })}
</TableCell> </TableCell>
<TableCell>{row.export_format}</TableCell> <TableCell>{reportFormatLabel(row.export_format)}</TableCell>
<TableCell> <TableCell>
<Badge variant="secondary">{row.status}</Badge> <Badge variant="secondary">{reportStatusLabel(row.status)}</Badge>
</TableCell> </TableCell>
<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 ?? "—"}

View File

@@ -1,5 +1,5 @@
export const riskModuleMeta = { export const riskModuleMeta = {
segment: "risk", segment: "risk",
title: "Risk", title: "风控中心",
description: "", description: "",
} as const; } as const;

View File

@@ -1,5 +1,5 @@
export const settingsModuleMeta = { export const settingsModuleMeta = {
segment: "settings", segment: "settings",
title: "Settings", title: "系统设置",
description: "", description: "管理影响钱包划转与跨模块行为的全局运行参数。",
} as const; } as const;

View File

@@ -0,0 +1,226 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getAdminSettings,
updateAdminSetting,
} from "@/api/admin-settings";
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LotteryApiBizError } from "@/types/api/errors";
const DRAW_GROUP = "draw";
const SETTLEMENT_GROUP = "settlement";
const DRAW_KEYS = {
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
COOLDOWN_MINUTES: "draw.cooldown_minutes",
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
} as const;
interface RuntimeDraft {
requireManualReview: boolean;
cooldownMinutes: string;
autoSettlement: boolean;
}
export function SystemSettingsScreen() {
const { t } = useTranslation(["common", "config", "adminUsers"]);
const [draft, setDraft] = useState<RuntimeDraft>({
requireManualReview: false,
cooldownMinutes: "15",
autoSettlement: true,
});
const [saved, setSaved] = useState<RuntimeDraft>({
requireManualReview: false,
cooldownMinutes: "15",
autoSettlement: true,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [drawRes, settlementRes] = await Promise.all([
getAdminSettings(DRAW_GROUP),
getAdminSettings(SETTLEMENT_GROUP),
]);
const kv: Record<string, unknown> = {};
for (const item of [...drawRes.items, ...settlementRes.items]) {
kv[item.key] = item.value;
}
const nextDraft: RuntimeDraft = {
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
};
setDraft(nextDraft);
setSaved(nextDraft);
setDirty(false);
} catch {
toast.error(t("system.loadFailed", { ns: "config" }));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
setDraft((prev) => ({ ...prev, [field]: value }));
setDirty(true);
};
const handleSave = async () => {
setSaving(true);
try {
await updateAdminSetting(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview);
await updateAdminSetting(
DRAW_KEYS.COOLDOWN_MINUTES,
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
);
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
toast.success(t("system.saveSuccess", { ns: "config" }));
setSaved(draft);
setDirty(false);
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
);
} finally {
setSaving(false);
}
};
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<Card>
<CardHeader className="space-y-3">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{t("nav.settings", { ns: "common", defaultValue: "System Settings" })}
</p>
<CardTitle className="text-2xl">
{t("system.runtimeTitle", { ns: "config" })}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
{t("system.runtimeIntro1", { ns: "config" })}
</p>
<p>
{t("system.runtimeIntro2", { ns: "config" })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("system.title", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<p className="text-sm text-muted-foreground">
{t("system.description", { ns: "config" })}
</p>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-3 rounded-xl border border-border/70 p-4">
<div className="space-y-1">
<Label htmlFor="manual-review">{t("system.fields.manualReview", { ns: "config" })}</Label>
<p className="text-xs text-muted-foreground">
{t("system.hints.manualReview", { ns: "config" })}
</p>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="manual-review"
checked={draft.requireManualReview}
onCheckedChange={(checked) => updateDraft("requireManualReview", checked === true)}
disabled={loading || saving}
/>
<Label htmlFor="manual-review" className="text-sm font-medium">
{draft.requireManualReview
? t("system.states.enabled", { ns: "config" })
: t("system.states.disabled", { ns: "config" })}
</Label>
</div>
</div>
<div className="space-y-3 rounded-xl border border-border/70 p-4">
<div className="space-y-1">
<Label htmlFor="auto-settlement">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
<p className="text-xs text-muted-foreground">
{t("system.hints.autoSettlement", { ns: "config" })}
</p>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="auto-settlement"
checked={draft.autoSettlement}
onCheckedChange={(checked) => updateDraft("autoSettlement", checked === true)}
disabled={loading || saving}
/>
<Label htmlFor="auto-settlement" className="text-sm font-medium">
{draft.autoSettlement
? t("system.states.enabled", { ns: "config" })
: t("system.states.disabled", { ns: "config" })}
</Label>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="cooldown-minutes">{t("system.fields.cooldownMinutes", { ns: "config" })}</Label>
<Input
id="cooldown-minutes"
type="number"
min="0"
step="1"
value={draft.cooldownMinutes}
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
{t("system.hints.cooldownMinutes", { ns: "config" })}
</p>
</div>
<div className="flex items-center gap-4">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
<Button
variant="outline"
onClick={() => {
setDraft(saved);
setDirty(false);
}}
>
{t("system.discard", { ns: "config" })}
</Button>
)}
</div>
</CardContent>
</Card>
<WalletConfigDocScreen />
</div>
);
}

View File

@@ -0,0 +1,9 @@
"use client";
import { useTranslation } from "react-i18next";
export function InvalidSettlementBatchId(): React.ReactElement {
const { t } = useTranslation("settlement");
return <p className="text-destructive text-sm">{t("invalidBatchId")}</p>;
}

View File

@@ -1,4 +1,4 @@
export const settlementModuleMeta = { export const settlementModuleMeta = {
title: "结算", title: "结算批次",
description: "", description: "",
} as const; } as const;

View File

@@ -53,6 +53,19 @@ type Props = {
type SettlementAction = "approve" | "reject" | "payout"; type SettlementAction = "approve" | "reject" | "payout";
function settlementStatusText(value: string, t: (key: string) => string): string {
const key = `statusOptions.${value}`;
const translated = t(key);
return translated === key ? value : translated;
}
function settlementReviewStatusText(value: string | null, t: (key: string) => string): string {
if (!value) return "—";
const key = `reviewStatusOptions.${value}`;
const translated = t(key);
return translated === key ? value : translated;
}
export function SettlementBatchDetailsConsole({ batchId }: Props) { export function SettlementBatchDetailsConsole({ batchId }: Props) {
const { t } = useTranslation(["settlement", "common"]); const { t } = useTranslation(["settlement", "common"]);
const profile = useAdminProfile(); const profile = useAdminProfile();
@@ -195,11 +208,11 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
<CardContent className="grid gap-2 text-sm sm:grid-cols-2"> <CardContent className="grid gap-2 text-sm sm:grid-cols-2">
<p> <p>
<span className="text-muted-foreground">{t("settlementStatus")}</span>{" "} <span className="text-muted-foreground">{t("settlementStatus")}</span>{" "}
<span className="font-mono">{summary.status}</span> <span className="font-mono">{settlementStatusText(summary.status, t)}</span>
</p> </p>
<p> <p>
<span className="text-muted-foreground">{t("reviewState")}</span>{" "} <span className="text-muted-foreground">{t("reviewState")}</span>{" "}
<span className="font-mono">{summary.review_status ?? "—"}</span> <span className="font-mono">{settlementReviewStatusText(summary.review_status, t)}</span>
</p> </p>
<p> <p>
<span className="text-muted-foreground">{t("ticketTotal")}</span>{" "} <span className="text-muted-foreground">{t("ticketTotal")}</span>{" "}

View File

@@ -74,6 +74,13 @@ function settlementStatusText(value: string, t: (key: string) => string): string
return option ? t(option.label) : value; return option ? t(option.label) : value;
} }
function settlementReviewStatusText(value: string | null, t: (key: string) => string): string {
if (!value) return "—";
const key = `reviewStatusOptions.${value}`;
const translated = t(key);
return translated === key ? value : translated;
}
export function SettlementBatchesConsole() { export function SettlementBatchesConsole() {
const { t } = useTranslation(["settlement", "common"]); const { t } = useTranslation(["settlement", "common"]);
const profile = useAdminProfile(); const profile = useAdminProfile();
@@ -228,7 +235,7 @@ export function SettlementBatchesConsole() {
<Table id="settlement-batches-table"> <Table id="settlement-batches-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>ID</TableHead> <TableHead>{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("drawNo")}</TableHead> <TableHead>{t("drawNo")}</TableHead>
<TableHead className="text-right">{t("totalBet")}</TableHead> <TableHead className="text-right">{t("totalBet")}</TableHead>
<TableHead className="text-right">{t("actualDeduct")}</TableHead> <TableHead className="text-right">{t("actualDeduct")}</TableHead>
@@ -261,7 +268,9 @@ export function SettlementBatchesConsole() {
> >
{formatAdminMinorUnits(row.platform_profit)} {formatAdminMinorUnits(row.platform_profit)}
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground">{row.review_status ?? "—"}</TableCell> <TableCell className="text-xs text-muted-foreground">
{settlementReviewStatusText(row.review_status, t)}
</TableCell>
<TableCell> <TableCell>
<span <span
className={cn( className={cn(

View File

@@ -1,5 +1,5 @@
export const ticketsModuleMeta = { export const ticketsModuleMeta = {
segment: "tickets", segment: "tickets",
title: "Tickets", title: "注单列表",
description: "", description: "",
} as const; } as const;

View File

@@ -3,11 +3,19 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets"; import { getAdminTicketItems } from "@/api/admin-tickets";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Badge } from "@/components/ui/badge";
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";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@@ -18,33 +26,99 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerTicketItemsData } from "@/types/api/admin-player-tickets"; import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
import { ChevronDown } from "lucide-react";
const TICKET_STATUS_OPTIONS = [
"pending_confirm",
"partial_pending_confirm",
"success",
"failed",
"pending_payout",
"settled_win",
"settled_lose",
] as const;
type TicketFilters = {
playerQuery: string;
drawNo: string;
numberKeyword: string;
startDate: string;
endDate: string;
statuses: string[];
};
const emptyTicketFilters: TicketFilters = {
playerQuery: "",
drawNo: "",
numberKeyword: "",
startDate: "",
endDate: "",
statuses: [],
};
function ticketStatusText(value: string, t: (key: string) => string): string {
const key = `statusOptions.${value}`;
const translated = t(key);
return translated === key ? value : translated;
}
function ticketStatusSummary(statuses: string[], t: (key: string) => string): string {
if (statuses.length === 0) {
return t("statusOptions.all");
}
if (statuses.length === 1) {
return ticketStatusText(statuses[0], t);
}
return t("statusSelectedCount", { count: statuses.length, defaultValue: `已选 ${statuses.length}` });
}
function ticketStatusVariant(
value: string,
): "default" | "secondary" | "destructive" | "outline" {
if (value === "settled_win") return "secondary";
if (value === "failed") return "destructive";
if (value === "pending_payout") return "default";
return "outline";
}
export function PlayerTicketsConsole(): React.ReactElement { export function PlayerTicketsConsole(): React.ReactElement {
const { t } = useTranslation(["tickets", "common"]); const { t } = useTranslation(["tickets", "common"]);
const [playerIdDraft, setPlayerIdDraft] = useState(""); const formatTs = useAdminDateTimeFormatter();
const [drawNoDraft, setDrawNoDraft] = useState(""); const [draft, setDraft] = useState<TicketFilters>(emptyTicketFilters);
const [playerId, setPlayerId] = useState<number | null>(null); const [applied, setApplied] = useState<TicketFilters>(emptyTicketFilters);
const [drawNo, setDrawNo] = useState(""); const [data, setData] = useState<AdminTicketItemsData | null>(null);
const [data, setData] = useState<AdminPlayerTicketItemsData | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20); const [perPage, setPerPage] = useState(20);
const load = useCallback(async () => { const load = useCallback(async () => {
if (playerId == null || playerId < 1) {
setData(null);
return;
}
setLoading(true); setLoading(true);
setErr(null); setErr(null);
try { try {
const d = await getAdminPlayerTicketItems(playerId, { const playerQuery = applied.playerQuery.trim();
const playerId = Number(playerQuery);
const query =
playerQuery === ""
? {}
: Number.isInteger(playerId) && playerId > 0 && String(playerId) === playerQuery
? { player_id: playerId }
: { player_account: playerQuery };
const d = await getAdminTicketItems({
page, page,
per_page: perPage, per_page: perPage,
draw_no: drawNo.trim() || undefined, ...query,
draw_no: applied.drawNo.trim() || undefined,
status: applied.statuses.length > 0 ? applied.statuses : undefined,
number: applied.numberKeyword.trim() || undefined,
start_date: applied.startDate || undefined,
end_date: applied.endDate || undefined,
}); });
setData(d); setData(d);
} catch (e) { } catch (e) {
@@ -53,7 +127,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [playerId, page, perPage, drawNo, t]); }, [applied, page, perPage, t]);
useEffect(() => { useEffect(() => {
queueMicrotask(() => { queueMicrotask(() => {
@@ -62,108 +136,227 @@ export function PlayerTicketsConsole(): React.ReactElement {
}, [load]); }, [load]);
const runSearch = () => { const runSearch = () => {
const id = Number(playerIdDraft.trim());
if (Number.isNaN(id) || id < 1) {
setErr(t("invalidPlayerId"));
setPlayerId(null);
setData(null);
return;
}
setErr(null); setErr(null);
setPlayerId(id); setApplied({
setDrawNo(drawNoDraft.trim()); ...draft,
playerQuery: draft.playerQuery.trim(),
drawNo: draft.drawNo.trim(),
numberKeyword: draft.numberKeyword.trim(),
});
setPage(1); setPage(1);
}; };
const resetFilters = () => {
setDraft(emptyTicketFilters);
setApplied(emptyTicketFilters);
setErr(null);
setPage(1);
};
const toggleStatus = (status: string, checked: boolean) => {
setDraft((current) => ({
...current,
statuses: checked
? [...current.statuses, status]
: current.statuses.filter((item) => item !== status),
}));
};
return ( return (
<Card className="admin-list-card w-full max-w-none"> <Card className="admin-list-card w-full max-w-none">
<CardHeader className="admin-list-header"> <CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("playerTicketQuery")}</CardTitle> <CardTitle className="admin-list-title">{t("playerTicketQuery")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="admin-list-content"> <CardContent className="admin-list-content">
<div className="admin-list-toolbar"> <div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-4">
<div className="admin-list-field"> <div className="grid gap-1.5">
<Label htmlFor="pt-player" className="sm:w-20 sm:shrink-0">{t("playerId")}</Label> <Label htmlFor="pt-player">{t("playerId")}</Label>
<Input <Input
id="pt-player" id="pt-player"
inputMode="numeric" className="font-mono"
className="w-full font-mono sm:w-36" placeholder={t("playerIdPlaceholder")}
placeholder="players.id" value={draft.playerQuery}
value={playerIdDraft} onChange={(e) =>
onChange={(e) => setPlayerIdDraft(e.target.value)} setDraft((current) => ({ ...current, playerQuery: e.target.value }))
}
/> />
</div> </div>
<div className="admin-list-field xl:min-w-0"> <div className="grid gap-1.5">
<Label htmlFor="pt-draw" className="sm:w-20 sm:shrink-0">{t("drawNoOptional")}</Label> <Label htmlFor="pt-draw">{t("drawNoOptional")}</Label>
<Input <Input
id="pt-draw" id="pt-draw"
className="w-full font-mono text-sm sm:w-[16rem] xl:w-[20rem]" className="font-mono text-sm"
placeholder={t("drawNoPlaceholder")} placeholder={t("drawNoPlaceholder")}
value={drawNoDraft} value={draft.drawNo}
onChange={(e) => setDrawNoDraft(e.target.value)} onChange={(e) => setDraft((current) => ({ ...current, drawNo: e.target.value }))}
/> />
</div> </div>
<div className="admin-list-actions"> <div className="grid gap-1.5">
<AdminTableExportButton <Label htmlFor="pt-number">{t("numberKeyword")}</Label>
tableId="player-tickets-table" <Input
filename="玩家注单" id="pt-number"
sheetName="玩家注单" className="font-mono text-sm"
placeholder={t("numberKeywordPlaceholder")}
value={draft.numberKeyword}
onChange={(e) =>
setDraft((current) => ({ ...current, numberKeyword: e.target.value }))
}
/>
</div>
<div className="grid gap-1.5">
<AdminDateRangeField
id="pt-date-range"
label={t("placedDateRange")}
from={draft.startDate}
to={draft.endDate}
onRangeChange={(range) =>
setDraft((current) => ({
...current,
startDate: range.from,
endDate: range.to,
}))
}
/> />
<Button type="button" onClick={() => runSearch()}>
{t("query")}
</Button>
</div> </div>
</div> </div>
<div className="grid gap-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium leading-none">{t("statusFilterLabel")}</span>
<span className="text-muted-foreground text-xs">{t("statusHint")}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex h-11 w-full items-center justify-between rounded-md border border-border bg-card px-4 text-left text-sm font-normal text-primary shadow-sm outline-none transition-all hover:bg-accent hover:text-primary focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50">
<span className="truncate">{ticketStatusSummary(draft.statuses, t)}</span>
<ChevronDown className="size-4 shrink-0 opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[min(28rem,calc(100vw-2rem))]">
{TICKET_STATUS_OPTIONS.map((status) => (
<DropdownMenuCheckboxItem
key={status}
checked={draft.statuses.includes(status)}
onCheckedChange={(checked) => toggleStatus(status, checked === true)}
>
{ticketStatusText(status, t)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-wrap gap-2">
<AdminTableExportButton
tableId="tickets-table"
filename="注单列表"
sheetName="注单列表"
/>
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("query")}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
{t("resetFilters")}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("refreshCurrentPage")}
</Button>
</div>
{applied.playerQuery || applied.drawNo || applied.numberKeyword || applied.startDate || applied.endDate || applied.statuses.length > 0 ? (
<p className="text-muted-foreground text-sm">
{applied.playerQuery ? (
<>
{t("playerId")}<span className="font-mono">{applied.playerQuery}</span>
</>
) : (
<span>{t("allTickets", { defaultValue: "全部注单" })}</span>
)}
{applied.drawNo ? (
<>
{" · "}
{t("drawNo")}<span className="font-mono">{applied.drawNo}</span>
</>
) : null}
</p>
) : null}
{err ? <p className="text-sm text-destructive">{err}</p> : null} {err ? <p className="text-sm text-destructive">{err}</p> : null}
{loading && playerId != null ? ( {loading ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : null} ) : null}
{data ? ( {data ? (
<> <>
<div className="admin-table-shell"> <div className="admin-table-shell">
<Table id="player-tickets-table"> <Table id="tickets-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("ticketNo")}</TableHead> <TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("player")}</TableHead>
<TableHead>{t("orderNo")}</TableHead> <TableHead>{t("orderNo")}</TableHead>
<TableHead>{t("drawNo")}</TableHead> <TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead> <TableHead>{t("playCode")}</TableHead>
<TableHead>{t("number")}</TableHead> <TableHead>{t("number")}</TableHead>
<TableHead className="text-right">{t("betAmount")}</TableHead>
<TableHead className="text-right">{t("actualDeduct")}</TableHead> <TableHead className="text-right">{t("actualDeduct")}</TableHead>
<TableHead>{t("status")}</TableHead> <TableHead>{t("status")}</TableHead>
<TableHead>{t("failReason")}</TableHead> <TableHead>{t("failReason")}</TableHead>
<TableHead className="text-right">{t("winAmount")}</TableHead> <TableHead className="text-right">{t("winAmount")}</TableHead>
<TableHead>{t("placedAt")}</TableHead>
<TableHead>{t("updatedAt")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.items.length === 0 ? ( {data.items.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="text-muted-foreground"> <TableCell colSpan={13} className="text-muted-foreground">
{t("states.noData", { ns: "common" })} {t("states.noData", { ns: "common" })}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
data.items.map((row) => ( data.items.map((row) => {
const winLabel = row.jackpot_win_amount > 0
? `${row.win_amount_formatted} + ${row.jackpot_win_amount_formatted}`
: row.win_amount_formatted;
return (
<TableRow key={row.ticket_no}> <TableRow key={row.ticket_no}>
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell> <TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
<TableCell className="text-xs">
<div className="flex flex-col leading-tight">
<span className="font-medium">
{row.nickname ?? row.username ?? "—"}
</span>
<span className="font-mono text-[11px] text-muted-foreground">
{row.site_code && row.site_player_id
? `${row.site_code} / ${row.site_player_id}`
: row.site_player_id ?? `#${row.player_id}`}
</span>
</div>
</TableCell>
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell> <TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell> <TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{row.play_code}</TableCell> <TableCell className="text-xs">{row.play_code}</TableCell>
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell> <TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums text-xs"> <TableCell className="text-right tabular-nums text-xs">
{row.actual_deduct_amount} {row.total_bet_amount_formatted}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{row.actual_deduct_amount_formatted}
</TableCell>
<TableCell className="text-xs">
<Badge variant={ticketStatusVariant(row.status)}>
{ticketStatusText(row.status, t)}
</Badge>
</TableCell> </TableCell>
<TableCell className="text-xs">{row.status}</TableCell>
<TableCell className="max-w-[14rem] text-xs text-muted-foreground"> <TableCell className="max-w-[14rem] text-xs text-muted-foreground">
{row.fail_reason_text ?? row.fail_reason_code ?? "—"} {row.fail_reason_text ?? row.fail_reason_code ?? "—"}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums text-xs"> <TableCell className="text-right tabular-nums text-xs">{winLabel}</TableCell>
{row.win_amount + row.jackpot_win_amount} <TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell>
</TableCell> <TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
</TableRow> </TableRow>
)) );
})
)} )}
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -1,5 +1,5 @@
export const walletModuleMeta = { export const walletModuleMeta = {
segment: "wallet", segment: "wallet",
title: "Wallet", title: "钱包管理",
description: "", description: "",
} as const; } as const;

View File

@@ -6,12 +6,16 @@ export type AdminPlayerTicketItemRow = {
play_code: string; play_code: string;
original_number: string | null; original_number: string | null;
total_bet_amount: number; total_bet_amount: number;
total_bet_amount_formatted: string;
actual_deduct_amount: number; actual_deduct_amount: number;
actual_deduct_amount_formatted: string;
status: string; status: string;
fail_reason_code: string | null; fail_reason_code: string | null;
fail_reason_text: string | null; fail_reason_text: string | null;
win_amount: number; win_amount: number;
win_amount_formatted: string;
jackpot_win_amount: number; jackpot_win_amount: number;
jackpot_win_amount_formatted: string;
placed_at: string | null; placed_at: string | null;
updated_at: string | null; updated_at: string | null;
}; };

View File

@@ -0,0 +1,35 @@
export type AdminTicketItemRow = {
id: number;
ticket_no: string;
player_id: number;
site_code: string | null;
site_player_id: string | null;
username: string | null;
nickname: string | null;
order_no: string | null;
draw_no: string | null;
currency_code: string | null;
play_code: string;
original_number: string | null;
total_bet_amount: number;
total_bet_amount_formatted: string;
actual_deduct_amount: number;
actual_deduct_amount_formatted: string;
status: string;
fail_reason_code: string | null;
fail_reason_text: string | null;
win_amount: number;
win_amount_formatted: string;
jackpot_win_amount: number;
jackpot_win_amount_formatted: string;
placed_at: string | null;
updated_at: string | null;
};
export type AdminTicketItemsData = {
items: AdminTicketItemRow[];
total: number;
page: number;
per_page: number;
last_page: number;
};

View File

@@ -23,6 +23,10 @@ export type {
AdminPlayerTicketItemRow, AdminPlayerTicketItemRow,
AdminPlayerTicketItemsData, AdminPlayerTicketItemsData,
} from "./admin-player-tickets"; } from "./admin-player-tickets";
export type {
AdminTicketItemRow,
AdminTicketItemsData,
} from "./admin-tickets";
export type { export type {
AdminPlayerWalletsData, AdminPlayerWalletsData,
AdminPlayerWalletRow, AdminPlayerWalletRow,