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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ const LINKS: { href: string; key: string; match?: "exact" | "prefix" }[] = [
{ href: "/admin/config/odds", key: "odds" },
{ href: "/admin/config/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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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