/* global React */ (function () { const { useState, useCallback, useEffect, useRef } = React; const T = window.SC_T; const Icon = window.SC_Icon; const Api = window.SC_Api; const SRC_MAP = window.SC_SrcMap; const PAGE_SIZES = [100, 500, 1000, 0]; // ── Вращающийся шеврон SVG ───────────────────────────────────────────────── function Chevron({ open }) { return ( ); } // ── Утилита: найти ближайший scroll-контейнер ───────────────────────────── function getScrollParent(el) { let p = el.parentElement; while (p) { const { overflow, overflowY } = getComputedStyle(p); if (/auto|scroll/.test(overflow + overflowY)) return p; p = p.parentElement; } return document.documentElement; } // ── Группировка по первому слову ─────────────────────────────────────────── function groupByFirstWord(items, sorted = false) { const map = {}, order = []; items.forEach((item) => { const w = (item.phrase || '').trim().split(/\s+/)[0].toLowerCase(); if (!map[w]) { map[w] = []; order.push(w); } map[w].push(item); }); if (sorted) { order.sort((a, b) => a.localeCompare(b, 'ru')); return order.map((w) => ({ word: w, items: map[w].sort((a, b) => (a.phrase || '').localeCompare(b.phrase || '', 'ru')) })); } return order.map((w) => ({ word: w, items: map[w] })); } // ── Заголовок группы ────────────────────────────────────────────────────── function GroupHeader({ word, count, open, onClick, action }) { const [hover, setHover] = useState(false); return (
setHover(true)} onMouseLeave={() => setHover(false)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 16px 7px 14px', borderBottom: `1px solid ${T.borderSoft}`, borderLeft: `3px solid ${open ? T.accent : 'transparent'}`, background: open ? T.bg : hover ? T.bg : T.surface, cursor: 'pointer', userSelect: 'none', transition: 'background .12s ease, border-color .12s ease', position: 'sticky', top: 0, zIndex: 2, }} > {word} {count} {action &&
e.stopPropagation()}>{action}
}
); } // ── Группа фраз ─────────────────────────────────────────────────────────── function PhraseGroup({ word, items, renderRow, onTransferAll, isRejected }) { const [open, setOpen] = useState(false); const ref = useRef(null); const toggle = useCallback(() => { const next = !open; setOpen(next); if (!next && ref.current) { const el = ref.current; const container = getScrollParent(el); const elTop = el.getBoundingClientRect().top; const ctTop = container === document.documentElement ? 0 : container.getBoundingClientRect().top; const ctBottom = container === document.documentElement ? window.innerHeight : container.getBoundingClientRect().bottom; if (elTop < ctTop || elTop >= ctBottom) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } }, [open]); const transferAllBtn = onTransferAll ? ( ) : null; if (items.length === 1) return renderRow(items[0]); return (
{items.map((item, idx) => (
{renderRow(item)}
))}
); } // ── Список с группировкой ───────────────────────────────────────────────── function GroupedList({ items, renderRow, emptyText, sorted = false, onTransferAll, isRejected }) { const groups = groupByFirstWord(items, sorted); if (!groups.length) { return (
{emptyText || 'Пусто'}
); } return groups.map(({ word, items: gi }) => ( )); } // ── Строка в нормальном режиме ──────────────────────────────────────────── function NormalRow({ row }) { const [hover, setHover] = useState(false); return (
setHover(true)} onMouseLeave={() => setHover(false)} style={{ display: 'grid', gridTemplateColumns: '1fr 130px 100px', columnGap: 20, padding: '9px 18px', borderBottom: `1px solid ${T.borderSoft}`, fontSize: 13, alignItems: 'center', background: hover ? T.accentSoft : 'transparent', transition: 'background .12s ease', }} >
{row.phrase}
{(row.freq || 0).toLocaleString('ru')}
); } // ── Строка в панели редактирования ──────────────────────────────────────── function EditRow({ row, isRejected, onTransfer, transferring }) { const [hover, setHover] = useState(false); const busy = !!transferring; const isMe = transferring === row.phrase; const btnText = isRejected ? '← В ядро' : 'Убрать →'; const btnDir = isRejected ? 'to_core' : 'to_rejected'; const btnColor = isRejected ? T.accent : T.err; const rowHoverBg = isRejected ? T.accentSoft : '#FEF2F2'; return (
setHover(true)} onMouseLeave={() => setHover(false)} style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, padding: '8px 14px', borderBottom: `1px solid ${T.borderSoft}`, background: hover ? rowHoverBg : 'transparent', transition: 'background .1s ease', }} >
{row.phrase}
); } // ── Поисковое поле (переиспользуемое) ───────────────────────────────────── function SearchBox({ value, onChange, placeholder, maxWidth }) { return (
{Icon.search(13)} onChange(e.target.value)} placeholder={placeholder || 'Поиск…'} style={{ width: '100%', padding: '6px 10px 6px 28px', border: `1px solid ${T.border}`, borderRadius: 7, fontSize: 12.5, fontFamily: 'inherit', outline: 'none', background: T.surface }} onFocus={(e) => { e.target.style.borderColor = T.accent; e.target.style.boxShadow = `0 0 0 2px ${T.accentSoft}`; }} onBlur={(e) => { e.target.style.borderColor = T.border; e.target.style.boxShadow = 'none'; }} />
); } // ── Панель редактирования (одна колонка) ────────────────────────────────── function EditPanel({ title, count, items, isRejected, onTransfer, onTransferAll, transferring, emptyText, accentClr }) { const [search, setSearch] = useState(''); const visible = search ? items.filter((r) => r.phrase.toLowerCase().includes(search.toLowerCase())) : items; return (
{title} {count}
( )} emptyText={search ? 'Ничего не найдено' : emptyText} sorted onTransferAll={search ? null : onTransferAll} isRejected={isRejected} />
); } // ── Статистика и кнопка переключения (вне ResultsScreen — стабильный тип) ── function StatsRow({ total, seedCount, aiCount, suggestCount, wordstatCount }) { return (
); } function ToggleBtn({ editMode, onToggle }) { return ( ); } // ── Основной компонент ──────────────────────────────────────────────────── function ResultsScreen({ rows, totalCount, seedCount, aiCount, suggestCount, wordstatCount, allLoaded, onLoadAll, jobId, onError, onAdGen, onRowsUpdate, }) { const [q, setQ] = useState(''); const [srcFilter, setSrcFilter] = useState('all'); const [sortCol, setSortCol] = useState('freq'); const [sortAsc, setSortAsc] = useState(false); const [pageSize, setPageSize] = useState(500); const [editMode, setEditMode] = useState(false); const [rejectedPhrases, setRejectedPhrases] = useState([]); const [loadingEdit, setLoadingEdit] = useState(false); const [transferring, setTransferring] = useState(null); const [localRows, setLocalRows] = useState(rows); useEffect(() => { setLocalRows(rows); }, [rows]); // Включение / выключение режима редактирования const toggleEditMode = useCallback(async () => { const next = !editMode; setEditMode(next); if (!next) { setRejectedPhrases([]); return; } setLoadingEdit(true); try { await Promise.all([ !allLoaded ? onLoadAll() : Promise.resolve(), (async () => { if (!jobId) return; try { const data = await Api.getJson(`/api/job/${jobId}/rejected`); setRejectedPhrases(Array.isArray(data) ? data : []); } catch { setRejectedPhrases([]); } })(), ]); } finally { setLoadingEdit(false); } }, [editMode, allLoaded, onLoadAll, jobId]); // Оптимистичный перенос — UI обновляется мгновенно, откат при ошибке const handleTransfer = useCallback(async (phrase, direction) => { if (!jobId || transferring) return; setTransferring(phrase); // Снапшот для отката const prevLocalRows = localRows; const prevRejected = rejectedPhrases; // Вычисляем новый список заранее, чтобы передать родителю после успеха let newLocalRows = localRows; if (direction === 'to_rejected') { const moved = localRows.find((r) => r.phrase === phrase); newLocalRows = localRows.filter((r) => r.phrase !== phrase); setLocalRows(newLocalRows); if (moved) { setRejectedPhrases((prev) => [...prev, { id: Date.now(), phrase: moved.phrase, shows_per_month: moved.freq || 0, source: moved.src || 'suggest', }]); } } else { const moved = rejectedPhrases.find((r) => r.phrase === phrase); setRejectedPhrases((prev) => prev.filter((r) => r.phrase !== phrase)); if (moved) { const src = (SRC_MAP && SRC_MAP[moved.source]) ? SRC_MAP[moved.source] : (moved.source || 'suggest'); newLocalRows = [...localRows, { phrase: moved.phrase, freq: moved.shows_per_month || 0, src, cluster: '' }]; setLocalRows(newLocalRows); } } try { await Api.postJson(`/api/job/${jobId}/transfer`, { phrase, direction }); if (onRowsUpdate) onRowsUpdate(newLocalRows); } catch { // Откатываем UI к состоянию до клика setLocalRows(prevLocalRows); setRejectedPhrases(prevRejected); if (onError) onError('Не удалось перенести фразу — попробуйте ещё раз'); } finally { setTransferring(null); } }, [jobId, transferring, localRows, rejectedPhrases, onError, onRowsUpdate]); // Перенос всей группы одним кликом (оптимистичный, откат при ошибке) const handleTransferGroup = useCallback(async (phrases, direction) => { if (!jobId || transferring) return; setTransferring('__group__'); const prevLocalRows = localRows; const prevRejected = rejectedPhrases; let newLocalRows = localRows; let newRejected = rejectedPhrases; if (direction === 'to_rejected') { const moved = localRows.filter((r) => phrases.includes(r.phrase)); newLocalRows = localRows.filter((r) => !phrases.includes(r.phrase)); newRejected = [...rejectedPhrases, ...moved.map((m) => ({ id: Date.now() + Math.random(), phrase: m.phrase, shows_per_month: m.freq || 0, source: m.src || 'suggest' }))]; } else { const moved = rejectedPhrases.filter((r) => phrases.includes(r.phrase)); newRejected = rejectedPhrases.filter((r) => !phrases.includes(r.phrase)); newLocalRows = [...localRows, ...moved.map((m) => { const src = (SRC_MAP && SRC_MAP[m.source]) ? SRC_MAP[m.source] : (m.source || 'suggest'); return { phrase: m.phrase, freq: m.shows_per_month || 0, src, cluster: '' }; })]; } setLocalRows(newLocalRows); setRejectedPhrases(newRejected); let failed = false; try { await Api.postJson(`/api/job/${jobId}/transfer/batch`, { phrases, direction }); } catch { failed = true; } if (failed) { setLocalRows(prevLocalRows); setRejectedPhrases(prevRejected); if (onError) onError('Не удалось перенести группу — попробуйте ещё раз'); } else { if (onRowsUpdate) onRowsUpdate(newLocalRows); } setTransferring(null); }, [jobId, transferring, localRows, rejectedPhrases, onError, onRowsUpdate]); // ── Фильтрация / сортировка (нормальный режим) ───────────────────────── const filtered = localRows .filter((r) => (srcFilter === 'all' || r.src === srcFilter) && (!q || r.phrase.toLowerCase().includes(q.toLowerCase())) ) .sort((a, b) => { const va = a[sortCol] ?? 0, vb = b[sortCol] ?? 0; const rv = typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb), 'ru'); return sortAsc ? rv : -rv; }); const displayed = pageSize === 0 ? filtered : filtered.slice(0, pageSize); const onSort = (col) => { if (sortCol === col) setSortAsc(!sortAsc); else { setSortCol(col); setSortAsc(col !== 'freq'); } }; const handlePageSize = async (s) => { setPageSize(s); if (!allLoaded && (s === 0 || s > localRows.length)) await onLoadAll(); }; // ── Данные для edit-панелей ───────────────────────────────────────────── const rejectedForEdit = rejectedPhrases.map((r) => ({ phrase: r.phrase, freq: r.shows_per_month || 0, src: (SRC_MAP && SRC_MAP[r.source]) ? SRC_MAP[r.source] : (r.source || 'suggest'), source: r.source, })); // ── РЕЖИМ РЕДАКТИРОВАНИЯ — split-layout ──────────────────────────────── if (editMode) { return (
{loadingEdit ? (
Загрузка данных…
) : (
{/* Левая панель — Семантическое ядро */} handleTransferGroup(phrases, 'to_rejected')} transferring={transferring} emptyText="Семантическое ядро пусто" accentClr={T.accent} /> {/* Разделитель */}
{/* Правая панель — Отклонённые */} handleTransferGroup(phrases, 'to_core')} transferring={transferring} emptyText="Нет отклонённых фраз" accentClr={T.err} />
)}
); } // ── НОРМАЛЬНЫЙ РЕЖИМ ────────────────────────────────────────────────── const srcOpts = [ { v: 'all', l: 'Все' }, { v: 'seed', l: 'Seed' }, { v: 'ai', l: 'AI' }, { v: 'suggest', l: 'Suggest' }, { v: 'wordstat', l: 'Wordstat' }, ]; return (
{/* Toolbar */}
{Icon.search(14)} setQ(e.target.value)} placeholder="Поиск по фразам и кластерам..." style={{ width: '100%', padding: '7px 12px 7px 32px', border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 13, fontFamily: 'inherit', outline: 'none', background: T.surface }} onFocus={(e) => { e.target.style.borderColor = T.accent; e.target.style.boxShadow = `0 0 0 3px ${T.accentSoft}`; }} onBlur={(e) => { e.target.style.borderColor = T.border; e.target.style.boxShadow = 'none'; }} />
{srcOpts.map((o) => ( ))}
{PAGE_SIZES.map((s) => ( ))}
{displayed.length.toLocaleString('ru')} {` / ${(q || srcFilter !== 'all' ? filtered.length : totalCount).toLocaleString('ru')}`}
{/* Заголовок колонок — вне scroll-контейнера, sticky в outer-контексте */}
{[['phrase', 'Фраза'], ['freq', 'Кол-во поисков'], ['src', 'Источник']].map(([k, l]) => (
onSort(k)} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, userSelect: 'none', justifyContent: k === 'freq' ? 'flex-end' : 'flex-start', }}> {l}{sortCol === k && {sortAsc ? '▲' : '▼'}}
))}
{/* Строки — отдельный scroll-контекст */}
} emptyText="Ничего не нашлось" />
{/* Нижняя панель */}
{onAdGen && ( )}
); } window.ResultsScreen = ResultsScreen; })();