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,5 @@
export const reportsModuleMeta = {
segment: "reports",
title: "报表导出",
description: "创建异步导出任务并查看历史记录PRD §8 报表权限)。",
} as const;

View File

@@ -0,0 +1,256 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { getAdminReportJobs, postAdminReportJob } from "@/api/admin-reports";
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 { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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 { LotteryApiBizError } from "@/types/api/errors";
import type { AdminReportJobListData } from "@/types/api/admin-reports";
const REPORT_TYPES = [
{ value: "wallet_txns_daily", label: "钱包流水日报" },
{ value: "transfer_orders_daily", label: "转账单日报" },
] as const;
export function ReportsConsole(): React.ReactElement {
const formatTs = useAdminDateTimeFormatter();
const [data, setData] = useState<AdminReportJobListData | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(25);
const [reportType, setReportType] = useState<string>(REPORT_TYPES[0].value);
const [exportFormat, setExportFormat] = useState<"csv" | "xlsx">("csv");
const [filterJsonText, setFilterJsonText] = useState('{\n "currency_code": "NPR"\n}');
const [submitting, setSubmitting] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const d = await getAdminReportJobs({ page, per_page: perPage });
setData(d);
} catch (e) {
setErr(e instanceof LotteryApiBizError ? e.message : "加载失败");
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage]);
useEffect(() => {
queueMicrotask(() => {
void load();
});
}, [load]);
async function onCreate(): Promise<void> {
let filter_json: Record<string, unknown> | null = null;
const trimmed = filterJsonText.trim();
if (trimmed !== "") {
try {
filter_json = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
toast.error("筛选 JSON 无法解析");
return;
}
}
setSubmitting(true);
try {
await postAdminReportJob({
report_type: reportType,
export_format: exportFormat,
filter_json,
});
toast.success("已创建导出任务");
setPage(1);
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : "创建失败");
} finally {
setSubmitting(false);
}
}
const meta = data?.meta;
const lastPage = meta
? Math.max(1, meta.last_page)
: 1;
return (
<div className="flex w-full max-w-none flex-col gap-8">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
<code className="rounded bg-muted px-1">POST /api/v1/admin/report-jobs</code>
<code className="rounded bg-muted px-1">output_path</code>{" "}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-1.5">
<Label></Label>
<Select
modal={false}
value={reportType}
onValueChange={(v) => {
if (v) {
setReportType(v);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_TYPES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label></Label>
<Select
modal={false}
value={exportFormat}
onValueChange={(v) => {
if (v === "csv" || v === "xlsx") {
setExportFormat(v);
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="csv">CSV</SelectItem>
<SelectItem value="xlsx">XLSX</SelectItem>
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2 lg:col-span-3 grid gap-1.5">
<Label htmlFor="report-filter-json">filter_json</Label>
<Textarea
id="report-filter-json"
value={filterJsonText}
onChange={(e) => setFilterJsonText(e.target.value)}
rows={5}
className="font-mono text-xs"
/>
</div>
<div className="sm:col-span-2 lg:col-span-3">
<Button type="button" onClick={() => void onCreate()} disabled={submitting}>
{submitting ? "提交中…" : "创建任务"}
</Button>
</div>
</CardContent>
</Card>
<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 load()}>
</Button>
</CardHeader>
<CardContent className="space-y-4">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? (
<p className="text-muted-foreground text-sm"></p>
) : null}
{data ? (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-24">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
</TableCell>
</TableRow>
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="tabular-nums">{row.id}</TableCell>
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
<TableCell className="text-sm">{row.report_type}</TableCell>
<TableCell>{row.export_format}</TableCell>
<TableCell>
<Badge variant="secondary">{row.status}</Badge>
</TableCell>
<TableCell className="max-w-[12rem] truncate text-xs text-muted-foreground">
{row.output_path ?? "—"}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{meta ? (
<AdminListPaginationFooter
selectId="report-jobs-per-page"
total={meta.total}
page={meta.current_page}
lastPage={lastPage}
perPage={meta.per_page}
loading={loading}
onPerPageChange={(n) => {
setPerPage(n);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
</div>
);
}