feat(admin): 仪表盘重构、代理线路修复与后台体验优化
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics - 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复 - 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset) - 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Dices, Rocket, Trash2 } from "lucide-react";
|
||||
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";
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
} 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";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -28,13 +27,14 @@ import {
|
||||
} 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 { AdminDrawBatchesData } from "@/types/api/admin-draws";
|
||||
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 { drawStatusLabel } from "./draw-display";
|
||||
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
|
||||
const RESULT_SLOTS = [
|
||||
{ prize_type: "first", prize_index: 0, label: "resultSlots.first" },
|
||||
@@ -58,23 +58,22 @@ function randomDrawNumber4d(): string {
|
||||
return String(Math.floor(Math.random() * 10_000)).padStart(4, "0");
|
||||
}
|
||||
|
||||
export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
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 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 [manualNumbers, setManualNumbers] = useState<string[]>(() => RESULT_SLOTS.map(() => ""));
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!Number.isFinite(idNum)) {
|
||||
@@ -92,15 +91,13 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idNum]);
|
||||
}, [idNum, tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
}, [load]);
|
||||
|
||||
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [
|
||||
data,
|
||||
]);
|
||||
const pending = useMemo(() => data?.batches.filter((b) => b.status === "pending_review") ?? [], [data]);
|
||||
|
||||
function fillRandomManualNumbers(): void {
|
||||
setManualNumbers(RESULT_SLOTS.map(() => randomDrawNumber4d()));
|
||||
@@ -113,10 +110,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
await deleteAdminPendingResultBatch(idNum, batchId);
|
||||
toast.success(t("discardPendingBatchSuccess"));
|
||||
await load();
|
||||
await refreshDraw();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"),
|
||||
);
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("discardPendingBatchFailed"));
|
||||
} finally {
|
||||
setDiscardingBatchId(null);
|
||||
}
|
||||
@@ -141,7 +137,9 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
});
|
||||
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 {
|
||||
@@ -149,6 +147,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onPublishFlowDone(): Promise<void> {
|
||||
await load();
|
||||
await refreshDraw();
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
@@ -161,138 +164,160 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("manualResultEntry")}</CardTitle>
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("currentStatusLabel")}</span>
|
||||
<DrawStatusBadge
|
||||
status={data.draw_status}
|
||||
label={drawStatusLabel(data.draw_status, t)}
|
||||
/>
|
||||
<span>· {t("currentStatusDraftHint")}</span>
|
||||
</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">
|
||||
{t(slot.label, { index: "labelIndex" in slot ? slot.labelIndex : undefined })}
|
||||
</span>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={manualNumbers[i]}
|
||||
disabled={!canManageDraw || savingManual}
|
||||
placeholder="0000"
|
||||
className="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 items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canManageDraw || savingManual}
|
||||
onClick={fillRandomManualNumbers}
|
||||
>
|
||||
<Dices className="size-4" aria-hidden />
|
||||
{t("fillRandomNumbers")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={savingManual}
|
||||
onClick={() => setManualNumbers(RESULT_SLOTS.map(() => ""))}
|
||||
>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canManageDraw || 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("pendingBatches")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pending.length === 0 ? (
|
||||
<AdminNoResourceState className="py-6" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("batchId")}</TableHead>
|
||||
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
||||
<TableHead>{t("numberCount")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pending.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell className="font-mono text-xs">{b.id}</TableCell>
|
||||
<TableCell>v{b.result_version}</TableCell>
|
||||
<TableCell>{b.items.length}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManageDraw ? (
|
||||
<AdminRowActionsMenu
|
||||
busy={discardingBatchId === b.id}
|
||||
actions={[
|
||||
{
|
||||
key: "publish",
|
||||
label: t("reviewAndPublishAction"),
|
||||
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),
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t("noPublishPermission")}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user