feat: 完成 F17 报表导出(CSV/XLSX)\n\n- 后端新增 /api/exports/* 导出接口(含权限与审计记录)\n- CSV 采用流式生成,XLSX 使用 exceljs\n- 前端在报销/票据/审计页加入导出入口(下载 Blob)\n- 更新 P5 蓝图 v3 中 F17 状态为 DONE
这个提交包含在:
@@ -0,0 +1,45 @@
|
||||
import { apiRequestBlob, type ApiBlobResponse } from "./http";
|
||||
|
||||
export async function exportReimbursements(params: {
|
||||
format: "csv" | "xlsx";
|
||||
status?: string;
|
||||
scope?: "own" | "all";
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("format", params.format);
|
||||
if (params.status) qs.set("status", params.status);
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
|
||||
return apiRequestBlob(`/api/exports/reimbursements?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export async function exportInvoicePool(params: {
|
||||
format: "csv" | "xlsx";
|
||||
status?: string;
|
||||
ownerScope?: "own" | "all";
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("format", params.format);
|
||||
if (params.status) qs.set("status", params.status);
|
||||
if (params.ownerScope) qs.set("ownerScope", params.ownerScope);
|
||||
|
||||
return apiRequestBlob(`/api/exports/invoice-pool?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export async function exportAuditLogs(params: {
|
||||
format: "csv" | "xlsx";
|
||||
action?: string;
|
||||
targetType?: string;
|
||||
actorId?: string;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("format", params.format);
|
||||
if (params.action) qs.set("action", params.action);
|
||||
if (params.targetType) qs.set("targetType", params.targetType);
|
||||
if (params.actorId) qs.set("actorId", params.actorId);
|
||||
|
||||
return apiRequestBlob(`/api/exports/audit-logs?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export type { ApiBlobResponse };
|
||||
|
||||
@@ -153,3 +153,77 @@ export async function apiRequestData<T>(
|
||||
const response = await apiRequest<T>(path, options);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function parseFilenameFromContentDisposition(headerValue: string | null): string | null {
|
||||
if (!headerValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = headerValue.split(";").map((part) => part.trim());
|
||||
const filenamePart = parts.find((part) => part.toLowerCase().startsWith("filename="));
|
||||
if (!filenamePart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = filenamePart.slice("filename=".length).trim();
|
||||
return raw.replace(/^\"|\"$/g, "") || null;
|
||||
}
|
||||
|
||||
export type ApiBlobResponse = {
|
||||
requestId: string;
|
||||
blob: Blob;
|
||||
filename: string | null;
|
||||
};
|
||||
|
||||
export async function apiRequestBlob(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<ApiBlobResponse> {
|
||||
const {
|
||||
auth = true,
|
||||
retryOn401 = true,
|
||||
accessTokenOverride,
|
||||
headers,
|
||||
...rest
|
||||
} = options;
|
||||
|
||||
const requestHeaders = new Headers(headers ?? {});
|
||||
const token = accessTokenOverride ?? (auth ? getAccessToken() : null);
|
||||
|
||||
if (auth && token) {
|
||||
requestHeaders.set("authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...rest,
|
||||
credentials: "include",
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
if (response.status === 401 && auth && retryOn401 && refreshAccessToken) {
|
||||
const nextToken = await getRefreshedToken();
|
||||
if (nextToken) {
|
||||
return apiRequestBlob(path, {
|
||||
...options,
|
||||
retryOn401: false,
|
||||
accessTokenOverride: nextToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (clearSession) {
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
|
||||
const payload = await safeParseJson(response);
|
||||
if (!response.ok) {
|
||||
throw buildApiError(response, payload);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return {
|
||||
requestId: response.headers.get("x-request-id") ?? "",
|
||||
blob,
|
||||
filename: parseFilenameFromContentDisposition(response.headers.get("content-disposition")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.rel = "noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
在新工单中引用
屏蔽一个用户