773382a7bb
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
448 lines
21 KiB
HTML
448 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">
|
|
<span class="topbar__logo">WB</span>
|
|
<span class="topbar__name">Feedback</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 active_token_name %}
|
|
<span class="badge">{{ active_token_name }}</span>
|
|
{% 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="controls">
|
|
<div class="controls-card">
|
|
<form method="get" action="/">
|
|
<div class="star-filter">
|
|
<span class="star-filter-label">Оценки</span>
|
|
{% for star in [5,4,3,2,1] %}
|
|
<span class="star-pill" data-star="{{ star }}">
|
|
<input type="checkbox" id="star-{{ star }}" name="stars" value="{{ star }}" {% if star in selected_stars %}checked{% endif %}>
|
|
<label for="star-{{ star }}">{{ star }}★</label>
|
|
</span>
|
|
{% endfor %}
|
|
</div>
|
|
<div class="control-buttons">
|
|
<button type="submit" name="action" value="all">Выгрузить отзывы</button>
|
|
<button type="submit" name="action" value="unanswered">Только неотвеченные</button>
|
|
<button type="submit" name="action" value="unanswered" class="secondary" formaction="/?stars=5&stars=4">Новые 5★ и 4★</button>
|
|
<button type="submit" name="action" value="clear" class="secondary">Очистить список</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Автоответ -->
|
|
<div class="card auto-reply">
|
|
<div class="auto-reply__info">
|
|
<div class="auto-reply__title">
|
|
<h3>Автоответ</h3>
|
|
{% if auto_reply_enabled %}
|
|
<span class="status-dot status-dot--green"></span>
|
|
<span class="badge badge-green">Включён</span>
|
|
{% else %}
|
|
<span class="status-dot status-dot--gray"></span>
|
|
<span class="badge">Выключен</span>
|
|
{% endif %}
|
|
</div>
|
|
<p>По требованию WB — <strong>1 ответ каждые 10 минут</strong>. Очередь обрабатывается автоматически.</p>
|
|
{% if api_cooldown_seconds_left and api_cooldown_seconds_left > 0 %}
|
|
<p style="margin-top:6px;color:var(--amber)">⚠ API на паузе: ещё <span id="cooldown-counter" data-seconds="{{ api_cooldown_seconds_left }}">{{ api_cooldown_seconds_left }}</span> сек.</p>
|
|
{% endif %}
|
|
</div>
|
|
<form method="post" action="{{ url_for('auto_reply_toggle') }}" id="toggle-form">
|
|
<input type="hidden" name="enabled" value="{{ 0 if auto_reply_enabled else 1 }}">
|
|
<label class="tumbler" title="{{ 'Выключить автоответ' if auto_reply_enabled else 'Включить автоответ' }}">
|
|
<input type="checkbox" class="tumbler__input" {{ 'checked' if auto_reply_enabled else '' }} onchange="document.getElementById('toggle-form').submit()">
|
|
<span class="tumbler__track">
|
|
<span class="tumbler__thumb"></span>
|
|
</span>
|
|
</label>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Пулы автоответов -->
|
|
<div class="card">
|
|
<div class="section-header">
|
|
<h2>Пулы автоответов</h2>
|
|
</div>
|
|
<p style="margin-bottom:16px;font-size:13px">Для каждого нового отзыва текст выбирается случайно из пула.</p>
|
|
<form method="post" action="{{ url_for('auto_reply_pools') }}" id="pools-form">
|
|
<textarea name="pool_5" id="pool-5-hidden" hidden>{{ reply_pool_5_text }}</textarea>
|
|
<textarea name="pool_4" id="pool-4-hidden" hidden>{{ reply_pool_4_text }}</textarea>
|
|
<div class="pool-grid">
|
|
<div class="pool-block">
|
|
<div class="pool-label" style="margin-bottom:10px">
|
|
Ответы для <span class="star-chip star-chip--5">5★</span>
|
|
</div>
|
|
<div class="pool-items" id="pool-items-5"></div>
|
|
<button type="button" class="btn-add-item" onclick="addPoolItem('5')">+ Добавить ответ</button>
|
|
</div>
|
|
<div class="pool-block">
|
|
<div class="pool-label" style="margin-bottom:10px">
|
|
Ответы для <span class="star-chip star-chip--4">4★</span>
|
|
</div>
|
|
<div class="pool-items" id="pool-items-4"></div>
|
|
<button type="button" class="btn-add-item" onclick="addPoolItem('4')">+ Добавить ответ</button>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:16px">
|
|
<button type="submit">Сохранить пулы</button>
|
|
</div>
|
|
</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 %}
|
|
<div class="empty-state">Очередь пуста — новые отзывы будут загружены автоматически.</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Журнал автоответов -->
|
|
<div class="card">
|
|
<div class="section-header">
|
|
<h2>Журнал автоответов</h2>
|
|
<span class="badge">последние 100</span>
|
|
</div>
|
|
{% if auto_reply_logs %}
|
|
<div class="logs-table-wrap">
|
|
<table class="logs-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Дата лога</th>
|
|
<th>Дата оценки</th>
|
|
<th>Оценка</th>
|
|
<th>Товар</th>
|
|
<th>Покупатель</th>
|
|
<th>Текст отзыва</th>
|
|
<th>Статус</th>
|
|
<th>ID отзыва</th>
|
|
<th>Ответ</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for log in auto_reply_logs %}
|
|
<tr>
|
|
<td>{{ log["created_at"]|format_log_datetime }}</td>
|
|
<td>{{ log["review_created_at"]|format_log_datetime if log["review_created_at"] else "—" }}</td>
|
|
<td><span class="rating rating--{{ log['rating'] }}">{{ log["rating"] }}★</span></td>
|
|
<td>
|
|
{{ log["product_name"] or "—" }}
|
|
{% if log["nm_id"] %}
|
|
<span class="tag-article">#{{ log["nm_id"] }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>{{ log["user_name"] or "—" }}</td>
|
|
<td>{{ log["review_text"] or "—" }}</td>
|
|
<td>
|
|
{% 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 %}
|
|
</td>
|
|
<td><code style="font-size:0.72rem;color:var(--c-text-muted)">{{ log["review_id"] or "—" }}</code></td>
|
|
<td>{{ log["reply_text"] or "—" }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="empty-state">Пока нет записей автоответа.</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Список отзывов -->
|
|
{% if reviews %}
|
|
{% set unanswered_count = reviews | rejectattr('answer') | list | length %}
|
|
{% set answered_count = reviews | selectattr('answer') | list | length %}
|
|
|
|
<div class="summary">
|
|
<h2>{{ "Неотвеченные отзывы" if current_filter == 'unanswered' else "Все отзывы" }}</h2>
|
|
<p>Оценки: {{ selected_stars_display|join(', ') }}★.</p>
|
|
</div>
|
|
|
|
<!-- Счётчики -->
|
|
<div class="stats-bar">
|
|
<div class="stat-chip">
|
|
<span class="stat-value">{{ reviews|length }}</span>
|
|
<span>отзывов загружено</span>
|
|
</div>
|
|
{% if unanswered_count > 0 %}
|
|
<div class="stat-chip stat-chip--warn">
|
|
<span class="stat-value">{{ unanswered_count }}</span>
|
|
<span>без ответа</span>
|
|
</div>
|
|
{% endif %}
|
|
{% if answered_count > 0 %}
|
|
<div class="stat-chip stat-chip--ok">
|
|
<span class="stat-value">{{ answered_count }}</span>
|
|
<span>отвечено</span>
|
|
</div>
|
|
{% endif %}
|
|
{% for star in [5,4,3,2,1] %}
|
|
{% set cnt = reviews | selectattr('rating', 'equalto', star) | list | length %}
|
|
{% if cnt > 0 %}
|
|
<div class="stat-chip">
|
|
<span class="stat-value">{{ cnt }}</span>
|
|
<span>{{ star }}★</span>
|
|
</div>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
|
|
{% if current_filter == 'unanswered' and has_token %}
|
|
<form class="reply-form" method="post" action="{{ url_for('reply_all') }}">
|
|
<label for="reply-text">Ответ для всех неотвеченных отзывов</label>
|
|
<textarea id="reply-text" name="message" rows="4" placeholder="Напишите ответ один раз — он будет отправлен каждому неотвеченному отзыву" required></textarea>
|
|
{% for star in selected_stars_display %}
|
|
<input type="hidden" name="stars" value="{{ star }}">
|
|
{% endfor %}
|
|
{% for review in reviews %}
|
|
<input type="hidden" name="review_id" value="{{ review.id }}">
|
|
{% endfor %}
|
|
<div class="reply-form__actions">
|
|
<span>Допустимая длина: 2–5000 символов.</span>
|
|
<button type="submit">Ответить всем</button>
|
|
</div>
|
|
</form>
|
|
{% endif %}
|
|
|
|
<div class="reviews">
|
|
{% for review in reviews %}
|
|
<article class="review" data-rating="{{ review.rating }}">
|
|
<header class="review__header">
|
|
<div>
|
|
<span class="review__product">{{ review.product_name or 'Без названия' }}</span>
|
|
<span class="rating rating--{{ review.rating }}">★ {{ review.rating }}</span>
|
|
</div>
|
|
<div class="review__meta">
|
|
<span>{{ review.user_name or 'Покупатель' }}</span>
|
|
<span>{{ review.created_at|format_datetime }}</span>
|
|
</div>
|
|
</header>
|
|
|
|
{% if review.text %}
|
|
<p class="review__text">{{ review.text }}</p>
|
|
{% endif %}
|
|
|
|
<dl class="details">
|
|
{% if review.pros %}
|
|
<dt>Достоинства</dt>
|
|
<dd>{{ review.pros }}</dd>
|
|
{% endif %}
|
|
{% if review.cons %}
|
|
<dt>Недостатки</dt>
|
|
<dd>{{ review.cons }}</dd>
|
|
{% endif %}
|
|
</dl>
|
|
|
|
{% if review.answer %}
|
|
<p class="answer"><strong>Ответ:</strong> {{ review.answer }}</p>
|
|
{% else %}
|
|
<p class="no-answer">Без ответа</p>
|
|
{% endif %}
|
|
|
|
{% if has_token %}
|
|
<details class="inline-reply">
|
|
<summary>Ответить на отзыв</summary>
|
|
<form method="post" action="{{ url_for('reply_single', review_id=review.id) }}">
|
|
<textarea name="message" rows="3" placeholder="Введите ответ" required></textarea>
|
|
{% for star in selected_stars_display %}
|
|
<input type="hidden" name="stars" value="{{ star }}">
|
|
{% endfor %}
|
|
<input type="hidden" name="next_action" value="{{ 'unanswered' if current_filter == 'unanswered' else 'all' }}">
|
|
<button type="submit">Отправить</button>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
</article>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
{% elif current_filter %}
|
|
<div class="alert">Отзывы с выбранными оценками ({{ selected_stars_display|join(', ') }}★) не найдены.</div>
|
|
{% endif %}
|
|
|
|
</div>
|
|
|
|
<!-- Нижняя навигация (мобайл) -->
|
|
<nav class="bottom-nav">
|
|
<a href="{{ url_for('index') }}" class="bottom-nav__item active">
|
|
{% if reviews and (reviews | rejectattr('answer') | list | length) > 0 %}
|
|
<span class="bottom-nav__badge">{{ reviews | rejectattr('answer') | list | length }}</span>
|
|
{% endif %}
|
|
<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>
|
|
// ── Cooldown counter ───────────────────────────────────────────────
|
|
(() => {
|
|
const node = document.getElementById('cooldown-counter');
|
|
if (!node) return;
|
|
let s = parseInt(node.dataset.seconds || '0', 10);
|
|
const tick = setInterval(() => {
|
|
s--;
|
|
if (s <= 0) { clearInterval(tick); node.closest('p').remove(); return; }
|
|
node.textContent = s;
|
|
}, 1000);
|
|
})();
|
|
|
|
// ── Pool editor ────────────────────────────────────────────────────
|
|
function syncHidden(star) {
|
|
const items = document.querySelectorAll(`#pool-items-${star} .pool-item-input`);
|
|
const lines = [...items].map(i => i.value.trim()).filter(Boolean);
|
|
document.getElementById(`pool-${star}-hidden`).value = lines.join('\n');
|
|
}
|
|
|
|
function addPoolItem(star, value = '') {
|
|
const container = document.getElementById(`pool-items-${star}`);
|
|
const row = document.createElement('div');
|
|
row.className = 'pool-item';
|
|
row.innerHTML = `
|
|
<input type="text" class="pool-item-input" value="${value.replace(/"/g, '"')}" placeholder="Текст ответа…">
|
|
<button type="button" class="btn-delete-item" title="Удалить">✕</button>`;
|
|
row.querySelector('.pool-item-input').addEventListener('input', () => syncHidden(star));
|
|
row.querySelector('.btn-delete-item').addEventListener('click', () => {
|
|
row.remove();
|
|
syncHidden(star);
|
|
});
|
|
container.appendChild(row);
|
|
syncHidden(star);
|
|
row.querySelector('input').focus();
|
|
}
|
|
|
|
function initPool(star) {
|
|
const hidden = document.getElementById(`pool-${star}-hidden`);
|
|
const lines = hidden.value.split('\n').map(l => l.trim()).filter(Boolean);
|
|
lines.forEach(line => addPoolItem(star, line));
|
|
}
|
|
|
|
initPool('5');
|
|
initPool('4');
|
|
|
|
document.getElementById('pools-form').addEventListener('submit', () => {
|
|
syncHidden('5');
|
|
syncHidden('4');
|
|
});
|
|
|
|
// ── API polling — reload when new log entry appears ────────────────
|
|
(() => {
|
|
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();
|
|
}
|
|
// sync cooldown if page wasn't reloaded
|
|
const node = document.getElementById('cooldown-counter');
|
|
if (node && data.cooldown > 0) node.textContent = data.cooldown;
|
|
} catch(e) {}
|
|
};
|
|
poll();
|
|
setInterval(poll, 15000);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|