Files
lotteryAdmin/src/modules/draws/draw-review-console.tsx
kang 0d984b00f0
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
feat(admin): 仪表盘重构、代理线路修复与后台体验优化
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics
- 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复
- 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset)
- 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
2026-06-26 14:39:22 +08:00

323 lines
12 KiB
TypeScript

"use client";
import { ChevronDown, ChevronRight, Dices, Rocket, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
deleteAdminPendingResultBatch,
getAdminDrawResultBatches,
postAdminCreateManualResultBatch,
} from "@/api/admin-draws";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
import { DrawPublishDialog } from "@/modules/draws/draw-publish-dialog";
import { useDrawDetail } from "@/modules/draws/draw-detail-context";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
const RESULT_SLOTS = [
{ 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: `resultSlots.starter`,
labelIndex: i + 1,
})),
...Array.from({ length: 10 }, (_, i) => ({
prize_type: "consolation",
prize_index: i,
label: `resultSlots.consolation`,
labelIndex: i + 1,
})),
] as const;
function randomDrawNumber4d(): string {
return String(Math.floor(Math.random() * 10_000)).padStart(4, "0");
}
export function DrawReviewConsole({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const { refresh: refreshDraw } = useDrawDetail();
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
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 [manualOpen, setManualOpen] = useState(false);
const [publishBatch, setPublishBatch] = useState<AdminDrawBatchRow | null>(null);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [manualNumbers, setManualNumbers] = useState<string[]>(() => RESULT_SLOTS.map(() => ""));
const load = useCallback(async () => {
if (!Number.isFinite(idNum)) {
setError(tRef.current("invalidDrawId"));
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
setData(await getAdminDrawResultBatches(idNum));
} catch (e) {
setData(null);
setError(e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }));
} finally {
setLoading(false);
}
}, [idNum, tRef]);
useAsyncEffect(() => {
void load();
}, [load]);
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [data]);
function fillRandomManualNumbers(): void {
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();
await refreshDraw();
} 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));
if (invalid) {
toast.error(t("enter23Numbers"));
return;
}
setSavingManual(true);
try {
const res = await postAdminCreateManualResultBatch(idNum, {
items: RESULT_SLOTS.map((slot, i) => ({
prize_type: slot.prize_type,
prize_index: slot.prize_index,
number_4d: manualNumbers[i],
})),
});
toast.success(t("draftSaved", { version: res.batch.result_version }));
setManualNumbers(RESULT_SLOTS.map(() => ""));
setManualOpen(false);
await load();
await refreshDraw();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setSavingManual(false);
}
}
async function onPublishFlowDone(): Promise<void> {
await load();
await refreshDraw();
}
if (loading && !data) {
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (error) {
return <p className="text-sm text-destructive">{error}</p>;
}
if (!data) {
return <AdminNoResourceState />;
}
return (
<div className="space-y-4">
<div className="rounded-lg border border-border/60">
<div className="border-b border-border/60 px-3 py-2.5">
<h2 className="text-sm font-semibold">{t("pendingBatches")}</h2>
</div>
<div className="p-3">
{pending.length === 0 ? (
<AdminNoResourceState className="py-6" message={t("noPendingBatches")} />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("numberCount")}</TableHead>
<TableHead className="w-14 text-center">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((b) => (
<TableRow key={b.id}>
<TableCell>v{b.result_version}</TableCell>
<TableCell className="tabular-nums">{b.items.length}</TableCell>
<TableCell className="text-center">
{canManageDraw ? (
<AdminRowActionsMenu
busy={discardingBatchId === b.id}
actions={[
{
key: "publish",
label: t("reviewAndPublishAction"),
icon: Rocket,
onClick: () => setPublishBatch(b),
},
{
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),
}),
},
]}
/>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
{canManageDraw ? (
<div className="rounded-lg border border-border/60">
<button
type="button"
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold"
onClick={() => setManualOpen((open) => !open)}
>
{t("manualResultEntry")}
{manualOpen ? (
<ChevronDown className="size-4 text-muted-foreground" />
) : (
<ChevronRight className="size-4 text-muted-foreground" />
)}
</button>
{manualOpen ? (
<div className="space-y-3 border-t border-border/60 px-3 py-3">
<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">
<span className="text-xs text-muted-foreground">
{t(slot.label, { index: "labelIndex" in slot ? slot.labelIndex : undefined })}
</span>
<Input
inputMode="numeric"
maxLength={4}
value={manualNumbers[i]}
disabled={savingManual}
placeholder="0000"
className="h-8 font-mono"
onChange={(e) => {
const next = e.target.value.replace(/\D/g, "").slice(0, 4);
setManualNumbers((old) => old.map((v, idx) => (idx === i ? next : v)));
}}
/>
</label>
))}
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={savingManual}
onClick={fillRandomManualNumbers}
>
<Dices className="size-3.5" aria-hidden />
{t("fillRandomNumbers")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={savingManual}
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
>
{t("clear")}
</Button>
<Button
type="button"
size="sm"
className="h-8"
disabled={savingManual || !["closed", "review"].includes(data.draw_status)}
onClick={() =>
requestConfirm({
title: t("confirm.saveManualDraftTitle"),
description: t("confirm.saveManualDraftDescription"),
onConfirm: () => saveManualDraft(),
})
}
>
{savingManual ? t("saving") : t("saveDraft")}
</Button>
</div>
</div>
) : null}
</div>
) : null}
<DrawPublishDialog
open={publishBatch != null}
onOpenChange={(open) => {
if (!open) {
setPublishBatch(null);
}
}}
drawId={idNum}
batch={publishBatch}
onPublished={() => void onPublishFlowDone()}
onDiscarded={() => void onPublishFlowDone()}
/>
<ConfirmDialog />
</div>
);
}