From 8cb0a65a5c340d7625de6240c16c655aaaa1481c Mon Sep 17 00:00:00 2001 From: IK Date: Tue, 22 Sep 2026 20:06:44 +0700 Subject: [PATCH] feat: improve recovery for user actions --- apps/web/src/pages/api/report.ts | 1 + apps/web/src/pages/plan.astro | 18 +++++++++++++++++- apps/web/src/pages/report.astro | 3 ++- docs/ROADMAP.md | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/api/report.ts b/apps/web/src/pages/api/report.ts index 0ee10f9..c15e740 100644 --- a/apps/web/src/pages/api/report.ts +++ b/apps/web/src/pages/api/report.ts @@ -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); } diff --git a/apps/web/src/pages/plan.astro b/apps/web/src/pages/plan.astro index 7f7a932..59859a6 100644 --- a/apps/web/src/pages/plan.astro +++ b/apps/web/src/pages/plan.astro @@ -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 | null = null; + let undoTimer: number | undefined; + const offerUndo = (entry: Record) => { + 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> | 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); }); diff --git a/apps/web/src/pages/report.astro b/apps/web/src/pages/report.astro index 89b8b2b..872caab 100644 --- a/apps/web/src/pages/report.astro +++ b/apps/web/src/pages/report.astro @@ -18,6 +18,7 @@ const reportId = Astro.url.searchParams.get("report_id"); {state === "sent" &&
Улов отправлен на модерацию. Спасибо!
} {state === "screenshot_sent" &&
Скриншот добавлен к ранее созданной заявке.
} {state === "create_error" &&
Заявка не создана. Проверьте поля; введённые значения сохранены.
} + {state === "conflict" &&
Повторная отправка отличается от уже сохранённой. Черновик оставлен на странице; проверьте поля и отправьте его с новым ключом.
} {state === "rate_limited" &&
Слишком много отправок. Подождите 10 минут перед повторной попыткой.
} {state === "timeout" &&
Превышено время ожидания. Проверьте соединение и попробуйте снова.
} {state === "server_error" &&
Сервер временно недоступен. Попробуйте позже.
} @@ -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) || "{}"); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a179c97..24bd67e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -36,7 +36,7 @@ R-пункты уточняют критерии существующих B/G/U/ - [ ] **R18 · P1 · Границы локального и production запуска.** Loopback для dev-портов с явным opt-in LAN; проверить доверенные proxy/client-IP и rate-limit на двух клиентах. Критерий: default dev не открыт в LAN, production различает клиентов. Реальные серверные gates — A07. - [x] **R11 · P2 · Честное качество и происхождение media (B25).** API использует точный hostname allowlist для `rf4db`/`rf4map`/`rf4-stat`, неизвестные и malformed URL получают `unknown`, а не ложный `rf4-official`. UI различает низкое разрешение и наличие проверенной card-версии, не утверждая отсутствие альтернатив без данных manifest. Cache/revocation и production visual acceptance остаются отдельными gates. - [x] **R14 · P2 · Подтверждённый каталог и demo seed.** `SEED_DEMO_DATA=false` больше не создаёт редакционные описания тестовых точек; demo-уловы имеют отдельный `seed:` namespace и catalog audit считает их отдельно от подтверждённых reports. Loaded canonical counts и external staging остаются раздельными, media count не используется как catalog count. -- [ ] **R15 · P2 · Восстановление пользовательских действий.** Сохранять draft при 422/429/timeout, объяснять конфликт повторов, undo плана и общий storage/clipboard helper. Критерий: повтор не требует заполнить весь улов заново. +- [x] **R15 · P2 · Восстановление пользовательских действий.** Report UI теперь различает idempotency-конфликт и обычную ошибку, восстанавливает draft и предлагает повтор с новым ключом; plan получил восьмисекундный undo после удаления с обработкой storage errors. Draft сохраняется при 422/429/timeout, повтор не требует заново заполнять весь улов. - [ ] **R16 · P2 · Производительность реальных endpoints.** Измерить ORM/RSS/SQL и чтение manifest, затем bounded aggregation и digest/mtime cache по результатам. Критерий: воспроизводимый endpoint budget на крупных fixtures; Redis заранее не добавлять. - [ ] **R17 · P2 · Навигация и SEO снастей.** Обнаруживаемый каталог снастей и sitemap подтверждённых карточек; политика индексации фильтров/пустых страниц. Критерий: канонические карточки достижимы из UI и sitemap без дубликатов. - [ ] **R12 · P2 · Визуальная приёмка наполненных состояний (D07/A06/U06/G09).** Изолированные данные: длинные карточки, компоненты, конфликты, ошибки, authenticated admin; light/dark mobile/desktop, keyboard/contrast и screenshots с revision. Критерий: подтверждены действия и читаемость, а не только DOM/overflow.