From 56a3c9b98dbb844a6a1f3e8a2030b4f23b56f278 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 28 Jul 2026 19:05:03 +0800 Subject: [PATCH] fix: support offline PWA shell and cached ledgers --- apps/web/public/sw.js | 25 ------------- apps/web/src/App.vue | 17 ++++++++- apps/web/src/stores/auth.ts | 27 +++++++++++++- apps/web/src/stores/ledgers.ts | 35 ++++++++++++----- apps/web/src/styles.css | 17 +++++++++ apps/web/vite.config.ts | 68 +++++++++++++++++++++++++++++++++- 6 files changed, 151 insertions(+), 38 deletions(-) delete mode 100644 apps/web/public/sw.js diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js deleted file mode 100644 index cbd0915..0000000 --- a/apps/web/public/sw.js +++ /dev/null @@ -1,25 +0,0 @@ -const CACHE_NAME = "cents-shell-v3"; -const APP_SHELL = ["/", "/manifest.webmanifest"]; - -self.addEventListener("install", (event) => { - event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))); -}); - -self.addEventListener("activate", (event) => { - event.waitUntil( - caches - .keys() - .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))), - ); -}); - -self.addEventListener("fetch", (event) => { - if (event.request.method !== "GET") return; - - event.respondWith( - caches.match(event.request).then((cached) => { - if (cached) return cached; - return fetch(event.request).catch(() => caches.match("/")); - }), - ); -}); diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 4be8965..edf8a5e 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -1,4 +1,19 @@ + + - diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index fa7d741..810b64e 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -5,6 +5,27 @@ import { closeUserDb } from "../data/db"; export type SessionUser = { id: string; name: string }; let sessionPromise: Promise | null = null; +const LAST_SESSION_KEY = "cents:last-session-user"; + +function readLastSession(): SessionUser | null { + try { + const value = localStorage.getItem(LAST_SESSION_KEY); + if (!value) return null; + const user = JSON.parse(value) as SessionUser; + return user?.id && user?.name ? user : null; + } catch { + return null; + } +} + +function writeLastSession(user: SessionUser | null) { + try { + if (user) localStorage.setItem(LAST_SESSION_KEY, JSON.stringify(user)); + else localStorage.removeItem(LAST_SESSION_KEY); + } catch { + // Storage can be disabled by the browser; server auth still works. + } +} export const useAuthStore = defineStore("auth", { state: () => ({ @@ -21,8 +42,9 @@ export const useAuthStore = defineStore("auth", { const result = await apiRequest<{ user: SessionUser | null }>("/api/auth/session"); this.user = result.user; this.unavailable = false; + writeLastSession(result.user); } catch { - this.user = null; + this.user = readLastSession(); this.unavailable = true; } finally { this.loaded = true; @@ -40,6 +62,7 @@ export const useAuthStore = defineStore("auth", { body: { name }, }); this.user = result.user; + writeLastSession(result.user); }, async login(name: string, password: string) { const result = await apiRequest<{ user: SessionUser }>("/api/auth/login", { @@ -49,6 +72,7 @@ export const useAuthStore = defineStore("auth", { this.user = result.user; this.loaded = true; this.unavailable = false; + writeLastSession(result.user); }, async logout() { const userId = this.user?.id; @@ -56,6 +80,7 @@ export const useAuthStore = defineStore("auth", { if (userId) closeUserDb(userId); this.user = null; this.loaded = true; + writeLastSession(null); }, }, }); diff --git a/apps/web/src/stores/ledgers.ts b/apps/web/src/stores/ledgers.ts index 0cc3a80..ccb8bec 100644 --- a/apps/web/src/stores/ledgers.ts +++ b/apps/web/src/stores/ledgers.ts @@ -40,7 +40,15 @@ export const useLedgerStore = defineStore("ledgers", { const auth = useAuthStore(); if (!auth.user) return; const db = await getUserDb(auth.user.id); - const result = await apiRequest<{ ledgers: LedgerRecord[] }>("/api/ledgers"); + let serverLedgers: LedgerRecord[]; + try { + const result = await apiRequest<{ ledgers: LedgerRecord[] }>("/api/ledgers"); + serverLedgers = result.ledgers; + } catch (error) { + const cachedLedgers = await db.ledgers.toArray(); + if (!cachedLedgers.length) throw error; + serverLedgers = cachedLedgers; + } const displayPreferences = await db.userPreferences .where("userId") .equals(auth.user.id) @@ -57,17 +65,16 @@ export const useLedgerStore = defineStore("ledgers", { const summaryByLedger = new Map( summaryPreferences.map((preference) => [preference.id.split(":").at(-1), preference.value as LedgerSummaryRange]), ); - const serverLedgers = result.ledgers.map((ledger) => ({ + const hydratedLedgers = serverLedgers.map((ledger) => ({ ...ledger, amountDisplay: displayByLedger.get(ledger.id) ?? "base", summaryRange: summaryByLedger.get(ledger.id) ?? "year", })); - this.ledgers = serverLedgers; + this.ledgers = hydratedLedgers; - const allowedIds = new Set(this.ledgers.map((ledger) => ledger.id)); await db.transaction("rw", db.ledgers, async () => { await db.ledgers.clear(); - if (serverLedgers.length) await db.ledgers.bulkPut(serverLedgers); + if (hydratedLedgers.length) await db.ledgers.bulkPut(hydratedLedgers); }); const preferenceId = currentLedgerPreferenceId(auth.user.id); @@ -76,7 +83,11 @@ export const useLedgerStore = defineStore("ledgers", { this.currentLedgerId = preferredLedger?.id ?? this.ledgers[0]?.id ?? ""; if (this.currentLedgerId !== preference?.value) await this.persistCurrentLedger(); this.loaded = true; - await this.loadCurrentMembers(); + try { + await this.loadCurrentMembers(); + } catch { + this.currentMembers = []; + } })(); try { await ledgerLoadPromise; @@ -116,10 +127,14 @@ export const useLedgerStore = defineStore("ledgers", { this.currentMembers = []; return; } - const result = await apiRequest<{ members: LedgerMember[] }>( - `/api/ledgers/${encodeURIComponent(this.currentLedgerId)}/members`, - ); - this.currentMembers = result.members; + try { + const result = await apiRequest<{ members: LedgerMember[] }>( + `/api/ledgers/${encodeURIComponent(this.currentLedgerId)}/members`, + ); + this.currentMembers = result.members; + } catch { + this.currentMembers = []; + } }, memberName(userId: string) { return this.currentMembers.find((member) => member.id === userId)?.name ?? "历史用户"; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index d7fcb81..e0e205a 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -12,6 +12,23 @@ box-sizing: border-box; } +.offline-status { + position: fixed; + top: max(10px, env(safe-area-inset-top)); + left: 50%; + z-index: 100; + max-width: calc(100% - 32px); + padding: 7px 12px; + border: 1px solid #e9c98d; + border-radius: 999px; + background: rgba(255, 248, 228, .96); + color: #795b19; + font-size: 11px; + line-height: 1.2; + pointer-events: none; + transform: translateX(-50%); +} + html, body, #app { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 12f66a6..fc2b629 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -81,9 +81,75 @@ function appIdentityPlugin(mode: string): Plugin { }; } +function offlineServiceWorkerPlugin(): Plugin { + const publicAssets = [ + "/apple-touch-icon.png", + "/apple-touch-icon-dev.png", + "/icon-192.png", + "/icon-512.png", + "/icon-maskable-512.png", + "/icon-dev-192.png", + "/icon-dev-512.png", + "/icon-dev-maskable-512.png", + ]; + + return { + name: "cents-offline-service-worker", + generateBundle(_options, bundle) { + const bundledAssets = Object.keys(bundle) + .filter((fileName) => fileName !== "sw.js") + .map((fileName) => `/${fileName}`); + const precache = [...new Set([...bundledAssets, ...publicAssets])]; + const source = `const CACHE_NAME = "cents-shell-v4"; +const PRECACHE = ${JSON.stringify(precache, null, 2)}; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => Promise.all( + keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)), + )).then(() => self.clients.claim()), + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + const url = new URL(event.request.url); + if (url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return; + + if (event.request.mode === "navigate") { + event.respondWith( + fetch(event.request).then((response) => { + const copy = response.clone(); + void caches.open(CACHE_NAME).then((cache) => cache.put("/", copy)); + return response; + }).catch(() => caches.match("/")), + ); + return; + } + + event.respondWith( + caches.match(event.request).then((cached) => cached ?? fetch(event.request).then((response) => { + if (response.ok) { + const copy = response.clone(); + void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy)); + } + return response; + })), + ); +}); +`; + this.emitFile({ type: "asset", fileName: "sw.js", source }); + }, + }; +} + export default defineConfig(({ mode }) => ({ envDir: monorepoRoot, - plugins: [vue(), appIdentityPlugin(mode)], + plugins: [vue(), appIdentityPlugin(mode), offlineServiceWorkerPlugin()], server: { allowedHosts: ["dev.cents.homemade.net.cn"], proxy: {