feat: improve recovery for user actions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) || "{}");
|
||||
|
||||
Reference in New Issue
Block a user