Skip to content

Commit b609c4e

Browse files
committed
refactor interceptors for blob files & add download export xlsx files
1 parent 792992b commit b609c4e

11 files changed

Lines changed: 278 additions & 52 deletions

File tree

package-lock.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"class-transformer": "^0.5.1",
4747
"core-js": "^3.23.4",
4848
"dayjs": "^1.11.3",
49+
"file-saver": "^2.0.5",
4950
"fuse.js": "^6.6.2",
5051
"js-base64": "^3.7.7",
5152
"js-cookie": "^3.0.5",

projects/core/src/lib/interceptors/bearer-token.interceptor.ts

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,7 @@ export class BearerTokenInterceptor implements HttpInterceptor {
4646
*/
4747
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
4848
// Базовые заголовки для всех запросов
49-
const headers: Record<string, string> = {
50-
Accept: "application/json",
51-
};
49+
const headers: Record<string, string> = {};
5250

5351
const tokens = this.tokenService.getTokens();
5452

@@ -57,14 +55,24 @@ export class BearerTokenInterceptor implements HttpInterceptor {
5755
headers["Authorization"] = `Bearer ${tokens.access}`;
5856
}
5957

58+
// Для blob запросов (файлы) не устанавливаем Accept, чтобы не парсить blob как JSON
59+
const isBlobRequest =
60+
request.url.includes("/export") ||
61+
request.url.includes("/download") ||
62+
(request.headers.has("X-Request-Type") && request.headers.get("X-Request-Type") === "blob");
63+
64+
const hasAcceptHeader = request.headers.has("Accept");
65+
66+
if (!isBlobRequest && !hasAcceptHeader) {
67+
headers["Accept"] = "application/json";
68+
}
69+
6070
const req = request.clone({ setHeaders: headers });
6171

62-
// Если токены есть, обрабатываем запрос с возможностью обновления токенов
6372
if (tokens !== null) {
6473
return this.handleRequestWithTokens(req, next);
6574
} else {
66-
// Если токенов нет, просто выполняем запрос
67-
return next.handle(request);
75+
return next.handle(req);
6876
}
6977
}
7078

@@ -107,10 +115,9 @@ export class BearerTokenInterceptor implements HttpInterceptor {
107115
request: HttpRequest<unknown>,
108116
next: HttpHandler
109117
): Observable<HttpEvent<unknown>> {
110-
// Если токен еще не обновляется, начинаем процесс обновления
111118
if (!this.isRefreshing) {
112119
this.isRefreshing = true;
113-
this.refreshTokenSubject.next(null); // Сбрасываем subject
120+
this.refreshTokenSubject.next(null);
114121

115122
return this.tokenService.refreshTokens().pipe(
116123
catchError(err => {
@@ -119,22 +126,29 @@ export class BearerTokenInterceptor implements HttpInterceptor {
119126
}),
120127
switchMap(res => {
121128
this.isRefreshing = false;
122-
this.refreshTokenSubject.next(res.access); // Уведомляем о новом токене
129+
this.refreshTokenSubject.next(res.access);
123130

124-
// Сохраняем новые токены в хранилище
125131
this.tokenService.memTokens(res);
126132

127-
// Подготавливаем заголовки с новым токеном
128-
const headers: Record<string, string> = {
129-
Accept: "application/json",
130-
};
133+
const headers: Record<string, string> = {};
131134

132135
const tokens = this.tokenService.getTokens();
133136
if (tokens) {
134137
headers["Authorization"] = `Bearer ${tokens.access}`;
135138
}
136139

137-
// Повторяем исходный запрос с новым токеном
140+
const isBlobRequest =
141+
request.url.includes("/export") ||
142+
request.url.includes("/download") ||
143+
(request.headers.has("X-Request-Type") &&
144+
request.headers.get("X-Request-Type") === "blob");
145+
146+
const hasAcceptHeader = request.headers.has("Accept");
147+
148+
if (!isBlobRequest && !hasAcceptHeader) {
149+
headers["Accept"] = "application/json";
150+
}
151+
138152
return next.handle(
139153
request.clone({
140154
setHeaders: headers,
@@ -144,17 +158,32 @@ export class BearerTokenInterceptor implements HttpInterceptor {
144158
);
145159
}
146160

147-
// Если токен уже обновляется, ждем завершения процесса
148161
return this.refreshTokenSubject.pipe(
149-
filter(token => token !== null), // Ждем получения нового токена
150-
take(1), // Берем только первое значение
151-
switchMap(token =>
152-
next.handle(
162+
filter(token => token !== null),
163+
take(1),
164+
switchMap(token => {
165+
const headers: Record<string, string> = {
166+
Authorization: `Bearer ${token}`,
167+
};
168+
169+
const isBlobRequest =
170+
request.url.includes("/export") ||
171+
request.url.includes("/download") ||
172+
(request.headers.has("X-Request-Type") &&
173+
request.headers.get("X-Request-Type") === "blob");
174+
175+
const hasAcceptHeader = request.headers.has("Accept");
176+
177+
if (!isBlobRequest && !hasAcceptHeader) {
178+
headers["Accept"] = "application/json";
179+
}
180+
181+
return next.handle(
153182
request.clone({
154-
setHeaders: { Authorization: `Bearer ${token}` },
183+
setHeaders: headers,
155184
})
156-
)
157-
)
185+
);
186+
})
158187
);
159188
}
160189
}

projects/core/src/lib/interceptors/camelcase.interceptor.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,24 +54,34 @@ export class CamelcaseInterceptor implements HttpInterceptor {
5454
}),
5555
});
5656
} else {
57-
// Если тела нет, просто клонируем запрос
5857
req = request.clone();
5958
}
6059

6160
// Выполняем запрос и обрабатываем ответ
6261
return next.handle(req).pipe(
6362
map((event: HttpEvent<any>) => {
64-
// Обрабатываем только HTTP ответы (не события загрузки и т.д.)
6563
if (event instanceof HttpResponse) {
64+
if (event.body instanceof Blob) {
65+
return event;
66+
}
67+
68+
if (typeof event.body !== "object" || event.body === null) {
69+
return event;
70+
}
71+
6672
// Клонируем ответ с преобразованным телом (snake_case → camelCase)
67-
return event.clone({
68-
body: camelcaseKeys(event.body, {
69-
deep: true, // Рекурсивное преобразование вложенных объектов
70-
}),
71-
});
73+
try {
74+
return event.clone({
75+
body: camelcaseKeys(event.body, {
76+
deep: true, // Рекурсивное преобразование вложенных объектов
77+
}),
78+
});
79+
} catch (error) {
80+
console.warn("CamelcaseInterceptor: Failed to transform response body", error);
81+
return event;
82+
}
7283
}
7384

74-
// Для других типов событий возвращаем как есть
7585
return event;
7686
})
7787
);

projects/core/src/lib/services/api.service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ export class ApiService {
4444
return this.http.get(this.apiUrl + path, { params, ...options }).pipe(first()) as Observable<T>;
4545
}
4646

47+
getFile(path: string, params?: HttpParams): Observable<Blob> {
48+
return this.http
49+
.get(this.apiUrl + path, {
50+
params,
51+
responseType: "blob",
52+
})
53+
.pipe(first()) as Observable<Blob>;
54+
}
55+
4756
/**
4857
* Выполняет PUT запрос к API (полное обновление ресурса)
4958
* @param path - Относительный путь к ресурсу

projects/social_platform/src/app/office/program/detail/list/list.component.html

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,54 @@
5555
(closeFilter)="closeFilter()"
5656
(clear)="onClearFilters()"
5757
></app-projects-filter>
58+
59+
@if (listType === 'projects') {
60+
<div class="page__export" style="margin-top: 0">
61+
<app-button
62+
[loader]="loadingExportProjects()"
63+
customTypographyClass="text-body-12"
64+
size="medium"
65+
(click)="downloadProjects()"
66+
>
67+
<span class="text-body-12">выгрузка проектов</span>
68+
<i appIcon icon="download" appSquare="18"></i>
69+
</app-button>
70+
71+
<app-button
72+
[loader]="loadingExportSubmittedProjects()"
73+
customTypographyClass="text-body-12"
74+
size="medium"
75+
(click)="downloadSubmittedProjects()"
76+
>
77+
<span class="text-body-12">сданные решения</span>
78+
<i appIcon icon="download" appSquare="18"></i>
79+
</app-button>
80+
</div>
81+
} @else {
82+
<div class="page__export">
83+
<app-button
84+
[loader]="loadingExportRates()"
85+
customTypographyClass="text-body-12"
86+
size="medium"
87+
(click)="downloadRates()"
88+
>
89+
<span class="text-body-12">выгрузка оценок</span>
90+
<i appIcon icon="download" appSquare="18"></i>
91+
</app-button>
92+
93+
<app-button
94+
[disabled]="true"
95+
style="opacity: 0.5"
96+
[loader]="loadingExportCalculations()"
97+
customTypographyClass="text-body-12"
98+
size="medium"
99+
(click)="downloadCalculations()"
100+
>
101+
<span class="text-body-12">итоговые расчеты</span>
102+
<i appIcon icon="download" appSquare="18"></i>
103+
</app-button>
104+
</div>
105+
}
58106
</div>
59107
</div>
60108
}

projects/social_platform/src/app/office/program/detail/list/list.component.scss

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
.page {
55
display: grid;
66
grid-template-columns: 8fr 2fr;
7-
gap: 20px;
7+
gap: 10px;
88
padding-bottom: 100px;
99

1010
&__outlet {
@@ -59,6 +59,22 @@
5959
&__left {
6060
width: 100%;
6161
}
62+
63+
&__export {
64+
display: flex;
65+
align-items: center;
66+
flex-direction: column;
67+
gap: 10px;
68+
margin-top: 10px;
69+
70+
::ng-deep {
71+
app-button {
72+
span {
73+
margin-right: 5px !important;
74+
}
75+
}
76+
}
77+
}
6278
}
6379

6480
.filter {

0 commit comments

Comments
 (0)