/* global React */
(function () {
const { useState, useEffect, useRef, useCallback, memo } = React;
const T = window.SC_T;
const Api = window.SC_Api;
const PAGE_SIZE = 100;
const LIMITS = {
yandex_title: 56,
yandex_title2: 56,
yandex_desc: 81,
google_title1: 30,
google_desc1: 90,
};
const EMPTY_SET = new Set();
const COLS = [
{ field: 'yandex_title', label: 'Заголовок 1', group: 'yandex' },
{ field: 'yandex_title2', label: 'Заголовок 2', group: 'yandex' },
{ field: 'yandex_desc', label: 'Текст', group: 'yandex' },
{ field: 'google_title1', label: 'Заголовок', group: 'google' },
{ field: 'google_desc1', label: 'Описание', group: 'google' },
];
// ── Ячейка редактирования ─────────────────────────────────────────────────
const AdCell = memo(function AdCell({ field, value, saving, onChange }) {
const limit = LIMITS[field];
const over = (value || '').length > limit;
const isLong = limit >= 81;
return (
{isLong ? (
{over ? (
слишком много символов
) : saving ? (
сохраняется…
) : null}
= limit * 0.85 ? T.warn : T.inkSubtle,
}}>
{(value || '').length}/{limit}
);
});
// ── Строка фразы ─────────────────────────────────────────────────────────
const PhraseRow = memo(function PhraseRow({
index, phrase, ad, status, savingFields, globalUrl, useCustomUrl, onFieldChange, onCustomUrlToggle,
}) {
const [hover, setHover] = useState(false);
const statusColor = status === 'done' ? T.ok : status === 'generating' ? T.accent : T.inkSubtle;
const rowBg = hover ? '#EDEFF2' : '#F8F9FB';
const handleChange = useCallback(
(field, value) => onFieldChange(phrase, field, value),
[phrase, onFieldChange],
);
const handleUrlToggle = useCallback((e) => {
onCustomUrlToggle(phrase, e.target.checked);
}, [phrase, onCustomUrlToggle]);
return (
setHover(true)}
onMouseLeave={() => setHover(false)}
style={{ borderBottom: `1px solid ${T.border}`, transition: 'background .1s' }}
>
{/* # */}
|
{index}
|
{/* Фраза + URL */}
{phrase}
{status === 'done' ? '✓ готово' : status === 'generating' ? '⟳ в очереди…' : '— ожидает'}
{/* URL */}
{useCustomUrl ? (
handleChange('url', e.target.value)}
placeholder="https://…"
style={{
width: '100%', fontFamily: 'inherit', fontSize: 11,
border: `1px solid ${T.border}`, borderRadius: 4,
padding: '3px 6px', background: T.bg, color: T.ink, outline: 'none',
}}
/>
) : globalUrl ? (
{globalUrl}
) : (
ссылка не задана
)}
|
{COLS.map(({ field }) => (
|
))}
);
});
// ── Пагинация ─────────────────────────────────────────────────────────────
const btnStyle = (disabled) => ({
border: `1px solid ${T.border}`, borderRadius: 7,
padding: '5px 14px', fontSize: 12, fontWeight: 500,
fontFamily: 'inherit', cursor: disabled ? 'not-allowed' : 'pointer',
background: disabled ? T.borderSoft : T.bg,
color: disabled ? T.inkSubtle : T.ink,
transition: 'background .12s',
});
function Pagination({ page, totalPages, onGoTo }) {
const [inputVal, setInputVal] = useState(String(page));
useEffect(() => { setInputVal(String(page)); }, [page]);
const commit = () => {
const n = parseInt(inputVal, 10);
if (!isNaN(n)) onGoTo(n);
else setInputVal(String(page));
};
if (totalPages <= 1) return null;
return (
Страница
setInputVal(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && commit()}
style={{
width: 52, fontFamily: 'inherit', fontSize: 12,
border: `1px solid ${T.border}`, borderRadius: 6,
padding: '4px 6px', background: T.bg, color: T.ink,
outline: 'none', textAlign: 'center',
}}
/>
из {totalPages}
);
}
// ── Главный компонент ─────────────────────────────────────────────────────
function AdGenScreen({ jobId, phrases, onBack }) {
const [prompt, setPrompt] = useState('');
const [globalUrl, setGlobalUrl] = useState('');
const [ads, setAds] = useState({});
const [statuses, setStatuses] = useState({});
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState({ done: 0, total: 0 });
const [savingFields, setSavingFields] = useState({});
const [startFrom, setStartFrom] = useState(1);
const [customUrls, setCustomUrls] = useState(new Set());
const [page, setPage] = useState(1);
const adsRef = useRef({});
const saveTimers = useRef({});
const urlSaveTimer = useRef(null);
const tableRef = useRef(null);
const total = phrases.length;
const totalPages = Math.ceil(total / PAGE_SIZE) || 1;
const goToPage = useCallback((p) => {
const clamped = Math.max(1, Math.min(totalPages, p));
setPage(clamped);
if (tableRef.current) tableRef.current.scrollTop = 0;
}, [totalPages]);
// ── Загрузка данных с сервера ──────────────────────────────────────────
const loadAdsFromServer = useCallback(async () => {
try {
const adsData = await Api.getJson(`/api/job/${jobId}/ads`);
const map = {}, st = {}, customSet = new Set();
adsData.forEach((a) => {
map[a.phrase] = a;
st[a.phrase] = 'done';
if (a.url) customSet.add(a.phrase);
});
setAds(map);
adsRef.current = map;
setStatuses(st);
setCustomUrls(customSet);
} catch {}
}, [jobId]);
// ── Инициализация: загрузка существующих данных + проверка статуса ────
useEffect(() => {
if (!jobId) return;
Promise.all([
Api.getJson(`/api/job/${jobId}/ads`),
Api.getJson(`/api/job/${jobId}`),
Api.getJson(`/api/job/${jobId}/ads/status`).catch(() => null),
]).then(([adsData, jobData, statusData]) => {
const map = {}, st = {}, customSet = new Set();
adsData.forEach((a) => {
map[a.phrase] = a;
st[a.phrase] = 'done';
if (a.url) customSet.add(a.phrase);
});
setAds(map);
adsRef.current = map;
setStatuses(st);
setCustomUrls(customSet);
if (jobData.global_url) setGlobalUrl(jobData.global_url);
// Восстанавливаем промпт из сервера если есть
if (statusData?.prompt) setPrompt(statusData.prompt);
// Если генерация уже идёт в фоне — подключаемся к прогрессу
if (statusData?.generating) {
setGenerating(true);
setProgress({ done: statusData.done_count || 0, total: statusData.total || 0 });
}
}).catch(() => {});
}, [jobId]);
// ── Polling пока идёт генерация ───────────────────────────────────────
useEffect(() => {
if (!generating) return;
const id = setInterval(async () => {
try {
const [adsData, statusData] = await Promise.all([
Api.getJson(`/api/job/${jobId}/ads`),
Api.getJson(`/api/job/${jobId}/ads/status`),
]);
const map = {}, st = {}, customSet = new Set();
adsData.forEach((a) => {
map[a.phrase] = a;
st[a.phrase] = 'done';
if (a.url) customSet.add(a.phrase);
});
setAds(map);
adsRef.current = map;
setStatuses(st);
setCustomUrls(customSet);
setProgress({ done: statusData.done_count || 0, total: statusData.total || 0 });
if (!statusData.generating) {
setGenerating(false);
}
} catch {}
}, 2500);
return () => clearInterval(id);
}, [generating, jobId]);
// ── Управление генерацией ─────────────────────────────────────────────
const startGeneration = useCallback(async () => {
const from = Math.max(1, Math.min(phrases.length, startFrom || 1));
const toGenerate = phrases.slice(from - 1);
if (!prompt.trim() || generating || !toGenerate.length) return;
setGenerating(true);
setProgress({ done: 0, total: toGenerate.length });
// Ставим все фразы в статус «в очереди»
setStatuses((prev) => {
const next = { ...prev };
toGenerate.forEach((p) => { if (!next[p] || next[p] !== 'done') next[p] = 'generating'; });
return next;
});
const token = localStorage.getItem('sc_token') || '';
try {
const resp = await fetch(`/api/job/${jobId}/ads/generate`, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ phrases: toGenerate, prompt }),
});
if (!resp.ok) {
setGenerating(false);
return;
}
// Задача запущена на сервере — polling подхватит прогресс
} catch {
setGenerating(false);
}
}, [jobId, phrases, prompt, generating, startFrom]);
const stopGeneration = useCallback(async () => {
setGenerating(false);
const token = localStorage.getItem('sc_token') || '';
try {
await fetch(`/api/job/${jobId}/ads/stop`, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
});
} catch {}
// Обновляем список — часть фраз могла успеть сгенерироваться
await loadAdsFromServer();
}, [jobId, loadAdsFromServer]);
// ── Глобальный URL ────────────────────────────────────────────────────
const handleGlobalUrlChange = useCallback((val) => {
setGlobalUrl(val);
if (urlSaveTimer.current) clearTimeout(urlSaveTimer.current);
urlSaveTimer.current = setTimeout(() => {
Api.patchJson(`/api/job/${jobId}/global-url`, { global_url: val }).catch(() => {});
}, 1500);
}, [jobId]);
// ── Редактирование ячеек ──────────────────────────────────────────────
const handleFieldChange = useCallback((phrase, field, value) => {
setAds((prev) => {
const next = { ...prev, [phrase]: { ...(prev[phrase] || {}), [field]: value } };
adsRef.current = next;
return next;
});
const key = `${phrase}\x00${field}`;
if (saveTimers.current[key]) clearTimeout(saveTimers.current[key]);
const limit = LIMITS[field];
if (limit && (value || '').length > limit) return;
saveTimers.current[key] = setTimeout(async () => {
setSavingFields((prev) => {
const set = new Set(prev[phrase] || []);
set.add(field);
return { ...prev, [phrase]: set };
});
const current = adsRef.current[phrase] || {};
try { await Api.patchJson(`/api/job/${jobId}/ads`, { phrase, ...current }); } catch {}
setSavingFields((prev) => {
const set = new Set(prev[phrase] || []);
set.delete(field);
return { ...prev, [phrase]: set };
});
delete saveTimers.current[key];
}, 2000);
}, [jobId]);
const handleCustomUrlToggle = useCallback((phrase, checked) => {
setCustomUrls((prev) => {
const next = new Set(prev);
if (checked) next.add(phrase); else next.delete(phrase);
return next;
});
if (!checked) handleFieldChange(phrase, 'url', '');
}, [handleFieldChange]);
// ── Производные ───────────────────────────────────────────────────────
const fromIdx = Math.max(1, Math.min(total, startFrom || 1));
const genCount = total - fromIdx + 1;
const done = progress.done;
const pageStart = (page - 1) * PAGE_SIZE;
const pagePhrases = phrases.slice(pageStart, pageStart + PAGE_SIZE);
// ── Render ────────────────────────────────────────────────────────────
return (
{/* Top bar */}
Генератор рекламы
{generating && (
{done} / {progress.total || '?'} фраз • работает в фоне
)}
{/* Prompt + Global URL */}
Промпт — опишите продукт или услугу для ИИ:
{/* Глобальный URL */}
Ссылка для всех фраз:
handleGlobalUrlChange(e.target.value)}
placeholder="https://example.com/?utm_source=yandex&utm_medium=cpc"
style={{
flex: 1, fontFamily: 'inherit', fontSize: 12,
border: `1px solid ${T.border}`, borderRadius: 7,
padding: '6px 10px', background: T.bg, color: T.ink, outline: 'none',
}}
/>
{generating && (
Генерация идёт в фоне — можно закрыть страницу, прогресс сохранится
)}
{/* Table */}
{phrases.length === 0 ? (
Нет фраз для генерации. Вернитесь к результатам.
) : (
| # |
Фраза / Ссылка |
{COLS.map(({ field, label, group }) => (
{group === 'yandex' ? 'Яндекс' : 'Google'}
{label}
|
))}
{pagePhrases.map((phrase, i) => (
))}
)}
{/* Pagination */}
);
}
window.AdGenScreen = AdGenScreen;
})();