feat: 添加财务摘要接口,更新管理员抽奖模块和导航,优化权限管理逻辑
This commit is contained in:
5
src/modules/reconcile/meta.ts
Normal file
5
src/modules/reconcile/meta.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const reconcileModuleMeta = {
|
||||
segment: "reconcile",
|
||||
title: "对账",
|
||||
description: "钱包对账任务列表、创建与明细(PRD §8 钱包对账)。",
|
||||
} as const;
|
||||
354
src/modules/reconcile/reconcile-console.tsx
Normal file
354
src/modules/reconcile/reconcile-console.tsx
Normal 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_start(ISO)</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_end(ISO)</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user