Files
portal/app/resources/js/views/ImportView.vue
T
Дмитрий 9ff19f3cc3 @
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна

Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».

База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.

Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).

Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».

Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-07-12 12:56:22 +03:00

246 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Импорт данных — загрузка CSV исторических лидов из crm.bp-gr.ru (ТЗ §6).
*
* Flow: выбрать файл → загрузить → polling прогресса → таблица результата.
* Неизвестные статусы маппятся через UnknownStatusesDialog.
*/
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
getImport,
getUnknownStatuses,
listImports,
uploadImport,
type ImportLogResource,
type UnknownStatus,
} from '../api/imports';
import UnknownStatusesDialog from '../components/import/UnknownStatusesDialog.vue';
const file = ref<File | null>(null);
const dryRun = ref(false);
const uploading = ref(false);
const errorMessage = ref<string | null>(null);
const history = ref<ImportLogResource[]>([]);
const activeImport = ref<ImportLogResource | null>(null);
const unknownStatuses = ref<UnknownStatus[]>([]);
const wizardOpen = ref(false);
/** Интервал опроса прогресса активного импорта, мс. */
const POLL_INTERVAL_MS = 2000;
let pollTimer: ReturnType<typeof setInterval> | null = null;
const canUpload = computed(() => file.value !== null && !uploading.value);
// Русские подписи статусов импорта (не показываем юзеру сырой enum).
const IMPORT_STATUS_RU: Record<string, string> = {
pending: 'В очереди',
processing: 'Обрабатывается',
done: 'Готово',
completed: 'Готово',
failed: 'Ошибка',
cancelled: 'Отменён',
};
function importStatusLabel(s: string): string {
return IMPORT_STATUS_RU[s] ?? s;
}
const isProcessing = computed(
() => activeImport.value?.status === 'pending' || activeImport.value?.status === 'processing',
);
async function refreshHistory(): Promise<void> {
try {
history.value = await listImports();
} catch {
// история — не критично, тихо игнорируем
}
}
async function refreshUnknown(): Promise<void> {
try {
unknownStatuses.value = await getUnknownStatuses();
} catch {
unknownStatuses.value = [];
}
}
function stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function pollOnce(id: number): Promise<void> {
try {
activeImport.value = await getImport(id);
if (!isProcessing.value) {
stopPolling();
await refreshHistory();
await refreshUnknown();
}
} catch {
stopPolling();
}
}
function startPolling(id: number): void {
stopPolling();
pollTimer = setInterval(() => {
void pollOnce(id);
}, POLL_INTERVAL_MS);
}
async function submit(): Promise<void> {
if (file.value === null) {
return;
}
uploading.value = true;
errorMessage.value = null;
try {
activeImport.value = await uploadImport(file.value, dryRun.value);
startPolling(activeImport.value.id);
file.value = null;
} catch {
errorMessage.value = 'Не удалось загрузить файл. Проверьте формат (CSV, до 10 МБ).';
} finally {
uploading.value = false;
}
}
async function onWizardResolved(): Promise<void> {
wizardOpen.value = false;
await refreshUnknown();
}
onMounted(async () => {
await refreshHistory();
await refreshUnknown();
});
onUnmounted(stopPolling);
</script>
<template>
<v-container fluid class="import-view pa-6">
<header class="page-head mb-4">
<h1 class="text-h4 mb-2">Импорт данных</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Перенос ваших исторических заявок. Формат CSV-выгрузка (UTF-8).
</p>
</header>
<v-alert
v-if="unknownStatuses.length > 0"
data-test="unknown-banner"
type="warning"
variant="tonal"
class="mb-4"
>
Найдено {{ unknownStatuses.length }} неизвестных статусов воронки замапьте вручную.
<template #append>
<v-btn size="small" variant="flat" data-tour="import-map-btn" @click="wizardOpen = true">Замапить</v-btn>
</template>
</v-alert>
<v-card variant="outlined" class="pa-6 mb-6">
<v-file-input
v-model="file"
label="CSV-файл выгрузки лидов"
accept=".csv,text/csv"
prepend-icon="mdi-database-import-outline"
variant="outlined"
density="comfortable"
:disabled="uploading"
data-tour="import-file"
/>
<v-checkbox
v-model="dryRun"
label="Пробный прогон (проверить файл без записи сделок)"
density="compact"
hide-details
data-tour="import-dryrun"
/>
<v-alert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</v-alert>
<div class="mt-4">
<v-btn
data-test="upload-btn"
color="primary"
:loading="uploading"
:disabled="!canUpload"
data-tour="import-submit"
@click="submit"
>
Загрузить
</v-btn>
</div>
</v-card>
<v-card v-if="activeImport" variant="outlined" class="pa-6 mb-6" data-tour="import-progress">
<h2 class="text-h6 mb-3">Текущий импорт {{ activeImport.filename }}</h2>
<v-progress-linear v-if="isProcessing" indeterminate color="primary" class="mb-3" />
<div data-test="active-status" class="text-body-2">
Статус: <strong>{{ importStatusLabel(activeImport.status) }}</strong>
</div>
<v-table v-if="!isProcessing" density="compact" class="mt-3">
<tbody>
<tr>
<td>Добавлено</td>
<td>{{ activeImport.rows_added }}</td>
</tr>
<tr>
<td>Обновлено</td>
<td>{{ activeImport.rows_updated }}</td>
</tr>
<tr>
<td>Пропущено</td>
<td>{{ activeImport.rows_skipped }}</td>
</tr>
<tr>
<td>Неизвестных статусов</td>
<td>{{ activeImport.unknown_statuses_count }}</td>
</tr>
</tbody>
</v-table>
<v-alert v-if="activeImport.status === 'failed'" type="error" variant="tonal" class="mt-3">
{{ activeImport.error_message }}
</v-alert>
</v-card>
<v-card variant="outlined" class="pa-6" data-tour="import-history">
<h2 class="text-h6 mb-3">История импортов</h2>
<v-table v-if="history.length > 0" density="compact">
<thead>
<tr>
<th>Файл</th>
<th>Статус</th>
<th>Добавлено</th>
<th>Обновлено</th>
<th>Пропущено</th>
</tr>
</thead>
<tbody>
<tr v-for="row in history" :key="row.id">
<td>{{ row.filename }}</td>
<td>{{ importStatusLabel(row.status) }}</td>
<td>{{ row.rows_added }}</td>
<td>{{ row.rows_updated }}</td>
<td>{{ row.rows_skipped }}</td>
</tr>
</tbody>
</v-table>
<p v-else class="text-body-2 text-medium-emphasis ma-0">Импортов пока нет.</p>
</v-card>
<UnknownStatusesDialog v-model="wizardOpen" :statuses="unknownStatuses" @resolved="onWizardResolved" />
</v-container>
</template>
<style scoped>
.import-view {
max-width: 1100px;
}
</style>