sync
This commit is contained in:
@@ -25,9 +25,21 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
const rows = await json(`${root.dataset.apiUrl}/api/v1/admin/external-observations?limit=200`, {headers:{Authorization:`Bearer ${token}`}});
|
||||
const pending = rows.filter((row: Record<string, unknown>) => !["published", "rejected"].includes(String(row.status)));
|
||||
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
|
||||
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl><a href="${esc(row.source_url)}" target="_blank" rel="noreferrer">Открыть первоисточник</a></div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><div><button type="button" data-map>Сопоставить</button><button type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button class="reject" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
|
||||
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl><a href="${esc(row.source_url)}" target="_blank" rel="noreferrer">Открыть первоисточник</a></div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button type="button" data-suggest>Подсказать соответствия</button><button type="button" data-map>Сопоставить</button><button type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button class="reject" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
|
||||
}
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; 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 { if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector<HTMLSelectElement>('[name="fish"]')?.value; const waterbody_slug = card.querySelector<HTMLSelectElement>('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value || null})}); } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); } await loadQueue(); } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
|
||||
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; 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 {
|
||||
if (button.hasAttribute("data-suggest")) {
|
||||
const suggestion = await json(`${base}/alias-suggestions`, {headers});
|
||||
const fish = fishes.find(item => item.slug === suggestion.fish_slug);
|
||||
const water = waters.find(item => item.slug === suggestion.waterbody_slug);
|
||||
const message = card.querySelector<HTMLElement>("[data-alias-message]");
|
||||
if (message) message.textContent = fish || water
|
||||
? `Ранее подтверждено: рыба — ${fish?.name_ru ?? "нет соответствия"}, водоём — ${water?.name_ru ?? "нет соответствия"}. Проверьте и выберите значения перед сопоставлением.`
|
||||
: "Для этого источника подтверждённых соответствий пока нет.";
|
||||
button.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector<HTMLSelectElement>('[name="fish"]')?.value; const waterbody_slug = card.querySelector<HTMLSelectElement>('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value || null})}); } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); } await loadQueue(); } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { api, spotPath, type Activity, type DictionaryItem } from "../lib/api";
|
||||
import { api, type DictionaryItem } from "../lib/api";
|
||||
|
||||
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[character] ?? character);
|
||||
const escapeXml = (value: string) => value.replace(/[<>&'"]/g, c => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[c] ?? c);
|
||||
let lastGood: { origin: string; xml: string; at: number } | undefined;
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru";
|
||||
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy"]);
|
||||
const origin = site?.origin ?? "https://rf4spotter.ru";
|
||||
const output = (xml: string) => new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" } });
|
||||
if (lastGood?.origin === origin && Date.now() - lastGood.at < 1800000) return output(lastGood.xml);
|
||||
try {
|
||||
const [activity, fishes, waters] = await Promise.all([api<Activity[]>("/api/v1/activity?hours=72&limit=100"), api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<DictionaryItem[]>("/api/v1/waterbodies?limit=500")]);
|
||||
paths.add("/fish"); paths.add("/waterbodies");
|
||||
fishes.forEach(item => paths.add(`/fish/${item.slug}`));
|
||||
waters.forEach(item => paths.add(`/waterbodies/${item.slug}`));
|
||||
activity.forEach(item => paths.add(spotPath(item)));
|
||||
activity.forEach(item => paths.add(`/waterbodies/${item.waterbody_slug}/${item.fish_slug}`));
|
||||
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy", "/fish", "/waterbodies"]);
|
||||
for (const [endpoint, prefix] of [["fishes", "fish"], ["waterbodies", "waterbodies"]]) {
|
||||
for (let offset = 0; ; offset += 500) {
|
||||
const rows = await api<DictionaryItem[]>(`/api/v1/${endpoint}?limit=500&offset=${offset}`);
|
||||
rows.forEach(row => paths.add(`/${prefix}/${row.slug}`));
|
||||
if (paths.size > 49000) throw new Error("Sitemap index required");
|
||||
if (rows.length < 500) break;
|
||||
}
|
||||
}
|
||||
for (let offset = 0; ; offset += 500) {
|
||||
const rows = await api<string[]>(`/api/v1/public-spot-pages?limit=500&offset=${offset}`);
|
||||
rows.forEach(path => paths.add(path));
|
||||
if (paths.size > 49000) throw new Error("Sitemap index required");
|
||||
if (rows.length < 1000) break;
|
||||
}
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${[...paths].map(path => `<url><loc>${escapeXml(new URL(path, origin).toString())}</loc></url>`).join("")}</urlset>`;
|
||||
lastGood = { origin, xml, at: Date.now() };
|
||||
return output(xml);
|
||||
} catch {
|
||||
// A temporary API outage must not make the static part of the sitemap unavailable.
|
||||
if (lastGood?.origin === origin) return output(lastGood.xml);
|
||||
return new Response("Sitemap temporarily unavailable", { status: 503, headers: { "Retry-After": "60", "Cache-Control": "no-store" } });
|
||||
}
|
||||
const urls = [...paths].map(path => `<url><loc>${escapeXml(new URL(path, origin).toString())}</loc></url>`).join("");
|
||||
return new Response(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`, {
|
||||
headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,9 +4,10 @@ import SourceBadge from "../../components/SourceBadge.astro";
|
||||
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
||||
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
||||
import CatchList from "../../components/CatchList.astro";
|
||||
import { activityLevel, api, plural, type Activity, type Catch, type Spot } from "../../lib/api";
|
||||
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type Spot } from "../../lib/api";
|
||||
const { id } = Astro.params;
|
||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||
let timeline: { start: string; end: string; count: number }[] = [];
|
||||
try {
|
||||
const readable = id?.match(/^(.+)-(-?\d+)x(-?\d+)$/);
|
||||
const spotResult = readable
|
||||
@@ -15,7 +16,12 @@ try {
|
||||
if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301);
|
||||
const [catchResult, activityRows] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
|
||||
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === spotResult.id) ?? null;
|
||||
} catch { unavailable = true; }
|
||||
timeline = await api<typeof timeline>(`/api/v1/spots/${spotResult.id}/timeline`);
|
||||
} catch (error) {
|
||||
unavailable = true;
|
||||
Astro.response.status = error instanceof ApiError && [404, 422].includes(error.status) ? 404 : 503;
|
||||
if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60");
|
||||
}
|
||||
const level = activity ? activityLevel(activity.activity_score) : null;
|
||||
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
|
||||
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
|
||||
@@ -28,7 +34,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||
<ActivityTimeline catches={catches}/>
|
||||
<ActivityTimeline buckets={timeline}/>
|
||||
<section class="detail-grid">
|
||||
<div>
|
||||
<div class="section-heading"><h2>Последние уловы</h2></div>
|
||||
|
||||
Reference in New Issue
Block a user