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

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

View File

@@ -1,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>
))
)}