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) || "{}");
+1 -1
View File
@@ -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.