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

@@ -53,6 +53,15 @@ export async function postAdminPublishResultBatch(
); );
} }
export async function deleteAdminPendingResultBatch(
drawId: number,
batchId: number,
): Promise<AdminDrawActionResponse> {
return adminRequest.delete<AdminDrawActionResponse>(
`${A}/draws/${drawId}/result-batches/${batchId}`,
);
}
export async function postAdminGenerateDrawPlan(): Promise<AdminDrawPlanGenerateResponse> { export async function postAdminGenerateDrawPlan(): Promise<AdminDrawPlanGenerateResponse> {
return adminRequest.post<AdminDrawPlanGenerateResponse>(`${A}/draws/generate-plan`); return adminRequest.post<AdminDrawPlanGenerateResponse>(`${A}/draws/generate-plan`);
} }

View File

@@ -177,7 +177,10 @@
"description": "Controls review flow after RNG draw generation, cooldown duration, and automatic settlement behavior. These are global runtime policies and do not belong to versioned operations config.", "description": "Controls review flow after RNG draw generation, cooldown duration, and automatic settlement behavior. These are global runtime policies and do not belong to versioned operations config.",
"loadFailed": "Failed to load system settings", "loadFailed": "Failed to load system settings",
"saveSuccess": "System settings saved", "saveSuccess": "System settings saved",
"saveRuntimeSuccess": "Draw and settlement parameters saved",
"saveFrontendSuccess": "Front-end display settings saved",
"saveFailed": "Failed to save system settings", "saveFailed": "Failed to save system settings",
"unsavedChanges": "Unsaved changes",
"frontendConfig": "Front-end configuration", "frontendConfig": "Front-end configuration",
"fields": { "fields": {
"manualReview": "Require manual review for draw results", "manualReview": "Require manual review for draw results",
@@ -211,7 +214,11 @@
}, },
"discard": "Discard changes", "discard": "Discard changes",
"confirmSaveTitle": "Save system runtime parameters?", "confirmSaveTitle": "Save system runtime parameters?",
"confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation." "confirmSaveDescription": "This updates draw review, cooldown, auto settlement/approval/payout, and play-rules display. It may affect site-wide operation.",
"confirmSaveRuntimeTitle": "Save draw and settlement parameters?",
"confirmSaveRuntimeDescription": "This updates draw review, schedule timing, cooldown, and auto settlement/approval/payout. Play-rules HTML is not changed.",
"confirmSaveFrontendTitle": "Save front-end display settings?",
"confirmSaveFrontendDescription": "This updates play-rules HTML on the player site. Draw and settlement logic are not changed."
}, },
"currencies": { "currencies": {
"title": "Currency management", "title": "Currency management",

View File

@@ -135,6 +135,11 @@
"batchId": "Batch ID", "batchId": "Batch ID",
"numberCount": "Number count", "numberCount": "Number count",
"reviewAndPublishAction": "Review and publish", "reviewAndPublishAction": "Review and publish",
"discardPendingBatch": "Delete draft",
"discardingPendingBatch": "Deleting…",
"discardPendingBatchSuccess": "Pending batch removed. You can re-enter numbers or run RNG.",
"discardPendingBatchFailed": "Delete failed",
"publishReadOnlyHint": "This page is read-only for verification. To change numbers, delete this batch and save a new draft under manual entry.",
"noPublishPermission": "No publish permission", "noPublishPermission": "No publish permission",
"batchNotFound": "Batch not found", "batchNotFound": "Batch not found",
"batchNotFoundDesc": "Return to the review list and confirm the batch ID.", "batchNotFoundDesc": "Return to the review list and confirm the batch ID.",
@@ -196,6 +201,8 @@
"saveManualDraftDescription": "23 numbers will be saved for review.", "saveManualDraftDescription": "23 numbers will be saved for review.",
"publishTitle": "Confirm publish results?", "publishTitle": "Confirm publish results?",
"publishDescription": "Results become visible to players and may trigger settlement.", "publishDescription": "Results become visible to players and may trigger settlement.",
"discardPendingBatchTitle": "Delete pending result batch?",
"discardPendingBatchDescription": "This removes the 23-number draft and returns the draw to closed (awaiting draw). You can enter numbers again or run RNG. Published results are not affected.",
"generatePlanTitle": "Confirm generate draw plan?", "generatePlanTitle": "Confirm generate draw plan?",
"generatePlanDescription": "Future bettable draws will be created per system rules." "generatePlanDescription": "Future bettable draws will be created per system rules."
}, },

View File

@@ -177,7 +177,10 @@
"description": "RNG ड्रअपछि समीक्षा प्रवाह, कूलडाउन अवधि र स्वचालित सेटलमेन्ट व्यवहार नियन्त्रण गर्छ। यी ग्लोबल रनटाइम नीति हुन् र संस्करणयुक्त सञ्चालन कन्फिगरेसनमा पर्दैनन्।", "description": "RNG ड्रअपछि समीक्षा प्रवाह, कूलडाउन अवधि र स्वचालित सेटलमेन्ट व्यवहार नियन्त्रण गर्छ। यी ग्लोबल रनटाइम नीति हुन् र संस्करणयुक्त सञ्चालन कन्फिगरेसनमा पर्दैनन्।",
"loadFailed": "प्रणाली सेटिङ लोड असफल भयो", "loadFailed": "प्रणाली सेटिङ लोड असफल भयो",
"saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो", "saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो",
"saveRuntimeSuccess": "ड्रअ र सेटलमेन्ट प्यारामिटर सुरक्षित भयो",
"saveFrontendSuccess": "फ्रन्ट-एन्ड प्रदर्शन सेटिङ सुरक्षित भयो",
"saveFailed": "प्रणाली सेटिङ सुरक्षित गर्न असफल", "saveFailed": "प्रणाली सेटिङ सुरक्षित गर्न असफल",
"unsavedChanges": "नसुरक्षित परिवर्तन छ",
"frontendConfig": "फ्रन्ट-एन्ड कन्फिग", "frontendConfig": "फ्रन्ट-एन्ड कन्फिग",
"fields": { "fields": {
"manualReview": "ड्रअ परिणामका लागि म्यानुअल समीक्षा चाहिने", "manualReview": "ड्रअ परिणामका लागि म्यानुअल समीक्षा चाहिने",
@@ -211,7 +214,11 @@
}, },
"discard": "परिवर्तन त्याग्नुहोस्", "discard": "परिवर्तन त्याग्नुहोस्",
"confirmSaveTitle": "प्रणाली रनटाइम प्यारामिटर सुरक्षित गर्ने?", "confirmSaveTitle": "प्रणाली रनटाइम प्यारामिटर सुरक्षित गर्ने?",
"confirmSaveDescription": "ड्रअ समीक्षा, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट र खेल नियम प्रदर्शन अद्यावधिक हुन्छ। साइटव्यापी सञ्चालनमा असर पर्न सक्छ।" "confirmSaveDescription": "ड्रअ समीक्षा, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट र खेल नियम प्रदर्शन अद्यावधिक हुन्छ। साइटव्यापी सञ्चालनमा असर पर्न सक्छ।",
"confirmSaveRuntimeTitle": "ड्रअ र सेटलमेन्ट प्यारामिटर सुरक्षित गर्ने?",
"confirmSaveRuntimeDescription": "ड्रअ समीक्षा, तालिका, कूलडाउन, स्वचालित सेटलमेन्ट/अनुमोदन/पेआउट अद्यावधिक हुन्छ। खेल नियम HTML परिवर्तन हुँदैन।",
"confirmSaveFrontendTitle": "फ्रन्ट-एन्ड प्रदर्शन सेटिङ सुरक्षित गर्ने?",
"confirmSaveFrontendDescription": "खेलाडी साइटको खेल नियम HTML अद्यावधिक हुन्छ। ड्रअ र सेटलमेन्ट तर्क परिवर्तन हुँदैन।"
}, },
"currencies": { "currencies": {
"title": "मुद्रा व्यवस्थापन", "title": "मुद्रा व्यवस्थापन",

View File

@@ -135,6 +135,11 @@
"batchId": "ब्याच ID", "batchId": "ब्याच ID",
"numberCount": "नम्बर संख्या", "numberCount": "नम्बर संख्या",
"reviewAndPublishAction": "जाँचेर प्रकाशित गर्नुहोस्", "reviewAndPublishAction": "जाँचेर प्रकाशित गर्नुहोस्",
"discardPendingBatch": "ड्राफ्ट मेटाउनुहोस्",
"discardingPendingBatch": "मेटाउँदै…",
"discardPendingBatchSuccess": "पेन्डिङ ब्याच मेटियो। पुनः नम्बर राख्न वा RNG चलाउन सकिन्छ।",
"discardPendingBatchFailed": "मेटाउन असफल",
"publishReadOnlyHint": "यो पृष्ठ केवल जाँचका लागि हो। नम्बर बदल्न पहिले यो ब्याच मेटाउनुहोस् र म्यानुअल प्रविष्टिबाट नयाँ ड्राफ्ट सुरक्षित गर्नुहोस्।",
"noPublishPermission": "प्रकाशन अनुमति छैन", "noPublishPermission": "प्रकाशन अनुमति छैन",
"batchNotFound": "ब्याच भेटिएन", "batchNotFound": "ब्याच भेटिएन",
"batchNotFoundDesc": "समीक्षा सूचीमा फर्केर batch ID जाँच गर्नुहोस्।", "batchNotFoundDesc": "समीक्षा सूचीमा फर्केर batch ID जाँच गर्नुहोस्।",
@@ -196,6 +201,8 @@
"saveManualDraftDescription": "२३ नम्बर समीक्षाका लागि सुरक्षित हुनेछ।", "saveManualDraftDescription": "२३ नम्बर समीक्षाका लागि सुरक्षित हुनेछ।",
"publishTitle": "नतिजा प्रकाशन पुष्टि?", "publishTitle": "नतिजा प्रकाशन पुष्टि?",
"publishDescription": "खेलाडीहरूले नतिजा देख्नेछन्।", "publishDescription": "खेलाडीहरूले नतिजा देख्नेछन्।",
"discardPendingBatchTitle": "पेन्डिङ नतिजा ब्याच मेटाउने?",
"discardPendingBatchDescription": "२३ नम्बरको ड्राफ्ट मेटिन्छ र ड्रअ «बन्द — नतिजा पर्खँदै» मा फर्किन्छ। पुनः प्रविष्टि वा RNG गर्न सकिन्छ।",
"generatePlanTitle": "ड्रअ योजना सिर्जना पुष्टि?", "generatePlanTitle": "ड्रअ योजना सिर्जना पुष्टि?",
"generatePlanDescription": "भविष्यका ड्रअहरू सिर्जना हुनेछन्।" "generatePlanDescription": "भविष्यका ड्रअहरू सिर्जना हुनेछन्।"
}, },

View File

@@ -177,7 +177,10 @@
"description": "用于控制 RNG 开奖后的审核流转、冷静期时长和系统自动结算行为。这些参数属于全局运行策略,不跟随玩法/赔率版本发布。", "description": "用于控制 RNG 开奖后的审核流转、冷静期时长和系统自动结算行为。这些参数属于全局运行策略,不跟随玩法/赔率版本发布。",
"loadFailed": "系统设置加载失败", "loadFailed": "系统设置加载失败",
"saveSuccess": "系统设置已保存", "saveSuccess": "系统设置已保存",
"saveRuntimeSuccess": "开奖与结算参数已保存",
"saveFrontendSuccess": "前端展示配置已保存",
"saveFailed": "系统设置保存失败", "saveFailed": "系统设置保存失败",
"unsavedChanges": "有未保存的更改",
"frontendConfig": "前端配置", "frontendConfig": "前端配置",
"fields": { "fields": {
"manualReview": "开奖结果必须人工审核", "manualReview": "开奖结果必须人工审核",
@@ -211,7 +214,11 @@
}, },
"discard": "放弃更改", "discard": "放弃更改",
"confirmSaveTitle": "确认保存系统运行参数?", "confirmSaveTitle": "确认保存系统运行参数?",
"confirmSaveDescription": "将更新开奖审核、冷静期、自动结算/审核/派彩及玩法规则展示,可能影响全站运行。" "confirmSaveDescription": "将更新开奖审核、冷静期、自动结算/审核/派彩及玩法规则展示,可能影响全站运行。",
"confirmSaveRuntimeTitle": "确认保存开奖与结算参数?",
"confirmSaveRuntimeDescription": "将更新开奖审核、期号节奏、冷静期、自动结算/审核/派彩等,不影响玩法规则 HTML。",
"confirmSaveFrontendTitle": "确认保存前端展示配置?",
"confirmSaveFrontendDescription": "将更新玩家端玩法规则页面 HTML不影响开奖与结算逻辑。"
}, },
"currencies": { "currencies": {
"title": "币种管理", "title": "币种管理",

View File

@@ -135,6 +135,11 @@
"batchId": "批次 ID", "batchId": "批次 ID",
"numberCount": "号码条数", "numberCount": "号码条数",
"reviewAndPublishAction": "核对并发布", "reviewAndPublishAction": "核对并发布",
"discardPendingBatch": "删除草稿",
"discardingPendingBatch": "删除中…",
"discardPendingBatchSuccess": "已删除待确认批次,可重新录入或 RNG",
"discardPendingBatchFailed": "删除失败",
"publishReadOnlyHint": "发布页仅用于核对;若要改号请先删除本批次,回到上方「人工录入」重新保存。",
"noPublishPermission": "无发布权限", "noPublishPermission": "无发布权限",
"batchNotFound": "未找到批次", "batchNotFound": "未找到批次",
"batchNotFoundDesc": "请返回审核列表确认 batch id。", "batchNotFoundDesc": "请返回审核列表确认 batch id。",
@@ -196,6 +201,8 @@
"saveManualDraftDescription": "将写入 23 个开奖号码草稿,提交后进入审核流程。", "saveManualDraftDescription": "将写入 23 个开奖号码草稿,提交后进入审核流程。",
"publishTitle": "确认发布开奖结果?", "publishTitle": "确认发布开奖结果?",
"publishDescription": "发布后将对玩家可见并可能触发结算,请再次核对号码。", "publishDescription": "发布后将对玩家可见并可能触发结算,请再次核对号码。",
"discardPendingBatchTitle": "确认删除待确认批次?",
"discardPendingBatchDescription": "将删除本批次 23 个号码草稿,期号回到「已封盘待开奖」,可重新人工录入或 RNG。已发布的结果不受影响。",
"generatePlanTitle": "确认批量生成期号计划?", "generatePlanTitle": "确认批量生成期号计划?",
"generatePlanDescription": "将按系统规则补充未来可下注期号。" "generatePlanDescription": "将按系统规则补充未来可下注期号。"
}, },

View File

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

View File

@@ -1,11 +1,15 @@
"use client"; "use client";
import { Dices, Rocket } from "lucide-react"; import { Dices, Rocket, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; 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 { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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 [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingManual, setSavingManual] = useState(false); const [savingManual, setSavingManual] = useState(false);
const [discardingBatchId, setDiscardingBatchId] = useState<number | null>(null);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [manualNumbers, setManualNumbers] = useState<string[]>( const [manualNumbers, setManualNumbers] = useState<string[]>(
() => RESULT_SLOTS.map(() => ""), () => RESULT_SLOTS.map(() => ""),
@@ -99,6 +104,22 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
setManualNumbers(RESULT_SLOTS.map(() => randomDrawNumber4d())); 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> { async function saveManualDraft(): Promise<void> {
if (!Number.isFinite(idNum)) return; if (!Number.isFinite(idNum)) return;
const invalid = manualNumbers.some((n) => !/^[0-9]{4}$/.test(n)); const invalid = manualNumbers.some((n) => !/^[0-9]{4}$/.test(n));
@@ -233,6 +254,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<TableCell className="text-center"> <TableCell className="text-center">
{canManageDraw ? ( {canManageDraw ? (
<AdminRowActionsMenu <AdminRowActionsMenu
busy={discardingBatchId === b.id}
actions={[ actions={[
{ {
key: "publish", key: "publish",
@@ -240,6 +262,20 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
icon: Rocket, icon: Rocket,
href: `/admin/draws/${drawId}/publish/${b.id}`, 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"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -12,7 +12,6 @@ import { AdminPageCard } from "@/components/admin/admin-page-card";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen"; import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -68,6 +67,37 @@ interface RuntimeDraft {
playRulesHtmlNe: string; 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({ function SaveActions({
dirty, dirty,
loading, loading,
@@ -143,8 +173,19 @@ export function SystemSettingsScreen() {
playRulesHtmlNe: "", playRulesHtmlNe: "",
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [savingRuntime, setSavingRuntime] = useState(false);
const [dirty, setDirty] = 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 () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -182,7 +223,6 @@ export function SystemSettingsScreen() {
}; };
setDraft(nextDraft); setDraft(nextDraft);
setSaved(nextDraft); setSaved(nextDraft);
setDirty(false);
} catch { } catch {
toast.error(t("system.loadFailed", { ns: "config" })); toast.error(t("system.loadFailed", { ns: "config" }));
} finally { } finally {
@@ -198,11 +238,20 @@ export function SystemSettingsScreen() {
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => { const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
setDraft((prev) => ({ ...prev, [field]: value })); setDraft((prev) => ({ ...prev, [field]: value }));
setDirty(true);
}; };
const handleSave = async () => { const discardSection = (keys: readonly (keyof RuntimeDraft)[]) => {
setSaving(true); setDraft((prev) => {
const next = { ...prev };
for (const key of keys) {
next[key] = saved[key];
}
return next;
});
};
const handleSaveRuntime = async () => {
setSavingRuntime(true);
try { try {
await updateAdminSetting( await updateAdminSetting(
DRAW_KEYS.DEFAULT_CURRENCY, DRAW_KEYS.DEFAULT_CURRENCY,
@@ -245,19 +294,44 @@ export function SystemSettingsScreen() {
await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove); await updateAdminSetting(DRAW_KEYS.AUTO_APPROVE, draft.autoApprove);
await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout); await updateAdminSetting(DRAW_KEYS.AUTO_PAYOUT, draft.autoPayout);
await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout); await updateAdminSetting(DRAW_KEYS.APPLY_REBATE_TO_PAYOUT, draft.applyRebateToPayout);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh); toast.success(t("system.saveRuntimeSuccess", { ns: "config" }));
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn); setSaved((prev) => {
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe); const next = { ...prev };
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh); for (const key of RUNTIME_DRAFT_KEYS) {
toast.success(t("system.saveSuccess", { ns: "config" })); next[key] = draft[key];
setSaved(draft); }
setDirty(false); return next;
});
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }), error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
); );
} finally { } 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 ( return (
<div className="flex w-full max-w-none flex-col gap-6"> <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 <AdminPageCard
title={t("system.title", { ns: "config" })} title={t("system.title", { ns: "config" })}
description={t("system.description", { ns: "config" })} description={t("system.description", { ns: "config" })}
@@ -462,6 +549,24 @@ export function SystemSettingsScreen() {
disabled={loading || saving} disabled={loading || saving}
/> />
</div> </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> </div>
</AdminPageCard> </AdminPageCard>
@@ -517,33 +622,27 @@ export function SystemSettingsScreen() {
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div>
</AdminPageCard>
<Card className="admin-list-card">
<CardContent className="admin-list-content">
<SaveActions <SaveActions
dirty={dirty} dirty={frontendDirty}
loading={loading} loading={loading}
saving={saving} saving={savingFrontend}
onSave={() => onSave={() =>
requestConfirm({ requestConfirm({
title: t("system.confirmSaveTitle", { ns: "config" }), title: t("system.confirmSaveFrontendTitle", { ns: "config" }),
description: t("system.confirmSaveDescription", { ns: "config" }), description: t("system.confirmSaveFrontendDescription", { ns: "config" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }), confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => handleSave(), onConfirm: () => handleSaveFrontend(),
}) })
} }
onDiscard={() => { onDiscard={() => discardSection(FRONTEND_DRAFT_KEYS)}
setDraft(saved);
setDirty(false);
}}
saveLabel={saveLabel} saveLabel={saveLabel}
savingLabel={savingLabel} savingLabel={savingLabel}
discardLabel={discardLabel} discardLabel={discardLabel}
/> />
</CardContent> </div>
</Card> </AdminPageCard>
<ConfirmDialog /> <ConfirmDialog />
</div> </div>
); );