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

@@ -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>
);
})}