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
+147
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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, '&amp;').replace(/</g, '&lt;') +
'</code></p></body></html>'
}
+210 -31
View File
@@ -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()
})
}
+4
View File
@@ -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, 于是内容会顶到状态栏下.
+72
View File
@@ -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>
+33 -4
View File
@@ -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"
/> />
)} )}