fix(sales): «Скачать файл для МТС» слал 401 — кнопка была обычной href
Маршрут /api/sales/ad-audience/mts-file висит за middleware auth:sales (Bearer-токен). Обычная <a href> браузерная навигация не отправляет заголовок Authorization — начальник получал бы 401 вместо файла. Качаем через тот же axios-слой, что и остальные запросы портала продаж (downloadSalesAdAudienceMtsFile в api/sales.ts, токен уходит в заголовке), дальше отдаём файл через Blob + временную ссылку. Кнопка переведена с href на @click. Тест на кнопку переписан: раньше проверял href на маршрут (то есть проверял ровно то, что было сломано), теперь проверяет, что href нет и что клик зовёт функцию скачивания через API.
This commit is contained in:
@@ -1083,6 +1083,38 @@ export async function setSalesAdAudienceChannels(
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sales/ad-audience/mts-file (Bearer, начальник) — файл со списком
|
||||
* номеров для МТС.
|
||||
*
|
||||
* Маршрут за auth:sales (Bearer-токен, который SPA шлёт через axios) — обычная
|
||||
* <a href> его не пошлёт и получит 401. Качаем через тот же axios-слой, что
|
||||
* и остальные запросы, и отдаём файл пользователю через Blob + временную ссылку.
|
||||
*/
|
||||
export async function downloadSalesAdAudienceMtsFile(): Promise<void> {
|
||||
const response = await axios.get('/api/sales/ad-audience/mts-file', {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Accept: 'text/plain',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
...authHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
const disposition = response.headers['content-disposition'] as string | undefined;
|
||||
const match = disposition?.match(/filename="?([^"]+)"?/);
|
||||
const filename = match?.[1] ?? `liderra-mts-${new Date().toISOString().slice(0, 10)}.txt`;
|
||||
|
||||
const blobUrl = URL.createObjectURL(response.data as Blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
|
||||
// ─── Потенциальные клиенты (воронка-канбан) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
assignSalesAdAudienceFirm,
|
||||
listSalesManagers,
|
||||
setSalesAdAudienceChannels,
|
||||
downloadSalesAdAudienceMtsFile,
|
||||
extractSalesErrorMessage,
|
||||
type SalesAdAudienceState,
|
||||
type SalesAdAudienceDurations,
|
||||
@@ -301,6 +302,20 @@ async function applyChannels(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Начальник жмёт «Скачать файл для МТС» — файл качается через axios (Bearer-токен). */
|
||||
const downloadingMtsFile = ref(false);
|
||||
|
||||
async function downloadMtsFile(): Promise<void> {
|
||||
downloadingMtsFile.value = true;
|
||||
try {
|
||||
await downloadSalesAdAudienceMtsFile();
|
||||
} catch (e) {
|
||||
notify(extractSalesErrorMessage(e, 'Не удалось скачать файл для МТС.'), 'error');
|
||||
} finally {
|
||||
downloadingMtsFile.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── назначение менеджера ────────────────────────────────────────────────────────
|
||||
|
||||
const managers = ref<SalesManagerRow[]>([]);
|
||||
@@ -368,6 +383,7 @@ defineExpose({
|
||||
selectedFirmIds,
|
||||
applyingChannels,
|
||||
pick,
|
||||
downloadingMtsFile,
|
||||
managers,
|
||||
assignOpen,
|
||||
assignFirm,
|
||||
@@ -384,6 +400,7 @@ defineExpose({
|
||||
doAssign,
|
||||
channelsLabel,
|
||||
applyChannels,
|
||||
downloadMtsFile,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -550,8 +567,9 @@ defineExpose({
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="text-none"
|
||||
href="/api/sales/ad-audience/mts-file"
|
||||
:loading="downloadingMtsFile"
|
||||
data-testid="mts-file"
|
||||
@click="downloadMtsFile"
|
||||
>
|
||||
Скачать файл для МТС
|
||||
</v-btn>
|
||||
|
||||
@@ -59,6 +59,7 @@ vi.mock('../../resources/js/api/sales', async (importOriginal) => {
|
||||
assignSalesAdAudienceFirm: vi.fn(),
|
||||
listSalesManagers: vi.fn(),
|
||||
setSalesAdAudienceChannels: vi.fn(),
|
||||
downloadSalesAdAudienceMtsFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -289,13 +290,21 @@ describe('Реклама на кандидатов — экран начальн
|
||||
expect(wrapper.find('[data-testid="firm-channels"]').text()).toBe('нигде');
|
||||
});
|
||||
|
||||
it('кнопка «Скачать файл для МТС» ведёт на маршрут выгрузки', async () => {
|
||||
it('кнопка «Скачать файл для МТС» качает файл через axios-слой с токеном, не через href', async () => {
|
||||
vi.mocked(salesApi.downloadSalesAdAudienceMtsFile).mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
|
||||
const link = wrapper.find('[data-testid="mts-file"]');
|
||||
expect(link.exists()).toBe(true);
|
||||
expect(link.attributes('href')).toBe('/api/sales/ad-audience/mts-file');
|
||||
const btn = wrapper.find('[data-testid="mts-file"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
// Маршрут за auth:sales (Bearer-токен) — обычный <a href> его не пошлёт и получит 401.
|
||||
expect(btn.attributes('href')).toBeUndefined();
|
||||
|
||||
await btn.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(salesApi.downloadSalesAdAudienceMtsFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Task 9 плана «Выбор площадки прогрева» (2026-07-19): состояние канала ВК
|
||||
|
||||
Reference in New Issue
Block a user