diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index a5cee30..afe1aa5 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -1,26 +1,40 @@ diff --git a/apps/web/src/data/api.ts b/apps/web/src/data/api.ts index a281cb6..9fc6edd 100644 --- a/apps/web/src/data/api.ts +++ b/apps/web/src/data/api.ts @@ -9,15 +9,23 @@ export class ApiError extends Error { export async function apiRequest( path: string, - options: { method?: string; body?: unknown } = {}, + options: { method?: string; body?: unknown; timeoutMs?: number } = {}, ): Promise { - const response = await fetch(path, { - method: options.method ?? "GET", - credentials: "same-origin", - headers: options.body === undefined ? undefined : { "Content-Type": "application/json" }, - body: options.body === undefined ? undefined : JSON.stringify(options.body), - }); - const payload = await response.json().catch(() => ({})) as { error?: string }; - if (!response.ok) throw new ApiError(payload.error ?? "请求失败", response.status); - return payload as T; + const controller = new AbortController(); + const timeoutMs = options.timeoutMs ?? (options.method && options.method !== "GET" ? 10000 : 3000); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(path, { + method: options.method ?? "GET", + credentials: "same-origin", + headers: options.body === undefined ? undefined : { "Content-Type": "application/json" }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }); + const payload = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) throw new ApiError(payload.error ?? "请求失败", response.status); + return payload as T; + } finally { + window.clearTimeout(timeout); + } } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 8425833..2218e0b 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -14,21 +14,32 @@ .offline-status { position: fixed; - top: max(8px, env(safe-area-inset-top)); right: 8px; - z-index: 100; - width: 30px; - height: 30px; - display: grid; - place-items: center; - padding: 0; + bottom: calc(68px + env(safe-area-inset-bottom) + 4px); + left: 8px; + z-index: 6; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; border: 1px solid #e9c98d; - border-radius: 50%; + border-radius: 6px; + padding: 0 10px; background: rgba(255, 248, 228, .96); color: #795b19; + font-size: 10px; + line-height: 1; box-shadow: 0 2px 8px rgba(74, 57, 18, .12); } +.offline-status:disabled { opacity: .8; } +.offline-status .spinning { animation: offline-reconnect-spin 800ms linear infinite; } + +@keyframes offline-reconnect-spin { + to { transform: rotate(360deg); } +} + html, body, #app { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1f9884e..289c391 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -103,6 +103,12 @@ function offlineServiceWorkerPlugin(): Plugin { const source = `const CACHE_NAME = "cents-shell-v4"; const PRECACHE = ${JSON.stringify(precache, null, 2)}; +function fetchWithTimeout(request, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return fetch(request, { signal: controller.signal }).finally(() => clearTimeout(timer)); +} + self.addEventListener("install", (event) => { event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting())); }); @@ -122,7 +128,7 @@ self.addEventListener("fetch", (event) => { if (event.request.mode === "navigate") { event.respondWith( - fetch(event.request).then((response) => { + fetchWithTimeout(event.request, 1500).then((response) => { if (!response.ok) throw new Error("navigation unavailable"); const copy = response.clone(); void caches.open(CACHE_NAME).then((cache) => cache.put("/", copy));