708 lines
30 KiB
HTML
708 lines
30 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>hh.ru автоотклик</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0f1117; --card: #1a1d27; --border: #2a2f3a; --text: #e5e7eb;
|
||
--muted: #9ca3af; --accent: #3b82f6; --accent-hover: #60a5fa;
|
||
--green: #22c55e; --red: #ef4444; --amber: #f59e0b;
|
||
--input-bg: #12141c; --hover: #1e222d;
|
||
--secondary: #2a2f3a; --secondary-hover: #333a47;
|
||
--danger-bg: #3a1d1d; --danger-hover: #4a2525;
|
||
--done-bg: #14351f; --new-bg: #1a2f4a; --err-bg: #3a1d1d;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
background: var(--bg); color: var(--text); font-size: 14px; }
|
||
header { background: var(--card); border-bottom: 1px solid var(--border);
|
||
padding: 12px 24px; display: flex; align-items: center; gap: 24px; }
|
||
header h1 { font-size: 18px; font-weight: 600; }
|
||
nav { display: flex; gap: 4px; }
|
||
nav button { border: none; background: none; padding: 8px 14px; cursor: pointer;
|
||
border-radius: 8px; font-size: 14px; color: var(--muted); }
|
||
nav button.active { background: var(--accent); color: #fff; }
|
||
main { max-width: 1100px; margin: 24px auto; padding: 0 24px; }
|
||
.card { background: var(--card); border: 1px solid var(--border);
|
||
border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||
.card h2 { font-size: 15px; margin-bottom: 14px; }
|
||
.row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||
.field { margin-bottom: 12px; flex: 1; min-width: 200px; }
|
||
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||
input[type=text], input[type=number], select, textarea {
|
||
width: 100%; padding: 8px 10px; border: 1px solid var(--border);
|
||
border-radius: 8px; font-size: 14px; font-family: inherit;
|
||
background: var(--input-bg); color: var(--text); }
|
||
input[type=file] { color: var(--muted); font-size: 13px; }
|
||
input[type=file]::file-selector-button {
|
||
background: var(--secondary); color: var(--text); border: none;
|
||
border-radius: 6px; padding: 6px 12px; margin-right: 10px; cursor: pointer; }
|
||
input[type=file]::file-selector-button:hover { background: var(--secondary-hover); }
|
||
textarea { resize: vertical; }
|
||
button { padding: 8px 16px; border: none; border-radius: 8px; cursor: pointer;
|
||
font-size: 14px; background: var(--accent); color: #fff; }
|
||
button:hover { background: var(--accent-hover); }
|
||
button.secondary { background: var(--secondary); color: var(--text); }
|
||
button.secondary:hover { background: var(--secondary-hover); }
|
||
button.danger { background: var(--danger-bg); color: var(--red); }
|
||
button.danger:hover { background: var(--danger-hover); }
|
||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border);
|
||
vertical-align: top; }
|
||
th { font-size: 12px; color: var(--muted); font-weight: 500; }
|
||
tr:hover td { background: var(--hover); }
|
||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||
font-size: 12px; }
|
||
.badge.done { background: var(--done-bg); color: var(--green); }
|
||
.badge.new { background: var(--new-bg); color: var(--accent); }
|
||
.badge.err { background: var(--err-bg); color: var(--red); }
|
||
.muted { color: var(--muted); font-size: 12px; }
|
||
.error { color: var(--red); font-size: 13px; margin-top: 6px; }
|
||
.ok { color: var(--green); font-size: 13px; margin-top: 6px; }
|
||
.account-item { display: flex; justify-content: space-between; align-items: center;
|
||
padding: 10px 12px; border: 1px solid var(--border);
|
||
border-radius: 8px; margin-bottom: 8px; background: var(--card); }
|
||
.account-item .name { font-weight: 600; }
|
||
.account-item .actions { display: flex; gap: 8px; }
|
||
.hidden { display: none !important; }
|
||
.texts-panel textarea { min-height: 90px; margin-bottom: 8px; }
|
||
.texts-panel .item { margin-bottom: 14px; }
|
||
.texts-panel .item .title { font-weight: 600; margin-bottom: 4px; }
|
||
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||
.toolbar .spacer { flex: 1; }
|
||
.status-line { font-size: 13px; margin-top: 8px; }
|
||
.empty { color: var(--muted); text-align: center; padding: 30px 0; }
|
||
.link { color: var(--accent); text-decoration: none; }
|
||
.link:hover { text-decoration: underline; }
|
||
.actions-cell { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.actions-cell button { padding: 4px 10px; font-size: 12px; }
|
||
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.75); display: flex;
|
||
align-items: center; justify-content: center; z-index: 100; }
|
||
.modal-box { background: var(--card); border: 1px solid var(--border);
|
||
border-radius: 12px; padding: 20px; max-width: 920px; width: 94%;
|
||
max-height: 92vh; overflow: auto; }
|
||
.modal-box img { max-width: 100%; border: 1px solid var(--border);
|
||
border-radius: 8px; display: block; }
|
||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||
::-webkit-scrollbar-track { background: var(--bg); }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--secondary-hover); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>hh.ru автоотклик</h1>
|
||
<nav>
|
||
<button id="tab-btn-accounts" class="active" onclick="showTab('accounts')">Аккаунты</button>
|
||
<button id="tab-btn-vacancies" onclick="showTab('vacancies')">Вакансии</button>
|
||
</nav>
|
||
<div style="margin-left:auto; display:flex; align-items:center; gap:12px">
|
||
<span class="muted" id="current-user"></span>
|
||
<button class="secondary" onclick="logout()">Выйти</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<!-- ============ ВКЛАДКА: АККАУНТЫ ============ -->
|
||
<section id="tab-accounts">
|
||
<div class="card">
|
||
<h2>Новый аккаунт</h2>
|
||
<div class="row">
|
||
<div class="field" style="flex:0 0 220px">
|
||
<label>Имя аккаунта</label>
|
||
<input type="text" id="new-name" placeholder="например, my_account">
|
||
</div>
|
||
<div class="field" style="flex:0 0 160px">
|
||
<label>Регион</label>
|
||
<select id="new-area">
|
||
<option value="1">Москва</option>
|
||
<option value="2">Санкт-Петербург</option>
|
||
<option value="113">Россия</option>
|
||
<option value="3">Екатеринбург</option>
|
||
<option value="4">Новосибирск</option>
|
||
<option value="88">Казань</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Поисковые запросы (по одному на строку, можно с операторами: NAME:Python, NOT senior)</label>
|
||
<textarea id="new-queries" rows="3" placeholder="Python разработчик NAME:Python backend NAME:Python AND NOT NAME:senior"></textarea>
|
||
</div>
|
||
<div class="field">
|
||
<label>Стиль отклика (необязательно)</label>
|
||
<input type="text" id="new-style" placeholder="Кратко, по делу, без канцелярита">
|
||
</div>
|
||
<div class="field">
|
||
<label>Резюме (текст, который ИИ использует для откликов)</label>
|
||
<textarea id="new-resume" rows="5" placeholder="Имя, опыт, навыки, проекты..."></textarea>
|
||
<div style="margin-top:6px; display:flex; gap:8px; align-items:center">
|
||
<input type="file" id="new-resume-file" accept=".txt,.md,.docx,.pdf" style="flex:1">
|
||
<button class="secondary" onclick="uploadResume('new')">Загрузить файл</button>
|
||
</div>
|
||
</div>
|
||
<button onclick="createAccount()">Создать аккаунт</button>
|
||
<div id="new-msg"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Аккаунты</h2>
|
||
<div id="accounts-list"><div class="empty">Загрузка...</div></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ============ ВКЛАДКА: ВАКАНСИИ ============ -->
|
||
<section id="tab-vacancies" class="hidden">
|
||
<div class="card">
|
||
<div class="toolbar">
|
||
<select id="acc-select" style="max-width:260px" onchange="selectAccount()"></select>
|
||
<button class="secondary" onclick="loadAccountSettings()">Обновить</button>
|
||
<div class="spacer"></div>
|
||
<button class="secondary" onclick="openLogin()">Войти в hh.ru</button>
|
||
<button onclick="runSearch()">Найти вакансии</button>
|
||
</div>
|
||
<div id="acc-info" class="muted"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>Настройки аккаунта</h2>
|
||
<div class="row">
|
||
<div class="field" style="flex:0 0 160px">
|
||
<label>Регион</label>
|
||
<select id="cfg-area">
|
||
<option value="1">Москва</option>
|
||
<option value="2">Санкт-Петербург</option>
|
||
<option value="113">Россия</option>
|
||
<option value="3">Екатеринбург</option>
|
||
<option value="4">Новосибирск</option>
|
||
<option value="88">Казань</option>
|
||
</select>
|
||
</div>
|
||
<div class="field" style="flex:0 0 120px">
|
||
<label>Страниц</label>
|
||
<input type="number" id="cfg-pages" min="1" max="10" value="1">
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Поисковые запросы</label>
|
||
<textarea id="cfg-queries" rows="3"></textarea>
|
||
</div>
|
||
<div class="field">
|
||
<label>Стиль отклика</label>
|
||
<input type="text" id="cfg-style">
|
||
</div>
|
||
<div class="field">
|
||
<label>Резюме</label>
|
||
<textarea id="cfg-resume" rows="6"></textarea>
|
||
<div style="margin-top:6px; display:flex; gap:8px; align-items:center">
|
||
<input type="file" id="cfg-resume-file" accept=".txt,.md,.docx,.pdf" style="flex:1">
|
||
<button class="secondary" onclick="uploadResume('cfg')">Загрузить файл</button>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Ключ сессии hh.ru (все cookies из браузера)</label>
|
||
<input type="password" id="cfg-session" placeholder="hhtoken=...; hhuid=...; hhrole=...; _xsrf=...">
|
||
<div style="margin-top:6px; display:flex; gap:8px; align-items:center">
|
||
<button class="secondary" onclick="saveSession()">Сохранить ключ</button>
|
||
<span id="session-msg" class="muted"></span>
|
||
</div>
|
||
</div>
|
||
<div class="field">
|
||
<label>Резюме на hh.ru (для автовыбора в модалке)</label>
|
||
<div style="display:flex; gap:8px; align-items:center">
|
||
<select id="cfg-resume-id" style="flex:1">
|
||
<option value="">— не выбрано —</option>
|
||
</select>
|
||
<button class="secondary" onclick="loadResumes()">Обновить список</button>
|
||
</div>
|
||
</div>
|
||
<button onclick="saveSettings()">Сохранить настройки</button>
|
||
<span id="cfg-msg"></span>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="toolbar">
|
||
<h2 style="margin:0">Вакансии</h2>
|
||
<div class="spacer"></div>
|
||
<span id="search-status" class="muted"></span>
|
||
<button class="secondary" onclick="resetProgress()">Сбросить прогресс</button>
|
||
<button id="btn-generate" onclick="generateTexts()" disabled>Сгенерировать тексты</button>
|
||
</div>
|
||
<div id="vacancies-table"></div>
|
||
</div>
|
||
|
||
<div class="card texts-panel hidden" id="texts-panel">
|
||
<h2>Тексты откликов (можно редактировать)</h2>
|
||
<div id="texts-list"></div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<!-- ============ МОДАЛКА: ПРОВЕРКА ОТКЛИКА ============ -->
|
||
<div id="shot-modal" class="modal hidden" onclick="if(event.target===this) closeShot()">
|
||
<div class="modal-box">
|
||
<div class="toolbar">
|
||
<h2 style="margin:0">Проверьте отклик</h2>
|
||
<div class="spacer"></div>
|
||
<button class="secondary" onclick="closeShot()">Закрыть</button>
|
||
</div>
|
||
<div id="shot-note" class="muted" style="margin-bottom:8px"></div>
|
||
<img id="shot-img" alt="Скриншот вакансии">
|
||
<div class="toolbar" style="margin-top:12px">
|
||
<button id="btn-send" onclick="confirmSend()">Отправить отклик</button>
|
||
<button id="btn-done" class="secondary" onclick="markDone(currentShotId)">Отправил (отметить)</button>
|
||
<span id="send-note" class="muted"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentAccount = null;
|
||
let vacancies = [];
|
||
let generatedTexts = {};
|
||
let currentShotId = null;
|
||
let sentTexts = new Set();
|
||
|
||
// ---------- Вкладки ----------
|
||
function showTab(name) {
|
||
document.getElementById('tab-accounts').classList.toggle('hidden', name !== 'accounts');
|
||
document.getElementById('tab-vacancies').classList.toggle('hidden', name !== 'vacancies');
|
||
document.getElementById('tab-btn-accounts').classList.toggle('active', name === 'accounts');
|
||
document.getElementById('tab-btn-vacancies').classList.toggle('active', name === 'vacancies');
|
||
if (name === 'vacancies') loadAccounts();
|
||
}
|
||
|
||
// ---------- Аккаунты ----------
|
||
async function api(url, opts = {}) {
|
||
const resp = await fetch(url, {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
...opts,
|
||
});
|
||
if (resp.status === 401) { window.location = '/login'; throw new Error('Требуется вход'); }
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
|
||
return data;
|
||
}
|
||
|
||
function logout() { window.location = '/logout'; }
|
||
|
||
async function loadAccounts() {
|
||
try {
|
||
const me = await api('/api/me');
|
||
const lim = me.limit ? ` · генерации сегодня: ${me.used}/${me.limit}` : '';
|
||
document.getElementById('current-user').textContent = me.user + lim;
|
||
} catch (e) { /* 401 уже редиректит */ }
|
||
const list = await api('/api/accounts');
|
||
const sel = document.getElementById('acc-select');
|
||
const prev = sel.value;
|
||
sel.innerHTML = '';
|
||
list.forEach(a => {
|
||
const opt = document.createElement('option');
|
||
opt.value = a; opt.textContent = a;
|
||
sel.appendChild(opt);
|
||
});
|
||
if (list.includes(prev)) sel.value = prev;
|
||
renderAccounts(list);
|
||
if (list.length) selectAccount();
|
||
}
|
||
|
||
function renderAccounts(list) {
|
||
const box = document.getElementById('accounts-list');
|
||
if (!list.length) { box.innerHTML = '<div class="empty">Пока нет аккаунтов — создайте первый выше</div>'; return; }
|
||
box.innerHTML = '';
|
||
list.forEach(name => {
|
||
const div = document.createElement('div');
|
||
div.className = 'account-item';
|
||
div.innerHTML = `
|
||
<span class="name">${name}</span>
|
||
<span class="actions">
|
||
<button class="secondary" onclick="pickAccount('${name}')">Вакансии</button>
|
||
<button class="danger" onclick="deleteAccount('${name}')">Удалить</button>
|
||
</span>`;
|
||
box.appendChild(div);
|
||
});
|
||
}
|
||
|
||
async function createAccount() {
|
||
const msg = document.getElementById('new-msg');
|
||
try {
|
||
await api('/api/accounts', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: document.getElementById('new-name').value,
|
||
queries: document.getElementById('new-queries').value,
|
||
area: document.getElementById('new-area').value,
|
||
style: document.getElementById('new-style').value,
|
||
resume: document.getElementById('new-resume').value,
|
||
}),
|
||
});
|
||
msg.className = 'ok'; msg.textContent = 'Аккаунт создан';
|
||
document.getElementById('new-name').value = '';
|
||
await loadAccounts();
|
||
} catch (e) { msg.className = 'error'; msg.textContent = e.message; }
|
||
}
|
||
|
||
async function deleteAccount(name) {
|
||
if (!confirm(`Удалить аккаунт «${name}»? Сессия и прогресс будут удалены.`)) return;
|
||
await api(`/api/accounts/${name}`, { method: 'DELETE' });
|
||
await loadAccounts();
|
||
}
|
||
|
||
function pickAccount(name) {
|
||
document.getElementById('acc-select').value = name;
|
||
selectAccount();
|
||
showTab('vacancies');
|
||
}
|
||
|
||
// ---------- Настройки аккаунта ----------
|
||
async function selectAccount() {
|
||
currentAccount = document.getElementById('acc-select').value;
|
||
if (!currentAccount) return;
|
||
await loadAccountSettings();
|
||
await runSearch();
|
||
}
|
||
|
||
async function loadAccountSettings() {
|
||
currentAccount = document.getElementById('acc-select').value;
|
||
if (!currentAccount) return;
|
||
const data = await api(`/api/accounts/${currentAccount}`);
|
||
const cfg = data.config || {};
|
||
document.getElementById('cfg-queries').value = (cfg.queries || []).join('\n');
|
||
document.getElementById('cfg-area').value = cfg.area || 1;
|
||
document.getElementById('cfg-pages').value = cfg.pages || 1;
|
||
document.getElementById('cfg-style').value = cfg.style || '';
|
||
document.getElementById('cfg-resume').value = data.resume || '';
|
||
document.getElementById('cfg-resume-id').value = cfg.resume_id || '';
|
||
document.getElementById('acc-info').textContent =
|
||
`Откликнуто: ${data.responded.length}`;
|
||
// Статус ключа сессии (сам ключ не показываем — он хранится на сервере)
|
||
const sm = document.getElementById('session-msg');
|
||
if (data.has_session) {
|
||
sm.className = 'ok';
|
||
sm.textContent = 'Ключ сохранён на сервере. Меняйте только если истёк.';
|
||
} else {
|
||
sm.className = 'error';
|
||
sm.textContent = 'Ключ не задан — вставьте cookies и нажмите «Сохранить ключ»';
|
||
}
|
||
}
|
||
|
||
// ---------- Ключ сессии hh.ru ----------
|
||
async function saveSession() {
|
||
const msg = document.getElementById('session-msg');
|
||
const session = document.getElementById('cfg-session').value.trim();
|
||
if (!session) { msg.className = 'error'; msg.textContent = 'Вставьте cookies'; return; }
|
||
msg.className = ''; msg.textContent = 'Сохраняю и проверяю...';
|
||
try {
|
||
const data = await api(`/api/accounts/${currentAccount}/session`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ session }),
|
||
});
|
||
msg.className = 'ok';
|
||
msg.textContent = data.warning || 'Сохранено, сессия работает';
|
||
if (data.resumes) fillResumeSelect(data.resumes);
|
||
} catch (e) {
|
||
msg.className = 'error'; msg.textContent = e.message;
|
||
}
|
||
}
|
||
|
||
async function loadResumes() {
|
||
const msg = document.getElementById('session-msg');
|
||
msg.className = ''; msg.textContent = 'Загружаю резюме...';
|
||
try {
|
||
const data = await api(`/api/accounts/${currentAccount}/resumes`);
|
||
fillResumeSelect(data.resumes);
|
||
msg.className = 'ok'; msg.textContent = `Найдено резюме: ${data.resumes.length}`;
|
||
} catch (e) {
|
||
msg.className = 'error'; msg.textContent = e.message;
|
||
}
|
||
}
|
||
|
||
function fillResumeSelect(resumes) {
|
||
const sel = document.getElementById('cfg-resume-id');
|
||
const prev = sel.value;
|
||
sel.innerHTML = '<option value="">— не выбрано —</option>' +
|
||
resumes.map(r => `<option value="${r.id}">${r.title}</option>`).join('');
|
||
if (prev) sel.value = prev;
|
||
}
|
||
|
||
async function saveSettings() {
|
||
const msg = document.getElementById('cfg-msg');
|
||
try {
|
||
await api(`/api/accounts/${currentAccount}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
queries: document.getElementById('cfg-queries').value,
|
||
area: document.getElementById('cfg-area').value,
|
||
pages: document.getElementById('cfg-pages').value,
|
||
style: document.getElementById('cfg-style').value,
|
||
resume: document.getElementById('cfg-resume').value,
|
||
resume_id: document.getElementById('cfg-resume-id').value,
|
||
}),
|
||
});
|
||
msg.className = 'ok'; msg.textContent = 'Сохранено';
|
||
setTimeout(() => msg.textContent = '', 2000);
|
||
} catch (e) { msg.className = 'error'; msg.textContent = e.message; }
|
||
}
|
||
|
||
// ---------- Загрузка резюме файлом ----------
|
||
async function uploadResume(which) {
|
||
const fileInput = document.getElementById(which === 'cfg' ? 'cfg-resume-file' : 'new-resume-file');
|
||
const file = fileInput.files[0];
|
||
if (!file) { alert('Выберите файл'); return; }
|
||
const target = which === 'cfg' ? currentAccount : document.getElementById('new-name').value.trim();
|
||
if (!target) { alert('Сначала укажите имя аккаунта'); return; }
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
const resp = await fetch(`/api/accounts/${target}/resume-upload`, { method: 'POST', body: fd });
|
||
const data = await resp.json();
|
||
if (!resp.ok) { alert('Ошибка: ' + data.error); return; }
|
||
const ta = document.getElementById(which === 'cfg' ? 'cfg-resume' : 'new-resume');
|
||
ta.value = data.text;
|
||
alert(`Резюме загружено (${data.chars} символов). Проверьте текст и сохраните настройки.`);
|
||
}
|
||
|
||
// ---------- Поиск ----------
|
||
async function runSearch() {
|
||
if (!currentAccount) return;
|
||
const status = document.getElementById('search-status');
|
||
status.textContent = 'Ищу...';
|
||
try {
|
||
const data = await api(`/api/accounts/${currentAccount}/search`, { method: 'POST' });
|
||
vacancies = data.vacancies;
|
||
status.textContent = `Найдено: ${data.total}`;
|
||
renderVacancies();
|
||
} catch (e) {
|
||
status.textContent = '';
|
||
document.getElementById('vacancies-table').innerHTML =
|
||
`<div class="error">Ошибка поиска: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderVacancies() {
|
||
const box = document.getElementById('vacancies-table');
|
||
if (!vacancies.length) {
|
||
box.innerHTML = '<div class="empty">Вакансий нет. Проверьте запросы или нажмите «Найти вакансии»</div>';
|
||
return;
|
||
}
|
||
let html = `<table><thead><tr>
|
||
<th style="width:28px"><input type="checkbox" id="check-all" onchange="toggleAll(this)"></th>
|
||
<th>Вакансия</th><th>Компания</th><th>Зарплата</th><th>Опыт</th><th>Статус</th><th>Действия</th>
|
||
</tr></thead><tbody>`;
|
||
vacancies.forEach(v => {
|
||
const salary = v.salary
|
||
? (v.salary.from ? `от ${v.salary.from.toLocaleString('ru')}` : '') +
|
||
(v.salary.to ? ` до ${v.salary.to.toLocaleString('ru')}` : '')
|
||
: '—';
|
||
const exp = { 'noExperience': 'без опыта', 'between1And3': '1–3 года',
|
||
'between3And6': '3–6 лет', 'moreThan6': '6+ лет' }[v.experience] || '—';
|
||
const status = v.responded
|
||
? '<span class="badge done">откликнуто</span>'
|
||
: '<span class="badge new">новая</span>';
|
||
html += `<tr data-id="${v.id}">
|
||
<td><input type="checkbox" class="v-check" data-id="${v.id}" onchange="updateGenerateBtn()"></td>
|
||
<td><a class="link" href="${v.url}" target="_blank">${v.title}</a></td>
|
||
<td>${v.company || '—'}</td>
|
||
<td>${salary}</td>
|
||
<td>${exp}</td>
|
||
<td>${status}</td>
|
||
<td class="actions-cell">
|
||
<button class="secondary" onclick="openVacancy('${v.id}')">Открыть</button>
|
||
<button class="secondary" onclick="markDone('${v.id}')">Отправил</button>
|
||
</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
box.innerHTML = html;
|
||
updateGenerateBtn();
|
||
}
|
||
|
||
function selectedIds() {
|
||
return [...document.querySelectorAll('.v-check:checked')].map(c => c.dataset.id);
|
||
}
|
||
|
||
function toggleAll(cb) {
|
||
document.querySelectorAll('.v-check').forEach(c => c.checked = cb.checked);
|
||
updateGenerateBtn();
|
||
}
|
||
|
||
function updateGenerateBtn() {
|
||
document.getElementById('btn-generate').disabled = selectedIds().length === 0;
|
||
}
|
||
|
||
// ---------- Генерация текстов ----------
|
||
async function generateTexts() {
|
||
const ids = selectedIds();
|
||
if (!ids.length) return;
|
||
const btn = document.getElementById('btn-generate');
|
||
btn.disabled = true; btn.textContent = 'Генерирую...';
|
||
try {
|
||
const data = await api(`/api/accounts/${currentAccount}/generate`, {
|
||
method: 'POST', body: JSON.stringify({ ids }),
|
||
});
|
||
generatedTexts = { ...generatedTexts, ...data.texts };
|
||
renderTexts(data.errors || {});
|
||
} catch (e) {
|
||
alert('Ошибка генерации: ' + e.message);
|
||
}
|
||
btn.textContent = 'Сгенерировать тексты';
|
||
updateGenerateBtn();
|
||
}
|
||
|
||
function renderTexts(errors = {}) {
|
||
const panel = document.getElementById('texts-panel');
|
||
const list = document.getElementById('texts-list');
|
||
panel.classList.remove('hidden');
|
||
list.innerHTML = '';
|
||
vacancies.filter(v => generatedTexts[v.id]).forEach(v => {
|
||
const div = document.createElement('div');
|
||
div.className = 'item';
|
||
const sent = sentTexts.has(v.id);
|
||
div.innerHTML = `
|
||
<div class="title">${v.title} — ${v.company || ''}</div>
|
||
<textarea data-id="${v.id}">${escapeHtml(generatedTexts[v.id])}</textarea>
|
||
<div class="toolbar">
|
||
${sent
|
||
? '<button class="secondary" disabled>Отправлено ✓</button>'
|
||
: `<button class="secondary" onclick="sendVacancy('${v.id}')">Отправить отклик</button>
|
||
<button class="secondary" onclick="openVacancy('${v.id}')">Открыть в браузере</button>`}
|
||
<span class="muted" id="note-${v.id}"></span>
|
||
</div>`;
|
||
list.appendChild(div);
|
||
});
|
||
vacancies.filter(v => errors[v.id]).forEach(v => {
|
||
const div = document.createElement('div');
|
||
div.className = 'item';
|
||
div.innerHTML = `<div class="title">${v.title}</div>
|
||
<div class="error">Ошибка ИИ: ${escapeHtml(errors[v.id])}</div>`;
|
||
list.appendChild(div);
|
||
});
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/"/g,'"');
|
||
}
|
||
|
||
// ---------- Открытие, проверка и отправка ----------
|
||
function showShotModal(id, screenshot, note, sent) {
|
||
currentShotId = id;
|
||
document.getElementById('shot-img').src = screenshot + '?t=' + Date.now();
|
||
document.getElementById('shot-note').textContent = note;
|
||
document.getElementById('send-note').textContent = '';
|
||
document.getElementById('btn-send').style.display = sent ? 'none' : '';
|
||
document.getElementById('btn-done').style.display = sent ? 'none' : '';
|
||
document.getElementById('shot-modal').classList.remove('hidden');
|
||
}
|
||
|
||
async function openVacancy(id) {
|
||
const ta = document.querySelector(`textarea[data-id="${id}"]`);
|
||
const message = ta ? ta.value : (generatedTexts[id] || '');
|
||
const note = document.getElementById(`note-${id}`);
|
||
if (note) { note.textContent = 'Открываю браузер...'; }
|
||
try {
|
||
const data = await api(`/api/accounts/${currentAccount}/open`, {
|
||
method: 'POST', body: JSON.stringify({ id, message }),
|
||
});
|
||
if (note) note.textContent = data.note || '';
|
||
if (data.screenshot) {
|
||
showShotModal(id, data.screenshot, data.note || '', false);
|
||
} else {
|
||
alert(data.note || 'Не удалось открыть вакансию');
|
||
}
|
||
} catch (e) {
|
||
if (note) note.textContent = 'Ошибка: ' + e.message;
|
||
alert('Ошибка: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function sendVacancy(id) {
|
||
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
|
||
const ta = document.querySelector(`textarea[data-id="${id}"]`);
|
||
const message = ta ? ta.value : (generatedTexts[id] || '');
|
||
const note = document.getElementById(`note-${id}`);
|
||
if (note) { note.textContent = 'Отправляю отклик...'; }
|
||
try {
|
||
const data = await api(`/api/accounts/${currentAccount}/send-now`, {
|
||
method: 'POST', body: JSON.stringify({ id, message }),
|
||
});
|
||
if (note) note.textContent = data.note || '';
|
||
if (data.screenshot) {
|
||
showShotModal(id, data.screenshot, data.note || 'Отклик отправлен', true);
|
||
sentTexts.add(id);
|
||
const v = vacancies.find(x => x.id === id);
|
||
if (v) v.responded = true;
|
||
renderVacancies();
|
||
renderTexts();
|
||
} else {
|
||
alert(data.note || 'Не удалось отправить отклик');
|
||
}
|
||
} catch (e) {
|
||
if (note) note.textContent = 'Ошибка: ' + e.message;
|
||
alert('Ошибка: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function closeShot() {
|
||
document.getElementById('shot-modal').classList.add('hidden');
|
||
}
|
||
|
||
async function confirmSend() {
|
||
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
|
||
const btn = document.getElementById('btn-send');
|
||
const note = document.getElementById('send-note');
|
||
btn.disabled = true; note.textContent = 'Отправляю...';
|
||
try {
|
||
const data = await api(`/api/accounts/${currentAccount}/send`, {
|
||
method: 'POST', body: JSON.stringify({ id: currentShotId }),
|
||
});
|
||
note.textContent = data.note || '';
|
||
if (data.ok) {
|
||
const v = vacancies.find(x => x.id === currentShotId);
|
||
if (v) v.responded = true;
|
||
sentTexts.add(currentShotId);
|
||
renderVacancies();
|
||
renderTexts();
|
||
}
|
||
} catch (e) {
|
||
note.textContent = 'Ошибка: ' + e.message;
|
||
}
|
||
btn.disabled = false;
|
||
}
|
||
|
||
async function markDone(id) {
|
||
if (!currentAccount) { alert('Сначала выберите аккаунт'); return; }
|
||
await api(`/api/accounts/${currentAccount}/done`, {
|
||
method: 'POST', body: JSON.stringify({ id }),
|
||
});
|
||
const v = vacancies.find(x => x.id === id);
|
||
if (v) v.responded = true;
|
||
sentTexts.add(id);
|
||
renderVacancies();
|
||
renderTexts();
|
||
const note = document.getElementById(`note-${id}`);
|
||
if (note) note.textContent = 'Отмечено как отправленное';
|
||
}
|
||
|
||
async function resetProgress() {
|
||
if (!confirm('Сбросить прогресс (все вакансии снова будут «новыми»)?')) return;
|
||
await api(`/api/accounts/${currentAccount}/reset`, { method: 'POST' });
|
||
await runSearch();
|
||
}
|
||
|
||
async function openLogin() {
|
||
if (!currentAccount) return;
|
||
try {
|
||
const data = await api(`/api/accounts/${currentAccount}/login`);
|
||
alert(data.note || 'Вход через cookies');
|
||
} catch (e) {
|
||
alert('Ошибка: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ---------- Старт ----------
|
||
loadAccounts();
|
||
</script>
|
||
</body>
|
||
</html> |