feat(宏观): ifinance 版本展示、手动/每日更新与按需文件缓存
Docker Build and Push / build-image (push) Successful in 3m58s
Docker Build and Push / build-image (push) Successful in 3m58s
- 「我」页展示 ifinance 版本/构建时间/构建号/上次检查, 点击手动更新 info.json - 启动及每 30 分钟检查, 超过 24h 自动刷新索引 - 新增 Service Worker: 按 files[].hash 内容寻址, 只缓存打开过的页面 - 未命中时按需下载单个文件并校验 sha256, 索引升级后清理旧缓存 - 缓存页面通过 CSP sandbox 隔离, 支持看板行内脚本且无法访问宿主数据
This commit is contained in:
@@ -0,0 +1,147 @@
|
|||||||
|
// ifinance 文件缓存 Service Worker
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 宿主把 iframe 指向 /__ifinance__/<path>?h=<sha256>&t=<mime>, 由本 SW:
|
||||||
|
// 1. 按 hash 从 Cache Storage 取本地缓存 (内容寻址, 索引更新后自然失效)
|
||||||
|
// 2. 未命中时只拉当前这一个文件 (按需加载, 不预下载整个模块包)
|
||||||
|
// 3. 用 sha256 校验后再写入缓存; 校验失败仍然返回内容, 但不落盘
|
||||||
|
//
|
||||||
|
// 为什么不用 blob: URL 直接渲染: blob: 文档会继承宿主页面的 CSP
|
||||||
|
// (script-src 'self'), 内嵌看板的 <script> 会被拦截, 图表无法渲染。
|
||||||
|
// SW 返回的是普通 http(s) 子文档, 不受宿主 CSP 的脚本限制。
|
||||||
|
//
|
||||||
|
// 文件缓存里以 /__ifinance_file__/<hash> 为 key, 供宿主侧清理旧版本。
|
||||||
|
const PAGE_PREFIX = '/__ifinance__/'
|
||||||
|
const FILE_CACHE_NAME = 'ifinance-files-v1'
|
||||||
|
const FILE_KEY_PREFIX = '/__ifinance_file__/'
|
||||||
|
const REMOTE_BASE = 'https://ifinance.honor3.com'
|
||||||
|
const NETWORK_TIMEOUT = 20000
|
||||||
|
|
||||||
|
self.addEventListener('install', () => {
|
||||||
|
// 新版本 SW 立刻接管, 避免用户必须再打开一次页面
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url)
|
||||||
|
if (url.origin !== self.location.origin) return
|
||||||
|
if (!url.pathname.startsWith(PAGE_PREFIX)) return
|
||||||
|
if (event.request.method !== 'GET') return
|
||||||
|
event.respondWith(serve(url))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function serve(url) {
|
||||||
|
const rel = decodeURIComponent(url.pathname.slice(PAGE_PREFIX.length))
|
||||||
|
const hash = (url.searchParams.get('h') || '').toLowerCase()
|
||||||
|
const type = url.searchParams.get('t') || guessType(rel)
|
||||||
|
const keyUrl = self.location.origin + FILE_KEY_PREFIX + (hash || encodeURIComponent(rel))
|
||||||
|
|
||||||
|
// 缓存不可用时 (隐私模式/配额/被策略禁用) 仍然正常联网加载
|
||||||
|
let cache = null
|
||||||
|
if (typeof caches !== 'undefined') {
|
||||||
|
try { cache = await caches.open(FILE_CACHE_NAME) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache && hash) {
|
||||||
|
try {
|
||||||
|
const hit = await cache.match(keyUrl)
|
||||||
|
if (hit) return respond(await hit.arrayBuffer(), type, 'hit')
|
||||||
|
} catch { /* 读取失败按未命中处理 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await fetchRemote(rel)
|
||||||
|
if (cache && hash) {
|
||||||
|
try {
|
||||||
|
const digest = await sha256Hex(payload)
|
||||||
|
if (digest === hash) {
|
||||||
|
await cache.put(keyUrl, new Response(payload, {
|
||||||
|
headers: { 'Content-Type': withCharset(type), 'Cache-Control': 'no-store' },
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
console.warn('[ifinance-sw] hash 不匹配, 不写入缓存:', rel, digest, '!=', hash)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// crypto.subtle 不可用等: 只跳过落盘, 不影响本次展示
|
||||||
|
console.warn('[ifinance-sw] 校验失败, 跳过缓存:', rel, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return respond(payload, type, 'network')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ifinance-sw] 加载失败:', rel, e)
|
||||||
|
return respondText(errorPage(rel, e), 'text/html; charset=utf-8', 502)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRemote(rel) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT)
|
||||||
|
try {
|
||||||
|
const res = await fetch(REMOTE_BASE + '/' + rel, {
|
||||||
|
cache: 'no-cache',
|
||||||
|
mode: 'cors',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||||
|
return await res.arrayBuffer()
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function respond(buf, type, source) {
|
||||||
|
return new Response(buf, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': withCharset(type),
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-Ifinance-Cache': source,
|
||||||
|
// 同源内容用 CSP sandbox 隔离成不透明源: 行内脚本照常运行 (看板需要),
|
||||||
|
// 但读不到宿主 localStorage/cookie, 也不能访问 parent。
|
||||||
|
'Content-Security-Policy': 'sandbox allow-scripts',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondText(text, type, status = 200) {
|
||||||
|
return new Response(text, {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': withCharset(type),
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Content-Security-Policy': 'sandbox allow-scripts',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCharset(type) {
|
||||||
|
const t = String(type || 'text/html')
|
||||||
|
return /charset=/i.test(t) ? t : t + '; charset=utf-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessType(rel) {
|
||||||
|
return /\.json$/i.test(rel) ? 'application/json' : 'text/html'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(buf) {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', buf)
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorPage(rel, err) {
|
||||||
|
const msg = String(err?.message || err || '加载失败')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
return '<!doctype html><html><head><meta charset="utf-8">' +
|
||||||
|
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
||||||
|
'<style>body{margin:0;padding:24px;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;' +
|
||||||
|
'color:#666;background:#f5f5f5}h3{margin:0 0 8px;color:#333;font-size:15px}code{font-size:12px;color:#999;word-break:break-all}</style>' +
|
||||||
|
'</head><body><h3>内容加载失败</h3><div>请检查网络后重试。</div><p><code>' +
|
||||||
|
msg + '<br>' + rel.replace(/&/g, '&').replace(/</g, '<') +
|
||||||
|
'</code></p></body></html>'
|
||||||
|
}
|
||||||
+210
-31
@@ -5,11 +5,17 @@
|
|||||||
// - 没有统一入口页, 每个 html 都是独立入口 (在 WebView/iframe 中打开)
|
// - 没有统一入口页, 每个 html 都是独立入口 (在 WebView/iframe 中打开)
|
||||||
// - categories[] 对应 App 的 tab, items[] 是条目; 未知字段忽略
|
// - categories[] 对应 App 的 tab, items[] 是条目; 未知字段忽略
|
||||||
//
|
//
|
||||||
// 本模块采用「纯前端直连」方式: 直接 fetch 远端 info.json, 条目 HTML 直接
|
// 缓存策略 (schema 2, 见 ifinance/readme.md「缓存协议」):
|
||||||
// 用 iframe 加载远端地址, 不做端上文件缓存 (始终最新)。
|
// - info.json: 远端优先, 失败回退同源内置快照 → localStorage 上次成功快照;
|
||||||
// 开发环境 (vite dev) 走 /ifinance-api 代理以规避跨域; 生产环境直连。
|
// 启动时拉一次, 之后每天最多自动检查一次, 「我」页可手动触发
|
||||||
// 若远端 info.json 拉取失败, 依次回退: 同源内置快照 (public/ifinance-info.json)
|
// - 条目文件: 按 files[].hash 内容寻址, 只缓存真正打开过的页面 (按需加载)
|
||||||
// → localStorage 上次成功快照, 保证分类 tab 仍可渲染。
|
// - 页面 HTML 由 Service Worker 从 /__ifinance__/... 返回:
|
||||||
|
// * 命中本地缓存 → 直接离线返回
|
||||||
|
// * 未命中 → 只拉当前这一个文件, 校验 sha256 后写入缓存
|
||||||
|
// 用同源 URL + SW 而不是 blob: URL, 因为 blob: 文档会继承宿主 CSP
|
||||||
|
// (script-src 'self'), 内嵌看板的行内脚本会被拦截。
|
||||||
|
// - 开发环境 (vite dev) 走 /ifinance-api 代理以规避跨域; 生产环境直连。
|
||||||
|
// 注意: SW 内直接访问远端 (ifinance 已开放 CORS, 无需代理)。
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
// 根地址: 生产直连; 开发用 vite 代理前缀 (见 vite.config.js)
|
// 根地址: 生产直连; 开发用 vite 代理前缀 (见 vite.config.js)
|
||||||
@@ -21,10 +27,20 @@ export const FINANCE_BASE = import.meta.env.DEV
|
|||||||
const FALLBACK_INFO_URL = '/ifinance-info.json'
|
const FALLBACK_INFO_URL = '/ifinance-info.json'
|
||||||
|
|
||||||
const INFO_CACHE_KEY = 'ifinance_info_v1'
|
const INFO_CACHE_KEY = 'ifinance_info_v1'
|
||||||
|
const FILE_CACHE_NAME = 'ifinance-files-v1'
|
||||||
|
|
||||||
// 模块级缓存: 一次会话内多处 (宏观页 / 详情页) 共享, 避免重复请求
|
// 页面缓存入口: 形如 /__ifinance__/public/econ/x.html?h=<hash>&t=text/html
|
||||||
|
// 由 public/ifinance-sw.js 拦截并返回内容
|
||||||
|
const PAGE_CACHE_PREFIX = '/__ifinance__/'
|
||||||
|
const SW_URL = (import.meta.env.BASE_URL || '/') + 'ifinance-sw.js'
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
// 模块级缓存: 一次会话内多处 (宏观页 / 详情页 / 「我」页) 共享
|
||||||
let memoInfo = null
|
let memoInfo = null
|
||||||
|
let memoCheckedAt = 0 // 上次成功检查远端 info.json 的时间戳
|
||||||
let inflight = null
|
let inflight = null
|
||||||
|
const listeners = new Set()
|
||||||
|
|
||||||
// 拼接 info.json 中给出的相对路径 (如 public/econ/x.html) 为可加载的绝对 URL
|
// 拼接 info.json 中给出的相对路径 (如 public/econ/x.html) 为可加载的绝对 URL
|
||||||
export function financeUrl(relPath) {
|
export function financeUrl(relPath) {
|
||||||
@@ -32,24 +48,27 @@ export function financeUrl(relPath) {
|
|||||||
return FINANCE_BASE + '/' + p
|
return FINANCE_BASE + '/' + p
|
||||||
}
|
}
|
||||||
|
|
||||||
// 条目 HTML 地址: Cloudflare Pages 走 clean URL, 去掉 .html 后缀直达 (避免 307 重定向)
|
// 条目 HTML 远端地址: Cloudflare Pages 走 clean URL, 去掉 .html 后缀直达 (避免 307)
|
||||||
export function financePageUrl(relPath) {
|
export function financePageUrl(relPath) {
|
||||||
return financeUrl(String(relPath || '').replace(/\.html?$/i, ''))
|
return financeUrl(String(relPath || '').replace(/\.html?$/i, ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLocalInfo() {
|
function readLocalState() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(INFO_CACHE_KEY)
|
const raw = localStorage.getItem(INFO_CACHE_KEY)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw)
|
||||||
if (parsed && parsed.info && Array.isArray(parsed.info.categories)) return parsed.info
|
const info = parsed?.info
|
||||||
|
if (info && Array.isArray(info.categories)) {
|
||||||
|
return { info, checkedAt: Number(parsed.checkedAt) || 0 }
|
||||||
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeLocalInfo(info) {
|
function writeLocalState(info, checkedAt) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(INFO_CACHE_KEY, JSON.stringify({ ts: Date.now(), info }))
|
localStorage.setItem(INFO_CACHE_KEY, JSON.stringify({ ts: Date.now(), checkedAt, info }))
|
||||||
} catch { /* 配额满等忽略 */ }
|
} catch { /* 配额满等忽略 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,24 +80,64 @@ async function fetchInfoFrom(url) {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拉取 (并缓存) info.json: 远端优先, 失败回退同源内置快照
|
// 用新索引替换模块状态, 通知所有订阅者, 并清理旧版本文件缓存
|
||||||
export async function fetchIfinanceInfo() {
|
function applyInfo(info, checkedAt) {
|
||||||
|
memoInfo = info
|
||||||
|
memoCheckedAt = checkedAt
|
||||||
|
writeLocalState(info, checkedAt)
|
||||||
|
pruneFileCache(info)
|
||||||
|
for (const fn of listeners) {
|
||||||
|
try { fn({ info, checkedAt }) } catch { /* 单个订阅者异常不影响其他人 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅索引变化 (宏观 tab / 详情页 / 「我」页跨组件同步)
|
||||||
|
export function subscribeIfinanceInfo(fn) {
|
||||||
|
listeners.add(fn)
|
||||||
|
return () => { listeners.delete(fn) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理不在当前 files[] 里的旧缓存 (按 hash 内容寻址, 旧文件已无引用)
|
||||||
|
async function pruneFileCache(info) {
|
||||||
|
if (typeof caches === 'undefined') return
|
||||||
|
const files = Array.isArray(info?.files) ? info.files : []
|
||||||
|
if (!files.length) return
|
||||||
|
const keep = new Set(files.map((f) => String(f?.hash || '').toLowerCase()).filter(Boolean))
|
||||||
|
try {
|
||||||
|
const cache = await caches.open(FILE_CACHE_NAME)
|
||||||
|
const reqs = await cache.keys()
|
||||||
|
await Promise.all(reqs.map((req) => {
|
||||||
|
const key = req.url.split('/').pop() || ''
|
||||||
|
return key && !keep.has(key.toLowerCase()) ? cache.delete(req) : null
|
||||||
|
}))
|
||||||
|
} catch { /* 缓存不可用/被清理时忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取 (并缓存) info.json: 内存 → 远端 → localStorage 快照 → 同源内置快照
|
||||||
|
export async function fetchIfinanceInfo({ force = false } = {}) {
|
||||||
|
if (!force && memoInfo) return memoInfo
|
||||||
if (inflight) return inflight
|
if (inflight) return inflight
|
||||||
inflight = (async () => {
|
inflight = (async () => {
|
||||||
let info
|
|
||||||
try {
|
try {
|
||||||
info = await fetchInfoFrom(financeUrl('info.json'))
|
const info = await fetchInfoFrom(financeUrl('info.json'))
|
||||||
|
applyInfo(info, Date.now())
|
||||||
|
return info
|
||||||
} catch (remoteErr) {
|
} catch (remoteErr) {
|
||||||
// 兜底: 同源内置快照 (ifinance 未开 CORS / 临时不可达时仍可渲染分类)
|
const cached = readLocalState()
|
||||||
|
if (cached?.info) {
|
||||||
|
// 远端不可用: 继续用上次成功快照 (保留 checkedAt, 下次还会重试)
|
||||||
|
memoInfo = cached.info
|
||||||
|
memoCheckedAt = cached.checkedAt
|
||||||
|
return cached.info
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
info = await fetchInfoFrom(FALLBACK_INFO_URL)
|
const info = await fetchInfoFrom(FALLBACK_INFO_URL)
|
||||||
|
applyInfo(info, 0)
|
||||||
|
return info
|
||||||
} catch {
|
} catch {
|
||||||
throw remoteErr
|
throw remoteErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
memoInfo = info
|
|
||||||
writeLocalInfo(info)
|
|
||||||
return info
|
|
||||||
})()
|
})()
|
||||||
try {
|
try {
|
||||||
return await inflight
|
return await inflight
|
||||||
@@ -87,30 +146,150 @@ export async function fetchIfinanceInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手动/每日刷新: 强制拉远端 info.json。失败直接抛出, 让调用方提示;
|
||||||
|
// 成功后返回是否发生变化 (buildHash 不变 = 内容没变)。
|
||||||
|
export async function refreshIfinanceInfo() {
|
||||||
|
const previous = memoInfo || readLocalState()?.info || null
|
||||||
|
let info
|
||||||
|
try {
|
||||||
|
info = await fetchInfoFrom(financeUrl('info.json'))
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('ifinance 索引更新失败: ' + (e?.message || '网络不可用'))
|
||||||
|
}
|
||||||
|
applyInfo(info, Date.now())
|
||||||
|
return {
|
||||||
|
info,
|
||||||
|
previous,
|
||||||
|
changed: !previous || previous.buildHash !== info.buildHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// React Hook: 获取分类索引。优先内存 → localStorage 快照 → 远端
|
// React Hook: 获取分类索引。优先内存 → localStorage 快照 → 远端
|
||||||
|
// 订阅模块级更新: 手动刷新/每日刷新后, 已挂载的页面会自动重渲染。
|
||||||
export function useIfinanceInfo() {
|
export function useIfinanceInfo() {
|
||||||
const [state, setState] = useState(() => {
|
const [state, setState] = useState(() => {
|
||||||
if (memoInfo) return { info: memoInfo, loading: false, error: '' }
|
const cached = readLocalState()
|
||||||
const cached = readLocalInfo()
|
return {
|
||||||
return { info: cached, loading: true, error: '' }
|
info: memoInfo || cached?.info || null,
|
||||||
|
loading: !(memoInfo || cached?.info),
|
||||||
|
error: '',
|
||||||
|
checkedAt: memoCheckedAt || cached?.checkedAt || 0,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true
|
let alive = true
|
||||||
setState((s) => ({ ...s, loading: true }))
|
const unsubscribe = subscribeIfinanceInfo(({ info, checkedAt }) => {
|
||||||
|
if (alive) setState({ info, loading: false, error: '', checkedAt })
|
||||||
|
})
|
||||||
fetchIfinanceInfo()
|
fetchIfinanceInfo()
|
||||||
.then((info) => { if (alive) setState({ info, loading: false, error: '' }) })
|
.then((info) => {
|
||||||
|
if (!alive) return
|
||||||
|
setState((s) => ({ ...s, info, loading: false, error: '' }))
|
||||||
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
const cached = readLocalInfo()
|
setState((s) => ({
|
||||||
setState({
|
...s,
|
||||||
info: cached,
|
|
||||||
loading: false,
|
loading: false,
|
||||||
error: cached ? '' : (e?.message || '外接模块加载失败'),
|
error: s.info ? '' : (e?.message || '外接模块加载失败'),
|
||||||
})
|
}))
|
||||||
})
|
})
|
||||||
return () => { alive = false }
|
return () => { alive = false; unsubscribe() }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// Service Worker: 把按需缓存的 ifinance 文件以同源 URL 提供给 iframe
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
let swPromise = null
|
||||||
|
|
||||||
|
// 注册/等待 SW 就绪。返回 true 表示 iframe 可以走 /__ifinance__/ 缓存入口。
|
||||||
|
// 任何环境不支持 (或被策略禁用) 都返回 false, 上层回退到远端直连。
|
||||||
|
export function initIfinanceCache() {
|
||||||
|
if (swPromise) return swPromise
|
||||||
|
if (
|
||||||
|
typeof navigator === 'undefined' ||
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
!('serviceWorker' in navigator) ||
|
||||||
|
typeof caches === 'undefined'
|
||||||
|
) {
|
||||||
|
swPromise = Promise.resolve(false)
|
||||||
|
return swPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
swPromise = (async () => {
|
||||||
|
try {
|
||||||
|
await navigator.serviceWorker.register(SW_URL, { scope: '/', updateViaCache: 'none' })
|
||||||
|
await Promise.race([
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error('service worker timeout')), 4000)),
|
||||||
|
])
|
||||||
|
if (navigator.serviceWorker.controller) return true
|
||||||
|
// 首次安装: 等待 activate 里的 clients.claim() 接管当前页面
|
||||||
|
const claimed = await new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => resolve(false), 1500)
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve(true)
|
||||||
|
}, { once: true })
|
||||||
|
})
|
||||||
|
return claimed || !!navigator.serviceWorker.controller
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ifinance] 缓存不可用, 回退远端直连:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return swPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileType(relPath) {
|
||||||
|
return /\.json$/i.test(relPath) ? 'application/json' : 'text/html'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存入口 URL (给 iframe 用)。hash 优先取 files[] 清单 (唯一事实来源),
|
||||||
|
// 其次退回 items[] 自带的 hash: 索引更新后 hash 变化 → 自动读取新文件,
|
||||||
|
// 不会命中旧缓存。
|
||||||
|
export function financeItemSrc(item) {
|
||||||
|
const rel = String(item?.html || '').replace(/^\/+/, '')
|
||||||
|
if (!rel) return ''
|
||||||
|
const file = (memoInfo?.files || []).find((f) => f.path === rel)
|
||||||
|
const hash = file?.hash || item?.hash
|
||||||
|
if (!hash) return financePageUrl(rel)
|
||||||
|
const params = new URLSearchParams({ h: String(hash), t: file?.type || fileType(rel) })
|
||||||
|
return PAGE_CACHE_PREFIX + rel + '?' + params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 每日自动刷新: 启动后检查一次, 之后每 30 分钟看一次是否已过 24h
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
let autoRefreshStarted = false
|
||||||
|
|
||||||
|
export function startIfinanceAutoRefresh() {
|
||||||
|
if (autoRefreshStarted || typeof window === 'undefined') return
|
||||||
|
autoRefreshStarted = true
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.onLine === false) return
|
||||||
|
const cached = readLocalState()
|
||||||
|
const last = memoCheckedAt || cached?.checkedAt || 0
|
||||||
|
if (!memoInfo && !cached?.info) {
|
||||||
|
// 还没索引: 先拉一次 (不阻塞启动)
|
||||||
|
fetchIfinanceInfo().catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Date.now() - last >= DAY_MS) {
|
||||||
|
refreshIfinanceInfo().catch((e) => console.warn('[ifinance] 自动更新失败:', e?.message || e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(check, 4000)
|
||||||
|
setInterval(check, 30 * 60 * 1000)
|
||||||
|
window.addEventListener('online', check)
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (!document.hidden) check()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BrowserRouter } from 'react-router-dom'
|
|||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import { AuthProvider } from './auth/AuthContext.jsx'
|
import { AuthProvider } from './auth/AuthContext.jsx'
|
||||||
import { initOta } from './api/ota.js'
|
import { initOta } from './api/ota.js'
|
||||||
|
import { startIfinanceAutoRefresh } from './lib/ifinance.js'
|
||||||
import './styles/globals.css'
|
import './styles/globals.css'
|
||||||
|
|
||||||
// OTA 启动: 仅 Tauri 内生效
|
// OTA 启动: 仅 Tauri 内生效
|
||||||
@@ -14,6 +15,9 @@ import './styles/globals.css'
|
|||||||
// - 异步执行, 不阻塞 React 挂载
|
// - 异步执行, 不阻塞 React 挂载
|
||||||
initOta()
|
initOta()
|
||||||
|
|
||||||
|
// ifinance 索引每日自动更新 (启动 4s 后检查一次, 之后每 30 分钟看是否已过 24h)
|
||||||
|
startIfinanceAutoRefresh()
|
||||||
|
|
||||||
// ==================== 顶部状态栏安全区校准 ====================
|
// ==================== 顶部状态栏安全区校准 ====================
|
||||||
// 背景: Android WebView (Tauri) 下 env(safe-area-inset-top) 常返回 0,
|
// 背景: Android WebView (Tauri) 下 env(safe-area-inset-top) 常返回 0,
|
||||||
// 而 antd-mobile 的 <SafeArea> 只是个依赖该变量的空 div, 于是内容会顶到状态栏下.
|
// 而 antd-mobile 的 <SafeArea> 只是个依赖该变量的空 div, 于是内容会顶到状态栏下.
|
||||||
|
|||||||
@@ -13,6 +13,30 @@ import {
|
|||||||
rollbackOta,
|
rollbackOta,
|
||||||
setOtaDevMode,
|
setOtaDevMode,
|
||||||
} from '../api/ota.js'
|
} from '../api/ota.js'
|
||||||
|
import { refreshIfinanceInfo, useIfinanceInfo } from '../lib/ifinance.js'
|
||||||
|
|
||||||
|
// ifinance 构建时间: 优先按 info.json 里的 ISO 字符串原样展示 (北京时间),
|
||||||
|
// 避免设备时区不同导致显示偏移。
|
||||||
|
function fmtIfinanceTime(value) {
|
||||||
|
if (!value) return '—'
|
||||||
|
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/)
|
||||||
|
if (m) return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}`
|
||||||
|
const d = new Date(value)
|
||||||
|
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCheckTime(ts) {
|
||||||
|
if (!ts) return '—'
|
||||||
|
const d = new Date(ts)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
|
|
||||||
@@ -120,6 +144,32 @@ export default function MePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ifinance 外部数据索引: 版本/构建时间 + 手动更新
|
||||||
|
const {
|
||||||
|
info: ifinanceInfo,
|
||||||
|
checkedAt: ifinanceCheckedAt,
|
||||||
|
error: ifinanceError,
|
||||||
|
} = useIfinanceInfo()
|
||||||
|
const [ifinanceBusy, setIfinanceBusy] = useState(false)
|
||||||
|
|
||||||
|
async function onRefreshIfinance() {
|
||||||
|
if (ifinanceBusy) return
|
||||||
|
setIfinanceBusy(true)
|
||||||
|
try {
|
||||||
|
const r = await refreshIfinanceInfo()
|
||||||
|
Toast.show({
|
||||||
|
icon: 'success',
|
||||||
|
content: r.changed
|
||||||
|
? 'ifinance 已更新到 v' + (r.info?.version || '')
|
||||||
|
: 'ifinance 已是最新 (v' + (r.info?.version || '—') + ')',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ icon: 'fail', content: e?.message || 'ifinance 更新失败' })
|
||||||
|
} finally {
|
||||||
|
setIfinanceBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onLogout() {
|
async function onLogout() {
|
||||||
const confirmed = window.confirm('确定退出登录?')
|
const confirmed = window.confirm('确定退出登录?')
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
@@ -147,6 +197,28 @@ export default function MePage() {
|
|||||||
<div className="me-email">{user?.email}</div>
|
<div className="me-email">{user?.email}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ifinance 外部数据索引: 点击版本行手动更新 info.json (每日也会自动更新一次) */}
|
||||||
|
<List className="me-list" mode="card" header="外部数据 (ifinance)">
|
||||||
|
<List.Item
|
||||||
|
clickable
|
||||||
|
onClick={onRefreshIfinance}
|
||||||
|
extra={ifinanceBusy ? '更新中…' : 'v' + (ifinanceInfo?.version || '—')}
|
||||||
|
description={ifinanceBusy ? '正在拉取 info.json…' : '点击检查并更新索引'}
|
||||||
|
>
|
||||||
|
ifinance 版本
|
||||||
|
</List.Item>
|
||||||
|
<List.Item extra={fmtIfinanceTime(ifinanceInfo?.generatedAt)}>构建时间</List.Item>
|
||||||
|
{ifinanceInfo?.buildId && (
|
||||||
|
<List.Item extra={ifinanceInfo.buildId}>构建号</List.Item>
|
||||||
|
)}
|
||||||
|
<List.Item extra={fmtCheckTime(ifinanceCheckedAt)}>上次检查</List.Item>
|
||||||
|
{ifinanceError && (
|
||||||
|
<List.Item>
|
||||||
|
<span className="me-ota-error">{ifinanceError}</span>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
{/* OTA 升级区 */}
|
{/* OTA 升级区 */}
|
||||||
<List className="me-list" mode="card" header="前端升级 (OTA)">
|
<List className="me-list" mode="card" header="前端升级 (OTA)">
|
||||||
<List.Item extra={'v' + cur.version + (cur.build ? ' · build ' + cur.build : '')}>当前版本</List.Item>
|
<List.Item extra={'v' + cur.version + (cur.build ? ' · build ' + cur.build : '')}>当前版本</List.Item>
|
||||||
|
|||||||
@@ -1,18 +1,45 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { NavBar, DotLoading } from 'antd-mobile'
|
import { NavBar, DotLoading } from 'antd-mobile'
|
||||||
import { useIfinanceInfo, financePageUrl } from '../../lib/ifinance.js'
|
import {
|
||||||
|
useIfinanceInfo,
|
||||||
|
financeItemSrc,
|
||||||
|
financePageUrl,
|
||||||
|
initIfinanceCache,
|
||||||
|
} from '../../lib/ifinance.js'
|
||||||
|
|
||||||
// 外接条目详情: 用 iframe 加载 ifinance 生成的独立 HTML。
|
// 外接条目详情: 用 iframe 加载 ifinance 生成的独立 HTML。
|
||||||
// - 页面本身无导航/头部 (符合 ifinance 规范), 由宿主提供 NavBar 返回
|
// - 页面本身无导航/头部 (符合 ifinance 规范), 由宿主提供 NavBar 返回
|
||||||
// - 直接指向远端地址 (纯前端直连, 不做端上缓存)
|
// - 优先走 Service Worker 缓存入口 (/__ifinance__/...), 只按需下载当前页面
|
||||||
|
// SW 响应带 `Content-Security-Policy: sandbox allow-scripts`, 页面处于不透明源,
|
||||||
|
// 行内脚本可以运行 (看板图表需要), 但无法访问宿主数据
|
||||||
|
// - SW 不可用时回退到远端地址 (功能不受影响, 只是没有本地缓存)
|
||||||
export default function FinanceItemView() {
|
export default function FinanceItemView() {
|
||||||
const { catId, itemId } = useParams()
|
const { catId, itemId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { info, loading, error } = useIfinanceInfo()
|
const { info, loading, error } = useIfinanceInfo()
|
||||||
|
const [src, setSrc] = useState('')
|
||||||
|
|
||||||
const cat = (info?.categories || []).find((c) => c.id === catId)
|
const cat = (info?.categories || []).find((c) => c.id === catId)
|
||||||
const item = (cat?.items || []).find((i) => i.id === itemId)
|
const item = (cat?.items || []).find((i) => i.id === itemId)
|
||||||
const title = item?.title || '详情'
|
const title = item?.title || '详情'
|
||||||
|
const html = item?.html || ''
|
||||||
|
|
||||||
|
// 条目/索引变化时重新决定 iframe 地址
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true
|
||||||
|
setSrc('')
|
||||||
|
if (!html) return () => { alive = false }
|
||||||
|
|
||||||
|
initIfinanceCache().then((cacheReady) => {
|
||||||
|
if (!alive) return
|
||||||
|
setSrc(cacheReady ? financeItemSrc(item) : financePageUrl(html))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => { alive = false }
|
||||||
|
// item 对象每次渲染都会重建, 用 html/hash 作为依赖
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [html, item?.hash, info?.buildId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="finance-item-page">
|
<div className="finance-item-page">
|
||||||
@@ -20,11 +47,13 @@ export default function FinanceItemView() {
|
|||||||
{loading && !item && <div className="loading"><DotLoading /> 加载中...</div>}
|
{loading && !item && <div className="loading"><DotLoading /> 加载中...</div>}
|
||||||
{!loading && error && <div className="error-banner">{error}</div>}
|
{!loading && error && <div className="error-banner">{error}</div>}
|
||||||
{!loading && !error && !item && <div className="empty">内容不存在</div>}
|
{!loading && !error && !item && <div className="empty">内容不存在</div>}
|
||||||
{item?.html && (
|
{html && !src && !error && <div className="loading"><DotLoading /> 加载中...</div>}
|
||||||
|
{html && src && (
|
||||||
<iframe
|
<iframe
|
||||||
|
key={src}
|
||||||
className="finance-frame"
|
className="finance-frame"
|
||||||
title={title}
|
title={title}
|
||||||
src={financePageUrl(item.html)}
|
src={src}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user