feat(автоподбор): фронт — экран источников конкурента (выбор, ручной источник)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-28 15:41:07 +03:00
parent 361d02a256
commit 3bc4325b78
3 changed files with 663 additions and 0 deletions
@@ -6,6 +6,7 @@ import AutoFormScreen from './screens/AutoFormScreen.vue';
import ManualFormScreen from './screens/ManualFormScreen.vue';
import LoadingScreen from './screens/LoadingScreen.vue';
import ListScreen from './screens/ListScreen.vue';
import DetailScreen from './screens/DetailScreen.vue';
type ScreenName =
| 'entry'
@@ -28,6 +29,7 @@ const ctx = reactive({
selectedSourceIds: [] as number[],
loadMsg: '',
loadSub: '',
editProjectId: null as number | null,
});
function go(name: ScreenName) {
@@ -43,6 +45,7 @@ const screens: Partial<Record<ScreenName, any>> = {
manualform: ManualFormScreen,
loading: LoadingScreen,
list: ListScreen,
detail: DetailScreen,
};
onMounted(() => {
@@ -0,0 +1,585 @@
<script setup lang="ts">
import { inject, onMounted, computed, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import type { SourceDto } from '../../../api/autopodbor';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const store = useAutopodborStore();
const showAddSource = ref(false);
const addSourceRaw = ref('');
const addSourceLoading = ref(false);
onMounted(async () => {
if (nav.ctx.competitorId) {
await store.loadCompetitor(nav.ctx.competitorId);
// Auto-select sources without existing project
nav.ctx.selectedSourceIds = store.sources
.filter((s: SourceDto) => s.existing_project_id == null)
.map((s: SourceDto) => s.id);
}
});
const sites = computed(() =>
store.sources.filter((s: SourceDto) => s.signal_type === 'site'),
);
const calls = computed(() =>
store.sources.filter((s: SourceDto) => s.signal_type === 'call'),
);
const selectedCount = computed(() => nav.ctx.selectedSourceIds.length);
const totalCount = computed(() => store.sources.length);
function isSelected(id: number): boolean {
return nav.ctx.selectedSourceIds.includes(id);
}
function toggleSource(id: number) {
const idx = nav.ctx.selectedSourceIds.indexOf(id);
if (idx === -1) {
nav.ctx.selectedSourceIds.push(id);
} else {
nav.ctx.selectedSourceIds.splice(idx, 1);
}
}
function clearSelection() {
nav.ctx.selectedSourceIds = [];
}
function goCreate() {
nav.go('create');
}
function editProject(projectId: number) {
nav.ctx.editProjectId = projectId;
nav.go('editproject');
}
async function doAddSource() {
if (!addSourceRaw.value.trim() || !nav.ctx.competitorId) return;
addSourceLoading.value = true;
try {
await store.addSource({ competitor_id: nav.ctx.competitorId, raw: addSourceRaw.value.trim() });
addSourceRaw.value = '';
showAddSource.value = false;
} finally {
addSourceLoading.value = false;
}
}
</script>
<template>
<div class="ld-detail-screen">
<!-- Topbar breadcrumb -->
<div class="ld-topbar">
<div class="ld-crumb">
Автоподбор
<template v-if="store.competitor"> · {{ store.competitor.name }}</template>
</div>
</div>
<div class="ld-detail-content">
<!-- Back link -->
<button class="ld-back" @click="nav.go('list')"> К списку конкурентов</button>
<!-- Competitor header -->
<template v-if="store.competitor">
<div class="ld-chead">
<h1 class="ld-chead__name">
{{ store.competitor.name }}
<span v-if="store.competitor.is_federal" class="ld-badge ld-badge--fed">федеральный</span>
</h1>
<div v-if="store.competitor.relevance_pct !== null" class="ld-relbox">
<div class="ld-relnum rel-100">{{ store.competitor.relevance_pct }}%</div>
<div class="ld-rellbl">похожесть</div>
</div>
</div>
<p v-if="store.competitor.studied_at" class="ld-studied">
Изучено {{ store.competitor.studied_at }} · найдено {{ totalCount }} источников
</p>
</template>
<!-- Explanatory note -->
<div class="ld-note">
Отметьте источники, по которым создать проекты. У каждого ссылка «где нашли».
<b>Подменный (с сайта)</b> номер из коллтрекинга, его набирают клиенты с сайта;
<b>настоящий</b> линия из кода сайта или справочника. Берём оба.
<b>Страница показывает актуальное состояние:</b>
источники, по которым проект уже создан, помечены « проект создан» их можно изменить прямо здесь.
</div>
<!-- Sites section -->
<div v-if="sites.length" class="ld-sect">
<div class="ld-secthd">
🌐 Сайты
<span class="ld-cnt">· {{ sites.length }} найдено · только головы доменов</span>
</div>
<div
v-for="src in sites"
:key="src.id"
class="ld-row"
:class="{ 'ld-row--used': src.existing_project_id != null }"
>
<input
type="checkbox"
class="ld-cb"
:checked="isSelected(src.id)"
:disabled="src.existing_project_id != null"
@change="toggleSource(src.id)"
>
<div class="ld-rinfo">
<div class="ld-rident ld-rident--site">{{ src.identifier }}</div>
<div class="ld-rprov">
Где нашли:
<a v-if="src.provenance_url" :href="src.provenance_url" target="_blank" rel="noopener">
{{ src.provenance_label || src.provenance_url }}
</a>
<span v-else>{{ src.provenance_label }}</span>
</div>
<span v-if="src.existing_project_id != null" class="ld-used"> проект создан</span>
</div>
<button
v-if="src.existing_project_id != null"
class="ld-btn-ghost ld-btn-ghost--sm"
@click="editProject(src.existing_project_id!)"
>
Изменить проект
</button>
</div>
</div>
<!-- Calls section -->
<div v-if="calls.length" class="ld-sect">
<div class="ld-secthd">
📞 Телефоны
<span class="ld-cnt">· {{ calls.length }} найдено</span>
</div>
<div
v-for="src in calls"
:key="src.id"
class="ld-row"
:class="{ 'ld-row--used': src.existing_project_id != null }"
>
<input
type="checkbox"
class="ld-cb"
:checked="isSelected(src.id)"
:disabled="src.existing_project_id != null"
@change="toggleSource(src.id)"
>
<div class="ld-rinfo">
<div class="ld-rident">
{{ src.identifier }}
<span v-if="src.phone_kind === 'real'" class="ld-tag ld-tag--real">настоящий</span>
<span v-if="src.phone_kind === 'substitute'" class="ld-tag ld-tag--sub">подменный · с сайта</span>
</div>
<div class="ld-rprov">
Где нашли:
<a v-if="src.provenance_url" :href="src.provenance_url" target="_blank" rel="noopener">
{{ src.provenance_label || src.provenance_url }}
</a>
<span v-else>{{ src.provenance_label }}</span>
</div>
<span v-if="src.existing_project_id != null" class="ld-used"> проект создан</span>
</div>
<button
v-if="src.existing_project_id != null"
class="ld-btn-ghost ld-btn-ghost--sm"
@click="editProject(src.existing_project_id!)"
>
Изменить проект
</button>
</div>
</div>
<!-- Manual source add -->
<div class="ld-addbox">
<b>Чего-то не хватает?</b>
<p>
Знаете ещё сайт или номер этого конкурента
<span v-if="!showAddSource" class="ld-addlink" @click="showAddSource = true">
добавьте источник вручную
</span>
<span v-else class="ld-addlink" @click="showAddSource = false">скрыть</span>.
</p>
<div v-if="showAddSource" class="ld-addsrc">
<input
v-model="addSourceRaw"
class="ld-inp"
placeholder="okna-komfort.ru · или +7 843 200-00-00"
@keydown.enter="doAddSource"
>
<button
class="ld-btn-primary ld-btn-primary--sm"
:disabled="addSourceLoading || !addSourceRaw.trim()"
@click="doAddSource"
>
Добавить источник
</button>
</div>
</div>
</div>
<!-- Bottom action bar -->
<div class="ld-actionbar">
<div class="ld-selinfo">
Выбрано <b>{{ selectedCount }}</b> из {{ totalCount }} источников
</div>
<div class="ld-actionbar__btns">
<button class="ld-btn-ghost" @click="clearSelection">Снять выбор</button>
<button
class="ld-btn-primary"
:disabled="selectedCount === 0"
@click="goCreate"
>
Создать проекты
</button>
</div>
</div>
</div>
</template>
<style scoped>
.ld-detail-screen {
display: flex;
flex-direction: column;
min-height: 100%;
}
.ld-topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0 14px;
border-bottom: 1px solid #e8e2d4;
margin-bottom: 20px;
}
.ld-crumb {
font-size: 13px;
color: #7a7468;
}
.ld-detail-content {
flex: 1;
padding-bottom: 80px;
}
.ld-back {
background: none;
border: none;
color: var(--liderra-teal, #0f6e56);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-bottom: 18px;
display: inline-block;
}
.ld-back:hover {
text-decoration: underline;
}
.ld-chead {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 8px;
}
.ld-chead__name {
font-size: 22px;
font-weight: 800;
color: #012019;
margin: 0;
flex: 1;
}
.ld-badge {
font-size: 11px;
border-radius: 4px;
padding: 2px 7px;
margin-left: 6px;
font-weight: 500;
vertical-align: middle;
}
.ld-badge--fed {
background: #edf3fb;
color: #1a4f8a;
border: 1px solid #c5d8ef;
}
.ld-relbox {
text-align: center;
flex-shrink: 0;
}
.ld-relnum {
font-size: 22px;
font-weight: 800;
line-height: 1;
}
.ld-rellbl {
font-size: 11px;
color: #9b9484;
margin-top: 2px;
}
.rel-100 { color: var(--liderra-teal, #0f6e56); }
.rel-hi { color: #2e7d32; }
.rel-mid { color: #b45309; }
.rel-low { color: #9b9484; }
.ld-studied {
font-size: 13px;
color: #7a7468;
margin: 0 0 14px;
}
.ld-note {
background: #f6f3ec;
border: 1px solid #e8e2d4;
border-radius: 8px;
padding: 12px 16px;
font-size: 13px;
color: #4a4540;
line-height: 1.55;
margin-bottom: 20px;
}
.ld-sect {
margin-bottom: 20px;
}
.ld-secthd {
font-size: 14px;
font-weight: 700;
color: #012019;
margin-bottom: 10px;
}
.ld-cnt {
font-size: 12.5px;
font-weight: 400;
color: #9b9484;
}
.ld-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e8e2d4;
border-radius: 8px;
margin-bottom: 6px;
background: #fff;
}
.ld-row--used {
background: #fbfaf5;
}
.ld-cb {
margin-top: 3px;
cursor: pointer;
flex-shrink: 0;
}
.ld-cb:disabled {
opacity: 0.5;
cursor: default;
}
.ld-rinfo {
flex: 1;
min-width: 0;
}
.ld-rident {
font-size: 14px;
font-weight: 600;
color: #012019;
margin-bottom: 4px;
}
.ld-rident--site {
color: var(--liderra-teal, #0f6e56);
}
.ld-rprov {
font-size: 12px;
color: #7a7468;
}
.ld-rprov a {
color: var(--liderra-teal, #0f6e56);
text-decoration: none;
}
.ld-rprov a:hover {
text-decoration: underline;
}
.ld-tag {
display: inline-block;
font-size: 11px;
border-radius: 4px;
padding: 1px 6px;
margin-left: 6px;
font-weight: 500;
vertical-align: middle;
}
.ld-tag--real {
background: #e8f3ee;
color: #0c5a46;
border: 1px solid #cfe3da;
}
.ld-tag--sub {
background: #fef9ec;
color: #8a5c10;
border: 1px solid #f0e0b0;
}
.ld-used {
display: inline-block;
font-size: 11.5px;
color: var(--liderra-teal, #0f6e56);
font-weight: 600;
margin-top: 4px;
}
.ld-addbox {
margin-top: 24px;
background: #f6f3ec;
border: 1px solid #e8e2d4;
border-radius: 10px;
padding: 16px 20px;
font-size: 13.5px;
color: #4a4540;
}
.ld-addbox b {
color: #012019;
}
.ld-addbox p {
margin: 6px 0 0;
line-height: 1.5;
}
.ld-addlink {
color: var(--liderra-teal, #0f6e56);
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
}
.ld-addlink:hover {
text-decoration-style: solid;
}
.ld-addsrc {
display: flex;
gap: 10px;
margin-top: 12px;
flex-wrap: wrap;
}
.ld-inp {
flex: 1;
min-width: 200px;
border: 1.5px solid #d5cfc2;
border-radius: 7px;
padding: 8px 12px;
font-size: 13.5px;
color: #012019;
background: #fff;
outline: none;
transition: border-color 150ms;
}
.ld-inp:focus {
border-color: var(--liderra-teal, #0f6e56);
}
.ld-actionbar {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-top: 1px solid #e8e2d4;
padding: 12px 0;
gap: 12px;
z-index: 10;
}
.ld-selinfo {
font-size: 13.5px;
color: #4a4540;
}
.ld-actionbar__btns {
display: flex;
gap: 10px;
align-items: center;
}
.ld-btn-primary {
background: var(--liderra-teal, #0f6e56);
color: #fff;
border: none;
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-primary:hover:not(:disabled) {
background: #0b5a45;
}
.ld-btn-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ld-btn-primary--sm {
padding: 8px 14px;
}
.ld-btn-ghost {
background: transparent;
color: var(--liderra-teal, #0f6e56);
border: 1.5px solid var(--liderra-teal, #0f6e56);
border-radius: 7px;
padding: 9px 18px;
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
transition: background 180ms ease;
}
.ld-btn-ghost:hover {
background: rgba(15, 110, 86, 0.06);
}
.ld-btn-ghost--sm {
padding: 7px 12px;
font-size: 12.5px;
white-space: nowrap;
align-self: center;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { reactive, ref } from 'vue';
vi.mock('../../resources/js/api/autopodbor');
import DetailScreen from '../../resources/js/views/autopodbor/screens/DetailScreen.vue';
import { useAutopodborStore } from '../../resources/js/stores/autopodborStore';
const vuetify = createVuetify();
function makeNav(competitorId = 3) {
return { go: vi.fn(), ctx: reactive({ runId: null, competitorId, selectedSourceIds: [] as number[], loadMsg: '', loadSub: '', editProjectId: null as number|null }), screen: ref('detail') };
}
function mountDetail(nav: any) {
return mount(DetailScreen, { global: { plugins: [vuetify], provide: { autopodborNav: nav } } });
}
function seed(store: any) {
store.competitor = { id: 3, name: 'Окна Комфорт', is_federal: false, relevance_pct: 100, origin: 'auto', site_url: 'okna.ru', directory_urls: [], description: 'd', studied_at: '2026-06-28', study_run_id: 9, search_run_id: 5 };
store.sources = [
{ id: 11, competitor_id: 3, signal_type: 'site', identifier: 'okna-komfort-kzn.ru', phone_kind: null, provenance_url: null, provenance_label: '2ГИС', created_project_id: null, existing_project_id: null },
{ id: 12, competitor_id: 3, signal_type: 'call', identifier: '78432001122', phone_kind: 'real', provenance_url: null, provenance_label: '2ГИС', created_project_id: null, existing_project_id: null },
{ id: 13, competitor_id: 3, signal_type: 'call', identifier: '78003507700', phone_kind: 'substitute', provenance_url: null, provenance_label: 'футер', created_project_id: 99, existing_project_id: 99 },
];
}
describe('DetailScreen', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); });
it('грузит конкурента и показывает источники', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store));
const nav = makeNav(3);
const w = mountDetail(nav);
await new Promise(r => setTimeout(r, 0));
expect(store.loadCompetitor).toHaveBeenCalledWith(3);
expect(w.text()).toContain('okna-komfort-kzn.ru');
expect(w.text()).toContain('проект создан'); // источник 13 с existing_project_id
});
it('по умолчанию выбраны источники без проекта (11 и 12, не 13)', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store));
const nav = makeNav(3);
mountDetail(nav);
await new Promise(r => setTimeout(r, 0));
expect(nav.ctx.selectedSourceIds).toContain(11);
expect(nav.ctx.selectedSourceIds).toContain(12);
expect(nav.ctx.selectedSourceIds).not.toContain(13);
});
it('«Создать проекты» ведёт на create', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store));
const nav = makeNav(3);
const w = mountDetail(nav);
await new Promise(r => setTimeout(r, 0));
const btn = w.findAll('button').find(b => b.text().includes('Создать проекты'));
await btn!.trigger('click');
expect(nav.go).toHaveBeenCalledWith('create');
});
it('«Изменить проект» у созданного источника ведёт на editproject', async () => {
const store = useAutopodborStore();
vi.spyOn(store, 'loadCompetitor').mockImplementation(async () => seed(store));
const nav = makeNav(3);
const w = mountDetail(nav);
await new Promise(r => setTimeout(r, 0));
const btn = w.findAll('button').find(b => b.text().includes('Изменить проект'));
await btn!.trigger('click');
expect(nav.ctx.editProjectId).toBe(99);
expect(nav.go).toHaveBeenCalledWith('editproject');
});
});