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:
+210
-31
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user