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:
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user