fix: unify admin error handling
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
export function adminErrorMessage(status: number, fallback: string): string {
|
||||||
|
if (status === 401) return "Неверный или истёкший административный токен.";
|
||||||
|
if (status === 409) return "Операция конфликтует с изменением в другой вкладке.";
|
||||||
|
if (status === 429) return "Слишком много попыток. Повторите позже.";
|
||||||
|
if (status >= 500) return "Сервис временно недоступен. Проверьте состояние и повторите позже.";
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminEndsSession(status: number): boolean {
|
||||||
|
return status === 401 || status === 429;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
<p class="admin-shortcuts"><kbd>S</kbd> подсказать · <kbd>M</kbd> сопоставить · <kbd>P</kbd> опубликовать карточку с фокусом</p>
|
<p class="admin-shortcuts"><kbd>S</kbd> подсказать · <kbd>M</kbd> сопоставить · <kbd>P</kbd> опубликовать карточку с фокусом</p>
|
||||||
</section>
|
</section>
|
||||||
<script>
|
<script>
|
||||||
|
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||||
const root = document.querySelector<HTMLElement>(".moderation-app");
|
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||||
const list = document.querySelector<HTMLElement>("[data-external-list]");
|
const list = document.querySelector<HTMLElement>("[data-external-list]");
|
||||||
@@ -49,7 +50,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
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 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 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("");
|
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 === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (response.status === 429) { endSession(); throw new Error("Слишком много попыток. Повторите позже."); } if (!response.ok) throw new Error(`Запрос завершился ошибкой ${response.status}.`); if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
|
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() {
|
async function loadQueue() {
|
||||||
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
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(""); }
|
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(""); }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
<section class="admin-dashboard-content" aria-live="polite"></section>
|
<section class="admin-dashboard-content" aria-live="polite"></section>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
|
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||||
const root = document.querySelector<HTMLElement>(".admin-dashboard");
|
const root = document.querySelector<HTMLElement>(".admin-dashboard");
|
||||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||||
const content = document.querySelector<HTMLElement>(".admin-dashboard-content");
|
const content = document.querySelector<HTMLElement>(".admin-dashboard-content");
|
||||||
@@ -27,7 +28,7 @@ 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 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 (content) content.innerHTML = ""; if (message) fail(message); };
|
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); };
|
||||||
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||||
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (response.status === 401 || response.status === 429) { endSession(); throw new Error(response.status === 429 ? "Слишком много попыток входа. Повторите позже." : "Неверный или истёкший административный токен."); } if (!response.ok) throw new Error("Не удалось загрузить административные данные."); keepSession(); return response.json(); }
|
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить административные данные.")); } keepSession(); return response.json(); }
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
error?.setAttribute("hidden", ""); status?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
error?.setAttribute("hidden", ""); status?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
||||||
@@ -48,7 +49,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
content?.addEventListener("click", async event => {
|
content?.addEventListener("click", async event => {
|
||||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
|
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try { if (button.hasAttribute("data-refresh")) { await loadDashboard(); succeed("Данные обновлены."); } else if (button.hasAttribute("data-official-import")) { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/imports/official-records`, {method:"POST",headers:{Authorization:`Bearer ${token}`}}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (response.status === 409) throw new Error("Импорт уже выполняется."); if (response.status === 429) { endSession(); throw new Error("Слишком много попыток. Повторите позже."); } if (response.status === 502) throw new Error("Официальный источник временно недоступен. Старые данные сохранены."); if (!response.ok) throw new Error("Не удалось запустить импорт."); keepSession(); await loadDashboard(); succeed("Импорт запущен. Список запусков обновлён."); } else { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/moderation-history-export`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) throw new Error(); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "rf4spotter-moderation-history.json"; link.click(); URL.revokeObjectURL(link.href); keepSession(); } }
|
try { if (button.hasAttribute("data-refresh")) { await loadDashboard(); succeed("Данные обновлены."); } else if (button.hasAttribute("data-official-import")) { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/imports/official-records`, {method:"POST",headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(response.status === 502 ? "Официальный источник временно недоступен. Старые данные сохранены." : adminErrorMessage(response.status, "Не удалось запустить импорт.")); } keepSession(); await loadDashboard(); succeed("Импорт запущен. Список запусков обновлён."); } else { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/moderation-history-export`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось выгрузить журнал решений.")); } const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "rf4spotter-moderation-history.json"; link.click(); URL.revokeObjectURL(link.href); keepSession(); } }
|
||||||
catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
|
catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
|
||||||
finally { button.disabled = false; }
|
finally { button.disabled = false; }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
<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>
|
<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>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
|
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||||
const root = document.querySelector<HTMLElement>("main[data-api-url]");
|
const root = document.querySelector<HTMLElement>("main[data-api-url]");
|
||||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||||
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
||||||
@@ -39,9 +40,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
error?.setAttribute("hidden", ""); list.innerHTML = loading(); list.setAttribute("aria-busy", "true");
|
error?.setAttribute("hidden", ""); list.innerHTML = loading(); list.setAttribute("aria-busy", "true");
|
||||||
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["entity_type", "status"]) { const value = String(values.get(key) || ""); if (value) params.set(key, value); }
|
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["entity_type", "status"]) { const value = String(values.get(key) || ""); if (value) params.set(key, value); }
|
||||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/media/catalog?${params}`, {headers:{Authorization:`Bearer ${token}`} });
|
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/media/catalog?${params}`, {headers:{Authorization:`Bearer ${token}`} });
|
||||||
if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); }
|
if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить медиатеку.")); }
|
||||||
if (response.status === 429) { endSession(); throw new Error("Слишком много попыток. Повторите позже."); }
|
|
||||||
if (!response.ok) throw new Error("Не удалось загрузить медиатеку.");
|
|
||||||
const rows: Record<string, unknown>[] = await response.json(); keepSession(); list.removeAttribute("aria-busy");
|
const rows: Record<string, unknown>[] = await response.json(); keepSession(); list.removeAttribute("aria-busy");
|
||||||
if (!rows.length && offset > 0) { offset = 0; return load(); }
|
if (!rows.length && offset > 0) { offset = 0; return load(); }
|
||||||
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
|
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
|
||||||
</section>
|
</section>
|
||||||
<script>
|
<script>
|
||||||
|
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||||
const root = document.querySelector<HTMLElement>(".moderation-app");
|
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||||
const list = document.querySelector<HTMLElement>("[data-moderation-list]");
|
const list = document.querySelector<HTMLElement>("[data-moderation-list]");
|
||||||
@@ -40,9 +41,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
if (!root || !list) return;
|
if (!root || !list) return;
|
||||||
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending&limit=51&offset=${offset}`, {headers:{Authorization:`Bearer ${token}`} });
|
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending&limit=51&offset=${offset}`, {headers:{Authorization:`Bearer ${token}`} });
|
||||||
if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); }
|
if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить очередь.")); }
|
||||||
if (response.status === 429) { endSession(); throw new Error("Слишком много попыток. Повторите позже."); }
|
|
||||||
if (!response.ok) throw new Error("Не удалось загрузить очередь.");
|
|
||||||
const rows: Record<string, unknown>[] = await response.json();
|
const rows: Record<string, unknown>[] = await response.json();
|
||||||
keepSession();
|
keepSession();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -67,7 +66,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
|
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
|
||||||
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
|
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
|
||||||
const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true);
|
const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true);
|
||||||
try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}${deleting ? `?expected_version=${card.dataset.version}` : ""}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason,expected_version:Number(card.dataset.version)})}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (response.status === 429) { endSession(); throw new Error("Слишком много попыток. Повторите позже."); } if (!response.ok) throw new Error("Не удалось сохранить решение."); keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
|
try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}${deleting ? `?expected_version=${card.dataset.version}` : ""}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason,expected_version:Number(card.dataset.version)})}); if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось сохранить решение.")); } keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } 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 (event.key.toLowerCase() !== "a" || target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-report-id]"); const approve = card?.querySelector<HTMLButtonElement>('[data-decision="approved"]'); if (approve && !approve.disabled) { event.preventDefault(); approve.click(); } });
|
document.addEventListener("keydown", event => { const target = event.target as HTMLElement; if (event.key.toLowerCase() !== "a" || target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-report-id]"); const approve = card?.querySelector<HTMLButtonElement>('[data-decision="approved"]'); if (approve && !approve.disabled) { event.preventDefault(); approve.click(); } });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { activityLevel, plural } from "../../src/lib/presentation.ts";
|
|||||||
import { fishVisualFamily } from "../../src/lib/fish-visuals.ts";
|
import { fishVisualFamily } from "../../src/lib/fish-visuals.ts";
|
||||||
import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts";
|
import { tackleVisualKind, tackleVisualTone } from "../../src/lib/tackle-visuals.ts";
|
||||||
import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts";
|
import { waterbodyVisual } from "../../src/lib/waterbody-visuals.ts";
|
||||||
|
import { pageHref, pageWindow } from "../../src/lib/pagination.ts";
|
||||||
|
import { adminEndsSession, adminErrorMessage } from "../../src/lib/admin-errors.ts";
|
||||||
|
|
||||||
test("activity levels share one complete 0-100 scale", () => {
|
test("activity levels share one complete 0-100 scale", () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -46,3 +48,31 @@ test("waterbody fingerprints are stable, bounded and entity-specific", () => {
|
|||||||
assert.ok(kuori.markerY >= 18 && kuori.markerY <= 40);
|
assert.ok(kuori.markerY >= 18 && kuori.markerY <= 40);
|
||||||
assert.match(kuori.code, /^[0-9A-Z]{2}$/);
|
assert.match(kuori.code, /^[0-9A-Z]{2}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("45 records are reachable across three stable pages", () => {
|
||||||
|
const pages = [pageWindow(45, 20, 0), pageWindow(45, 20, 20), pageWindow(45, 20, 40)];
|
||||||
|
assert.deepEqual(pages.map(page => [page.start, page.end]), [[1, 20], [21, 40], [41, 45]]);
|
||||||
|
assert.deepEqual(pages.map(page => [page.previousOffset, page.nextOffset]), [[null, 20], [0, 40], [20, null]]);
|
||||||
|
assert.deepEqual(pages.map(page => page.page), [1, 2, 3]);
|
||||||
|
const reached = pages.flatMap(page => Array.from({ length: page.end - page.start + 1 }, (_, index) => page.start + index));
|
||||||
|
assert.deepEqual(reached, Array.from({ length: 45 }, (_, index) => index + 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pagination links preserve filters and replace offset", () => {
|
||||||
|
const params = new URLSearchParams("fish=pike&waterbody=kuori&offset=20&offset=999");
|
||||||
|
const next = pageHref("/records", params, 40);
|
||||||
|
const first = pageHref("/records", params, 0, "#results");
|
||||||
|
assert.equal(next, "/records?fish=pike&waterbody=kuori&offset=40");
|
||||||
|
assert.equal(first, "/records?fish=pike&waterbody=kuori#results");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin errors keep auth and operational responses consistent", () => {
|
||||||
|
assert.equal(adminErrorMessage(401, "fallback"), "Неверный или истёкший административный токен.");
|
||||||
|
assert.equal(adminErrorMessage(409, "fallback"), "Операция конфликтует с изменением в другой вкладке.");
|
||||||
|
assert.equal(adminErrorMessage(429, "fallback"), "Слишком много попыток. Повторите позже.");
|
||||||
|
assert.equal(adminErrorMessage(503, "fallback"), "Сервис временно недоступен. Проверьте состояние и повторите позже.");
|
||||||
|
assert.equal(adminErrorMessage(422, "fallback"), "fallback");
|
||||||
|
assert.equal(adminEndsSession(401), true);
|
||||||
|
assert.equal(adminEndsSession(429), true);
|
||||||
|
assert.equal(adminEndsSession(409), false);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user