feat: add select-all to moderation queue
This commit is contained in:
@@ -10,7 +10,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
||||
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
|
||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-bulk" data-bulk hidden><strong data-bulk-count>0 выбрано</strong><label>Причина отклонения<textarea data-bulk-reason rows="1" maxlength="1000"></textarea></label><button data-action="primary" type="button" data-bulk-approve>Одобрить выбранные</button><button data-action="danger" type="button" data-bulk-reject>Отклонить выбранные</button></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><label class="queue-select-all"><input type="checkbox" data-bulk-select-all> Выбрать все записи на странице</label><div class="moderation-bulk" data-bulk hidden><strong data-bulk-count>0 выбрано</strong><label>Причина отклонения<textarea data-bulk-reason rows="1" maxlength="1000"></textarea></label><button data-action="primary" type="button" data-bulk-approve>Одобрить выбранные</button><button data-action="danger" type="button" data-bulk-reject>Отклонить выбранные</button></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
||||
<nav data-pages aria-label="Страницы очереди" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
|
||||
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
|
||||
</section>
|
||||
@@ -29,6 +29,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||
const bulk = document.querySelector<HTMLElement>("[data-bulk]");
|
||||
const selectAll = document.querySelector<HTMLInputElement>("[data-bulk-select-all]");
|
||||
let token = "";
|
||||
let offset = 0;
|
||||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -40,13 +41,14 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
||||
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); };
|
||||
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||
const syncBulk = () => { const cards = [...(list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]:checked") ?? [])]; if (bulk) bulk.hidden = cards.length === 0; const count = bulk?.querySelector<HTMLElement>("[data-bulk-count]"); if (count) count.textContent = `${cards.length} выбрано`; };
|
||||
const syncBulk = () => { const inputs = [...(list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]") ?? [])]; const cards = inputs.filter(input => input.checked); if (bulk) bulk.hidden = cards.length === 0; if (selectAll) { selectAll.checked = inputs.length > 0 && cards.length === inputs.length; selectAll.indeterminate = cards.length > 0 && cards.length < inputs.length; } const count = bulk?.querySelector<HTMLElement>("[data-bulk-count]"); if (count) count.textContent = `${cards.length} выбрано`; };
|
||||
const hydrateBulk = () => { list?.querySelectorAll<HTMLElement>("[data-report-id]").forEach(card => { if (card.querySelector("[data-bulk-select]")) return; const label = document.createElement("label"); label.className = "moderation-select"; label.innerHTML = `<input type="checkbox" data-bulk-select aria-label="Выбрать улов ${card.querySelector("h2")?.textContent ?? ""}">`; card.prepend(label); }); syncBulk(); };
|
||||
window.setInterval(hydrateBulk, 1000);
|
||||
async function runBulk(statusValue: "approved" | "rejected") { const selected = [...(list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]:checked") ?? [])].map(input => input.closest<HTMLElement>("[data-report-id]")); const reason = bulk?.querySelector<HTMLTextAreaElement>("[data-bulk-reason]")?.value.trim() || null; if (!selected.length) return; if (statusValue === "rejected" && !reason) { fail("Укажите причину отклонения."); return; } const buttons = bulk?.querySelectorAll<HTMLButtonElement>("button") ?? []; buttons.forEach(button => button.disabled = true); try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/catch-reports/bulk`, {method:"POST",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({status:statusValue,reason,items:selected.map(card => ({id:card?.dataset.reportId,expected_version:Number(card?.dataset.version)}))})}); if (response.status === 409) { await loadQueue(); throw new Error("Часть выбранных записей уже изменена. Очередь обновлена."); } if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось применить пакетное решение.")); keepSession(); await loadQueue(); succeed(`Обработано записей: ${selected.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного решения."); } finally { buttons.forEach(button => button.disabled = false); syncBulk(); } }
|
||||
bulk?.querySelector<HTMLButtonElement>("[data-bulk-approve]")?.addEventListener("click", () => runBulk("approved"));
|
||||
bulk?.querySelector<HTMLButtonElement>("[data-bulk-reject]")?.addEventListener("click", () => runBulk("rejected"));
|
||||
list?.addEventListener("change", event => { if ((event.target as HTMLElement).matches("[data-bulk-select]")) syncBulk(); });
|
||||
selectAll?.addEventListener("change", () => { list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]").forEach(input => input.checked = selectAll.checked); syncBulk(); });
|
||||
async function loadQueue() {
|
||||
if (!root || !list) return;
|
||||
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
.admin-queue-filters{display:grid;grid-template-columns:1.3fr repeat(3,1fr) auto;gap:10px;align-items:end;margin:0 0 18px;padding:16px;border-radius:12px;background:var(--deep)}.admin-queue-filters[hidden]{display:none}.admin-queue-filters label{color:#b8c5c2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.admin-queue-filters input,.admin-queue-filters select{display:block;width:100%;height:42px;margin-top:6px;padding:0 10px;border:1px solid #ffffff29;border-radius:8px;background:#ffffff0c;color:#fff}.admin-queue-filters option{color:var(--ink)}.admin-queue-filters button{height:42px}
|
||||
.admin-shortcuts{margin:14px 0;color:var(--text-muted);font-size:12px}.admin-shortcuts kbd{padding:2px 6px;border:1px solid var(--line);border-bottom-width:2px;border-radius:5px;background:var(--surface);font:700 11px Inter,sans-serif}.moderation-card:focus-within{outline:2px solid var(--focus);outline-offset:3px}
|
||||
.moderation-provenance{margin-top:18px;border-top:1px solid var(--border-soft);padding-top:14px}.moderation-provenance summary{cursor:pointer;color:var(--deep);font-weight:700}.moderation-provenance dl{margin-top:14px}.moderation-provenance p{margin:12px 0 0;color:var(--text-muted);font-size:12px;line-height:1.5}
|
||||
.queue-select-all{display:inline-flex;align-items:center;gap:8px;margin:0 0 12px;color:var(--text-muted);font-size:13px}.moderation-select{display:block;grid-column:1/-1}.moderation-select input{width:18px;height:18px}.moderation-bulk,[data-external-bulk]{display:flex;align-items:end;flex-wrap:wrap;gap:12px;margin:0 0 18px;padding:14px;border:1px solid var(--border);border-radius:12px;background:var(--surface);color:var(--text)}.moderation-bulk strong,[data-external-bulk] strong{margin-right:auto}.moderation-bulk label,[data-external-bulk] label{min-width:180px;color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em}.moderation-bulk textarea,[data-external-bulk] textarea,.moderation-bulk select,[data-external-bulk] select{display:block;width:100%;margin-top:5px;padding:9px;border:1px solid var(--border);border-radius:7px;background:var(--surface-field);color:var(--text);resize:vertical}.moderation-bulk button,[data-external-bulk] button{min-height:42px;padding:0 15px;border:0;border-radius:8px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-bulk button[data-action="danger"],[data-external-bulk] button[data-action="danger"]{background:var(--danger-soft);color:var(--danger)}
|
||||
@media(max-width:900px){.admin-queue-filters{grid-template-columns:1fr 1fr}}@media(max-width:520px){.admin-queue-filters{grid-template-columns:1fr}}
|
||||
.admin-dashboard{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-dashboard>.privacy{margin:10px 4px 25px}.admin-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.admin-kpis>a,.admin-kpis>article,.admin-dashboard-grid>section{padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.admin-kpis>a{color:inherit;text-decoration:none}.admin-kpis span,.admin-kpis small{display:block;color:var(--text-muted)}.admin-kpis strong{display:block;margin:10px 0;font:400 42px Georgia,serif;color:var(--deep)}.admin-kpis>a:hover{border-color:var(--focus);transform:translateY(-2px)}.admin-dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.admin-dashboard-grid h2{margin:0 0 16px;font:400 25px Georgia,serif}.admin-dashboard-grid ul{list-style:none;margin:0 0 16px;padding:0}.admin-dashboard-grid li{display:flex;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:13px}.admin-dashboard-grid li span{display:flex;align-items:center;gap:8px}.admin-dashboard-grid time{color:var(--text-muted)}
|
||||
@media(max-width:900px){.admin-kpis{grid-template-columns:1fr 1fr}}@media(max-width:720px){.admin-dashboard{width:calc(100% - 28px)}.admin-kpis,.admin-dashboard-grid{grid-template-columns:1fr}}
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
|
||||
.active-filters span, .spot-card, .record-table, .report-form,
|
||||
.periods, .detail-grid aside, .screenshot-retry, .moderation-card,
|
||||
.moderation-bulk, [data-external-bulk],
|
||||
.admin-kpis > a, .admin-kpis > article, .admin-dashboard-grid > section,
|
||||
.source-health-grid article, .state, .load-more, .signal-more {
|
||||
background: var(--surface-elevated);
|
||||
@@ -88,7 +89,8 @@
|
||||
|
||||
.spot-card:hover { background: var(--surface-raised); border-color: var(--border-strong); box-shadow: 0 18px 40px var(--shadow-color); }
|
||||
.report-form input, .report-form textarea, .report-form select,
|
||||
.moderation-actions textarea { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.moderation-actions textarea, .moderation-bulk textarea, .moderation-bulk select,
|
||||
[data-external-bulk] textarea, [data-external-bulk] select { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.record-head, .no-proof { background: var(--surface-raised); }
|
||||
.record-row, .periods > div, .catch-list article, .detail-grid aside li,
|
||||
.bait-line, .spot-stats, .moderation-summary dl div,
|
||||
@@ -190,7 +192,7 @@
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.theme-switcher, .theme-switcher button, .spot-card, .record-table,
|
||||
.report-form, .moderation-card, .catalog-grid > a, .data-passport,
|
||||
.report-form, .moderation-card, .moderation-bulk, [data-external-bulk], .catalog-grid > a, .data-passport,
|
||||
.source-chip, .notice, .state { border: 1px solid CanvasText; }
|
||||
.theme-switcher button[aria-pressed="true"] { background: Highlight; color: HighlightText; }
|
||||
.source-chip i, .health-state i, .quality-chip i, .activity-pill i { forced-color-adjust: none; }
|
||||
|
||||
Reference in New Issue
Block a user