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")),
|
||||
};
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户