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

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