116 lines
19 KiB
Plaintext
116 lines
19 KiB
Plaintext
---
|
||
import Layout from "../../layouts/Layout.astro";
|
||
import AdminNav from "../../components/AdminNav.astro";
|
||
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||
---
|
||
<Layout title="Внешние источники — RF4 Spotter">
|
||
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Staging</span><h1>Внешние<br/><em>наблюдения</em></h1></div><p>Сопоставьте названия с каноническим каталогом. Опубликовать можно только запись с координатами и весом.</p></section>
|
||
<section class="moderation-app" data-api-url={apiUrl}>
|
||
<AdminNav />
|
||
<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">Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.</p>
|
||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||
<form class="admin-queue-filters" hidden>
|
||
<label>Поиск<input name="q" type="search" maxlength="100" placeholder="Рыба или водоём" /></label>
|
||
<label>Источник<select name="source"><option value="">Все источники</option></select></label>
|
||
<label>Полнота<select name="completeness"><option value="all">Все записи</option><option value="complete">Только полные</option><option value="incomplete">Только неполные</option></select></label>
|
||
<label>Порядок<select name="order"><option value="risk">Сначала требуют внимания</option><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label>
|
||
<button data-action="primary" type="submit">Применить</button>
|
||
</form>
|
||
<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-external-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>S</kbd> подсказать · <kbd>M</kbd> сопоставить · <kbd>P</kbd> опубликовать карточку с фокусом</p>
|
||
</section>
|
||
<script>
|
||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||
const list = document.querySelector<HTMLElement>("[data-external-list]");
|
||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||
const status = document.querySelector<HTMLElement>("[data-admin-status]");
|
||
const pages = document.querySelector<HTMLElement>("[data-pages]");
|
||
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
|
||
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
||
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
|
||
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 filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
||
let offset = 0;
|
||
let token = ""; let fishes: Record<string, string>[] = []; let waters: Record<string, string>[] = [];
|
||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем внешние наблюдения</span>`;
|
||
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
|
||
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
||
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 (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
||
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
|
||
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, `Запрос завершился ошибкой ${response.status}.`)); } if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
|
||
async function loadQueue() {
|
||
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||
if (!fishes.length || !waters.length) { const [fishRows, waterRows, sources] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`), json(`${root.dataset.apiUrl}/api/v1/source-status`)]); fishes = fishRows; waters = waterRows; const sourceSelect = filters?.querySelector<HTMLSelectElement>('[name="source"]'); if (sourceSelect) sourceSelect.innerHTML = '<option value="">Все источники</option>' + (sources as Record<string, unknown>[]).map(source => `<option value="${esc(source.source_system)}">${esc(source.name)}</option>`).join(""); }
|
||
pages?.setAttribute("hidden", "");
|
||
const values = new FormData(filters ?? undefined); const params = new URLSearchParams({status:"review",limit:"51",offset:String(offset),completeness:String(values.get("completeness") || "all"),order:String(values.get("order") || "newest")}); const source = String(values.get("source") || ""); const query = String(values.get("q") || "").trim(); if (source) params.set("source_system", source); if (query) params.set("q", query);
|
||
const rows = await json(`${root.dataset.apiUrl}/api/v1/admin/external-observations?${params}`, {headers:{Authorization:`Bearer ${token}`}});
|
||
setLoading(false);
|
||
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
|
||
const pending = rows.slice(0, 50);
|
||
if (pages) pages.hidden = !pending.length;
|
||
if (previous) previous.disabled = offset === 0;
|
||
if (next) next.disabled = rows.length <= 50;
|
||
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
|
||
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `<article class="moderation-card" data-observation-id="${esc(row.id)}" data-version="${esc(row.moderation_version)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl>${sourceUrl ? `<a href="${sourceUrl}" target="_blank" rel="noreferrer">Открыть первоисточник</a>` : '<span class="privacy">Ссылка источника имеет небезопасный формат</span>'}</div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
|
||
const payloadLabels: Record<string, string> = {bait:"Приманка",fishing_method:"Метод ловли",rig_type:"Оснастка",retrieve_method:"Проводка",retrieve_speed:"Скорость проводки",player_name:"Игрок",published_at:"Опубликовано источником",region:"Регион",category:"Категория"};
|
||
const missingLabels: Record<string, string> = {coordinates:"координаты",weight_g:"вес"};
|
||
pending.forEach((row: Record<string, unknown>, index: number) => {
|
||
const card = list.children[index] as HTMLElement | undefined;
|
||
const summary = card?.querySelector<HTMLElement>(".moderation-summary");
|
||
if (!summary) return;
|
||
const payload = Object.entries((row.source_payload as Record<string, unknown>) ?? {});
|
||
const missing = ((row.missing_fields as string[]) ?? []).map(key => missingLabels[key] ?? key);
|
||
const details = document.createElement("details");
|
||
details.className = "moderation-provenance";
|
||
const sourceCheckLabels: Record<string, string> = {available:"доступен",missing:"не найден",temporary_error:"временная ошибка",blocked:"доступ ограничен"};
|
||
details.innerHTML = `<summary>Происхождение и исходные поля</summary><dl><div><dt>Впервые замечено</dt><dd>${esc(new Date(String(row.first_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Последний раз</dt><dd>${esc(new Date(String(row.last_seen_at)).toLocaleString("ru-RU"))}</dd></div><div><dt>Решение модератора</dt><dd>${row.reviewed_at ? esc(new Date(String(row.reviewed_at)).toLocaleString("ru-RU")) : "ещё нет"}</dd></div><div><dt>Проверка источника</dt><dd>${row.source_check_status ? esc(sourceCheckLabels[String(row.source_check_status)] ?? row.source_check_status) : "ещё не выполнялась"}${row.source_checked_at ? ` · ${esc(new Date(String(row.source_checked_at)).toLocaleString("ru-RU"))}` : ""}</dd></div><div><dt>Не хватает</dt><dd>${missing.length ? esc(missing.join(", ")) : "ничего"}</dd></div>${payload.map(([key,value]) => `<div><dt>${esc(payloadLabels[key] ?? key)}</dt><dd>${esc(value)}</dd></div>`).join("")}</dl><p>Показаны только разрешённые поля, сохранённые парсером. Перед публикацией сверьте их с первоисточником.</p>`;
|
||
summary.append(details);
|
||
});
|
||
}
|
||
async function changePage(delta: number) {
|
||
const oldOffset = offset;
|
||
offset = Math.max(0, offset + delta);
|
||
if (previous) previous.disabled = true;
|
||
if (next) next.disabled = true;
|
||
try { await loadQueue(); }
|
||
catch { offset = oldOffset; setLoading(false); if (list) list.innerHTML = ""; fail("Не удалось загрузить страницу. Повторите вход в очередь."); if (login) login.hidden = false; }
|
||
}
|
||
previous?.addEventListener("click", () => changePage(-50));
|
||
next?.addEventListener("click", () => changePage(50));
|
||
refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await loadQueue(); succeed("Очередь обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить очередь."); } finally { refresh.disabled = false; } });
|
||
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await loadQueue(); } catch (cause) { setLoading(false); fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
|
||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true); const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try {
|
||
if (button.hasAttribute("data-suggest")) {
|
||
const suggestion = await json(`${base}/alias-suggestions`, {headers});
|
||
const fish = fishes.find(item => item.slug === suggestion.fish_slug);
|
||
const water = waters.find(item => item.slug === suggestion.waterbody_slug);
|
||
const message = card.querySelector<HTMLElement>("[data-alias-message]");
|
||
if (message) message.textContent = fish || water
|
||
? `Ранее подтверждено: рыба — ${fish?.name_ru ?? "нет соответствия"}, водоём — ${water?.name_ru ?? "нет соответствия"}. Проверьте и выберите значения перед сопоставлением.`
|
||
: "Для этого источника подтверждённых соответствий пока нет.";
|
||
cardButtons.forEach(item => item.disabled = false);
|
||
return;
|
||
}
|
||
let message = "Наблюдение обновлено."; if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector<HTMLSelectElement>('[name="fish"]')?.value; const waterbody_slug = card.querySelector<HTMLSelectElement>('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value || null,expected_version:Number(card.dataset.version)})}); message = "Соответствия сохранены."; } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers,body:JSON.stringify({expected_version:Number(card.dataset.version)})}); message = "Наблюдение опубликовано."; } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason,expected_version:Number(card.dataset.version)})}); message = "Наблюдение отклонено."; } await loadQueue(); succeed(message); list.querySelector<HTMLButtonElement>("button")?.focus(); } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
|
||
document.addEventListener("keydown", event => { const target = event.target as HTMLElement; if (target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-observation-id]"); const shortcuts: Record<string, string> = {s:"[data-suggest]",m:"[data-map]",p:"[data-publish]"}; const button = card?.querySelector<HTMLButtonElement>(shortcuts[event.key.toLowerCase()] ?? "[data-no-shortcut]"); if (button && !button.disabled) { event.preventDefault(); button.click(); } });
|
||
</script>
|
||
</Layout>
|