feat: improve recovery for user actions

This commit is contained in:
ik
2026-09-22 20:06:44 +07:00
parent a8321536cc
commit 8cb0a65a5c
4 changed files with 21 additions and 3 deletions
+1
View File
@@ -22,6 +22,7 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => {
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json", "Idempotency-Key": idempotencyKey, "X-Forwarded-For": request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30_000) });
if (!response.ok) {
if (response.status === 429) return redirect("/report?state=rate_limited", 303);
if (response.status === 409) { cookies.delete("rf4-idempotency-key", { path: "/" }); return redirect("/report?state=conflict", 303); }
if (response.status >= 500) return redirect("/report?state=server_error", 303);
return redirect("/report?state=create_error", 303);
}
+17 -1
View File
@@ -55,6 +55,22 @@ import Layout from "../layouts/Layout.astro";
if (!Number.isFinite(timestamp)) return "Свежесть не распознана";
return Date.now() - timestamp > 24 * 60 * 60 * 1000 ? "Устарело" : "Свежо";
};
let undoEntry: Record<string, string> | null = null;
let undoTimer: number | undefined;
const offerUndo = (entry: Record<string, string>) => {
undoEntry = entry;
if (undoTimer) window.clearTimeout(undoTimer);
document.querySelector("[data-plan-undo]")?.remove();
if (!shareStatus) return;
shareStatus.textContent = "Точка удалена";
const undo = document.createElement("button");
undo.type = "button"; undo.dataset.action = "quiet"; undo.dataset.planUndo = "true"; undo.textContent = "Вернуть";
undo.addEventListener("click", () => {
if (undoEntry && writePlan([...readPlan(), undoEntry])) { undoEntry = null; undo.remove(); shareStatus.textContent = "Точка возвращена"; render(); }
});
shareStatus.append(" · ", undo);
undoTimer = window.setTimeout(() => { undoEntry = null; undo.remove(); }, 8000);
};
const shared = new URLSearchParams(location.search).get("plan");
let pendingImport: Array<Record<string, string>> | null = null;
if (shared) {
@@ -84,7 +100,7 @@ import Layout from "../layouts/Layout.astro";
const approach = document.createElement("p"); approach.textContent = [item.method, item.retrieve].filter(Boolean).join(" · ") || "Метод не указан";
const facts = document.createElement("small"); facts.textContent = [item.risk ? `Риск: ${item.risk}` : "Риск не рассчитан", freshnessLabel(item.freshness), item.confidence !== "" ? `Доверие: ${item.confidence}%` : "Доверие не рассчитано"].join(" · ");
const link = document.createElement("a"); link.href = item.key || "/"; link.textContent = "Открыть точку";
const remove = document.createElement("button"); remove.type = "button"; remove.dataset.action = "quiet"; remove.textContent = "Убрать"; remove.addEventListener("click", () => { if (writePlan(readPlan().filter(entry => entry.key !== item.key))) render(); });
const remove = document.createElement("button"); remove.type = "button"; remove.dataset.action = "quiet"; remove.textContent = "Убрать"; remove.addEventListener("click", () => { if (writePlan(readPlan().filter(entry => entry.key !== item.key))) { offerUndo(item); render(); } });
const actions = document.createElement("div"); actions.className = "plan-card__actions"; actions.append(link, remove);
card.append(heading, coordinate, meta, approach, facts, actions); list.append(card);
});
+2 -1
View File
@@ -18,6 +18,7 @@ const reportId = Astro.url.searchParams.get("report_id");
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
{state === "create_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Заявка не создана.</strong> Проверьте поля; введённые значения сохранены.</div>}
{state === "conflict" && <div class="notice warning" id="form-error" tabindex="-1"><strong>Повторная отправка отличается от уже сохранённой.</strong> Черновик оставлен на странице; проверьте поля и отправьте его с новым ключом.</div>}
{state === "rate_limited" && <div class="notice error" id="form-error" tabindex="-1"><strong>Слишком много отправок.</strong> Подождите 10 минут перед повторной попыткой.</div>}
{state === "timeout" && <div class="notice error" id="form-error" tabindex="-1"><strong>Превышено время ожидания.</strong> Проверьте соединение и попробуйте снова.</div>}
{state === "server_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Сервер временно недоступен.</strong> Попробуйте позже.</div>}
@@ -39,7 +40,7 @@ const reportId = Astro.url.searchParams.get("report_id");
removeItem: (k: string) => { try { sessionStorage.removeItem(k); } catch {} },
};
// A05: Restore draft on all error states that don't destroy the submission
const recoverableStates = ["create_error", "rate_limited", "server_error", "timeout"];
const recoverableStates = ["create_error", "conflict", "rate_limited", "server_error", "timeout"];
if (form && recoverableStates.includes(state)) {
try {
const draft = JSON.parse(safeStorage.getItem(key) || "{}");