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