fix(admin): show lead refresh progress and prevent stale responses

This commit is contained in:
Dana Medical Developer 2026-10-23 11:15:24 +00:00
parent b0e9da35e7
commit d8e7272800
6 changed files with 156 additions and 9 deletions

View File

@ -29,6 +29,7 @@ func pageParams(r *http.Request) (limit, offset int) {
// ---------- Заявки ----------
func (a *API) adminLeads(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
w.Header().Set("Cache-Control", "no-store")
limit, offset := pageParams(r)
q := r.URL.Query()
var from, to *time.Time

View File

@ -0,0 +1,20 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAdminLeadsResponseIsNotCacheable(t *testing.T) {
// Invalid dates return before storage access; no database or patient data needed.
a := &API{}
w := httptest.NewRecorder()
a.adminLeads(w, httptest.NewRequest(http.MethodGet, "/api/v1/admin/leads?from=invalid", nil), nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if got := w.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
}

View File

@ -6,7 +6,7 @@
<title>DANA — админка</title>
<link rel="stylesheet" href="admin.css?v=20261014-share">
<link rel="stylesheet" href="weekly-experiment.css?v=20261005-corners2">
<link rel="stylesheet" href="leads.css">
<link rel="stylesheet" href="leads.css?v=20261023-refresh">
<link rel="stylesheet" href="onec.css">
<link rel="stylesheet" href="reports-history.css">
<link rel="icon" href="/favicon.ico">
@ -93,8 +93,9 @@
<label class="ld-search"><span aria-hidden="true">⌕</span><input type="search" id="ld-q" placeholder="Поиск по имени, телефону или сообщению…" aria-label="Поиск заявок"></label>
<div class="ld-date"><label><span>С</span><input id="ld-from" type="date" aria-label="Дата с"></label><label><span>По</span><input id="ld-to" type="date" aria-label="Дата по"></label></div>
<button class="ld-export" id="ld-export" type="button" title="Экспортировать показанные заявки в CSV"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 17v3h16v-3"/></svg><span>Экспорт страницы</span></button>
<button class="ld-refresh" id="ld-refresh" type="button" aria-label="Обновить заявки" title="Обновить заявки">↻</button>
<button class="ld-refresh" id="ld-refresh" type="button" aria-label="Обновить заявки" title="Обновить заявки"><span aria-hidden="true">↻</span></button>
</div>
<p id="ld-updated" class="ld-updated" role="status" aria-live="polite"></p>
<div class="ld-table-wrap"><table class="ld-table">
<thead><tr><th>Дата</th><th>Имя</th><th>Телефон</th><th>Форма</th><th>Сообщение</th><th>Статус</th><th><span class="visually-hidden">Действие</span></th></tr></thead>
<tbody id="ld-body"></tbody>
@ -2408,7 +2409,7 @@ function debounce(fn, ms) {
</script>
<script src="dashboard.js"></script>
<script src="leads.js?v=20261014-wish"></script>
<script src="leads.js?v=20261023-refresh"></script>
<script src="doctors.js?v=20261006-rating"></script>
<script src="doctors-dash.js"></script>
<script src="banners.js"></script>

View File

@ -43,6 +43,14 @@ body.leads-view .page-title, body.leads-view .page-sub, body.leads-view .subtabs
.ld-export svg { width: 21px; height: 21px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.ld-export:disabled { opacity: .5; cursor: not-allowed; }
.ld-refresh { color: #324f70; background: #f2f7ff; border: 1px solid #d7e4f5; font-size: 26px; }
.ld-refresh span { display: inline-block; }
.ld-refresh[aria-busy="true"] span { animation: ld-refresh-spin .8s linear infinite; }
.ld-refresh:disabled { opacity: .65; cursor: wait; }
@keyframes ld-refresh-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .ld-refresh[aria-busy="true"] span { animation: none; } }
.ld-updated { margin: -9px 0 13px; color: #637891; font-size: 12px; }
.ld-updated:empty { display: none; }
.ld-updated--error { padding: 10px 12px; color: #92291f; background: #ffe3df; border-radius: 9px; }
.ld-table-wrap { overflow-x: auto; border: 1px solid #e3edfa; border-radius: 11px; }
.ld-table { min-width: 830px; }
.ld-table th { padding: 13px 19px; background: #f2f7ff; color: #526782; font-size: 12px; font-weight: 600; }

View File

@ -2,17 +2,52 @@
Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */
const Leads = {
offset: 0, limit: 30, total: 0, loaded: false, visible: [],
offset: 0, limit: 30, total: 0, loaded: false, visible: [], requestId: 0, controller: null,
async load() {
async load({ notify = false } = {}) {
const requestId = ++Leads.requestId;
Leads.controller?.abort();
const controller = new AbortController();
Leads.controller = controller;
const refresh = $('#ld-refresh'), updated = $('#ld-updated');
refresh.disabled = true;
refresh.setAttribute('aria-busy', 'true');
$('#ld-body').setAttribute('aria-busy', 'true');
updated.classList.remove('ld-updated--error');
updated.textContent = 'Обновляем заявки…';
let timedOut = false;
const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 20000);
const status = $('#ld-status').value;
const params = new URLSearchParams({ status, q: $('#ld-q').value.trim(), limit: Leads.limit, offset: Leads.offset });
if ($('#ld-from').value) params.set('from', $('#ld-from').value);
if ($('#ld-to').value) params.set('to', $('#ld-to').value);
let r;
try {
r = await api(`/admin/leads?${params}`);
} catch (e) { toast(e.message, true); return; }
const r = await api(`/admin/leads?${params}`, { signal: controller.signal, cache: 'no-store' });
if (requestId !== Leads.requestId || controller.signal.aborted) return false;
Leads.render(r, status);
Leads.loaded = true;
updated.textContent = 'Обновлено в ' + new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (notify) toast('Список заявок обновлён');
return true;
} catch (e) {
if (requestId !== Leads.requestId) return false;
const message = timedOut ? 'Истекло время ожидания. Попробуйте обновить заявки ещё раз.' : e.message;
updated.classList.add('ld-updated--error');
updated.textContent = `Не удалось обновить заявки. ${message}${Leads.loaded ? ' Показаны предыдущие данные.' : ''}`;
toast(message, true);
return false;
} finally {
clearTimeout(timeout);
if (requestId === Leads.requestId) {
Leads.controller = null;
refresh.disabled = false;
refresh.setAttribute('aria-busy', 'false');
$('#ld-body').setAttribute('aria-busy', 'false');
}
}
},
render(r, status) {
Leads.total = r.meta?.total || 0;
const counts = r.meta?.counts || {};
for (const [id, value] of [['ld-new', counts.new || 0], ['ld-new-badge', counts.new || 0],
@ -148,7 +183,7 @@ $('#ld-dlg').addEventListener('click', (e) => { if (e.target === e.currentTarget
$('#ld-status').onchange = () => { Leads.offset = 0; Leads.load(); };
$('#ld-q').addEventListener('input', debounce(() => { Leads.offset = 0; Leads.load(); }, 300));
for (const id of ['ld-from', 'ld-to']) $('#' + id).onchange = () => { Leads.offset = 0; Leads.load(); };
$('#ld-refresh').onclick = () => Leads.load();
$('#ld-refresh').onclick = () => { Leads.offset = 0; return Leads.load({ notify: true }); };
$('#ld-export').onclick = () => Leads.exportVisible();
$('#ld-prev').onclick = () => { Leads.offset = Math.max(0, Leads.offset - Leads.limit); Leads.load(); };
$('#ld-next').onclick = () => { Leads.offset += Leads.limit; Leads.load(); };

View File

@ -0,0 +1,82 @@
// Embedded admin UI with a fake API and fictional leads; never connects to a server.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import { parseHTML } from 'linkedom';
const html = await readFile(new URL('../../internal/api/adminui/index.html', import.meta.url), 'utf8');
const source = await readFile(new URL('../../internal/api/adminui/leads.js', import.meta.url), 'utf8');
const { document } = parseHTML(html);
const $ = (selector) => document.querySelector(selector);
const select = $('#ld-status');
Object.defineProperty(select, 'options', { get: () => [...select.querySelectorAll('option')] });
function selectStatus(status) { for (const option of select.options) { if (option.value === status) option.setAttribute('selected', ''); else option.removeAttribute('selected'); } }
selectStatus('new');
const pending = [], notifications = [], timers = new Map();
let timerId = 0;
const context = vm.createContext({ document, $, URLSearchParams, AbortController, Date, Intl, console,
el: (tag, props = {}) => Object.assign(document.createElement(tag), props),
api: (path, options) => new Promise((resolve, reject) => pending.push({ path, options, resolve, reject })),
toast: (message, bad) => notifications.push({ message, bad }),
debounce: (fn) => fn, plural: () => 'заявок',
LEAD_STATUS: { new: ['Новая', 'blue'], done: ['Обработана', 'green'] },
setTimeout: (fn) => { const id = ++timerId; timers.set(id, fn); return id; },
clearTimeout: (id) => timers.delete(id),
});
vm.runInContext(source + '\nglobalThis.testLeads = Leads;', context);
const leads = context.testLeads;
const response = (name = 'Тестовая заявка') => ({ data: [{ id: 1, created_at: '2026-10-23T10:00:00+03:00', name, phone: '+79990000000', status: 'new', form_title: 'Тестовая форма', message: 'Вымышленные данные' }], meta: { total: 1, counts: { new: 1 }, today: 1 } });
leads.offset = 60;
const first = $('#ld-refresh').onclick();
assert.equal($('#ld-refresh').disabled, true, 'Refresh must visibly enter loading state');
assert.equal($('#ld-refresh').getAttribute('aria-busy'), 'true');
assert.match($('#ld-updated').textContent, /Обновляем/);
assert.equal(new URLSearchParams(pending[0].path.split('?')[1]).get('offset'), '0', 'Manual refresh returns to newest results');
assert.equal(pending[0].options.cache, 'no-store');
pending[0].resolve(response());
await first;
assert.match($('#ld-body').textContent, /Тестовая заявка/);
assert.equal($('#ld-refresh').disabled, false);
assert.match($('#ld-updated').textContent, /Обновлено/);
assert.ok(notifications.some((n) => n.message === 'Список заявок обновлён'));
assert.equal(timers.size, 0);
// Filters win over an older request even if a transport ignores cancellation.
const older = leads.load();
selectStatus('done');
const newer = leads.load();
assert.equal(pending[1].options.signal.aborted, true);
pending[2].resolve(response('Свежий ответ'));
await newer;
pending[1].resolve(response('УСТАРЕВШИЙ ответ'));
await older;
assert.match($('#ld-body').textContent, /Свежий ответ/);
assert.doesNotMatch($('#ld-body').textContent, /УСТАРЕВШИЙ/);
// Preserve filters, but reset pagination for an explicit refresh.
$('#ld-q').value = 'проверка'; $('#ld-from').value = '2026-10-01'; $('#ld-to').value = '2026-10-23';
leads.offset = 30;
const refresh = $('#ld-refresh').onclick();
const params = new URLSearchParams(pending[3].path.split('?')[1]);
assert.equal(params.get('status'), 'done'); assert.equal(params.get('q'), 'проверка');
assert.equal(params.get('from'), '2026-10-01'); assert.equal(params.get('to'), '2026-10-23');
pending[3].reject(new Error('Тестовая ошибка сервера'));
await refresh;
assert.equal($('#ld-refresh').disabled, false, 'Errors must release the button');
assert.match($('#ld-updated').textContent, /Не удалось/);
assert.match($('#ld-body').textContent, /Свежий ответ/, 'Failure must not erase the previous results');
assert.ok(notifications.some((n) => n.bad && n.message.includes('Тестовая ошибка')));
const retry = $('#ld-refresh').onclick();
pending.at(-1).resolve({ data: [], meta: { total: 0, counts: {} } });
await retry;
assert.match($('#ld-body').textContent, /Заявки не найдены/);
assert.equal($('#ld-refresh').disabled, false);
const timeout = $('#ld-refresh').onclick();
for (const fn of [...timers.values()]) fn();
pending.at(-1).reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
await timeout;
assert.equal($('#ld-refresh').disabled, false, 'Timeout cannot leave refresh stuck');
assert.match($('#ld-updated').textContent, /время ожидания/i);
assert.equal(timers.size, 0);
console.log('PASS: actual refresh handler, loading/success/error/timeout feedback, no-store requests, first page, retained filters and stale-response protection; no live lead changes');