feat(автоподбор/UI): понятные метки источников + впечатываемый регион + групповое создание

FieldCompetitorScreen:
1) метки ✓ настоящий номер / 🎭 рекламный подменный + плейн-язык «где нашли»
   + легенда «как читать» (данные в БД и сверка с ЭТАЛОНом НЕ тронуты — перевод только в показе);
2) регион «впечатывается» — свой тайп-ахед в стиле экрана (без Vuetify, чтобы не ломать тесты)
   в окнах «Создать проект» и «Настройки проекта»; чипы для мультивыбора, подпись округа;
3) галка «Вся РФ (все регионы)» даёт баннер-объявление;
4) групповое создание проектов из всех выбранных источников без проекта (source_ids пачкой),
   заголовок/текст под «несколько». +тест на групповое создание. Фронт-спеки 24/24 зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-04 11:12:16 +03:00
parent 82e30d0548
commit ac3d6975c1
2 changed files with 224 additions and 23 deletions
@@ -2,7 +2,7 @@
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { autopodborErrorMessage, type FieldSourceDto, type WhereFound } from '../../../api/autopodbor';
import { REGIONS } from '../../../constants/regions';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../../constants/regions';
import { useOverlayDismiss } from '../../../composables/useOverlayDismiss';
interface AutopodborNav {
@@ -102,9 +102,22 @@ function phoneTypeLabel(s: FieldSourceDto) {
if (s.signal_type !== 'call') return '';
return { city: 'городской', mobile: 'мобильный', tollfree: '8-800' }[s.phone_type ?? ''] ?? '';
}
function phoneBadge(s: FieldSourceDto) {
if (s.signal_type !== 'call') return '';
return s.phone_kind === 'substitute' ? '🎭' : '✓';
// Понятная клиенту метка вида номера: настоящий vs рекламный подменный.
function kindTag(s: FieldSourceDto): { text: string; cls: string } | null {
if (s.signal_type !== 'call') return null;
return s.phone_kind === 'substitute'
? { text: '🎭 рекламный подменный', cls: 'ld-b-stop' }
: { text: '✓ настоящий номер', cls: 'ld-b-run' };
}
// Технические метки «где нашли» → человеческий язык (данные в БД не трогаем — только показ).
const WF_LABELS: Record<string, string> = {
'в коде сайта': 'на сайте',
коллтрекинг: 'показан на сайте',
'коллтрекинг (пул)': 'показан на сайте',
'на сайте (код города?)': 'на сайте (проверить)',
};
function wfLabel(l: string): string {
return WF_LABELS[l] ?? l;
}
function sourceValue(s: FieldSourceDto) {
return s.project?.signal_identifier ?? s.identifier;
@@ -289,9 +302,53 @@ async function saveAdd() {
// ——— создать проект из источника ———
// regions=[] + vsyaRf=true → «Вся РФ» (как в обычных проектах: пустой массив = вся страна).
const create = reactive({ open: false, srcId: null as number | null, srcLabel: '', regions: [] as number[], vsyaRf: true, limit: 5, mask: 127 });
const create = reactive({ open: false, srcIds: [] as number[], srcLabel: '', regions: [] as number[], vsyaRf: true, limit: 5, mask: 127 });
function openCreate(s: FieldSourceDto) {
Object.assign(create, { open: true, srcId: s.id, srcLabel: sourceValue(s), regions: [], vsyaRf: true, limit: 5, mask: 127 });
regQuery.value = '';
Object.assign(create, { open: true, srcIds: [s.id], srcLabel: sourceValue(s), regions: [], vsyaRf: true, 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 });
}
function onCreateVsyaRf() {
if (create.vsyaRf) create.regions = [];
}
// ——— свой тайп-ахед региона («впечатывается», как на слайде 1; в стиле экрана, без Vuetify) ———
function regionName(code: number): string {
return regions.find((r) => r.code === code)?.name ?? String(code);
}
// создание — мультивыбор
const regQuery = ref('');
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);
});
function pickRegion(code: number) {
if (!create.regions.includes(code)) create.regions.push(code);
create.vsyaRf = false;
regQuery.value = '';
}
function removeRegion(code: number) {
create.regions = create.regions.filter((c) => c !== code);
}
// настройки — одиночный
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);
});
function pickSetRegion(code: number) {
psettings.regionCode = code;
setQuery.value = regionName(code);
setOpen.value = false;
}
function isDayOn(mask: number, i: number) {
return (mask & (1 << i)) !== 0;
@@ -303,24 +360,34 @@ function setCreateDays(all: boolean) {
create.mask = all ? 127 : 31;
}
async function doCreate(launch: boolean) {
if (create.srcId === null) return;
if (create.srcIds.length === 0) return;
// «Вся РФ» снята, но регион не выбран — не создаём молча, просим выбрать.
if (!create.vsyaRf && create.regions.length === 0) {
flash('Выберите хотя бы один регион или отметьте «Вся РФ».');
return;
}
const many = create.srcIds.length > 1;
busy.value = true;
try {
await store.makeProjects({
source_ids: [create.srcId],
source_ids: create.srcIds,
regions: create.vsyaRf ? [] : create.regions,
daily_limit_target: create.limit,
delivery_days_mask: create.mask,
launch,
});
create.open = false;
selected.value = [];
await reload();
flash(launch ? 'Проект создан, идёт сбор ▶' : 'Проект создан (на паузе — запустите, когда готовы)');
flash(
many
? launch
? `Создано проектов: ${create.srcIds.length}, идёт сбор ▶`
: `Создано проектов: ${create.srcIds.length} (на паузе — запустите, когда готовы)`
: launch
? 'Проект создан, идёт сбор ▶'
: 'Проект создан (на паузе — запустите, когда готовы)',
);
} catch (e) {
flash(autopodborErrorMessage(e, 'Не удалось создать проект. Попробуйте ещё раз.'));
} finally {
@@ -337,6 +404,7 @@ function openSettings(s: FieldSourceDto) {
regionCode: s.project.regions?.[0] ?? regions[0]?.code ?? 24,
limit: s.project.daily_limit_target, mask: s.project.delivery_days_mask || 127,
});
setQuery.value = regionName(psettings.regionCode);
}
function toggleSetDay(i: number) {
psettings.mask ^= 1 << i;
@@ -445,7 +513,7 @@ onMounted(() => {
void reload();
});
defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, toggle, openEdit, openCollect, openAdd, openCreate, doCreate, toggleProject, bulkWork, bulkMoveToWork, moveToWork, sourceValue });
defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, toggle, openEdit, openCollect, openAdd, openCreate, openBulkCreate, doCreate, toggleProject, bulkWork, bulkMoveToWork, moveToWork, sourceValue });
</script>
<template>
@@ -474,6 +542,13 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
<button class="ld-tab" :class="{ 'ld-tab--on': ctab === 'sugg' }" @click="switchTab('sugg')">Предложения <span class="ld-tab__c">{{ props.length }}</span></button>
</div>
<div v-if="inWork.length || props.length" class="ld-legend">
<b>Как читать:</b>
<span class="ld-bdg ld-b-run"> настоящий номер</span> живой номер фирмы, по нему дозвонитесь;
<span class="ld-bdg ld-b-stop">🎭 рекламный подменный</span> номер из рекламы, он меняется, в работу брать не стоит;
<b>«где нашли»</b> источник номера: кликните и проверьте сами.
</div>
<!-- ===== РАБОТА ===== -->
<template v-if="ctab === 'work'">
<div v-if="inWork.length === 0" class="ld-note">Источников в работе пока нет добавьте вручную или попросите собрать.</div>
@@ -491,16 +566,16 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
<div class="ld-srcicon">{{ s.signal_type === 'call' ? '📞' : '🌐' }}</div>
<div class="ld-srcmain">
<div class="ld-srctitle">
<span v-if="phoneBadge(s)">{{ phoneBadge(s) }}</span>
{{ sourceValue(s) }}
<span v-if="phoneTypeLabel(s)" class="ld-bdg" :class="s.phone_type === 'mobile' ? 'ld-bdg--fed' : 'ld-bdg--loc'">{{ phoneTypeLabel(s) }}</span>
<span v-if="kindTag(s)" class="ld-bdg" :class="kindTag(s)!.cls">{{ kindTag(s)!.text }}</span>
</div>
<div v-if="s.office" class="ld-srcprov">🏢 {{ s.office }}</div>
<div class="ld-found">
<span class="ld-found__lbl">где нашли:</span>
<template v-for="(wf, i) in whereFound(s)" :key="i">
<a v-if="safeUrl(wf.url)" class="ld-fchip" :href="safeUrl(wf.url)!" target="_blank" rel="noopener">{{ wf.label }}</a>
<span v-else class="ld-fchip ld-fchip--plain">{{ wf.label }}</span>
<a v-if="safeUrl(wf.url)" class="ld-fchip" :href="safeUrl(wf.url)!" target="_blank" rel="noopener">{{ wfLabel(wf.label) }}</a>
<span v-else class="ld-fchip ld-fchip--plain">{{ wfLabel(wf.label) }}</span>
</template>
<span v-if="!whereFound(s).length" class="ld-found__lbl"></span>
<span v-if="confirms(s) > 1" class="ld-found__cnt">{{ confirms(s) }} подтвержд.</span>
@@ -532,7 +607,7 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
<span>Выбрано источников без проекта: <b>{{ selected.length }}</b> создадим проекты разом</span>
<div class="ld-bulkbar__acts">
<button class="ld-btn gray sm" @click="clearSel">Снять выбор</button>
<button class="ld-btn primary sm" :disabled="busy" @click="openCreate(selWork[0])">Создать проекты для выбранных </button>
<button class="ld-btn primary sm" :disabled="busy" @click="openBulkCreate()">Создать проекты для выбранных </button>
</div>
</template>
<template v-else-if="selAllHaveProj">
@@ -566,17 +641,17 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
<div class="ld-srcicon">{{ s.signal_type === 'call' ? '📞' : '🌐' }}</div>
<div class="ld-srcmain">
<div class="ld-srctitle">
<span v-if="phoneBadge(s)">{{ phoneBadge(s) }}</span>
{{ s.identifier }}
<span v-if="phoneTypeLabel(s)" class="ld-bdg" :class="s.phone_type === 'mobile' ? 'ld-bdg--fed' : 'ld-bdg--loc'">{{ phoneTypeLabel(s) }}</span>
<span v-if="kindTag(s)" class="ld-bdg" :class="kindTag(s)!.cls">{{ kindTag(s)!.text }}</span>
<span class="ld-bdg ld-bdg--sug">предложение</span>
</div>
<div v-if="s.office" class="ld-srcprov">🏢 {{ s.office }}</div>
<div class="ld-found">
<span class="ld-found__lbl">где нашли:</span>
<template v-for="(wf, i) in whereFound(s)" :key="i">
<a v-if="safeUrl(wf.url)" class="ld-fchip" :href="safeUrl(wf.url)!" target="_blank" rel="noopener">{{ wf.label }}</a>
<span v-else class="ld-fchip ld-fchip--plain">{{ wf.label }}</span>
<a v-if="safeUrl(wf.url)" class="ld-fchip" :href="safeUrl(wf.url)!" target="_blank" rel="noopener">{{ wfLabel(wf.label) }}</a>
<span v-else class="ld-fchip ld-fchip--plain">{{ wfLabel(wf.label) }}</span>
</template>
<span v-if="!whereFound(s).length" class="ld-found__lbl"></span>
<span v-if="confirms(s) > 1" class="ld-found__cnt">{{ confirms(s) }} подтвержд.</span>
@@ -660,8 +735,8 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
<!-- ===== Окно: Создать проект ===== -->
<div v-if="create.open" class="ld-ovl" @mousedown="onOverlayMousedown" @mouseup="(e) => onOverlayMouseup(e, () => { create.open = false; })">
<div class="ld-modal">
<h3 class="ld-modal__h">Создать проект из источника</h3>
<p class="ld-modal__m">{{ create.srcLabel }}. 📣 Лидерра поставит проект в сбор сразу. Первые лиды пойдут с {{ leadStartText() }}.</p>
<h3 class="ld-modal__h">{{ create.srcIds.length > 1 ? 'Создать проекты из выбранных источников' : 'Создать проект из источника' }}</h3>
<p class="ld-modal__m">{{ create.srcLabel }}. 📣 Лидерра поставит {{ create.srcIds.length > 1 ? 'проекты' : 'проект' }} в сбор сразу. Первые лиды пойдут с {{ leadStartText() }}.</p>
<div class="ld-rules">
<h4>Как заполнить</h4>
<ul>
@@ -671,9 +746,21 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
</ul>
</div>
<div class="ld-fld"><label>Регионы <span class="ld-req">*</span></label>
<label class="ld-selall" style="margin-bottom: 6px"><input type="checkbox" v-model="create.vsyaRf" /> Вся РФ</label>
<select v-if="!create.vsyaRf" v-model="create.regions" multiple class="ld-in" size="6"><option v-for="r in regions" :key="r.code" :value="r.code">{{ r.name }}</option></select>
<div class="ld-hint">Отметьте «Вся РФ» или снимите галочку и выберите один или несколько регионов (Ctrl/ несколько).</div></div>
<div class="ld-ta" :class="{ 'ld-ta--off': create.vsyaRf }">
<div v-if="create.regions.length" class="ld-ta__chips">
<span v-for="code in create.regions" :key="code" class="ld-ta__chip">{{ regionName(code) }}<button type="button" class="ld-ta__x" @click="removeRegion(code)">×</button></span>
</div>
<input v-model="regQuery" class="ld-in ld-ta__inp" :disabled="create.vsyaRf" placeholder="Начните вводить регион…" @focus="regOpen = true" @blur="regOpen = false" />
<div v-if="regOpen && !create.vsyaRf && regFiltered.length" class="ld-ta__menu">
<button v-for="r in regFiltered" :key="r.code" type="button" class="ld-ta__opt" @mousedown.prevent="pickRegion(r.code)">
<span class="ld-ta__opt-name">{{ r.name }}</span>
<span class="ld-ta__opt-sub">{{ FEDERAL_DISTRICT_NAMES[r.federalDistrict] || '' }}</span>
</button>
</div>
</div>
<label class="ld-selall" style="margin-top: 8px"><input type="checkbox" v-model="create.vsyaRf" @change="onCreateVsyaRf" /> Вся РФ (все регионы)</label>
<div v-if="create.vsyaRf" class="ld-banner" style="margin-top: 6px"><div class="ld-banner__txt"> Проект будет получать лиды по всей России по всем субъектам страны.</div></div>
<div v-else class="ld-hint">Начните вводить название региона можно выбрать один или несколько.</div></div>
<div class="ld-fld"><label>Лимит заявок в день <span class="ld-req">*</span></label><input v-model.number="create.limit" type="number" min="1" class="ld-in" /><div class="ld-hint">Если лимит превысит баланс проект приостановится, пока не пополните счёт.</div></div>
<div class="ld-fld"><label>Дни недели приёма</label>
<div class="ld-days"><button v-for="(d, i) in DAYS" :key="i" class="ld-day" :class="{ 'ld-day--on': isDayOn(create.mask, i) }" @click="toggleCreateDay(i)">{{ d }}</button></div>
@@ -693,7 +780,16 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
<h3 class="ld-modal__h">Настройки проекта</h3>
<p class="ld-modal__m">{{ psettings.label }}. Меняйте объём, регионы и дни приёма.</p>
<div class="ld-banner" style="margin: 0 0 14px"><div class="ld-banner__txt"> Ближайший сбор уже идёт по текущим настройкам. Новые количество, регионы и дни вступят в силу с <b>{{ appliesFromText() }}</b> в 21:00 МСК. Пауза действует сразу.</div></div>
<div class="ld-fld"><label>Регион <span class="ld-req">*</span></label><select v-model="psettings.regionCode" class="ld-in"><option v-for="r in regions" :key="r.code" :value="r.code">{{ r.name }}</option></select></div>
<div class="ld-fld"><label>Регион <span class="ld-req">*</span></label>
<div class="ld-ta">
<input v-model="setQuery" class="ld-in ld-ta__inp" placeholder="Начните вводить регион…" @focus="setOpen = true; setQuery = ''" @blur="setOpen = false; setQuery = regionName(psettings.regionCode)" />
<div v-if="setOpen && setFiltered.length" class="ld-ta__menu">
<button v-for="r in setFiltered" :key="r.code" type="button" class="ld-ta__opt" @mousedown.prevent="pickSetRegion(r.code)">
<span class="ld-ta__opt-name">{{ r.name }}</span>
<span class="ld-ta__opt-sub">{{ FEDERAL_DISTRICT_NAMES[r.federalDistrict] || '' }}</span>
</button>
</div>
</div></div>
<div class="ld-fld"><label>Лимит заявок в день <span class="ld-req">*</span></label><input v-model.number="psettings.limit" type="number" min="1" class="ld-in" /><div class="ld-hint">Если лимит превысит баланс проект приостановится, пока не пополните счёт.</div></div>
<div class="ld-fld"><label>Дни недели приёма</label>
<div class="ld-days"><button v-for="(d, i) in DAYS" :key="i" class="ld-day" :class="{ 'ld-day--on': isDayOn(psettings.mask, i) }" @click="toggleSetDay(i)">{{ d }}</button></div>
@@ -764,4 +860,88 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, t
padding: 22px 0 96px;
max-width: 1080px;
}
.ld-legend {
margin: 4px 0 16px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e4e0d6;
border-radius: 10px;
font-size: 12.5px;
line-height: 2;
color: #5b6b64;
}
.ld-legend .ld-bdg {
margin: 0 3px;
}
/* Свой тайп-ахед региона («впечатывается») */
.ld-ta {
position: relative;
}
.ld-ta--off {
opacity: 0.5;
}
.ld-ta__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 6px;
}
.ld-ta__chip {
display: inline-flex;
align-items: center;
gap: 2px;
background: #eef4f1;
color: #0f6e56;
border-radius: 999px;
padding: 3px 6px 3px 11px;
font-size: 13px;
}
.ld-ta__x {
border: 0;
background: transparent;
color: #0f6e56;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 3px;
}
.ld-ta__inp {
width: 100%;
}
.ld-ta__menu {
position: absolute;
left: 0;
right: 0;
top: 100%;
z-index: 40;
margin-top: 4px;
max-height: 240px;
overflow-y: auto;
background: #fff;
border: 1px solid #e4e0d6;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(1, 32, 25, 0.14);
}
.ld-ta__opt {
display: block;
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 8px 12px;
cursor: pointer;
}
.ld-ta__opt:hover {
background: #f2f7f4;
}
.ld-ta__opt-name {
display: block;
font-size: 14px;
color: #012019;
}
.ld-ta__opt-sub {
display: block;
font-size: 11px;
color: #8a948f;
}
</style>
@@ -131,4 +131,25 @@ describe('FieldCompetitorScreen — создание проекта из ист
expect(String(vm.toast).toLowerCase()).toContain('реквизит');
expect(String(vm.toast).toLowerCase()).not.toContain('баланс');
});
it('групповое создание: doCreate шлёт source_ids всех выбранных источников без проекта', async () => {
(api.fetchCompetitor as ReturnType<typeof vi.fn>).mockResolvedValue({
competitor: { id: 10, name: 'Окна', is_federal: false, relevance_pct: 90, origin: 'auto', box: 'field', site_url: 'okna.ru', directory_urls: [], studied_at: '2026-01-01', study_run_id: 1, search_run_id: 1, description: '' },
sources: [makeSource(1), makeSource(2)],
});
(api.createProjects as ReturnType<typeof vi.fn>).mockResolvedValue({ data: [], launch: null });
const store = useAutopodborStore();
await store.loadCompetitor(10);
const wrapper = mount(FieldCompetitorScreen, {
global: { provide: { autopodborNav: makeNav() }, stubs: { Transition: false } },
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.selected = [1, 2];
vm.openBulkCreate();
await wrapper.vm.$nextTick();
await vm.doCreate(true);
expect(api.createProjects).toHaveBeenLastCalledWith(expect.objectContaining({ source_ids: [1, 2], launch: true }));
});
});