feat: 统一管理端多语言、配置与票据/结算页面重构
This commit is contained in:
@@ -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
25
src/api/admin-tickets.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "अडिट लग · वित्त सम्बन्धित"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "सेटिङ"
|
||||||
|
|||||||
@@ -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": "पुष्टि"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "समय",
|
||||||
|
|||||||
@@ -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": "दैनिक नाफा सारांश",
|
||||||
|
|||||||
@@ -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": "अस्वीकृत"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "सबै टिकट"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "审计日志·资金相关"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"title": "审计日志",
|
"title": "审计日志",
|
||||||
"moduleCode": "模块编码",
|
"moduleCode": "模块",
|
||||||
"actionCode": "动作编码",
|
"actionCode": "动作",
|
||||||
"operatorType": "操作者类型",
|
"operatorType": "操作者类型",
|
||||||
"exactMatch": "精确匹配",
|
"exactMatch": "请输入完整名称",
|
||||||
"operatorTypePlaceholder": "如 admin / system",
|
"operatorTypePlaceholder": "如管理员、系统",
|
||||||
"operator": "操作者",
|
"operator": "操作者",
|
||||||
"module": "模块",
|
"module": "模块",
|
||||||
"action": "动作",
|
"action": "动作",
|
||||||
|
|||||||
@@ -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": "系统设置"
|
||||||
|
|||||||
@@ -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": "确认"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"todayBetTotal": "当期投注总额",
|
"todayBetTotal": "当期投注总额",
|
||||||
"currentDrawFinanceSummary": "当前大厅期财务汇总",
|
"currentDrawFinanceSummary": "当前大厅期财务汇总",
|
||||||
"currentPayout": "当期派彩",
|
"currentPayout": "当期派彩",
|
||||||
"payoutSummary": "中奖派彩 + Jackpot",
|
"payoutSummary": "中奖派彩 + 奖池",
|
||||||
"currentProfit": "当期平台盈亏",
|
"currentProfit": "当期平台盈亏",
|
||||||
"profitFormula": "投注 − 派彩(近似)",
|
"profitFormula": "投注 − 派彩(近似)",
|
||||||
"currentDraw": "当前期号",
|
"currentDraw": "当前期号",
|
||||||
|
|||||||
@@ -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": "时间",
|
||||||
|
|||||||
@@ -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": "每日盈亏汇总",
|
||||||
|
|||||||
@@ -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": "已驳回"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "全部注单"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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` */
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const authModuleMeta = {
|
export const authModuleMeta = {
|
||||||
segment: "login",
|
segment: "login",
|
||||||
title: "Login",
|
title: "登录",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const dashboardModuleMeta = {
|
export const dashboardModuleMeta = {
|
||||||
segment: "dashboard",
|
segment: "dashboard",
|
||||||
title: "Dashboard",
|
title: "仪表盘",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const drawsModuleMeta = {
|
export const drawsModuleMeta = {
|
||||||
segment: "draws",
|
segment: "draws",
|
||||||
title: "Draws",
|
title: "期号管理",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const jackpotModuleMeta = {
|
export const jackpotModuleMeta = {
|
||||||
title: "Jackpot",
|
title: "奖池记录",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const playersModuleMeta = {
|
export const playersModuleMeta = {
|
||||||
segment: "players",
|
segment: "players",
|
||||||
title: "Players",
|
title: "玩家列表",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const reconcileModuleMeta = {
|
export const reconcileModuleMeta = {
|
||||||
segment: "reconcile",
|
segment: "reconcile",
|
||||||
title: "Reconcile",
|
title: "对账中心",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const reportsModuleMeta = {
|
export const reportsModuleMeta = {
|
||||||
segment: "reports",
|
segment: "reports",
|
||||||
title: "Reports",
|
title: "报表导出",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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 ?? "—"}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const riskModuleMeta = {
|
export const riskModuleMeta = {
|
||||||
segment: "risk",
|
segment: "risk",
|
||||||
title: "Risk",
|
title: "风控中心",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const settingsModuleMeta = {
|
export const settingsModuleMeta = {
|
||||||
segment: "settings",
|
segment: "settings",
|
||||||
title: "Settings",
|
title: "系统设置",
|
||||||
description: "",
|
description: "管理影响钱包划转与跨模块行为的全局运行参数。",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
226
src/modules/settings/system-settings-screen.tsx
Normal file
226
src/modules/settings/system-settings-screen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/modules/settlement/invalid-settlement-batch-id.tsx
Normal file
9
src/modules/settlement/invalid-settlement-batch-id.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export const settlementModuleMeta = {
|
export const settlementModuleMeta = {
|
||||||
title: "结算",
|
title: "结算批次",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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>{" "}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const ticketsModuleMeta = {
|
export const ticketsModuleMeta = {
|
||||||
segment: "tickets",
|
segment: "tickets",
|
||||||
title: "Tickets",
|
title: "注单列表",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export const walletModuleMeta = {
|
export const walletModuleMeta = {
|
||||||
segment: "wallet",
|
segment: "wallet",
|
||||||
title: "Wallet",
|
title: "钱包管理",
|
||||||
description: "",
|
description: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
35
src/types/api/admin-tickets.ts
Normal file
35
src/types/api/admin-tickets.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user