feat: 增加管理端多语言与多模块界面国际化支持
This commit is contained in:
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const adminUsersModuleMeta = {
|
||||
segment: "admin_users",
|
||||
title: "管理列表",
|
||||
title: "Admins",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const auditLogsModuleMeta = {
|
||||
segment: "audit-logs",
|
||||
title: "审计日志",
|
||||
title: "Audit Logs",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const authModuleMeta = {
|
||||
segment: "login",
|
||||
title: "登录",
|
||||
title: "Login",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const dashboardModuleMeta = {
|
||||
segment: "dashboard",
|
||||
title: "仪表盘",
|
||||
title: "Dashboard",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const drawsModuleMeta = {
|
||||
segment: "draws",
|
||||
title: "期号列表",
|
||||
title: "Draws",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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}`}>蓄水比例 0–1</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}`}>爆池派彩比例 0–1</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const jackpotModuleMeta = {
|
||||
title: "奖池",
|
||||
title: "Jackpot",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const playersModuleMeta = {
|
||||
segment: "players",
|
||||
title: "玩家列表",
|
||||
title: "Players",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const reconcileModuleMeta = {
|
||||
segment: "reconcile",
|
||||
title: "对账",
|
||||
title: "Reconcile",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const reportsModuleMeta = {
|
||||
segment: "reports",
|
||||
title: "报表",
|
||||
title: "Reports",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const riskModuleMeta = {
|
||||
segment: "risk",
|
||||
title: "风控",
|
||||
title: "Risk",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const settingsModuleMeta = {
|
||||
segment: "settings",
|
||||
title: "系统设置",
|
||||
title: "Settings",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const settlementModuleMeta = {
|
||||
title: "结算",
|
||||
title: "Settlement",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const ticketsModuleMeta = {
|
||||
segment: "tickets",
|
||||
title: "注单",
|
||||
title: "Tickets",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const walletModuleMeta = {
|
||||
segment: "wallet",
|
||||
title: "钱包流水与对账",
|
||||
title: "Wallet",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user