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: {