feat: 增加管理端多语言与多模块界面国际化支持
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user