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 行删除
+74
查看文件
@@ -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")),
};
}