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 中打开)
|
||||
// - 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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import { AuthProvider } from './auth/AuthContext.jsx'
|
||||
import { initOta } from './api/ota.js'
|
||||
import { startIfinanceAutoRefresh } from './lib/ifinance.js'
|
||||
import './styles/globals.css'
|
||||
|
||||
// OTA 启动: 仅 Tauri 内生效
|
||||
@@ -14,6 +15,9 @@ import './styles/globals.css'
|
||||
// - 异步执行, 不阻塞 React 挂载
|
||||
initOta()
|
||||
|
||||
// ifinance 索引每日自动更新 (启动 4s 后检查一次, 之后每 30 分钟看是否已过 24h)
|
||||
startIfinanceAutoRefresh()
|
||||
|
||||
// ==================== 顶部状态栏安全区校准 ====================
|
||||
// 背景: Android WebView (Tauri) 下 env(safe-area-inset-top) 常返回 0,
|
||||
// 而 antd-mobile 的 <SafeArea> 只是个依赖该变量的空 div, 于是内容会顶到状态栏下.
|
||||
|
||||
@@ -13,6 +13,30 @@ import {
|
||||
rollbackOta,
|
||||
setOtaDevMode,
|
||||
} 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
|
||||
|
||||
@@ -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() {
|
||||
const confirmed = window.confirm('确定退出登录?')
|
||||
if (!confirmed) return
|
||||
@@ -147,6 +197,28 @@ export default function MePage() {
|
||||
<div className="me-email">{user?.email}</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 升级区 */}
|
||||
<List className="me-list" mode="card" header="前端升级 (OTA)">
|
||||
<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 { NavBar, DotLoading } from 'antd-mobile'
|
||||
import { useIfinanceInfo, financePageUrl } from '../../lib/ifinance.js'
|
||||
import {
|
||||
useIfinanceInfo,
|
||||
financeItemSrc,
|
||||
financePageUrl,
|
||||
initIfinanceCache,
|
||||
} from '../../lib/ifinance.js'
|
||||
|
||||
// 外接条目详情: 用 iframe 加载 ifinance 生成的独立 HTML。
|
||||
// - 页面本身无导航/头部 (符合 ifinance 规范), 由宿主提供 NavBar 返回
|
||||
// - 直接指向远端地址 (纯前端直连, 不做端上缓存)
|
||||
// - 优先走 Service Worker 缓存入口 (/__ifinance__/...), 只按需下载当前页面
|
||||
// SW 响应带 `Content-Security-Policy: sandbox allow-scripts`, 页面处于不透明源,
|
||||
// 行内脚本可以运行 (看板图表需要), 但无法访问宿主数据
|
||||
// - SW 不可用时回退到远端地址 (功能不受影响, 只是没有本地缓存)
|
||||
export default function FinanceItemView() {
|
||||
const { catId, itemId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { info, loading, error } = useIfinanceInfo()
|
||||
const [src, setSrc] = useState('')
|
||||
|
||||
const cat = (info?.categories || []).find((c) => c.id === catId)
|
||||
const item = (cat?.items || []).find((i) => i.id === itemId)
|
||||
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 (
|
||||
<div className="finance-item-page">
|
||||
@@ -20,11 +47,13 @@ export default function FinanceItemView() {
|
||||
{loading && !item && <div className="loading"><DotLoading /> 加载中...</div>}
|
||||
{!loading && error && <div className="error-banner">{error}</div>}
|
||||
{!loading && !error && !item && <div className="empty">内容不存在</div>}
|
||||
{item?.html && (
|
||||
{html && !src && !error && <div className="loading"><DotLoading /> 加载中...</div>}
|
||||
{html && src && (
|
||||
<iframe
|
||||
key={src}
|
||||
className="finance-frame"
|
||||
title={title}
|
||||
src={financePageUrl(item.html)}
|
||||
src={src}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user