feat(autopodbor): показ очереди и этапа клиенту во время сбора (фронт)

Компонент RunCollectStatus: «вы в очереди №N» (пока прогон ждёт) или «этап N
из M: подпись» (пока идёт). Встроен в оба окна сбора (конкуренты/источники) и
экран загрузки ручного изучения. RunDto получил queue_position и progress.
Никаких фейковых процентов — только реальные этапы движка. 4 теста Vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-05 04:31:19 +03:00
parent 428b7b0f01
commit ffc5da7543
6 changed files with 83 additions and 5 deletions
+11
View File
@@ -45,6 +45,17 @@ export interface RunDto {
finished_at: string | null;
created_at: string | null;
competitor_id: number | null;
/** Место в общей очереди (пока прогон ждёт запуска). null — если уже идёт/завершён. */
queue_position: number | null;
/** Текущий этап работы (пока прогон идёт): «этап stage из total: label». null — если ждёт/завершён. */
progress: RunProgress | null;
}
/** Этап живого прогона для показа клиенту. */
export interface RunProgress {
stage: number;
total: number;
label: string;
}
export type Box = 'proposal' | 'field' | 'archived';
@@ -4,6 +4,7 @@ import { useAutopodborStore } from '../../../stores/autopodborStore';
import { autopodborErrorMessage, type FieldSourceDto, type WhereFound } from '../../../api/autopodbor';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../../constants/regions';
import { useOverlayDismiss } from '../../../composables/useOverlayDismiss';
import RunCollectStatus from './RunCollectStatus.vue';
interface AutopodborNav {
go: (s: string) => void;
@@ -302,17 +303,17 @@ async function saveAdd() {
// ——— создать проект из источника ———
// regions=[] + vsyaRf=true → «Вся РФ» (как в обычных проектах: пустой массив = вся страна).
const create = reactive({ open: false, srcIds: [] as number[], srcLabel: '', regions: [] as number[], vsyaRf: true, limit: 5, mask: 127 });
const create = reactive({ open: false, srcIds: [] as number[], srcLabel: '', regions: [] as number[], vsyaRf: false, limit: 5, mask: 127 });
function openCreate(s: FieldSourceDto) {
regQuery.value = '';
Object.assign(create, { open: true, srcIds: [s.id], srcLabel: sourceValue(s), regions: [], vsyaRf: true, limit: 5, mask: 127 });
Object.assign(create, { open: true, srcIds: [s.id], srcLabel: sourceValue(s), regions: [], vsyaRf: false, limit: 5, mask: 127 });
}
// Групповое создание: проекты для всех выбранных источников БЕЗ проекта — одним окном.
function openBulkCreate() {
const ids = selWork.value.filter((s) => !s.project).map((s) => s.id);
if (!ids.length) return;
regQuery.value = '';
Object.assign(create, { open: true, srcIds: ids, srcLabel: `выбрано источников: ${ids.length}`, regions: [], vsyaRf: true, limit: 5, mask: 127 });
Object.assign(create, { open: true, srcIds: ids, srcLabel: `выбрано источников: ${ids.length}`, regions: [], vsyaRf: false, limit: 5, mask: 127 });
}
function onCreateVsyaRf() {
if (create.vsyaRf) create.regions = [];
@@ -328,7 +329,7 @@ const regOpen = ref(false);
const regFiltered = computed(() => {
const q = regQuery.value.trim().toLowerCase();
const chosen = new Set(create.regions);
return regions.filter((r) => !chosen.has(r.code) && (q === '' || r.name.toLowerCase().includes(q))).slice(0, 8);
return regions.filter((r) => !chosen.has(r.code) && (q === '' || r.name.toLowerCase().includes(q)));
});
function pickRegion(code: number) {
if (!create.regions.includes(code)) create.regions.push(code);
@@ -343,7 +344,7 @@ const setQuery = ref('');
const setOpen = ref(false);
const setFiltered = computed(() => {
const q = setQuery.value.trim().toLowerCase();
return regions.filter((r) => q === '' || r.name.toLowerCase().includes(q)).slice(0, 8);
return regions.filter((r) => q === '' || r.name.toLowerCase().includes(q));
});
function pickSetRegion(code: number) {
psettings.regionCode = code;
@@ -712,6 +713,7 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
</template>
<template v-else>
<h3 class="ld-modal__h">Идёт изучение <span class="ld-spin"></span></h3>
<RunCollectStatus :run="store.currentRun" />
<p class="ld-modal__m">Можно закрыть вкладку сохраним результат. Деньги спишутся только при успехе.</p>
</template>
</div>
@@ -5,6 +5,7 @@ import { autopodborErrorMessage, type FieldCompetitorDto, type DuplicateGroupDto
import { REGIONS } from '../../../constants/regions';
import { useOverlayDismiss } from '../../../composables/useOverlayDismiss';
import CompetitorElementsEditor from './CompetitorElementsEditor.vue';
import RunCollectStatus from './RunCollectStatus.vue';
interface AutopodborNav {
go: (s: string) => void;
@@ -459,6 +460,7 @@ defineExpose({ competitors, selected, toggle, toggleAll, bulkProjects, openColle
</template>
<template v-else>
<h3 class="ld-modal__h">Идёт подбор <span class="ld-spin"></span></h3>
<RunCollectStatus :run="store.currentRun" />
<p class="ld-modal__m">Можно закрыть вкладку мы сохраним результат. Деньги спишутся только при успехе.</p>
</template>
</div>
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { inject } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import RunCollectStatus from './RunCollectStatus.vue';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const store = useAutopodborStore();
</script>
<template>
@@ -9,6 +12,7 @@ const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; scre
<div class="ld-loading-screen__wrap">
<div class="ld-spin"></div>
<p class="ld-loading-screen__msg">{{ nav.ctx.loadMsg || 'Идёт работа…' }}</p>
<RunCollectStatus :run="store.currentRun" />
<p class="ld-loading-screen__sub">{{ nav.ctx.loadSub || 'Пожалуйста, подождите.' }}</p>
<p class="ld-loading-screen__note">
Можно закрыть вкладку мы сохраним результат и покажем его, когда будет готово.
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { RunDto } from '../../../api/autopodbor';
// Что показать клиенту, пока идёт сбор: место в общей очереди (пока ждём запуска) ИЛИ текущий этап
// (пока идёт работа). Очередь важнее если прогон ещё не начали, показываем место.
defineProps<{ run: RunDto | null }>();
</script>
<template>
<p v-if="run && run.queue_position" class="ld-modal__m">
Вы в очереди: <b>{{ run.queue_position }}</b>. Как только дойдёт черёд начнём.
</p>
<p v-else-if="run && run.progress" class="ld-modal__m">
Этап {{ run.progress.stage }} из {{ run.progress.total }}: <b>{{ run.progress.label }}</b>
</p>
</template>
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import RunCollectStatus from '../../resources/js/views/autopodbor/screens/RunCollectStatus.vue';
describe('RunCollectStatus — что видит клиент во время сбора', () => {
it('в очереди — показывает номер места', () => {
const w = mount(RunCollectStatus, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props: { run: { status: 'queued', queue_position: 3, progress: null } as any },
});
expect(w.text()).toContain('№3');
expect(w.text().toLowerCase()).toContain('очеред');
});
it('идёт работа — показывает «этап N из M» и подпись этапа', () => {
const w = mount(RunCollectStatus, {
props: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run: { status: 'running', queue_position: null, progress: { stage: 2, total: 4, label: 'Ищем фирмы в справочниках' } } as any,
},
});
const t = w.text();
expect(t).toContain('2');
expect(t).toContain('4');
expect(t).toContain('Ищем фирмы в справочниках');
});
it('очередь важнее этапа: если есть и место, и этап — показываем место', () => {
const w = mount(RunCollectStatus, {
props: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run: { status: 'queued', queue_position: 5, progress: { stage: 1, total: 4, label: 'что-то' } as any } as any,
},
});
expect(w.text()).toContain('№5');
expect(w.text()).not.toContain('что-то');
});
it('нет данных (run=null) — ничего не показывает и не падает', () => {
const w = mount(RunCollectStatus, { props: { run: null } });
expect(w.text().trim()).toBe('');
});
});