/* 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;
})();