Redesign: add 1-3★ auto-reply pools, filter modes, new settings UI
- app.py: add pool keys for stars 1-3, AUTO_REPLY_STARS_KEY/FILTER_KEY, _load_enabled_stars(), _load_filter_mode(), updated _has_review_content() with filter_mode param, new /auto-reply-settings POST route, index route passes enabled_stars/filter_mode/pools 1-5 to template - templates/index.html: remove reviews list & controls section, replace pool card with full settings card (star toggles, filter mode radios, dynamic per-star pool columns), update JS for all 5 stars - templates/login.html: fix ratings feature text (1-5★), update price to 7 ₽/день with corrected description - static/styles.css: login-promo fixed 300px width, logo 140px, add settings UI styles (star-toggles, filter-options, pool-columns, star-chips 1-3) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+81
-176
@@ -40,29 +40,6 @@
|
||||
<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">
|
||||
@@ -92,33 +69,66 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Пулы автоответов -->
|
||||
<!-- Настройки автоответа -->
|
||||
<div class="card">
|
||||
<div class="section-header">
|
||||
<h2>Пулы автоответов</h2>
|
||||
<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>
|
||||
<form method="post" action="{{ url_for('auto_reply_settings') }}" id="settings-form">
|
||||
|
||||
<!-- Звёзды -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">На какие оценки отвечать</div>
|
||||
<div class="star-toggles">
|
||||
{% for star in [1,2,3,4,5] %}
|
||||
<label class="star-toggle star-toggle--{{ star }}">
|
||||
<input type="checkbox" name="stars" value="{{ star }}"
|
||||
{% if star in enabled_stars %}checked{% endif %}
|
||||
onchange="updateColumns()">
|
||||
<span>{{ star }}★</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:16px">
|
||||
<button type="submit">Сохранить пулы</button>
|
||||
|
||||
<!-- Фильтр типа отзывов -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Отвечать на отзывы</div>
|
||||
<div class="filter-options">
|
||||
<label class="filter-option">
|
||||
<input type="radio" name="filter_mode" value="no_text" {% if filter_mode == 'no_text' %}checked{% endif %}>
|
||||
<span>Без основного текста <small>(достоинства/недостатки допустимы)</small></span>
|
||||
</label>
|
||||
<label class="filter-option">
|
||||
<input type="radio" name="filter_mode" value="empty" {% if filter_mode == 'empty' %}checked{% endif %}>
|
||||
<span>Полностью пустые <small>(нет текста, достоинств и недостатков)</small></span>
|
||||
</label>
|
||||
<label class="filter-option">
|
||||
<input type="radio" name="filter_mode" value="all" {% if filter_mode == 'all' %}checked{% endif %}>
|
||||
<span>Все отзывы <small>(независимо от наличия текста)</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Динамические колонки пулов -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Шаблоны ответов</div>
|
||||
<div class="pool-columns" id="pool-columns">
|
||||
{% for star in [1,2,3,4,5] %}
|
||||
<div class="pool-col pool-col--{{ star }}" id="pool-col-{{ star }}" {% if star not in enabled_stars %}style="display:none"{% endif %}>
|
||||
<div class="pool-col__header">
|
||||
<span class="star-chip star-chip--{{ star }}">{{ star }}★</span>
|
||||
</div>
|
||||
<div class="pool-items" id="pool-items-{{ star }}"></div>
|
||||
<textarea name="pool_{{ star }}_raw" id="pool-{{ star }}-hidden" hidden>{{ vars()['reply_pool_' ~ star ~ '_text'] }}</textarea>
|
||||
<button type="button" class="btn-add-item" onclick="addPoolItem('{{ star }}')">+ Добавить ответ</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px">
|
||||
<button type="submit">Сохранить настройки</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -225,126 +235,12 @@
|
||||
{% 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"/>
|
||||
@@ -387,40 +283,49 @@
|
||||
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');
|
||||
// inject as hidden inputs for form submission
|
||||
const container = document.getElementById(`pool-items-${star}`);
|
||||
container.querySelectorAll('input[name="pool_' + star + '_item"]').forEach(e => e.remove());
|
||||
lines.forEach(line => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = `pool_${star}_item`;
|
||||
inp.value = line;
|
||||
container.appendChild(inp);
|
||||
});
|
||||
}
|
||||
|
||||
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.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);
|
||||
});
|
||||
row.querySelector('.btn-delete-item').addEventListener('click', () => { row.remove(); syncHidden(star); });
|
||||
container.appendChild(row);
|
||||
syncHidden(star);
|
||||
row.querySelector('input').focus();
|
||||
}
|
||||
|
||||
function updateColumns() {
|
||||
[1,2,3,4,5].forEach(star => {
|
||||
const cb = document.querySelector(`input[name="stars"][value="${star}"]`);
|
||||
const col = document.getElementById(`pool-col-${star}`);
|
||||
if (col) col.style.display = cb && cb.checked ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
if (!hidden) return;
|
||||
hidden.value.split('\n').map(l => l.trim()).filter(Boolean).forEach(line => addPoolItem(star, line));
|
||||
}
|
||||
|
||||
initPool('5');
|
||||
initPool('4');
|
||||
|
||||
document.getElementById('pools-form').addEventListener('submit', () => {
|
||||
syncHidden('5');
|
||||
syncHidden('4');
|
||||
document.getElementById('settings-form').addEventListener('submit', () => {
|
||||
[1,2,3,4,5].forEach(syncHidden);
|
||||
});
|
||||
|
||||
[1,2,3,4,5].forEach(initPool);
|
||||
|
||||
// ── API polling — reload when new log entry appears ────────────────
|
||||
(() => {
|
||||
let lastLogId = null;
|
||||
|
||||
Reference in New Issue
Block a user