feat: add bulk moderation decisions

This commit is contained in:
ik
2026-09-21 20:25:17 +07:00
parent 2fc4b88a80
commit e74929e7f7
3 changed files with 53 additions and 2 deletions
+9 -1
View File
@@ -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-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><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>
@@ -28,6 +28,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
const bulk = document.querySelector<HTMLElement>("[data-bulk]");
let token = "";
let offset = 0;
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
@@ -39,6 +40,13 @@ 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 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(); });
async function loadQueue() {
if (!root || !list) return;
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();