Files
hh_auto/templates/index.html
T

587 lines
25 KiB
HTML
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.
<!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; }
.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; }
::-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>
</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 разработчик&#10;NAME:Python backend&#10;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>
<script>
let currentAccount = null;
let vacancies = [];
let generatedTexts = {};
// ---------- Вкладки ----------
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,
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
return data;
}
async function loadAccounts() {
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';
div.innerHTML = `
<div class="title">${v.title} — ${v.company || ''}</div>
<textarea data-id="${v.id}">${escapeHtml(generatedTexts[v.id])}</textarea>
<div class="toolbar">
<button class="secondary" onclick="openVacancy('${v.id}')">Открыть в браузере</button>
<button class="secondary" onclick="markDone('${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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;');
}
// ---------- Открытие и отметка ----------
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 {
await api(`/api/accounts/${currentAccount}/open`, {
method: 'POST', body: JSON.stringify({ id, message }),
});
} catch (e) {
if (note) note.textContent = 'Ошибка: ' + e.message;
}
}
async function markDone(id) {
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;
renderVacancies();
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();
}
function openLogin() {
if (!currentAccount) return;
window.open(`/api/accounts/${currentAccount}/login`, '_blank');
}
// ---------- Старт ----------
loadAccounts();
</script>
</body>
</html>