de278227ac
- index.html: store dropdown in topbar (switch without leaving page) - cabinet.html: store-cards with Активировать/Проверить, edit hidden in <details> - Removed 'Используется' button, active shown as green card + label - next=index param to return to main page after store switch - Brand name changed to WBfeed.ru across all templates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
432 lines
21 KiB
HTML
432 lines
21 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Отзывы — WB Feedback</title>
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||
</head>
|
||
<body>
|
||
|
||
<nav class="topbar">
|
||
<div class="topbar__inner">
|
||
<a href="{{ url_for('index') }}" class="topbar__brand">
|
||
<img src="{{ url_for('static', filename='wb3.png') }}" class="topbar__logo-img" alt="WB">
|
||
<span class="topbar__name">WBfeed.ru</span>
|
||
</a>
|
||
<div class="topbar__nav">
|
||
<a href="{{ url_for('index') }}" class="topbar__link active">Отзывы</a>
|
||
<a href="{{ url_for('cabinet') }}" class="topbar__link">Кабинет</a>
|
||
{% if current_user["is_admin"] %}
|
||
<a href="{{ url_for('admin_panel') }}" class="topbar__link">Администратор</a>
|
||
{% endif %}
|
||
</div>
|
||
<div class="topbar__user">
|
||
<span class="topbar__username">{{ current_user["username"] }}</span>
|
||
{% if tokens %}
|
||
<div class="store-picker">
|
||
<button type="button" class="store-picker__btn" onclick="this.closest('.store-picker').classList.toggle('open')">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||
{{ active_token_name or 'Магазин не выбран' }}
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
|
||
</button>
|
||
<div class="store-picker__dropdown">
|
||
{% for t in tokens %}
|
||
<form method="post" action="{{ url_for('cabinet') }}">
|
||
<input type="hidden" name="cabinet_action" value="select">
|
||
<input type="hidden" name="token_id" value="{{ t.id }}">
|
||
<input type="hidden" name="next" value="index">
|
||
<button type="submit" class="store-picker__item {% if t.id == active_token_id %}store-picker__item--active{% endif %}">
|
||
{{ t.name }}
|
||
{% if t.id == active_token_id %}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>{% endif %}
|
||
</button>
|
||
</form>
|
||
{% endfor %}
|
||
<div class="store-picker__sep"></div>
|
||
<a href="{{ url_for('cabinet') }}" class="store-picker__item">Управление магазинами →</a>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
<a href="{{ url_for('logout') }}" class="btn-ghost">Выйти</a>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="page">
|
||
|
||
{% if error_message %}
|
||
<div class="alert alert-error">{{ error_message }}</div>
|
||
{% endif %}
|
||
{% if success_message %}
|
||
<div class="alert alert-success">{{ success_message }}</div>
|
||
{% endif %}
|
||
|
||
<!-- Настройки автоответа -->
|
||
<div class="card">
|
||
<div class="section-header">
|
||
<h2>Настройки автоответа</h2>
|
||
<form method="post" action="{{ url_for('auto_reply_toggle') }}">
|
||
<input type="hidden" name="enabled" value="{{ 0 if auto_reply_enabled else 1 }}">
|
||
{% if auto_reply_enabled %}
|
||
<button type="submit" class="btn-stop">▮▮ Стоп</button>
|
||
{% else %}
|
||
<button type="submit" class="btn-start">▶ Старт</button>
|
||
{% endif %}
|
||
</form>
|
||
</div>
|
||
|
||
{% if auto_reply_enabled %}
|
||
<div class="autoreply-status">
|
||
<span class="status-dot status-dot--green"></span>
|
||
<span>Автоответ активен</span>
|
||
{% if api_cooldown_seconds_left and api_cooldown_seconds_left > 0 %}
|
||
· Следующий ответ через <span id="cooldown-counter" data-seconds="{{ api_cooldown_seconds_left }}">{{ api_cooldown_seconds_left }}</span> сек.
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<form method="post" action="{{ url_for('auto_reply_settings') }}" id="settings-form">
|
||
<fieldset {% if auto_reply_enabled %}disabled{% endif %} style="border:none;padding:0;margin:0">
|
||
|
||
<!-- Фильтр — pill-кнопки -->
|
||
<div class="settings-section">
|
||
<div class="settings-label">Отвечать на отзывы</div>
|
||
<div class="filter-pills">
|
||
<label class="filter-pill">
|
||
<input type="radio" name="filter_mode" value="no_text" {% if filter_mode == 'no_text' %}checked{% endif %}>
|
||
<span>
|
||
<strong>Без текста</strong>
|
||
<small>достоинства/недостатки допустимы</small>
|
||
</span>
|
||
</label>
|
||
<label class="filter-pill">
|
||
<input type="radio" name="filter_mode" value="empty" {% if filter_mode == 'empty' %}checked{% endif %}>
|
||
<span>
|
||
<strong>Полностью пустые</strong>
|
||
<small>нет ни текста, ни плюсов/минусов</small>
|
||
</span>
|
||
</label>
|
||
<label class="filter-pill">
|
||
<input type="radio" name="filter_mode" value="all" {% if filter_mode == 'all' %}checked{% endif %}>
|
||
<span>
|
||
<strong>Все отзывы</strong>
|
||
<small>независимо от наличия текста</small>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Шаблоны ответов — вкладки по звёздам -->
|
||
<div class="settings-section">
|
||
<div class="settings-label">Шаблоны ответов</div>
|
||
|
||
<!-- Вкладки звёзд -->
|
||
<div class="star-tabs" id="star-tabs">
|
||
{% set star_labels = {1: 'Плохо', 2: 'Не понравилось', 3: 'Нормально', 4: 'Хорошо', 5: 'Отлично'} %}
|
||
{% for star in [1,2,3,4,5] %}
|
||
<div class="star-tab star-tab--{{ star }} {% if star in enabled_stars %}star-tab--on{% endif %}"
|
||
data-star="{{ star }}" onclick="setActiveStar({{ star }})">
|
||
<span class="star-tab__label">{{ star }}★</span>
|
||
<span class="star-tab__count" id="tab-count-{{ star }}">0</span>
|
||
<label class="star-tab__toggle" onclick="event.stopPropagation()" title="Включить/выключить">
|
||
<input type="checkbox" name="stars" value="{{ star }}"
|
||
id="star-cb-{{ star }}"
|
||
{% if star in enabled_stars %}checked{% endif %}
|
||
onchange="onStarToggle({{ star }})">
|
||
<span class="star-tab__toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Панель редактирования активной звезды -->
|
||
<div class="star-panel" id="star-panel">
|
||
<div class="star-panel__header" id="star-panel-header"></div>
|
||
<div class="pool-panel-items" id="pool-panel-items"></div>
|
||
<button type="button" class="btn-add-template" id="btn-add-template">+ Добавить шаблон</button>
|
||
</div>
|
||
|
||
<!-- Скрытые textarea со значениями пулов для всех звёзд -->
|
||
{% for star in [1,2,3,4,5] %}
|
||
<textarea name="pool_{{ star }}_raw" id="pool-{{ star }}-hidden" hidden>{{ reply_pools[star] }}</textarea>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<div class="settings-footer">
|
||
<button type="submit" class="btn-save">Сохранить настройки</button>
|
||
</div>
|
||
</fieldset>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Очередь автоответов -->
|
||
<div class="card">
|
||
<div class="section-header">
|
||
<h2>Очередь автоответов</h2>
|
||
{% if auto_reply_queue %}
|
||
<span class="badge">{{ auto_reply_queue|length }}</span>
|
||
{% endif %}
|
||
</div>
|
||
{% if auto_reply_queue %}
|
||
<div class="logs-table-wrap">
|
||
<table class="logs-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>ID отзыва</th>
|
||
<th>Дата оценки</th>
|
||
<th>Оценка</th>
|
||
<th>Товар</th>
|
||
<th>Покупатель</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for item in auto_reply_queue %}
|
||
<tr>
|
||
<td>{{ loop.index }}</td>
|
||
<td><code style="font-size:0.72rem;color:var(--c-text-muted)">{{ item.get("id") or "—" }}</code></td>
|
||
<td>{{ item.get("review_created_at")|format_log_datetime if item.get("review_created_at") else "—" }}</td>
|
||
<td><span class="rating rating--{{ item.rating }}">{{ item.rating }}★</span></td>
|
||
<td>
|
||
{{ item.get("product_name") or "—" }}
|
||
{% if item.get("nm_id") %}
|
||
<span class="tag-article">#{{ item.nm_id }}</span>
|
||
{% endif %}
|
||
</td>
|
||
<td>{{ item.get("user_name") or "—" }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
{% if auto_reply_enabled and next_fetch_seconds_left > 0 %}
|
||
<div class="empty-state">Очередь подгрузится через <span id="fetch-counter" data-seconds="{{ next_fetch_seconds_left }}">{{ next_fetch_seconds_left }}</span> сек.</div>
|
||
{% elif auto_reply_enabled %}
|
||
<div class="empty-state">Загрузка очереди…</div>
|
||
{% else %}
|
||
<div class="empty-state">Нажмите «Старт» для запуска автоответов.</div>
|
||
{% endif %}
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Журнал автоответов -->
|
||
<div class="card">
|
||
<div class="section-header">
|
||
<h2>Журнал автоответов</h2>
|
||
{% if auto_reply_logs %}
|
||
<span class="badge">{{ auto_reply_logs|length }}</span>
|
||
{% endif %}
|
||
</div>
|
||
{% if auto_reply_logs %}
|
||
<div class="log-list">
|
||
{% for log in auto_reply_logs %}
|
||
<div class="log-entry log-entry--{{ log['status'] }}">
|
||
<div class="log-entry__left">
|
||
<span class="log-entry__rating rating--{{ log['rating'] }}">{{ log['rating'] }}★</span>
|
||
<div class="log-entry__status">
|
||
{% if log['status'] == 'sent' %}
|
||
<span class="log-status--sent">✓ Отправлен</span>
|
||
{% elif log['status'] == 'skipped' %}
|
||
<span class="log-status--skip">— Пропущен</span>
|
||
{% else %}
|
||
<span class="log-status--error">✗ Ошибка</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
<div class="log-entry__body">
|
||
<div class="log-entry__meta">
|
||
<span class="log-entry__product">{{ log['product_name'] or '—' }}{% if log['nm_id'] %} <span class="tag-article">#{{ log['nm_id'] }}</span>{% endif %}</span>
|
||
<span class="log-entry__buyer">{{ log['user_name'] or '—' }}</span>
|
||
<span class="log-entry__time">{{ log['created_at']|format_log_datetime }}</span>
|
||
</div>
|
||
{% if log['review_text'] %}
|
||
<div class="log-entry__review">{{ log['review_text'] }}</div>
|
||
{% endif %}
|
||
{% if log['reply_text'] %}
|
||
<div class="log-entry__reply">
|
||
<span class="log-entry__reply-ico">↩</span>{{ log['reply_text'] }}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="empty-state">Пока нет записей автоответа.</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
|
||
</div>
|
||
|
||
<!-- Нижняя навигация (мобайл) -->
|
||
<nav class="bottom-nav">
|
||
<a href="{{ url_for('index') }}" class="bottom-nav__item active">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/>
|
||
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||
<line x1="9" y1="12" x2="15" y2="12"/>
|
||
<line x1="9" y1="16" x2="13" y2="16"/>
|
||
</svg>
|
||
Отзывы
|
||
</a>
|
||
<a href="{{ url_for('cabinet') }}" class="bottom-nav__item">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||
<circle cx="12" cy="7" r="4"/>
|
||
</svg>
|
||
Кабинет
|
||
</a>
|
||
{% if current_user["is_admin"] %}
|
||
<a href="{{ url_for('admin_panel') }}" class="bottom-nav__item">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||
</svg>
|
||
Админ
|
||
</a>
|
||
{% endif %}
|
||
</nav>
|
||
|
||
<script>
|
||
// ── Tick-down helper ───────────────────────────────────────────────
|
||
function startCountdown(id, onZero) {
|
||
const node = document.getElementById(id);
|
||
if (!node) return;
|
||
let s = parseInt(node.dataset.seconds || '0', 10);
|
||
const tick = setInterval(() => {
|
||
s--;
|
||
if (s <= 0) { clearInterval(tick); if (onZero) onZero(); else node.textContent = '0'; return; }
|
||
node.textContent = s;
|
||
}, 1000);
|
||
return { update: (v) => { s = v; node.textContent = v; } };
|
||
}
|
||
|
||
const cooldownCtrl = startCountdown('cooldown-counter');
|
||
const fetchCtrl = startCountdown('fetch-counter', () => window.location.reload());
|
||
|
||
// ── Star tab pool editor ───────────────────────────────────────────
|
||
const poolData = {1:[],2:[],3:[],4:[],5:[]};
|
||
let activeStar = 5;
|
||
|
||
function esc(s) {
|
||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
function updateTabCount(star) {
|
||
const el = document.getElementById(`tab-count-${star}`);
|
||
if (el) el.textContent = poolData[star].length;
|
||
}
|
||
|
||
function onStarToggle(star) {
|
||
const cb = document.getElementById(`star-cb-${star}`);
|
||
const tab = document.querySelector(`.star-tab[data-star="${star}"]`);
|
||
if (tab) tab.classList.toggle('star-tab--on', cb.checked);
|
||
}
|
||
|
||
function setActiveStar(star) {
|
||
activeStar = star;
|
||
document.querySelectorAll('.star-tab').forEach(t =>
|
||
t.classList.toggle('star-tab--active', +t.dataset.star === star));
|
||
renderPanel();
|
||
}
|
||
|
||
function renderPanel() {
|
||
const COLORS = {1:'#EF4444',2:'#F97316',3:'#EAB308',4:'#3B82F6',5:'#22C55E'};
|
||
const LABELS = {1:'1★ — Плохо',2:'2★ — Не понравилось',3:'3★ — Нормально',4:'4★ — Хорошо',5:'5★ — Отлично'};
|
||
const header = document.getElementById('star-panel-header');
|
||
if (header) {
|
||
header.innerHTML = `<span style="color:${COLORS[activeStar]};font-weight:700;font-size:15px">${LABELS[activeStar]}</span>
|
||
<span style="color:var(--c-text-muted);font-size:13px">${poolData[activeStar].length ? poolData[activeStar].length + ' шаблон(а/ов)' : 'нет шаблонов'}</span>`;
|
||
}
|
||
const container = document.getElementById('pool-panel-items');
|
||
container.innerHTML = '';
|
||
poolData[activeStar].forEach((text, idx) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'pool-template-row';
|
||
row.innerHTML = `<span class="pool-template-num">${idx+1}</span>
|
||
<textarea class="pool-template-input" rows="3" placeholder="Текст ответа для ${activeStar}★…">${esc(text)}</textarea>
|
||
<button type="button" class="pool-template-del" title="Удалить">✕</button>`;
|
||
const ta = row.querySelector('textarea');
|
||
ta.addEventListener('input', () => { poolData[activeStar][idx] = ta.value; updateTabCount(activeStar); renderPanelHeader(); });
|
||
row.querySelector('.pool-template-del').addEventListener('click', () => {
|
||
poolData[activeStar].splice(idx, 1);
|
||
updateTabCount(activeStar);
|
||
renderPanel();
|
||
});
|
||
container.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function renderPanelHeader() {
|
||
const LABELS = {1:'1★ — Плохо',2:'2★ — Не понравилось',3:'3★ — Нормально',4:'4★ — Хорошо',5:'5★ — Отлично'};
|
||
const COLORS = {1:'#EF4444',2:'#F97316',3:'#EAB308',4:'#3B82F6',5:'#22C55E'};
|
||
const header = document.getElementById('star-panel-header');
|
||
if (header) {
|
||
header.innerHTML = `<span style="color:${COLORS[activeStar]};font-weight:700;font-size:15px">${LABELS[activeStar]}</span>
|
||
<span style="color:var(--c-text-muted);font-size:13px">${poolData[activeStar].length ? poolData[activeStar].length + ' шаблон(а/ов)' : 'нет шаблонов'}</span>`;
|
||
}
|
||
}
|
||
|
||
function addTemplate() {
|
||
poolData[activeStar].push('');
|
||
updateTabCount(activeStar);
|
||
renderPanel();
|
||
const inputs = document.querySelectorAll('.pool-template-input');
|
||
if (inputs.length) { inputs[inputs.length-1].focus(); }
|
||
}
|
||
|
||
function serializeForSubmit() {
|
||
[1,2,3,4,5].forEach(star => {
|
||
const hidden = document.getElementById(`pool-${star}-hidden`);
|
||
if (hidden) hidden.value = poolData[star].filter(s => s.trim()).join('\n');
|
||
});
|
||
}
|
||
|
||
function initPools() {
|
||
[1,2,3,4,5].forEach(star => {
|
||
const hidden = document.getElementById(`pool-${star}-hidden`);
|
||
if (hidden) poolData[star] = hidden.value.split('\n').map(l => l.trim()).filter(Boolean);
|
||
updateTabCount(star);
|
||
});
|
||
const firstOn = [5,4,3,2,1].find(s => document.getElementById(`star-cb-${s}`)?.checked) || 5;
|
||
setActiveStar(firstOn);
|
||
}
|
||
|
||
document.getElementById('settings-form').addEventListener('submit', serializeForSubmit);
|
||
document.getElementById('btn-add-template')?.addEventListener('click', addTemplate);
|
||
initPools();
|
||
|
||
// ── API polling ────────────────────────────────────────────────────
|
||
(() => {
|
||
let lastLogId = null;
|
||
const poll = async () => {
|
||
try {
|
||
const r = await fetch('/api/status');
|
||
if (!r.ok) return;
|
||
const data = await r.json();
|
||
if (lastLogId === null) {
|
||
lastLogId = data.last_log_id;
|
||
} else if (data.last_log_id !== lastLogId) {
|
||
window.location.reload();
|
||
}
|
||
if (cooldownCtrl && data.cooldown > 0) cooldownCtrl.update(data.cooldown);
|
||
if (fetchCtrl && data.next_fetch_seconds > 0) fetchCtrl.update(data.next_fetch_seconds);
|
||
if (data.next_fetch_seconds === 0 && data.queue_len === 0 && data.auto_reply_enabled) {
|
||
window.location.reload();
|
||
}
|
||
} catch(e) {}
|
||
};
|
||
poll();
|
||
setInterval(poll, 10000);
|
||
})();
|
||
// ── Store picker close on outside click ───────────────────
|
||
document.addEventListener('click', e => {
|
||
if (!e.target.closest('.store-picker')) {
|
||
document.querySelectorAll('.store-picker.open').forEach(el => el.classList.remove('open'));
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|