feat(宏观): ifinance 版本展示、手动/每日更新与按需文件缓存
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:
jif2.zhang
2026-11-03 15:33:40 +08:00
parent 5622e4b3d4
commit fa172d8974
5 changed files with 466 additions and 35 deletions
+210 -31
View File
@@ -5,11 +5,17 @@
// - 没有统一入口页, 每个 html 都是独立入口 (在 WebView/iframe 中打开)
// - categories[] 对应 App 的 tab, items[] 是条目; 未知字段忽略
//
// 本模块采用「纯前端直连」方式: 直接 fetch 远端 info.json, 条目 HTML 直接
// 用 iframe 加载远端地址, 不做端上文件缓存 (始终最新)。
// 开发环境 (vite dev) 走 /ifinance-api 代理以规避跨域; 生产环境直连。
// 若远端 info.json 拉取失败, 依次回退: 同源内置快照 (public/ifinance-info.json)
// → localStorage 上次成功快照, 保证分类 tab 仍可渲染。
// 缓存策略 (schema 2, 见 ifinance/readme.md「缓存协议」):
// - info.json: 远端优先, 失败回退同源内置快照 → localStorage 上次成功快照;
// 启动时拉一次, 之后每天最多自动检查一次, 「我」页可手动触发
// - 条目文件: 按 files[].hash 内容寻址, 只缓存真正打开过的页面 (按需加载)
// - 页面 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'
// 根地址: 生产直连; 开发用 vite 代理前缀 (见 vite.config.js)
@@ -21,10 +27,20 @@ export const FINANCE_BASE = import.meta.env.DEV
const FALLBACK_INFO_URL = '/ifinance-info.json'
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 memoCheckedAt = 0 // 上次成功检查远端 info.json 的时间戳
let inflight = null
const listeners = new Set()
// 拼接 info.json 中给出的相对路径 (如 public/econ/x.html) 为可加载的绝对 URL
export function financeUrl(relPath) {
@@ -32,24 +48,27 @@ export function financeUrl(relPath) {
return FINANCE_BASE + '/' + p
}
// 条目 HTML 地址: Cloudflare Pages 走 clean URL, 去掉 .html 后缀直达 (避免 307 重定向)
// 条目 HTML 远端地址: Cloudflare Pages 走 clean URL, 去掉 .html 后缀直达 (避免 307)
export function financePageUrl(relPath) {
return financeUrl(String(relPath || '').replace(/\.html?$/i, ''))
}
function readLocalInfo() {
function readLocalState() {
try {
const raw = localStorage.getItem(INFO_CACHE_KEY)
if (!raw) return null
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 */ }
return null
}
function writeLocalInfo(info) {
function writeLocalState(info, checkedAt) {
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 { /* 配额满等忽略 */ }
}
@@ -61,24 +80,64 @@ async function fetchInfoFrom(url) {
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
inflight = (async () => {
let info
try {
info = await fetchInfoFrom(financeUrl('info.json'))
const info = await fetchInfoFrom(financeUrl('info.json'))
applyInfo(info, Date.now())
return info
} catch (remoteErr) {
// 兜底: 同源内置快照 (ifinance 未开 CORS / 临时不可达时仍可渲染分类)
const cached = readLocalState()
if (cached?.info) {
// 远端不可用: 继续用上次成功快照 (保留 checkedAt, 下次还会重试)
memoInfo = cached.info
memoCheckedAt = cached.checkedAt
return cached.info
}
try {
info = await fetchInfoFrom(FALLBACK_INFO_URL)
const info = await fetchInfoFrom(FALLBACK_INFO_URL)
applyInfo(info, 0)
return info
} catch {
throw remoteErr
}
}
memoInfo = info
writeLocalInfo(info)
return info
})()
try {
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 快照 → 远端
// 订阅模块级更新: 手动刷新/每日刷新后, 已挂载的页面会自动重渲染。
export function useIfinanceInfo() {
const [state, setState] = useState(() => {
if (memoInfo) return { info: memoInfo, loading: false, error: '' }
const cached = readLocalInfo()
return { info: cached, loading: true, error: '' }
const cached = readLocalState()
return {
info: memoInfo || cached?.info || null,
loading: !(memoInfo || cached?.info),
error: '',
checkedAt: memoCheckedAt || cached?.checkedAt || 0,
}
})
useEffect(() => {
let alive = true
setState((s) => ({ ...s, loading: true }))
const unsubscribe = subscribeIfinanceInfo(({ info, checkedAt }) => {
if (alive) setState({ info, loading: false, error: '', checkedAt })
})
fetchIfinanceInfo()
.then((info) => { if (alive) setState({ info, loading: false, error: '' }) })
.then((info) => {
if (!alive) return
setState((s) => ({ ...s, info, loading: false, error: '' }))
})
.catch((e) => {
if (!alive) return
const cached = readLocalInfo()
setState({
info: cached,
setState((s) => ({
...s,
loading: false,
error: cached ? '' : (e?.message || '外接模块加载失败'),
})
error: s.info ? '' : (e?.message || '外接模块加载失败'),
}))
})
return () => { alive = false }
return () => { alive = false; unsubscribe() }
}, [])
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()
})
}