feat: 添加财务摘要接口,更新管理员抽奖模块和导航,优化权限管理逻辑

This commit is contained in:
2026-05-11 16:21:22 +08:00
parent f083b28fc6
commit b539bf0660
57 changed files with 2134 additions and 108 deletions

View File

@@ -1,8 +1,10 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { getAdminDraw } from "@/api/admin-draws";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
@@ -10,6 +12,8 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { cn } from "@/lib/utils";
import { DrawStatusBadge } from "./draw-status-badge";
function Field({ label, children }: { label: string; children: React.ReactNode }) {
@@ -101,14 +105,22 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
<CardTitle className="text-base"></CardTitle>
<CardDescription> / </CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-6 text-sm">
<span>{data.result_batch_counts.total}</span>
<span className="text-amber-600 dark:text-amber-400">
{data.result_batch_counts.pending_review}
</span>
<span className="text-emerald-600 dark:text-emerald-400">
{data.result_batch_counts.published}
</span>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-6 text-sm">
<span>{data.result_batch_counts.total}</span>
<span className="text-amber-600 dark:text-amber-400">
{data.result_batch_counts.pending_review}
</span>
<span className="text-emerald-600 dark:text-emerald-400">
{data.result_batch_counts.published}
</span>
</div>
<Link
href={`/admin/draws/${drawId}/finance`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "w-fit")}
>
/
</Link>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,172 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
/** PRD §15.4:单期投注/派彩与结算批次(`GET …/draws/{id}/finance-summary` */
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawFinanceSummaryData | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
if (!Number.isFinite(idNum) || idNum < 1) {
setErr("无效的期号 ID");
setLoading(false);
return;
}
setLoading(true);
setErr(null);
try {
setData(await getAdminDrawFinanceSummary(idNum));
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setData(null);
} finally {
setLoading(false);
}
}, [idNum]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
if (loading && !data) {
return <p className="text-muted-foreground text-sm"></p>;
}
if (err || !data) {
return <p className="text-destructive text-sm">{err ?? "无数据"}</p>;
}
const cur = data.currency_code ?? "—";
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>
{cur} = + Jackpot
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<span className="text-muted-foreground"></span>
<p className="font-mono font-semibold">{data.draw_no}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p>{data.draw_status}</p>
</div>
<div>
<span className="text-muted-foreground"> / </span>
<p className="tabular-nums">
{data.order_count} / {data.ticket_item_count}
</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p className="tabular-nums font-medium">{data.total_bet_minor}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p className="tabular-nums font-medium">{data.total_payout_minor}</p>
</div>
<div>
<span className="text-muted-foreground"></span>
<p
className={cn(
"tabular-nums font-semibold",
data.approx_house_gross_minor >= 0 ? "text-emerald-600" : "text-destructive",
)}
>
{data.approx_house_gross_minor}
</p>
</div>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
</Button>
<Link
href="/admin/settlement-batches"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription> `settlement_batches` </CardDescription>
</CardHeader>
<CardContent>
{data.settlement_batches.length === 0 ? (
<p className="text-muted-foreground text-sm"></p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">Jackpot</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.settlement_batches.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell className="text-xs">{b.status}</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_ticket_count}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_win_count}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_payout_amount}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{b.total_jackpot_payout_amount}
</TableCell>
<TableCell className="font-mono text-[11px] text-muted-foreground">
{b.finished_at ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,17 @@
/**
* 后台开奖域与 PRD 阶段 3 · §11.4 / §11.7 验收对照(路径以 `API_V1_PREFIX=/api/v1` 为前缀)。
*
* - 期号列表:`GET …/admin/draws`
* - 期号详情 / 状态:`GET …/admin/draws/{draw}`
* - 开奖结果批次(含待审核、已发布):`GET …/admin/draws/{draw}/result-batches`
* - 发布:`POST …/admin/draws/{draw}/result-batches/{batch}/publish`
*/
export const DRAW_ADMIN_API_PRD_LINES = [
"GET /api/v1/admin/draws",
"GET /api/v1/admin/draws/{draw}",
"GET /api/v1/admin/draws/{draw}/result-batches",
"POST /api/v1/admin/draws/{draw}/result-batches/{batch}/publish",
] as const;
/** 具备其一即可执行发布、进入发布页(与 Laravel `prd.draw_result.manage` 一致) */
export const PRD_DRAW_RESULT_MANAGE = "prd.draw_result.manage" as const;

View File

@@ -17,10 +17,18 @@ import {
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
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 { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchId: string }) {
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
]);
const idNum = Number(drawId);
const batchNum = Number(batchId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
@@ -90,13 +98,14 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
);
}
const canPublish = batch.status === "pending_review";
const canPublish =
canManageDraw && batch.status === "pending_review";
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<Link href={`/admin/draws/${drawId}/review`} className={buttonVariants({ variant: "ghost", size: "sm" })}>
</Link>
</div>
@@ -107,22 +116,36 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
<span className="font-mono font-medium">{data.draw_no}</span> · v
{batch.result_version}{" "}
<span className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{batch.status}</span>
· {" "}
<code className="rounded bg-muted px-1 text-xs">
POST /result-batches/{batch.id}/publish
</code>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!canPublish ? (
{!canManageDraw ? (
<Alert variant="destructive">
<AlertTitle></AlertTitle>
<AlertDescription>
<code className="rounded bg-background/80 px-1">{PRD_DRAW_RESULT_MANAGE}</code>{" "}
</AlertDescription>
</Alert>
) : null}
{!canPublish && canManageDraw ? (
<Alert>
<AlertTitle></AlertTitle>
<AlertDescription>
pending_review {batch.status}
</AlertDescription>
</Alert>
) : (
) : null}
{canPublish ? (
<Alert>
<AlertTitle></AlertTitle>
<AlertDescription></AlertDescription>
</Alert>
)}
) : null}
<div className="overflow-x-auto rounded-lg border border-border">
<Table>

View File

@@ -15,12 +15,19 @@ import {
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
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 { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
export function DrawResultsConsole({ drawId }: { drawId: string }) {
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
]);
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -68,14 +75,14 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">
{data.draw_no} · <DrawStatusBadge status={data.draw_status} /> ·
/
</p>
</div>
<Link
href={`/admin/draws/${drawId}/review`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{canManageDraw ? "去审核 / 发布" : "查看审核队列"}
</Link>
</div>

View File

@@ -15,12 +15,19 @@ import {
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
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 { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
export function DrawReviewConsole({ drawId }: { drawId: string }) {
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
]);
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -68,8 +75,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<CardHeader>
<CardTitle className="text-lg"></CardTitle>
<CardDescription>
RNG 23
DB <DrawStatusBadge status={data.draw_status} />
RNG {" "}
<code className="rounded bg-muted px-1 text-xs">{PRD_DRAW_RESULT_MANAGE}</code>{" "}
{" "}
<code className="rounded bg-muted px-1 text-xs">POST /result-batches//publish</code>
DB <DrawStatusBadge status={data.draw_status} />
</CardDescription>
</CardHeader>
<CardContent>
@@ -94,12 +104,16 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<TableCell>v{b.result_version}</TableCell>
<TableCell>{b.items.length}</TableCell>
<TableCell className="text-right">
<Link
href={`/admin/draws/${drawId}/review/${b.id}`}
className={cn(buttonVariants({ size: "sm" }))}
>
</Link>
{canManageDraw ? (
<Link
href={`/admin/draws/${drawId}/publish/${b.id}`}
className={cn(buttonVariants({ size: "sm" }))}
>
</Link>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}

View File

@@ -7,11 +7,22 @@ import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const segments = [
{ suffix: "", key: "status", label: "当前状态" },
{ suffix: "", key: "status", label: "期号状态" },
{ suffix: "/results", key: "results", label: "开奖结果" },
{ suffix: "/review", key: "review", label: "审核 / 发布" },
{ suffix: "/finance", key: "finance", label: "期号收支" },
{ suffix: "/review", key: "review", label: "审核与发布" },
] as const;
function isReviewTabActive(pathname: string, base: string): boolean {
const reviewPrefix = `${base}/review`;
const publishPrefix = `${base}/publish`;
return (
pathname === reviewPrefix ||
pathname.startsWith(`${reviewPrefix}/`) ||
pathname.startsWith(`${publishPrefix}/`)
);
}
export function DrawSubnav({ drawId }: { drawId: string }) {
const pathname = usePathname();
const base = `/admin/draws/${drawId}`;
@@ -24,8 +35,8 @@ export function DrawSubnav({ drawId }: { drawId: string }) {
suffix === ""
? pathname === base || pathname === `${base}/`
: suffix === "/review"
? pathname === href || pathname?.startsWith(`${href}/`)
: pathname === href;
? isReviewTabActive(pathname, base)
: pathname === href || pathname.startsWith(`${href}/`);
return (
<Link

View File

@@ -29,6 +29,7 @@ import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
import { DRAW_ADMIN_API_PRD_LINES } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
/** 下拉「不限」;请求时不传 status */
@@ -103,7 +104,14 @@ export function DrawsIndexConsole() {
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-lg"></CardTitle>
<CardDescription></CardDescription>
<CardDescription className="space-y-2">
<span>
3 · §11.4
</span>
<span className="block font-mono text-[11px] leading-relaxed text-muted-foreground">
{DRAW_ADMIN_API_PRD_LINES.join(" · ")}
</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Grid桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */}

View File

@@ -1,5 +1,6 @@
export const drawsModuleMeta = {
segment: "draws",
title: "开奖",
description: "期号列表、状态、开奖结果、审核与发布。",
description:
"PRD 阶段3 §11.4:期号列表 / 状态 / 开奖结果 / 审核队列 / 发布POST …/result-batches/…/publish路由 `/admin/draws/[id]/publish/[batchId]` 与接口动词对齐。",
} as const;