feat: extend admin operations and media review

This commit is contained in:
ik
2026-09-16 19:50:01 +07:00
parent 4c75db1f74
commit 9bcb019d3a
13 changed files with 1063 additions and 28 deletions
+59
View File
@@ -29,6 +29,17 @@ def published_assets(entity_type: str | None = None) -> list[dict]:
"image_url": f"/api/v1/media/assets/{item['sha256']}",
"source_system": source,
"source_url": source_page,
"variants": [
{
"role": variant.get("role"),
"format": variant.get("format"),
"width": variant.get("width"),
"height": variant.get("height"),
"url": f"/api/v1/media/assets/{variant['sha256']}",
}
for variant in item.get("derivatives", [])
if variant.get("sha256") and variant.get("local_path")
],
})
return sorted(result, key=lambda item: (str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"]))
@@ -44,3 +55,51 @@ def published_file(digest: str) -> tuple[Path, str] | None:
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
return None
return target, str(item["content_type"])
def review_assets(entity_type: str | None = None, status: str | None = None) -> list[dict]:
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
result = []
for item in manifest.get("assets", []):
item_status = str(item.get("status") or "")
if item_status not in {"approved", "upgrade_queued", "upgrade_stored"} or (status and item_status != status):
continue
if entity_type and item.get("entity_type") != entity_type:
continue
digest = str(item.get("sha256") or "")
if len(digest) != 64 or not item.get("local_path"):
continue
source_page = str(item.get("source_page") or "")
source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official"
result.append({
"id": digest,
"status": item_status,
"entity_type": item.get("entity_type"),
"entity_key": item.get("entity_key"),
"label": item.get("label"),
"width": item.get("width"),
"height": item.get("height"),
"content_type": item.get("content_type"),
"image_url": f"/api/v1/admin/media/assets/{digest}",
"source_system": source,
"source_url": source_page,
"duplicate_of": item.get("duplicate_of"),
"derivatives": [{
"role": variant.get("role"), "format": variant.get("format"),
"width": variant.get("width"), "height": variant.get("height"),
} for variant in item.get("derivatives", [])],
})
return sorted(result, key=lambda item: (str(item["status"]), str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"]))
def review_file(digest: str) -> tuple[Path, str] | None:
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
return None
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
item = next((row for row in manifest.get("assets", []) if row.get("sha256") == digest and row.get("status") in {"approved", "upgrade_queued", "upgrade_stored"}), None)
if not item or not item.get("local_path"):
return None
target = (MEDIA_ROOT / item["local_path"]).resolve()
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
return None
return target, str(item.get("content_type") or "application/octet-stream")
+69 -2
View File
@@ -1,12 +1,12 @@
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal
from uuid import UUID
import httpx
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse
from fastapi.responses import FileResponse, JSONResponse
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import joinedload
@@ -15,6 +15,7 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs
from ..config import settings
from ..dependencies import Db
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
from ..media_catalog import review_assets, review_file
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
from ..public_cache import public_cache
from ..schemas import AdminCatchReportOut, AdminModerationHistoryOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
@@ -29,6 +30,26 @@ def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header
return verify_admin(request, db, authorization, settings)
@router.get("/api/v1/admin/media/catalog")
def admin_media_catalog(
_: Annotated[str, Depends(_admin)],
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> list[dict]:
return review_assets(entity_type, status)[offset:offset + limit]
@router.get("/api/v1/admin/media/assets/{digest}", response_class=FileResponse)
def admin_media_asset(digest: str, _: Annotated[str, Depends(_admin)]) -> FileResponse:
item = review_file(digest)
if not item:
raise HTTPException(status_code=404, detail="Media review asset not found")
path, media_type = item
return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"})
@router.get("/api/v1/admin/diagnostics")
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
report_counts = {status.value: count for status, count in db.execute(
@@ -131,6 +152,52 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O
raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc
@router.get("/api/v1/admin/source-status")
def admin_source_status(db: Db, _: Annotated[str, Depends(_admin)]) -> list[dict[str, object]]:
"""Return safe operational details needed by the owner dashboard."""
now = datetime.now(timezone.utc)
result: list[dict[str, object]] = []
for source in db.scalars(select(DataSource).order_by(DataSource.name)):
runs = list(db.scalars(
select(CommunityImportRun)
.where(CommunityImportRun.source_system == source.key)
.order_by(CommunityImportRun.started_at.desc()).limit(20)
))
latest = runs[0] if runs else None
success = next((run for run in runs if run.status == "success"), None)
recent_failures = sum(
1 for run in runs
if run.status == "failed" and aware(run.started_at) >= now - timedelta(hours=24)
)
next_allowed = (
aware(latest.started_at) + timedelta(seconds=settings.community_import_interval_seconds)
if latest else None
)
cooldown_seconds = max(0, int((next_allowed - now).total_seconds())) if next_allowed else 0
if not source.enabled:
state = "disabled"
elif latest is None:
state = "waiting"
elif latest.status == "failed":
state = "source_changed" if "CommunityParseError" in (latest.error_summary or "") else "temporarily_limited"
elif aware(latest.started_at) < now - timedelta(seconds=settings.community_import_interval_seconds * 2):
state = "stale"
else:
state = "healthy"
result.append({
"source_system": source.key,
"name": source.name,
"status": state,
"last_started_at": latest.started_at if latest else None,
"last_success_at": success.started_at if success else None,
"next_allowed_at": next_allowed,
"cooldown_seconds": cooldown_seconds,
"recent_failures_24h": recent_failures,
"backoff_recommended": recent_failures >= 5,
})
return result
def _external_out(item: ExternalObservation) -> ExternalObservationOut:
allowed_payload = {
key: value for key, value in (item.payload or {}).items()
+370 -3
View File
@@ -30,6 +30,17 @@
"title": "Confidence Score",
"type": "integer"
},
"coordinate_precision": {
"title": "Coordinate Precision",
"type": "string"
},
"coordinate_sources": {
"items": {
"type": "string"
},
"title": "Coordinate Sources",
"type": "array"
},
"explanation": {
"title": "Explanation",
"type": "string"
@@ -101,7 +112,9 @@
"activity_score",
"confidence_score",
"explanation",
"sources"
"sources",
"coordinate_precision",
"coordinate_sources"
],
"title": "ActivityOut",
"type": "object"
@@ -1610,6 +1623,17 @@
"title": "Catches 7D",
"type": "integer"
},
"coordinate_precision": {
"title": "Coordinate Precision",
"type": "string"
},
"coordinate_sources": {
"items": {
"type": "string"
},
"title": "Coordinate Sources",
"type": "array"
},
"description": {
"anyOf": [
{
@@ -1660,7 +1684,9 @@
"catches_24h",
"catches_3d",
"catches_7d",
"top_baits"
"top_baits",
"coordinate_precision",
"coordinate_sources"
],
"title": "SpotOut",
"type": "object"
@@ -1700,6 +1726,28 @@
},
"WaterbodyOut": {
"properties": {
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description"
},
"fish_species_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Fish Species Count"
},
"id": {
"format": "uuid",
"title": "Id",
@@ -1713,6 +1761,107 @@
"title": "Slug",
"type": "string"
},
"source_aliases": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Source Aliases"
},
"source_checked_at": {
"anyOf": [
{
"format": "date-time",
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Checked At"
},
"source_external_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source External Id"
},
"source_fish_species": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Source Fish Species"
},
"source_image_urls": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Source Image Urls"
},
"source_point_urls": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Source Point Urls"
},
"source_system": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source System"
},
"source_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Source Url"
},
"unlock_level": {
"anyOf": [
{
@@ -1729,7 +1878,17 @@
"id",
"slug",
"name_ru",
"unlock_level"
"unlock_level",
"fish_species_count",
"source_system",
"source_external_id",
"source_url",
"description",
"source_aliases",
"source_fish_species",
"source_image_urls",
"source_point_urls",
"source_checked_at"
],
"title": "WaterbodyOut",
"type": "object"
@@ -2651,6 +2810,162 @@
"summary": "Admin Start Official Import"
}
},
"/api/v1/admin/media/assets/{digest}": {
"get": {
"operationId": "admin_media_asset_api_v1_admin_media_assets__digest__get",
"parameters": [
{
"in": "path",
"name": "digest",
"required": true,
"schema": {
"title": "Digest",
"type": "string"
}
},
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Media Asset"
}
},
"/api/v1/admin/media/catalog": {
"get": {
"operationId": "admin_media_catalog_api_v1_admin_media_catalog_get",
"parameters": [
{
"in": "query",
"name": "entity_type",
"required": false,
"schema": {
"anyOf": [
{
"pattern": "^(fish|waterbody|tackle|reference)$",
"type": "string"
},
{
"type": "null"
}
],
"title": "Entity Type"
}
},
{
"in": "query",
"name": "status",
"required": false,
"schema": {
"anyOf": [
{
"pattern": "^(approved|upgrade_queued|upgrade_stored)$",
"type": "string"
},
{
"type": "null"
}
],
"title": "Status"
}
},
{
"in": "query",
"name": "limit",
"required": false,
"schema": {
"default": 50,
"maximum": 100,
"minimum": 1,
"title": "Limit",
"type": "integer"
}
},
{
"in": "query",
"name": "offset",
"required": false,
"schema": {
"default": 0,
"minimum": 0,
"title": "Offset",
"type": "integer"
}
},
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"additionalProperties": true,
"type": "object"
},
"title": "Response Admin Media Catalog Api V1 Admin Media Catalog Get",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Media Catalog"
}
},
"/api/v1/admin/moderation-history": {
"get": {
"operationId": "admin_moderation_history_api_v1_admin_moderation_history_get",
@@ -2781,6 +3096,58 @@
"summary": "Admin Moderation History Export"
}
},
"/api/v1/admin/source-status": {
"get": {
"description": "Return safe operational details needed by the owner dashboard.",
"operationId": "admin_source_status_api_v1_admin_source_status_get",
"parameters": [
{
"in": "header",
"name": "authorization",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"additionalProperties": true,
"type": "object"
},
"title": "Response Admin Source Status Api V1 Admin Source Status Get",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Admin Source Status"
}
},
"/api/v1/baits": {
"get": {
"operationId": "baits_api_v1_baits_get",
+29
View File
@@ -56,6 +56,17 @@ def test_activity_filters_and_explains_score() -> None:
assert payload["items"][0]["sources"] == ["manual-import"]
def test_waterbody_catalog_exposes_nullable_source_provenance() -> None:
response = client.get("/api/v1/waterbodies")
assert response.status_code == 200
item = next(row for row in response.json() if row["slug"] == "test-lake")
assert item["source_system"] is None
assert item["source_external_id"] is None
assert item["source_url"] is None
assert item["description"] is None
assert item["source_checked_at"] is None
def test_invalid_period_is_rejected() -> None:
assert client.get("/api/v1/activity?hours=13").status_code == 422
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
@@ -151,6 +162,24 @@ def test_public_source_status_hides_internal_details() -> None:
assert all("error_summary" not in item and "source_url" not in item for item in response.json())
def test_admin_source_status_requires_auth_and_exposes_safe_cooldown_fields() -> None:
assert client.get("/api/v1/admin/source-status").status_code == 401
response = client.get("/api/v1/admin/source-status", headers={"Authorization": "Bearer change-me-in-production"})
assert response.status_code == 200
assert response.json()
assert all({"status", "cooldown_seconds", "recent_failures_24h", "backoff_recommended"} <= set(item) for item in response.json())
assert all("error_summary" not in item and "base_url" not in item for item in response.json())
def test_admin_media_review_requires_auth() -> None:
assert client.get("/api/v1/admin/media/catalog").status_code == 401
response = client.get("/api/v1/admin/media/catalog?status=approved&limit=2", headers={"Authorization": "Bearer change-me-in-production"})
assert response.status_code == 200
assert len(response.json()) <= 2
if response.json():
assert {"status", "width", "height", "source_system", "source_url", "derivatives"} <= set(response.json()[0])
def test_liveness_does_not_probe_dependencies() -> None:
response = client.get("/health?token=must-not-be-logged")
assert response.json() == {"status": "ok"}
+2 -1
View File
@@ -11,6 +11,7 @@ import "../styles/tackle-glyph.css";
import "../styles/empty-states.css";
import "../styles/alpha-banner.css";
import "../styles/signal-pagination.css";
import "../styles/pagination.css";
import "../styles/dashboard-polish.css";
import "../styles/loading-states.css";
import "../styles/theme.css";
@@ -31,7 +32,7 @@ const theme = storedTheme === "light" || storedTheme === "dark" ? storedTheme :
const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru";
const canonical = new URL(path, siteUrl).toString();
const socialImage = new URL(image, siteUrl).toString();
const preventIndexing = noindex || path.startsWith("/admin/");
const preventIndexing = noindex || path === "/admin" || path.startsWith("/admin/");
const websiteJsonLd = { "@type": "WebSite", name: "RF4 Spotter", url: siteUrl, inLanguage: "ru" };
// A08: Skip structuredData on error pages (explicit errorPage prop)
// Don't infer error from noindex alone — main page can have noindex on 422
@@ -19,7 +19,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-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>
@@ -32,6 +32,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
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]");
@@ -48,7 +49,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 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 === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (response.status === 409) { await loadQueue(); 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 === 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 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(""); }
@@ -89,6 +90,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
}
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("Вы вышли из административной панели."));
+12 -9
View File
@@ -9,6 +9,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<p class="privacy">Токен существует только в памяти вкладки. Сессия завершится после 15 минут бездействия.</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>
<section class="admin-dashboard-content" aria-live="polite"></section>
</main>
<script>
@@ -16,37 +17,39 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const login = document.querySelector<HTMLFormElement>(".admin-login");
const content = document.querySelector<HTMLElement>(".admin-dashboard-content");
const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
let token = "";
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const fail = (message: string) => { 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 (content) content.innerHTML = ""; if (message) fail(message); };
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 publicJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`); return response.ok ? response.json() : []; }
async function loadDashboard() {
if (!content) return;
error?.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>';
const diagnostics = await authorizedJson("/api/v1/admin/diagnostics");
const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), publicJson("/api/v1/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]);
const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), authorizedJson("/api/v1/admin/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]);
const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {};
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}</span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
const importRows = (imports as Record<string, unknown>[]).map(run => `<li><span>${esc(run.status)}</span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}</time></li>`).join("");
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; const cooldown = Number(source.cooldown_seconds ?? 0); const detail = cooldown > 0 ? ` · cooldown ${Math.ceil(cooldown / 60)} мин` : source.backoff_recommended ? " · backoff рекомендован" : ""; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}<small>${esc(detail)}</small></span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
const importLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
const importRows = (imports as Record<string, unknown>[]).map(run => { const status = String(run.status); const rows = Number(run.rows_seen ?? 0); const result = rows ? ` · ${rows} строк` : ""; return `<li><span>${esc(importLabels[status] ?? status)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
const actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"};
const typeLabels: Record<string, string> = {catch_report:"Улов",external_observation:"Внешнее наблюдение"};
const historyRows = (history as Record<string, unknown>[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `<li><span><b>${esc(typeLabels[type] ?? "Запись")}</b> · ${esc(actionLabels[action] ?? action)}${event.reason ? `<small>${esc(event.reason)}</small>` : ""}</span><time>${esc(new Date(String(event.decided_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><h2>Состояние источников</h2><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a></section><section><h2>Последние импорты</h2><ul>${importRows || "<li>Запусков пока нет</li>"}</ul></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><div class="admin-section-head"><h2>Состояние источников</h2><button type="button" data-action="secondary" data-refresh>Обновить</button></div><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a><br /><a href="/admin/media">Проверить медиа →</a></section><section><div class="admin-section-head"><h2>Последние импорты</h2><button type="button" data-action="secondary" data-official-import>Запустить импорт</button></div><ul>${importRows || "<li>Запусков пока нет</li>"}</ul><p class="privacy">Импорт обращается к официальному источнику и соблюдает cooldown.</p></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
}
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
content?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export]"); 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;
try { 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(); }
catch { fail("Не удалось выгрузить журнал решений."); }
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(); } }
catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
finally { button.disabled = false; }
});
</script>
+57
View File
@@ -0,0 +1,57 @@
---
import Layout from "../../layouts/Layout.astro";
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
---
<Layout title="Проверка медиа — RF4 Spotter" noindex>
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Media review</span><h1>Проверка<br/><em>медиа</em></h1></div><p>Сравнение approved и upgrade_queued файлов перед отдельным редакционным решением.</p></section>
<main class="moderation-app" data-api-url={apiUrl}>
<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>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</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><section class="media-library__grid" data-media-list aria-live="polite"></section>
<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>
<script>
const root = document.querySelector<HTMLElement>("main[data-api-url]");
const login = document.querySelector<HTMLFormElement>(".admin-login");
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
const list = document.querySelector<HTMLElement>("[data-media-list]");
const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
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]");
let token = ""; let offset = 0; let timer: ReturnType<typeof setTimeout> | undefined;
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const url = (value: unknown) => { try { const parsed = new URL(String(value), root?.dataset.apiUrl); return parsed.protocol === "http:" || parsed.protocol === "https:" ? esc(parsed.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 (timer) clearTimeout(timer); timer = undefined; if (login) { login.hidden = false; login.reset(); } if (filters) filters.hidden = true; if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
const keepSession = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const loading = () => '<div class="loading-card" aria-hidden="true"></div><span class="sr-only">Загружаем медиа</span>';
async function load() {
if (!root || !list || !filters) return;
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 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.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");
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}`;
if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; }
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}</article>`; }).join("");
}
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
const move = async (delta: number) => { offset = Math.max(0, offset + delta); try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки страницы."); } };
previous?.addEventListener("click", () => move(-50)); next?.addEventListener("click", () => move(50)); refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await load(); succeed("Медиатека обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить медиатеку."); } finally { refresh.disabled = false; } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
</script>
</Layout>
+22 -4
View File
@@ -9,6 +9,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<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>
<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>
<script>
@@ -17,9 +18,15 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const list = document.querySelector<HTMLElement>("[data-moderation-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]");
let token = "";
let offset = 0;
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));
@@ -32,16 +39,27 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
async function loadQueue() {
if (!root || !list) return;
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending`, {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.status === 429) { endSession(); throw new Error("Слишком много попыток. Повторите позже."); }
if (!response.ok) throw new Error("Не удалось загрузить очередь.");
const reports: Record<string, unknown>[] = await response.json();
const rows: Record<string, unknown>[] = await response.json();
keepSession();
setLoading(false);
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
const reports = rows.slice(0, 50);
if (pages) pages.hidden = !reports.length;
if (previous) previous.disabled = offset === 0;
if (next) next.disabled = rows.length <= 50;
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
list.innerHTML = reports.map(report => { const screenshotUrl = safeHttpUrl(report.screenshot_url); return `<article class="moderation-card" data-report-id="${esc(report.id)}" data-version="${esc(report.moderation_version)}"><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">${screenshotUrl ? `<a href="${screenshotUrl}" target="_blank" rel="noreferrer"><img src="${screenshotUrl}" 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; if (sessionBar) sessionBar.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
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); fail("Не удалось загрузить страницу очереди."); } }
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; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
list?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
@@ -49,7 +67,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.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
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.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 === 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 : "Ошибка сохранения."); }
});
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>