feat: 增加管理端多语言与多模块界面国际化支持

This commit is contained in:
2026-05-19 09:11:55 +08:00
parent 49a4caf01e
commit 1b1dfc92ab
110 changed files with 4053 additions and 1308 deletions

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import {
Breadcrumb,
BreadcrumbItem,
@@ -11,13 +12,12 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { adminShellNavItems, ADMIN_BASE } from "@/modules/_config/admin-nav";
import { CONFIG_ROUTE_LABELS } from "@/modules/config/config-nav-model";
import React from "react";
const DRAW_ROUTE_LABELS: Record<string, string> = {
finance: "期号收支",
review: "审核",
results: "开奖结果",
finance: "Draw Finance",
review: "Review",
results: "Results",
};
function titleCase(value: string): string {
@@ -34,15 +34,16 @@ type BreadcrumbCrumb = {
};
export function AdminBreadcrumb() {
const { t } = useTranslation(["common", "dashboard", "reports", "audit", "config", "draws"]);
const pathname = usePathname();
// 把路径拆分成段
// Split the current path into segments.
const segments = pathname.split("/").filter(Boolean);
// 基础面包屑:首页/仪表盘
// Base breadcrumb: home / dashboard.
const breadcrumbs: BreadcrumbCrumb[] = [
{
label: "首页",
label: t("nav.home", { ns: "common" }),
href: ADMIN_BASE,
isCurrent: pathname === ADMIN_BASE,
},
@@ -56,8 +57,16 @@ export function AdminBreadcrumb() {
});
if (navItem && navItem.href !== ADMIN_BASE) {
const navLabelMap: Record<string, string> = {
dashboard: t("title", { ns: "dashboard" }),
reports: t("title", { ns: "reports" }),
"audit-logs": t("title", { ns: "audit" }),
};
breadcrumbs.push({
label: navItem.segment === "draws" ? "期号列表" : navItem.label,
label:
navItem.segment === "draws"
? "Draws"
: navLabelMap[navItem.segment] ?? navItem.label,
href: navItem.href,
isCurrent: pathname === navItem.href || segments.length === 2,
});
@@ -67,9 +76,14 @@ export function AdminBreadcrumb() {
const subSegment = segments[2];
let subLabel = "";
if (businessSegment === "config" && subSegment) {
subLabel = CONFIG_ROUTE_LABELS[subSegment] ?? titleCase(subSegment);
subLabel = t(`nav.items.${subSegment}`, { ns: "config", defaultValue: titleCase(subSegment) });
} else {
subLabel = subSegment ? DRAW_ROUTE_LABELS[subSegment] ?? titleCase(subSegment) : "";
subLabel = subSegment
? t(`subnav.${subSegment}`, {
ns: "draws",
defaultValue: DRAW_ROUTE_LABELS[subSegment] ?? titleCase(subSegment),
})
: "";
}
if (subLabel) {
breadcrumbs.push({

View File

@@ -2,8 +2,9 @@
import * as React from "react";
import { format, parse } from "date-fns";
import { zhCN } from "date-fns/locale";
import { enUS } from "date-fns/locale";
import { CalendarIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
@@ -16,16 +17,18 @@ export function AdminDateField({
label,
value,
onChange,
placeholder = "选择日期",
placeholder,
}: {
id: string;
label: string;
/** `yyyy-MM-dd` 或空 */
/** `yyyy-MM-dd` or empty */
value: string;
onChange: (next: string) => void;
placeholder?: string;
}) {
const { t } = useTranslation(["common"]);
const [open, setOpen] = React.useState(false);
const resolvedPlaceholder = placeholder ?? t("date.placeholder", { ns: "common", defaultValue: "Select date" });
const parsed = React.useMemo(() => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
@@ -35,7 +38,7 @@ export function AdminDateField({
return Number.isNaN(d.getTime()) ? undefined : d;
}, [value]);
const summary = parsed ? format(parsed, "yyyy年M月d日", { locale: zhCN }) : placeholder;
const summary = parsed ? format(parsed, "yyyy-MM-dd", { locale: enUS }) : resolvedPlaceholder;
return (
<div className="grid gap-1.5">
@@ -62,7 +65,7 @@ export function AdminDateField({
<PopoverContent align="start" sideOffset={6} className="w-auto min-w-fit p-0">
<Calendar
mode="single"
locale={zhCN}
locale={enUS}
captionLayout="dropdown"
selected={parsed}
defaultMonth={parsed}
@@ -82,7 +85,7 @@ export function AdminDateField({
setOpen(false);
}}
>
{t("actions.clear", { ns: "common", defaultValue: "Clear" })}
</Button>
</div>
</PopoverContent>

View File

@@ -3,8 +3,9 @@
import * as React from "react";
import type { DateRange } from "react-day-picker";
import { format, parse } from "date-fns";
import { zhCN } from "date-fns/locale";
import { enUS } from "date-fns/locale";
import { CalendarRange } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
@@ -31,19 +32,19 @@ function summarize(from: string, to: string, placeholder: string): string {
if (!df && !dt) {
return placeholder;
}
const a = df ? format(df, "yyyy年M月d日", { locale: zhCN }) : "";
const b = dt ? format(dt, "yyyy年M月d日", { locale: zhCN }) : "";
return `${a} ${b}`;
const a = df ? format(df, "yyyy-MM-dd", { locale: enUS }) : "...";
const b = dt ? format(dt, "yyyy-MM-dd", { locale: enUS }) : "...";
return `${a} - ${b}`;
}
/** shadcn Popover + Calendar `mode="range"`;输出与原先两个 `yyyy-MM-dd` 筛选字段兼容 */
/** Range date picker compatible with legacy `yyyy-MM-dd` filter fields. */
export function AdminDateRangeField({
id,
label,
from: fromProp,
to: toProp,
onRangeChange,
placeholder = "选择日期范围",
placeholder,
}: {
id: string;
label?: string;
@@ -52,8 +53,11 @@ export function AdminDateRangeField({
onRangeChange: (next: { from: string; to: string }) => void;
placeholder?: string;
}) {
const { t } = useTranslation(["common"]);
const [open, setOpen] = React.useState(false);
const isMobile = useIsMobile();
const resolvedPlaceholder =
placeholder ?? t("date.rangePlaceholder", { ns: "common", defaultValue: "Select date range" });
const selected = React.useMemo((): DateRange | undefined => {
const df = parseYmd(fromProp);
@@ -98,16 +102,20 @@ export function AdminDateRangeField({
>
<CalendarRange className="pointer-events-none size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 truncate text-left">
{summarize(fromProp, toProp, placeholder)}
{summarize(fromProp, toProp, resolvedPlaceholder)}
</span>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto max-w-[calc(100vw-2rem)] min-w-fit p-0">
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
{t("date.rangeHint", {
ns: "common",
defaultValue:
"Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.",
})}
</p>
<Calendar
mode="range"
locale={zhCN}
locale={enUS}
captionLayout="dropdown"
selected={selected}
defaultMonth={defaultMonth}
@@ -133,7 +141,7 @@ export function AdminDateRangeField({
setOpen(false);
}}
>
{t("actions.clear", { ns: "common", defaultValue: "Clear" })}
</Button>
<Button
type="button"
@@ -144,7 +152,7 @@ export function AdminDateRangeField({
setOpen(false);
}}
>
{t("actions.done", { ns: "common", defaultValue: "Done" })}
</Button>
</div>
</PopoverContent>

View File

@@ -37,7 +37,11 @@ export function AdminLanguageSwitcher() {
applyAdminUiLocale(next);
await i18n.changeLanguage(next);
setLocale(next);
toast.success(`${t("language.changed", { defaultValue: "语言已切换" })}: ${ADMIN_LOCALE_LABELS[next]}`);
toast.success(
t("language.changed", {
language: ADMIN_LOCALE_LABELS[next],
}),
);
}
return (
@@ -49,7 +53,7 @@ export function AdminLanguageSwitcher() {
<DropdownMenuContent align="end" className="min-w-[12rem]">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("language.title", { defaultValue: "界面语言" })}
{t("language.title")}
</DropdownMenuLabel>
{ADMIN_API_LOCALES.map((code) => (
<DropdownMenuItem

View File

@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import {
Pagination,
@@ -21,7 +22,7 @@ import { cn } from "@/lib/utils";
export const ADMIN_LIST_PER_PAGE_OPTIONS = [10, 20, 25, 50, 100] as const;
/** 服务端分页页码序列(省略号);与 shadcn Pagination 拼装用 */
/** Server-side pagination page slices with ellipsis markers. */
export function adminListPageSlices(
current: number,
last: number,
@@ -58,15 +59,16 @@ export function AdminPerPagePicker({
perPage,
onChange,
}: {
/** 表单控件 id · 区分同页多块列表 */
/** Form control id used to distinguish multiple lists on one page. */
selectId: string;
perPage: number;
onChange: (next: number) => void;
}) {
const { t } = useTranslation(["common"]);
return (
<div className="flex items-center gap-2">
<Label htmlFor={selectId} className="text-sm leading-none whitespace-nowrap">
{t("pagination.perPage", { defaultValue: "Per page" })}
</Label>
<Select
modal={false}
@@ -79,7 +81,7 @@ export function AdminPerPagePicker({
}}
>
<SelectTrigger id={selectId} size="sm" className="w-[6.75rem]">
<SelectValue placeholder="请选择" />
<SelectValue placeholder={t("pagination.selectPlaceholder", { defaultValue: "Select" })} />
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
{ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => (
@@ -93,7 +95,7 @@ export function AdminPerPagePicker({
);
}
/** 表格底栏:左统计 + 右「每页条数」与 shadcn Pagination与 Data Table pagination 一节一致) */
/** Table footer: left summary, right per-page picker and pagination. */
export function AdminListPaginationFooter({
selectId,
total,
@@ -113,10 +115,16 @@ export function AdminListPaginationFooter({
onPerPageChange: (nextPerPage: number) => void;
onPageChange: (nextPage: number) => void;
}) {
const { t } = useTranslation(["common"]);
return (
<div className="flex flex-col gap-4 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted-foreground text-sm tabular-nums">
{total} {page} / {lastPage}
{t("pagination.summary", {
total,
page,
lastPage,
defaultValue: "{{total}} total, page {{page}} / {{lastPage}}",
})}
</p>
<div className="flex w-full flex-col items-stretch gap-4 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end lg:gap-6">
<AdminPerPagePicker
@@ -132,7 +140,7 @@ export function AdminListPaginationFooter({
<PaginationItem>
<PaginationPrevious
href="#"
text="上一页"
text={t("pagination.previous", { defaultValue: "Previous" })}
aria-disabled={page <= 1 || loading}
className={cn(
(page <= 1 || loading) && "pointer-events-none opacity-50",
@@ -175,7 +183,7 @@ export function AdminListPaginationFooter({
<PaginationItem>
<PaginationNext
href="#"
text="下一页"
text={t("pagination.next", { defaultValue: "Next" })}
aria-disabled={page >= lastPage || loading}
className={cn(
(page >= lastPage || loading) && "pointer-events-none opacity-50",

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { SparklesIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Sidebar,
@@ -33,6 +34,7 @@ function isActive(pathname: string, item: { href: string; activeMatchPrefix?: st
}
export function AdminAppSidebar() {
const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "reports", "audit"]);
const pathname = usePathname();
const profile = useAdminProfile();
const visibleNav = useMemo(
@@ -55,7 +57,7 @@ export function AdminAppSidebar() {
<SparklesIcon data-icon="inline-start" aria-hidden />
<div className="flex flex-col items-start gap-0 group-data-[collapsible=icon]:hidden">
<span className="font-semibold tracking-tight text-sidebar-foreground">
{t("app.title", { ns: "common" })}
</span>
<span className="text-[11px] leading-tight text-sidebar-foreground/70">
Lottery Admin
@@ -67,7 +69,7 @@ export function AdminAppSidebar() {
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel></SidebarGroupLabel>
<SidebarGroupLabel>{t("sidebar.workspace", { ns: "common", defaultValue: "Workspace" })}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{visibleNav.map((item) => {
@@ -75,12 +77,12 @@ export function AdminAppSidebar() {
return (
<SidebarMenuItem key={item.segment}>
<SidebarMenuButton
tooltip={item.label}
tooltip={t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })}
isActive={isActive(pathname, item)}
render={<Link href={item.href} />}
>
<Icon data-icon="inline-start" aria-hidden />
<span>{item.label}</span>
<span>{t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);

View File

@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { readToken } from "@/stores/admin-token";
@@ -10,10 +11,11 @@ type ShellAuthGateProps = {
};
/**
* 壳内路由:仅客户端可读 `localStorage` Token无 Token 时跳转登录。
* 注意:首屏仍可能先出现服务端渲染的页面片段,再完成跳转(未用 Cookie + middleware 前无法完全避免)。
* Shell route guard. Reads the auth token from localStorage on the client and
* redirects to the login page when no token is present.
*/
export function ShellAuthGate({ children }: ShellAuthGateProps) {
const { t } = useTranslation("common");
const router = useRouter();
const [allowed, setAllowed] = useState(false);
@@ -31,7 +33,7 @@ export function ShellAuthGate({ children }: ShellAuthGateProps) {
if (!allowed) {
return (
<div className="flex min-h-[50vh] w-full flex-1 items-center justify-center text-sm text-muted-foreground">
{t("auth.checking", { defaultValue: "Checking sign-in status…" })}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { ShieldCheckIcon, TriangleAlertIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { isAxiosError } from "axios";
@@ -18,6 +19,7 @@ import { useAdminSessionStore } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
export function LoginForm() {
const { t } = useTranslation("auth");
const router = useRouter();
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
@@ -43,7 +45,7 @@ export function LoginForm() {
try {
const data = await getAdminCaptcha();
if (!data) {
toast.error("无法获取验证码,请检查接口或网络");
toast.error(t("captchaLoadFailed"));
setCaptchaKey(null);
setCaptchaSrc(null);
@@ -55,7 +57,7 @@ export function LoginForm() {
} finally {
setLoadingCaptcha(false);
}
}, [apiConfigured]);
}, [apiConfigured, t]);
useEffect(() => {
if (readToken()) {
@@ -73,12 +75,12 @@ export function LoginForm() {
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!apiConfigured) {
toast.error("未配置 NEXT_PUBLIC_LOTTERY_API_BASE_URL");
toast.error(t("apiBaseMissingToast"));
return;
}
if (!captchaKey || !captchaSrc) {
toast.error("请先刷新验证码");
toast.error(t("captchaRequired"));
void loadCaptcha();
return;
@@ -94,7 +96,9 @@ export function LoginForm() {
});
setBearerToken(result.token);
setAdminProfile(result.admin);
toast.success(`欢迎,${result.admin.nickname || result.admin.username}`);
toast.success(
t("welcome", { name: result.admin.nickname || result.admin.username }),
);
router.replace("/admin");
router.refresh();
} catch (err) {
@@ -105,11 +109,11 @@ export function LoginForm() {
return;
}
if (isAxiosError(err)) {
toast.error(err.message || "网络请求失败");
toast.error(err.message || t("networkFailed"));
return;
}
toast.error("登录失败");
toast.error(t("loginFailed"));
} finally {
setSubmitting(false);
}
@@ -142,7 +146,7 @@ export function LoginForm() {
</div>
<div className="space-y-1.5">
<CardTitle className="text-balance text-2xl font-semibold tracking-tight">
{authModuleMeta.title}
{t("loginTitle", { defaultValue: authModuleMeta.title })}
</CardTitle>
</div>
</CardHeader>
@@ -151,19 +155,19 @@ export function LoginForm() {
{!apiConfigured ? (
<Alert variant="destructive" className="text-left">
<TriangleAlertIcon />
<AlertTitle> API </AlertTitle>
<AlertTitle>{t("apiMissingTitle")}</AlertTitle>
<AlertDescription>
{" "}
{t("apiMissingDescriptionPrefix")}{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
NEXT_PUBLIC_LOTTERY_API_BASE_URL
</code>{" "}
Laravel URL http://127.0.0.1:8000
{t("apiMissingDescriptionSuffix")}
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="admin-account" className="text-sm font-medium">
{t("account")}
</Label>
<Input
id="admin-account"
@@ -171,7 +175,7 @@ export function LoginForm() {
autoComplete="username"
value={account}
onChange={(ev) => setAccount(ev.target.value)}
placeholder="登录账号"
placeholder={t("accountPlaceholder")}
required
disabled={submitting}
className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25"
@@ -179,7 +183,7 @@ export function LoginForm() {
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="admin-password" className="text-sm font-medium">
{t("password")}
</Label>
<Input
id="admin-password"
@@ -188,7 +192,7 @@ export function LoginForm() {
autoComplete="current-password"
value={password}
onChange={(ev) => setPassword(ev.target.value)}
placeholder="密码"
placeholder={t("passwordPlaceholder")}
required
disabled={submitting}
className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25"
@@ -196,7 +200,7 @@ export function LoginForm() {
</div>
<div className="mb-8 flex flex-col gap-2">
<Label htmlFor="admin-captcha" className="text-sm font-medium">
{t("captcha")}
</Label>
<div className="flex flex-wrap items-center gap-3">
<Input
@@ -205,7 +209,7 @@ export function LoginForm() {
autoComplete="off"
value={captchaCode}
onChange={(ev) => setCaptchaCode(ev.target.value)}
placeholder="图中字符"
placeholder={t("captchaPlaceholder")}
maxLength={32}
required
disabled={submitting}
@@ -218,7 +222,7 @@ export function LoginForm() {
disabled={
loadingCaptcha || !apiConfigured || submitting
}
aria-label={loadingCaptcha ? "加载验证码中" : "点击刷新验证码"}
aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")}
>
{captchaSrc ? (
// eslint-disable-next-line @next/next/no-img-element -- data URL from API
@@ -231,7 +235,7 @@ export function LoginForm() {
/>
) : (
<span className="px-2 text-xs text-muted-foreground">
{loadingCaptcha ? "加载中…" : "点击获取"}
{loadingCaptcha ? t("captchaLoading") : t("captchaFetch")}
</span>
)}
</button>
@@ -245,7 +249,7 @@ export function LoginForm() {
className="h-11 w-full text-base font-medium shadow-sm"
disabled={submitting || !apiConfigured}
>
{submitting ? "登录中…" : "登录"}
{submitting ? t("submitting") : t("submit")}
</Button>
</CardFooter>
</form>

View File

@@ -7,6 +7,7 @@ import {
UserRoundIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher";
@@ -64,6 +65,7 @@ function initialsFromProfile(profile: AdminProfile | null): string {
}
export function ShellToolbar() {
const { t } = useTranslation("common");
const router = useRouter();
const adminProfile = useAdminProfile();
const clearSession = useAdminSessionStore((s) => s.clearSession);
@@ -71,11 +73,11 @@ export function ShellToolbar() {
const displayName =
adminProfile?.nickname?.trim() ||
adminProfile?.username?.trim() ||
"管理员";
t("toolbar.defaultAdmin");
function onLogout() {
clearSession();
toast.success("已退出登录");
toast.success(t("toolbar.loggedOut"));
router.replace("/admin/login");
router.refresh();
}
@@ -87,9 +89,9 @@ export function ShellToolbar() {
variant="ghost"
size="icon"
className="relative shrink-0 text-primary hover:text-primary"
aria-label="通知"
title="通知"
onClick={() => toast.message("通知功能开发中")}
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">
@@ -132,7 +134,7 @@ export function ShellToolbar() {
<DropdownMenuGroup>
<DropdownMenuItem disabled className="gap-2">
<UserRoundIcon />
{t("toolbar.accountSettings")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
@@ -143,7 +145,7 @@ export function ShellToolbar() {
onClick={onLogout}
>
<LogOutIcon />
退
{t("actions.logout")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>

View File

@@ -6,48 +6,108 @@ import { initReactI18next } from "react-i18next";
import { adminHtmlLang, applyAdminUiLocale, type AdminApiLocale } from "@/lib/admin-locale";
import enAudit from "@/i18n/locales/en/audit.json";
import enAdminUsers from "@/i18n/locales/en/adminUsers.json";
import enAuth from "@/i18n/locales/en/auth.json";
import enCommon from "@/i18n/locales/en/common.json";
import enConfig from "@/i18n/locales/en/config.json";
import enDashboard from "@/i18n/locales/en/dashboard.json";
import enDraws from "@/i18n/locales/en/draws.json";
import enJackpot from "@/i18n/locales/en/jackpot.json";
import enReports from "@/i18n/locales/en/reports.json";
import enRisk from "@/i18n/locales/en/risk.json";
import enSettlement from "@/i18n/locales/en/settlement.json";
import enPlayers from "@/i18n/locales/en/players.json";
import enTickets from "@/i18n/locales/en/tickets.json";
import enReconcile from "@/i18n/locales/en/reconcile.json";
import enWallet from "@/i18n/locales/en/wallet.json";
import neAudit from "@/i18n/locales/ne/audit.json";
import neAdminUsers from "@/i18n/locales/ne/adminUsers.json";
import neAuth from "@/i18n/locales/ne/auth.json";
import neCommon from "@/i18n/locales/ne/common.json";
import neConfig from "@/i18n/locales/ne/config.json";
import neDashboard from "@/i18n/locales/ne/dashboard.json";
import neDraws from "@/i18n/locales/ne/draws.json";
import neJackpot from "@/i18n/locales/ne/jackpot.json";
import neReports from "@/i18n/locales/ne/reports.json";
import neRisk from "@/i18n/locales/ne/risk.json";
import neSettlement from "@/i18n/locales/ne/settlement.json";
import nePlayers from "@/i18n/locales/ne/players.json";
import neTickets from "@/i18n/locales/ne/tickets.json";
import neReconcile from "@/i18n/locales/ne/reconcile.json";
import neWallet from "@/i18n/locales/ne/wallet.json";
import zhAudit from "@/i18n/locales/zh/audit.json";
import zhAdminUsers from "@/i18n/locales/zh/adminUsers.json";
import zhAuth from "@/i18n/locales/zh/auth.json";
import zhCommon from "@/i18n/locales/zh/common.json";
import zhConfig from "@/i18n/locales/zh/config.json";
import zhDashboard from "@/i18n/locales/zh/dashboard.json";
import zhDraws from "@/i18n/locales/zh/draws.json";
import zhJackpot from "@/i18n/locales/zh/jackpot.json";
import zhReports from "@/i18n/locales/zh/reports.json";
import zhRisk from "@/i18n/locales/zh/risk.json";
import zhSettlement from "@/i18n/locales/zh/settlement.json";
import zhPlayers from "@/i18n/locales/zh/players.json";
import zhTickets from "@/i18n/locales/zh/tickets.json";
import zhReconcile from "@/i18n/locales/zh/reconcile.json";
import zhWallet from "@/i18n/locales/zh/wallet.json";
export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const;
export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number];
export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "en";
const namespaces = ["common", "auth", "dashboard", "reports", "audit"] as const;
const namespaces = ["common", "auth", "dashboard", "reports", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "wallet", "adminUsers", "config"] as const;
const resources = {
en: {
common: enCommon,
config: enConfig,
adminUsers: enAdminUsers,
auth: enAuth,
dashboard: enDashboard,
draws: enDraws,
jackpot: enJackpot,
players: enPlayers,
tickets: enTickets,
reconcile: enReconcile,
reports: enReports,
risk: enRisk,
audit: enAudit,
settlement: enSettlement,
wallet: enWallet,
},
ne: {
common: neCommon,
config: neConfig,
adminUsers: neAdminUsers,
auth: neAuth,
dashboard: neDashboard,
draws: neDraws,
jackpot: neJackpot,
players: nePlayers,
tickets: neTickets,
reconcile: neReconcile,
reports: neReports,
risk: neRisk,
audit: neAudit,
settlement: neSettlement,
wallet: neWallet,
},
zh: {
common: zhCommon,
config: zhConfig,
adminUsers: zhAdminUsers,
auth: zhAuth,
dashboard: zhDashboard,
draws: zhDraws,
jackpot: zhJackpot,
players: zhPlayers,
tickets: zhTickets,
reconcile: zhReconcile,
reports: zhReports,
risk: zhRisk,
audit: zhAudit,
settlement: zhSettlement,
wallet: zhWallet,
},
} satisfies Record<AdminLanguage, Record<(typeof namespaces)[number], Record<string, unknown>>>;

View File

@@ -0,0 +1,83 @@
{
"title": "Admins",
"listTitle": "Admin user list",
"createAdmin": "Create admin",
"searchPlaceholder": "Search by username / nickname / email",
"loadFailed": "Failed to load admin list",
"nicknameRequired": "Enter a nickname",
"newPasswordMin": "New password must be at least 8 characters",
"roleRequired": "Select at least one role",
"usernameRequired": "Enter a login username",
"passwordMin": "Password must be at least 8 characters",
"createSuccess": "Created admin {{name}}",
"updateSuccess": "Updated {{name}}",
"saveAccountFailed": "Failed to save account",
"deleteSuccess": "Deleted {{name}}",
"deleteFailed": "Delete failed",
"allPermissions": "All permissions",
"saveRoleSuccess": "Updated roles for {{name}}",
"saveRoleFailed": "Failed to save roles",
"savePermissionSuccess": "Updated permissions for {{name}}",
"savePermissionFailed": "Failed to save permissions",
"saving": "Saving…",
"deleting": "Deleting…",
"common": {
"none": "None"
},
"table": {
"account": "Account",
"nickname": "Nickname",
"status": "Status",
"roles": "Roles",
"direct": "Direct",
"effective": "Effective",
"actions": "Actions"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"actions": {
"permissions": "Permissions",
"edit": "Edit",
"delete": "Delete",
"cancel": "Cancel",
"save": "Save"
},
"permissionDialog": {
"title": "Admin permissions",
"rolesTitle": "Roles",
"rolesDescription": "Saved as default-site roles and merged with direct permissions as effective permissions.",
"rolePermissionCount": "Contains {{count}} functional permissions",
"directTitle": "Direct permissions",
"directDescription": "Expand by menu or domain and check specific prd.* items; in most cases role changes are enough.",
"selectedRoles": "Selected roles:",
"saveRoles": "Save roles",
"saveDirect": "Save direct permissions"
},
"accountDialog": {
"createTitle": "Create admin",
"editTitle": "Edit account",
"createDescription": "Assign at least one default-site role. Login usernames may contain letters, numbers, dots, underscores, and hyphens only, and are stored in lowercase.",
"editDescription": "Login username cannot be changed. Leave password empty to keep it unchanged.",
"username": "Login username",
"usernamePlaceholder": "For example: ops_admin",
"nickname": "Nickname",
"nicknamePlaceholder": "Display name",
"emailOptional": "Email (optional)",
"emailPlaceholder": "Leave empty if not needed",
"password": "Password",
"passwordOptional": "Password (optional)",
"passwordPlaceholderCreate": "At least 8 characters",
"passwordPlaceholderEdit": "Leave empty to keep unchanged",
"rolesRequired": "Roles (default site, at least one)",
"rolesDescription": "After creation, you can continue adjusting roles or grant direct permissions in Permissions.",
"noRoles": "No roles available yet. Wait for the list to finish loading and try again."
},
"delete": {
"currentUserBlocked": "You cannot delete the currently signed-in account",
"rowActionTitle": "Delete this admin",
"confirmTitle": "Confirm deletion",
"confirmDescription": "Delete admin {{name}}? This action cannot be undone."
}
}

View File

@@ -1,3 +1,14 @@
{
"title": "Audit Logs"
"title": "Audit Logs",
"moduleCode": "Module code",
"actionCode": "Action code",
"operatorType": "Operator type",
"exactMatch": "Exact match",
"operatorTypePlaceholder": "For example admin / system",
"operator": "Operator",
"module": "Module",
"action": "Action",
"target": "Target",
"time": "Time",
"empty": "No data"
}

View File

@@ -1,3 +1,24 @@
{
"title": "Login"
"title": "Login",
"loginTitle": "Admin Login",
"account": "Account",
"accountPlaceholder": "Login account",
"password": "Password",
"passwordPlaceholder": "Password",
"captcha": "Captcha",
"captchaPlaceholder": "Enter captcha",
"captchaLoading": "Loading captcha",
"captchaRefresh": "Click to refresh captcha",
"captchaFetch": "Click to get captcha",
"apiMissingTitle": "API base URL not configured",
"apiMissingDescriptionPrefix": "Set",
"apiMissingDescriptionSuffix": "in the environment (Laravel base URL, for example http://127.0.0.1:8000).",
"submit": "Log in",
"submitting": "Signing in…",
"captchaLoadFailed": "Failed to load captcha. Check the API or network.",
"apiBaseMissingToast": "NEXT_PUBLIC_LOTTERY_API_BASE_URL is not configured",
"captchaRequired": "Refresh the captcha first",
"welcome": "Welcome, {{name}}",
"networkFailed": "Network request failed",
"loginFailed": "Login failed"
}

View File

@@ -4,7 +4,7 @@
"ne": "नेपाली",
"zh": "中文",
"title": "Interface language",
"changed": "Language"
"changed": "Language switched to {{language}}"
},
"app": {
"title": "Lottery Admin"
@@ -15,6 +15,65 @@
"search": "Search",
"apply": "Apply",
"loading": "Loading...",
"submitting": "Submitting..."
"submitting": "Submitting...",
"logout": "Log out",
"close": "Close",
"viewAll": "View all",
"viewDetails": "View details",
"reviewNow": "Review now",
"create": "Create",
"createTask": "Create task",
"clear": "Clear",
"done": "Done"
},
"date": {
"placeholder": "Select date",
"rangePlaceholder": "Select date range",
"rangeHint": "Select a start date, then an end date. For a single day, click the same date twice. Click Done to close."
},
"pagination": {
"perPage": "Per page",
"selectPlaceholder": "Select",
"summary": "{{total}} total, page {{page}} / {{lastPage}}",
"previous": "Previous",
"next": "Next"
},
"states": {
"noData": "No data",
"loading": "Loading…",
"comingSoon": "Feature under development"
},
"errors": {
"loadFailed": "Failed to load"
},
"toolbar": {
"defaultAdmin": "Administrator",
"notifications": "Notifications",
"notificationsComingSoon": "Notifications are under development",
"accountSettings": "Account settings",
"loggedOut": "Signed out"
},
"nav": {
"home": "Home",
"dashboard": "Dashboard",
"admin_users": "Admin Users",
"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"
},
"sidebar": {
"workspace": "Workspace"
},
"auth": {
"checking": "Checking sign-in status…"
}
}

View File

@@ -0,0 +1,83 @@
{
"title": "Configuration Center",
"nav": {
"aria": "Operations configuration sub-navigation",
"sidebarTitle": "Operations configuration",
"groups": {
"betting": "Betting and display",
"risk_wallet": "Risk and funds"
},
"items": {
"plays": "Play types and limits",
"odds": "Odds",
"rebate": "Commission / rebate",
"risk-cap": "Payout caps",
"wallet": "Wallet thresholds"
}
},
"versionStatus": {
"active": "Active",
"draft": "Draft",
"archived": "Archived"
},
"versionSwitcher": {
"sheetTitle": "Switch configuration version",
"sheetDescription": "Choose a version to view on this page. Drafts are editable, while active and archived versions are read-only.",
"loading": "Loading…",
"noneSelected": "No version selected",
"switch": "Switch version",
"empty": "No version records yet.",
"count": "{{count}} items",
"effectiveAt": "Effective at: {{value}}",
"note": "Note: {{value}}",
"current": "Current",
"selected": "Selected",
"view": "View",
"rollback": "Rollback",
"delete": "Delete",
"deleteConfirmTitle": "Delete this version?",
"deleteConfirmDescription": "Version ID {{id}} (version_no {{version}}) will be permanently deleted. Active versions cannot be deleted."
},
"versionActions": {
"publishCurrent": "Set as current version",
"refreshing": "Refreshing",
"refresh": "Refresh versions",
"newDraft": "New draft",
"saveDraft": "Save draft"
},
"wallet": {
"title": "Wallet transfer limit settings",
"description": "Amounts use the game's minor currency unit (for example, under NPR, 100 = 1.00 NPR). The minimum amount must be at least 1 minor unit.",
"loadFailed": "Failed to load",
"saveSuccess": "Saved successfully",
"saveFailed": "Save failed",
"fields": {
"inMin": "Minimum transfer-in amount",
"inMax": "Maximum transfer-in amount",
"outMin": "Minimum transfer-out amount",
"outMax": "Maximum transfer-out amount"
},
"placeholders": {
"min": "For example: 1.00",
"max": "For example: 10000.00"
},
"hints": {
"inMin": "Per-order minimum from main wallet to lottery wallet",
"inMax": "Per-order maximum from main wallet to lottery wallet",
"outMin": "Per-order minimum from lottery wallet to main wallet",
"outMax": "Per-order maximum from lottery wallet to main wallet"
},
"discard": "Discard changes"
},
"play": {
"batchGroups": {
"d2": "2D Global",
"d3": "3D Global",
"d4": "4D Global",
"big-small": "Big / Small",
"position": "Position Plays",
"box": "Box Plays",
"jackpot": "Jackpot"
}
}
}

View File

@@ -1,3 +1,57 @@
{
"title": "Dashboard"
"title": "Dashboard",
"refresh": "Refresh",
"notice": "Notice",
"todayBetTotal": "Current draw total bet",
"currentDrawFinanceSummary": "Finance summary for the current hall draw",
"currentPayout": "Current payout",
"payoutSummary": "Winning payout + Jackpot",
"currentProfit": "Current platform profit",
"profitFormula": "Bet - payout (approx.)",
"currentDraw": "Current draw",
"drawSequence": "Round {{sequence}}",
"drawDetails": "Draw details",
"ticketCount": "Ticket item count",
"relatedBetAmount": "Related bet amount",
"riskCapUsage": "Risk cap usage",
"lockedAndCap": "Locked {{locked}} / Cap {{cap}}",
"occupancyDetails": "Occupancy details",
"hotNumbersTop10": "Top 10 hot numbers",
"playDimension": "Play dimension",
"soldOutDistribution": "Sold-out distribution",
"soldOutTotal": "Total sold out",
"pendingReviewResults": "Pending result review",
"abnormalTransferOrders": "Abnormal transfer orders",
"viewTransferOrders": "View transfer orders",
"noSoldOutNumbers": "No sold-out numbers",
"noPoolData": "No pool data for this dimension",
"numbersByUsage": "Numbers by usage",
"capUsage": "Cap usage",
"tabs": {
"4d": "4D",
"3d": "3D",
"2d": "2D",
"special": "Special"
},
"soldOutBuckets": {
"d4": "4D",
"d3": "3D",
"d2": "2D",
"special": "Special",
"other": "Other"
},
"quickLinks": {
"createDrawPlan": "Create draw plan",
"drawSchedule": "Open sale / draws",
"results": "Results",
"tickets": "Ticket management",
"walletTransactions": "Wallet transactions",
"reports": "Reports",
"auditLogs": "Audit logs"
},
"warnings": {
"drawPermission": "This account has no draw view/manage permission. Finance and risk data were not returned.",
"walletPermission": "This account has no wallet reconciliation permission. Abnormal transfer count was not returned.",
"loadFailed": "Failed to load. Check the API and login state."
}
}

View File

@@ -0,0 +1,132 @@
{
"title": "Draws",
"statusListTitle": "Draw list",
"generatePlan": "Generate draw plan",
"generating": "Generating…",
"generateSuccess": "Generated {{created}} draws, buffer {{upcoming}}/{{target}}",
"generateFailed": "Generation failed",
"drawNo": "Draw no.",
"status": "Status",
"startTime": "Start time",
"closeTime": "Close time",
"drawTime": "Draw time",
"betTotal": "Total bet",
"payoutTotal": "Total payout",
"profitLoss": "Profit/Loss",
"actions": "Actions",
"queryDraw": "Search draw",
"reset": "Reset",
"fuzzyDrawNo": "Fuzzy draw no.",
"viewDetails": "View details",
"invalidDrawId": "Invalid draw ID",
"loadFailed": "Failed to load. Check login and API configuration.",
"drawDetail": "Draw details",
"businessDate": "Business date",
"sequenceNo": "Sequence no.",
"plannedDraw": "Planned draw",
"coolingEndTime": "Cooling ends at",
"resultSource": "Result source",
"currentResultVersion": "Current result version",
"settleVersion": "Settlement version",
"isReopened": "Reopened",
"yes": "Yes",
"no": "No",
"batchStats": "Batch stats",
"batchTotal": "Total batches",
"pendingReview": "Pending review",
"published": "Published",
"viewFinance": "View draw finance",
"drawActions": "Draw actions",
"drawActionsDesc": "Manual close / cancel / RNG / reopen / settlement all call backend APIs directly.",
"manualClose": "Manual close",
"cancelDraw": "Cancel draw",
"cancelBeforeDraw": "Cancel before draw",
"rngDraw": "RNG draw",
"rngAutoGenerate": "RNG auto generate",
"reopen": "Reopen",
"cooldownReopen": "Reopen in cooldown",
"runSettlement": "Run settlement",
"processing": "Processing…",
"actionSuccess": "{{name}} succeeded",
"actionFailed": "{{name}} failed",
"hallPreviewStatus": "Hall preview {{status}}",
"financeOverview": "Draw finance overview",
"orderAndItemCount": "Orders / Ticket items",
"actualBet": "Actual bet deducted",
"currentPayout": "Current payout total",
"grossProfit": "Approx. gross profit",
"settlementBatchList": "Settlement batch list (filter by draw)",
"relatedSettlementBatches": "Related settlement batches",
"noSettlementBatches": "No settlement batch records.",
"ticketCount": "Tickets",
"winCount": "Wins",
"finishedAt": "Finished at",
"resultsTitle": "Results",
"reviewAndPublish": "Review / publish",
"viewReviewQueue": "View review queue",
"noPublishedBatch": "No published batches.",
"version": "Version v{{version}}",
"sourceType": "Source {{source}}",
"manualEntry": "Manual",
"rng": "RNG",
"rngSummary": "RNG hash {{hash}}",
"confirmedAt": "Confirmed at {{time}}",
"prize": "Prize",
"tail3": "Last 3",
"tail2": "Last 2",
"headTail": "Head/Tail",
"manualResultEntry": "Manual result entry",
"currentStatusAndDraft": "Current status {{status}}. Saving creates a pending batch and does not publish it.",
"enter23Numbers": "Please enter all 23 groups of 4 digits",
"draftSaved": "Draft v{{version}} saved, waiting to be published",
"saveFailed": "Failed to save",
"clear": "Clear",
"saveDraft": "Save draft",
"saving": "Saving…",
"pendingBatches": "Pending batches",
"noPendingBatches": "There are no pending_review batches.",
"batchId": "Batch ID",
"numberCount": "Number count",
"reviewAndPublishAction": "Review and publish",
"noPublishPermission": "No publish permission",
"batchNotFound": "Batch not found",
"batchNotFoundDesc": "Return to the review list and confirm the batch ID.",
"backToReviewQueue": "Back to review queue",
"publishTitle": "Publish",
"cannotPublish": "Cannot publish",
"cannotPublishDesc": "Current batch status is '{{status}}'.",
"checkBeforePublish": "Check the numbers before publishing",
"checkBeforePublishDesc": "Publish only after confirming the numbers.",
"publishedView": "View published result",
"confirmPublish": "Confirm publish",
"submitting": "Submitting…",
"publishSuccess": "Published · {{drawNo}} · status {{status}}",
"publishFailed": "Publish failed",
"sourceTypeFull": "Source: {{source}} · Items: {{count}}/23 · RNG hash: {{hash}}",
"subnav": {
"status": "Draw status",
"results": "Results",
"finance": "Draw finance",
"review": "Review & publish"
},
"statusOptions": {
"all": "All",
"pending": "Pending",
"open": "Open",
"closing": "Closing",
"closed": "Closed",
"drawing": "Drawing",
"review": "Review",
"cooldown": "Cooldown",
"settling": "Settling",
"settled": "Settled",
"cancelled": "Cancelled"
},
"resultSlots": {
"first": "1st prize",
"second": "2nd prize",
"third": "3rd prize",
"starter": "Starter {{index}}",
"consolation": "Consolation {{index}}"
}
}

View File

@@ -0,0 +1,46 @@
{
"title": "Jackpot",
"configTitle": "Jackpot pool configuration",
"loadFailed": "Failed to load",
"saveSuccess": "Saved",
"saveFailed": "Save failed",
"invalidDrawId": "Enter a valid draw ID",
"manualBurstSuccess": "Jackpot burst triggered manually",
"manualBurstFailed": "Manual burst failed",
"noPoolData": "No pool data",
"displayBalance": "Display balance {{amount}}",
"currentAmount": "Current pool balance (minor unit)",
"contributionRate": "Contribution rate 0-1",
"triggerThreshold": "Burst threshold (minor unit)",
"payoutRate": "Burst payout rate 0-1",
"forceTriggerGap": "Force burst gap (settled draws)",
"minBetAmount": "Minimum bet amount (minor unit)",
"comboTriggerPlays": "Combo trigger plays (comma separated)",
"status": "Status",
"disabled": "Disabled",
"enabled": "Enabled",
"saving": "Saving…",
"save": "Save",
"manualBurstDrawId": "Manual burst draw ID",
"manualBurstAmount": "Burst amount (empty for all)",
"processing": "Processing…",
"manualBurst": "Manual burst",
"filter": "Filter",
"drawNo": "Draw no.",
"optional": "Optional",
"apply": "Apply",
"payoutRecords": "Jackpot payout records",
"contributionRecords": "Jackpot contribution records",
"subnavLabel": "Jackpot sub navigation",
"subnavPools": "Pool configuration",
"subnavRecords": "Records",
"payoutLoadFailed": "Failed to load payout records",
"contributionLoadFailed": "Failed to load contribution records",
"trigger": "Trigger",
"payoutAmount": "Payout amount",
"winnerCount": "Winner count",
"time": "Time",
"ticketNo": "Ticket",
"player": "Player",
"contributionAmount": "Contribution amount"
}

View File

@@ -0,0 +1,49 @@
{
"title": "Players",
"listTitle": "Player list",
"createPlayer": "Create player",
"searchPlaceholder": "Search by player ID / username / nickname",
"search": "Search",
"refresh": "Refresh",
"loadFailed": "Failed to load player list",
"siteCodeRequired": "Enter the site code",
"sitePlayerIdRequired": "Enter the site player ID",
"createFailed": "Failed to create player",
"createSuccess": "Created player {{name}}",
"noChanges": "No changes",
"updateFailed": "Failed to update player",
"updateSuccess": "Updated {{name}}",
"deleteFailed": "Delete failed",
"deleteSuccess": "Deleted player {{name}}",
"statusNormal": "Normal",
"statusFrozen": "Frozen",
"statusBanned": "Banned",
"site": "Site",
"sitePlayerId": "Site player ID",
"username": "Username",
"nickname": "Nickname",
"currency": "Currency",
"balance": "Balance",
"available": "Available",
"status": "Status",
"lastLogin": "Last login",
"actions": "Actions",
"edit": "Edit",
"delete": "Delete",
"createDialogTitle": "Create player",
"editDialogTitle": "Edit player",
"createDialogDesc": "Manually register a main-site player to the lottery platform. Usually this is created automatically through SSO login.",
"editDialogDesc": "Edit player information.",
"siteCode": "Site code",
"siteCodePlaceholder": "For example main_site",
"sitePlayerIdLabel": "Site player ID",
"sitePlayerIdPlaceholder": "Unique identifier returned by the main site",
"usernamePlaceholderOptional": "Optional",
"nicknamePlaceholderOptional": "Optional",
"defaultCurrency": "Default currency",
"cancel": "Cancel",
"save": "Save",
"saving": "Saving…",
"confirmDelete": "Confirm delete",
"confirmDeleteDesc": "Delete player {{name}}? This action cannot be undone."
}

View File

@@ -0,0 +1,45 @@
{
"title": "Reconcile",
"createTitle": "Create reconcile job",
"createDesc": "Abnormal flows are checked automatically by scheduled jobs. This section allows finance to trigger jobs manually: choose reconcile type and time range, and optionally fill in target references (player IDs, transfer numbers, or idempotency keys, one per line). Jobs and items are persisted for audit and future automation.",
"reconcileType": "Reconcile type",
"walletTransfer": "Wallet transfer (main site ⇄ lottery)",
"startTime": "Start time",
"endTime": "End time",
"scope": "Scope (optional)",
"scopePlaceholder": "One reference per line, for example player ID, wallet transfer number, or idempotency key.\nLeave empty to create a scoped job record without explicit refs.",
"scopeHint": "When reconciling with wallet transactions in pending_reconcile status, paste the transfer number or idempotency key above.",
"advancedToggleOpen": "Show advanced options (custom items JSON)",
"advancedToggleClose": "Hide advanced options (custom items JSON)",
"advancedJson": "Items JSON (overrides generated rows from the scope above)",
"createTask": "Create reconcile job",
"submitting": "Submitting…",
"loadFailed": "Failed to load",
"loadItemsFailed": "Failed to load details",
"periodRequired": "Enter both reconcile start and end time",
"periodInvalid": "Invalid time range",
"periodOrderInvalid": "End time must be later than or equal to start time",
"advancedJsonInvalid": "The advanced JSON cannot be parsed",
"createSuccess": "Reconcile job created",
"createFailed": "Failed to create job",
"noCreatePermission": "Current account cannot create reconcile jobs.",
"jobsTitle": "Reconcile jobs",
"jobsDesc": "Click a row to view paginated item details.",
"refresh": "Refresh",
"jobNo": "Job no.",
"type": "Type",
"status": "Status",
"period": "Period",
"createdAt": "Created at",
"detailsTitle": "Job details",
"sideARef": "Lottery ref",
"sideBRef": "Main site ref",
"differenceAmount": "Difference (cent)",
"noDetails": "No details",
"statusCompleted": "Completed",
"statusRunning": "Running",
"statusFailed": "Failed",
"itemMismatch": "Mismatch",
"itemMatched": "Matched",
"itemPendingCheck": "Pending check"
}

View File

@@ -1,3 +1,34 @@
{
"title": "Reports"
"title": "Reports",
"createExport": "Create export",
"reportType": "Report type",
"exportFormat": "Export format",
"filterJson": "filter_json (optional)",
"parseFilterFailed": "Failed to parse filter JSON",
"createSuccess": "Export job created",
"createFailed": "Failed to create job",
"downloadFailed": "Download failed",
"taskList": "Job list",
"jobId": "Job no.",
"type": "Type",
"format": "Format",
"status": "Status",
"output": "Output",
"download": "Download",
"createdAt": "Created at",
"id": "ID",
"empty": "No data",
"reportTypes": {
"draw_profit_summary": "Draw profit summary",
"daily_profit_summary": "Daily profit summary",
"player_win_loss": "Player win/loss report",
"wallet_transfer_report": "Wallet transfer report",
"hot_number_risk_report": "Hot number risk report",
"play_dimension_report": "Play dimension report",
"sold_out_number_report": "Sold-out number report",
"rebate_commission_report": "Rebate and commission report",
"audit_operation_report": "Audit operation report",
"wallet_txns_daily": "Wallet transactions daily",
"transfer_orders_daily": "Transfer orders daily"
}
}

View File

@@ -0,0 +1,91 @@
{
"title": "Risk",
"center": "Risk center",
"drawNo": "Draw no.",
"status": "Status",
"closeTime": "Close time",
"actions": "Actions",
"all": "All",
"search": "Search",
"refresh": "Refresh",
"fuzzyDrawNo": "Fuzzy draw no.",
"loadDrawListFailed": "Failed to load draw list",
"enterRisk": "Enter risk",
"poolsTitle": "Risk pools",
"searchNumber": "Search number",
"searchNumberPlaceholder": "For example 8888",
"riskFilter": "Risk filter",
"sort": "Sort",
"filterAll": "All",
"filterSoldOut": "Sold out",
"filterHighRisk": ">80%",
"sortUsageDesc": "Usage ratio ↓",
"sortLockedDesc": "Locked amount ↓",
"sortRemainingAsc": "Remaining ↑",
"sortNumberAsc": "Number ↑",
"loadPoolsFailed": "Failed to load risk pools",
"capAmount": "Cap",
"lockedAmount": "Locked",
"remainingAmount": "Remaining",
"usageRatio": "Usage",
"poolStatus": "Status",
"soldOut": "Sold out",
"warning": "Warning",
"normal": "Normal",
"recover": "Recover",
"close": "Close",
"view": "View",
"manualCloseSuccess": "Number betting closed manually",
"recoverSuccess": "Number betting recovered",
"actionFailed": "Action failed",
"detailTitle": "Risk pool details",
"loadDetailFailed": "Failed to load risk pool details",
"backToList": "Back to list",
"backToAllPools": "Back to all risk pools",
"numberTitle": "Number {{number}}",
"drawMeta": "Draw {{drawNo}}",
"totalCap": "Cap amount",
"lockedWorstCase": "Locked (worst-case payout reserved)",
"remainingSellable": "Remaining sellable",
"isSoldOut": "Sold out",
"yes": "Yes",
"no": "No",
"occupationLogs": "Occupancy / release logs",
"time": "Time",
"action": "Action",
"amount": "Amount",
"source": "Source",
"ticketNo": "Ticket no.",
"playCode": "Play",
"loadLogsFailed": "Failed to load lock logs",
"lockLogsTitle": "Risk lock logs",
"drawInfoLoadFailed": "Failed to load draw info",
"loadingDraw": "Loading draw…",
"headerTitle": "Risk · Draw {{drawNo}}",
"databaseStatus": "Database status",
"hallPreviewStatus": "(Hall preview: {{status}})",
"subnavOccupancy": "Occupancy",
"subnavHot": "Hot numbers",
"subnavSoldOut": "Sold-out list",
"subnavPools": "All risk pools",
"changeDraw": "Change draw",
"number4d": "Number (4 digits)",
"optional": "Optional",
"actionFilter": "Action",
"noLimit": "No limit",
"lock": "Lock",
"release": "Release",
"applyFilter": "Apply filter",
"statusOptions": {
"pending": "Pending",
"open": "Open",
"closing": "Closing",
"closed": "Closed",
"drawing": "Drawing",
"review": "Review",
"cooldown": "Cooldown",
"settling": "Settling",
"settled": "Settled",
"cancelled": "Cancelled"
}
}

View File

@@ -0,0 +1,54 @@
{
"title": "Settlement",
"filter": "Filter",
"drawNo": "Draw no.",
"status": "Status",
"apply": "Apply",
"batchList": "Settlement batches",
"loadFailed": "Failed to load",
"exportFailed": "Export failed",
"actionSuccess": "{{name}} succeeded",
"actionFailed": "{{name}} failed",
"placeholderDrawNo": "For example 20260511-001",
"reviewStatus": "Review status",
"ticketCount": "Ticket count",
"winCount": "Win count",
"payoutTotal": "Total payout",
"jackpot": "Jackpot",
"finishedAt": "Finished at",
"details": "Details",
"approve": "Approve",
"pass": "Pass",
"reject": "Reject",
"payout": "Payout",
"export": "Export",
"backToList": "Back to batch list",
"errorTitle": "Error",
"retry": "Retry",
"batchSummary": "Batch #{{id}}",
"summaryMeta": "Draw {{drawNo}} · draw status {{drawStatus}} · result batch v{{version}}",
"settlementStatus": "Settlement status",
"reviewState": "Review status",
"ticketTotal": "Ticket count",
"winTotal": "Win count",
"payoutAmount": "Payout total",
"jackpotPayout": "Jackpot payout",
"startedAt": "Started",
"endedAt": "Ended",
"runPayout": "Run payout",
"exportSettlementReport": "Export settlement report",
"loadingSummary": "Loading summary…",
"detailTitle": "Settlement details",
"ticketNo": "Ticket no.",
"playCode": "Play",
"player": "Player",
"matchedTier": "Matched tier",
"regularPayout": "Regular payout",
"loadingDetails": "Loading details…",
"statusOptions": {
"all": "All",
"running": "Running",
"completed": "Completed",
"failed": "Failed"
}
}

View File

@@ -0,0 +1,19 @@
{
"title": "Tickets",
"playerTicketQuery": "Player ticket query",
"playerId": "Player ID",
"invalidPlayerId": "Enter a valid player ID",
"drawNoOptional": "Draw no. (optional)",
"drawNoPlaceholder": "For example 20260520-001",
"query": "Query",
"loadFailed": "Failed to load",
"ticketNo": "Ticket no.",
"orderNo": "Order no.",
"drawNo": "Draw no.",
"playCode": "Play",
"number": "Number",
"actualDeduct": "Actual deduct",
"status": "Status",
"failReason": "Fail reason",
"winAmount": "Win amount"
}

View File

@@ -0,0 +1,69 @@
{
"title": "Wallet",
"subnavLabel": "Wallet sub pages",
"subnavTransactions": "Wallet transactions",
"subnavTransferOrders": "Transfer orders",
"noPermission": "Current account has no access to this page",
"copySuccess": "{{label}} copied to clipboard",
"copyFailed": "Copy failed. Check browser permissions or copy manually.",
"statusProcessing": "Processing",
"statusSuccess": "Success",
"statusFailed": "Failed",
"statusPendingReconcile": "Pending reconcile",
"statusReversed": "Reversed",
"statusManuallyProcessed": "Manually processed",
"statusPosted": "Posted",
"filterAll": "All",
"transferIn": "Main site transfer in",
"transferOut": "Main site transfer out",
"transferOutRefund": "Transfer-out refund",
"transferOrders": "Transfer orders",
"walletTransactions": "Wallet transactions",
"playerWalletQuery": "Player wallet query",
"localTransferNo": "Local transfer no.",
"externalRefNo": "Main site ref no.",
"playerAccount": "Player account",
"playerAccountPlaceholder": "Main site player ID or username (fuzzy)",
"playerId": "Player ID",
"playerIdOptional": "Optional, higher priority than account",
"requestDateRange": "Request date range",
"status": "Status",
"options": "Options",
"abnormalOnly": "Abnormal only",
"abnormalOnlyPending": "Abnormal only (pending reconcile)",
"search": "Search",
"resetFilters": "Reset filters",
"refreshCurrentPage": "Refresh current page",
"loadFailed": "Failed to load",
"direction": "Direction",
"amount": "Amount",
"failReason": "Fail reason",
"requestTime": "Requested at",
"finishedTime": "Finished at",
"actions": "Actions",
"reverse": "Reverse",
"manualProcess": "Manual process",
"processing": "Processing…",
"reverseSuccess": "Reversed successfully",
"manualProcessSuccess": "Manually processed successfully",
"actionFailed": "Action failed",
"txnNo": "Txn no.",
"bizType": "Business type",
"type": "Type",
"queryFailed": "Query failed",
"invalidPlayerId": "Enter a valid player ID",
"querying": "Querying…",
"query": "Query",
"sitePlayer": "Site player",
"walletType": "Type",
"currency": "Currency",
"balanceMinor": "Balance (minor unit)",
"availableBalance": "Available (estimated)",
"noWalletRows": "No wallet rows. Players with no bets or transfers may have no records.",
"copyTransferNo": "Local transfer no.",
"copyExternalRefNo": "Main site ref no.",
"copyTxnNo": "Txn no.",
"copyExternalTxnRefNo": "Main site ref no.",
"in": "In",
"out": "Out"
}

View File

@@ -0,0 +1,83 @@
{
"title": "प्रशासक",
"listTitle": "प्रशासक सूची",
"createAdmin": "प्रशासक सिर्जना",
"searchPlaceholder": "प्रयोगकर्ता नाम / उपनाम / इमेलबाट खोज्नुहोस्",
"loadFailed": "प्रशासक सूची लोड असफल भयो",
"nicknameRequired": "उपनाम लेख्नुहोस्",
"newPasswordMin": "नयाँ पासवर्ड कम्तीमा 8 वर्ण हुनुपर्छ",
"roleRequired": "कम्तीमा एउटा भूमिका छान्नुहोस्",
"usernameRequired": "लगइन प्रयोगकर्ता नाम लेख्नुहोस्",
"passwordMin": "पासवर्ड कम्तीमा 8 वर्ण हुनुपर्छ",
"createSuccess": "प्रशासक {{name}} सिर्जना भयो",
"updateSuccess": "{{name}} अपडेट भयो",
"saveAccountFailed": "खाता सुरक्षित गर्न असफल",
"deleteSuccess": "{{name}} मेटाइयो",
"deleteFailed": "मेटाउन असफल",
"allPermissions": "सबै अनुमति",
"saveRoleSuccess": "{{name}} को भूमिका अपडेट भयो",
"saveRoleFailed": "भूमिका सुरक्षित गर्न असफल",
"savePermissionSuccess": "{{name}} को अनुमति अपडेट भयो",
"savePermissionFailed": "अनुमति सुरक्षित गर्न असफल",
"saving": "सेभ हुँदैछ…",
"deleting": "मेटिँदैछ…",
"common": {
"none": "कुनै छैन"
},
"table": {
"account": "खाता",
"nickname": "उपनाम",
"status": "स्थिति",
"roles": "भूमिका",
"direct": "प्रत्यक्ष",
"effective": "प्रभावी",
"actions": "कार्य"
},
"status": {
"enabled": "सक्रिय",
"disabled": "निष्क्रिय"
},
"actions": {
"permissions": "अनुमति",
"edit": "सम्पादन",
"delete": "मेटाउनुहोस्",
"cancel": "रद्द गर्नुहोस्",
"save": "सेभ गर्नुहोस्"
},
"permissionDialog": {
"title": "प्रशासक अनुमति",
"rolesTitle": "भूमिका",
"rolesDescription": "पूर्वनिर्धारित साइट भूमिकाको रूपमा सुरक्षित हुन्छ र प्रत्यक्ष अनुमतिसँग जोडिएर प्रभावी अनुमति बन्छ।",
"rolePermissionCount": "{{count}} वटा कार्य अनुमति समावेश",
"directTitle": "प्रत्यक्ष अनुमति",
"directDescription": "मेनु वा व्यवसाय क्षेत्र अनुसार विस्तार गरी prd.* अनुमति छान्नुहोस्; धेरैजसो अवस्थामा भूमिका बदल्नु पर्याप्त हुन्छ।",
"selectedRoles": "हाल छनोट गरिएका भूमिका:",
"saveRoles": "भूमिका सेभ गर्नुहोस्",
"saveDirect": "प्रत्यक्ष अनुमति सेभ गर्नुहोस्"
},
"accountDialog": {
"createTitle": "प्रशासक सिर्जना",
"editTitle": "खाता सम्पादन",
"createDescription": "कम्तीमा एउटा पूर्वनिर्धारित साइट भूमिका तोक्नुपर्छ। लगइन नाममा अक्षर, अंक, डट, अन्डरस्कोर र हाइफन मात्र प्रयोग गर्न सकिन्छ र सेभ भएपछि साना अक्षरमा राखिन्छ।",
"editDescription": "लगइन नाम परिवर्तन गर्न मिल्दैन। पासवर्ड खाली छोडेमा परिवर्तन हुँदैन।",
"username": "लगइन नाम",
"usernamePlaceholder": "उदाहरण: ops_admin",
"nickname": "उपनाम",
"nicknamePlaceholder": "देखिने नाम",
"emailOptional": "इमेल (वैकल्पिक)",
"emailPlaceholder": "नचाहिए खाली छोड्नुहोस्",
"password": "पासवर्ड",
"passwordOptional": "पासवर्ड (वैकल्पिक)",
"passwordPlaceholderCreate": "कम्तीमा 8 वर्ण",
"passwordPlaceholderEdit": "परिवर्तन नगर्न खाली छोड्नुहोस्",
"rolesRequired": "भूमिका (पूर्वनिर्धारित साइट, कम्तीमा एक)",
"rolesDescription": "सिर्जना भएपछि अनुमतिमा गएर भूमिका वा प्रत्यक्ष अनुमति थप समायोजन गर्न सकिन्छ।",
"noRoles": "अहिले भूमिका डाटा छैन। सूची लोड भएपछि फेरि प्रयास गर्नुहोस्।"
},
"delete": {
"currentUserBlocked": "हाल लगइन गरिएको खाता मेटाउन मिल्दैन",
"rowActionTitle": "यो प्रशासक मेटाउनुहोस्",
"confirmTitle": "मेटाउने पुष्टि",
"confirmDescription": "प्रशासक {{name}} मेटाउने? यो कार्य फिर्ता लिन सकिँदैन।"
}
}

View File

@@ -1,3 +1,14 @@
{
"title": "अडिट लग"
"title": "अडिट लग",
"moduleCode": "मोड्युल कोड",
"actionCode": "कार्य कोड",
"operatorType": "अपरेटर प्रकार",
"exactMatch": "ठ्याक्कै मिलान",
"operatorTypePlaceholder": "जस्तै admin / system",
"operator": "अपरेटर",
"module": "मोड्युल",
"action": "कार्य",
"target": "लक्ष्य",
"time": "समय",
"empty": "डाटा छैन"
}

View File

@@ -1,3 +1,24 @@
{
"title": "लगइन"
"title": "लगइन",
"loginTitle": "एडमिन लगइन",
"account": "खाता",
"accountPlaceholder": "लगइन खाता",
"password": "पासवर्ड",
"passwordPlaceholder": "पासवर्ड",
"captcha": "क्याप्चा",
"captchaPlaceholder": "क्याप्चा लेख्नुहोस्",
"captchaLoading": "क्याप्चा लोड हुँदैछ",
"captchaRefresh": "क्याप्चा रिफ्रेस गर्न क्लिक गर्नुहोस्",
"captchaFetch": "क्याप्चा लिन क्लिक गर्नुहोस्",
"apiMissingTitle": "API ठेगाना सेट गरिएको छैन",
"apiMissingDescriptionPrefix": "परिवेशमा",
"apiMissingDescriptionSuffix": "सेट गर्नुहोस् (Laravel root URL, जस्तै http://127.0.0.1:8000)।",
"submit": "लगइन",
"submitting": "लगइन हुँदैछ…",
"captchaLoadFailed": "क्याप्चा लोड गर्न सकिएन। API वा नेटवर्क जाँच गर्नुहोस्।",
"apiBaseMissingToast": "NEXT_PUBLIC_LOTTERY_API_BASE_URL सेट गरिएको छैन",
"captchaRequired": "पहिले क्याप्चा रिफ्रेस गर्नुहोस्",
"welcome": "स्वागत छ, {{name}}",
"networkFailed": "नेटवर्क अनुरोध असफल भयो",
"loginFailed": "लगइन असफल भयो"
}

View File

@@ -4,7 +4,7 @@
"ne": "नेपाली",
"zh": "中文",
"title": "इन्टरफेस भाषा",
"changed": "भाषा"
"changed": "भाषा {{language}} मा परिवर्तन भयो"
},
"app": {
"title": "Lottery Admin"
@@ -15,6 +15,65 @@
"search": "खोज",
"apply": "लागू गर्नुहोस्",
"loading": "लोड हुँदैछ...",
"submitting": "पेश हुँदैछ..."
"submitting": "पेश हुँदैछ...",
"logout": "लगआउट",
"close": "बन्द गर्नुहोस्",
"viewAll": "सबै हेर्नुहोस्",
"viewDetails": "विवरण हेर्नुहोस्",
"reviewNow": "अहिले समीक्षा गर्नुहोस्",
"create": "सिर्जना गर्नुहोस्",
"createTask": "टास्क सिर्जना गर्नुहोस्",
"clear": "खाली गर्नुहोस्",
"done": "समाप्त"
},
"date": {
"placeholder": "मिति छान्नुहोस्",
"rangePlaceholder": "मिति दायरा छान्नुहोस्",
"rangeHint": "सुरु मिति छान्नुहोस्, त्यसपछि अन्त्य मिति। एउटै दिनका लागि सोही मितिमा दुई पटक क्लिक गर्नुहोस्। बन्द गर्न Done थिच्नुहोस्।"
},
"pagination": {
"perPage": "प्रति पृष्ठ",
"selectPlaceholder": "छान्नुहोस्",
"summary": "कुल {{total}}; पृष्ठ {{page}} / {{lastPage}}",
"previous": "अघिल्लो",
"next": "अर्को"
},
"states": {
"noData": "डाटा छैन",
"loading": "लोड हुँदैछ…",
"comingSoon": "सुविधा विकासमा छ"
},
"errors": {
"loadFailed": "लोड असफल भयो"
},
"toolbar": {
"defaultAdmin": "प्रशासक",
"notifications": "सूचना",
"notificationsComingSoon": "सूचना सुविधा विकासमा छ",
"accountSettings": "खाता सेटिङ",
"loggedOut": "लगआउट भयो"
},
"nav": {
"home": "गृह",
"dashboard": "ड्यासबोर्ड",
"admin_users": "प्रशासक सूची",
"players": "खेलाडी सूची",
"wallet": "वालेट",
"draws": "ड्रअहरू",
"config": "कन्फिगरेसन",
"risk": "जोखिम",
"settlement": "सेटलमेन्ट",
"jackpot": "Jackpot",
"reconcile": "मिलान",
"tickets": "टिकटहरू",
"reports": "रिपोर्टहरू",
"audit": "अडिट लग",
"settings": "सेटिङ"
},
"sidebar": {
"workspace": "कार्यस्थान"
},
"auth": {
"checking": "लगइन स्थिति जाँच हुँदैछ…"
}
}

View File

@@ -0,0 +1,83 @@
{
"title": "कन्फिगरेसन केन्द्र",
"nav": {
"aria": "सञ्चालन कन्फिगरेसन उप-नेभिगेसन",
"sidebarTitle": "सञ्चालन कन्फिगरेसन",
"groups": {
"betting": "बेटिङ र प्रदर्शन",
"risk_wallet": "जोखिम र कोष"
},
"items": {
"plays": "खेल प्रकार र सीमा",
"odds": "अड्स",
"rebate": "कमिसन / रिबेट",
"risk-cap": "पेमेन्ट क्याप",
"wallet": "वालेट थ्रेसहोल्ड"
}
},
"versionStatus": {
"active": "सक्रिय",
"draft": "ड्राफ्ट",
"archived": "अभिलेख"
},
"versionSwitcher": {
"sheetTitle": "कन्फिगरेसन संस्करण बदल्नुहोस्",
"sheetDescription": "यस पृष्ठमा हेर्न एउटा संस्करण छान्नुहोस्। ड्राफ्ट सम्पादनयोग्य छ; सक्रिय र अभिलेख संस्करण केवल पढ्न मिल्ने छन्।",
"loading": "लोड हुँदैछ…",
"noneSelected": "कुनै संस्करण छानिएको छैन",
"switch": "संस्करण बदल्नुहोस्",
"empty": "संस्करण रेकर्ड छैन।",
"count": "{{count}} वटा",
"effectiveAt": "लागू समय: {{value}}",
"note": "टिप्पणी: {{value}}",
"current": "हाल हेर्दै",
"selected": "छानिएको",
"view": "हेर्नुहोस्",
"rollback": "रोलब्याक",
"delete": "मेटाउनुहोस्",
"deleteConfirmTitle": "यो संस्करण मेटाउने?",
"deleteConfirmDescription": "संस्करण ID {{id}} (version_no {{version}}) स्थायी रूपमा मेटाइनेछ। सक्रिय संस्करण मेटाउन मिल्दैन।"
},
"versionActions": {
"publishCurrent": "हालको संस्करण बनाउनुहोस्",
"refreshing": "रिफ्रेस हुँदैछ",
"refresh": "संस्करण रिफ्रेस",
"newDraft": "नयाँ ड्राफ्ट",
"saveDraft": "ड्राफ्ट सेभ गर्नुहोस्"
},
"wallet": {
"title": "वालेट ट्रान्सफर सीमा सेटिङ",
"description": "रकम खेलको सानो मुद्रा एकाइमा हुन्छ (उदाहरण, NPR मा 100 = 1.00 NPR)। न्यूनतम रकम कम्तीमा 1 सानो एकाइ हुनुपर्छ।",
"loadFailed": "लोड असफल भयो",
"saveSuccess": "सफलतापूर्वक सेभ भयो",
"saveFailed": "सेभ असफल भयो",
"fields": {
"inMin": "न्यूनतम ट्रान्सफर-इन रकम",
"inMax": "अधिकतम ट्रान्सफर-इन रकम",
"outMin": "न्यूनतम ट्रान्सफर-आउट रकम",
"outMax": "अधिकतम ट्रान्सफर-आउट रकम"
},
"placeholders": {
"min": "उदाहरण: 1.00",
"max": "उदाहरण: 10000.00"
},
"hints": {
"inMin": "मुख्य वालेटबाट लटरी वालेटमा प्रति अर्डर न्यूनतम",
"inMax": "मुख्य वालेटबाट लटरी वालेटमा प्रति अर्डर अधिकतम",
"outMin": "लटरी वालेटबाट मुख्य वालेटमा प्रति अर्डर न्यूनतम",
"outMax": "लटरी वालेटबाट मुख्य वालेटमा प्रति अर्डर अधिकतम"
},
"discard": "परिवर्तन त्याग्नुहोस्"
},
"play": {
"batchGroups": {
"d2": "2D ग्लोबल",
"d3": "3D ग्लोबल",
"d4": "4D ग्लोबल",
"big-small": "Big / Small",
"position": "स्थिति खेलहरू",
"box": "बक्स खेलहरू",
"jackpot": "Jackpot"
}
}
}

View File

@@ -1,3 +1,57 @@
{
"title": "ड्यासबोर्ड"
"title": "ड्यासबोर्ड",
"refresh": "रिफ्रेस",
"notice": "सूचना",
"todayBetTotal": "हालको ड्रअ कुल बेट",
"currentDrawFinanceSummary": "हालको हल ड्रअको वित्तीय सारांश",
"currentPayout": "हालको भुक्तानी",
"payoutSummary": "जितेको भुक्तानी + Jackpot",
"currentProfit": "हालको प्लेटफर्म नाफा",
"profitFormula": "बेट - भुक्तानी (अनुमानित)",
"currentDraw": "हालको ड्रअ",
"drawSequence": "राउन्ड {{sequence}}",
"drawDetails": "ड्रअ विवरण",
"ticketCount": "टिकट वस्तु संख्या",
"relatedBetAmount": "सम्बन्धित बेट रकम",
"riskCapUsage": "जोखिम क्याप प्रयोग",
"lockedAndCap": "लक {{locked}} / क्याप {{cap}}",
"occupancyDetails": "अकुपेन्सी विवरण",
"hotNumbersTop10": "शीर्ष 10 हट नम्बर",
"playDimension": "प्ले डाइमेन्सन",
"soldOutDistribution": "बिक्री समाप्त वितरण",
"soldOutTotal": "कुल बिक्री समाप्त",
"pendingReviewResults": "समीक्षा बाँकी परिणाम",
"abnormalTransferOrders": "असामान्य ट्रान्सफर अर्डर",
"viewTransferOrders": "ट्रान्सफर अर्डर हेर्नुहोस्",
"noSoldOutNumbers": "बिक्री समाप्त नम्बर छैन",
"noPoolData": "यस डाइमेन्सनमा पूल डाटा छैन",
"numbersByUsage": "प्रयोग अनुसार नम्बर",
"capUsage": "क्याप प्रयोग",
"tabs": {
"4d": "4D",
"3d": "3D",
"2d": "2D",
"special": "विशेष"
},
"soldOutBuckets": {
"d4": "4D",
"d3": "3D",
"d2": "2D",
"special": "विशेष",
"other": "अन्य"
},
"quickLinks": {
"createDrawPlan": "ड्रअ योजना सिर्जना",
"drawSchedule": "खुला बिक्री / ड्रअ",
"results": "परिणाम",
"tickets": "टिकट व्यवस्थापन",
"walletTransactions": "वालेट कारोबार",
"reports": "रिपोर्ट",
"auditLogs": "अडिट लग"
},
"warnings": {
"drawPermission": "यो खातासँग ड्रअ हेर्ने वा व्यवस्थापन अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।",
"walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।",
"loadFailed": "लोड असफल भयो। API र लगइन अवस्था जाँच गर्नुहोस्।"
}
}

View File

@@ -0,0 +1,132 @@
{
"title": "ड्रअ",
"statusListTitle": "ड्रअ सूची",
"generatePlan": "ड्रअ योजना सिर्जना",
"generating": "सिर्जना हुँदैछ…",
"generateSuccess": "{{created}} ड्रअ सिर्जना भयो, बफर {{upcoming}}/{{target}}",
"generateFailed": "सिर्जना असफल भयो",
"drawNo": "ड्रअ नं.",
"status": "स्थिति",
"startTime": "सुरु समय",
"closeTime": "बन्द समय",
"drawTime": "ड्रअ समय",
"betTotal": "कुल बेट",
"payoutTotal": "कुल भुक्तानी",
"profitLoss": "नाफा/नोक्सानी",
"actions": "कार्य",
"queryDraw": "ड्रअ खोज्नुहोस्",
"reset": "रिसेट",
"fuzzyDrawNo": "फजी ड्रअ नं.",
"viewDetails": "विवरण हेर्नुहोस्",
"invalidDrawId": "अवैध ड्रअ ID",
"loadFailed": "लोड असफल भयो। लगइन र API कन्फिग जाँच गर्नुहोस्।",
"drawDetail": "ड्रअ विवरण",
"businessDate": "व्यवसाय मिति",
"sequenceNo": "क्रम संख्या",
"plannedDraw": "योजनाबद्ध ड्रअ",
"coolingEndTime": "कुलिङ समाप्ति",
"resultSource": "नतिजा स्रोत",
"currentResultVersion": "हालको नतिजा संस्करण",
"settleVersion": "सेटलमेन्ट संस्करण",
"isReopened": "फेरि खोलिएको",
"yes": "हो",
"no": "होइन",
"batchStats": "ब्याच तथ्यांक",
"batchTotal": "कुल ब्याच",
"pendingReview": "समीक्षा बाँकी",
"published": "प्रकाशित",
"viewFinance": "ड्रअ वित्त हेर्नुहोस्",
"drawActions": "ड्रअ कार्य",
"drawActionsDesc": "म्यानुअल बन्द / रद्द / RNG / पुनःखोलाइ / सेटलमेन्ट सबै सीधै ब्याकएन्ड API मा जान्छ।",
"manualClose": "म्यानुअल बन्द",
"cancelDraw": "ड्रअ रद्द",
"cancelBeforeDraw": "ड्रअ अघि रद्द",
"rngDraw": "RNG ड्रअ",
"rngAutoGenerate": "RNG स्वचालित सिर्जना",
"reopen": "पुनःखोल्नुहोस्",
"cooldownReopen": "कुलिङमा पुनःखोल्नुहोस्",
"runSettlement": "सेटलमेन्ट चलाउनुहोस्",
"processing": "प्रक्रियामा…",
"actionSuccess": "{{name}} सफल भयो",
"actionFailed": "{{name}} असफल भयो",
"hallPreviewStatus": "हल पूर्वावलोकन {{status}}",
"financeOverview": "ड्रअ वित्तीय सारांश",
"orderAndItemCount": "अर्डर / टिकट आइटम",
"actualBet": "वास्तविक कटौती बेट",
"currentPayout": "हालको कुल भुक्तानी",
"grossProfit": "अनुमानित कुल नाफा",
"settlementBatchList": "सेटलमेन्ट ब्याच सूची (ड्रअ अनुसार)",
"relatedSettlementBatches": "सम्बन्धित सेटलमेन्ट ब्याच",
"noSettlementBatches": "सेटलमेन्ट ब्याच अभिलेख छैन।",
"ticketCount": "टिकट",
"winCount": "जित",
"finishedAt": "समाप्त समय",
"resultsTitle": "परिणाम",
"reviewAndPublish": "समीक्षा / प्रकाशित",
"viewReviewQueue": "समीक्षा सूची हेर्नुहोस्",
"noPublishedBatch": "प्रकाशित ब्याच छैन।",
"version": "संस्करण v{{version}}",
"sourceType": "स्रोत {{source}}",
"manualEntry": "म्यानुअल",
"rng": "RNG",
"rngSummary": "RNG ह्यास {{hash}}",
"confirmedAt": "पुष्टि समय {{time}}",
"prize": "पुरस्कार",
"tail3": "अन्तिम 3",
"tail2": "अन्तिम 2",
"headTail": "हेड/टेल",
"manualResultEntry": "म्यानुअल परिणाम प्रविष्टि",
"currentStatusAndDraft": "हालको स्थिति {{status}} · सेभ गरेपछि pending batch बन्छ, सिधै प्रकाशित हुँदैन",
"enter23Numbers": "कृपया 23 वटा 4-अङ्क समूह पूरा भर्नुहोस्",
"draftSaved": "ड्राफ्ट v{{version}} सुरक्षित भयो, प्रकाशनको प्रतिक्षामा",
"saveFailed": "सेभ असफल भयो",
"clear": "खाली गर्नुहोस्",
"saveDraft": "ड्राफ्ट सुरक्षित गर्नुहोस्",
"saving": "सेभ हुँदैछ…",
"pendingBatches": "बाँकी ब्याच",
"noPendingBatches": "pending_review ब्याच छैन।",
"batchId": "ब्याच ID",
"numberCount": "नम्बर संख्या",
"reviewAndPublishAction": "जाँचेर प्रकाशित गर्नुहोस्",
"noPublishPermission": "प्रकाशन अनुमति छैन",
"batchNotFound": "ब्याच भेटिएन",
"batchNotFoundDesc": "समीक्षा सूचीमा फर्केर batch ID जाँच गर्नुहोस्।",
"backToReviewQueue": "समीक्षा सूचीमा फर्कनुहोस्",
"publishTitle": "प्रकाशित",
"cannotPublish": "प्रकाशित गर्न मिल्दैन",
"cannotPublishDesc": "हालको ब्याच स्थिति '{{status}}' हो।",
"checkBeforePublish": "प्रकाशन अघि नम्बर जाँच गर्नुहोस्",
"checkBeforePublishDesc": "सही भएपछि मात्र प्रकाशित गर्नुहोस्।",
"publishedView": "प्रकाशित नतिजा हेर्नुहोस्",
"confirmPublish": "प्रकाशन पुष्टि गर्नुहोस्",
"submitting": "पेश हुँदैछ…",
"publishSuccess": "प्रकाशित भयो · {{drawNo}} · स्थिति {{status}}",
"publishFailed": "प्रकाशन असफल भयो",
"sourceTypeFull": "स्रोत: {{source}} · संख्या: {{count}}/23 · RNG ह्यास: {{hash}}",
"subnav": {
"status": "ड्रअ स्थिति",
"results": "परिणाम",
"finance": "ड्रअ वित्त",
"review": "समीक्षा र प्रकाशन"
},
"statusOptions": {
"all": "सबै",
"pending": "सुरु नभएको",
"open": "बेट खुला",
"closing": "बन्द हुँदै",
"closed": "बन्द",
"drawing": "ड्रअ हुँदै",
"review": "समीक्षा",
"cooldown": "कुलडाउन",
"settling": "सेटल हुँदै",
"settled": "सेटल भयो",
"cancelled": "रद्द"
},
"resultSlots": {
"first": "पहिलो पुरस्कार",
"second": "दोस्रो पुरस्कार",
"third": "तेस्रो पुरस्कार",
"starter": "विशेष {{index}}",
"consolation": "सान्त्वना {{index}}"
}
}

View File

@@ -0,0 +1,46 @@
{
"title": "Jackpot",
"configTitle": "Jackpot पूल कन्फिगरेसन",
"loadFailed": "लोड असफल भयो",
"saveSuccess": "सुरक्षित भयो",
"saveFailed": "सुरक्षित गर्न असफल",
"invalidDrawId": "मान्य ड्रअ ID लेख्नुहोस्",
"manualBurstSuccess": "Jackpot म्यानुअल रूपमा ट्रिगर भयो",
"manualBurstFailed": "म्यानुअल बर्स्ट असफल भयो",
"noPoolData": "पूल डाटा छैन",
"displayBalance": "प्रदर्शित ब्यालेन्स {{amount}}",
"currentAmount": "हालको पूल ब्यालेन्स (सानो एकाइ)",
"contributionRate": "योगदान अनुपात 0-1",
"triggerThreshold": "बर्स्ट थ्रेसहोल्ड (सानो एकाइ)",
"payoutRate": "बर्स्ट भुक्तानी अनुपात 0-1",
"forceTriggerGap": "बलपूर्वक बर्स्ट अन्तर (सेटल ड्रअ)",
"minBetAmount": "न्यूनतम बेट रकम (सानो एकाइ)",
"comboTriggerPlays": "कम्बो ट्रिगर प्ले (comma-separated)",
"status": "स्थिति",
"disabled": "बन्द",
"enabled": "खुला",
"saving": "सुरक्षित हुँदैछ…",
"save": "सुरक्षित गर्नुहोस्",
"manualBurstDrawId": "म्यानुअल बर्स्ट ड्रअ ID",
"manualBurstAmount": "बर्स्ट रकम (खाली भए सबै)",
"processing": "प्रक्रियामा…",
"manualBurst": "म्यानुअल बर्स्ट",
"filter": "फिल्टर",
"drawNo": "ड्रअ नं.",
"optional": "वैकल्पिक",
"apply": "लागू गर्नुहोस्",
"payoutRecords": "Jackpot भुक्तानी रेकर्ड",
"contributionRecords": "Jackpot योगदान रेकर्ड",
"subnavLabel": "Jackpot उपनेभिगेसन",
"subnavPools": "पूल कन्फिगरेसन",
"subnavRecords": "रेकर्ड",
"payoutLoadFailed": "भुक्तानी रेकर्ड लोड असफल भयो",
"contributionLoadFailed": "योगदान रेकर्ड लोड असफल भयो",
"trigger": "ट्रिगर",
"payoutAmount": "भुक्तानी रकम",
"winnerCount": "विजेता संख्या",
"time": "समय",
"ticketNo": "टिकट",
"player": "खेलाडी",
"contributionAmount": "योगदान रकम"
}

View File

@@ -0,0 +1,49 @@
{
"title": "खेलाडी",
"listTitle": "खेलाडी सूची",
"createPlayer": "खेलाडी सिर्जना",
"searchPlaceholder": "खेलाडी ID / प्रयोगकर्ता नाम / उपनामबाट खोज्नुहोस्",
"search": "खोज",
"refresh": "रिफ्रेस",
"loadFailed": "खेलाडी सूची लोड असफल भयो",
"siteCodeRequired": "साइट कोड लेख्नुहोस्",
"sitePlayerIdRequired": "साइट खेलाडी ID लेख्नुहोस्",
"createFailed": "खेलाडी सिर्जना असफल भयो",
"createSuccess": "खेलाडी {{name}} सिर्जना भयो",
"noChanges": "कुनै परिवर्तन छैन",
"updateFailed": "खेलाडी अपडेट असफल भयो",
"updateSuccess": "{{name}} अपडेट भयो",
"deleteFailed": "मेटाउन असफल",
"deleteSuccess": "खेलाडी {{name}} मेटाइयो",
"statusNormal": "सामान्य",
"statusFrozen": "फ्रिज",
"statusBanned": "प्रतिबन्धित",
"site": "साइट",
"sitePlayerId": "साइट खेलाडी ID",
"username": "प्रयोगकर्ता नाम",
"nickname": "उपनाम",
"currency": "मुद्रा",
"balance": "ब्यालेन्स",
"available": "उपलब्ध",
"status": "स्थिति",
"lastLogin": "अन्तिम लगइन",
"actions": "कार्य",
"edit": "सम्पादन",
"delete": "मेटाउनुहोस्",
"createDialogTitle": "खेलाडी सिर्जना",
"editDialogTitle": "खेलाडी सम्पादन",
"createDialogDesc": "मुख्य साइटको खेलाडीलाई लटरी प्लेटफर्ममा म्यानुअल दर्ता गर्नुहोस्। प्रायः SSO लगइनबाट स्वतः सिर्जना हुन्छ।",
"editDialogDesc": "खेलाडी जानकारी सम्पादन गर्नुहोस्।",
"siteCode": "साइट कोड",
"siteCodePlaceholder": "जस्तै main_site",
"sitePlayerIdLabel": "साइट खेलाडी ID",
"sitePlayerIdPlaceholder": "मुख्य साइटले फिर्ता गरेको अद्वितीय चिन्ह",
"usernamePlaceholderOptional": "वैकल्पिक",
"nicknamePlaceholderOptional": "वैकल्पिक",
"defaultCurrency": "पूर्वनिर्धारित मुद्रा",
"cancel": "रद्द गर्नुहोस्",
"save": "सुरक्षित गर्नुहोस्",
"saving": "सुरक्षित हुँदैछ…",
"confirmDelete": "मेटाउने पुष्टि",
"confirmDeleteDesc": "खेलाडी {{name}} मेटाउने? यो कार्य फिर्ता गर्न मिल्दैन।"
}

View File

@@ -0,0 +1,45 @@
{
"title": "मिलान",
"createTitle": "म्यानुअल मिलान कार्य",
"createDesc": "असामान्य फ्लोहरू scheduled task ले स्वतः जाँच्छ। यहाँ वित्तले म्यानुअल रूपमा मिलान कार्य सुरु गर्न सक्छ: प्रकार र समय सीमा छान्नुहोस्, अनि आवश्यक परे player ID, transfer no, वा idempotency key जस्ता सन्दर्भहरू प्रति लाइन लेख्नुहोस्।",
"reconcileType": "मिलान प्रकार",
"walletTransfer": "वालेट ट्रान्सफर (मुख्य साइट ⇄ लटरी)",
"startTime": "सुरु समय",
"endTime": "अन्त्य समय",
"scope": "दायरा (वैकल्पिक)",
"scopePlaceholder": "प्रति लाइन एउटा सन्दर्भ, जस्तै player ID, wallet transfer no, वा idempotency key.\nखाली छोडेमा केवल कार्य रेकर्ड सिर्जना हुन्छ।",
"scopeHint": "pending_reconcile स्थितिको वालेट कारोबारसँग मिलान गर्दा transfer no वा idempotency key माथि टाँस्नुहोस्।",
"advancedToggleOpen": "उन्नत विकल्प देखाउनुहोस् (custom items JSON)",
"advancedToggleClose": "उन्नत विकल्प लुकाउनुहोस् (custom items JSON)",
"advancedJson": "Items JSON (माथिको दायराबाट बनेका row हरूलाई override गर्छ)",
"createTask": "मिलान कार्य सिर्जना",
"submitting": "पेश हुँदैछ…",
"loadFailed": "लोड असफल भयो",
"loadItemsFailed": "विवरण लोड असफल भयो",
"periodRequired": "सुरु र अन्त्य समय दुवै लेख्नुहोस्",
"periodInvalid": "अवैध समय दायरा",
"periodOrderInvalid": "अन्त्य समय सुरु समयभन्दा पछाडि वा बराबर हुनुपर्छ",
"advancedJsonInvalid": "उन्नत JSON parse गर्न सकिएन",
"createSuccess": "मिलान कार्य सिर्जना भयो",
"createFailed": "कार्य सिर्जना असफल भयो",
"noCreatePermission": "हालको खातासँग मिलान कार्य सिर्जना गर्ने अनुमति छैन।",
"jobsTitle": "मिलान कार्यहरू",
"jobsDesc": "विवरण हेर्न row क्लिक गर्नुहोस्।",
"refresh": "रिफ्रेस",
"jobNo": "कार्य नं.",
"type": "प्रकार",
"status": "स्थिति",
"period": "अवधि",
"createdAt": "सिर्जना समय",
"detailsTitle": "कार्य विवरण",
"sideARef": "लटरी साइड सन्दर्भ",
"sideBRef": "मुख्य साइट सन्दर्भ",
"differenceAmount": "अन्तर (cent)",
"noDetails": "विवरण छैन",
"statusCompleted": "सम्पन्न",
"statusRunning": "चलिरहेको",
"statusFailed": "असफल",
"itemMismatch": "मेल खाएन",
"itemMatched": "मेल खायो",
"itemPendingCheck": "जाँच बाँकी"
}

View File

@@ -1,3 +1,34 @@
{
"title": "रिपोर्ट"
"title": "रिपोर्ट",
"createExport": "निर्यात सिर्जना",
"reportType": "रिपोर्ट प्रकार",
"exportFormat": "निर्यात ढाँचा",
"filterJson": "filter_json (वैकल्पिक)",
"parseFilterFailed": "फिल्टर JSON पार्स गर्न सकिएन",
"createSuccess": "निर्यात कार्य सिर्जना भयो",
"createFailed": "कार्य सिर्जना असफल भयो",
"downloadFailed": "डाउनलोड असफल भयो",
"taskList": "कार्य सूची",
"jobId": "कार्य नं.",
"type": "प्रकार",
"format": "ढाँचा",
"status": "स्थिति",
"output": "आउटपुट",
"download": "डाउनलोड",
"createdAt": "सिर्जना समय",
"id": "ID",
"empty": "डाटा छैन",
"reportTypes": {
"draw_profit_summary": "ड्रअ नाफा सारांश",
"daily_profit_summary": "दैनिक नाफा सारांश",
"player_win_loss": "खेलाडी जित/हार रिपोर्ट",
"wallet_transfer_report": "वालेट ट्रान्सफर रिपोर्ट",
"hot_number_risk_report": "हट नम्बर जोखिम रिपोर्ट",
"play_dimension_report": "प्ले डाइमेन्सन रिपोर्ट",
"sold_out_number_report": "बिक्री समाप्त नम्बर रिपोर्ट",
"rebate_commission_report": "रिबेट र कमिसन रिपोर्ट",
"audit_operation_report": "अडिट अपरेशन रिपोर्ट",
"wallet_txns_daily": "वालेट कारोबार दैनिक",
"transfer_orders_daily": "ट्रान्सफर अर्डर दैनिक"
}
}

View File

@@ -0,0 +1,91 @@
{
"title": "जोखिम",
"center": "जोखिम केन्द्र",
"drawNo": "ड्रअ नं.",
"status": "स्थिति",
"closeTime": "बन्द समय",
"actions": "कार्य",
"all": "सबै",
"search": "खोज",
"refresh": "रिफ्रेस",
"fuzzyDrawNo": "फजी ड्रअ नं.",
"loadDrawListFailed": "ड्रअ सूची लोड असफल भयो",
"enterRisk": "जोखिममा जानुहोस्",
"poolsTitle": "जोखिम पूल",
"searchNumber": "नम्बर खोज्नुहोस्",
"searchNumberPlaceholder": "जस्तै 8888",
"riskFilter": "जोखिम फिल्टर",
"sort": "क्रमबद्ध",
"filterAll": "सबै",
"filterSoldOut": "बिक्री समाप्त",
"filterHighRisk": ">80%",
"sortUsageDesc": "प्रयोग अनुपात ↓",
"sortLockedDesc": "लक रकम ↓",
"sortRemainingAsc": "बाँकी ↑",
"sortNumberAsc": "नम्बर ↑",
"loadPoolsFailed": "जोखिम पूल लोड असफल भयो",
"capAmount": "क्याप",
"lockedAmount": "लक गरिएको",
"remainingAmount": "बाँकी",
"usageRatio": "प्रयोग अनुपात",
"poolStatus": "स्थिति",
"soldOut": "बिक्री समाप्त",
"warning": "चेतावनी",
"normal": "सामान्य",
"recover": "पुनर्स्थापना",
"close": "बन्द",
"view": "हेर्नुहोस्",
"manualCloseSuccess": "नम्बर बेटिङ म्यानुअल रूपमा बन्द गरियो",
"recoverSuccess": "नम्बर बेटिङ पुनर्स्थापित गरियो",
"actionFailed": "कार्य असफल भयो",
"detailTitle": "जोखिम पूल विवरण",
"loadDetailFailed": "जोखिम पूल विवरण लोड असफल भयो",
"backToList": "सूचीमा फर्कनुहोस्",
"backToAllPools": "सबै जोखिम पूलमा फर्कनुहोस्",
"numberTitle": "नम्बर {{number}}",
"drawMeta": "ड्रअ {{drawNo}}",
"totalCap": "क्याप रकम",
"lockedWorstCase": "लक गरिएको (अधिकतम भुक्तानी सुरक्षित)",
"remainingSellable": "बाँकी बिक्रीयोग्य",
"isSoldOut": "बिक्री समाप्त",
"yes": "हो",
"no": "होइन",
"occupationLogs": "यो नम्बरको लक / रिलिज लग",
"time": "समय",
"action": "कार्य",
"amount": "रकम",
"source": "स्रोत",
"ticketNo": "टिकट नं.",
"playCode": "प्ले",
"loadLogsFailed": "लक लग लोड असफल भयो",
"lockLogsTitle": "जोखिम लक लग",
"drawInfoLoadFailed": "ड्रअ जानकारी लोड असफल भयो",
"loadingDraw": "ड्रअ लोड हुँदैछ…",
"headerTitle": "जोखिम · ड्रअ {{drawNo}}",
"databaseStatus": "डेटाबेस स्थिति",
"hallPreviewStatus": "(हल पूर्वावलोकन: {{status}})",
"subnavOccupancy": "अकुपेन्सी",
"subnavHot": "हट नम्बर",
"subnavSoldOut": "बिक्री समाप्त सूची",
"subnavPools": "सबै जोखिम पूल",
"changeDraw": "ड्रअ परिवर्तन गर्नुहोस्",
"number4d": "नम्बर (4 अङ्क)",
"optional": "वैकल्पिक",
"actionFilter": "कार्य",
"noLimit": "सीमा छैन",
"lock": "लक",
"release": "रिलिज",
"applyFilter": "फिल्टर लागू गर्नुहोस्",
"statusOptions": {
"pending": "सुरु नभएको",
"open": "खुला",
"closing": "बन्द हुँदै",
"closed": "बन्द",
"drawing": "ड्रअ हुँदै",
"review": "समीक्षा",
"cooldown": "कुलडाउन",
"settling": "सेटल हुँदै",
"settled": "सेटल भयो",
"cancelled": "रद्द"
}
}

View File

@@ -0,0 +1,54 @@
{
"title": "सेटलमेन्ट",
"filter": "फिल्टर",
"drawNo": "ड्रअ नं.",
"status": "स्थिति",
"apply": "लागू गर्नुहोस्",
"batchList": "सेटलमेन्ट ब्याच",
"loadFailed": "लोड असफल भयो",
"exportFailed": "निर्यात असफल भयो",
"actionSuccess": "{{name}} सफल भयो",
"actionFailed": "{{name}} असफल भयो",
"placeholderDrawNo": "जस्तै 20260511-001",
"reviewStatus": "समीक्षा स्थिति",
"ticketCount": "टिकट संख्या",
"winCount": "जित संख्या",
"payoutTotal": "कुल भुक्तानी",
"jackpot": "Jackpot",
"finishedAt": "समाप्त समय",
"details": "विवरण",
"approve": "स्वीकृत",
"pass": "पास",
"reject": "अस्वीकृत",
"payout": "भुक्तानी",
"export": "निर्यात",
"backToList": "ब्याच सूचीमा फर्कनुहोस्",
"errorTitle": "त्रुटि",
"retry": "पुन: प्रयास",
"batchSummary": "ब्याच #{{id}}",
"summaryMeta": "ड्रअ {{drawNo}} · ड्रअ स्थिति {{drawStatus}} · परिणाम ब्याच v{{version}}",
"settlementStatus": "सेटलमेन्ट स्थिति",
"reviewState": "समीक्षा स्थिति",
"ticketTotal": "टिकट संख्या",
"winTotal": "जित संख्या",
"payoutAmount": "कुल भुक्तानी",
"jackpotPayout": "Jackpot भुक्तानी",
"startedAt": "सुरु",
"endedAt": "समाप्त",
"runPayout": "भुक्तानी चलाउनुहोस्",
"exportSettlementReport": "सेटलमेन्ट रिपोर्ट निर्यात",
"loadingSummary": "सारांश लोड हुँदैछ…",
"detailTitle": "सेटलमेन्ट विवरण",
"ticketNo": "टिकट नं.",
"playCode": "प्ले",
"player": "खेलाडी",
"matchedTier": "मिलेको स्तर",
"regularPayout": "सामान्य भुक्तानी",
"loadingDetails": "विवरण लोड हुँदैछ…",
"statusOptions": {
"all": "सबै",
"running": "चलिरहेको",
"completed": "सम्पन्न",
"failed": "असफल"
}
}

View File

@@ -0,0 +1,19 @@
{
"title": "टिकट",
"playerTicketQuery": "खेलाडी टिकट खोज",
"playerId": "खेलाडी ID",
"invalidPlayerId": "मान्य खेलाडी ID लेख्नुहोस्",
"drawNoOptional": "ड्रअ नं. (वैकल्पिक)",
"drawNoPlaceholder": "जस्तै 20260520-001",
"query": "खोज",
"loadFailed": "लोड असफल भयो",
"ticketNo": "टिकट नं.",
"orderNo": "अर्डर नं.",
"drawNo": "ड्रअ नं.",
"playCode": "प्ले",
"number": "नम्बर",
"actualDeduct": "कटौती",
"status": "स्थिति",
"failReason": "असफल कारण",
"winAmount": "जित रकम"
}

View File

@@ -0,0 +1,69 @@
{
"title": "वालेट",
"subnavLabel": "वालेट उपपृष्ठहरू",
"subnavTransactions": "वालेट कारोबार",
"subnavTransferOrders": "ट्रान्सफर अर्डर",
"noPermission": "हालको खातासँग यो पृष्ठमा पहुँच अनुमति छैन",
"copySuccess": "{{label}} क्लिपबोर्डमा प्रतिलिपि भयो",
"copyFailed": "प्रतिलिपि असफल भयो। ब्राउजर अनुमति जाँच गर्नुहोस् वा म्यानुअल रूपमा कपी गर्नुहोस्।",
"statusProcessing": "प्रक्रियामा",
"statusSuccess": "सफल",
"statusFailed": "असफल",
"statusPendingReconcile": "मिलान बाँकी",
"statusReversed": "रिभर्स भयो",
"statusManuallyProcessed": "म्यानुअल रूपमा प्रक्रिया गरियो",
"statusPosted": "पोस्ट गरियो",
"filterAll": "सबै",
"transferIn": "मुख्य साइटबाट भित्र",
"transferOut": "मुख्य साइटतर्फ बाहिर",
"transferOutRefund": "ट्रान्सफर-आउट फिर्ता",
"transferOrders": "ट्रान्सफर अर्डर",
"walletTransactions": "वालेट कारोबार",
"playerWalletQuery": "खेलाडी वालेट खोज",
"localTransferNo": "स्थानीय ट्रान्सफर नं.",
"externalRefNo": "मुख्य साइट सन्दर्भ नं.",
"playerAccount": "खेलाडी खाता",
"playerAccountPlaceholder": "मुख्य साइट खेलाडी ID वा प्रयोगकर्ता नाम (fuzzy)",
"playerId": "खेलाडी ID",
"playerIdOptional": "वैकल्पिक, खाताभन्दा प्राथमिक",
"requestDateRange": "अनुरोध मिति दायरा",
"status": "स्थिति",
"options": "विकल्प",
"abnormalOnly": "असामान्य मात्र",
"abnormalOnlyPending": "असामान्य मात्र (मिलान बाँकी)",
"search": "खोज",
"resetFilters": "फिल्टर रिसेट",
"refreshCurrentPage": "हालको पृष्ठ रिफ्रेस",
"loadFailed": "लोड असफल भयो",
"direction": "दिशा",
"amount": "रकम",
"failReason": "असफल कारण",
"requestTime": "अनुरोध समय",
"finishedTime": "समाप्त समय",
"actions": "कार्य",
"reverse": "रिभर्स",
"manualProcess": "म्यानुअल प्रक्रिया",
"processing": "प्रक्रियामा…",
"reverseSuccess": "रिभर्स सफल भयो",
"manualProcessSuccess": "म्यानुअल प्रक्रिया सफल भयो",
"actionFailed": "कार्य असफल भयो",
"txnNo": "कारोबार नं.",
"bizType": "व्यवसाय प्रकार",
"type": "प्रकार",
"queryFailed": "खोज असफल भयो",
"invalidPlayerId": "मान्य खेलाडी ID लेख्नुहोस्",
"querying": "खोजिँदैछ…",
"query": "खोज",
"sitePlayer": "साइट खेलाडी",
"walletType": "प्रकार",
"currency": "मुद्रा",
"balanceMinor": "ब्यालेन्स (सानो एकाइ)",
"availableBalance": "उपलब्ध (अनुमानित)",
"noWalletRows": "वालेट रेकर्ड छैन। बेट वा ट्रान्सफर नगरेका खेलाडीमा रेकर्ड नहुन सक्छ।",
"copyTransferNo": "स्थानीय ट्रान्सफर नं.",
"copyExternalRefNo": "मुख्य साइट सन्दर्भ नं.",
"copyTxnNo": "कारोबार नं.",
"copyExternalTxnRefNo": "मुख्य साइट सन्दर्भ नं.",
"in": "भित्र",
"out": "बाहिर"
}

View File

@@ -0,0 +1,83 @@
{
"title": "管理员",
"listTitle": "管理员用户列表",
"createAdmin": "新建管理员",
"searchPlaceholder": "按用户名 / 昵称 / 邮箱搜索",
"loadFailed": "加载管理员列表失败",
"nicknameRequired": "请填写昵称",
"newPasswordMin": "新密码至少 8 位",
"roleRequired": "请至少选择一个角色",
"usernameRequired": "请填写登录账号",
"passwordMin": "密码至少 8 位",
"createSuccess": "已创建管理员 {{name}}",
"updateSuccess": "已更新 {{name}}",
"saveAccountFailed": "保存账号失败",
"deleteSuccess": "已删除 {{name}}",
"deleteFailed": "删除失败",
"allPermissions": "全部权限",
"saveRoleSuccess": "已更新 {{name}} 的角色",
"saveRoleFailed": "保存角色失败",
"savePermissionSuccess": "已更新 {{name}} 的权限",
"savePermissionFailed": "保存权限失败",
"saving": "保存中…",
"deleting": "删除中…",
"common": {
"none": "无"
},
"table": {
"account": "账号",
"nickname": "昵称",
"status": "状态",
"roles": "角色",
"direct": "直接权限",
"effective": "生效权限",
"actions": "操作"
},
"status": {
"enabled": "启用",
"disabled": "禁用"
},
"actions": {
"permissions": "权限",
"edit": "编辑",
"delete": "删除",
"cancel": "取消",
"save": "保存"
},
"permissionDialog": {
"title": "管理员权限",
"rolesTitle": "角色",
"rolesDescription": "保存至默认站点,与「直接权限」叠加为有效权限。",
"rolePermissionCount": "含 {{count}} 项功能权限",
"directTitle": "直接权限",
"directDescription": "按菜单或业务域展开,勾选具体的 prd.*;多数情况只调角色即可。",
"selectedRoles": "当前勾选的角色:",
"saveRoles": "保存角色",
"saveDirect": "保存直接权限"
},
"accountDialog": {
"createTitle": "新建管理员",
"editTitle": "编辑账号",
"createDescription": "须为账号指定至少一个默认站点角色。登录账号仅可使用字母、数字、点、下划线与连字符,保存后为小写。",
"editDescription": "登录账号不可修改。留空密码表示不修改。",
"username": "登录账号",
"usernamePlaceholder": "例如 ops_admin",
"nickname": "昵称",
"nicknamePlaceholder": "显示名称",
"emailOptional": "邮箱(可选)",
"emailPlaceholder": "留空则不填",
"password": "密码",
"passwordOptional": "密码(可选)",
"passwordPlaceholderCreate": "至少 8 位",
"passwordPlaceholderEdit": "不修改请留空",
"rolesRequired": "角色(默认站点,至少一项)",
"rolesDescription": "创建后即可在「权限」中继续调整角色或直接授权。",
"noRoles": "暂无角色数据,请等待列表加载完成后重试。"
},
"delete": {
"currentUserBlocked": "不能删除当前登录账号",
"rowActionTitle": "删除该管理员",
"confirmTitle": "确认删除",
"confirmDescription": "确定删除管理员 {{name}}?此操作不可撤销。"
}
}

View File

@@ -1,3 +1,14 @@
{
"title": "审计日志"
"title": "审计日志",
"moduleCode": "模块编码",
"actionCode": "动作编码",
"operatorType": "操作者类型",
"exactMatch": "精确匹配",
"operatorTypePlaceholder": "如 admin / system",
"operator": "操作者",
"module": "模块",
"action": "动作",
"target": "目标",
"time": "时间",
"empty": "无数据"
}

View File

@@ -1,3 +1,24 @@
{
"title": "登录"
"title": "登录",
"loginTitle": "后台登录",
"account": "账号",
"accountPlaceholder": "登录账号",
"password": "密码",
"passwordPlaceholder": "密码",
"captcha": "验证码",
"captchaPlaceholder": "图中字符",
"captchaLoading": "加载验证码中",
"captchaRefresh": "点击刷新验证码",
"captchaFetch": "点击获取",
"apiMissingTitle": "未配置 API 地址",
"apiMissingDescriptionPrefix": "请在环境中设置",
"apiMissingDescriptionSuffix": "Laravel 根 URL如 http://127.0.0.1:8000。",
"submit": "登录",
"submitting": "登录中…",
"captchaLoadFailed": "无法获取验证码,请检查接口或网络",
"apiBaseMissingToast": "未配置 NEXT_PUBLIC_LOTTERY_API_BASE_URL",
"captchaRequired": "请先刷新验证码",
"welcome": "欢迎,{{name}}",
"networkFailed": "网络请求失败",
"loginFailed": "登录失败"
}

View File

@@ -4,7 +4,7 @@
"ne": "नेपाली",
"zh": "中文",
"title": "界面语言",
"changed": "语言"
"changed": "语言已切换为 {{language}}"
},
"app": {
"title": "彩票后台"
@@ -15,6 +15,65 @@
"search": "搜索",
"apply": "应用",
"loading": "加载中...",
"submitting": "提交中..."
"submitting": "提交中...",
"logout": "退出登录",
"close": "关闭",
"viewAll": "查看全部",
"viewDetails": "查看详情",
"reviewNow": "立即审核",
"create": "创建",
"createTask": "创建任务",
"clear": "清除",
"done": "完成"
},
"date": {
"placeholder": "选择日期",
"rangePlaceholder": "选择日期范围",
"rangeHint": "先选开始日,再选结束日(单日可对同一天点两次);点「完成」关闭面板。"
},
"pagination": {
"perPage": "每页条数",
"selectPlaceholder": "请选择",
"summary": "共 {{total}} 条;第 {{page}} / {{lastPage}} 页",
"previous": "上一页",
"next": "下一页"
},
"states": {
"noData": "暂无数据",
"loading": "加载中…",
"comingSoon": "功能开发中"
},
"errors": {
"loadFailed": "加载失败"
},
"toolbar": {
"defaultAdmin": "管理员",
"notifications": "通知",
"notificationsComingSoon": "通知功能开发中",
"accountSettings": "账号设置",
"loggedOut": "已退出登录"
},
"nav": {
"home": "首页",
"dashboard": "仪表盘",
"admin_users": "管理列表",
"players": "玩家列表",
"wallet": "钱包流水",
"draws": "期号列表",
"config": "运营配置",
"risk": "风控",
"settlement": "结算",
"jackpot": "Jackpot",
"reconcile": "对账",
"tickets": "玩家注单",
"reports": "报表导出",
"audit": "审计日志",
"settings": "系统设置"
},
"sidebar": {
"workspace": "工作台"
},
"auth": {
"checking": "正在校验登录状态…"
}
}

View File

@@ -0,0 +1,83 @@
{
"title": "配置中心",
"nav": {
"aria": "运营配置子导航",
"sidebarTitle": "运营配置导航",
"groups": {
"betting": "投注与展示",
"risk_wallet": "风控与资金"
},
"items": {
"plays": "玩法与限额",
"odds": "赔率",
"rebate": "佣金 / 回水",
"risk-cap": "赔付封顶",
"wallet": "钱包阈值"
}
},
"versionStatus": {
"active": "生效中",
"draft": "草稿",
"archived": "已归档"
},
"versionSwitcher": {
"sheetTitle": "切换配置版本",
"sheetDescription": "选择一条版本在本页查看;草稿可编辑,生效中与已归档为只读。",
"loading": "加载中…",
"noneSelected": "未选择版本",
"switch": "切换版本",
"empty": "暂无版本记录。",
"count": "{{count}} 条",
"effectiveAt": "生效时间:{{value}}",
"note": "备注:{{value}}",
"current": "当前查看",
"selected": "已选中",
"view": "查看",
"rollback": "回滚",
"delete": "删除",
"deleteConfirmTitle": "确认删除版本?",
"deleteConfirmDescription": "将永久删除版本 ID {{id}}version_no {{version}})。生效中的版本不可删除。"
},
"versionActions": {
"publishCurrent": "启用为当前版本",
"refreshing": "刷新中",
"refresh": "刷新版本",
"newDraft": "新建草稿",
"saveDraft": "保存草稿"
},
"wallet": {
"title": "钱包转账限额配置",
"description": "金额单位为游戏币种最小单位(如 NPR 下 100 = 1.00 NPR。最小金额至少为 1 最小单位。",
"loadFailed": "加载失败",
"saveSuccess": "保存成功",
"saveFailed": "保存失败",
"fields": {
"inMin": "转入最小金额",
"inMax": "转入最大金额",
"outMin": "转出最小金额",
"outMax": "转出最大金额"
},
"placeholders": {
"min": "例如 1.00",
"max": "例如 10000.00"
},
"hints": {
"inMin": "主站钱包转入彩票钱包的单笔下限",
"inMax": "主站钱包转入彩票钱包的单笔上限",
"outMin": "彩票钱包转出主站钱包的单笔下限",
"outMax": "彩票钱包转出主站钱包的单笔上限"
},
"discard": "放弃更改"
},
"play": {
"batchGroups": {
"d2": "2D 全局",
"d3": "3D 全局",
"d4": "4D 全局",
"big-small": "Big / Small",
"position": "位置类玩法",
"box": "包号类玩法",
"jackpot": "Jackpot"
}
}
}

View File

@@ -1,3 +1,57 @@
{
"title": "仪表盘"
"title": "仪表盘",
"refresh": "刷新",
"notice": "提示",
"todayBetTotal": "当期投注总额",
"currentDrawFinanceSummary": "当前大厅期财务汇总",
"currentPayout": "当期派彩",
"payoutSummary": "中奖派彩 + Jackpot",
"currentProfit": "当期平台盈亏",
"profitFormula": "投注 派彩(近似)",
"currentDraw": "当前期号",
"drawSequence": "第 {{sequence}} 期",
"drawDetails": "期号详情",
"ticketCount": "本期注单笔数",
"relatedBetAmount": "关联投注额",
"riskCapUsage": "风险封顶占用",
"lockedAndCap": "已占用 {{locked}} / 封顶 {{cap}}",
"occupancyDetails": "占用明细",
"hotNumbersTop10": "热门号码 Top 10",
"playDimension": "玩法维度",
"soldOutDistribution": "售罄分布",
"soldOutTotal": "售罄合计",
"pendingReviewResults": "待审核开奖",
"abnormalTransferOrders": "异常转账单",
"viewTransferOrders": "查看转账单",
"noSoldOutNumbers": "暂无售罄号码",
"noPoolData": "该维度暂无池数据",
"numbersByUsage": "号码(按占用率)",
"capUsage": "封顶占用",
"tabs": {
"4d": "4D",
"3d": "3D",
"2d": "2D",
"special": "特别"
},
"soldOutBuckets": {
"d4": "4D",
"d3": "3D",
"d2": "2D",
"special": "特别号",
"other": "其他"
},
"quickLinks": {
"createDrawPlan": "创建期计划",
"drawSchedule": "开售 / 期号",
"results": "开奖结果",
"tickets": "注单管理",
"walletTransactions": "钱包流水",
"reports": "报表中心",
"auditLogs": "审计日志"
},
"warnings": {
"drawPermission": "当前账号无开奖查看/管理权限,财务与风控数据未返回。",
"walletPermission": "当前账号无钱包对账查看权限,异常转账计数未返回。",
"loadFailed": "加载失败,请检查 API 与登录状态。"
}
}

View File

@@ -0,0 +1,132 @@
{
"title": "期号",
"statusListTitle": "期号列表",
"generatePlan": "批量生成期开奖计划",
"generating": "生成中…",
"generateSuccess": "已生成 {{created}} 期,当前缓冲 {{upcoming}}/{{target}}",
"generateFailed": "生成失败",
"drawNo": "期号",
"status": "状态",
"startTime": "开始时间",
"closeTime": "封盘时间",
"drawTime": "开奖时间",
"betTotal": "下注总额",
"payoutTotal": "派彩总额",
"profitLoss": "盈亏",
"actions": "操作",
"queryDraw": "查询期号",
"reset": "重置",
"fuzzyDrawNo": "模糊匹配期号",
"viewDetails": "查看详情",
"invalidDrawId": "无效的期号 ID",
"loadFailed": "加载失败,请检查登录与 API 配置",
"drawDetail": "开奖详情",
"businessDate": "业务日",
"sequenceNo": "流水序号",
"plannedDraw": "计划开奖",
"coolingEndTime": "冷静期结束",
"resultSource": "结果来源",
"currentResultVersion": "当前结果版本",
"settleVersion": "结算版本",
"isReopened": "是否重开",
"yes": "是",
"no": "否",
"batchStats": "批次统计",
"batchTotal": "总批次",
"pendingReview": "待审核",
"published": "已发布",
"viewFinance": "查看期号收支",
"drawActions": "期号操作",
"drawActionsDesc": "手动封盘 / 取消 / RNG / 重开 / 触发结算均直接调用后台接口。",
"manualClose": "手动封盘",
"cancelDraw": "取消期号",
"cancelBeforeDraw": "未开奖前取消",
"rngDraw": "RNG开奖",
"rngAutoGenerate": "RNG 自动生成",
"reopen": "重开",
"cooldownReopen": "冷静期重开",
"runSettlement": "触发结算",
"processing": "处理中…",
"actionSuccess": "{{name}}成功",
"actionFailed": "{{name}}失败",
"hallPreviewStatus": "大厅预览 {{status}}",
"financeOverview": "期号收支概览",
"orderAndItemCount": "订单数 / 注项数",
"actualBet": "当期实扣投注",
"currentPayout": "当期派彩合计",
"grossProfit": "近似毛损益",
"settlementBatchList": "结算批次列表(按期号筛选)",
"relatedSettlementBatches": "本关联期结算批次",
"noSettlementBatches": "暂无结算批次记录。",
"ticketCount": "票数",
"winCount": "中奖数",
"finishedAt": "完成时间",
"resultsTitle": "开奖结果",
"reviewAndPublish": "去审核 / 发布",
"viewReviewQueue": "查看审核队列",
"noPublishedBatch": "暂无已发布批次。",
"version": "版本 v{{version}}",
"sourceType": "生成方式 {{source}}",
"manualEntry": "人工录入",
"rng": "RNG",
"rngSummary": "RNG 摘要 {{hash}}",
"confirmedAt": "确认时间 {{time}}",
"prize": "奖项",
"tail3": "尾3",
"tail2": "尾2",
"headTail": "头/尾",
"manualResultEntry": "人工录入开奖结果",
"currentStatusAndDraft": "当前状态 {{status}} · 保存后生成待确认批次,不会直接发布",
"enter23Numbers": "请完整输入 23 组 4 位数字",
"draftSaved": "已保存草稿 v{{version}},等待确认发布",
"saveFailed": "保存失败",
"clear": "清空",
"saveDraft": "保存草稿",
"saving": "保存中…",
"pendingBatches": "待确认批次",
"noPendingBatches": "当前没有待审核pending_review批次。",
"batchId": "批次 ID",
"numberCount": "号码条数",
"reviewAndPublishAction": "核对并发布",
"noPublishPermission": "无发布权限",
"batchNotFound": "未找到批次",
"batchNotFoundDesc": "请返回审核列表确认 batch id。",
"backToReviewQueue": "返回审核队列",
"publishTitle": "发布",
"cannotPublish": "不可发布",
"cannotPublishDesc": "当前批次状态为「{{status}}」。",
"checkBeforePublish": "请核对以下号码后再发布",
"checkBeforePublishDesc": "确认无误后点击发布。",
"publishedView": "查看已发布展示",
"confirmPublish": "确认发布",
"submitting": "提交中…",
"publishSuccess": "已发布 · {{drawNo}} · 状态 {{status}}",
"publishFailed": "发布失败",
"sourceTypeFull": "生成方式:{{source}} · 号码条数:{{count}}/23 · RNG 摘要:{{hash}}",
"subnav": {
"status": "期号状态",
"results": "开奖结果",
"finance": "期号收支",
"review": "审核与发布"
},
"statusOptions": {
"all": "不限",
"pending": "未开始",
"open": "可下注",
"closing": "封盘中",
"closed": "已封盘待开奖",
"drawing": "开奖处理中",
"review": "待人工审核",
"cooldown": "冷静期",
"settling": "结算处理中",
"settled": "已结算",
"cancelled": "已取消"
},
"resultSlots": {
"first": "头奖",
"second": "二奖",
"third": "三奖",
"starter": "特别奖 {{index}}",
"consolation": "安慰奖 {{index}}"
}
}

View File

@@ -0,0 +1,46 @@
{
"title": "奖池",
"configTitle": "Jackpot 奖池配置",
"loadFailed": "加载失败",
"saveSuccess": "已保存",
"saveFailed": "保存失败",
"invalidDrawId": "请填写有效的期号 ID",
"manualBurstSuccess": "已手动触发爆池",
"manualBurstFailed": "手动爆池失败",
"noPoolData": "暂无奖池数据",
"displayBalance": "展示余额 {{amount}}",
"currentAmount": "当前池余额(最小单位)",
"contributionRate": "蓄水比例 01",
"triggerThreshold": "爆池阈值(最小单位)",
"payoutRate": "爆池派彩比例 01",
"forceTriggerGap": "强制爆池间隔(已结算期数)",
"minBetAmount": "最低下注额(最小单位)",
"comboTriggerPlays": "组合触发玩法(逗号分隔)",
"status": "开关",
"disabled": "关闭",
"enabled": "开启",
"saving": "保存中…",
"save": "保存",
"manualBurstDrawId": "手动爆池期号 ID",
"manualBurstAmount": "爆池金额(空为全部)",
"processing": "处理中…",
"manualBurst": "手动爆池",
"filter": "筛选",
"drawNo": "期号",
"optional": "可选",
"apply": "应用",
"payoutRecords": "Jackpot 派彩记录",
"contributionRecords": "Jackpot 蓄水记录",
"subnavLabel": "Jackpot 子导航",
"subnavPools": "奖池配置",
"subnavRecords": "记录",
"payoutLoadFailed": "派彩记录加载失败",
"contributionLoadFailed": "蓄水记录加载失败",
"trigger": "触发",
"payoutAmount": "派彩额",
"winnerCount": "中奖人数",
"time": "时间",
"ticketNo": "注单",
"player": "玩家",
"contributionAmount": "蓄水额"
}

View File

@@ -0,0 +1,49 @@
{
"title": "玩家",
"listTitle": "玩家列表",
"createPlayer": "新建玩家",
"searchPlaceholder": "按玩家 ID / 用户名 / 昵称搜索",
"search": "搜索",
"refresh": "刷新",
"loadFailed": "加载玩家列表失败",
"siteCodeRequired": "请填写主站编号",
"sitePlayerIdRequired": "请填写主站玩家 ID",
"createFailed": "创建玩家失败",
"createSuccess": "已创建玩家 {{name}}",
"noChanges": "没有变更",
"updateFailed": "更新玩家失败",
"updateSuccess": "已更新 {{name}}",
"deleteFailed": "删除失败",
"deleteSuccess": "已删除玩家 {{name}}",
"statusNormal": "正常",
"statusFrozen": "冻结",
"statusBanned": "封禁",
"site": "主站",
"sitePlayerId": "主站玩家ID",
"username": "用户名",
"nickname": "昵称",
"currency": "币种",
"balance": "余额",
"available": "可用",
"status": "状态",
"lastLogin": "最后登录",
"actions": "操作",
"edit": "编辑",
"delete": "删除",
"createDialogTitle": "新建玩家",
"editDialogTitle": "编辑玩家",
"createDialogDesc": "手动注册一个主站玩家到彩票平台,通常由 SSO 登录自动创建。",
"editDialogDesc": "编辑玩家信息。",
"siteCode": "主站编号",
"siteCodePlaceholder": "例如 main_site",
"sitePlayerIdLabel": "主站玩家 ID",
"sitePlayerIdPlaceholder": "主站返回的唯一标识",
"usernamePlaceholderOptional": "选填",
"nicknamePlaceholderOptional": "选填",
"defaultCurrency": "默认币种",
"cancel": "取消",
"save": "保存",
"saving": "保存中…",
"confirmDelete": "确认删除",
"confirmDeleteDesc": "确定要删除玩家 {{name}} 吗?此操作不可恢复。"
}

View File

@@ -0,0 +1,45 @@
{
"title": "对账",
"createTitle": "人工发起对账",
"createDesc": "异常流水由定时任务自动核对。此处供财务按产品文档手动触发:选择对账类型与时间范围;可选填写待核对对象(玩家标识、划转单号或幂等键,每行一条)。任务与明细落库留痕,后续可接自动差异引擎。",
"reconcileType": "对账类型",
"walletTransfer": "钱包划转(主站 ⇄ 彩票)",
"startTime": "对账开始时间",
"endTime": "对账结束时间",
"scope": "限定范围(可选)",
"scopePlaceholder": "每行一条待核对引用,例如:玩家 ID、钱包划转单号、幂等键等。\n留空表示本时间段内不额外指定单据仅任务留痕。",
"scopeHint": "与「钱包流水」中待对账pending_reconcile流水对照使用时可将单号或幂等键粘贴至上方。",
"advancedToggleOpen": "展开高级选项(自定义明细 JSON",
"advancedToggleClose": "收起高级选项(自定义明细 JSON",
"advancedJson": "明细 JSON将覆盖上方「限定范围」生成的行",
"createTask": "创建对账任务",
"submitting": "提交中…",
"loadFailed": "加载失败",
"loadItemsFailed": "加载明细失败",
"periodRequired": "请填写对账时间范围(开始与结束)",
"periodInvalid": "时间无效,请检查所选日期与时间",
"periodOrderInvalid": "结束时间需晚于或等于开始时间",
"advancedJsonInvalid": "高级选项中的 JSON 无法解析",
"createSuccess": "已创建对账任务",
"createFailed": "创建失败",
"noCreatePermission": "当前账号无新建对账任务权限。",
"jobsTitle": "对账任务",
"jobsDesc": "点击一行查看差异明细与分页。",
"refresh": "刷新",
"jobNo": "任务号",
"type": "类型",
"status": "状态",
"period": "对账周期",
"createdAt": "创建时间",
"detailsTitle": "任务明细",
"sideARef": "彩票侧引用",
"sideBRef": "主站侧引用",
"differenceAmount": "差额(分)",
"noDetails": "无明细",
"statusCompleted": "已完成",
"statusRunning": "执行中",
"statusFailed": "失败",
"itemMismatch": "不一致",
"itemMatched": "一致",
"itemPendingCheck": "待核对"
}

View File

@@ -1,3 +1,34 @@
{
"title": "报表"
"title": "报表",
"createExport": "新建导出",
"reportType": "报表类型",
"exportFormat": "导出格式",
"filterJson": "filter_json可选",
"parseFilterFailed": "筛选 JSON 无法解析",
"createSuccess": "已创建导出任务",
"createFailed": "创建失败",
"downloadFailed": "下载失败",
"taskList": "任务列表",
"jobId": "任务号",
"type": "类型",
"format": "格式",
"status": "状态",
"output": "输出",
"download": "下载",
"createdAt": "创建时间",
"id": "ID",
"empty": "无数据",
"reportTypes": {
"draw_profit_summary": "期号盈亏",
"daily_profit_summary": "每日盈亏汇总",
"player_win_loss": "玩家输赢报表",
"wallet_transfer_report": "玩家转入转出报表",
"hot_number_risk_report": "热门号码风险报表",
"play_dimension_report": "玩法维度报表",
"sold_out_number_report": "售罄号码报表",
"rebate_commission_report": "佣金回水报表",
"audit_operation_report": "后台操作审计报表",
"wallet_txns_daily": "钱包流水日报",
"transfer_orders_daily": "转账单日报"
}
}

View File

@@ -0,0 +1,91 @@
{
"title": "风控",
"center": "风控中心",
"drawNo": "期号",
"status": "状态",
"closeTime": "封盘时间",
"actions": "操作",
"all": "全部",
"search": "搜索",
"refresh": "刷新",
"fuzzyDrawNo": "模糊匹配期号",
"loadDrawListFailed": "加载期号列表失败",
"enterRisk": "进入风控",
"poolsTitle": "风险池",
"searchNumber": "搜索号码",
"searchNumberPlaceholder": "如 8888",
"riskFilter": "风险筛选",
"sort": "排序",
"filterAll": "全部",
"filterSoldOut": "售罄",
"filterHighRisk": ">80%",
"sortUsageDesc": "占用比 ↓(热门)",
"sortLockedDesc": "已占用额 ↓",
"sortRemainingAsc": "剩余额 ↑(紧俏)",
"sortNumberAsc": "号码 ↑",
"loadPoolsFailed": "加载风险池失败",
"capAmount": "封顶",
"lockedAmount": "已占用",
"remainingAmount": "剩余",
"usageRatio": "占用比",
"poolStatus": "状态",
"soldOut": "售罄",
"warning": "预警",
"normal": "正常",
"recover": "恢复",
"close": "关闭",
"view": "查看",
"manualCloseSuccess": "已手动关闭号码下注",
"recoverSuccess": "已恢复号码下注",
"actionFailed": "操作失败",
"detailTitle": "风险池详情",
"loadDetailFailed": "加载风险池详情失败",
"backToList": "返回列表",
"backToAllPools": "返回全部风险池",
"numberTitle": "号码 {{number}}",
"drawMeta": "期号 {{drawNo}}",
"totalCap": "封顶额",
"lockedWorstCase": "已占用(最坏赔付预留)",
"remainingSellable": "剩余可售",
"isSoldOut": "售罄",
"yes": "是",
"no": "否",
"occupationLogs": "本号码占用 / 释放流水",
"time": "时间",
"action": "动作",
"amount": "金额",
"source": "来源",
"ticketNo": "注单号",
"playCode": "玩法",
"loadLogsFailed": "加载占用流水失败",
"lockLogsTitle": "风险占用流水",
"drawInfoLoadFailed": "无法加载期号信息",
"loadingDraw": "加载期号…",
"headerTitle": "风控 · 第 {{drawNo}} 期",
"databaseStatus": "数据库状态",
"hallPreviewStatus": "(大厅展示态:{{status}}",
"subnavOccupancy": "风险占用",
"subnavHot": "热门号码",
"subnavSoldOut": "售罄列表",
"subnavPools": "全部风险池",
"changeDraw": "更换期号",
"number4d": "号码4 位)",
"optional": "可选",
"actionFilter": "动作",
"noLimit": "不限",
"lock": "锁定 lock",
"release": "释放 release",
"applyFilter": "应用筛选",
"statusOptions": {
"pending": "未开始",
"open": "可下注",
"closing": "封盘中",
"closed": "已封盘待开奖",
"drawing": "开奖处理中",
"review": "待审核",
"cooldown": "冷静期",
"settling": "结算中",
"settled": "已结算",
"cancelled": "已取消"
}
}

View File

@@ -0,0 +1,54 @@
{
"title": "结算",
"filter": "筛选",
"drawNo": "期号",
"status": "状态",
"apply": "应用",
"batchList": "结算批次",
"loadFailed": "加载失败",
"exportFailed": "导出失败",
"actionSuccess": "{{name}}成功",
"actionFailed": "{{name}}失败",
"placeholderDrawNo": "如 20260511-001",
"reviewStatus": "审核状态",
"ticketCount": "注单数",
"winCount": "中奖笔数",
"payoutTotal": "派彩合计",
"jackpot": "Jackpot",
"finishedAt": "完成时间",
"details": "明细",
"approve": "审核通过",
"pass": "通过",
"reject": "驳回",
"payout": "派彩",
"export": "导出",
"backToList": "返回批次列表",
"errorTitle": "错误",
"retry": "重试",
"batchSummary": "批次 #{{id}}",
"summaryMeta": "期号 {{drawNo}} · 期状态 {{drawStatus}} · 结果批次 v{{version}}",
"settlementStatus": "结算状态",
"reviewState": "审核状态",
"ticketTotal": "注单数",
"winTotal": "中奖笔数",
"payoutAmount": "派彩合计",
"jackpotPayout": "Jackpot 划出",
"startedAt": "开始",
"endedAt": "结束",
"runPayout": "执行派彩",
"exportSettlementReport": "导出结算报表",
"loadingSummary": "加载摘要…",
"detailTitle": "注单结算明细",
"ticketNo": "注单号",
"playCode": "玩法",
"player": "玩家",
"matchedTier": "匹配档",
"regularPayout": "常规派彩",
"loadingDetails": "加载明细…",
"statusOptions": {
"all": "不限",
"running": "进行中",
"completed": "已完成",
"failed": "失败"
}
}

View File

@@ -0,0 +1,19 @@
{
"title": "注单",
"playerTicketQuery": "玩家注单查询",
"playerId": "玩家 ID",
"invalidPlayerId": "请输入有效玩家 ID",
"drawNoOptional": "期号 draw_no可选",
"drawNoPlaceholder": "如 20260520-001",
"query": "查询",
"loadFailed": "加载失败",
"ticketNo": "注单号",
"orderNo": "订单号",
"drawNo": "期号",
"playCode": "玩法",
"number": "号码",
"actualDeduct": "实扣",
"status": "状态",
"failReason": "失败原因",
"winAmount": "中奖"
}

View File

@@ -0,0 +1,69 @@
{
"title": "钱包",
"subnavLabel": "钱包子页",
"subnavTransactions": "钱包流水",
"subnavTransferOrders": "转账单",
"noPermission": "当前账号无访问该页的权限",
"copySuccess": "{{label}}已复制到剪贴板",
"copyFailed": "复制失败,请检查浏览器权限或手动选择文本",
"statusProcessing": "处理中",
"statusSuccess": "成功",
"statusFailed": "失败",
"statusPendingReconcile": "待对账",
"statusReversed": "已冲正",
"statusManuallyProcessed": "已人工处理",
"statusPosted": "已记账",
"filterAll": "不限",
"transferIn": "主站转入",
"transferOut": "主站转出",
"transferOutRefund": "转出失败回补",
"transferOrders": "转账单",
"walletTransactions": "钱包流水",
"playerWalletQuery": "玩家钱包查询",
"localTransferNo": "本地单号",
"externalRefNo": "主站流水号",
"playerAccount": "玩家账号",
"playerAccountPlaceholder": "主站玩家 ID 或用户名(模糊)",
"playerId": "玩家 ID",
"playerIdOptional": "可选,优先于账号",
"requestDateRange": "请求日期范围",
"status": "状态",
"options": "选项",
"abnormalOnly": "仅异常单",
"abnormalOnlyPending": "仅异常(待对账)",
"search": "搜索",
"resetFilters": "重置筛选",
"refreshCurrentPage": "刷新当前页",
"loadFailed": "加载失败",
"direction": "方向",
"amount": "金额",
"failReason": "失败原因",
"requestTime": "请求时间",
"finishedTime": "完成时间",
"actions": "操作",
"reverse": "冲正",
"manualProcess": "人工处理",
"processing": "处理中…",
"reverseSuccess": "冲正成功",
"manualProcessSuccess": "人工处理成功",
"actionFailed": "操作失败",
"txnNo": "流水号",
"bizType": "类型(业务)",
"type": "类型",
"queryFailed": "查询失败",
"invalidPlayerId": "请输入有效玩家 ID",
"querying": "查询中…",
"query": "查询",
"sitePlayer": "站点玩家",
"walletType": "类型",
"currency": "币种",
"balanceMinor": "余额(最小单位)",
"availableBalance": "可用(推算)",
"noWalletRows": "暂无钱包行(从未下过注或未划转也可能无记录)",
"copyTransferNo": "本地单号",
"copyExternalRefNo": "主站流水号",
"copyTxnNo": "流水号",
"copyExternalTxnRefNo": "主站流水号",
"in": "入",
"out": "出"
}

View File

@@ -1,7 +1,8 @@
/**
* 导航与路由的单一事实来源;新增业务模块时先改这里,再增加 `app/admin/(shell)/.../page.tsx`。
* Single source of truth for admin navigation and routes.
*
* `requiredAny` 与登录接口返回的 `admin.permissions`Laravel `prd.*`)对齐;缺省表示任意已登录用户可见。
* `requiredAny` matches `admin.permissions` from the login response (Laravel `prd.*`).
* When omitted, the item is visible to any signed-in admin.
*/
export const ADMIN_BASE = "/admin" as const;
@@ -24,21 +25,21 @@ export type AdminNavItem = {
| "audit"
| "admin_users";
activeMatchPrefix?: string;
/** 拥有任一权限 slug 即显示侧栏项 */
/** Show the nav item when the user has any of these permission slugs. */
requiredAny?: readonly string[];
};
export const adminShellNavItems: AdminNavItem[] = [
{ segment: "dashboard", label: "仪表盘", href: "/admin" },
{ segment: "dashboard", label: "Dashboard", href: "/admin" },
{
segment: "admin_users",
label: "管理列表",
label: "Admin Users",
href: "/admin/admin-users",
requiredAny: ["prd.admin_user.manage"],
},
{
segment: "players",
label: "玩家列表",
label: "Players",
href: "/admin/players",
requiredAny: [
"prd.users.manage",
@@ -48,7 +49,7 @@ export const adminShellNavItems: AdminNavItem[] = [
},
{
segment: "wallet",
label: "钱包流水",
label: "Wallet",
href: "/admin/wallet/transactions",
activeMatchPrefix: "/admin/wallet",
requiredAny: [
@@ -62,13 +63,13 @@ export const adminShellNavItems: AdminNavItem[] = [
},
{
segment: "draws",
label: "期号列表",
label: "Draws",
href: "/admin/draws",
requiredAny: ["prd.draw_result.manage", "prd.draw_result.view"],
},
{
segment: "config",
label: "运营配置",
label: "Configuration",
href: "/admin/config",
requiredAny: [
"prd.play_switch.manage",
@@ -83,13 +84,13 @@ export const adminShellNavItems: AdminNavItem[] = [
},
{
segment: "risk",
label: "风控",
label: "Risk",
href: "/admin/risk",
requiredAny: ["prd.draw_result.view", "prd.draw_result.manage"],
},
{
segment: "settlement",
label: "结算",
label: "Settlement",
href: "/admin/settlement-batches",
requiredAny: [
"prd.payout.manage",
@@ -106,7 +107,7 @@ export const adminShellNavItems: AdminNavItem[] = [
},
{
segment: "reconcile",
label: "对账",
label: "Reconcile",
href: "/admin/reconcile",
requiredAny: [
"prd.wallet_reconcile.manage",
@@ -116,7 +117,7 @@ export const adminShellNavItems: AdminNavItem[] = [
},
{
segment: "tickets",
label: "玩家注单",
label: "Tickets",
href: "/admin/tickets",
requiredAny: [
"prd.users.view_cs",
@@ -132,7 +133,7 @@ export const adminShellNavItems: AdminNavItem[] = [
},
{
segment: "reports",
label: "报表导出",
label: "Reports",
href: "/admin/reports",
requiredAny: [
"prd.report.all",
@@ -143,9 +144,9 @@ export const adminShellNavItems: AdminNavItem[] = [
},
{
segment: "audit",
label: "审计日志",
label: "Audit Logs",
href: "/admin/audit-logs",
requiredAny: ["prd.audit.all", "prd.audit.self", "prd.audit.finance"],
},
{ segment: "settings", label: "系统设置", href: "/admin/settings" },
{ segment: "settings", label: "Settings", href: "/admin/settings" },
];

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -40,6 +41,7 @@ import { useAdminProfile } from "@/stores/admin-session";
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
export function AdminUsersConsole(): React.ReactElement {
const { t } = useTranslation(["adminUsers", "common"]);
const profile = useAdminProfile();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
@@ -59,7 +61,7 @@ export function AdminUsersConsole(): React.ReactElement {
const [saving, setSaving] = useState(false);
const [savingRoles, setSavingRoles] = useState(false);
const [permissionOpen, setPermissionOpen] = useState(false);
/** `false` = 折叠;缺省为展开 */
/** `false` = collapsed; default expanded */
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
const [accountOpen, setAccountOpen] = useState(false);
@@ -129,16 +131,16 @@ export function AdminUsersConsole(): React.ReactElement {
async function submitAccount(): Promise<void> {
const nick = formNickname.trim();
if (nick === "") {
toast.error("请填写昵称");
toast.error(t("nicknameRequired"));
return;
}
if (accountMode === "edit" && formPassword !== "" && formPassword.length < 8) {
toast.error("新密码至少 8 位");
toast.error(t("newPasswordMin"));
return;
}
if (accountMode === "create" && formCreateRoles.length === 0) {
toast.error("请至少选择一个角色");
toast.error(t("roleRequired"));
return;
}
@@ -147,11 +149,11 @@ export function AdminUsersConsole(): React.ReactElement {
if (accountMode === "create") {
const u = formUsername.trim();
if (u === "") {
toast.error("请填写登录账号");
toast.error(t("usernameRequired"));
return;
}
if (formPassword.length < 8) {
toast.error("密码至少 8 位");
toast.error(t("passwordMin"));
return;
}
const created = await postAdminUser({
@@ -164,7 +166,7 @@ export function AdminUsersConsole(): React.ReactElement {
});
setItems((prev) => [created, ...prev]);
setTotal((t) => t + 1);
toast.success(`已创建管理员 ${created.username}`);
toast.success(t("createSuccess", { name: created.username }));
handleAccountDialogOpenChange(false);
} else {
const id = editingAccountId;
@@ -186,11 +188,11 @@ export function AdminUsersConsole(): React.ReactElement {
}
const updated = await putAdminUser(id, body);
setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row)));
toast.success(`已更新 ${updated.username}`);
toast.success(t("updateSuccess", { name: updated.username }));
handleAccountDialogOpenChange(false);
}
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "保存账号失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("saveAccountFailed");
toast.error(msg);
} finally {
setAccountSaving(false);
@@ -206,10 +208,10 @@ export function AdminUsersConsole(): React.ReactElement {
await deleteAdminUser(deleteTarget.id);
setItems((prev) => prev.filter((r) => r.id !== deleteTarget.id));
setTotal((t) => Math.max(0, t - 1));
toast.success(`已删除 ${deleteTarget.username}`);
toast.success(t("deleteSuccess", { name: deleteTarget.username }));
setDeleteTarget(null);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "删除失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("deleteFailed");
toast.error(msg);
} finally {
setDeleteBusy(false);
@@ -229,10 +231,10 @@ export function AdminUsersConsole(): React.ReactElement {
}
const flat = catalog?.permissions ?? [];
if (flat.length > 0) {
return [{ key: "all", label: "全部权限", permissions: flat }];
return [{ key: "all", label: t("allPermissions"), permissions: flat }];
}
return [];
}, [catalog]);
}, [catalog, t]);
function isDirectGroupOpen(key: string): boolean {
return directMenuExpanded[key] !== false;
@@ -271,7 +273,7 @@ export function AdminUsersConsole(): React.ReactElement {
setTotal(listData.meta.total);
setLastPage(Math.max(1, listData.meta.last_page));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载管理员列表失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
setErr(msg);
setItems([]);
setTotal(0);
@@ -279,7 +281,7 @@ export function AdminUsersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query]);
}, [page, perPage, query, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -324,9 +326,9 @@ export function AdminUsersConsole(): React.ReactElement {
: row,
),
);
toast.success(`已更新 ${result.username} 的角色`);
toast.success(t("saveRoleSuccess", { name: result.username }));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "保存角色失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("saveRoleFailed");
toast.error(msg);
} finally {
setSavingRoles(false);
@@ -354,9 +356,9 @@ export function AdminUsersConsole(): React.ReactElement {
: row,
),
);
toast.success(`已更新 ${result.username} 的权限`);
toast.success(t("savePermissionSuccess", { name: result.username }));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "保存权限失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("savePermissionFailed");
toast.error(msg);
} finally {
setSaving(false);
@@ -368,15 +370,15 @@ export function AdminUsersConsole(): React.ReactElement {
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<CardTitle></CardTitle>
<CardTitle>{t("listTitle")}</CardTitle>
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
{t("createAdmin")}
</Button>
</div>
<div className="flex w-full max-w-lg gap-2">
<Input
value={keyword}
placeholder="按用户名 / 昵称 / 邮箱搜索"
placeholder={t("searchPlaceholder")}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
@@ -392,37 +394,37 @@ export function AdminUsersConsole(): React.ReactElement {
setQuery(keyword.trim());
}}
>
{t("actions.search", { ns: "common" })}
</Button>
<Button type="button" variant="secondary" onClick={() => void load()}>
{t("actions.refresh", { ns: "common" })}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && items.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-20 whitespace-nowrap"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="min-w-[11rem]"></TableHead>
<TableHead>{t("table.account")}</TableHead>
<TableHead>{t("table.nickname")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
<TableHead>{t("table.roles")}</TableHead>
<TableHead>{t("table.direct")}</TableHead>
<TableHead>{t("table.effective")}</TableHead>
<TableHead className="min-w-[11rem]">{t("table.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
@@ -439,18 +441,18 @@ export function AdminUsersConsole(): React.ReactElement {
<TableCell>
{row.status === 0 ? (
<Badge variant="secondary" className="font-normal">
{t("status.enabled")}
</Badge>
) : (
<Badge variant="outline" className="border-amber-600/50 text-amber-800 dark:text-amber-400">
{t("status.disabled")}
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.roles.length === 0 ? (
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">{t("common.none")}</span>
) : (
row.roles.map((slug) => (
<Badge key={slug} variant="secondary">
@@ -472,7 +474,7 @@ export function AdminUsersConsole(): React.ReactElement {
openPermissionEditor(row);
}}
>
{t("actions.permissions")}
</Button>
<Button
type="button"
@@ -482,7 +484,7 @@ export function AdminUsersConsole(): React.ReactElement {
}
onClick={() => openEditAccount(row)}
>
{t("actions.edit")}
</Button>
<Button
type="button"
@@ -490,11 +492,13 @@ export function AdminUsersConsole(): React.ReactElement {
variant="destructive"
disabled={profile?.id === row.id}
title={
profile?.id === row.id ? "不能删除当前登录账号" : "删除该管理员"
profile?.id === row.id
? t("delete.currentUserBlocked")
: t("delete.rowActionTitle")
}
onClick={() => setDeleteTarget(row)}
>
{t("actions.delete")}
</Button>
</div>
</TableCell>
@@ -526,7 +530,7 @@ export function AdminUsersConsole(): React.ReactElement {
className="flex h-[min(88vh,800px)] max-h-[90vh] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-[min(85vh,780px)] sm:max-w-3xl"
>
<DialogHeader className="shrink-0 space-y-1 border-b px-4 py-3 pr-12">
<DialogTitle></DialogTitle>
<DialogTitle>{t("permissionDialog.title")}</DialogTitle>
<DialogDescription>
{selectedUser ? (
<>
@@ -541,9 +545,9 @@ export function AdminUsersConsole(): React.ReactElement {
<div className="space-y-6 pb-1">
<section className="space-y-3">
<div>
<h3 className="text-sm font-medium leading-none"></h3>
<h3 className="text-sm font-medium leading-none">{t("permissionDialog.rolesTitle")}</h3>
<p className="mt-1.5 text-xs text-muted-foreground">
{t("permissionDialog.rolesDescription")}
</p>
</div>
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
@@ -559,7 +563,7 @@ export function AdminUsersConsole(): React.ReactElement {
<span className="block leading-none font-medium">{r.name}</span>
<span className="text-xs text-muted-foreground">{r.slug}</span>
<span className="block text-xs text-muted-foreground/90">
{r.permission_slugs.length}
{t("permissionDialog.rolePermissionCount", { count: r.permission_slugs.length })}
</span>
</span>
</label>
@@ -570,15 +574,15 @@ export function AdminUsersConsole(): React.ReactElement {
<section className="space-y-3">
<div>
<h3 className="text-sm font-medium leading-none"></h3>
<h3 className="text-sm font-medium leading-none">{t("permissionDialog.directTitle")}</h3>
<p className="mt-1.5 text-xs text-muted-foreground">
/ prd.*
{t("permissionDialog.directDescription")}
</p>
</div>
<div className="rounded-md border bg-muted/20 p-2.5 text-xs text-muted-foreground">
{t("permissionDialog.selectedRoles")}
{draftRoles.length === 0 ? (
<span className="ml-1 text-foreground/80"></span>
<span className="ml-1 text-foreground/80">{t("common.none")}</span>
) : (
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
{draftRoles.map((slug) => (
@@ -653,7 +657,7 @@ export function AdminUsersConsole(): React.ReactElement {
handlePermissionDialogOpenChange(false);
}}
>
{t("actions.close", { ns: "common" })}
</Button>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-nowrap sm:justify-end">
<Button
@@ -663,7 +667,7 @@ export function AdminUsersConsole(): React.ReactElement {
disabled={!selectedUser || savingRoles}
onClick={() => void saveRoles()}
>
{savingRoles ? "保存中…" : "保存角色"}
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
</Button>
<Button
type="button"
@@ -671,7 +675,7 @@ export function AdminUsersConsole(): React.ReactElement {
disabled={!selectedUser || saving}
onClick={() => void savePermissions()}
>
{saving ? "保存中…" : "保存直接权限"}
{saving ? t("saving") : t("permissionDialog.saveDirect")}
</Button>
</div>
</div>
@@ -681,63 +685,69 @@ export function AdminUsersConsole(): React.ReactElement {
<Dialog open={accountOpen} onOpenChange={handleAccountDialogOpenChange}>
<DialogContent showCloseButton className="max-h-[90vh] max-w-lg gap-4 overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{accountMode === "create" ? "新建管理员" : "编辑账号"}</DialogTitle>
<DialogTitle>
{accountMode === "create" ? t("accountDialog.createTitle") : t("accountDialog.editTitle")}
</DialogTitle>
<DialogDescription>
{accountMode === "create"
? "须为账号指定至少一个默认站点角色。登录账号仅可使用字母、数字、点、下划线与连字符,保存后为小写。"
: "登录账号不可修改。留空密码表示不修改。"}
? t("accountDialog.createDescription")
: t("accountDialog.editDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none"></div>
<div className="text-sm font-medium leading-none">{t("accountDialog.username")}</div>
<Input
value={formUsername}
disabled={accountMode === "edit"}
placeholder="例如 ops_admin"
placeholder={t("accountDialog.usernamePlaceholder")}
autoComplete="off"
onChange={(e) => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none"></div>
<div className="text-sm font-medium leading-none">{t("accountDialog.nickname")}</div>
<Input
value={formNickname}
placeholder="显示名称"
placeholder={t("accountDialog.nicknamePlaceholder")}
onChange={(e) => setFormNickname(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none"></div>
<div className="text-sm font-medium leading-none">{t("accountDialog.emailOptional")}</div>
<Input
type="email"
value={formEmail}
placeholder="留空则不填"
placeholder={t("accountDialog.emailPlaceholder")}
onChange={(e) => setFormEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none">
{accountMode === "edit" ? "(可选)" : ""}
{accountMode === "edit" ? t("accountDialog.passwordOptional") : t("accountDialog.password")}
</div>
<Input
type="password"
value={formPassword}
placeholder={accountMode === "create" ? "至少 8 位" : "不修改请留空"}
placeholder={
accountMode === "create"
? t("accountDialog.passwordPlaceholderCreate")
: t("accountDialog.passwordPlaceholderEdit")
}
autoComplete="new-password"
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
{accountMode === "create" ? (
<div className="space-y-2">
<div className="text-sm font-medium leading-none"></div>
<div className="text-sm font-medium leading-none">{t("accountDialog.rolesRequired")}</div>
<p className="text-xs text-muted-foreground">
{t("accountDialog.rolesDescription")}
</p>
<div className="max-h-52 space-y-2 overflow-y-auto rounded-md border p-2.5 sm:grid sm:max-h-56 sm:grid-cols-2 sm:gap-2 sm:space-y-0">
{(catalog?.roles ?? []).length === 0 ? (
<p className="col-span-full text-xs text-muted-foreground">
{t("accountDialog.noRoles")}
</p>
) : (
(catalog?.roles ?? []).map((r) => {
@@ -760,14 +770,14 @@ export function AdminUsersConsole(): React.ReactElement {
</div>
) : null}
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none"></div>
<div className="text-sm font-medium leading-none">{t("table.status")}</div>
<select
className={selectClassName}
value={formStatus}
onChange={(e) => setFormStatus(Number(e.target.value))}
>
<option value={0}></option>
<option value={1}></option>
<option value={0}>{t("status.enabled")}</option>
<option value={1}>{t("status.disabled")}</option>
</select>
</div>
</div>
@@ -777,10 +787,10 @@ export function AdminUsersConsole(): React.ReactElement {
variant="outline"
onClick={() => handleAccountDialogOpenChange(false)}
>
{t("actions.cancel")}
</Button>
<Button type="button" disabled={accountSaving} onClick={() => void submitAccount()}>
{accountSaving ? "保存中…" : "保存"}
{accountSaving ? t("saving") : t("actions.save")}
</Button>
</div>
</DialogContent>
@@ -789,14 +799,10 @@ export function AdminUsersConsole(): React.ReactElement {
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent showCloseButton className="max-w-md gap-4">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("delete.confirmTitle")}</DialogTitle>
<DialogDescription>
{deleteTarget ? (
<>
{" "}
<span className="font-medium text-foreground">{deleteTarget.username}</span>
</>
<>{t("delete.confirmDescription", { name: deleteTarget.username })}</>
) : null}
</DialogDescription>
</DialogHeader>
@@ -807,7 +813,7 @@ export function AdminUsersConsole(): React.ReactElement {
disabled={deleteBusy}
onClick={() => setDeleteTarget(null)}
>
{t("actions.cancel")}
</Button>
<Button
type="button"
@@ -815,7 +821,7 @@ export function AdminUsersConsole(): React.ReactElement {
disabled={deleteBusy}
onClick={() => void confirmDelete()}
>
{deleteBusy ? "删除中…" : "删除"}
{deleteBusy ? t("deleting") : t("actions.delete")}
</Button>
</div>
</DialogContent>

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminAuditLogs } from "@/api/admin-audit";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -21,6 +22,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminAuditLogListData } from "@/types/api/admin-audit";
export function AuditLogsConsole(): React.ReactElement {
const { t } = useTranslation(["audit", "common"]);
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminAuditLogListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -47,12 +49,12 @@ export function AuditLogsConsole(): React.ReactElement {
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, appliedModule, appliedAction, appliedOpType]);
}, [page, perPage, appliedModule, appliedAction, appliedOpType, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -66,39 +68,39 @@ export function AuditLogsConsole(): React.ReactElement {
<Card className="w-full max-w-none">
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardTitle>{t("title")}</CardTitle>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
{t("actions.refresh", { ns: "common" })}
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="aud-mod">module_code</Label>
<Label htmlFor="aud-mod">{t("moduleCode")}</Label>
<Input
id="aud-mod"
value={moduleCode}
onChange={(e) => setModuleCode(e.target.value)}
placeholder="精确匹配"
placeholder={t("exactMatch")}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="aud-act">action_code</Label>
<Label htmlFor="aud-act">{t("actionCode")}</Label>
<Input
id="aud-act"
value={actionCode}
onChange={(e) => setActionCode(e.target.value)}
placeholder="精确匹配"
placeholder={t("exactMatch")}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="aud-op">operator_type</Label>
<Label htmlFor="aud-op">{t("operatorType")}</Label>
<Input
id="aud-op"
value={operatorType}
onChange={(e) => setOperatorType(e.target.value)}
placeholder="如 admin / system"
placeholder={t("operatorTypePlaceholder")}
/>
</div>
<div className="flex items-end">
@@ -111,14 +113,14 @@ export function AuditLogsConsole(): React.ReactElement {
setPage(1);
}}
>
{t("actions.search", { ns: "common" })}
</Button>
</div>
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
@@ -128,18 +130,18 @@ export function AuditLogsConsole(): React.ReactElement {
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("operator")}</TableHead>
<TableHead>{t("module")}</TableHead>
<TableHead>{t("action")}</TableHead>
<TableHead>{t("target")}</TableHead>
<TableHead>{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
) : (

View File

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

View File

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

View File

@@ -1,71 +1,49 @@
/**
* 运营配置子导航与面包屑的单一数据源。
* 新增配置页:在此追加条目,并增加 `app/admin/(shell)/config/.../page.tsx`
* Single source of truth for config sub-navigation and breadcrumb routes.
* Add new config pages here and create the matching `app/admin/(shell)/config/.../page.tsx`.
*/
export type ConfigNavGroup = {
id: string;
label: string;
items: readonly {
href: string;
title: string;
description: string;
key: string;
}[];
};
export const CONFIG_NAV_GROUPS: readonly ConfigNavGroup[] = [
{
id: "betting",
label: "投注与展示",
items: [
{
href: "/admin/config/plays",
title: "玩法与限额",
description: "目录开关、单玩法限额、版本发布",
key: "plays",
},
{
href: "/admin/config/odds",
title: "赔率",
description: "按玩法与奖级维护乘数与币种",
key: "odds",
},
{
href: "/admin/config/rebate",
title: "佣金 / 回水",
description: "从赔率草稿批量调整回水比例",
key: "rebate",
},
],
},
{
id: "risk_wallet",
label: "风控与资金",
items: [
{
href: "/admin/config/risk-cap",
title: "赔付封顶",
description: "按号码维度的封顶版本",
key: "risk-cap",
},
{
href: "/admin/config/wallet",
title: "钱包阈值",
description: "转入转出上下限(系统设置)",
key: "wallet",
},
],
},
] as const;
const CONFIG_ROUTE_LABEL_ENTRIES: readonly [string, string][] = [
["plays", "玩法与限额"],
["odds", "赔率"],
["rebate", "佣金 / 回水"],
["risk-cap", "赔付封顶"],
["wallet", "钱包阈值"],
];
/** 面包屑第三段 slug → 中文 */
export const CONFIG_ROUTE_LABELS: Readonly<Record<string, string>> = Object.fromEntries(
CONFIG_ROUTE_LABEL_ENTRIES,
) as Readonly<Record<string, string>>;
export function flattenConfigNavHrefs(): string[] {
const out: string[] = [];
for (const g of CONFIG_NAV_GROUPS) {

View File

@@ -1,13 +1,9 @@
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
const LABELS: Record<string, string> = {
draft: "草稿",
active: "生效中",
archived: "已归档",
};
export function ConfigStatusBadge({ status }: { status: string }) {
const label = LABELS[status] ?? status;
const { t } = useTranslation("config");
const label = t(`versionStatus.${status}`, { defaultValue: status });
const className =
status === "active"
? "border-emerald-500/20 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300"

View File

@@ -2,15 +2,16 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
const LINKS: { href: string; label: string; match?: "exact" | "prefix" }[] = [
{ href: "/admin/config/plays", label: "玩法配置" },
{ href: "/admin/config/odds", label: "赔率配置" },
{ href: "/admin/config/rebate", label: "佣金 / 回水" },
{ href: "/admin/config/risk-cap", label: "风控封顶" },
{ href: "/admin/config/wallet", label: "钱包配置" },
const LINKS: { href: string; key: string; match?: "exact" | "prefix" }[] = [
{ href: "/admin/config/plays", key: "plays" },
{ href: "/admin/config/odds", key: "odds" },
{ href: "/admin/config/rebate", key: "rebate" },
{ href: "/admin/config/risk-cap", key: "risk-cap" },
{ href: "/admin/config/wallet", key: "wallet" },
];
function linkActive(pathname: string, href: string, match: "exact" | "prefix"): boolean {
@@ -21,14 +22,15 @@ function linkActive(pathname: string, href: string, match: "exact" | "prefix"):
}
export function ConfigSubNav() {
const { t } = useTranslation("config");
const pathname = usePathname();
return (
<nav
className="flex flex-wrap gap-2 border-b border-border pb-3 mb-6"
aria-label="运营配置子导航"
aria-label={t("nav.aria")}
>
{LINKS.map(({ href, label, match = "prefix" }) => {
{LINKS.map(({ href, key, match = "prefix" }) => {
const active = linkActive(pathname, href, match);
return (
<Link
@@ -41,7 +43,7 @@ export function ConfigSubNav() {
: "bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{label}
{t(`nav.items.${key}`)}
</Link>
);
})}

View File

@@ -1,6 +1,7 @@
"use client";
import { Plus, RefreshCw, Rocket, Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -23,14 +24,16 @@ export function ConfigVersionActions({
loadingList = false,
loadingDetail = false,
saving = false,
publishLabel = "启用为当前版本",
publishLabel,
onRefresh,
onNewDraft,
onSaveDraft,
onPublish,
className,
}: ConfigVersionActionsProps) {
const { t } = useTranslation("config");
const draftActionBusy = saving || loadingDetail;
const resolvedPublishLabel = publishLabel ?? t("versionActions.publishCurrent");
return (
<div className={cn("flex flex-wrap items-center gap-2 lg:justify-end", className)}>
@@ -42,7 +45,7 @@ export function ConfigVersionActions({
onClick={onRefresh}
>
<RefreshCw className={loadingList ? "size-4 animate-spin" : "size-4"} aria-hidden />
{loadingList ? "刷新中" : "刷新版本"}
{loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")}
</Button>
<Button
type="button"
@@ -51,7 +54,7 @@ export function ConfigVersionActions({
onClick={onNewDraft}
>
<Plus className="size-4" aria-hidden />
稿
{t("versionActions.newDraft")}
</Button>
{isDraft ? (
<>
@@ -63,7 +66,7 @@ export function ConfigVersionActions({
onClick={onSaveDraft}
>
<Save className="size-4" aria-hidden />
稿
{t("versionActions.saveDraft")}
</Button>
<Button
type="button"
@@ -72,7 +75,7 @@ export function ConfigVersionActions({
onClick={onPublish}
>
<Rocket className="size-4" aria-hidden />
{publishLabel}
{resolvedPublishLabel}
</Button>
</>
) : null}

View File

@@ -2,6 +2,7 @@
import { useMemo, useState } from "react";
import { Layers } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -25,19 +26,6 @@ import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import type { ConfigVersionSummary } from "@/types/api/admin-config";
function versionStatusLabel(status: string): string {
if (status === "active") {
return "生效中";
}
if (status === "draft") {
return "草稿";
}
if (status === "archived") {
return "已归档";
}
return status;
}
const STATUS_ORDER = ["draft", "active", "archived"] as const;
export type ConfigVersionSwitcherProps = {
@@ -59,17 +47,21 @@ export function ConfigVersionSwitcher({
selectedId,
onSelectedIdChange,
loading = false,
sheetTitle = "切换配置版本",
sheetDescription = "选择一条版本在本页查看;草稿可编辑,生效中与已归档为只读。",
sheetTitle,
sheetDescription,
className,
onDeleteVersion,
onRollbackVersion,
rollbackBusy = false,
}: ConfigVersionSwitcherProps) {
const { t } = useTranslation(["config", "adminUsers"]);
const formatDt = useAdminDateTimeFormatter();
const [sheetOpen, setSheetOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ConfigVersionSummary | null>(null);
const [deletingId, setDeletingId] = useState<number | null>(null);
const resolvedSheetTitle = sheetTitle ?? t("versionSwitcher.sheetTitle", { ns: "config" });
const resolvedSheetDescription =
sheetDescription ?? t("versionSwitcher.sheetDescription", { ns: "config" });
const sortedVersions = useMemo(
() => [...versions].sort((a, b) => b.id - a.id),
@@ -98,10 +90,10 @@ export function ConfigVersionSwitcher({
() =>
STATUS_ORDER.map((status) => ({
status,
label: versionStatusLabel(status),
label: t(`versionStatus.${status}`, { ns: "config" }),
count: groupedVersions.get(status)?.length ?? 0,
})),
[groupedVersions],
[groupedVersions, t],
);
function switchTo(id: number) {
@@ -138,7 +130,11 @@ export function ConfigVersionSwitcher({
<span className="text-sm text-muted-foreground">#{selectedVersion.id}</span>
</>
) : (
<span className="text-sm text-muted-foreground">{loading ? "加载中…" : "未选择版本"}</span>
<span className="text-sm text-muted-foreground">
{loading
? t("versionSwitcher.loading", { ns: "config" })
: t("versionSwitcher.noneSelected", { ns: "config" })}
</span>
)}
</div>
<Button
@@ -149,7 +145,7 @@ export function ConfigVersionSwitcher({
className="h-9 shrink-0 border-slate-300 bg-white px-3 text-slate-800 hover:bg-slate-50 hover:text-slate-950"
>
<Layers className="size-4" aria-hidden />
{t("versionSwitcher.switch", { ns: "config" })}
</Button>
</div>
@@ -161,10 +157,10 @@ export function ConfigVersionSwitcher({
<div className="border-b bg-white px-5 pb-4 pt-5">
<SheetHeader className="space-y-2 text-left">
<SheetTitle className="text-[17px] font-semibold tracking-tight text-slate-950">
{sheetTitle}
{resolvedSheetTitle}
</SheetTitle>
<SheetDescription className="max-w-[320px] text-[13px] leading-5 text-slate-500">
{sheetDescription}
{resolvedSheetDescription}
</SheetDescription>
</SheetHeader>
</div>
@@ -183,7 +179,7 @@ export function ConfigVersionSwitcher({
<div className="flex-1 overflow-auto space-y-5 px-4 py-4">
{sortedVersions.length === 0 ? (
<Card className="border-dashed border-slate-200 bg-white/80 p-5 text-center text-sm text-slate-500 shadow-none">
{t("versionSwitcher.empty", { ns: "config" })}
</Card>
) : (
STATUS_ORDER.map((status) => {
@@ -204,11 +200,11 @@ export function ConfigVersionSwitcher({
)}
/>
<p className="text-[15px] font-semibold text-slate-950">
{versionStatusLabel(status)}
{t(`versionStatus.${status}`, { ns: "config" })}
</p>
</div>
<p className="rounded-full bg-white px-2 py-0.5 text-xs font-medium tabular-nums text-slate-500 ring-1 ring-slate-200">
{rows.length}
{t("versionSwitcher.count", { ns: "config", count: rows.length })}
</p>
</div>
<div className="space-y-2.5">
@@ -244,13 +240,21 @@ export function ConfigVersionSwitcher({
</span>
</div>
<p className="line-clamp-2 text-[13px] leading-5 text-slate-500">
{v.effective_at ? formatDt(v.effective_at) : "—"}
{v.reason ? ` · 备注:${v.reason}` : ""}
{t("versionSwitcher.effectiveAt", {
ns: "config",
value: v.effective_at ? formatDt(v.effective_at) : "—",
})}
{v.reason
? ` · ${t("versionSwitcher.note", {
ns: "config",
value: v.reason,
})}`
: ""}
</p>
</div>
{isCurrent ? (
<span className="shrink-0 rounded-full bg-slate-950 px-2.5 py-1 text-xs font-medium text-white shadow-sm">
{t("versionSwitcher.current", { ns: "config" })}
</span>
) : null}
</div>
@@ -265,7 +269,9 @@ export function ConfigVersionSwitcher({
)}
onClick={() => switchTo(v.id)}
>
{isCurrent ? "已选中" : "查看"}
{isCurrent
? t("versionSwitcher.selected", { ns: "config" })
: t("versionSwitcher.view", { ns: "config" })}
</Button>
{onRollbackVersion && v.status !== "draft" ? (
<Button
@@ -279,7 +285,7 @@ export function ConfigVersionSwitcher({
setSheetOpen(false);
}}
>
{t("versionSwitcher.rollback", { ns: "config" })}
</Button>
) : null}
{onDeleteVersion && v.status !== "active" ? (
@@ -291,7 +297,7 @@ export function ConfigVersionSwitcher({
disabled={deletingId === v.id}
onClick={() => setDeleteTarget(v)}
>
{t("versionSwitcher.delete", { ns: "config" })}
</Button>
) : null}
</div>
@@ -312,14 +318,18 @@ export function ConfigVersionSwitcher({
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle>
<DialogDescription>
ID {deleteTarget?.id}version_no {deleteTarget?.version_no}
{t("versionSwitcher.deleteConfirmDescription", {
ns: "config",
id: deleteTarget?.id,
version: deleteTarget?.version_no,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button
type="button"
@@ -327,7 +337,7 @@ export function ConfigVersionSwitcher({
disabled={deletingId !== null}
onClick={() => void confirmDelete()}
>
{t("versionSwitcher.delete", { ns: "config" })}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -3,6 +3,7 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model";
@@ -12,6 +13,7 @@ function navLinkActive(pathname: string, href: string): boolean {
}
export function ConfigWorkspaceShell({ children }: { children: ReactNode }) {
const { t } = useTranslation("config");
const pathname = usePathname() ?? "";
return (
@@ -21,15 +23,15 @@ export function ConfigWorkspaceShell({ children }: { children: ReactNode }) {
<div className="h-full rounded-2xl border border-border/70 bg-card/80 p-3 shadow-sm backdrop-blur lg:overflow-auto">
<div className="mb-3 px-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{t("nav.sidebarTitle")}
</p>
</div>
<nav className="hidden space-y-3 lg:block" aria-label="运营配置子导航">
<nav className="hidden space-y-3 lg:block" aria-label={t("nav.aria")}>
{CONFIG_NAV_GROUPS.map((group) => (
<div key={group.id} className="space-y-1.5">
<p className="px-2 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{group.label}
{t(`nav.groups.${group.id}`)}
</p>
<ul className="space-y-1">
{group.items.map((item) => {
@@ -45,7 +47,7 @@ export function ConfigWorkspaceShell({ children }: { children: ReactNode }) {
: "border-transparent bg-transparent text-foreground hover:border-border hover:bg-muted/60",
)}
>
<div className="font-medium">{item.title}</div>
<div className="font-medium">{t(`nav.items.${item.key}`)}</div>
</Link>
</li>
);
@@ -70,7 +72,7 @@ export function ConfigWorkspaceShell({ children }: { children: ReactNode }) {
: "border-border bg-background text-foreground hover:bg-muted/60",
)}
>
{item.title}
{t(`nav.items.${item.key}`)}
</Link>
);
})}

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -63,6 +64,7 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
}
export function OddsConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
@@ -76,7 +78,7 @@ export function OddsConfigDocScreen() {
const [error, setError] = useState<string | null>(null);
const [catTab, setCatTab] = useState<CatTab>("all");
/** 用户点选的玩法;空字符串表示尚未选择,由 resolvedPlayCode 回落到分类内第一项 */
/** User-selected play type. Empty means none selected yet and falls back to the first item in the category. */
const [playCode, setPlayCode] = useState<string>("");
const [rollbackOpen, setRollbackOpen] = useState(false);
@@ -90,12 +92,12 @@ export function OddsConfigDocScreen() {
const d = await getAdminPlayTypes();
setTypes(d.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载玩法失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setTypes([]);
} finally {
setLoadingTypes(false);
}
}, []);
}, [t]);
const refreshList = useCallback(async () => {
setLoadingList(true);
@@ -104,13 +106,13 @@ export function OddsConfigDocScreen() {
const d = await getAllConfigVersions(getOddsVersions);
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(() => {
@@ -126,13 +128,13 @@ export function OddsConfigDocScreen() {
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
}, [t]);
useEffect(() => {
if (list.length === 0 || selectedId !== "") {
@@ -255,10 +257,10 @@ export function OddsConfigDocScreen() {
const d = await putOddsItems(detail.id, payload);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success("已保存草稿");
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
@@ -273,11 +275,11 @@ export function OddsConfigDocScreen() {
const d = await publishOddsVersion(detail.id);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success("已启用为当前版本");
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
} finally {
setSaving(false);
}
@@ -309,13 +311,13 @@ export function OddsConfigDocScreen() {
reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
toast.success(`Created draft v${d.version_no}`);
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
} finally {
setSaving(false);
}
@@ -331,7 +333,7 @@ export function OddsConfigDocScreen() {
reason: `rollback from v${rollbackTarget.version_no}`,
clone_from_version_id: rollbackTarget.id,
});
toast.success(`已自 v${rollbackTarget.version_no} 克隆为新草稿 v${d.version_no}`);
toast.success(`Cloned v${rollbackTarget.version_no} into new draft v${d.version_no}`);
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
@@ -339,7 +341,7 @@ export function OddsConfigDocScreen() {
setRollbackOpen(false);
setRollbackTarget(null);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "回滚失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Rollback failed");
} finally {
setSaving(false);
}
@@ -350,10 +352,10 @@ export function OddsConfigDocScreen() {
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteOddsVersion(row.id);
toast.success("已删除该版本");
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
throw e;
}
}
@@ -383,7 +385,7 @@ export function OddsConfigDocScreen() {
}, [activeCompareRows, detail, draftRows, resolvedPlayCode]);
const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: "全部" },
{ id: "all", label: "All" },
{ id: "d4", label: "4D" },
{ id: "d3", label: "3D" },
{ id: "d2", label: "2D" },
@@ -393,11 +395,11 @@ export function OddsConfigDocScreen() {
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("nav.items.odds", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-wrap gap-2">
<span className="text-base text-muted-foreground self-center mr-2"></span>
<span className="text-base text-muted-foreground self-center mr-2">Category</span>
{catTabs.map((t) => (
<Button
key={t.id}
@@ -412,10 +414,10 @@ export function OddsConfigDocScreen() {
</div>
<div className="space-y-2 min-h-[96px]">
<p className="text-base text-muted-foreground"></p>
<p className="text-base text-muted-foreground">Play Type</p>
<div className="flex flex-wrap gap-2 min-h-[44px]">
{filteredTypes.length === 0 ? (
<span className="text-base text-muted-foreground"></span>
<span className="text-base text-muted-foreground">No play types in this category.</span>
) : (
filteredTypes.map((t) => (
<Button
@@ -444,8 +446,8 @@ export function OddsConfigDocScreen() {
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="赔率配置版本"
sheetDescription="选择版本在本页查看;非草稿版本可回滚为新建草稿。"
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."
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
@@ -467,7 +469,7 @@ export function OddsConfigDocScreen() {
{detail ? (
<p className="text-sm text-muted-foreground">
Active version:
{activeHead ? (
<>
v{activeHead.version_no}
@@ -477,7 +479,7 @@ export function OddsConfigDocScreen() {
"—"
)}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
<span className="text-amber-600 dark:text-amber-400"> - This version is read-only. Create a draft before editing odds.</span>
) : null}
</p>
) : null}
@@ -486,7 +488,7 @@ export function OddsConfigDocScreen() {
{loadingDetail || loadingTypes ? (
<div className="flex min-h-[420px] items-center">
<p className="text-base text-muted-foreground"></p>
<p className="text-base text-muted-foreground">Loading details</p>
</div>
) : resolvedPlayCode ? (
<div className="grid min-h-[420px] gap-4 max-w-md">
@@ -521,17 +523,17 @@ export function OddsConfigDocScreen() {
</ConfigReadonlyValue>
)}
<span className="text-sm text-muted-foreground tabular-nums">
×{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
Multiplier x{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
</span>
</div>
) : (
<p className="text-sm text-destructive"> {scope} </p>
<p className="text-sm text-destructive">Missing {scope} row. Check seed or version data.</p>
)}
</div>
);
})}
<div className="grid gap-1 pt-2 border-t">
<Label>%</Label>
<Label>Rebate Rate (%)</Label>
{isDraft ? (
<Input
type="text"
@@ -546,7 +548,7 @@ export function OddsConfigDocScreen() {
{rebatePercentUi}
</ConfigReadonlyValue>
)}
<p className="text-sm text-muted-foreground"> rebate_rate</p>
<p className="text-sm text-muted-foreground">Writes rebate_rate to all prize scopes under this play type.</p>
</div>
</div>
) : null}
@@ -556,17 +558,17 @@ export function OddsConfigDocScreen() {
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>Confirm rollback</DialogTitle>
<DialogDescription>
v{rollbackTarget?.version_no} 稿线
A new draft will be cloned from version v{rollbackTarget?.version_no}. The active version will not be overwritten directly.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRollbackOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
Confirm rollback
</Button>
</DialogFooter>
</DialogContent>
@@ -575,16 +577,16 @@ export function OddsConfigDocScreen() {
<Dialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
<DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>Publish odds version?</DialogTitle>
<DialogDescription>
New odds affect new tickets immediately. Existing successful tickets still settle by their saved odds snapshot.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border">
<div className="grid grid-cols-3 border-b bg-muted/40 px-3 py-2 text-sm font-medium">
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span>Prize Scope</span>
<span className="text-right">Current Active</span>
<span className="text-right">After Publish</span>
</div>
{publishDiffRows.map((row) => (
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
@@ -598,7 +600,7 @@ export function OddsConfigDocScreen() {
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPublishConfirmOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button
type="button"
@@ -608,7 +610,7 @@ export function OddsConfigDocScreen() {
void handlePublish();
}}
>
Confirm publish
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -65,49 +66,41 @@ type PlayConfigSaveItemPayload = {
type PlayBatchSwitchGroup = {
key: string;
label: string;
match: (row: PlayConfigItemRow) => boolean;
};
const PLAY_BATCH_SWITCH_GROUPS: PlayBatchSwitchGroup[] = [
{
key: "d2",
label: "2D 全局",
match: (row) => row.dimension === 2,
},
{
key: "d3",
label: "3D 全局",
match: (row) => row.dimension === 3,
},
{
key: "d4",
label: "4D 全局",
match: (row) => row.dimension === 4,
},
{
key: "big-small",
label: "Big / Small",
match: (row) => row.play_code === "big" || row.play_code === "small",
},
{
key: "position",
label: "位置类玩法",
match: (row) => row.category === "position",
},
{
key: "box",
label: "包号类玩法",
match: (row) => row.category === "box",
},
{
key: "jackpot",
label: "Jackpot",
match: (row) => row.category === "jackpot" || row.play_code.includes("jackpot"),
},
];
/** 版本草稿保存 payload:直接按当前草稿快照落库。 */
/** Save payload for play-config drafts. Persist the current draft snapshot directly. */
function buildPlayConfigSavePayload(
draftRows: PlayConfigItemRow[],
): PlayConfigSaveItemPayload[] {
@@ -135,6 +128,7 @@ function buildPlayConfigSavePayload(
}
export function PlayConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null);
@@ -160,13 +154,13 @@ export function PlayConfigDocScreen() {
draftId !== null && d.items.some((x) => String(x.id) === draftId) ? null : draftId,
);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(() => {
@@ -191,7 +185,7 @@ export function PlayConfigDocScreen() {
if (detailRequestSeq.current !== requestSeq) {
return;
}
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setDetail(null);
setDraftRows([]);
} finally {
@@ -199,7 +193,7 @@ export function PlayConfigDocScreen() {
setLoadingDetail(false);
}
}
}, []);
}, [t]);
useEffect(() => {
if (list.length === 0) {
@@ -274,12 +268,13 @@ export function PlayConfigDocScreen() {
const enabledCount = rows.filter((row) => row.is_enabled).length;
return {
...group,
label: t(`play.batchGroups.${group.key}`, { ns: "config", defaultValue: group.key }),
total: rows.length,
enabledCount,
allEnabled: rows.length > 0 && enabledCount === rows.length,
};
}),
[draftRows],
[draftRows, t],
);
async function handleSaveDraft() {
@@ -289,7 +284,7 @@ export function PlayConfigDocScreen() {
const payload = buildPlayConfigSavePayload(draftRows);
for (const r of payload) {
if (r.min_bet_amount > r.max_bet_amount) {
toast.error(`${r.play_code}: 最小额不能大于最大额`);
toast.error(`${r.play_code}: min_bet_amount cannot exceed max_bet_amount`);
return;
}
}
@@ -298,10 +293,10 @@ export function PlayConfigDocScreen() {
const d = await putPlayConfigItems(detail.id, payload);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success("已保存草稿");
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
@@ -316,11 +311,11 @@ export function PlayConfigDocScreen() {
const d = await publishPlayConfigVersion(detail.id);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success("已启用为当前版本");
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
} finally {
setSaving(false);
}
@@ -334,14 +329,14 @@ export function PlayConfigDocScreen() {
reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
toast.success(`Created draft v${d.version_no}`);
setCreatingDraftId(String(d.id));
setSelectedId(String(d.id));
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
} finally {
setSaving(false);
}
@@ -361,7 +356,7 @@ export function PlayConfigDocScreen() {
updateConfigRow(rulePlayCode, { rule_text_zh: ruleDraftZh.trim() || null });
setRuleDialogOpen(false);
setRulePlayCode(null);
toast.message("规则说明已写入本地草稿,记得保存草稿");
toast.message("Rule text saved into the local draft. Save the draft to persist it.");
}
const activeHead = list.find((x) => x.status === "active");
@@ -369,10 +364,10 @@ export function PlayConfigDocScreen() {
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deletePlayConfigVersion(row.id);
toast.success("已删除该版本");
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
throw e;
}
}
@@ -380,7 +375,7 @@ export function PlayConfigDocScreen() {
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("nav.items.plays", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-xl border bg-muted/20 p-3">
@@ -390,7 +385,7 @@ export function PlayConfigDocScreen() {
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="玩法配置版本"
sheetTitle={`${t("nav.items.plays", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion}
className="lg:flex-1"
/>
@@ -412,14 +407,14 @@ export function PlayConfigDocScreen() {
<p className="text-sm text-muted-foreground">
{activeHead ? (
<>
线 v{activeHead.version_no}
Active version v{activeHead.version_no}
{activeHead.effective_at ? ` · ${activeHead.effective_at}` : ""}
</>
) : null}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400">
{activeHead ? " — " : ""}
稿
Limits and rules are read-only. Create a draft first.
</span>
) : null}
</p>
@@ -429,14 +424,14 @@ export function PlayConfigDocScreen() {
<div className="rounded-xl border bg-muted/20 p-3">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium"></p>
<p className="text-sm font-medium">Batch switches</p>
<p className="text-xs text-muted-foreground">
稿
Only updates the current draft. The player betting table refreshes after save and publish.
</p>
</div>
{!isDraft ? (
<span className="text-xs text-amber-600 dark:text-amber-400">
稿
Current version is read-only. Create a draft first.
</span>
) : null}
</div>
@@ -449,7 +444,7 @@ export function PlayConfigDocScreen() {
<div className="min-w-[92px]">
<p className="text-sm font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">
{group.total > 0 ? `${group.enabledCount}/${group.total} 启用` : "暂无玩法"}
{group.total > 0 ? `${group.enabledCount}/${group.total} enabled` : "No play types"}
</p>
</div>
<Button
@@ -459,7 +454,7 @@ export function PlayConfigDocScreen() {
disabled={!isDraft || saving || group.total === 0}
onClick={() => applyBatchSwitch(group, !group.allEnabled)}
>
{group.allEnabled ? "关闭" : "开启"}
{group.allEnabled ? "Disable" : "Enable"}
</Button>
</div>
))}
@@ -470,20 +465,20 @@ export function PlayConfigDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[88px] text-center"></TableHead>
<TableHead className="min-w-[120px] text-center"></TableHead>
<TableHead className="w-[120px] text-center"></TableHead>
<TableHead className="w-[110px] text-center"></TableHead>
<TableHead className="w-[110px] text-center"></TableHead>
<TableHead className="w-[140px] text-center"></TableHead>
<TableHead className="text-center">Play Code</TableHead>
<TableHead className="w-[100px] text-center">Category</TableHead>
<TableHead className="w-[88px] text-center">Status</TableHead>
<TableHead className="min-w-[120px] text-center">Display Name</TableHead>
<TableHead className="w-[120px] text-center">Order</TableHead>
<TableHead className="w-[110px] text-center">Min Bet</TableHead>
<TableHead className="w-[110px] text-center">Max Bet</TableHead>
<TableHead className="w-[140px] text-center">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -499,11 +494,11 @@ export function PlayConfigDocScreen() {
onCheckedChange={(v) => {
updateConfigRow(row.play_code, { is_enabled: v === true });
}}
aria-label={`启用 ${row.play_code}`}
aria-label={`Enable ${row.play_code}`}
/>
) : (
<ConfigReadonlyValue className="justify-center">
{row.is_enabled ? "启用" : "停用"}
{row.is_enabled ? "Enabled" : "Disabled"}
</ConfigReadonlyValue>
)}
</TableCell>
@@ -593,10 +588,10 @@ export function PlayConfigDocScreen() {
disabled={saving}
onClick={() => openRuleEditor(row.play_code)}
>
Rule Text
</Button>
) : (
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">Read only</span>
)}
</TableCell>
</TableRow>
@@ -610,9 +605,9 @@ export function PlayConfigDocScreen() {
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
<DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>Rule Text (Chinese)</DialogTitle>
<DialogDescription>
{rulePlayCode ?? "—"}稿稿
Play {rulePlayCode ?? "—"}; changes are only stored in the draft until you save and publish it.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
@@ -626,10 +621,10 @@ export function PlayConfigDocScreen() {
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRuleDialogOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={saveRuleZh}>
稿
Apply to Draft
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,4 +1,4 @@
/** 奖项档位顺序(含 starter / consolation)。 */
/** Prize scope order, including starter and consolation. */
export const PRIZE_SCOPE_ORDER = [
"first",
@@ -11,14 +11,14 @@ export const PRIZE_SCOPE_ORDER = [
export type PrizeScopeCode = (typeof PRIZE_SCOPE_ORDER)[number];
export const PRIZE_SCOPE_LABELS: Record<PrizeScopeCode, string> = {
first: "头奖赔率",
second: "二奖赔率",
third: "三奖赔率",
starter: "特别奖赔率",
consolation: "安慰奖赔率",
first: "First Prize Odds",
second: "Second Prize Odds",
third: "Third Prize Odds",
starter: "Starter Prize Odds",
consolation: "Consolation Prize Odds",
};
/** 文档示意:特别奖 / 安慰奖按组数展示时的倍数提示(仅文案)。 */
/** Display-only multiplier hints for starter and consolation grouped prizes. */
export const PRIZE_SCOPE_MULTIPLIER_HINT: Partial<Record<PrizeScopeCode, string>> = {
starter: "× 10",
consolation: "× 10",

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -47,6 +48,7 @@ function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPl
}
export function RebateConfigDocScreen() {
const { t } = useTranslation(["config", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
@@ -67,20 +69,20 @@ export function RebateConfigDocScreen() {
const d = await getAdminPlayTypes();
setTypes(d.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载玩法失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setTypes([]);
}
}, []);
}, [t]);
const refreshList = useCallback(async () => {
try {
const d = await getAllConfigVersions(getOddsVersions);
setListRows(d.items);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setListRows([]);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(async () => {
@@ -105,13 +107,13 @@ export function RebateConfigDocScreen() {
setP3(inferPercentFrom(3, rows, typeList));
setP4(inferPercentFrom(4, rows, typeList));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载明细失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setDetail(null);
setDraftRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
}, [t]);
useEffect(() => {
if (listRows.length === 0 || selectedId !== "") {
@@ -194,10 +196,10 @@ export function RebateConfigDocScreen() {
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
toast.success("已保存草稿");
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
@@ -216,11 +218,11 @@ export function RebateConfigDocScreen() {
setP2(inferPercentFrom(2, rows, types));
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
toast.success("已发布赔率版本(含回水)");
toast.success("Published odds version with rebate");
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
} finally {
setSaving(false);
}
@@ -234,7 +236,7 @@ export function RebateConfigDocScreen() {
reason: `rebate draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
toast.success(`Created draft v${d.version_no}`);
await refreshList();
setSelectedId(String(d.id));
const rows = d.items.map((it) => ({ ...it }));
@@ -244,7 +246,7 @@ export function RebateConfigDocScreen() {
setP3(inferPercentFrom(3, rows, types));
setP4(inferPercentFrom(4, rows, types));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
} finally {
setSaving(false);
}
@@ -255,10 +257,10 @@ export function RebateConfigDocScreen() {
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteOddsVersion(row.id);
toast.success("已删除该版本");
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
throw e;
}
}
@@ -266,7 +268,7 @@ export function RebateConfigDocScreen() {
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"> / </CardTitle>
<CardTitle className="text-lg">{t("nav.items.rebate", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-wrap items-center gap-3">
@@ -275,8 +277,8 @@ export function RebateConfigDocScreen() {
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loading}
sheetTitle="回水配置版本"
sheetDescription="回水写入赔率版本草稿;选择与赔率配置共用同一套版本。"
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."
onDeleteVersion={handleDeleteVersion}
className="w-auto min-w-0"
/>
@@ -286,7 +288,7 @@ export function RebateConfigDocScreen() {
loadingList={loading}
loadingDetail={loadingDetail}
saving={saving}
publishLabel="发布生效"
publishLabel="Publish"
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
@@ -295,9 +297,9 @@ export function RebateConfigDocScreen() {
{detail ? (
<p className="text-sm text-muted-foreground">
v{detail.version_no} · {detail.status === "draft" ? "草稿" : detail.status === "active" ? "生效中" : "已归档"}
Editing version v{detail.version_no} · {detail.status === "draft" ? "Draft" : detail.status === "active" ? "Active" : "Archived"}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
<span className="text-amber-600 dark:text-amber-400"> - Create a draft before editing rebate.</span>
) : null}
</p>
) : null}
@@ -305,7 +307,7 @@ export function RebateConfigDocScreen() {
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2">
<Label>2D %</Label>
<Label>2D Rebate Rate (%)</Label>
{isDraft ? (
<Input
type="number"
@@ -321,7 +323,7 @@ export function RebateConfigDocScreen() {
)}
</div>
<div className="grid gap-2">
<Label>3D %</Label>
<Label>3D Rebate Rate (%)</Label>
{isDraft ? (
<Input
type="number"
@@ -337,7 +339,7 @@ export function RebateConfigDocScreen() {
)}
</div>
<div className="grid gap-2">
<Label>4D %</Label>
<Label>4D Rebate Rate (%)</Label>
{isDraft ? (
<Input
type="number"
@@ -355,26 +357,26 @@ export function RebateConfigDocScreen() {
</div>
<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="中奖是否享受回水" />
<Checkbox id="win-enjoy" checked aria-disabled disabled aria-label="Apply rebate on winning tickets" />
<div className="grid gap-1">
<Label htmlFor="win-enjoy" className="font-medium leading-snug">
Apply rebate on winning tickets
</Label>
<p className="text-sm text-muted-foreground">
/
Placeholder field. It can later be aligned with risk and settlement rules and persisted.
</p>
</div>
</div>
<div className="grid gap-1 text-sm">
<span className="text-muted-foreground">线</span>
<span className="text-muted-foreground">Effective Time (current active odds version)</span>
<span className="font-mono text-sm">
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
</span>
</div>
{loading || loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
</CardContent>
</Card>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -70,6 +71,7 @@ function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
}
export function RiskCapDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
@@ -92,13 +94,13 @@ export function RiskCapDocScreen() {
const d = await getAllConfigVersions(getRiskCapVersions);
setList(d.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载版本列表失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(() => {
@@ -130,14 +132,14 @@ export function RiskCapDocScreen() {
setDraftRows(mapped);
syncDefaultCapFromRows(mapped);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载版本明细失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setDetail(null);
setDraftRows([]);
syncDefaultCapFromRows([]);
} finally {
setLoadingDetail(false);
}
}, []);
}, [t]);
useEffect(() => {
if (list.length === 0 || selectedId !== "") {
@@ -187,19 +189,19 @@ export function RiskCapDocScreen() {
return;
}
if (draftRows.length === 0) {
toast.error("至少保留一行封顶配置");
toast.error("At least one cap row is required");
return;
}
for (const r of draftRows) {
if (isDefaultRiskRow(r)) {
if (r.cap_amount <= 0) {
toast.error("默认封顶金额必须大于 0");
toast.error("Default cap amount must be greater than 0");
return;
}
continue;
}
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
toast.error(`号码须为 4 位数字:${r.normalized_number}`);
toast.error(`Number must be 4 digits: ${r.normalized_number}`);
return;
}
}
@@ -222,10 +224,10 @@ export function RiskCapDocScreen() {
}));
setDraftRows(saved);
syncDefaultCapFromRows(saved);
toast.success("已保存草稿");
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("wallet.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
@@ -248,11 +250,11 @@ export function RiskCapDocScreen() {
}));
setDraftRows(pub);
syncDefaultCapFromRows(pub);
toast.success("已启用为当前版本");
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "发布失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
} finally {
setSaving(false);
}
@@ -266,7 +268,7 @@ export function RiskCapDocScreen() {
reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(`已创建草稿 v${d.version_no}`);
toast.success(`Created draft v${d.version_no}`);
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
@@ -280,7 +282,7 @@ export function RiskCapDocScreen() {
setDraftRows(nd);
syncDefaultCapFromRows(nd);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建草稿失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
} finally {
setSaving(false);
}
@@ -289,7 +291,7 @@ export function RiskCapDocScreen() {
function applyDefaultCap() {
const n = Number.parseInt(defaultCapStr, 10);
if (!Number.isFinite(n) || n <= 0) {
toast.error("请输入有效的封顶金额");
toast.error("Enter a valid cap amount");
return;
}
setDraftRows((prev) => {
@@ -297,7 +299,7 @@ export function RiskCapDocScreen() {
return [defaultRiskRowFromAmount(n), ...next];
});
setSyncOpen(false);
toast.message("已写入本地草稿,记得保存草稿");
toast.message("Saved into local draft. Save the draft to persist it.");
}
const occFiltered = useMemo(() => {
@@ -316,10 +318,10 @@ export function RiskCapDocScreen() {
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deleteRiskCapVersion(row.id);
toast.success("已删除该版本");
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "删除失败");
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
throw e;
}
}
@@ -328,11 +330,11 @@ export function RiskCapDocScreen() {
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg">
{t("nav.items.risk-cap", { ns: "config" })}
{detail ? (
<span className="text-muted-foreground font-normal">
{" "}
· v{detail.version_no}
· v{detail.version_no}
</span>
) : null}
</CardTitle>
@@ -344,7 +346,7 @@ export function RiskCapDocScreen() {
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle="风控封顶版本"
sheetTitle={`${t("nav.items.risk-cap", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion}
className="w-auto min-w-0"
/>
@@ -362,9 +364,9 @@ export function RiskCapDocScreen() {
{detail ? (
<p className="text-sm text-muted-foreground">
{detail.effective_at ? formatDt(detail.effective_at) : "—"} · {detail.reason ?? "—"}
Effective at: {detail.effective_at ? formatDt(detail.effective_at) : "—"} · Note: {detail.reason ?? "—"}
{!isDraft ? (
<span className="text-amber-600 dark:text-amber-400"> 稿</span>
<span className="text-amber-600 dark:text-amber-400"> - Read only. Create a draft first.</span>
) : null}
</p>
) : null}
@@ -373,13 +375,13 @@ export function RiskCapDocScreen() {
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<section className="space-y-3 rounded-lg border bg-muted/20 p-4">
<h3 className="text-sm font-medium"></h3>
<h3 className="text-sm font-medium">Default Cap</h3>
<p className="text-sm text-muted-foreground">
使
Numbers without a special cap use this default cap template.
</p>
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1">
<Label htmlFor="default-cap"></Label>
<Label htmlFor="default-cap">Cap Amount (minor unit)</Label>
{isDraft ? (
<Input
id="default-cap"
@@ -398,7 +400,7 @@ export function RiskCapDocScreen() {
</div>
{isDraft ? (
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
Update
</Button>
) : null}
</div>
@@ -406,7 +408,7 @@ export function RiskCapDocScreen() {
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium"></h3>
<h3 className="text-sm font-medium">Special Caps</h3>
{isDraft ? (
<Button
type="button"
@@ -414,25 +416,25 @@ export function RiskCapDocScreen() {
disabled={saving}
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
>
+
+ Add Special Cap
</Button>
) : null}
</div>
{loadingDetail ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">Loading details</p>
) : specialRows.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">No detail rows.</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[90px] text-right"></TableHead>
<TableHead className="w-[72px] text-center"></TableHead>
<TableHead className="w-[160px]"></TableHead>
<TableHead className="w-[110px]">Number</TableHead>
<TableHead className="w-[140px]">Cap Amount</TableHead>
<TableHead className="w-[90px] text-right">Used</TableHead>
<TableHead className="w-[90px] text-right">Remaining</TableHead>
<TableHead className="w-[72px] text-center">Sold Out</TableHead>
<TableHead className="w-[160px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -485,10 +487,10 @@ export function RiskCapDocScreen() {
disabled={saving}
onClick={() => removeRow(idx)}
>
{t("actions.delete", { ns: "adminUsers" })}
</Button>
) : (
<span className="text-sm text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">Read only</span>
)}
</TableCell>
</TableRow>
@@ -500,42 +502,42 @@ export function RiskCapDocScreen() {
</section>
<section className="space-y-3">
<h3 className="text-sm font-medium"></h3>
<h3 className="text-sm font-medium">All Number Occupancy</h3>
<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.
</p>
<div className="flex flex-wrap gap-3 items-end">
<div className="grid gap-1">
<Label htmlFor="occ-search"></Label>
<Label htmlFor="occ-search">Search Number</Label>
<Input
id="occ-search"
className="w-[140px] font-mono"
placeholder=" 8888"
placeholder="e.g. 8888"
value={occSearch}
onChange={(e) => setOccSearch(e.target.value)}
/>
</div>
<Button type="button" variant="outline" onClick={() => toast.message("售罄 / 高风险筛选待接入")}>
<Button type="button" variant="outline" onClick={() => toast.message("Sold-out / high-risk preset filter is pending integration")}>
Filter Presets
</Button>
<Button
type="button"
variant="outline"
onClick={() => toast.message("导出 CSV 待接入")}
onClick={() => toast.message("CSV export is pending integration")}
>
CSV
Export CSV
</Button>
</div>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead>Number</TableHead>
<TableHead className="text-right">Used</TableHead>
<TableHead className="text-right">Remaining</TableHead>
<TableHead className="text-right">Ratio</TableHead>
<TableHead className="text-center">Sold Out</TableHead>
<TableHead className="w-[140px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -548,7 +550,7 @@ export function RiskCapDocScreen() {
<TableCell className="text-center text-muted-foreground"></TableCell>
<TableCell>
<Button type="button" variant="ghost" disabled>
Close
</Button>
</TableCell>
</TableRow>
@@ -562,17 +564,17 @@ export function RiskCapDocScreen() {
<Dialog open={syncOpen} onOpenChange={setSyncOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>Sync Default Cap</DialogTitle>
<DialogDescription>
{defaultCapStr || "(空)"}稿稿
The default cap template will be set to {defaultCapStr || "(empty)"}. This only changes the draft. Save and publish after confirming.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSyncOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={applyDefaultCap}>
Confirm
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -42,6 +43,7 @@ interface Draft {
}
export function WalletConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers"]);
const [draft, setDraft] = useState<Draft>({
inMin: "",
inMax: "",
@@ -71,11 +73,11 @@ export function WalletConfigDocScreen() {
setSaved(d);
setDirty(false);
} catch {
toast.error("加载失败");
toast.error(t("wallet.loadFailed", { ns: "config" }));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(() => {
@@ -95,11 +97,13 @@ export function WalletConfigDocScreen() {
await updateAdminSetting(KEYS.IN_MAX, displayToMinorUnits(draft.inMax));
await updateAdminSetting(KEYS.OUT_MIN, displayToMinorUnits(draft.outMin));
await updateAdminSetting(KEYS.OUT_MAX, displayToMinorUnits(draft.outMax));
toast.success("保存成功");
toast.success(t("wallet.saveSuccess", { ns: "config" }));
setSaved(draft);
setDirty(false);
} catch (error) {
toast.error(error instanceof LotteryApiBizError ? error.message : "保存失败");
toast.error(
error instanceof LotteryApiBizError ? error.message : t("wallet.saveFailed", { ns: "config" }),
);
} finally {
setSaving(false);
}
@@ -108,81 +112,81 @@ export function WalletConfigDocScreen() {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("wallet.title", { ns: "config" })}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<p className="text-sm text-muted-foreground">
NPR 100 = 1.00 NPR 1
{t("wallet.description", { ns: "config" })}
</p>
<div className="grid gap-6 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="in-min"></Label>
<Label htmlFor="in-min">{t("wallet.fields.inMin", { ns: "config" })}</Label>
<Input
id="in-min"
type="number"
min="0"
step="0.01"
placeholder="例如 1.00"
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.inMin}
onChange={(e) => handleChange("inMin", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
{t("wallet.hints.inMin", { ns: "config" })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="in-max"></Label>
<Label htmlFor="in-max">{t("wallet.fields.inMax", { ns: "config" })}</Label>
<Input
id="in-max"
type="number"
min="0"
step="0.01"
placeholder="例如 10000.00"
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.inMax}
onChange={(e) => handleChange("inMax", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
{t("wallet.hints.inMax", { ns: "config" })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="out-min"></Label>
<Label htmlFor="out-min">{t("wallet.fields.outMin", { ns: "config" })}</Label>
<Input
id="out-min"
type="number"
min="0"
step="0.01"
placeholder="例如 1.00"
placeholder={t("wallet.placeholders.min", { ns: "config" })}
value={draft.outMin}
onChange={(e) => handleChange("outMin", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
{t("wallet.hints.outMin", { ns: "config" })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="out-max"></Label>
<Label htmlFor="out-max">{t("wallet.fields.outMax", { ns: "config" })}</Label>
<Input
id="out-max"
type="number"
min="0"
step="0.01"
placeholder="例如 10000.00"
placeholder={t("wallet.placeholders.max", { ns: "config" })}
value={draft.outMax}
onChange={(e) => handleChange("outMax", e.target.value)}
disabled={loading || saving}
/>
<p className="text-xs text-muted-foreground">
{t("wallet.hints.outMax", { ns: "config" })}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
{saving ? "保存中…" : "保存"}
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
<Button
@@ -192,7 +196,7 @@ export function WalletConfigDocScreen() {
setDirty(false);
}}
>
{t("wallet.discard", { ns: "config" })}
</Button>
)}
</div>

View File

@@ -1,29 +1,29 @@
export const configHubMeta = {
title: "配置中心",
description: "统一管理玩法目录、赔率、回水和风险封顶,先草稿、后发布、再生效。",
title: "Configuration Center",
description: "Manage play catalogs, odds, rebates, and risk caps with draft, publish, and activation stages.",
} as const;
export const configPlayConfigMeta = {
title: "玩法配置",
description: "维护玩法开关、限额和规则文案,目录变更会直接影响下注入口。",
title: "Play Configuration",
description: "Manage play switches, limits, and rule text. Catalog changes directly affect betting entry points.",
} as const;
export const configOddsMeta = {
title: "赔率配置",
description: "维护赔率、返水和佣金,发布前请重点核对数值范围与币种。",
title: "Odds Configuration",
description: "Manage odds, rebates, and commissions. Verify ranges and currency before publishing.",
} as const;
export const configRebateMeta = {
title: "佣金 / 回水",
description: "从赔率草稿中批量调整回水比例,适合按玩法维度统一修正。",
title: "Commission / Rebate",
description: "Batch-adjust rebate rates from the odds draft, suitable for dimension-wide updates.",
} as const;
export const configRiskCapMeta = {
title: "风控封顶",
description: "管理号码封顶版本和风险池阈值,发布前先确认号码与期号。",
title: "Risk Caps",
description: "Manage number cap versions and risk pool thresholds. Confirm number scope and draw before publishing.",
} as const;
export const configWalletMeta = {
title: "钱包配置",
description: "维护钱包相关阈值与转账策略。",
title: "Wallet Configuration",
description: "Manage wallet thresholds and transfer policies.",
} as const;

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { useTranslation } from "react-i18next";
import {
AlertTriangle,
ClipboardList,
@@ -30,7 +31,7 @@ import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
type HotPlayTab = "4D" | "3D" | "2D" | "特别";
type HotPlayTab = "4D" | "3D" | "2D" | "special";
type SoldOutBuckets = {
d4: number;
@@ -66,7 +67,7 @@ function formatSignedMoneyMinor(minor: number, currencyCode: string | null): str
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
}
/** 与后端 {@see AdminDashboardSnapshotBuilder::soldOutBucketKey} 维度对齐 */
/** Aligned with the bucket dimensions used by AdminDashboardSnapshotBuilder::soldOutBucketKey. */
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
const raw = normalizedNumber.trim();
const digits = raw.replace(/\D/g, "");
@@ -74,7 +75,7 @@ function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
const hasLetter = /[A-Za-z]/.test(raw);
if (hasLetter && digitLen < 3) {
return "特别";
return "special";
}
if (digitLen >= 4) {
return "4D";
@@ -86,7 +87,7 @@ function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
return "2D";
}
if (hasLetter) {
return "特别";
return "special";
}
return "other";
}
@@ -99,6 +100,7 @@ function topPoolsForTab(pools: AdminRiskPoolRow[], tab: HotPlayTab): AdminRiskPo
}
function RiskSemiGauge({ pct }: { pct: number }): ReactElement {
const { t } = useTranslation("dashboard");
const v = Math.min(100, Math.max(0, pct));
const r = 76;
const arcLen = Math.PI * r;
@@ -126,13 +128,14 @@ function RiskSemiGauge({ pct }: { pct: number }): ReactElement {
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-end pb-1 text-center">
<p className="text-lg font-bold tabular-nums text-[#1a365d]">{v.toFixed(2)}%</p>
<p className="text-[11px] leading-tight text-muted-foreground"></p>
<p className="text-[11px] leading-tight text-muted-foreground">{t("capUsage")}</p>
</div>
</div>
);
}
function HotBarChart({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
const { t } = useTranslation("dashboard");
const maxU = Math.max(0.0001, ...rows.map((r) => r.usage_ratio ?? 0));
return (
@@ -142,10 +145,10 @@ function HotBarChart({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
className="pointer-events-none absolute bottom-6 left-0 top-2 w-6 rotate-180 text-[10px] leading-tight text-muted-foreground [writing-mode:vertical-rl]"
aria-hidden
>
{t("capUsage")}
</span>
{rows.length === 0 ? (
<p className="w-full pb-6 text-center text-sm text-muted-foreground"></p>
<p className="w-full pb-6 text-center text-sm text-muted-foreground">{t("noPoolData")}</p>
) : (
rows.map((row) => {
const u = row.usage_ratio ?? 0;
@@ -170,25 +173,26 @@ function HotBarChart({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
})
)}
</div>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground"></p>
<p className="mt-1.5 text-center text-[11px] text-muted-foreground">{t("numbersByUsage")}</p>
</div>
);
}
function SoldOutDonut({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
const { t } = useTranslation("dashboard");
const entries: { key: keyof SoldOutBuckets; label: string; color: string }[] = [
{ key: "d4", label: "4D", color: "oklch(0.32 0.08 260)" },
{ key: "d3", label: "3D", color: "oklch(0.48 0.12 250)" },
{ key: "d2", label: "2D", color: "oklch(0.78 0.14 95)" },
{ key: "special", label: "特别号", color: "oklch(0.55 0.22 25)" },
{ key: "other", label: "其他", color: "oklch(0.62 0.16 145)" },
{ key: "d4", label: t("soldOutBuckets.d4"), color: "oklch(0.32 0.08 260)" },
{ key: "d3", label: t("soldOutBuckets.d3"), color: "oklch(0.48 0.12 250)" },
{ key: "d2", label: t("soldOutBuckets.d2"), color: "oklch(0.78 0.14 95)" },
{ key: "special", label: t("soldOutBuckets.special"), color: "oklch(0.55 0.22 25)" },
{ key: "other", label: t("soldOutBuckets.other"), color: "oklch(0.62 0.16 145)" },
];
const total = entries.reduce((s, e) => s + buckets[e.key], 0);
if (total === 0) {
return (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<p></p>
<p>{t("noSoldOutNumbers")}</p>
</div>
);
}
@@ -227,7 +231,7 @@ function SoldOutDonut({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
/>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<p className="text-2xl font-bold tabular-nums text-[#1a365d]">{total}</p>
<p className="text-[11px] text-muted-foreground"></p>
<p className="text-[11px] text-muted-foreground">{t("soldOutTotal")}</p>
</div>
</div>
<ul className="min-w-0 flex-1 space-y-2 text-sm">
@@ -246,6 +250,7 @@ function SoldOutDonut({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
}
export function DashboardConsole(): ReactElement {
const { t } = useTranslation(["dashboard", "common"]);
const [todayLabel] = useState(() => format(new Date(), "yyyy-MM-dd EEEE", { locale: zhCN }));
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -304,21 +309,21 @@ export function DashboardConsole(): ReactElement {
const noticeParts: string[] = d.warnings.map((w) => w.message);
if (d.resolved_draw != null && !d.capabilities.draw_finance_risk) {
noticeParts.push("当前账号无开奖查看/管理权限,财务与风控数据未返回。");
noticeParts.push(t("warnings.drawPermission"));
}
if (d.hall != null && !d.capabilities.wallet_transfer_view) {
noticeParts.push("当前账号无钱包对账查看权限,异常转账计数未返回。");
noticeParts.push(t("warnings.walletPermission"));
}
setNotice(noticeParts.length > 0 ? noticeParts.join(" ") : null);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载失败,请检查 API 与登录状态。";
e instanceof LotteryApiBizError ? e.message : t("warnings.loadFailed");
setError(msg);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
}, [t]);
useEffect(() => {
const t = window.setTimeout(() => {
@@ -335,28 +340,27 @@ export function DashboardConsole(): ReactElement {
const hallStatusLabel = hall?.status ?? "—";
const isOpenLike =
hallStatusLabel.toLowerCase().includes("open") ||
hallStatusLabel.includes("开售") ||
hallStatusLabel.includes("开放");
hallStatusLabel.toLowerCase().includes("sale");
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
{ href: "/admin/draws", label: "创建期计划", icon: <Diamond className="size-5" /> },
{ href: "/admin/draws", label: "开售 / 期号", icon: <Ticket className="size-5" /> },
{ href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: <Diamond className="size-5" /> },
{ href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: <Ticket className="size-5" /> },
{
href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws",
label: "开奖结果",
label: t("quickLinks.results"),
icon: <FileSearch className="size-5" />,
},
{ href: "/admin/tickets", label: "注单管理", icon: <Shield className="size-5" /> },
{ href: "/admin/wallet/transactions", label: "钱包流水", icon: <Wallet className="size-5" /> },
{ href: "/admin/reports", label: "报表中心", icon: <FileSpreadsheet className="size-5" /> },
{ href: "/admin/audit-logs", label: "审计日志", icon: <ScrollText className="size-5" /> },
{ href: "/admin/tickets", label: t("quickLinks.tickets"), icon: <Shield className="size-5" /> },
{ href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: <Wallet className="size-5" /> },
{ href: "/admin/reports", label: t("quickLinks.reports"), icon: <FileSpreadsheet className="size-5" /> },
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-5" /> },
];
return (
<div className="space-y-6 pb-10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-[#1a365d]"></h1>
<h1 className="text-2xl font-bold tracking-tight text-[#1a365d]">{t("title")}</h1>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{todayLabel}</span>
@@ -369,26 +373,26 @@ export function DashboardConsole(): ReactElement {
onClick={() => void load(true)}
>
<RefreshCw className={refreshing ? "size-4 animate-spin" : "size-4"} />
{t("refresh")}
</Button>
</div>
</div>
{error ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle></AlertTitle>
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{notice && !error ? (
<Alert className="border-sky-200 bg-sky-50 dark:border-sky-900/50 dark:bg-sky-950/30">
<AlertTitle></AlertTitle>
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{notice}</AlertDescription>
</Alert>
) : null}
{/* Row 1 — 核心财务 KPI */}
{/* Row 1 - Core finance KPI */}
<div className="grid gap-4 md:grid-cols-3">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
@@ -407,13 +411,13 @@ export function DashboardConsole(): ReactElement {
<Wallet className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("todayBetTotal")}</p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
</p>
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
{t("currentDrawFinanceSummary")}
</p>
</div>
</div>
@@ -424,13 +428,13 @@ export function DashboardConsole(): ReactElement {
<Gift className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("currentPayout")}</p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance ? formatMoneyMinor(finance.total_payout_minor, currency) : "—"}
</p>
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
+ Jackpot
{t("payoutSummary")}
</p>
</div>
</div>
@@ -441,13 +445,13 @@ export function DashboardConsole(): ReactElement {
<TrendingUp className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("currentProfit")}</p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance ? formatSignedMoneyMinor(finance.approx_house_gross_minor, currency) : "—"}
</p>
<p className="mt-2 flex items-center gap-1 text-xs text-emerald-600">
<TrendingUp className="size-3.5 shrink-0" aria-hidden />
{t("profitFormula")}
</p>
</div>
</div>
@@ -456,7 +460,7 @@ export function DashboardConsole(): ReactElement {
)}
</div>
{/* Row 2 — 期号 / 投注 / 风险表 */}
{/* Row 2 - Draw / betting / risk */}
<div className="grid gap-4 md:grid-cols-3">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
@@ -472,10 +476,10 @@ export function DashboardConsole(): ReactElement {
<Ticket className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("currentDraw")}</p>
<p className="mt-1 font-mono text-2xl font-bold text-[#c41e3a]">{hall?.draw_no ?? "—"}</p>
<p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span> {hall?.sequence_no ?? "—"} </span>
<span>{t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}</span>
<span className="hidden sm:inline">·</span>
<span className="inline-flex items-center gap-1.5">
<span
@@ -495,7 +499,7 @@ export function DashboardConsole(): ReactElement {
)}
href={`/admin/draws/${drawId}`}
>
{t("drawDetails")}
</Link>
) : null}
</div>
@@ -507,12 +511,12 @@ export function DashboardConsole(): ReactElement {
<Wallet className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("ticketCount")}</p>
<p className="mt-1 text-2xl font-bold tabular-nums text-[#1a365d]">
{finance != null ? finance.ticket_item_count.toLocaleString("zh-CN") : "—"}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{" "}
{t("relatedBetAmount")}{" "}
<span className="font-medium text-foreground">
{finance ? formatMoneyMinor(finance.total_bet_minor, currency) : "—"}
</span>
@@ -526,10 +530,12 @@ export function DashboardConsole(): ReactElement {
<Shield className="size-5" aria-hidden />
</div>
<div className="min-w-0 flex-1 text-center sm:text-left">
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("riskCapUsage")}</p>
<p className="mt-1 text-xs tabular-nums text-muted-foreground">
{formatMoneyMinor(riskLocked, currency)} / {" "}
{formatMoneyMinor(riskCap, currency)}
{t("lockedAndCap", {
locked: formatMoneyMinor(riskLocked, currency),
cap: formatMoneyMinor(riskCap, currency),
})}
</p>
<div className="mt-2">
<RiskSemiGauge pct={usagePct} />
@@ -542,7 +548,7 @@ export function DashboardConsole(): ReactElement {
)}
href={`/admin/risk/draws/${drawId}/occupancy`}
>
{t("occupancyDetails")}
</Link>
) : null}
</div>
@@ -552,30 +558,35 @@ export function DashboardConsole(): ReactElement {
)}
</div>
{/* Row 3 — 图表 */}
{/* Row 3 - Charts */}
<div className="grid gap-4 lg:grid-cols-2">
<Card className="border-slate-200/90 shadow-sm">
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-2 space-y-0 pb-2">
<div>
<CardTitle className="text-base font-semibold text-[#1a365d]"> Top 10</CardTitle>
<CardTitle className="text-base font-semibold text-[#1a365d]">{t("hotNumbersTop10")}</CardTitle>
</div>
<div className="flex items-center gap-3">
<div role="tablist" aria-label="玩法维度" className="flex gap-1 border-b border-transparent">
{(["4D", "3D", "2D", "特别"] as const).map((tab) => (
<div role="tablist" aria-label={t("playDimension")} className="flex gap-1 border-b border-transparent">
{([
{ value: "4D", label: t("tabs.4d") },
{ value: "3D", label: t("tabs.3d") },
{ value: "2D", label: t("tabs.2d") },
{ value: "special", label: t("tabs.special") },
] as const).map((tab) => (
<button
key={tab}
key={tab.value}
type="button"
role="tab"
aria-selected={hotTab === tab}
aria-selected={hotTab === tab.value}
className={cn(
"-mb-px border-b-2 px-2.5 py-1 text-sm font-medium transition-colors",
hotTab === tab
hotTab === tab.value
? "border-[#c41e3a] text-[#c41e3a]"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
onClick={() => setHotTab(tab)}
onClick={() => setHotTab(tab.value)}
>
{tab}
{tab.label}
</button>
))}
</div>
@@ -587,7 +598,7 @@ export function DashboardConsole(): ReactElement {
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
)}
>
{t("actions.viewAll", { ns: "common" })}
</Link>
) : null}
</div>
@@ -604,7 +615,7 @@ export function DashboardConsole(): ReactElement {
<Card className="border-slate-200/90 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle className="text-base font-semibold text-[#1a365d]"></CardTitle>
<CardTitle className="text-base font-semibold text-[#1a365d]">{t("soldOutDistribution")}</CardTitle>
</div>
{drawId != null ? (
<Link
@@ -614,7 +625,7 @@ export function DashboardConsole(): ReactElement {
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
)}
>
{t("actions.viewAll", { ns: "common" })}
</Link>
) : null}
</CardHeader>
@@ -624,13 +635,13 @@ export function DashboardConsole(): ReactElement {
) : soldOutBuckets ? (
<SoldOutDonut buckets={soldOutBuckets} />
) : (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
</div>
{/* Row 4 — 待办 */}
{/* Row 4 - To-do */}
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col justify-between gap-4 rounded-xl border border-slate-200/90 bg-white p-5 shadow-sm sm:flex-row sm:items-center">
<div className="flex gap-4">
@@ -638,7 +649,7 @@ export function DashboardConsole(): ReactElement {
<ClipboardList className="size-5" aria-hidden />
</div>
<div>
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("pendingReviewResults")}</p>
<p className="mt-1 text-4xl font-bold tabular-nums text-[#c41e3a]">
{pendingReview ?? "—"}
</p>
@@ -652,7 +663,7 @@ export function DashboardConsole(): ReactElement {
"shrink-0 border-[#c41e3a] text-[#c41e3a] hover:bg-[#c41e3a]/5",
)}
>
{t("actions.reviewNow", { ns: "common" })}
</Link>
) : null}
</div>
@@ -662,7 +673,7 @@ export function DashboardConsole(): ReactElement {
<AlertTriangle className="size-5" aria-hidden />
</div>
<div>
<p className="text-sm font-medium text-slate-600"></p>
<p className="text-sm font-medium text-slate-600">{t("abnormalTransferOrders")}</p>
<p className="mt-1 text-4xl font-bold tabular-nums text-[#c41e3a]">
{abnormalTransferTotal ?? "—"}
</p>
@@ -675,12 +686,12 @@ export function DashboardConsole(): ReactElement {
"shrink-0 border-[#c41e3a] text-[#c41e3a] hover:bg-[#c41e3a]/5",
)}
>
{t("viewTransferOrders")}
</Link>
</div>
</div>
{/* Row 5 — 快捷入口 */}
{/* Row 5 - Quick links */}
<Card className="border-slate-200/90 shadow-sm">
<CardContent className="flex flex-wrap justify-center gap-3 py-6 sm:gap-6">
{quickLinks.map((q) => (

View File

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

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -37,6 +38,7 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
}
export function DrawDetailConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const idNum = Number(drawId);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
@@ -49,7 +51,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError("无效的期号 ID");
setError(t("invalidDrawId"));
setLoading(false);
return;
}
@@ -59,21 +61,21 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
setData(await getAdminDraw(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : "加载失败");
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum]);
}, [idNum, t]);
async function runAction(name: string, action: () => Promise<unknown>): Promise<void> {
if (!Number.isFinite(idNum)) return;
setActing(name);
try {
await action();
toast.success(`${name}成功`);
toast.success(t("actionSuccess", { name }));
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : `${name}失败`);
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name }));
} finally {
setActing(null);
}
@@ -87,11 +89,11 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
}, [load]);
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? "无数据"}</p>;
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
}
return (
@@ -101,46 +103,49 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="text-xl">{data.draw_no}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground"></p>
<p className="mt-1 text-sm text-muted-foreground">{t("drawDetail")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<DrawStatusBadge status={data.status} label={data.status} />
<DrawStatusBadge status={data.hall_preview_status} label={`大厅预览 ${data.hall_preview_status}`} />
<DrawStatusBadge
status={data.hall_preview_status}
label={t("hallPreviewStatus", { status: data.hall_preview_status })}
/>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-6 p-6 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="业务日">{data.business_date}</Field>
<Field label="流水序号">{data.sequence_no}</Field>
<Field label="开始时间">{formatDt(data.start_time)}</Field>
<Field label="封盘时间">{formatDt(data.close_time)}</Field>
<Field label="计划开奖">{formatDt(data.draw_time)}</Field>
<Field label="冷静期结束">{formatDt(data.cooling_end_time)}</Field>
<Field label={t("businessDate")}>{data.business_date}</Field>
<Field label={t("sequenceNo")}>{data.sequence_no}</Field>
<Field label={t("startTime")}>{formatDt(data.start_time)}</Field>
<Field label={t("closeTime")}>{formatDt(data.close_time)}</Field>
<Field label={t("plannedDraw")}>{formatDt(data.draw_time)}</Field>
<Field label={t("coolingEndTime")}>{formatDt(data.cooling_end_time)}</Field>
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<Field label="结果来源">{data.result_source ?? "—"}</Field>
<Field label="当前结果版本">{data.current_result_version}</Field>
<Field label="结算版本">{data.settle_version}</Field>
<Field label="是否重开">{data.is_reopened ? "是" : "否"}</Field>
<Field label={t("resultSource")}>{data.result_source ?? "—"}</Field>
<Field label={t("currentResultVersion")}>{data.current_result_version}</Field>
<Field label={t("settleVersion")}>{data.settle_version}</Field>
<Field label={t("isReopened")}>{data.is_reopened ? t("yes") : t("no")}</Field>
</div>
</div>
<div className="rounded-xl border bg-muted/20 p-4">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-sm font-medium text-muted-foreground">{t("batchStats")}</p>
<div className="mt-3 grid gap-3 text-sm">
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2">
<span></span>
<span>{t("batchTotal")}</span>
<span className="font-semibold">{data.result_batch_counts.total}</span>
</div>
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2 text-amber-600 dark:text-amber-400">
<span></span>
<span>{t("pendingReview")}</span>
<span className="font-semibold">{data.result_batch_counts.pending_review}</span>
</div>
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2 text-emerald-600 dark:text-emerald-400">
<span></span>
<span>{t("published")}</span>
<span className="font-semibold">{data.result_batch_counts.published}</span>
</div>
</div>
@@ -148,7 +153,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
href={`/admin/draws/${drawId}/finance`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "mt-4 w-full")}
>
{t("viewFinance")}
</Link>
</div>
</CardContent>
@@ -156,9 +161,9 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("drawActions")}</CardTitle>
<p className="text-sm text-muted-foreground">
/ / RNG / /
{t("drawActionsDesc")}
</p>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
@@ -166,42 +171,42 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
type="button"
variant="secondary"
disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)}
onClick={() => void runAction("手动封盘", () => postAdminManualCloseDraw(idNum))}
onClick={() => void runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum))}
>
{acting === "手动封盘" ? "处理中…" : "手动封盘"}
{acting === t("manualClose") ? t("processing") : t("manualClose")}
</Button>
<Button
type="button"
variant="outline"
disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)}
onClick={() => void runAction("取消期号", () => postAdminCancelDraw(idNum))}
onClick={() => void runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum))}
>
{acting === "取消期号" ? "处理中…" : "未开奖前取消"}
{acting === t("cancelDraw") ? t("processing") : t("cancelBeforeDraw")}
</Button>
<Button
type="button"
disabled={!canManageDraw || acting !== null || data.status !== "closed"}
onClick={() => void runAction("RNG开奖", () => postAdminRunDrawRng(idNum))}
onClick={() => void runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum))}
>
{acting === "RNG开奖" ? "生成中…" : "RNG 自动生成"}
{acting === t("rngDraw") ? t("generating") : t("rngAutoGenerate")}
</Button>
{isSuperAdmin ? (
<Button
type="button"
variant="destructive"
disabled={acting !== null || data.status !== "cooldown"}
onClick={() => void runAction("重开", () => postAdminReopenDraw(idNum))}
onClick={() => void runAction(t("reopen"), () => postAdminReopenDraw(idNum))}
>
{acting === "重开" ? "处理中…" : "冷静期重开"}
{acting === t("reopen") ? t("processing") : t("cooldownReopen")}
</Button>
) : null}
<Button
type="button"
variant="outline"
disabled={acting !== null || !["settling", "cooldown"].includes(data.status)}
onClick={() => void runAction("触发结算", () => postAdminRunDrawSettlement(idNum))}
onClick={() => void runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum))}
>
{acting === "触发结算" ? "处理中…" : "触发结算"}
{acting === t("runSettlement") ? t("processing") : t("runSettlement")}
</Button>
</CardContent>
</Card>

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
@@ -21,6 +22,7 @@ import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance
import { toast } from "sonner";
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation(["draws", "common"]);
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawFinanceSummaryData | null>(null);
const [err, setErr] = useState<string | null>(null);
@@ -29,7 +31,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
const load = useCallback(async () => {
if (!Number.isFinite(idNum) || idNum < 1) {
setErr("无效的期号 ID");
setErr(t("invalidDrawId"));
setLoading(false);
return;
}
@@ -38,22 +40,22 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
try {
setData(await getAdminDrawFinanceSummary(idNum));
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setData(null);
} finally {
setLoading(false);
}
}, [idNum]);
}, [idNum, t]);
async function runSettlement(): Promise<void> {
if (!Number.isFinite(idNum) || idNum < 1) return;
setSettling(true);
try {
const res = await postAdminRunDrawSettlement(idNum);
toast.success(res.ran ? "已触发结算" : "当前状态不可结算或已处理");
toast.success(res.ran ? t("runSettlement") : t("status"));
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "触发结算失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: t("runSettlement") }));
} finally {
setSettling(false);
}
@@ -66,44 +68,44 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
}, [load]);
if (loading && !data) {
return <p className="text-muted-foreground text-sm"></p>;
return <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>;
}
if (err || !data) {
return <p className="text-destructive text-sm">{err ?? "无数据"}</p>;
return <p className="text-destructive text-sm">{err ?? t("states.noData", { ns: "common" })}</p>;
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("financeOverview")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("drawNo")}</span>
<p className="font-mono font-semibold">{data.draw_no}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("status")}</span>
<p>{data.draw_status}</p>
</div>
<div>
<span className="text-muted-foreground"> / </span>
<span className="text-muted-foreground">{t("orderAndItemCount")}</span>
<p className="tabular-nums">
{data.order_count} / {data.ticket_item_count}
</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("actualBet")}</span>
<p className="tabular-nums font-medium">{data.total_bet_minor}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("currentPayout")}</span>
<p className="tabular-nums font-medium">{data.total_payout_minor}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("grossProfit")}</span>
<p
className={cn(
"tabular-nums font-semibold",
@@ -118,38 +120,38 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
{t("actions.refresh", { ns: "common" })}
</Button>
<Button type="button" size="sm" disabled={settling} onClick={() => void runSettlement()}>
{settling ? "处理中…" : "触发结算"}
{settling ? t("processing") : t("runSettlement")}
</Button>
<Link
href="/admin/settlement-batches"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("settlementBatchList")}
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("relatedSettlementBatches")}</CardTitle>
</CardHeader>
<CardContent>
{data.settlement_batches.length === 0 ? (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("noSettlementBatches")}</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">Jackpot</TableHead>
<TableHead></TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("ticketCount")}</TableHead>
<TableHead className="text-right">{t("winCount")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("jackpot")}</TableHead>
<TableHead>{t("finishedAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminDrawResultBatches, postAdminPublishResultBatch } from "@/api/admin-draws";
@@ -25,6 +26,7 @@ import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
const { t } = useTranslation(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
@@ -38,7 +40,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError("无效的期号 ID");
setError(t("invalidDrawId"));
setLoading(false);
return;
}
@@ -48,11 +50,11 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : "加载失败");
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum]);
}, [idNum, t]);
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -71,10 +73,10 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
setPublishing(true);
try {
const res = await postAdminPublishResultBatch(idNum, batchNum);
toast.success(`已发布 · ${res.draw_no} · 状态 ${res.status}`);
toast.success(t("publishSuccess", { drawNo: res.draw_no, status: res.status }));
await load();
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "发布失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("publishFailed");
toast.error(msg);
} finally {
setPublishing(false);
@@ -82,18 +84,18 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
}
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? "无数据"}</p>;
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
}
if (!batch) {
return (
<Alert variant="destructive">
<AlertTitle></AlertTitle>
<AlertDescription> batch id</AlertDescription>
<AlertTitle>{t("batchNotFound")}</AlertTitle>
<AlertDescription>{t("batchNotFoundDesc")}</AlertDescription>
</Alert>
);
}
@@ -105,31 +107,31 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Link href={`/admin/draws/${drawId}/review`} className={buttonVariants({ variant: "ghost", size: "sm" })}>
{t("backToReviewQueue")}
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("publishTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!canManageDraw ? (
<Alert variant="destructive">
<AlertTitle></AlertTitle>
<AlertDescription></AlertDescription>
<AlertTitle>{t("noPublishPermission")}</AlertTitle>
<AlertDescription>{t("noPublishPermission")}</AlertDescription>
</Alert>
) : null}
{!canPublish && canManageDraw ? (
<Alert>
<AlertTitle></AlertTitle>
<AlertDescription>{batch.status}</AlertDescription>
<AlertTitle>{t("cannotPublish")}</AlertTitle>
<AlertDescription>{t("cannotPublishDesc", { status: batch.status })}</AlertDescription>
</Alert>
) : null}
{canPublish ? (
<Alert>
<AlertTitle></AlertTitle>
<AlertDescription></AlertDescription>
<AlertTitle>{t("checkBeforePublish")}</AlertTitle>
<AlertDescription>{t("checkBeforePublishDesc")}</AlertDescription>
</Alert>
) : null}
@@ -137,7 +139,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>{t("prize")}</TableHead>
<TableHead>#</TableHead>
<TableHead className="font-mono">4D</TableHead>
</TableRow>
@@ -155,8 +157,11 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
</div>
<p className="font-mono text-xs text-muted-foreground">
{batch.source_type === "manual" ? "人工录入" : "RNG 自动生成"} ·
{batch.items.length}/23 · RNG {batch.rng_seed_hash ?? "—"}
{t("sourceTypeFull", {
source: batch.source_type === "manual" ? t("manualEntry") : t("rngAutoGenerate"),
count: batch.items.length,
hash: batch.rng_seed_hash ?? "—",
})}
</p>
</CardContent>
<CardFooter className="justify-end gap-2">
@@ -164,14 +169,14 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
href={`/admin/draws/${drawId}/results`}
className={cn(buttonVariants({ variant: "outline", size: "default" }))}
>
{t("publishedView")}
</Link>
<Button
type="button"
disabled={!canPublish || publishing}
onClick={() => void publish()}
>
{publishing ? "提交中…" : "确认发布"}
{publishing ? t("submitting") : t("confirmPublish")}
</Button>
</CardFooter>
</Card>

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { buttonVariants } from "@/components/ui/button";
@@ -24,6 +25,7 @@ import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
export function DrawResultsConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
@@ -35,7 +37,7 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError("无效的期号 ID");
setError(t("invalidDrawId"));
setLoading(false);
return;
}
@@ -45,11 +47,11 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : "加载失败");
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum]);
}, [idNum, t]);
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -59,11 +61,11 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
}, [load]);
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? "无数据"}</p>;
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
}
const published = data.batches.filter((b) => b.status === "published");
@@ -72,23 +74,23 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-semibold"></h2>
<h2 className="text-lg font-semibold">{t("resultsTitle")}</h2>
<p className="text-sm text-muted-foreground">
{data.draw_no} · <DrawStatusBadge status={data.draw_status} />
{t("drawNo")} {data.draw_no} · <DrawStatusBadge status={data.draw_status} />
</p>
</div>
<Link
href={`/admin/draws/${drawId}/review`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{canManageDraw ? "去审核 / 发布" : "查看审核队列"}
{canManageDraw ? t("reviewAndPublish") : t("viewReviewQueue")}
</Link>
</div>
{published.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t("noPublishedBatch")}
</CardContent>
</Card>
) : (
@@ -99,24 +101,29 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
}
function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
const { t } = useTranslation("draws");
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base"> v{batch.result_version}</CardTitle>
<CardTitle className="text-base">{t("version", { version: batch.result_version })}</CardTitle>
<p className="font-mono text-xs text-muted-foreground">
{batch.source_type === "manual" ? "人工录入" : "RNG"} · RNG {batch.rng_seed_hash ?? "—"} · {batch.confirmed_at ?? "—"}
{t("sourceType", {
source: batch.source_type === "manual" ? t("manualEntry") : t("rng"),
})}{" "}
· {t("rngSummary", { hash: batch.rng_seed_hash ?? "—" })} ·{" "}
{t("confirmedAt", { time: batch.confirmed_at ?? "—" })}
</p>
</CardHeader>
<CardContent className="overflow-x-auto pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>{t("prize")}</TableHead>
<TableHead>#</TableHead>
<TableHead className="font-mono">4D</TableHead>
<TableHead className="hidden sm:table-cell">3</TableHead>
<TableHead className="hidden sm:table-cell">2</TableHead>
<TableHead className="hidden md:table-cell">/</TableHead>
<TableHead className="hidden sm:table-cell">{t("tail3")}</TableHead>
<TableHead className="hidden sm:table-cell">{t("tail2")}</TableHead>
<TableHead className="hidden md:table-cell">{t("headTail")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminDrawResultBatches, postAdminCreateManualResultBatch } from "@/api/admin-draws";
@@ -26,22 +27,25 @@ import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
const RESULT_SLOTS = [
{ prize_type: "first", prize_index: 0, label: "头奖" },
{ prize_type: "second", prize_index: 0, label: "二奖" },
{ prize_type: "third", prize_index: 0, label: "三奖" },
{ prize_type: "first", prize_index: 0, label: "resultSlots.first" },
{ prize_type: "second", prize_index: 0, label: "resultSlots.second" },
{ prize_type: "third", prize_index: 0, label: "resultSlots.third" },
...Array.from({ length: 10 }, (_, i) => ({
prize_type: "starter",
prize_index: i,
label: `特别奖 ${i + 1}`,
label: `resultSlots.starter`,
labelIndex: i + 1,
})),
...Array.from({ length: 10 }, (_, i) => ({
prize_type: "consolation",
prize_index: i,
label: `安慰奖 ${i + 1}`,
label: `resultSlots.consolation`,
labelIndex: i + 1,
})),
] as const;
export function DrawReviewConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
@@ -57,7 +61,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError("无效的期号 ID");
setError(t("invalidDrawId"));
setLoading(false);
return;
}
@@ -67,11 +71,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : "加载失败");
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum]);
}, [idNum, t]);
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -88,7 +92,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
if (!Number.isFinite(idNum)) return;
const invalid = manualNumbers.some((n) => !/^[0-9]{4}$/.test(n));
if (invalid) {
toast.error("请完整输入 23 组 4 位数字");
toast.error(t("enter23Numbers"));
return;
}
@@ -101,38 +105,46 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
number_4d: manualNumbers[i],
})),
});
toast.success(`已保存草稿 v${res.batch.result_version},等待确认发布`);
toast.success(t("draftSaved", { version: res.batch.result_version }));
setManualNumbers(RESULT_SLOTS.map(() => ""));
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setSavingManual(false);
}
}
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? "无数据"}</p>;
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("manualResultEntry")}</CardTitle>
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<DrawStatusBadge status={data.draw_status} /> ·
{t("currentStatusAndDraft", {
status: data.draw_status,
}).split(data.draw_status)[0]}
<DrawStatusBadge status={data.draw_status} />
{t("currentStatusAndDraft", {
status: data.draw_status,
}).split(data.draw_status)[1] ?? ""}
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{RESULT_SLOTS.map((slot, i) => (
<label key={`${slot.prize_type}-${slot.prize_index}`} className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">{slot.label}</span>
<span className="text-xs font-medium text-muted-foreground">
{t(slot.label, { index: "labelIndex" in slot ? slot.labelIndex : undefined })}
</span>
<Input
inputMode="numeric"
maxLength={4}
@@ -155,14 +167,14 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
disabled={savingManual}
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
>
{t("clear")}
</Button>
<Button
type="button"
disabled={!canManageDraw || savingManual || !["closed", "review"].includes(data.draw_status)}
onClick={() => void saveManualDraft()}
>
{savingManual ? "保存中…" : "保存草稿"}
{savingManual ? t("saving") : t("saveDraft")}
</Button>
</div>
</CardContent>
@@ -170,21 +182,21 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("pendingBatches")}</CardTitle>
</CardHeader>
<CardContent>
{pending.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
pending_review
{t("noPendingBatches")}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t("batchId")}</TableHead>
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("numberCount")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -199,10 +211,10 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
href={`/admin/draws/${drawId}/publish/${b.id}`}
className={cn(buttonVariants({ size: "sm" }))}
>
{t("reviewAndPublishAction")}
</Link>
) : (
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">{t("noPublishPermission")}</span>
)}
</TableCell>
</TableRow>

View File

@@ -2,15 +2,16 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const segments = [
{ suffix: "", key: "status", label: "期号状态" },
{ suffix: "/results", key: "results", label: "开奖结果" },
{ suffix: "/finance", key: "finance", label: "期号收支" },
{ suffix: "/review", key: "review", label: "审核与发布" },
{ suffix: "", key: "status", label: "subnav.status" },
{ suffix: "/results", key: "results", label: "subnav.results" },
{ suffix: "/finance", key: "finance", label: "subnav.finance" },
{ suffix: "/review", key: "review", label: "subnav.review" },
] as const;
function isReviewTabActive(pathname: string, base: string): boolean {
@@ -24,6 +25,7 @@ function isReviewTabActive(pathname: string, base: string): boolean {
}
export function DrawSubnav({ drawId }: { drawId: string }) {
const { t } = useTranslation("draws");
const pathname = usePathname();
const base = `/admin/draws/${drawId}`;
@@ -46,7 +48,7 @@ export function DrawSubnav({ drawId }: { drawId: string }) {
buttonVariants({ variant: active ? "default" : "outline", size: "sm" }),
)}
>
{label}
{t(label)}
</Link>
);
})}

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminDraws, postAdminGenerateDrawPlan } from "@/api/admin-draws";
@@ -37,27 +38,29 @@ const DRAW_FILTER_ALL = "__all__";
/** 与 {@see App\Lottery\DrawStatus} 一致 */
const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "pending", label: "未开始" },
{ value: "open", label: "可下注" },
{ value: "closing", label: "封盘中" },
{ value: "closed", label: "已封盘待开奖" },
{ value: "drawing", label: "开奖处理中" },
{ value: "review", label: "待人工审核" },
{ value: "cooldown", label: "冷静期" },
{ value: "settling", label: "结算处理中" },
{ value: "settled", label: "已结算" },
{ value: "cancelled", label: "已取消" },
{ value: "pending", label: "statusOptions.pending" },
{ value: "open", label: "statusOptions.open" },
{ value: "closing", label: "statusOptions.closing" },
{ value: "closed", label: "statusOptions.closed" },
{ value: "drawing", label: "statusOptions.drawing" },
{ value: "review", label: "statusOptions.review" },
{ value: "cooldown", label: "statusOptions.cooldown" },
{ value: "settling", label: "statusOptions.settling" },
{ value: "settled", label: "statusOptions.settled" },
{ value: "cancelled", label: "statusOptions.cancelled" },
];
function drawAdminStatusSelectLabel(raw: unknown): string {
function drawAdminStatusSelectLabel(raw: unknown, t: (key: string) => string): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === DRAW_FILTER_ALL) {
return "不限";
return t("statusOptions.all");
}
return DRAW_STATUS_OPTIONS.find((o) => o.value === v)?.label ?? v;
const key = DRAW_STATUS_OPTIONS.find((o) => o.value === v)?.label;
return key ? t(key) : v;
}
export function DrawsIndexConsole() {
const { t } = useTranslation(["draws", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminDrawListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -76,8 +79,9 @@ export function DrawsIndexConsole() {
draftStatus === "" || !DRAW_STATUS_OPTIONS.some((o) => o.value === draftStatus)
? DRAW_FILTER_ALL
: draftStatus,
t,
),
[draftStatus],
[draftStatus, t],
);
const load = useCallback(async () => {
@@ -96,22 +100,28 @@ export function DrawsIndexConsole() {
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载失败,请检查登录与 API 配置";
e instanceof LotteryApiBizError ? e.message : t("loadFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, appliedDrawNo, appliedStatus]);
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
async function generatePlan(): Promise<void> {
setGenerating(true);
try {
const res = await postAdminGenerateDrawPlan();
toast.success(`已生成 ${res.created} 期,当前缓冲 ${res.upcoming}/${res.buffer_target}`);
toast.success(
t("generateSuccess", {
created: res.created,
upcoming: res.upcoming,
target: res.buffer_target,
}),
);
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "生成失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("generateFailed"));
} finally {
setGenerating(false);
}
@@ -127,9 +137,9 @@ export function DrawsIndexConsole() {
return (
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("statusListTitle")}</CardTitle>
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
{generating ? "生成中…" : "批量生成期开奖计划"}
{generating ? t("generating") : t("generatePlan")}
</Button>
</CardHeader>
<CardContent className="space-y-4">
@@ -138,17 +148,17 @@ export function DrawsIndexConsole() {
className="grid max-w-full gap-x-6 gap-y-3 sm:grid-cols-[minmax(0,12rem)_minmax(0,11rem)_auto] sm:gap-y-1.5"
>
<Label htmlFor="draw-filter-no">
{t("drawNo")}
</Label>
<Input
id="draw-filter-no"
placeholder="模糊匹配期号"
placeholder={t("fuzzyDrawNo")}
value={draftDrawNo}
className="w-full min-w-0 sm:w-full"
onChange={(e) => setDraftDrawNo(e.target.value)}
/>
<Label htmlFor="draw-filter-status">
{t("status")}
</Label>
<div className="min-w-0">
<Select
@@ -167,10 +177,10 @@ export function DrawsIndexConsole() {
<SelectValue>{drawStatusTriggerLabel}</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={DRAW_FILTER_ALL}></SelectItem>
<SelectItem value={DRAW_FILTER_ALL}>{t("statusOptions.all")}</SelectItem>
{DRAW_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
@@ -185,7 +195,7 @@ export function DrawsIndexConsole() {
setPage(1);
}}
>
{t("queryDraw")}
</Button>
<Button
type="button"
@@ -198,7 +208,7 @@ export function DrawsIndexConsole() {
setPage(1);
}}
>
{t("reset")}
</Button>
</div>
</div>
@@ -211,28 +221,28 @@ export function DrawsIndexConsole() {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("startTime")}</TableHead>
<TableHead>{t("closeTime")}</TableHead>
<TableHead>{t("drawTime")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("betTotal")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("profitLoss")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
{t("states.loading", { ns: "common" })}
</TableCell>
</TableRow>
) : data === null || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
@@ -264,7 +274,7 @@ export function DrawsIndexConsole() {
href={`/admin/draws/${row.id}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("viewDetails")}
</Link>
</TableCell>
</TableRow>

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
getAdminJackpotPools,
@@ -53,6 +54,7 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
}
export function JackpotPoolsConsole() {
const { t } = useTranslation(["jackpot", "common"]);
const [items, setItems] = useState<AdminJackpotPoolRow[]>([]);
const [drafts, setDrafts] = useState<Record<number, Draft>>({});
const [loading, setLoading] = useState(true);
@@ -70,11 +72,11 @@ export function JackpotPoolsConsole() {
}
setDrafts(d);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
queueMicrotask(() => {
@@ -107,10 +109,10 @@ export function JackpotPoolsConsole() {
.filter(Boolean),
status: Number.parseInt(d.status, 10),
});
toast.success("已保存");
toast.success(t("saveSuccess"));
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setSavingId(null);
}
@@ -121,7 +123,7 @@ export function JackpotPoolsConsole() {
if (!d) return;
const drawId = Number.parseInt(d.manual_burst_draw_id, 10);
if (!Number.isFinite(drawId) || drawId <= 0) {
toast.error("请填写有效的期号 ID");
toast.error(t("invalidDrawId"));
return;
}
@@ -135,10 +137,10 @@ export function JackpotPoolsConsole() {
draw_id: drawId,
amount: amount !== undefined && Number.isFinite(amount) ? amount : undefined,
});
toast.success("已手动触发爆池");
toast.success(t("manualBurstSuccess"));
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "手动爆池失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("manualBurstFailed"));
} finally {
setBurstingId(null);
}
@@ -148,12 +150,12 @@ export function JackpotPoolsConsole() {
<ModuleScaffold>
<Card>
<CardHeader>
<CardTitle className="text-base">Jackpot </CardTitle>
<CardTitle className="text-base">{t("configTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
{loading ? <p className="text-muted-foreground text-sm"></p> : null}
{loading ? <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> : null}
{!loading && items.length === 0 ? (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("noPoolData")}</p>
) : null}
{items.map((p) => {
const d = drafts[p.id] ?? toDraft(p);
@@ -162,12 +164,14 @@ export function JackpotPoolsConsole() {
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
<span className="text-xs text-muted-foreground">
{formatAdminMinorUnits(p.current_amount, p.currency_code)}
{t("displayBalance", {
amount: formatAdminMinorUnits(p.current_amount, p.currency_code),
})}
</span>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor={`amt-${p.id}`}></Label>
<Label htmlFor={`amt-${p.id}`}>{t("currentAmount")}</Label>
<Input
id={`amt-${p.id}`}
className="font-mono"
@@ -176,7 +180,7 @@ export function JackpotPoolsConsole() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`cr-${p.id}`}> 01</Label>
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
<Input
id={`cr-${p.id}`}
className="font-mono"
@@ -185,7 +189,7 @@ export function JackpotPoolsConsole() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`th-${p.id}`}></Label>
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
<Input
id={`th-${p.id}`}
className="font-mono"
@@ -194,7 +198,7 @@ export function JackpotPoolsConsole() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`pr-${p.id}`}> 01</Label>
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
<Input
id={`pr-${p.id}`}
className="font-mono"
@@ -203,7 +207,7 @@ export function JackpotPoolsConsole() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`gap-${p.id}`}></Label>
<Label htmlFor={`gap-${p.id}`}>{t("forceTriggerGap")}</Label>
<Input
id={`gap-${p.id}`}
className="font-mono"
@@ -212,7 +216,7 @@ export function JackpotPoolsConsole() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`min-${p.id}`}></Label>
<Label htmlFor={`min-${p.id}`}>{t("minBetAmount")}</Label>
<Input
id={`min-${p.id}`}
className="font-mono"
@@ -221,7 +225,7 @@ export function JackpotPoolsConsole() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`combo-${p.id}`}></Label>
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
<Input
id={`combo-${p.id}`}
className="font-mono"
@@ -231,7 +235,7 @@ export function JackpotPoolsConsole() {
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Label>{t("status")}</Label>
<Select
value={d.status}
onValueChange={(v) => updateDraft(p.id, { status: v ?? "0" })}
@@ -240,21 +244,21 @@ export function JackpotPoolsConsole() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="0">{t("disabled")}</SelectItem>
<SelectItem value="1">{t("enabled")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button type="button" disabled={savingId === p.id} onClick={() => void save(p)}>
{savingId === p.id ? "保存中…" : "保存"}
{savingId === p.id ? t("saving") : t("save")}
</Button>
</div>
<div className="rounded-md border border-amber-200 bg-amber-50 p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
<div className="space-y-1.5">
<Label htmlFor={`burst-draw-${p.id}`}> ID</Label>
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
<Input
id={`burst-draw-${p.id}`}
className="font-mono"
@@ -263,7 +267,7 @@ export function JackpotPoolsConsole() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`burst-amount-${p.id}`}></Label>
<Label htmlFor={`burst-amount-${p.id}`}>{t("manualBurstAmount")}</Label>
<Input
id={`burst-amount-${p.id}`}
className="font-mono"
@@ -277,7 +281,7 @@ export function JackpotPoolsConsole() {
disabled={burstingId === p.id}
onClick={() => void manualBurst(p)}
>
{burstingId === p.id ? "处理中…" : "手动爆池"}
{burstingId === p.id ? t("processing") : t("manualBurst")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -26,6 +27,7 @@ import type {
} from "@/types/api/admin-jackpot";
export function JackpotRecordsConsole() {
const { t } = useTranslation(["jackpot", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [drawNo, setDrawNo] = useState("");
const [appliedDrawNo, setAppliedDrawNo] = useState("");
@@ -52,11 +54,11 @@ export function JackpotRecordsConsole() {
});
setPayouts(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "派彩记录加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("payoutLoadFailed"));
} finally {
setLoadingP(false);
}
}, [pPage, pPer, appliedDrawNo]);
}, [pPage, pPer, appliedDrawNo, t]);
const loadContribs = useCallback(async () => {
setLoadingC(true);
@@ -68,11 +70,11 @@ export function JackpotRecordsConsole() {
});
setContribs(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "蓄水记录加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("contributionLoadFailed"));
} finally {
setLoadingC(false);
}
}, [cPage, cPer, appliedDrawNo]);
}, [cPage, cPer, appliedDrawNo, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -96,21 +98,21 @@ export function JackpotRecordsConsole() {
<ModuleScaffold>
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("filter")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex max-w-xs flex-1 flex-col gap-1.5">
<Label htmlFor="jk-draw"></Label>
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
<Input
id="jk-draw"
className="font-mono"
value={drawNo}
onChange={(e) => setDrawNo(e.target.value)}
placeholder="可选"
placeholder={t("optional")}
/>
</div>
<Button type="button" onClick={applyDraw}>
{t("apply")}
</Button>
</CardContent>
</Card>
@@ -119,21 +121,21 @@ export function JackpotRecordsConsole() {
<Card className="mb-8">
<CardHeader>
<CardTitle className="text-base">Jackpot </CardTitle>
<CardTitle className="text-base">{t("payoutRecords")}</CardTitle>
</CardHeader>
<CardContent>
{loadingP && !payouts ? (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("trigger")}</TableHead>
<TableHead className="text-right">{t("payoutAmount")}</TableHead>
<TableHead className="text-right">{t("winnerCount")}</TableHead>
<TableHead>{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -174,21 +176,21 @@ export function JackpotRecordsConsole() {
<Card>
<CardHeader>
<CardTitle className="text-base">Jackpot </CardTitle>
<CardTitle className="text-base">{t("contributionRecords")}</CardTitle>
</CardHeader>
<CardContent>
{loadingC && !contribs ? (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("player")}</TableHead>
<TableHead className="text-right">{t("contributionAmount")}</TableHead>
<TableHead>{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@@ -2,19 +2,21 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
const LINKS: { href: string; label: string }[] = [
{ href: "/admin/jackpot/pools", label: "奖池配置" },
{ href: "/admin/jackpot/records", label: "记录" },
{ href: "/admin/jackpot/pools", label: "subnavPools" },
{ href: "/admin/jackpot/records", label: "subnavRecords" },
];
export function JackpotSubNav() {
const { t } = useTranslation("jackpot");
const pathname = usePathname();
return (
<nav className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3" aria-label="Jackpot 子导航">
<nav className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3" aria-label={t("subnavLabel")}>
{LINKS.map(({ href, label }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
@@ -28,7 +30,7 @@ export function JackpotSubNav() {
: "bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{label}
{t(label)}
</Link>
);
})}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -40,10 +41,10 @@ import {
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow } from "@/types/api/admin-player";
function playerStatusLabel(status: number): string {
if (status === 0) return "正常";
if (status === 1) return "冻结";
if (status === 2) return "封禁";
function playerStatusLabelT(status: number, t: (key: string) => string): string {
if (status === 0) return t("statusNormal");
if (status === 1) return t("statusFrozen");
if (status === 2) return t("statusBanned");
return String(status);
}
@@ -62,12 +63,13 @@ function formatMinorUnits(minor: number, currencyCode: string): string {
}
const PLAYER_STATUS_OPTIONS = [
{ value: 0, label: "正常" },
{ value: 1, label: "冻结" },
{ value: 2, label: "封禁" },
{ value: 0, label: "statusNormal" },
{ value: 1, label: "statusFrozen" },
{ value: 2, label: "statusBanned" },
];
export function PlayersConsole(): React.ReactElement {
const { t } = useTranslation(["players", "common"]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
const [keyword, setKeyword] = useState("");
@@ -111,7 +113,7 @@ export function PlayersConsole(): React.ReactElement {
setTotal(data.meta.total);
setLastPage(Math.max(1, data.meta.last_page));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "加载玩家列表失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
setErr(msg);
setItems([]);
setTotal(0);
@@ -119,7 +121,7 @@ export function PlayersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query]);
}, [page, perPage, query, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -161,11 +163,11 @@ export function PlayersConsole(): React.ReactElement {
async function submitAccount(): Promise<void> {
if (accountMode === "create") {
if (formSiteCode.trim() === "") {
toast.error("请填写主站编号");
toast.error(t("siteCodeRequired"));
return;
}
if (formSitePlayerId.trim() === "") {
toast.error("请填写主站玩家 ID");
toast.error(t("sitePlayerIdRequired"));
return;
}
setAccountSaving(true);
@@ -180,10 +182,10 @@ export function PlayersConsole(): React.ReactElement {
});
setItems((prev) => [created, ...prev]);
setTotal((t) => t + 1);
toast.success(`已创建玩家 ${created.username ?? created.site_player_id}`);
toast.success(t("createSuccess", { name: created.username ?? created.site_player_id }));
handleAccountDialogOpenChange(false);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "创建玩家失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("createFailed");
toast.error(msg);
} finally {
setAccountSaving(false);
@@ -204,7 +206,7 @@ export function PlayersConsole(): React.ReactElement {
}
if (Object.keys(body).length === 0) {
toast.success("没有变更");
toast.success(t("noChanges"));
handleAccountDialogOpenChange(false);
return;
}
@@ -213,10 +215,10 @@ export function PlayersConsole(): React.ReactElement {
try {
const updated = await putAdminPlayer(id, body);
setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row)));
toast.success(`已更新 ${updated.username ?? updated.site_player_id}`);
toast.success(t("updateSuccess", { name: updated.username ?? updated.site_player_id }));
handleAccountDialogOpenChange(false);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "更新玩家失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("updateFailed");
toast.error(msg);
} finally {
setAccountSaving(false);
@@ -231,10 +233,10 @@ export function PlayersConsole(): React.ReactElement {
await deleteAdminPlayer(deleteTarget.id);
setItems((prev) => prev.filter((r) => r.id !== deleteTarget.id));
setTotal((t) => Math.max(0, t - 1));
toast.success(`已删除玩家 ${deleteTarget.username ?? deleteTarget.site_player_id}`);
toast.success(t("deleteSuccess", { name: deleteTarget.username ?? deleteTarget.site_player_id }));
setDeleteTarget(null);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : "删除失败";
const msg = e instanceof LotteryApiBizError ? e.message : t("deleteFailed");
toast.error(msg);
} finally {
setDeleteBusy(false);
@@ -246,15 +248,15 @@ export function PlayersConsole(): React.ReactElement {
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<CardTitle></CardTitle>
<CardTitle>{t("listTitle")}</CardTitle>
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
{t("createPlayer")}
</Button>
</div>
<div className="flex w-full max-w-lg gap-2">
<Input
value={keyword}
placeholder="按玩家 ID / 用户名 / 昵称搜索"
placeholder={t("searchPlaceholder")}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
@@ -270,40 +272,40 @@ export function PlayersConsole(): React.ReactElement {
setQuery(keyword.trim());
}}
>
{t("search")}
</Button>
<Button type="button" variant="secondary" onClick={() => void load()}>
{t("refresh")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && items.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">ID</TableHead>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap text-right"></TableHead>
<TableHead className="whitespace-nowrap text-right"></TableHead>
<TableHead className="w-20 whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="min-w-[10rem]"></TableHead>
<TableHead>{t("site")}</TableHead>
<TableHead>{t("sitePlayerId")}</TableHead>
<TableHead>{t("username")}</TableHead>
<TableHead>{t("nickname")}</TableHead>
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
<TableHead className="whitespace-nowrap text-right">{t("balance")}</TableHead>
<TableHead className="whitespace-nowrap text-right">{t("available")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
<TableHead className="min-w-[10rem]">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 && !loading ? (
<TableRow>
<TableCell colSpan={11} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
@@ -331,7 +333,7 @@ export function PlayersConsole(): React.ReactElement {
</TableCell>
<TableCell>
<Badge variant={playerStatusVariant(row.status)} className="font-normal">
{playerStatusLabel(row.status)}
{playerStatusLabelT(row.status, t)}
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
@@ -355,7 +357,7 @@ export function PlayersConsole(): React.ReactElement {
}
onClick={() => openEditAccount(row)}
>
{t("edit")}
</Button>
<Button
type="button"
@@ -363,7 +365,7 @@ export function PlayersConsole(): React.ReactElement {
variant="destructive"
onClick={() => setDeleteTarget(row)}
>
{t("delete")}
</Button>
</div>
</TableCell>
@@ -392,59 +394,57 @@ export function PlayersConsole(): React.ReactElement {
<Dialog open={accountOpen} onOpenChange={handleAccountDialogOpenChange}>
<DialogContent showCloseButton className="max-h-[90vh] max-w-lg gap-4 overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{accountMode === "create" ? "新建玩家" : "编辑玩家"}</DialogTitle>
<DialogTitle>{accountMode === "create" ? t("createDialogTitle") : t("editDialogTitle")}</DialogTitle>
<DialogDescription>
{accountMode === "create"
? "手动注册一个主站玩家到彩票平台,通常由 SSO 登录自动创建。"
: "编辑玩家信息。"}
{accountMode === "create" ? t("createDialogDesc") : t("editDialogDesc")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{accountMode === "create" && (
<>
<div className="space-y-1.5">
<Label htmlFor="player-site-code"></Label>
<Label htmlFor="player-site-code">{t("siteCode")}</Label>
<Input
id="player-site-code"
value={formSiteCode}
placeholder="例如 main_site"
placeholder={t("siteCodePlaceholder")}
onChange={(e) => setFormSiteCode(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="player-site-id"> ID</Label>
<Label htmlFor="player-site-id">{t("sitePlayerIdLabel")}</Label>
<Input
id="player-site-id"
value={formSitePlayerId}
placeholder="主站返回的唯一标识"
placeholder={t("sitePlayerIdPlaceholder")}
onChange={(e) => setFormSitePlayerId(e.target.value)}
/>
</div>
</>
)}
<div className="space-y-1.5">
<Label htmlFor="player-username"></Label>
<Label htmlFor="player-username">{t("username")}</Label>
<Input
id="player-username"
value={formUsername}
disabled={accountMode === "edit"}
placeholder={accountMode === "create" ? "选填" : ""}
placeholder={accountMode === "create" ? t("usernamePlaceholderOptional") : ""}
onChange={(e) => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="player-nickname"></Label>
<Label htmlFor="player-nickname">{t("nickname")}</Label>
<Input
id="player-nickname"
value={formNickname}
placeholder="选填"
placeholder={t("nicknamePlaceholderOptional")}
onChange={(e) => setFormNickname(e.target.value)}
/>
</div>
{accountMode === "create" && (
<>
<div className="space-y-1.5">
<Label htmlFor="player-currency"></Label>
<Label htmlFor="player-currency">{t("defaultCurrency")}</Label>
<Input
id="player-currency"
value={formDefaultCurrency}
@@ -453,7 +453,7 @@ export function PlayersConsole(): React.ReactElement {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="player-status"></Label>
<Label htmlFor="player-status">{t("status")}</Label>
<Select
value={String(formStatus)}
onValueChange={(v) => setFormStatus(Number(v))}
@@ -464,7 +464,7 @@ export function PlayersConsole(): React.ReactElement {
<SelectContent>
{PLAYER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
@@ -474,7 +474,7 @@ export function PlayersConsole(): React.ReactElement {
)}
{accountMode === "edit" && (
<div className="space-y-1.5">
<Label htmlFor="player-edit-status"></Label>
<Label htmlFor="player-edit-status">{t("status")}</Label>
<Select
value={String(formStatus)}
onValueChange={(v) => setFormStatus(Number(v))}
@@ -485,7 +485,7 @@ export function PlayersConsole(): React.ReactElement {
<SelectContent>
{PLAYER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={String(o.value)}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
@@ -499,14 +499,14 @@ export function PlayersConsole(): React.ReactElement {
variant="outline"
onClick={() => handleAccountDialogOpenChange(false)}
>
{t("cancel")}
</Button>
<Button
type="button"
disabled={accountSaving}
onClick={() => void submitAccount()}
>
{accountSaving ? "保存中…" : "保存"}
{accountSaving ? t("saving") : t("save")}
</Button>
</div>
</DialogContent>
@@ -515,23 +515,21 @@ export function PlayersConsole(): React.ReactElement {
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent showCloseButton className="max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("confirmDelete")}</DialogTitle>
<DialogDescription>
{" "}
{deleteTarget ? (
<span className="font-medium text-foreground">
{deleteTarget.username ?? deleteTarget.site_player_id}
</span>
) : null}{" "}
{deleteTarget
? t("confirmDeleteDesc", {
name: deleteTarget.username ?? deleteTarget.site_player_id,
})
: null}
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
{t("cancel")}
</Button>
<Button type="button" variant="destructive" disabled={deleteBusy} onClick={() => void confirmDelete()}>
{deleteBusy ? "删除中…" : "删除"}
{deleteBusy ? t("actions.submitting", { ns: "common" }) : t("delete")}
</Button>
</div>
</DialogContent>

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -42,34 +43,34 @@ import type {
const MANAGE = ["prd.wallet_reconcile.manage"] as const;
/** 与后端 reconcile_type 对齐;扩展时在 API 与下拉同步增加 */
const RECONCILE_TYPE_OPTIONS = [{ value: "wallet_transfer", label: "钱包划转(主站 ⇄ 彩票)" }] as const;
const RECONCILE_TYPE_OPTIONS = [{ value: "wallet_transfer", label: "walletTransfer" }] as const;
function reconcileTypeLabel(slug: string): string {
function reconcileTypeLabel(slug: string, t: (key: string) => string): string {
const hit = RECONCILE_TYPE_OPTIONS.find((o) => o.value === slug);
return hit?.label ?? slug;
return hit ? t(hit.label) : slug;
}
function jobStatusLabel(status: string): string {
function jobStatusLabel(status: string, t: (key: string) => string): string {
switch (status) {
case "completed":
return "已完成";
return t("statusCompleted");
case "running":
return "执行中";
return t("statusRunning");
case "failed":
return "失败";
return t("statusFailed");
default:
return status;
}
}
function itemStatusLabel(status: string): string {
function itemStatusLabel(status: string, t: (key: string) => string): string {
switch (status) {
case "mismatch":
return "不一致";
return t("itemMismatch");
case "matched":
return "一致";
return t("itemMatched");
case "pending_check":
return "待核对";
return t("itemPendingCheck");
default:
return status;
}
@@ -106,6 +107,7 @@ function scopeLinesToItems(
}
export function ReconcileConsole(): React.ReactElement {
const { t } = useTranslation(["reconcile", "common"]);
const profile = useAdminProfile();
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
const formatTs = useAdminDateTimeFormatter();
@@ -137,12 +139,12 @@ export function ReconcileConsole(): React.ReactElement {
const d = await getAdminReconcileJobs({ page, per_page: perPage });
setJobs(d);
} catch (e) {
setJobsErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setJobsErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setJobs(null);
} finally {
setJobsLoading(false);
}
}, [page, perPage]);
}, [page, perPage, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -163,12 +165,12 @@ export function ReconcileConsole(): React.ReactElement {
});
setItems(d);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载明细失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadItemsFailed"));
setItems(null);
} finally {
setItemsLoading(false);
}
}, [selectedId, itemsPage, itemsPerPage]);
}, [selectedId, itemsPage, itemsPerPage, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -178,17 +180,17 @@ export function ReconcileConsole(): React.ReactElement {
async function onCreate(): Promise<void> {
if (!periodStartLocal.trim() || !periodEndLocal.trim()) {
toast.error("请填写对账时间范围(开始与结束)");
toast.error(t("periodRequired"));
return;
}
const periodStartIso = toIsoFromDatetimeLocal(periodStartLocal);
const periodEndIso = toIsoFromDatetimeLocal(periodEndLocal);
if (periodStartIso == null || periodEndIso == null) {
toast.error("时间无效,请检查所选日期与时间");
toast.error(t("periodInvalid"));
return;
}
if (new Date(periodStartIso).getTime() > new Date(periodEndIso).getTime()) {
toast.error("结束时间需晚于或等于开始时间");
toast.error(t("periodOrderInvalid"));
return;
}
@@ -202,7 +204,7 @@ export function ReconcileConsole(): React.ReactElement {
Parameters<typeof postAdminReconcileJob>[0]["items"]
>;
} catch {
toast.error("高级选项中的 JSON 无法解析");
toast.error(t("advancedJsonInvalid"));
return;
}
}
@@ -220,7 +222,7 @@ export function ReconcileConsole(): React.ReactElement {
period_end: periodEndIso,
items: itemsPayload,
});
toast.success("已创建对账任务");
toast.success(t("createSuccess"));
setPage(1);
setScopeLines("");
if (showAdvanced) {
@@ -228,7 +230,7 @@ export function ReconcileConsole(): React.ReactElement {
}
await loadJobs();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("createFailed"));
} finally {
setSubmitting(false);
}
@@ -242,15 +244,14 @@ export function ReconcileConsole(): React.ReactElement {
{canCreate ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("createTitle")}</CardTitle>
<CardDescription>
<strong className="font-medium text-foreground"></strong>
{t("createDesc")}
</CardDescription>
</CardHeader>
<CardContent className="grid max-w-3xl gap-4">
<div className="grid gap-1.5">
<Label htmlFor="rc-type"></Label>
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
<Select
modal={false}
value={reconcileType}
@@ -261,12 +262,12 @@ export function ReconcileConsole(): React.ReactElement {
}}
>
<SelectTrigger id="rc-type" className="w-full max-w-md">
<SelectValue>{reconcileTypeLabel(reconcileType)}</SelectValue>
<SelectValue>{reconcileTypeLabel(reconcileType, t)}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{RECONCILE_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
@@ -274,7 +275,7 @@ export function ReconcileConsole(): React.ReactElement {
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="rc-start"></Label>
<Label htmlFor="rc-start">{t("startTime")}</Label>
<Input
id="rc-start"
type="datetime-local"
@@ -283,7 +284,7 @@ export function ReconcileConsole(): React.ReactElement {
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rc-end"></Label>
<Label htmlFor="rc-end">{t("endTime")}</Label>
<Input
id="rc-end"
type="datetime-local"
@@ -293,19 +294,17 @@ export function ReconcileConsole(): React.ReactElement {
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rc-scope"></Label>
<Label htmlFor="rc-scope">{t("scope")}</Label>
<Textarea
id="rc-scope"
value={scopeLines}
onChange={(e) => setScopeLines(e.target.value)}
rows={5}
placeholder={
"每行一条待核对引用,例如:玩家 ID、钱包划转单号、幂等键等。\n留空表示本时间段内不额外指定单据仅任务留痕。"
}
placeholder={t("scopePlaceholder")}
className="min-h-[100px] text-sm"
/>
<p className="text-xs text-muted-foreground">
pending_reconcile使
{t("scopeHint")}
</p>
</div>
<div className="flex flex-col gap-2 border-t pt-4">
@@ -316,11 +315,11 @@ export function ReconcileConsole(): React.ReactElement {
className="w-fit px-0 text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((x) => !x)}
>
{showAdvanced ? "收起" : "展开"} JSON
{showAdvanced ? t("advancedToggleClose") : t("advancedToggleOpen")}
</Button>
{showAdvanced ? (
<div className="grid gap-1.5">
<Label htmlFor="rc-items-adv"> JSON</Label>
<Label htmlFor="rc-items-adv">{t("advancedJson")}</Label>
<Textarea
id="rc-items-adv"
value={itemsJson}
@@ -333,28 +332,28 @@ export function ReconcileConsole(): React.ReactElement {
) : null}
</div>
<Button type="button" onClick={() => void onCreate()} disabled={submitting}>
{submitting ? "提交中…" : "创建对账任务"}
{submitting ? t("submitting") : t("createTask")}
</Button>
</CardContent>
</Card>
) : (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("noCreatePermission")}</p>
)}
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-1.5"></CardDescription>
<CardTitle>{t("jobsTitle")}</CardTitle>
<CardDescription className="mt-1.5">{t("jobsDesc")}</CardDescription>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
{t("refresh")}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
{jobsLoading && !jobs ? (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : null}
{jobs ? (
<>
@@ -363,18 +362,18 @@ export function ReconcileConsole(): React.ReactElement {
<TableHeader>
<TableRow>
<TableHead className="w-24">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("jobNo")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("period")}</TableHead>
<TableHead>{t("createdAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
@@ -393,9 +392,9 @@ export function ReconcileConsole(): React.ReactElement {
>
<TableCell className="tabular-nums">{row.id}</TableCell>
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
<TableCell className="text-sm">{reconcileTypeLabel(row.reconcile_type)}</TableCell>
<TableCell className="text-sm">{reconcileTypeLabel(row.reconcile_type, t)}</TableCell>
<TableCell>
<Badge variant="secondary">{jobStatusLabel(row.status)}</Badge>
<Badge variant="secondary">{jobStatusLabel(row.status, t)}</Badge>
</TableCell>
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
<span className="line-clamp-2">
@@ -435,34 +434,34 @@ export function ReconcileConsole(): React.ReactElement {
{selectedId != null ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("detailsTitle")}</CardTitle>
<CardDescription className="font-mono text-xs">#{selectedId}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{itemsLoading && !items ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
{items ? (
<>
{items.job_no ? (
<p className="font-mono text-sm text-muted-foreground"> {items.job_no}</p>
<p className="font-mono text-sm text-muted-foreground">{t("jobNo")} {items.job_no}</p>
) : null}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("sideARef")}</TableHead>
<TableHead>{t("sideBRef")}</TableHead>
<TableHead>{t("differenceAmount")}</TableHead>
<TableHead>{t("status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{t("noDetails")}
</TableCell>
</TableRow>
) : (
@@ -472,7 +471,7 @@ export function ReconcileConsole(): React.ReactElement {
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
<TableCell className="tabular-nums">{r.difference_amount}</TableCell>
<TableCell className="text-sm">{itemStatusLabel(r.status)}</TableCell>
<TableCell className="text-sm">{itemStatusLabel(r.status, t)}</TableCell>
</TableRow>
))
)}

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -34,20 +35,21 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminReportJobListData } from "@/types/api/admin-reports";
const REPORT_TYPES = [
{ value: "draw_profit_summary", label: "期号盈亏" },
{ value: "daily_profit_summary", label: "每日盈亏汇总" },
{ value: "player_win_loss", label: "玩家输赢报表" },
{ value: "wallet_transfer_report", label: "玩家转入转出报表" },
{ value: "hot_number_risk_report", label: "热门号码风险报表" },
{ value: "play_dimension_report", label: "玩法维度报表" },
{ value: "sold_out_number_report", label: "售罄号码报表" },
{ value: "rebate_commission_report", label: "佣金回水报表" },
{ value: "audit_operation_report", label: "后台操作审计报表" },
{ value: "wallet_txns_daily", label: "钱包流水日报" },
{ value: "transfer_orders_daily", label: "转账单日报" },
{ value: "draw_profit_summary" },
{ value: "daily_profit_summary" },
{ value: "player_win_loss" },
{ value: "wallet_transfer_report" },
{ value: "hot_number_risk_report" },
{ value: "play_dimension_report" },
{ value: "sold_out_number_report" },
{ value: "rebate_commission_report" },
{ value: "audit_operation_report" },
{ value: "wallet_txns_daily" },
{ value: "transfer_orders_daily" },
] as const;
export function ReportsConsole(): React.ReactElement {
const { t } = useTranslation(["reports", "common"]);
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminReportJobListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -67,12 +69,12 @@ export function ReportsConsole(): React.ReactElement {
const d = await getAdminReportJobs({ page, per_page: perPage });
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage]);
}, [page, perPage, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -87,7 +89,7 @@ export function ReportsConsole(): React.ReactElement {
try {
filter_json = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
toast.error("筛选 JSON 无法解析");
toast.error(t("parseFilterFailed"));
return;
}
}
@@ -99,11 +101,11 @@ export function ReportsConsole(): React.ReactElement {
parameters: filter_json,
filter_json,
});
toast.success("已创建导出任务");
toast.success(t("createSuccess"));
setPage(1);
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("createFailed"));
} finally {
setSubmitting(false);
}
@@ -119,7 +121,7 @@ export function ReportsConsole(): React.ReactElement {
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("下载失败");
toast.error(t("downloadFailed"));
}
}
@@ -132,11 +134,11 @@ export function ReportsConsole(): React.ReactElement {
<div className="flex w-full max-w-none flex-col gap-8">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("createExport")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-1.5">
<Label></Label>
<Label>{t("reportType")}</Label>
<Select
modal={false}
value={reportType}
@@ -152,14 +154,14 @@ export function ReportsConsole(): React.ReactElement {
<SelectContent>
{REPORT_TYPES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(`reportTypes.${o.value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label></Label>
<Label>{t("exportFormat")}</Label>
<Select
modal={false}
value={exportFormat}
@@ -179,7 +181,7 @@ export function ReportsConsole(): React.ReactElement {
</Select>
</div>
<div className="sm:col-span-2 lg:col-span-3 grid gap-1.5">
<Label htmlFor="report-filter-json">filter_json</Label>
<Label htmlFor="report-filter-json">{t("filterJson")}</Label>
<Textarea
id="report-filter-json"
value={filterJsonText}
@@ -190,7 +192,7 @@ export function ReportsConsole(): React.ReactElement {
</div>
<div className="sm:col-span-2 lg:col-span-3">
<Button type="button" onClick={() => void onCreate()} disabled={submitting}>
{submitting ? "提交中…" : "创建任务"}
{submitting ? t("actions.submitting", { ns: "common" }) : t("actions.createTask", { ns: "common" })}
</Button>
</div>
</CardContent>
@@ -199,16 +201,16 @@ export function ReportsConsole(): React.ReactElement {
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardTitle>{t("taskList")}</CardTitle>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
{t("actions.refresh", { ns: "common" })}
</Button>
</CardHeader>
<CardContent className="space-y-4">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
<>
@@ -216,21 +218,21 @@ export function ReportsConsole(): React.ReactElement {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24">{t("id")}</TableHead>
<TableHead>{t("jobId")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("format")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("output")}</TableHead>
<TableHead>{t("download")}</TableHead>
<TableHead>{t("createdAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
) : (
@@ -238,7 +240,11 @@ export function ReportsConsole(): React.ReactElement {
<TableRow key={row.id}>
<TableCell className="tabular-nums">{row.id}</TableCell>
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
<TableCell className="text-sm">{row.report_type}</TableCell>
<TableCell className="text-sm">
{t(`reportTypes.${row.report_type}`, {
defaultValue: row.report_type,
})}
</TableCell>
<TableCell>{row.export_format}</TableCell>
<TableCell>
<Badge variant="secondary">{row.status}</Badge>
@@ -253,7 +259,7 @@ export function ReportsConsole(): React.ReactElement {
size="sm"
onClick={() => void onDownload(row.id)}
>
{t("actions.download", { ns: "common" })}
</Button>
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminDraw } from "@/api/admin-draws";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
@@ -8,6 +9,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
export function RiskDrawHeader({ drawId }: { drawId: number }) {
const { t } = useTranslation("risk");
const [draw, setDraw] = useState<AdminDrawShowData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -18,11 +20,11 @@ export function RiskDrawHeader({ drawId }: { drawId: number }) {
setDraw(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "无法加载期号信息";
e instanceof LotteryApiBizError ? e.message : t("drawInfoLoadFailed");
setError(msg);
setDraw(null);
}
}, [drawId]);
}, [drawId, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -35,18 +37,20 @@ export function RiskDrawHeader({ drawId }: { drawId: number }) {
}
if (!draw) {
return <p className="text-sm text-muted-foreground"></p>;
return <p className="text-sm text-muted-foreground">{t("loadingDraw")}</p>;
}
return (
<div className="mb-4 space-y-1">
<h1 className="text-xl font-semibold tracking-tight">
· {draw.draw_no}
{t("headerTitle", { drawNo: draw.draw_no })}
</h1>
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span></span>
<span>{t("databaseStatus")}</span>
<DrawStatusBadge status={draw.status} />
<span className="text-xs opacity-80">{draw.hall_preview_status}</span>
<span className="text-xs opacity-80">
{t("hallPreviewStatus", { status: draw.hall_preview_status })}
</span>
</p>
</div>
);

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminDraws } from "@/api/admin-draws";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -31,19 +32,20 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "pending", label: "未开始" },
{ value: "open", label: "可下注" },
{ value: "closing", label: "封盘中" },
{ value: "closed", label: "已封盘待开奖" },
{ value: "drawing", label: "开奖处理中" },
{ value: "review", label: "待审核" },
{ value: "cooldown", label: "冷静期" },
{ value: "settling", label: "结算中" },
{ value: "settled", label: "已结算" },
{ value: "cancelled", label: "已取消" },
{ value: "pending", label: "statusOptions.pending" },
{ value: "open", label: "statusOptions.open" },
{ value: "closing", label: "statusOptions.closing" },
{ value: "closed", label: "statusOptions.closed" },
{ value: "drawing", label: "statusOptions.drawing" },
{ value: "review", label: "statusOptions.review" },
{ value: "cooldown", label: "statusOptions.cooldown" },
{ value: "settling", label: "statusOptions.settling" },
{ value: "settled", label: "statusOptions.settled" },
{ value: "cancelled", label: "statusOptions.cancelled" },
];
export function RiskIndexConsole() {
const { t } = useTranslation(["risk", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminDrawListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -57,10 +59,11 @@ export function RiskIndexConsole() {
const riskStatusTriggerLabel = useMemo(() => {
if (statusFilter === "") {
return "全部";
return t("all");
}
return DRAW_STATUS_OPTIONS.find((o) => o.value === statusFilter)?.label ?? statusFilter;
}, [statusFilter]);
const key = DRAW_STATUS_OPTIONS.find((o) => o.value === statusFilter)?.label;
return key ? t(key) : statusFilter;
}, [statusFilter, t]);
const load = useCallback(async () => {
setLoading(true);
@@ -75,13 +78,13 @@ export function RiskIndexConsole() {
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载期号列表失败";
e instanceof LotteryApiBizError ? e.message : t("loadDrawListFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, drawNoQuery, statusFilter]);
}, [page, perPage, drawNoQuery, statusFilter, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -100,15 +103,15 @@ export function RiskIndexConsole() {
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("center")}</CardTitle>
<div className="flex w-full max-w-4xl flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<div className="grid flex-1 gap-2 sm:min-w-[12rem]">
<Label htmlFor="risk-index-draw-no" className="text-xs text-muted-foreground">
{t("drawNo")}
</Label>
<Input
id="risk-index-draw-no"
placeholder="模糊匹配期号"
placeholder={t("fuzzyDrawNo")}
value={drawNoInput}
onChange={(e) => setDrawNoInput(e.target.value)}
onKeyDown={(e) => {
@@ -120,7 +123,7 @@ export function RiskIndexConsole() {
</div>
<div className="grid gap-2 sm:w-44">
<Label htmlFor="risk-index-status" className="text-xs text-muted-foreground">
{t("status")}
</Label>
<Select
modal={false}
@@ -135,10 +138,10 @@ export function RiskIndexConsole() {
<SelectValue>{riskStatusTriggerLabel}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="all"></SelectItem>
<SelectItem value="all">{t("all")}</SelectItem>
{DRAW_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
@@ -146,10 +149,10 @@ export function RiskIndexConsole() {
</div>
<div className="flex gap-2">
<Button type="button" size="sm" onClick={() => applySearch()}>
{t("search")}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("refresh")}
</Button>
</div>
</div>
@@ -157,24 +160,24 @@ export function RiskIndexConsole() {
<CardContent className="space-y-4">
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading && (data?.items.length ?? 0) === 0 ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("closeTime")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(data?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
</TableCell>
<TableCell colSpan={4} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
(data?.items ?? []).map((row: AdminDrawListItem) => (
@@ -191,7 +194,7 @@ export function RiskIndexConsole() {
href={`/admin/risk/draws/${row.id}/occupancy`}
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
>
{t("enterRisk")}
</Link>
</TableCell>
</TableRow>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -31,6 +32,7 @@ import type { AdminRiskLockLogListData, AdminRiskLockLogRow } from "@/types/api/
const ACTION_ALL = "__all__";
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
const { t } = useTranslation(["risk", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
@@ -59,13 +61,13 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载占用流水失败";
e instanceof LotteryApiBizError ? e.message : t("loadLogsFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [drawId, page, perPage, appliedAction, appliedNumber]);
}, [drawId, page, perPage, appliedAction, appliedNumber, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -76,23 +78,23 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("lockLogsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid max-w-full gap-3 sm:grid-cols-[minmax(0,8rem)_minmax(0,10rem)_auto] sm:items-end">
<div className="space-y-1.5">
<Label htmlFor="risk-log-number">4 </Label>
<Label htmlFor="risk-log-number">{t("number4d")}</Label>
<Input
id="risk-log-number"
inputMode="numeric"
maxLength={4}
value={draftNumber}
onChange={(e) => setDraftNumber(e.target.value.replace(/\D/g, "").slice(0, 4))}
placeholder="可选"
placeholder={t("optional")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="risk-log-action"></Label>
<Label htmlFor="risk-log-action">{t("actionFilter")}</Label>
<Select
modal={false}
value={draftAction}
@@ -104,9 +106,9 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTION_ALL}></SelectItem>
<SelectItem value="lock"> lock</SelectItem>
<SelectItem value="release"> release</SelectItem>
<SelectItem value={ACTION_ALL}>{t("noLimit")}</SelectItem>
<SelectItem value="lock">{t("lock")}</SelectItem>
<SelectItem value="release">{t("release")}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -120,7 +122,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
setPage(1);
}}
>
{t("applyFilter")}
</Button>
</div>
</div>
@@ -128,20 +130,20 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading && !data ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : (
<>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("time")}</TableHead>
<TableHead>{t("searchNumber")}</TableHead>
<TableHead>{t("action")}</TableHead>
<TableHead className="text-right">{t("amount")}</TableHead>
<TableHead>{t("source")}</TableHead>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminRiskPoolDetail } from "@/api/admin-risk";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -28,6 +29,7 @@ export function RiskPoolDetailConsole({
drawId: number;
number4d: string;
}) {
const { t } = useTranslation(["risk", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
@@ -43,13 +45,13 @@ export function RiskPoolDetailConsole({
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载风险池详情失败";
e instanceof LotteryApiBizError ? e.message : t("loadDetailFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [drawId, number4d, page, perPage]);
}, [drawId, number4d, page, perPage, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -59,9 +61,9 @@ export function RiskPoolDetailConsole({
if (error && !data) {
return (
<Card className="border-destructive/40">
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("detailTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-destructive">{error}</p>
@@ -69,7 +71,7 @@ export function RiskPoolDetailConsole({
href={`/admin/risk/draws/${drawId}/pools`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("backToList")}
</Link>
</CardContent>
</Card>
@@ -77,7 +79,7 @@ export function RiskPoolDetailConsole({
}
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
}
if (!data) {
@@ -93,41 +95,41 @@ export function RiskPoolDetailConsole({
href={`/admin/risk/draws/${drawId}/pools`}
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
>
{t("backToAllPools")}
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">
<span className="font-mono">{pool.normalized_number}</span>
{t("numberTitle", { number: pool.normalized_number })}
</CardTitle>
<p className="text-sm text-muted-foreground"> {data.draw_no}</p>
<p className="text-sm text-muted-foreground">{t("drawMeta", { drawNo: data.draw_no })}</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border bg-muted/40 p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t("totalCap")}</p>
<p className="mt-1 font-mono text-sm font-medium tabular-nums">
{formatAdminMinorUnits(pool.total_cap_amount)}
</p>
</div>
<div className="rounded-lg border bg-muted/40 p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t("lockedWorstCase")}</p>
<p className="mt-1 font-mono text-sm font-medium tabular-nums">
{formatAdminMinorUnits(pool.locked_amount)}
</p>
</div>
<div className="rounded-lg border bg-muted/40 p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="text-xs text-muted-foreground">{t("remainingSellable")}</p>
<p className="mt-1 font-mono text-sm font-medium tabular-nums">
{formatAdminMinorUnits(pool.remaining_amount)}
</p>
</div>
<div className="rounded-lg border bg-muted/40 p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-sm font-medium">{pool.is_sold_out ? "是" : "否"}</p>
<p className="text-xs text-muted-foreground">{t("isSoldOut")}</p>
<p className="mt-1 text-sm font-medium">{pool.is_sold_out ? t("yes") : t("no")}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{" "}
{t("usageRatio")}{" "}
{pool.usage_ratio != null ? `${(pool.usage_ratio * 100).toFixed(2)}%` : "—"}
</p>
</div>
@@ -136,19 +138,19 @@ export function RiskPoolDetailConsole({
<Card>
<CardHeader>
<CardTitle className="text-base"> / </CardTitle>
<CardTitle className="text-base">{t("occupationLogs")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("time")}</TableHead>
<TableHead>{t("action")}</TableHead>
<TableHead className="text-right">{t("amount")}</TableHead>
<TableHead>{t("source")}</TableHead>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -37,10 +38,10 @@ import type { AdminRiskPoolListData, AdminRiskPoolRow } from "@/types/api/admin-
const SORT_OPTIONS: { value: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc"; label: string }[] =
[
{ value: "usage_desc", label: "占用比 ↓(热门)" },
{ value: "locked_desc", label: "已占用额 ↓" },
{ value: "remaining_asc", label: "剩余额 ↑(紧俏)" },
{ value: "number_asc", label: "号码 ↑" },
{ value: "usage_desc", label: "sortUsageDesc" },
{ value: "locked_desc", label: "sortLockedDesc" },
{ value: "remaining_asc", label: "sortRemainingAsc" },
{ value: "number_asc", label: "sortNumberAsc" },
];
type RiskFilter = "all" | "sold_out" | "high_risk";
@@ -60,6 +61,7 @@ export function RiskPoolsConsole({
defaultSort,
allowSortChange = false,
}: RiskPoolsConsoleProps) {
const { t } = useTranslation(["risk", "common"]);
const [sort, setSort] = useState(defaultSort);
const [filter, setFilter] = useState<RiskFilter>(soldOutOnly ? "sold_out" : "all");
const [number, setNumber] = useState("");
@@ -85,13 +87,13 @@ export function RiskPoolsConsole({
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : "加载风险池失败";
e instanceof LotteryApiBizError ? e.message : t("loadPoolsFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [drawId, filter, number, page, perPage, sort]);
}, [drawId, filter, number, page, perPage, sort, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -117,14 +119,14 @@ export function RiskPoolsConsole({
),
};
});
toast.success(row.is_sold_out ? "已恢复号码下注" : "已手动关闭号码下注");
toast.success(row.is_sold_out ? t("recoverSuccess") : t("manualCloseSuccess"));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "操作失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed"));
} finally {
setActingNumber(null);
}
},
[drawId],
[drawId, t],
);
return (
@@ -133,12 +135,12 @@ export function RiskPoolsConsole({
<CardTitle className="text-lg">{title}</CardTitle>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<Label htmlFor="risk-pool-number"></Label>
<Label htmlFor="risk-pool-number">{t("searchNumber")}</Label>
<Input
id="risk-pool-number"
value={number}
maxLength={4}
placeholder="如 8888"
placeholder={t("searchNumberPlaceholder")}
className="h-9 w-32 font-mono"
onChange={(event) => {
setNumber(event.target.value.replace(/\D/g, "").slice(0, 4));
@@ -147,12 +149,12 @@ export function RiskPoolsConsole({
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Label>{t("riskFilter")}</Label>
<div className="flex flex-wrap gap-2">
{[
["all", "全部"],
["sold_out", "售罄"],
["high_risk", ">80%"],
["all", t("filterAll")],
["sold_out", t("filterSoldOut")],
["high_risk", t("filterHighRisk")],
].map(([value, label]) => (
<Button
key={value}
@@ -171,7 +173,7 @@ export function RiskPoolsConsole({
</div>
{allowSortChange ? (
<div className="space-y-1.5">
<Label htmlFor="risk-pool-sort"></Label>
<Label htmlFor="risk-pool-sort">{t("sort")}</Label>
<Select
modal={false}
value={sort}
@@ -187,7 +189,7 @@ export function RiskPoolsConsole({
<SelectContent>
{SORT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
@@ -199,20 +201,20 @@ export function RiskPoolsConsole({
<CardContent className="space-y-4">
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loading && !data ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : (
<>
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t("searchNumber")}</TableHead>
<TableHead className="text-right">{t("capAmount")}</TableHead>
<TableHead className="text-right">{t("lockedAmount")}</TableHead>
<TableHead className="text-right">{t("remainingAmount")}</TableHead>
<TableHead className="text-right">{t("usageRatio")}</TableHead>
<TableHead>{t("poolStatus")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -255,7 +257,7 @@ export function RiskPoolsConsole({
: "bg-muted text-muted-foreground",
)}
>
{row.is_sold_out ? "售罄" : highRisk ? "预警" : "正常"}
{row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")}
</span>
</TableCell>
<TableCell className="text-right">
@@ -267,7 +269,7 @@ export function RiskPoolsConsole({
disabled={acting}
onClick={() => void handleManualStatus(row)}
>
{row.is_sold_out ? "恢复" : "关闭"}
{row.is_sold_out ? t("recover") : t("close")}
</Button>
<Link
href={`/admin/risk/draws/${drawId}/pools/${row.normalized_number}`}
@@ -276,7 +278,7 @@ export function RiskPoolsConsole({
"h-8 px-0",
)}
>
{t("view")}
</Link>
</div>
</TableCell>

View File

@@ -2,18 +2,20 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const segments = [
{ suffix: "/occupancy", key: "occupancy", label: "风险占用" },
{ suffix: "/hot", key: "hot", label: "热门号码" },
{ suffix: "/sold-out", key: "sold-out", label: "售罄列表" },
{ suffix: "/pools", key: "pools", label: "全部风险池" },
{ suffix: "/occupancy", key: "occupancy", label: "subnavOccupancy" },
{ suffix: "/hot", key: "hot", label: "subnavHot" },
{ suffix: "/sold-out", key: "sold-out", label: "subnavSoldOut" },
{ suffix: "/pools", key: "pools", label: "subnavPools" },
] as const;
export function RiskSubnav({ drawId }: { drawId: string }) {
const { t } = useTranslation("risk");
const pathname = usePathname();
const base = `/admin/risk/draws/${drawId}`;
@@ -31,7 +33,7 @@ export function RiskSubnav({ drawId }: { drawId: string }) {
href={href}
className={cn(buttonVariants({ variant: active ? "default" : "outline", size: "sm" }))}
>
{label}
{t(label)}
</Link>
);
})}
@@ -39,7 +41,7 @@ export function RiskSubnav({ drawId }: { drawId: string }) {
href="/admin/risk"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "ml-auto")}
>
{t("changeDraw")}
</Link>
</nav>
);

View File

@@ -1,5 +1,5 @@
export const settingsModuleMeta = {
segment: "settings",
title: "系统设置",
title: "Settings",
description: "",
} as const;

View File

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

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -38,6 +39,7 @@ type Props = {
};
export function SettlementBatchDetailsConsole({ batchId }: Props) {
const { t } = useTranslation(["settlement", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [summary, setSummary] = useState<AdminSettlementBatchShowData | null>(null);
const [details, setDetails] = useState<AdminSettlementBatchDetailsData | null>(null);
@@ -58,29 +60,29 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
setSummary(s);
setDetails(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setSummary(null);
setDetails(null);
} finally {
setLoading(false);
}
}, [batchId, page, perPage]);
}, [batchId, page, perPage, t]);
async function runAction(label: string, action: () => Promise<unknown>): Promise<void> {
setActing(label);
try {
await action();
toast.success(`${label}成功`);
toast.success(t("actionSuccess", { name: label }));
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : `${label}失败`);
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: label }));
} finally {
setActing(null);
}
}
async function exportCsv(): Promise<void> {
setActing("导出");
setActing(t("export"));
try {
const blob = await downloadAdminSettlementBatchExport(batchId);
const url = URL.createObjectURL(blob);
@@ -92,7 +94,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "导出失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("exportFailed"));
} finally {
setActing(null);
}
@@ -107,19 +109,19 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
<ModuleScaffold>
<div className="mb-4">
<Link href="/admin/settlement-batches" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "px-0")}>
{t("backToList")}
</Link>
</div>
{err ? (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("errorTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-destructive">{err}</p>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("retry")}
</Button>
</CardContent>
</Card>
@@ -128,44 +130,47 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
{summary ? (
<Card className="mb-6">
<CardHeader>
<CardTitle className="font-mono text-base"> #{summary.id}</CardTitle>
<CardTitle className="font-mono text-base">{t("batchSummary", { id: summary.id })}</CardTitle>
<p className="text-sm text-muted-foreground">
{summary.draw_no ?? "—"} · {summary.draw_status ?? "—"} · v
{summary.result_batch_version ?? "—"}
{t("summaryMeta", {
drawNo: summary.draw_no ?? "—",
drawStatus: summary.draw_status ?? "—",
version: summary.result_batch_version ?? "—",
})}
</p>
</CardHeader>
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="text-muted-foreground">{t("settlementStatus")}</span>{" "}
<span className="font-mono">{summary.status}</span>
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="text-muted-foreground">{t("reviewState")}</span>{" "}
<span className="font-mono">{summary.review_status ?? "—"}</span>
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="text-muted-foreground">{t("ticketTotal")}</span>{" "}
<span className="tabular-nums">{summary.total_ticket_count}</span>
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="text-muted-foreground">{t("winTotal")}</span>{" "}
<span className="tabular-nums">{summary.total_win_count}</span>
</p>
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="text-muted-foreground">{t("payoutAmount")}</span>{" "}
<span className="font-mono tabular-nums">{formatAdminMinorUnits(summary.total_payout_amount)}</span>
</p>
<p>
<span className="text-muted-foreground">Jackpot </span>{" "}
<span className="text-muted-foreground">{t("jackpotPayout")}</span>{" "}
<span className="font-mono tabular-nums">
{formatAdminMinorUnits(summary.total_jackpot_payout_amount)}
</span>
</p>
<p>
<span className="text-muted-foreground"></span> {formatDt(summary.started_at)}
<span className="text-muted-foreground">{t("startedAt")}</span> {formatDt(summary.started_at)}
</p>
<p>
<span className="text-muted-foreground"></span> {formatDt(summary.finished_at)}
<span className="text-muted-foreground">{t("endedAt")}</span> {formatDt(summary.finished_at)}
</p>
<div className="flex flex-wrap gap-2 sm:col-span-2">
<Button
@@ -173,40 +178,40 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
size="sm"
variant="outline"
disabled={acting !== null || summary.status !== "pending_review"}
onClick={() => void runAction("审核通过", () => postAdminApproveSettlementBatch(batchId))}
onClick={() => void runAction(t("approve"), () => postAdminApproveSettlementBatch(batchId))}
>
{t("approve")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={acting !== null || summary.status !== "pending_review"}
onClick={() => void runAction("驳回", () => postAdminRejectSettlementBatch(batchId))}
onClick={() => void runAction(t("reject"), () => postAdminRejectSettlementBatch(batchId))}
>
{t("reject")}
</Button>
<Button
type="button"
size="sm"
disabled={acting !== null || summary.status !== "approved"}
onClick={() => void runAction("执行派彩", () => postAdminPayoutSettlementBatch(batchId))}
onClick={() => void runAction(t("runPayout"), () => postAdminPayoutSettlementBatch(batchId))}
>
{t("runPayout")}
</Button>
<Button type="button" size="sm" variant="secondary" disabled={acting !== null} onClick={() => void exportCsv()}>
{t("exportSettlementReport")}
</Button>
</div>
</CardContent>
</Card>
) : loading ? (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("loadingSummary")}</p>
) : null}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("detailTitle")}</CardTitle>
</CardHeader>
<CardContent>
{details ? (
@@ -214,12 +219,12 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">Jackpot</TableHead>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
<TableHead>{t("player")}</TableHead>
<TableHead>{t("matchedTier")}</TableHead>
<TableHead className="text-right">{t("regularPayout")}</TableHead>
<TableHead className="text-right">{t("jackpot")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -256,7 +261,9 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
/>
</>
) : (
<p className="text-muted-foreground text-sm">{loading ? "加载明细…" : "无数据"}</p>
<p className="text-muted-foreground text-sm">
{loading ? t("loadingDetails") : t("states.noData", { ns: "common" })}
</p>
)}
</CardContent>
</Card>

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -42,13 +43,14 @@ import { settlementModuleMeta } from "@/modules/settlement/meta";
const STATUS_ALL = "__all__";
const STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: STATUS_ALL, label: "不限" },
{ value: "running", label: "进行中" },
{ value: "completed", label: "已完成" },
{ value: "failed", label: "失败" },
{ value: STATUS_ALL, label: "statusOptions.all" },
{ value: "running", label: "statusOptions.running" },
{ value: "completed", label: "statusOptions.completed" },
{ value: "failed", label: "statusOptions.failed" },
];
export function SettlementBatchesConsole() {
const { t } = useTranslation(["settlement", "common"]);
const formatDt = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminSettlementBatchListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -76,12 +78,12 @@ export function SettlementBatchesConsole() {
});
setData(d);
} catch (e) {
setError(e instanceof LotteryApiBizError ? e.message : "加载失败");
setError(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, appliedDrawNo, appliedStatus]);
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
useEffect(() => {
const t = window.setTimeout(() => void load(), 0);
@@ -98,10 +100,10 @@ export function SettlementBatchesConsole() {
setActingId(batchId);
try {
await action();
toast.success(`${label}成功`);
toast.success(t("actionSuccess", { name: label }));
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : `${label}失败`);
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: label }));
} finally {
setActingId(null);
}
@@ -120,7 +122,7 @@ export function SettlementBatchesConsole() {
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "导出失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("exportFailed"));
} finally {
setActingId(null);
}
@@ -133,21 +135,21 @@ export function SettlementBatchesConsole() {
</div>
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("filter")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end">
<div className="flex min-w-[12rem] flex-1 flex-col gap-1.5">
<Label htmlFor="sb-draw-no"></Label>
<Label htmlFor="sb-draw-no">{t("drawNo")}</Label>
<Input
id="sb-draw-no"
value={draftDrawNo}
onChange={(e) => setDraftDrawNo(e.target.value)}
placeholder="如 20260511-001"
placeholder={t("placeholderDrawNo")}
className="font-mono"
/>
</div>
<div className="flex min-w-[10rem] flex-col gap-1.5">
<Label></Label>
<Label>{t("status")}</Label>
<Select value={draftStatus} onValueChange={(v) => setDraftStatus(v ?? STATUS_ALL)}>
<SelectTrigger>
<SelectValue />
@@ -155,40 +157,40 @@ export function SettlementBatchesConsole() {
<SelectContent>
{STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" onClick={applyFilters}>
{t("apply")}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("batchList")}</CardTitle>
</CardHeader>
<CardContent>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
{loading && !data ? (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">Jackpot</TableHead>
<TableHead></TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("version", { ns: "draws", version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("reviewStatus")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("ticketCount")}</TableHead>
<TableHead className="text-right">{t("winCount")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("jackpot")}</TableHead>
<TableHead>{t("finishedAt")}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
@@ -230,33 +232,33 @@ export function SettlementBatchesConsole() {
href={`/admin/settlement-batches/${row.id}/details`}
className={cn(buttonVariants({ variant: "link", size: "sm" }), "px-0")}
>
{t("details")}
</Link>
<Button
type="button"
size="sm"
variant="outline"
disabled={actingId !== null || row.status !== "pending_review"}
onClick={() => void runBatchAction(row.id, "审核通过", () => postAdminApproveSettlementBatch(row.id))}
onClick={() => void runBatchAction(row.id, t("approve"), () => postAdminApproveSettlementBatch(row.id))}
>
{t("pass")}
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={actingId !== null || row.status !== "pending_review"}
onClick={() => void runBatchAction(row.id, "驳回", () => postAdminRejectSettlementBatch(row.id))}
onClick={() => void runBatchAction(row.id, t("reject"), () => postAdminRejectSettlementBatch(row.id))}
>
{t("reject")}
</Button>
<Button
type="button"
size="sm"
disabled={actingId !== null || row.status !== "approved"}
onClick={() => void runBatchAction(row.id, "执行派彩", () => postAdminPayoutSettlementBatch(row.id))}
onClick={() => void runBatchAction(row.id, t("runPayout"), () => postAdminPayoutSettlementBatch(row.id))}
>
{t("payout")}
</Button>
<Button
type="button"
@@ -265,7 +267,7 @@ export function SettlementBatchesConsole() {
disabled={actingId !== null}
onClick={() => void exportBatch(row.id)}
>
{t("export")}
</Button>
</div>
</TableCell>

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -20,6 +21,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerTicketItemsData } from "@/types/api/admin-player-tickets";
export function PlayerTicketsConsole(): React.ReactElement {
const { t } = useTranslation(["tickets", "common"]);
const [playerIdDraft, setPlayerIdDraft] = useState("");
const [drawNoDraft, setDrawNoDraft] = useState("");
const [playerId, setPlayerId] = useState<number | null>(null);
@@ -45,12 +47,12 @@ export function PlayerTicketsConsole(): React.ReactElement {
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [playerId, page, perPage, drawNo]);
}, [playerId, page, perPage, drawNo, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -61,7 +63,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
const runSearch = () => {
const id = Number(playerIdDraft.trim());
if (Number.isNaN(id) || id < 1) {
setErr("请输入有效玩家 ID");
setErr(t("invalidPlayerId"));
setPlayerId(null);
setData(null);
return;
@@ -75,12 +77,12 @@ export function PlayerTicketsConsole(): React.ReactElement {
return (
<Card className="w-full max-w-none">
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("playerTicketQuery")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="grid gap-1.5">
<Label htmlFor="pt-player"> ID</Label>
<Label htmlFor="pt-player">{t("playerId")}</Label>
<Input
id="pt-player"
inputMode="numeric"
@@ -91,23 +93,23 @@ export function PlayerTicketsConsole(): React.ReactElement {
/>
</div>
<div className="grid min-w-[10rem] flex-1 gap-1.5">
<Label htmlFor="pt-draw"> draw_no</Label>
<Label htmlFor="pt-draw">{t("drawNoOptional")}</Label>
<Input
id="pt-draw"
className="font-mono text-sm"
placeholder="如 20260520-001"
placeholder={t("drawNoPlaceholder")}
value={drawNoDraft}
onChange={(e) => setDrawNoDraft(e.target.value)}
/>
</div>
<Button type="button" onClick={() => runSearch()}>
{t("query")}
</Button>
</div>
{err ? <p className="text-sm text-destructive">{err}</p> : null}
{loading && playerId != null ? (
<p className="text-muted-foreground text-sm"></p>
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
@@ -116,22 +118,22 @@ export function PlayerTicketsConsole(): React.ReactElement {
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("orderNo")}</TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
<TableHead>{t("number")}</TableHead>
<TableHead className="text-right">{t("actualDeduct")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("failReason")}</TableHead>
<TableHead className="text-right">{t("winAmount")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (

View File

@@ -1,5 +1,5 @@
export const walletModuleMeta = {
segment: "wallet",
title: "钱包流水与对账",
title: "Wallet",
description: "",
} as const;

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
@@ -58,6 +59,7 @@ function CellMonoId({
/** 用于 toast / 无障碍:如「流水号」「主站流水号」 */
copyHint?: string;
}): React.ReactElement {
const { t } = useTranslation("wallet");
if (value == null || value === "") {
return <span className="text-muted-foreground">{empty}</span>;
}
@@ -68,11 +70,11 @@ function CellMonoId({
await navigator.clipboard.writeText(value);
toast.success(
copyHint
? `${copyHint}已复制到剪贴板`
: "已复制到剪贴板",
? t("copySuccess", { label: copyHint })
: t("copySuccess", { label: "" }).trim(),
);
} catch {
toast.error("复制失败,请检查浏览器权限或手动选择文本");
toast.error(t("copyFailed"));
}
};
@@ -80,8 +82,8 @@ function CellMonoId({
<button
type="button"
className="group inline-flex min-w-0 w-full max-w-full items-center gap-1 rounded-md border border-transparent px-0.5 py-0.5 text-left font-mono text-xs transition-colors hover:border-border hover:bg-muted/60"
title={`${value}\n点击复制`}
aria-label={copyHint ? `复制${copyHint}` : "复制到剪贴板"}
title={value}
aria-label={copyHint ?? t("copyTxnNo")}
onClick={(e) => void copy(e)}
>
<span className="min-w-0 flex-1 truncate">{value}</span>
@@ -103,22 +105,22 @@ function statusBadgeVariant(
return "default";
}
function statusLabel(status: string): string {
function statusLabelT(status: string, t: (key: string) => string): string {
switch (status) {
case "processing":
return "处理中";
return t("statusProcessing");
case "success":
return "成功";
return t("statusSuccess");
case "failed":
return "失败";
return t("statusFailed");
case "pending_reconcile":
return "待对账";
return t("statusPendingReconcile");
case "reversed":
return "已冲正";
return t("statusReversed");
case "manually_processed":
return "已人工处理";
return t("statusManuallyProcessed");
case "posted":
return "已记账";
return t("statusPosted");
default:
return status;
}
@@ -175,41 +177,44 @@ const WALLET_FILTER_ALL = "__all__";
/** 与 {@see WalletTransactionListController}、{@see LotteryTransferService} 当前写入的 biz_type 一致 */
const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [
{ value: "transfer_in", label: "主站转入" },
{ value: "transfer_out", label: "主站转出" },
{ value: "transfer_out_refund", label: "转出失败回补" },
{ value: "transfer_in", label: "transferIn" },
{ value: "transfer_out", label: "transferOut" },
{ value: "transfer_out_refund", label: "transferOutRefund" },
];
/** 与 {@see WalletTransactionListController::ALLOWED_STATUS} 一致 */
const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "posted", label: "已记账" },
{ value: "pending_reconcile", label: "待对账" },
{ value: "reversed", label: "已冲正" },
{ value: "posted", label: "statusPosted" },
{ value: "pending_reconcile", label: "statusPendingReconcile" },
{ value: "reversed", label: "statusReversed" },
];
/** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */
const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "processing", label: "处理中" },
{ value: "success", label: "成功" },
{ value: "failed", label: "失败" },
{ value: "pending_reconcile", label: "待对账" },
{ value: "reversed", label: "已冲正" },
{ value: "manually_processed", label: "已人工处理" },
{ value: "processing", label: "statusProcessing" },
{ value: "success", label: "statusSuccess" },
{ value: "failed", label: "statusFailed" },
{ value: "pending_reconcile", label: "statusPendingReconcile" },
{ value: "reversed", label: "statusReversed" },
{ value: "manually_processed", label: "statusManuallyProcessed" },
];
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
function walletAdminSelectDisplayedLabel(
raw: unknown,
options: readonly { value: string; label: string }[],
t?: (key: string) => string,
): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === WALLET_FILTER_ALL) {
return "不限";
return t ? t("filterAll") : "All";
}
return options.find((o) => o.value === v)?.label ?? v;
const key = options.find((o) => o.value === v)?.label;
return key ? (t ? t(key) : key) : v;
}
export function TransferOrdersPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminTransferOrderListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -231,7 +236,7 @@ export function TransferOrdersPanel(): React.ReactElement {
toast.success(successMsg);
void load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "操作失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed"));
} finally {
setActionLoading((prev) => {
const next = new Set(prev);
@@ -242,10 +247,10 @@ export function TransferOrdersPanel(): React.ReactElement {
};
const handleReverse = (transferNo: string) =>
doAction(transferNo, () => reverseTransferOrder(transferNo), "冲正成功");
doAction(transferNo, () => reverseTransferOrder(transferNo), t("reverseSuccess"));
const handleManuallyProcess = (transferNo: string) =>
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), "人工处理成功");
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess"));
const load = useCallback(async () => {
setLoading(true);
@@ -270,12 +275,12 @@ export function TransferOrdersPanel(): React.ReactElement {
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, applied]);
}, [page, perPage, applied, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -297,43 +302,43 @@ export function TransferOrdersPanel(): React.ReactElement {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("transferOrders")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="to-transfer-no"></Label>
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
<Input
id="to-transfer-no"
placeholder="模糊"
placeholder={t("search")}
value={draft.transferNo}
onChange={(e) => setDraft((d) => ({ ...d, transferNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-ext"></Label>
<Label htmlFor="to-ext">{t("externalRefNo")}</Label>
<Input
id="to-ext"
placeholder="模糊"
placeholder={t("search")}
value={draft.externalRefNo}
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-account"></Label>
<Label htmlFor="to-account">{t("playerAccount")}</Label>
<Input
id="to-account"
placeholder="主站玩家 ID 或用户名(模糊)"
placeholder={t("playerAccountPlaceholder")}
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-player"> ID</Label>
<Label htmlFor="to-player">{t("playerId")}</Label>
<Input
id="to-player"
inputMode="numeric"
placeholder="可选,优先于账号"
placeholder={t("playerIdOptional")}
value={draft.playerId}
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
@@ -341,7 +346,7 @@ export function TransferOrdersPanel(): React.ReactElement {
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="to-created-range"
label="请求日期范围"
label={t("requestDateRange")}
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
@@ -350,7 +355,7 @@ export function TransferOrdersPanel(): React.ReactElement {
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-status"></Label>
<Label htmlFor="to-status">{t("status")}</Label>
<Select
modal={false}
value={
@@ -367,21 +372,21 @@ export function TransferOrdersPanel(): React.ReactElement {
>
<SelectTrigger id="to-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, TRANSFER_ORDER_STATUS_OPTIONS)}
{(v) => walletAdminSelectDisplayedLabel(v, TRANSFER_ORDER_STATUS_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{TRANSFER_ORDER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
<span className="text-sm font-medium leading-none"></span>
<span className="text-sm font-medium leading-none">{t("options")}</span>
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={draft.abnormalOnly}
@@ -389,25 +394,25 @@ export function TransferOrdersPanel(): React.ReactElement {
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
}
/>
{t("abnormalOnly")}
</label>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("search")}
</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>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
@@ -416,37 +421,33 @@ export function TransferOrdersPanel(): React.ReactElement {
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]"></TableHead>
<TableHead className="min-w-0 max-w-[12rem]"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="w-14"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="min-w-0 max-w-[14rem]"></TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">
</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">
</TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<TableHead className="whitespace-nowrap">{t("playerAccount")}</TableHead>
<TableHead className="w-14">{t("direction")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="min-w-0 max-w-[14rem]">{t("failReason")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead>
<TableHead className="w-24">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
<CellMonoId value={row.transfer_no} copyHint="本地单号" />
<CellMonoId value={row.transfer_no} copyHint={t("copyTransferNo")} />
</TableCell>
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint="主站流水号" />
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
</TableCell>
<TableCell className="text-xs">
#{row.player_id}
@@ -460,7 +461,7 @@ export function TransferOrdersPanel(): React.ReactElement {
{formatMinorUnits(row.amount, row.currency_code)}
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(row.status)}>{statusLabel(row.status)}</Badge>
<Badge variant={statusBadgeVariant(row.status)}>{statusLabelT(row.status, t)}</Badge>
</TableCell>
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
{row.fail_reason?.trim() ? row.fail_reason : "—"}
@@ -481,7 +482,7 @@ export function TransferOrdersPanel(): React.ReactElement {
disabled={actionLoading.has(row.transfer_no)}
onClick={() => handleReverse(row.transfer_no)}
>
{actionLoading.has(row.transfer_no) ? "处理中…" : "冲正"}
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
</Button>
<Button
size="sm"
@@ -490,7 +491,7 @@ export function TransferOrdersPanel(): React.ReactElement {
disabled={actionLoading.has(row.transfer_no)}
onClick={() => handleManuallyProcess(row.transfer_no)}
>
{actionLoading.has(row.transfer_no) ? "处理中…" : "人工处理"}
{actionLoading.has(row.transfer_no) ? t("processing") : t("manualProcess")}
</Button>
</div>
) : (
@@ -524,6 +525,7 @@ export function TransferOrdersPanel(): React.ReactElement {
}
export function WalletTxnsPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminWalletTxnListData | null>(null);
const [loading, setLoading] = useState(true);
@@ -557,12 +559,12 @@ export function WalletTxnsPanel(): React.ReactElement {
});
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, applied]);
}, [page, perPage, applied, t]);
useEffect(() => {
queueMicrotask(() => {
@@ -584,49 +586,49 @@ export function WalletTxnsPanel(): React.ReactElement {
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("walletTransactions")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="tx-no"></Label>
<Label htmlFor="tx-no">{t("txnNo")}</Label>
<Input
id="tx-no"
placeholder="模糊"
placeholder={t("search")}
value={draft.txnNo}
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-ext"></Label>
<Label htmlFor="tx-ext">{t("externalRefNo")}</Label>
<Input
id="tx-ext"
placeholder="模糊"
placeholder={t("search")}
value={draft.externalRefNo}
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-account"></Label>
<Label htmlFor="tx-account">{t("playerAccount")}</Label>
<Input
id="tx-account"
placeholder="主站玩家 ID 或用户名(模糊)"
placeholder={t("playerAccountPlaceholder")}
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-player"> ID</Label>
<Label htmlFor="tx-player">{t("playerId")}</Label>
<Input
id="tx-player"
inputMode="numeric"
placeholder="可选,优先于账号"
placeholder={t("playerIdOptional")}
value={draft.playerId}
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-biz"></Label>
<Label htmlFor="tx-biz">{t("bizType")}</Label>
<Select
modal={false}
value={
@@ -643,21 +645,21 @@ export function WalletTxnsPanel(): React.ReactElement {
>
<SelectTrigger id="tx-biz" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS)}
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{WALLET_TXN_BIZ_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-status"></Label>
<Label htmlFor="tx-status">{t("status")}</Label>
<Select
modal={false}
value={
@@ -674,14 +676,14 @@ export function WalletTxnsPanel(): React.ReactElement {
>
<SelectTrigger id="tx-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS)}
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}></SelectItem>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{WALLET_TXN_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
{t(o.label)}
</SelectItem>
))}
</SelectContent>
@@ -690,7 +692,7 @@ export function WalletTxnsPanel(): React.ReactElement {
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="tx-created-range"
label="请求日期范围"
label={t("requestDateRange")}
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
@@ -699,7 +701,7 @@ export function WalletTxnsPanel(): React.ReactElement {
/>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
<span className="text-sm font-medium leading-none"></span>
<span className="text-sm font-medium leading-none">{t("options")}</span>
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={draft.abnormalOnly}
@@ -707,25 +709,25 @@ export function WalletTxnsPanel(): React.ReactElement {
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
}
/>
{t("abnormalOnlyPending")}
</label>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("search")}
</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>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
) : null}
{data ? (
@@ -734,35 +736,31 @@ export function WalletTxnsPanel(): React.ReactElement {
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]"></TableHead>
<TableHead className="min-w-0 max-w-[12rem]"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="whitespace-nowrap"></TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">
</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">
</TableHead>
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<TableHead className="whitespace-nowrap">{t("playerAccount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
<CellMonoId value={row.txn_no} copyHint="流水号" />
<CellMonoId value={row.txn_no} copyHint={t("copyTxnNo")} />
</TableCell>
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint="主站流水号" />
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
</TableCell>
<TableCell className="min-w-0 text-xs">
#{row.player_id}
@@ -773,10 +771,10 @@ export function WalletTxnsPanel(): React.ReactElement {
</TableCell>
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
<TableCell className="tabular-nums text-xs">
{row.amount} ({row.direction === 1 ? "入" : "出"})
{row.amount} ({row.direction === 1 ? t("in") : t("out")})
</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(row.status)}>{statusLabel(row.status)}</Badge>
<Badge variant={statusBadgeVariant(row.status)}>{statusLabelT(row.status, t)}</Badge>
</TableCell>
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.created_at)}
@@ -811,6 +809,7 @@ export function WalletTxnsPanel(): React.ReactElement {
}
export function PlayerWalletPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const [playerId, setPlayerId] = useState("");
const [result, setResult] = useState<AdminPlayerWalletsData | null>(null);
const [err, setErr] = useState<string | null>(null);
@@ -819,7 +818,7 @@ export function PlayerWalletPanel(): React.ReactElement {
const query = useCallback(async () => {
const id = Number(playerId.trim());
if (Number.isNaN(id) || id < 1) {
setErr("请输入有效玩家 ID");
setErr(t("invalidPlayerId"));
setResult(null);
return;
}
@@ -829,57 +828,57 @@ export function PlayerWalletPanel(): React.ReactElement {
const d = await getAdminPlayerWallets(id);
setResult(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "查询失败");
setErr(e instanceof LotteryApiBizError ? e.message : t("queryFailed"));
setResult(null);
} finally {
setLoading(false);
}
}, [playerId]);
}, [playerId, t]);
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>{t("playerWalletQuery")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1.5">
<Label htmlFor="pw-id"> ID</Label>
<Label htmlFor="pw-id">{t("playerId")}</Label>
<Input
id="pw-id"
inputMode="numeric"
placeholder="例如 1"
placeholder="1"
value={playerId}
onChange={(e) => setPlayerId(e.target.value)}
className="w-40"
/>
</div>
<Button type="button" onClick={() => void query()} disabled={loading}>
{loading ? "查询中…" : "查询"}
{loading ? t("querying") : t("query")}
</Button>
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{result ? (
<div className="space-y-3 rounded-lg border p-4 text-sm">
<p>
<span className="text-muted-foreground"></span>{" "}
<span className="text-muted-foreground">{t("sitePlayer")}</span>{" "}
{result.player.site_code}:{result.player.site_player_id}
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("walletType")}</TableHead>
<TableHead>{t("currency")}</TableHead>
<TableHead>{t("balanceMinor")}</TableHead>
<TableHead>{t("availableBalance")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.wallets.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
</TableCell>
{t("noWalletRows")}
</TableCell>
</TableRow>
) : (
result.wallets.map((w) => (

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
@@ -14,18 +15,19 @@ const RECONCILE_PERMS = [
] as const;
const tabs: { href: string; label: string; requiredAny: readonly string[] }[] = [
{ href: "/admin/wallet/transactions", label: "钱包流水", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/transfer-orders", label: "转账单", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/transactions", label: "subnavTransactions", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/transfer-orders", label: "subnavTransferOrders", requiredAny: RECONCILE_PERMS },
];
export function WalletSubnav(): React.ReactElement {
const { t } = useTranslation("wallet");
const pathname = usePathname();
const profile = useAdminProfile();
const perms = profile?.permissions;
return (
<nav
aria-label="钱包子页"
aria-label={t("subnavLabel")}
className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3"
>
{tabs.map((t) => {
@@ -40,14 +42,14 @@ export function WalletSubnav(): React.ReactElement {
);
if (!allowed) {
return (
<span key={t.href} className={className} title="当前账号无访问该页的权限">
{t.label}
<span key={t.href} className={className} title={t("noPermission")}>
{t(t.label)}
</span>
);
}
return (
<Link key={t.href} href={t.href} className={className}>
{t.label}
{t(t.label)}
</Link>
);
})}