From f34c8c3bae425562d1967f5ba6c47e1974b8c092 Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Mon, 26 Oct 2026 16:05:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=A6=BB=E7=BA=BF):=20=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E5=8F=AF=E7=9C=8B=20=E2=80=94=E2=80=94=20GET?= =?UTF-8?q?=20=E5=93=8D=E5=BA=94=E6=9C=AC=E5=9C=B0=E7=BC=93=E5=AD=98=20+?= =?UTF-8?q?=20=E5=90=8C=E6=AD=A5=E5=87=AD=E8=AF=81=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=20(=E7=A6=BB=E7=BA=BF=E4=B8=8D=E6=B8=85=E5=BA=93)=20+?= =?UTF-8?q?=20=E5=B7=A6=E4=B8=8A=E8=A7=92=E7=A6=BB=E7=BA=BF=E5=BE=BD?= =?UTF-8?q?=E6=A0=87=20(=E4=B8=8E=20Windows=20=E5=A4=96=E5=A3=B3=E5=88=86?= =?UTF-8?q?=E6=94=AF=E5=90=8C=E6=BA=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/client.js | 147 ++++++++++++++++++++++++++++++--- src/auth/AuthContext.jsx | 4 +- src/layouts/MobileShell.jsx | 12 +++ src/lib/powersync/connector.js | 24 +++++- src/styles/globals.css | 29 +++++++ 5 files changed, 199 insertions(+), 17 deletions(-) diff --git a/src/api/client.js b/src/api/client.js index 2d5873e..560ef43 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -1,13 +1,121 @@ // API 调用封装: 自动带 JWT、401 自动清 token、抛统一错误 +// 0.2.1 离线支持: +// - GET 请求成功后把响应写入本地缓存 (localStorage), 离线/网络失败时回退缓存 (界面仍有数据) +// - 写请求 (POST/PUT/DELETE) 仍要求在线 (任务模块的离线写走 PowerSync 上传队列) +// - 统一在线/离线状态订阅, 供 UI 显示离线标识 const TOKEN_KEY = 'iboard_token' const REMEMBER_KEY = 'iboard_remember' +const UID_KEY = 'iboard_uid' // API 基址: // - 默认相对路径 `/api` (web dev: Vite 代理; web prod: 同源 Express 一起托管) -// - 打包 Android 时设 VITE_API_BASE=http://bh.vps.honor3.com:3001 走外网后端 +// - 打包 Android / Windows 外壳时设 VITE_API_BASE=http://bh.vps.honor3.com:3001 走外网后端 const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/+$/, '') const API_PREFIX = API_BASE ? API_BASE + '/api' : '/api' +// ---------- 在线/离线状态 (网络事件 + 请求结果联动) ---------- +let offline = typeof navigator !== 'undefined' ? navigator.onLine === false : false +const offlineListeners = new Set() + +export function isOffline() { + return offline +} + +function setOffline(next) { + if (offline === next) return + offline = next + offlineListeners.forEach((cb) => { + try { cb(next) } catch { /* ignore */ } + }) +} + +if (typeof window !== 'undefined') { + window.addEventListener('online', () => setOffline(false)) + window.addEventListener('offline', () => setOffline(true)) +} + +/** 订阅在线/离线变化, 返回取消函数 */ +export function subscribeOffline(cb) { + offlineListeners.add(cb) + return () => offlineListeners.delete(cb) +} + +// ---------- GET 响应本地缓存 ---------- +// 目的: 任何界面只要成功访问过一次, 离线时都能展示上次的数据 +const CACHE_PREFIX = 'iboard_cache:' +const CACHE_TOTAL_LIMIT = 3 * 1024 * 1024 // 约 3MB (localStorage 通常 5MB) +const CACHE_ENTRY_LIMIT = 512 * 1024 // 单条上限, 超大响应不缓存 + +function cacheKey(path) { + return CACHE_PREFIX + path +} + +function readCache(path) { + try { + const raw = localStorage.getItem(cacheKey(path)) + if (!raw) return null + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || !('data' in parsed)) return null + return parsed + } catch { + return null + } +} + +function cacheSizeUsed() { + let total = 0 + try { + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k && k.startsWith(CACHE_PREFIX)) total += (localStorage.getItem(k) || '').length + } + } catch { /* ignore */ } + return total +} + +function evictOldest(needBytes) { + try { + const entries = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (!k || !k.startsWith(CACHE_PREFIX)) continue + let ts = 0 + try { ts = JSON.parse(localStorage.getItem(k) || '{}').ts || 0 } catch { /* ignore */ } + entries.push({ k, ts }) + } + entries.sort((a, b) => a.ts - b.ts) + let used = cacheSizeUsed() + for (const e of entries) { + if (used + needBytes <= CACHE_TOTAL_LIMIT) break + used -= (localStorage.getItem(e.k) || '').length + localStorage.removeItem(e.k) + } + } catch { /* ignore */ } +} + +function writeCache(path, data) { + try { + const payload = JSON.stringify({ ts: Date.now(), data }) + if (payload.length > CACHE_ENTRY_LIMIT) return // 过大不缓存 + evictOldest(payload.length) + localStorage.setItem(cacheKey(path), payload) + } catch { + // 配额满等异常: 静默失败, 不影响主流程 + } +} + +/** 清空所有 GET 缓存 (退出登录时调用) */ +export function clearApiCache() { + try { + const keys = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k && k.startsWith(CACHE_PREFIX)) keys.push(k) + } + keys.forEach((k) => localStorage.removeItem(k)) + } catch { /* ignore */ } +} + export function getToken() { return localStorage.getItem(TOKEN_KEY) || '' } @@ -27,7 +135,6 @@ export function setRememberFlag(v) { } // 当前用户 id (0.2.1 离线写入需要 ownerId/creatorId, 本地库无法即时从服务端获取) -const UID_KEY = 'iboard_uid' export function getCurrentUserId() { return localStorage.getItem(UID_KEY) || '' } @@ -42,15 +149,30 @@ export function getApiBase() { export async function api(path, { method = 'GET', body, headers = {} } = {}) { const token = getToken() - const res = await fetch(API_PREFIX + path, { - method, - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: 'Bearer ' + token } : {}), - ...headers, - }, - body: body ? JSON.stringify(body) : undefined, - }) + const isRead = method === 'GET' + let res + try { + res = await fetch(API_PREFIX + path, { + method, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: 'Bearer ' + token } : {}), + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }) + } catch { + // 网络层失败: 离线 / 服务不可达 + setOffline(true) + if (isRead) { + const cached = readCache(path) + if (cached) return cached.data + throw new Error('当前离线,且该界面尚无本地缓存') + } + throw new Error('当前离线,修改操作需要联网后重试') + } + + setOffline(false) if (res.status === 401) { setToken('') const data = await res.json().catch(() => ({})) @@ -59,5 +181,6 @@ export async function api(path, { method = 'GET', body, headers = {} } = {}) { } const data = await res.json().catch(() => ({})) if (!res.ok) throw new Error(data.error || '请求失败 (' + res.status + ')') + if (isRead) writeCache(path, data) return data -} \ No newline at end of file +} diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index 06c8a7d..fe57e08 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -1,5 +1,5 @@ import { createContext, useContext, useEffect, useState } from 'react' -import { api, getToken, setToken, setRememberFlag, setCurrentUserId } from '../api/client.js' +import { api, getToken, setToken, setRememberFlag, setCurrentUserId, clearApiCache } from '../api/client.js' import { disconnectAndClearPowersync } from '../lib/powersync/db.js' const AuthContext = createContext(null) @@ -51,6 +51,8 @@ export function AuthProvider({ children }) { setRememberFlag(false) setUser(null) setCurrentUserId('') + try { clearApiCache() } catch { /* ignore */ } + try { localStorage.removeItem('iboard_ps_cred') } catch { /* ignore */ } // 断开同步并清空本地 SQLite (0.2.1 离线编辑) try { await disconnectAndClearPowersync() } catch { /* ignore */ } } diff --git a/src/layouts/MobileShell.jsx b/src/layouts/MobileShell.jsx index 7e2eac6..19cc6c3 100644 --- a/src/layouts/MobileShell.jsx +++ b/src/layouts/MobileShell.jsx @@ -1,11 +1,13 @@ import { useEffect, useState } from 'react' import { Outlet, useLocation } from 'react-router-dom' import BottomNav from '../components/BottomNav.jsx' +import { isOffline, subscribeOffline } from '../api/client.js' // 移动端应用外壳 // - 顶部安全区: 用 --safe-top 撑开 (由 main.jsx 的 calibrateSafeTop 实测校准) // - 中间
: flex:1 可滚动主区 // - 底部 TabBar: antd-mobile TabBar 自带底部安全区 +// - 左上角离线标识 (0.2.1): 离线或服务不可达时显示, 请求恢复后自动隐藏 // // 注意: 这里不再用 antd-mobile 的 . // 它只是一个空 div, 高度完全依赖 env(safe-area-inset-top); 而 Android WebView @@ -22,9 +24,19 @@ export default function MobileShell() { return () => clearTimeout(t) }, [location.pathname]) + // 离线状态 (窗口左上角标识) + const [offline, setOfflineState] = useState(isOffline()) + useEffect(() => subscribeOffline(setOfflineState), []) + return (