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

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminDrawResultBatches, postAdminCreateManualResultBatch } from "@/api/admin-draws";
@@ -26,22 +27,25 @@ import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
const RESULT_SLOTS = [
{ prize_type: "first", prize_index: 0, label: "头奖" },
{ prize_type: "second", prize_index: 0, label: "二奖" },
{ prize_type: "third", prize_index: 0, label: "三奖" },
{ prize_type: "first", prize_index: 0, label: "resultSlots.first" },
{ prize_type: "second", prize_index: 0, label: "resultSlots.second" },
{ prize_type: "third", prize_index: 0, label: "resultSlots.third" },
...Array.from({ length: 10 }, (_, i) => ({
prize_type: "starter",
prize_index: i,
label: `特别奖 ${i + 1}`,
label: `resultSlots.starter`,
labelIndex: i + 1,
})),
...Array.from({ length: 10 }, (_, i) => ({
prize_type: "consolation",
prize_index: i,
label: `安慰奖 ${i + 1}`,
label: `resultSlots.consolation`,
labelIndex: i + 1,
})),
] as const;
export function DrawReviewConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
@@ -57,7 +61,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError("无效的期号 ID");
setError(t("invalidDrawId"));
setLoading(false);
return;
}
@@ -67,11 +71,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : "加载失败");
setError(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum]);
}, [idNum, t]);
useEffect(() => {
const timer = window.setTimeout(() => {
@@ -88,7 +92,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
if (!Number.isFinite(idNum)) return;
const invalid = manualNumbers.some((n) => !/^[0-9]{4}$/.test(n));
if (invalid) {
toast.error("请完整输入 23 组 4 位数字");
toast.error(t("enter23Numbers"));
return;
}
@@ -101,38 +105,46 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
number_4d: manualNumbers[i],
})),
});
toast.success(`已保存草稿 v${res.batch.result_version},等待确认发布`);
toast.success(t("draftSaved", { version: res.batch.result_version }));
setManualNumbers(RESULT_SLOTS.map(() => ""));
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "保存失败");
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setSavingManual(false);
}
}
if (loading && !data) {
return <p className="text-sm text-muted-foreground"></p>;
return <p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? "无数据"}</p>;
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("manualResultEntry")}</CardTitle>
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<DrawStatusBadge status={data.draw_status} /> ·
{t("currentStatusAndDraft", {
status: data.draw_status,
}).split(data.draw_status)[0]}
<DrawStatusBadge status={data.draw_status} />
{t("currentStatusAndDraft", {
status: data.draw_status,
}).split(data.draw_status)[1] ?? ""}
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{RESULT_SLOTS.map((slot, i) => (
<label key={`${slot.prize_type}-${slot.prize_index}`} className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground">{slot.label}</span>
<span className="text-xs font-medium text-muted-foreground">
{t(slot.label, { index: "labelIndex" in slot ? slot.labelIndex : undefined })}
</span>
<Input
inputMode="numeric"
maxLength={4}
@@ -155,14 +167,14 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
disabled={savingManual}
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
>
{t("clear")}
</Button>
<Button
type="button"
disabled={!canManageDraw || savingManual || !["closed", "review"].includes(data.draw_status)}
onClick={() => void saveManualDraft()}
>
{savingManual ? "保存中…" : "保存草稿"}
{savingManual ? t("saving") : t("saveDraft")}
</Button>
</div>
</CardContent>
@@ -170,21 +182,21 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardTitle className="text-lg">{t("pendingBatches")}</CardTitle>
</CardHeader>
<CardContent>
{pending.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
pending_review
{t("noPendingBatches")}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t("batchId")}</TableHead>
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("numberCount")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -199,10 +211,10 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
href={`/admin/draws/${drawId}/publish/${b.id}`}
className={cn(buttonVariants({ size: "sm" }))}
>
{t("reviewAndPublishAction")}
</Link>
) : (
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">{t("noPublishPermission")}</span>
)}
</TableCell>
</TableRow>