Выгрузка маршрутов: в имени файла сокращённые типы заявок (до 7) и дата

Имя формирует бэкенд и отдаёт в Content-Disposition (он добавлен в
экспонируемые заголовки CORS), фронт сохраняет файл под этим именем.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-13 10:40:19 +05:00
parent dce71ab672
commit bacf18c608
7 changed files with 81 additions and 13 deletions
@@ -44,7 +44,7 @@ export const RoutesExportScreen: React.FC<IRoutesExportScreenProps> = ({ api })
setError(undefined);
setIsBusy(true);
api.reports.routes(selected)
.then(blob => download(blob, texts.routesExport.fileName))
.then(file => download(file.blob, file.fileName ?? texts.routesExport.fileName))
.catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export'))))
.then(() => setIsBusy(false), () => setIsBusy(false));
};
@@ -1,4 +1,4 @@
import { IHttpClient } from './httpClient';
import { IDownloadedFile, IHttpClient } from './httpClient';
import {
CreateRequestTypeAccessInput,
CreateRequestTypeInput,
@@ -175,7 +175,7 @@ export class ApprovalRouteApi {
// Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON.
public readonly reports = {
routes: (requestTypeIds: number[]): Promise<Blob> =>
routes: (requestTypeIds: number[]): Promise<IDownloadedFile> =>
this.http.postForBlob('/api/reports/routes', { requestTypeIds })
};
}
@@ -18,6 +18,28 @@ export class ApiClientError extends Error {
}
}
/** Скачанный файл; fileName пуст, если сервер не прислал Content-Disposition. */
export interface IDownloadedFile {
blob: Blob;
fileName?: string;
}
// filename*=UTF-8''... (RFC 5987, так отдаёт ASP.NET для кириллицы) или обычный filename=...
// eslint-disable-next-line @rushstack/no-new-null -- header — результат Headers.get() (DOM API)
function fileNameFromDisposition(header: string | null | undefined): string | undefined {
if (!header) return undefined;
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header);
if (encoded) {
try {
return decodeURIComponent(encoded[1]);
} catch {
// Битая кодировка — падаем на обычный filename ниже.
}
}
const plain = /filename="?([^";]+)"?/i.exec(header);
return plain ? plain[1] : undefined;
}
export type QueryParams = Record<string, string | number | boolean | undefined>;
export interface IHttpClient {
@@ -27,7 +49,7 @@ export interface IHttpClient {
patch<T>(path: string, body?: unknown): Promise<T>;
delete(path: string): Promise<void>;
/** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */
postForBlob(path: string, body: unknown): Promise<Blob>;
postForBlob(path: string, body: unknown): Promise<IDownloadedFile>;
}
function buildUrl(baseUrl: string, path: string, query?: QueryParams): string {
@@ -107,7 +129,7 @@ export class AadApiHttpClient implements IHttpClient {
await this.send<void>('DELETE', path);
}
public async postForBlob(path: string, body: unknown): Promise<Blob> {
public async postForBlob(path: string, body: unknown): Promise<IDownloadedFile> {
const url = buildUrl(this.baseUrl, path);
const response = await this.client.fetch(url, AadHttpClient.configurations.v1, {
method: 'POST',
@@ -119,7 +141,7 @@ export class AadApiHttpClient implements IHttpClient {
await parseResponse<void>(response);
throw new ApiClientError(response.status, [`HTTP ${response.status}`]);
}
return response.blob();
return { blob: await response.blob(), fileName: fileNameFromDisposition(response.headers.get('Content-Disposition')) };
}
private send<T>(method: string, path: string, body?: unknown): Promise<T> {