feat: 完成 F17 报表导出(CSV/XLSX)\n\n- 后端新增 /api/exports/* 导出接口(含权限与审计记录)\n- CSV 采用流式生成,XLSX 使用 exceljs\n- 前端在报销/票据/审计页加入导出入口(下载 Blob)\n- 更新 P5 蓝图 v3 中 F17 状态为 DONE

这个提交包含在:
2026-02-09 19:35:51 +08:00
父节点 2c58061ec7
当前提交 f233a0d5bc
共修改 11 个文件,包含 2062 行新增和 2 行删除
+70 -1
查看文件
@@ -1,7 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { isApiError } from "../api/types";
import { exportAuditLogs } from "../api/exports";
import { fetchAuditLogs, type AuditLogListResponse } from "../api/audit";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { downloadBlob } from "../lib/download";
const DEFAULT_PAGE_SIZE = 20;
@@ -10,6 +14,7 @@ export function AuditLogsPage() {
const [actionFilter, setActionFilter] = useState("");
const [targetTypeFilter, setTargetTypeFilter] = useState("");
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState("");
const [data, setData] = useState<AuditLogListResponse | null>(null);
@@ -63,7 +68,71 @@ export function AuditLogsPage() {
return (
<div>
<PageHeader title="审计工作台" subtitle="只读查看系统关键操作审计记录。" />
<PageHeader
title="审计工作台"
subtitle="只读查看系统关键操作审计记录。"
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" type="button" disabled={exporting} data-testid="audit-export">
{exporting ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="audit-export-csv"
onSelect={async () => {
setExporting(true);
try {
const response = await exportAuditLogs({
format: "csv",
action: actionFilter || undefined,
targetType: targetTypeFilter || undefined,
});
downloadBlob(response.blob, response.filename ?? "audit-logs.csv");
setError("");
} catch (err) {
if (isApiError(err)) {
setError(`${err.code}: ${err.message}`);
} else {
setError("导出审计日志失败,请稍后重试");
}
} finally {
setExporting(false);
}
}}
>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem
data-testid="audit-export-xlsx"
onSelect={async () => {
setExporting(true);
try {
const response = await exportAuditLogs({
format: "xlsx",
action: actionFilter || undefined,
targetType: targetTypeFilter || undefined,
});
downloadBlob(response.blob, response.filename ?? "audit-logs.xlsx");
setError("");
} catch (err) {
if (isApiError(err)) {
setError(`${err.code}: ${err.message}`);
} else {
setError("导出审计日志失败,请稍后重试");
}
} finally {
setExporting(false);
}
}}
>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
<section className="card-std space-y-4">
<div className="grid gap-3 md:grid-cols-3">
+45
查看文件
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import { exportInvoicePool } from "../api/exports";
import {
archiveInvoice,
bindInvoice,
@@ -19,9 +20,11 @@ import {
import { fetchReimbursements } from "../api/reimbursements";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { downloadBlob } from "../lib/download";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
const PAGE_SIZE = 20;
@@ -267,6 +270,26 @@ export function InvoicePoolPage() {
},
});
const exportMutation = useMutation({
mutationFn: (format: "csv" | "xlsx") =>
exportInvoicePool({
format,
status: status || undefined,
ownerScope,
}),
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `invoice-pool.${format}`);
setError("");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("导出失败,请稍后重试");
}
},
});
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
const detail = detailQuery.data;
const canEditDetail = Boolean(
@@ -281,6 +304,28 @@ export function InvoicePoolPage() {
<PageHeader
title="票据中心"
subtitle="支持上传、OCR 状态轮询、绑定报销与签名预览。"
actions={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="invoice-export"
variant="secondary"
type="button"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
{error ? (
+52
查看文件
@@ -4,12 +4,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import { exportReimbursements } from "../api/exports";
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { downloadBlob } from "../lib/download";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
@@ -19,6 +22,7 @@ export function ReimbursementsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { permissions, me } = useAuth();
const queryClient = useQueryClient();
const [exportError, setExportError] = useState("");
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
const page = Number(searchParams.get("page") ?? "1") || 1;
@@ -51,6 +55,21 @@ export function ReimbursementsPage() {
},
});
const exportMutation = useMutation({
mutationFn: (format: "csv" | "xlsx") => exportReimbursements({ format, status, scope }),
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `reimbursements.${format}`);
setExportError("");
},
onError: (err) => {
if (isApiError(err)) {
setExportError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
return;
}
setExportError(err instanceof Error ? err.message : "导出失败");
},
});
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
return (
@@ -86,6 +105,33 @@ export function ReimbursementsPage() {
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-export"
variant="secondary"
type="button"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="reimbursements-export-csv"
onSelect={() => exportMutation.mutate("csv")}
>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem
data-testid="reimbursements-export-xlsx"
onSelect={() => exportMutation.mutate("xlsx")}
>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
}
/>
@@ -101,6 +147,12 @@ export function ReimbursementsPage() {
</p>
) : null}
{exportError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}
</p>
) : null}
<Card className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<Tabs