diff --git a/README.md b/README.md
index 2e6c2b9..913a072 100644
--- a/README.md
+++ b/README.md
@@ -246,7 +246,7 @@ curl -H "Authorization: Bearer change-me-in-production" \
В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Неуспешные попытки API-входа считаются в БД по HMAC-идентификатору адреса и временно блокируются после десяти ошибок за десять минут; успешная авторизация очищает ошибки клиента. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью.
-Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести до применения пагинации. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
+Администратор может одобрить, отклонить или удалить сообщение. Очередь внешних наблюдений фильтруется на сервере по источнику и полноте, ищет рыбу/водоём и сортируется по свежести до применения пагинации. Во время решения вся карточка блокируется; при ошибке введённая причина остаётся на месте, а после успеха интерфейс сообщает результат и переводит фокус к следующей записи. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
## Эксплуатация production
diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro
index 5e1c797..1cf4a6b 100644
--- a/apps/web/src/pages/admin/external-sources.astro
+++ b/apps/web/src/pages/admin/external-sources.astro
@@ -15,7 +15,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
ПорядокСначала новые Сначала старые
Применить
-
+
Предыдущая
@@ -27,6 +27,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const login = document.querySelector(".admin-login");
const list = document.querySelector("[data-external-list]");
const error = document.querySelector("[data-admin-error]");
+ const status = document.querySelector("[data-admin-status]");
const pages = document.querySelector("[data-pages]");
const previous = document.querySelector("[data-previous]");
const next = document.querySelector("[data-next]");
@@ -41,7 +42,8 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
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 (error) { error.textContent = message; error.hidden = false; } };
+ 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[], selected?: unknown) => items.map(item => `${esc(item.name_ru)} `).join("");
@@ -75,7 +77,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
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("button"); const card = button?.closest("[data-observation-id]"); if (!button || !card || !root) return; button.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 {
+ list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest("button"); const card = button?.closest("[data-observation-id]"); if (!button || !card || !root) return; const cardButtons = card.querySelectorAll("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);
@@ -84,9 +86,9 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (message) message.textContent = fish || water
? `Ранее подтверждено: рыба — ${fish?.name_ru ?? "нет соответствия"}, водоём — ${water?.name_ru ?? "нет соответствия"}. Проверьте и выберите значения перед сопоставлением.`
: "Для этого источника подтверждённых соответствий пока нет.";
- button.disabled = false;
+ cardButtons.forEach(item => item.disabled = false);
return;
}
- if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector('[name="fish"]')?.value; const waterbody_slug = card.querySelector('[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('[name="note"]')?.value || null})}); } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); } await loadQueue(); } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
+ let message = "Наблюдение обновлено."; if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector('[name="fish"]')?.value; const waterbody_slug = card.querySelector('[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('[name="note"]')?.value || null})}); message = "Соответствия сохранены."; } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); message = "Наблюдение опубликовано."; } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); message = "Наблюдение отклонено."; } await loadQueue(); succeed(message); list.querySelector("button")?.focus(); } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
diff --git a/apps/web/src/pages/admin/moderation.astro b/apps/web/src/pages/admin/moderation.astro
index 784453f..59c4300 100644
--- a/apps/web/src/pages/admin/moderation.astro
+++ b/apps/web/src/pages/admin/moderation.astro
@@ -8,13 +8,14 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
Токен хранится только в памяти страницы и не записывается в URL или localStorage.
Административная сессия активна Выйти
-
+
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 78f7ba9..36aa6f8 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -64,7 +64,7 @@
- [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage.
- [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений.
-- [ ] **M03 · Эффективность очередей — в работе.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку от новых или старых; параметры работают до пагинации. Далее: приоритет риска, понятное подтверждение результата, восстановление черновика причины, переход к следующей карточке и доступные горячие клавиши.
+- [ ] **M03 · Эффективность очередей — в работе.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку от новых или старых; параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Далее: приоритет риска и доступные горячие клавиши.
- [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных.
- [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором.
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.