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, 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>