refactor: unify action hierarchy

This commit is contained in:
ik
2026-09-12 10:05:49 +07:00
parent 2477543029
commit 0c25cf020f
11 changed files with 22 additions and 17 deletions
@@ -5,13 +5,13 @@ 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}>
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button type="submit">Открыть очередь</button></form>
<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="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
<nav data-pages aria-label="Страницы очереди" hidden>
<button type="button" data-previous>Предыдущая</button>
<button data-action="secondary" type="button" data-previous>Предыдущая</button>
<span data-page-number aria-live="polite"></span>
<button type="button" data-next>Следующая</button>
<button data-action="secondary" type="button" data-next>Следующая</button>
</nav>
</section>
<script>
@@ -44,7 +44,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
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; return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><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><a href="${esc(row.source_url)}" target="_blank" rel="noreferrer">Открыть первоисточник</a></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 type="button" data-suggest>Подсказать соответствия</button><button type="button" data-map>Сопоставить</button><button type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button class="reject" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><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><a href="${esc(row.source_url)}" target="_blank" rel="noreferrer">Открыть первоисточник</a></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("");
}
async function changePage(delta: number) {
const oldOffset = offset;
+2 -2
View File
@@ -5,7 +5,7 @@ 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> Очередь проверки</span><h1>Модерация<br/><em>уловов</em></h1></div><p>Проверьте данные и скриншот до того, как запись повлияет на публичную статистику.</p></section>
<section class="moderation-app" data-api-url={apiUrl}>
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button type="submit">Открыть очередь</button></form>
<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="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
</section>
@@ -27,7 +27,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const reports: Record<string, unknown>[] = await response.json();
setLoading(false);
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
list.innerHTML = reports.map(report => `<article class="moderation-card" data-report-id="${esc(report.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${report.screenshot_url ? `<a href="${esc(report.screenshot_url)}" target="_blank" rel="noreferrer"><img src="${esc(report.screenshot_url)}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button type="button" data-decision="approved">Одобрить</button><button class="reject" type="button" data-decision="rejected">Отклонить</button><button class="delete" type="button" data-delete>Удалить</button></div></div></article>`).join("");
list.innerHTML = reports.map(report => `<article class="moderation-card" data-report-id="${esc(report.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${report.screenshot_url ? `<a href="${esc(report.screenshot_url)}" target="_blank" rel="noreferrer"><img src="${esc(report.screenshot_url)}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button data-action="primary" type="button" data-decision="approved">Одобрить</button><button data-action="danger" type="button" data-decision="rejected">Отклонить</button><button data-action="quiet-danger" type="button" data-delete>Удалить</button></div></div></article>`).join("");
}
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
list?.addEventListener("click", async event => {