feat(autopodbor): повторный сбор источников — фронт

Кнопка «🔄 Собрать источники ещё раз» вместо мёртвой «✓ Источники собраны».
Окно предупреждает, что повтор платный (даже если нового нет). Честный итог
после сбора (N новых / M ранее удалённых). Блок «ранее удалённые — вернуть?»
с кнопкой «Вернуть» (box archived→proposal).

- RunDto.result + collectResultMessage (чистый хелпер) + тесты
- store.restoreSource + тест
- компонент RefoundDeletedSources + тест
- FieldCompetitorScreen: кнопка-повтор, честный итог, блок «вернуть», текст окна

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-05 06:08:53 +03:00
parent 78a37978bc
commit 002e2c91c4
8 changed files with 227 additions and 8 deletions
+28
View File
@@ -49,6 +49,8 @@ export interface RunDto {
queue_position: number | null;
/** Текущий этап работы (пока прогон идёт): «этап stage из total: label». null — если ждёт/завершён. */
progress: RunProgress | null;
/** Итог прогона (для study: сколько новых источников и сколько ранее удалённых нашли снова). */
result: RunResult | null;
}
/** Этап живого прогона для показа клиенту. */
@@ -58,6 +60,32 @@ export interface RunProgress {
label: string;
}
/** Итог сбора источников: новые + ранее удалённые (их id — для блока «вернуть?»). */
export interface RunResult {
new_sources: number;
refound_deleted: number;
refound_deleted_ids: number[];
}
/**
* Честное сообщение клиенту по итогу сбора источников. Пусто (''), если итога нет (первый сбор
* покажет общий флеш). Формулировки без согласования числа с существительным — «источников — N».
*/
export function collectResultMessage(result: RunResult | null): string {
if (!result) return '';
const { new_sources: added, refound_deleted: refound } = result;
if (added > 0 && refound > 0) {
return `Готово: новых источников — ${added}, ранее удалённых снова найдено — ${refound} (можно вернуть). Они в «Предложениях».`;
}
if (added > 0) {
return `Готово: новых источников — ${added}. Они в «Предложениях».`;
}
if (refound > 0) {
return `Новых источников нет. Ранее удалённых снова найдено — ${refound} (можно вернуть).`;
}
return 'Новых источников не нашлось — всё, что есть по этому конкуренту, вы уже видите.';
}
export type Box = 'proposal' | 'field' | 'archived';
export type PhoneType = 'city' | 'mobile' | 'tollfree' | null;
@@ -296,6 +296,15 @@ export const useAutopodborStore = defineStore('autopodbor', () => {
sources.value = sources.value.filter((s) => s.id !== sourceId);
}
/**
* «Вернуть» ранее удалённый источник (найден снова при повторном сборе): из архива → в предложения.
* Перезагружаем карточку, чтобы источник появился в «Предложениях», а из блока «вернуть» исчез.
*/
async function restoreSource(competitorId: number, sourceId: number): Promise<void> {
await setSourceBox(sourceId, 'proposal');
await loadCompetitor(competitorId);
}
/** Управление проектом источника через готовую ручку проектов (все гварды там). */
async function toggleProjectActive(projectId: number, active: boolean): Promise<void> {
await apiToggleProjectActive(projectId, active);
@@ -366,6 +375,7 @@ export const useAutopodborStore = defineStore('autopodbor', () => {
moveSourceToBox,
editSource,
removeSource,
restoreSource,
toggleProjectActive,
bulkProjectsById,
changeProjectSource,
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { autopodborErrorMessage, type FieldSourceDto, type WhereFound } from '../../../api/autopodbor';
import { autopodborErrorMessage, collectResultMessage, type FieldSourceDto, type WhereFound } from '../../../api/autopodbor';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../../constants/regions';
import { useOverlayDismiss } from '../../../composables/useOverlayDismiss';
import { getRequisites, updateRequisites, type Requisites } from '../../../api/requisites';
import RunCollectStatus from './RunCollectStatus.vue';
import RefoundDeletedSources from './RefoundDeletedSources.vue';
interface AutopodborNav {
go: (s: string) => void;
@@ -265,17 +266,38 @@ async function runCollect() {
collect.running = true;
try {
const run = await store.study(nav.ctx.competitorId);
await store.pollRun(run.id);
const final = await store.pollRun(run.id);
await reload();
collect.open = false;
ctab.value = 'sugg';
flash('Готово: найдены источники — выберите нужные');
// Итог: при повторном сборе — честное сообщение (N новых / M ранее удалённых); при первом
// сборе result пуст → общий текст.
flash(collectResultMessage(final.result) || 'Готово: найдены источники — выберите нужные');
} catch (e) {
collect.running = false;
flash(autopodborErrorMessage(e, 'Не удалось собрать источники. Попробуйте ещё раз.'));
}
}
// Ранее удалённые источники, которые повторный сбор нашёл снова (id — из итога последнего прогона).
// Показываем блоком «вернуть?» пока они в архиве; после «Вернуть» box→proposal и они исчезают отсюда.
const refoundDeleted = computed(() => {
const ids = store.currentRun?.result?.refound_deleted_ids ?? [];
return store.sources.filter((s) => ids.includes(s.id) && s.box === 'archived');
});
async function restoreDeleted(id: number) {
if (nav.ctx.competitorId === null || busy.value) return;
busy.value = true;
try {
await store.restoreSource(nav.ctx.competitorId, id);
flash('Источник возвращён в предложения ✓');
} catch {
flash('Не удалось вернуть источник. Попробуйте ещё раз.');
} finally {
busy.value = false;
}
}
// ——— добавить источник вручную ———
const addSrc = reactive({ open: false, type: 'site', site: '', phone: '', prov: '', note: '' });
function openAdd() {
@@ -650,7 +672,7 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
<div class="ld-acts">
<button v-if="!comp?.studied_at" class="ld-btn primary" :disabled="busy" @click="openCollect"> Собрать источники для меня</button>
<button v-else class="ld-btn primary" disabled title="Источники по этому конкуренту уже собраны — повторный сбор не нужен"> Источники собраны</button>
<button v-else class="ld-btn primary" :disabled="busy" title="Поищем новые источники. Уже найденные не задвоятся, работающие не тронем." @click="openCollect">🔄 Собрать источники ещё раз</button>
<button class="ld-btn ghost" :disabled="busy" @click="openAdd">+ Добавить источник вручную</button>
</div>
@@ -752,7 +774,8 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
<!-- ===== ПРЕДЛОЖЕНИЯ ===== -->
<template v-else>
<div v-if="props.length === 0" class="ld-note">Предложений по источникам пока нет. Нажмите «Собрать источники для меня».</div>
<RefoundDeletedSources :sources="refoundDeleted" @restore="restoreDeleted" />
<div v-if="props.length === 0 && refoundDeleted.length === 0" class="ld-note">Предложений по источникам пока нет. Нажмите «Собрать источники для меня».</div>
<template v-else>
<div class="ld-selrow">
<label class="ld-selall"><input type="checkbox" :checked="allSelected" @change="toggleAll" /> Выбрать все источники</label>
@@ -815,7 +838,8 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
<label>Что известно о конкуренте сейчас</label>
<div class="ld-hint">Сайт: {{ comp?.site_url || '—' }} · 2ГИС: {{ (comp?.directory_urls ?? []).some((u) => u.includes('2gis')) ? 'есть' : 'нет' }} · Яндекс: {{ (comp?.directory_urls ?? []).some((u) => u.includes('yandex')) ? 'есть' : 'нет' }}</div>
</div>
<div class="ld-price">💳 Сбор источников <b>{{ store.prices.study }} </b> за изучение конкурента. Деньги спишутся <b>только если найдём источники</b>; пусто бесплатно.</div>
<div v-if="!comp?.studied_at" class="ld-price">💳 Сбор источников <b>{{ store.prices.study }} </b> за изучение конкурента. Деньги спишутся <b>только если найдём источники</b>; пусто бесплатно.</div>
<div v-else class="ld-price">💳 Повторный сбор <b>{{ store.prices.study }} </b>. Спишем за работу движка, <b>даже если нового не найдём</b>. Уже найденные не задвоятся, работающие не тронем, а те, что вы удаляли, предложим вернуть.</div>
<div class="ld-modal__foot">
<button class="ld-btn gray sm" @click="collect.open = false">Отмена</button>
<button class="ld-btn primary sm" @click="runCollect">Запустить изучение (платно)</button>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { FieldSourceDto } from '../../../api/autopodbor';
// Блок «ранее удалённые — снова найдены»: источники, которые клиент когда-то удалил, а повторный сбор
// нашёл заново. Показываем отдельно с кнопкой «Вернуть» (вдруг передумал) — как «ранее удалённые» в шаге 1.
defineProps<{ sources: FieldSourceDto[] }>();
defineEmits<{ restore: [id: number] }>();
</script>
<template>
<div v-if="sources.length" class="ld-refound">
<h4 class="ld-refound__h"> Ранее удалённые снова найдены</h4>
<p class="ld-refound__m">Эти источники вы удаляли, а мы нашли их снова. Передумали верните в предложения.</p>
<div v-for="s in sources" :key="s.id" class="ld-refound__row">
<span class="ld-refound__id">{{ s.identifier }}</span>
<button class="ld-btn ghost sm" @click="$emit('restore', s.id)">Вернуть</button>
</div>
</div>
</template>
<style scoped>
.ld-refound {
margin: 14px 0;
padding: 14px 16px;
border: 1px solid #e8e2d4;
border-radius: 12px;
background: #fbf9f3;
}
.ld-refound__h {
margin: 0 0 4px;
font-size: 14px;
font-weight: 700;
color: #012019;
}
.ld-refound__m {
margin: 0 0 10px;
font-size: 12.5px;
color: #6b6355;
line-height: 1.5;
}
.ld-refound__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
border-top: 1px solid #efeadd;
}
.ld-refound__id {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: #012019;
word-break: break-all;
}
</style>
@@ -168,13 +168,13 @@ describe('FieldCompetitorScreen', () => {
expect(bulkSpy).toHaveBeenCalledWith('pause', expect.arrayContaining([100, 101]));
});
it('у изучённого конкурента нет кнопки «Собрать источники», показано «Источники собраны»', async () => {
it('у изучённого конкурента первичной кнопки «Собрать источники для меня» нет — вместо неё повтор', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10 })], { studied_at: '2026-06-30T00:00:00+00:00' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.findAll('button').find((b) => b.text().includes('Собрать источники для меня'))).toBeFalsy();
expect(w.text()).toContain('Источники собраны');
expect(w.text()).toContain('Собрать источники ещё раз');
});
it('неизучённый конкурент показывает кнопку «Собрать источники для меня»', async () => {
@@ -235,4 +235,32 @@ describe('FieldCompetitorScreen', () => {
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('тип не меняется');
});
it('у уже изученного конкурента кнопка — «Собрать источники ещё раз» (повтор разрешён)', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, box: 'field' })], { studied_at: '2026-07-05T00:00:00Z' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
expect(w.text()).toContain('Собрать источники ещё раз');
expect(w.text()).not.toContain('Источники собраны');
});
it('блок «ранее удалённые — вернуть?» показывает архивные источники из итога прогона', async () => {
const store = useAutopodborStore();
seed(store, [
src({ id: 10, box: 'field', identifier: 'okna.ru' }),
src({ id: 99, box: 'archived', identifier: 'staroe.ru' }),
], { studied_at: '2026-07-05T00:00:00Z' });
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
// Итог последнего прогона: источник 99 — ранее удалённый, найден снова.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
store.currentRun = { id: 5, result: { new_sources: 0, refound_deleted: 1, refound_deleted_ids: [99] } } as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(w.vm as any).switchTab('sugg');
await w.vm.$nextTick();
expect(w.text().toLowerCase()).toContain('ранее удал');
expect(w.text()).toContain('staroe.ru');
});
});
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import RefoundDeletedSources from '../../resources/js/views/autopodbor/screens/RefoundDeletedSources.vue';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const src = (id: number, identifier: string): any => ({ id, identifier, signal_type: 'site', box: 'archived' });
describe('RefoundDeletedSources — блок «ранее удалённые, вернуть?»', () => {
it('показывает ранее удалённые источники и кнопки «Вернуть»', () => {
const w = mount(RefoundDeletedSources, { props: { sources: [src(7, 'staroe.ru'), src(9, '79001112233')] } });
expect(w.text()).toContain('staroe.ru');
expect(w.text()).toContain('79001112233');
expect(w.text().toLowerCase()).toContain('ранее удал');
expect(w.findAll('button').length).toBe(2);
});
it('клик «Вернуть» шлёт событие restore с id источника', async () => {
const w = mount(RefoundDeletedSources, { props: { sources: [src(7, 'staroe.ru')] } });
await w.find('button').trigger('click');
expect(w.emitted('restore')?.[0]).toEqual([7]);
});
it('пусто → блок не рисуется', () => {
const w = mount(RefoundDeletedSources, { props: { sources: [] } });
expect(w.text().trim()).toBe('');
});
});
@@ -173,4 +173,17 @@ describe('autopodborStore', () => {
expect(api.deleteSource).toHaveBeenCalledWith(50);
expect(s.sources.map((x) => x.id)).toEqual([51]);
});
it('restoreSource возвращает ранее удалённый источник в предложения и перезагружает карточку', async () => {
(api.setSourceBox as ReturnType<typeof vi.fn>).mockResolvedValue({ id: 7, box: 'proposal' });
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
competitor: { id: 3, name: 'Окна' },
sources: [{ id: 7, competitor_id: 3, signal_type: 'site', identifier: 'staroe.ru', box: 'proposal', project: null }],
});
const s = useAutopodborStore();
await s.restoreSource(3, 7);
expect(api.setSourceBox).toHaveBeenCalledWith(7, 'proposal');
expect(s.sources).toHaveLength(1);
expect(s.sources[0].box).toBe('proposal');
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { collectResultMessage } from '../../resources/js/api/autopodbor';
describe('collectResultMessage — честный итог сбора источников', () => {
it('есть и новые, и ранее удалённые', () => {
const m = collectResultMessage({ new_sources: 2, refound_deleted: 1, refound_deleted_ids: [7] });
expect(m).toContain('2');
expect(m).toContain('1');
expect(m.toLowerCase()).toContain('ранее удал');
});
it('только новые', () => {
const m = collectResultMessage({ new_sources: 3, refound_deleted: 0, refound_deleted_ids: [] });
expect(m).toContain('3');
expect(m.toLowerCase()).toContain('предложен');
expect(m.toLowerCase()).not.toContain('ранее удал');
});
it('только ранее удалённые', () => {
const m = collectResultMessage({ new_sources: 0, refound_deleted: 2, refound_deleted_ids: [1, 2] });
expect(m.toLowerCase()).toContain('новых источников нет');
expect(m).toContain('2');
});
it('ничего нового — честно говорим, что всё уже видно', () => {
const m = collectResultMessage({ new_sources: 0, refound_deleted: 0, refound_deleted_ids: [] });
expect(m.toLowerCase()).toContain('не нашл');
expect(m.toLowerCase()).toContain('уже');
});
it('нет итога (null) — пустая строка (первый сбор через общий флеш)', () => {
expect(collectResultMessage(null)).toBe('');
});
});