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

@@ -0,0 +1,354 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
getAdminReconcileJobItems,
getAdminReconcileJobs,
postAdminReconcileJob,
} from "@/api/admin-reconcile";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminReconcileItemsData,
AdminReconcileJobListData,
} from "@/types/api/admin-reconcile";
const MANAGE = ["prd.wallet_reconcile.manage"] as const;
export function ReconcileConsole(): React.ReactElement {
const profile = useAdminProfile();
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
const formatTs = useAdminDateTimeFormatter();
const [jobs, setJobs] = useState<AdminReconcileJobListData | null>(null);
const [jobsLoading, setJobsLoading] = useState(true);
const [jobsErr, setJobsErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [items, setItems] = useState<AdminReconcileItemsData | null>(null);
const [itemsPage, setItemsPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(50);
const [itemsLoading, setItemsLoading] = useState(false);
const [reconcileType, setReconcileType] = useState("wallet_transfer");
const [periodStart, setPeriodStart] = useState("");
const [periodEnd, setPeriodEnd] = useState("");
const [itemsJson, setItemsJson] = useState("[]");
const [submitting, setSubmitting] = useState(false);
const loadJobs = useCallback(async () => {
setJobsLoading(true);
setJobsErr(null);
try {
const d = await getAdminReconcileJobs({ page, per_page: perPage });
setJobs(d);
} catch (e) {
setJobsErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setJobs(null);
} finally {
setJobsLoading(false);
}
}, [page, perPage]);
useEffect(() => {
queueMicrotask(() => {
void loadJobs();
});
}, [loadJobs]);
const loadItems = useCallback(async () => {
if (selectedId == null) {
setItems(null);
return;
}
setItemsLoading(true);
try {
const d = await getAdminReconcileJobItems(selectedId, {
page: itemsPage,
per_page: itemsPerPage,
});
setItems(d);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "加载明细失败");
setItems(null);
} finally {
setItemsLoading(false);
}
}, [selectedId, itemsPage, itemsPerPage]);
useEffect(() => {
queueMicrotask(() => {
void loadItems();
});
}, [loadItems]);
async function onCreate(): Promise<void> {
let itemsPayload: Parameters<typeof postAdminReconcileJob>[0]["items"];
const trimmed = itemsJson.trim();
if (trimmed !== "" && trimmed !== "[]") {
try {
itemsPayload = JSON.parse(trimmed) as NonNullable<
Parameters<typeof postAdminReconcileJob>[0]["items"]
>;
} catch {
toast.error("items JSON 无法解析");
return;
}
}
setSubmitting(true);
try {
await postAdminReconcileJob({
reconcile_type: reconcileType,
period_start: periodStart.trim() || undefined,
period_end: periodEnd.trim() || undefined,
items: itemsPayload,
});
toast.success("已创建对账任务");
setPage(1);
await loadJobs();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建失败");
} finally {
setSubmitting(false);
}
}
const jm = jobs?.meta;
const im = items?.meta;
return (
<div className="flex w-full max-w-none flex-col gap-8">
{canCreate ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
<code className="rounded bg-muted px-1">prd.wallet_reconcile.manage</code>
</CardDescription>
</CardHeader>
<CardContent className="grid max-w-3xl gap-4">
<div className="grid gap-1.5">
<Label htmlFor="rc-type">reconcile_type</Label>
<Input
id="rc-type"
value={reconcileType}
onChange={(e) => setReconcileType(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="rc-start">period_startISO</Label>
<Input
id="rc-start"
placeholder="2026-05-01T00:00:00Z"
value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rc-end">period_endISO</Label>
<Input
id="rc-end"
placeholder="2026-05-02T00:00:00Z"
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rc-items">items JSON</Label>
<Textarea
id="rc-items"
value={itemsJson}
onChange={(e) => setItemsJson(e.target.value)}
rows={6}
className="font-mono text-xs"
/>
</div>
<Button type="button" onClick={() => void onCreate()} disabled={submitting}>
{submitting ? "提交中…" : "创建任务"}
</Button>
</CardContent>
</Card>
) : (
<p className="text-muted-foreground text-sm">
·
</p>
)}
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
</Button>
</CardHeader>
<CardContent className="space-y-4">
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
{jobsLoading && !jobs ? (
<p className="text-muted-foreground text-sm"></p>
) : null}
{jobs ? (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
</TableCell>
</TableRow>
) : (
jobs.items.map((row) => (
<TableRow
key={row.id}
className={
selectedId === row.id
? "bg-muted/60 cursor-pointer"
: "cursor-pointer hover:bg-muted/40"
}
onClick={() => {
setSelectedId(row.id);
setItemsPage(1);
}}
>
<TableCell className="tabular-nums">{row.id}</TableCell>
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
<TableCell>{row.reconcile_type}</TableCell>
<TableCell>
<Badge variant="secondary">{row.status}</Badge>
</TableCell>
<TableCell className="max-w-[14rem] truncate text-xs text-muted-foreground">
{row.period_start ?? "—"} ~ {row.period_end ?? "—"}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{jm ? (
<AdminListPaginationFooter
selectId="reconcile-jobs-per-page"
total={jm.total}
page={jm.current_page}
lastPage={Math.max(1, jm.last_page)}
perPage={jm.per_page}
loading={jobsLoading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
{selectedId != null ? (
<Card>
<CardHeader>
<CardTitle> #{selectedId} </CardTitle>
<CardDescription>
{itemsLoading ? "加载中…" : items?.job_no ?? ""}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{items ? (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">ID</TableHead>
<TableHead>side_a_ref</TableHead>
<TableHead>side_b_ref</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
</TableCell>
</TableRow>
) : (
items.items.map((r) => (
<TableRow key={r.id}>
<TableCell>{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
<TableCell className="tabular-nums">{r.difference_amount}</TableCell>
<TableCell>{r.status}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{im ? (
<AdminListPaginationFooter
selectId="reconcile-items-per-page"
total={im.total}
page={im.current_page}
lastPage={Math.max(1, im.last_page)}
perPage={im.per_page}
loading={itemsLoading}
onPerPageChange={(n) => {
setItemsPerPage(n);
setItemsPage(1);
}}
onPageChange={setItemsPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
) : null}
</div>
);
}