feat(draws, i18n): 新增批量删除功能并增强多语言支持

在后台开奖管理模块中新增删除待发布结果批次的 API 接口。
更新 DrawPublishConsole 与 DrawReviewConsole 组件,新增“丢弃批次”按钮,支持删除草稿状态的结果批次。
新增删除成功与删除失败的 Toast 提示,优化用户操作反馈体验。
在英文、尼泊尔语与中文语言包中新增批量删除确认及删除成功相关翻译文案,完善多语言支持。
提升开奖管理流程的灵活性与易用性,方便管理员快速清理无效或误创建的批次数据。
This commit is contained in:
2026-06-01 15:41:28 +08:00
parent d30c135dde
commit 53bf64cc53
10 changed files with 272 additions and 43 deletions

View File

@@ -1,11 +1,16 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminDrawResultBatches, postAdminPublishResultBatch } from "@/api/admin-draws";
import {
deleteAdminPendingResultBatch,
getAdminDrawResultBatches,
postAdminPublishResultBatch,
} from "@/api/admin-draws";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
@@ -29,6 +34,7 @@ import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
const { t } = useTranslation(["draws", "common"]);
const router = useRouter();
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
@@ -39,6 +45,7 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [publishing, setPublishing] = useState(false);
const [discarding, setDiscarding] = useState(false);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const load = useCallback(async () => {
@@ -71,6 +78,22 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
return data?.batches.find((b) => b.id === batchNum);
}, [batchNum, data]);
async function discardBatch(): Promise<void> {
if (!Number.isFinite(idNum) || !Number.isFinite(batchNum)) return;
setDiscarding(true);
try {
await deleteAdminPendingResultBatch(idNum, batchNum);
toast.success(t("discardPendingBatchSuccess"));
router.replace(`/admin/draws/${drawId}/review`);
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
);
} finally {
setDiscarding(false);
}
}
async function publish(): Promise<void> {
if (!Number.isFinite(idNum) || !Number.isFinite(batchNum)) return;
setPublishing(true);
@@ -139,10 +162,13 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
</Alert>
) : null}
{canPublish ? (
<Alert>
<AlertTitle>{t("checkBeforePublish")}</AlertTitle>
<AlertDescription>{t("checkBeforePublishDesc")}</AlertDescription>
</Alert>
<>
<Alert>
<AlertTitle>{t("checkBeforePublish")}</AlertTitle>
<AlertDescription>{t("checkBeforePublishDesc")}</AlertDescription>
</Alert>
<p className="text-sm text-muted-foreground">{t("publishReadOnlyHint")}</p>
</>
) : null}
<div className="overflow-x-auto rounded-lg border border-border">
@@ -176,16 +202,33 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
})}
</p>
</CardContent>
<CardFooter className="justify-end gap-2">
<CardFooter className="flex flex-wrap justify-end gap-2">
<Link
href={`/admin/draws/${drawId}/results`}
className={cn(buttonVariants({ variant: "outline", size: "default" }))}
>
{t("publishedView")}
</Link>
{canPublish ? (
<Button
type="button"
variant="outline"
disabled={publishing || discarding}
onClick={() =>
requestConfirm({
title: t("confirm.discardPendingBatchTitle"),
description: t("confirm.discardPendingBatchDescription"),
confirmVariant: "destructive",
onConfirm: () => discardBatch(),
})
}
>
{discarding ? t("discardingPendingBatch") : t("discardPendingBatch")}
</Button>
) : null}
<Button
type="button"
disabled={!canPublish || publishing}
disabled={!canPublish || publishing || discarding}
onClick={() =>
requestConfirm({
title: t("confirm.publishTitle"),

View File

@@ -1,11 +1,15 @@
"use client";
import { Dices, Rocket } from "lucide-react";
import { Dices, Rocket, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminDrawResultBatches, postAdminCreateManualResultBatch } from "@/api/admin-draws";
import {
deleteAdminPendingResultBatch,
getAdminDrawResultBatches,
postAdminCreateManualResultBatch,
} from "@/api/admin-draws";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -61,6 +65,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [savingManual, setSavingManual] = useState(false);
const [discardingBatchId, setDiscardingBatchId] = useState<number | null>(null);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [manualNumbers, setManualNumbers] = useState<string[]>(
() => RESULT_SLOTS.map(() => ""),
@@ -99,6 +104,22 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
setManualNumbers(RESULT_SLOTS.map(() => randomDrawNumber4d()));
}
async function discardPendingBatch(batchId: number): Promise<void> {
if (!Number.isFinite(idNum)) return;
setDiscardingBatchId(batchId);
try {
await deleteAdminPendingResultBatch(idNum, batchId);
toast.success(t("discardPendingBatchSuccess"));
await load();
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
);
} finally {
setDiscardingBatchId(null);
}
}
async function saveManualDraft(): Promise<void> {
if (!Number.isFinite(idNum)) return;
const invalid = manualNumbers.some((n) => !/^[0-9]{4}$/.test(n));
@@ -233,6 +254,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<TableCell className="text-center">
{canManageDraw ? (
<AdminRowActionsMenu
busy={discardingBatchId === b.id}
actions={[
{
key: "publish",
@@ -240,6 +262,20 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
icon: Rocket,
href: `/admin/draws/${drawId}/publish/${b.id}`,
},
{
key: "discard",
label: t("discardPendingBatch"),
icon: Trash2,
destructive: true,
disabled: discardingBatchId !== null,
onClick: () =>
requestConfirm({
title: t("confirm.discardPendingBatchTitle"),
description: t("confirm.discardPendingBatchDescription"),
confirmVariant: "destructive",
onConfirm: () => discardPendingBatch(b.id),
}),
},
]}
/>
) : (

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -12,7 +12,6 @@ import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -68,6 +67,37 @@ interface RuntimeDraft {
playRulesHtmlNe: string;
}
const RUNTIME_DRAFT_KEYS = [
"defaultCurrency",
"drawIntervalMinutes",
"drawBettingWindowSeconds",
"drawCloseBeforeDrawSeconds",
"drawBufferDrawsAhead",
"requireManualReview",
"cooldownMinutes",
"currencyDisplayDecimals",
"currencyDecimalSeparator",
"currencyThousandsSeparator",
"autoSettlement",
"autoApprove",
"autoPayout",
"applyRebateToPayout",
] as const satisfies readonly (keyof RuntimeDraft)[];
const FRONTEND_DRAFT_KEYS = [
"playRulesHtmlZh",
"playRulesHtmlEn",
"playRulesHtmlNe",
] as const satisfies readonly (keyof RuntimeDraft)[];
function isSectionDirty(
draft: RuntimeDraft,
saved: RuntimeDraft,
keys: readonly (keyof RuntimeDraft)[],
): boolean {
return keys.some((key) => draft[key] !== saved[key]);
}
function SaveActions({
dirty,
loading,
@@ -143,8 +173,19 @@ export function SystemSettingsScreen() {
playRulesHtmlNe: "",
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [savingRuntime, setSavingRuntime] = useState(false);
const [savingFrontend, setSavingFrontend] = useState(false);
const runtimeDirty = useMemo(
() => isSectionDirty(draft, saved, RUNTIME_DRAFT_KEYS),
[draft, saved],
);
const frontendDirty = useMemo(
() => isSectionDirty(draft, saved, FRONTEND_DRAFT_KEYS),
[draft, saved],
);
const anyDirty = runtimeDirty || frontendDirty;
const saving = savingRuntime || savingFrontend;
const load = useCallback(async () => {
setLoading(true);
@@ -182,7 +223,6 @@ export function SystemSettingsScreen() {
};
setDraft(nextDraft);
setSaved(nextDraft);
setDirty(false);
} catch {
toast.error(t("system.loadFailed", { ns: "config" }));
} finally {
@@ -198,11 +238,20 @@ export function SystemSettingsScreen() {
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
setDraft((prev) => ({ ...prev, [field]: value }));
setDirty(true);
};
const handleSave = async () => {
setSaving(true);
const discardSection = (keys: readonly (keyof RuntimeDraft)[]) => {
setDraft((prev) => {
const next = { ...prev };
for (const key of keys) {
next[key] = saved[key];
}
return next;
});
};
const handleSaveRuntime = async () => {
setSavingRuntime(true);
try {
await updateAdminSetting(
DRAW_KEYS.DEFAULT_CURRENCY,
@@ -245,19 +294,44 @@ export function SystemSettingsScreen() {
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh);
toast.success(t("system.saveSuccess", { ns: "config" }));
setSaved(draft);
setDirty(false);
toast.success(t("system.saveRuntimeSuccess", { ns: "config" }));
setSaved((prev) => {
const next = { ...prev };
for (const key of RUNTIME_DRAFT_KEYS) {
next[key] = draft[key];
}
return next;
});
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
);
} finally {
setSaving(false);
setSavingRuntime(false);
}
};
const handleSaveFrontend = async () => {
setSavingFrontend(true);
try {
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh);
toast.success(t("system.saveFrontendSuccess", { ns: "config" }));
setSaved((prev) => {
const next = { ...prev };
for (const key of FRONTEND_DRAFT_KEYS) {
next[key] = draft[key];
}
return next;
});
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
);
} finally {
setSavingFrontend(false);
}
};
@@ -267,6 +341,19 @@ export function SystemSettingsScreen() {
return (
<div className="flex w-full max-w-none flex-col gap-6">
{anyDirty ? (
<div className="sticky top-0 z-20 -mx-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 shadow-sm backdrop-blur-sm">
<p className="text-sm font-medium text-amber-950 dark:text-amber-100">
{t("system.unsavedChanges", { ns: "config" })}
{runtimeDirty && frontendDirty
? ` · ${t("system.title", { ns: "config" })} / ${t("system.frontendConfig", { ns: "config" })}`
: runtimeDirty
? ` · ${t("system.title", { ns: "config" })}`
: ` · ${t("system.frontendConfig", { ns: "config" })}`}
</p>
</div>
) : null}
<AdminPageCard
title={t("system.title", { ns: "config" })}
description={t("system.description", { ns: "config" })}
@@ -462,6 +549,24 @@ export function SystemSettingsScreen() {
disabled={loading || saving}
/>
</div>
<SaveActions
dirty={runtimeDirty}
loading={loading}
saving={savingRuntime}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveRuntimeTitle", { ns: "config" }),
description: t("system.confirmSaveRuntimeDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => handleSaveRuntime(),
})
}
onDiscard={() => discardSection(RUNTIME_DRAFT_KEYS)}
saveLabel={saveLabel}
savingLabel={savingLabel}
discardLabel={discardLabel}
/>
</div>
</AdminPageCard>
@@ -517,33 +622,27 @@ export function SystemSettingsScreen() {
/>
</TabsContent>
</Tabs>
</div>
</AdminPageCard>
<Card className="admin-list-card">
<CardContent className="admin-list-content">
<SaveActions
dirty={dirty}
dirty={frontendDirty}
loading={loading}
saving={saving}
saving={savingFrontend}
onSave={() =>
requestConfirm({
title: t("system.confirmSaveTitle", { ns: "config" }),
description: t("system.confirmSaveDescription", { ns: "config" }),
title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => handleSave(),
onConfirm: () => handleSaveFrontend(),
})
}
onDiscard={() => {
setDraft(saved);
setDirty(false);
}}
onDiscard={() => discardSection(FRONTEND_DRAFT_KEYS)}
saveLabel={saveLabel}
savingLabel={savingLabel}
discardLabel={discardLabel}
/>
</CardContent>
</Card>
</div>
</AdminPageCard>
<ConfirmDialog />
</div>
);