Docker Build and Push / build-image (push) Successful in 2m37s
- 宏观只保留本地 新闻/经济文章, 外接 ifinance categories[] 动态成为 tab (纯前端直连, CORS/_headers, https) - 报警从宏观迁到监控第 4 个 tab (保留 Alert 模块, 供未来新增报警源) - 彻底删除油价/经济数据/策略研究: 前端组件+api、后端 apizero/data 路由、cron、fetchers、种子数据 - 新增迁移 DROP Indicator/IndicatorPoint/Strategy/OilPriceHistory; 重新生成 Prisma Client - CSP 放开 *.honor3.com (含端口通配), 避免新增子域反复重装外壳
117 lines
4.0 KiB
JavaScript
117 lines
4.0 KiB
JavaScript
// ifinance 外接模块 (宿主侧)
|
|
// ------------------------------------------------------------
|
|
// ifinance 是静态托管模块包, 唯一索引是 info.json。宿主约定:
|
|
// - 根地址固定为 https://ifinance.honor3.com (宿主常量, 不写进 info.json)
|
|
// - 没有统一入口页, 每个 html 都是独立入口 (在 WebView/iframe 中打开)
|
|
// - categories[] 对应 App 的 tab, items[] 是条目; 未知字段忽略
|
|
//
|
|
// 本模块采用「纯前端直连」方式: 直接 fetch 远端 info.json, 条目 HTML 直接
|
|
// 用 iframe 加载远端地址, 不做端上文件缓存 (始终最新)。
|
|
// 开发环境 (vite dev) 走 /ifinance-api 代理以规避跨域; 生产环境直连。
|
|
// 若远端 info.json 拉取失败, 依次回退: 同源内置快照 (public/ifinance-info.json)
|
|
// → localStorage 上次成功快照, 保证分类 tab 仍可渲染。
|
|
import { useEffect, useState } from 'react'
|
|
|
|
// 根地址: 生产直连; 开发用 vite 代理前缀 (见 vite.config.js)
|
|
export const FINANCE_BASE = import.meta.env.DEV
|
|
? '/ifinance-api'
|
|
: 'https://ifinance.honor3.com'
|
|
|
|
// 同源兜底快照 (构建时由 ifinance/info.json 拷贝到 public/, 仅在远端不可用时使用)
|
|
const FALLBACK_INFO_URL = '/ifinance-info.json'
|
|
|
|
const INFO_CACHE_KEY = 'ifinance_info_v1'
|
|
|
|
// 模块级缓存: 一次会话内多处 (宏观页 / 详情页) 共享, 避免重复请求
|
|
let memoInfo = null
|
|
let inflight = null
|
|
|
|
// 拼接 info.json 中给出的相对路径 (如 public/econ/x.html) 为可加载的绝对 URL
|
|
export function financeUrl(relPath) {
|
|
const p = String(relPath || '').replace(/^\/+/, '')
|
|
return FINANCE_BASE + '/' + p
|
|
}
|
|
|
|
// 条目 HTML 地址: Cloudflare Pages 走 clean URL, 去掉 .html 后缀直达 (避免 307 重定向)
|
|
export function financePageUrl(relPath) {
|
|
return financeUrl(String(relPath || '').replace(/\.html?$/i, ''))
|
|
}
|
|
|
|
function readLocalInfo() {
|
|
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
|
|
} catch { /* ignore */ }
|
|
return null
|
|
}
|
|
|
|
function writeLocalInfo(info) {
|
|
try {
|
|
localStorage.setItem(INFO_CACHE_KEY, JSON.stringify({ ts: Date.now(), info }))
|
|
} catch { /* 配额满等忽略 */ }
|
|
}
|
|
|
|
async function fetchInfoFrom(url) {
|
|
const res = await fetch(url, { cache: 'no-cache' })
|
|
if (!res.ok) throw new Error('ifinance 索引加载失败 (' + res.status + ')')
|
|
const info = await res.json()
|
|
if (!info || !Array.isArray(info.categories)) throw new Error('ifinance 索引格式无效')
|
|
return info
|
|
}
|
|
|
|
// 拉取 (并缓存) info.json: 远端优先, 失败回退同源内置快照
|
|
export async function fetchIfinanceInfo() {
|
|
if (inflight) return inflight
|
|
inflight = (async () => {
|
|
let info
|
|
try {
|
|
info = await fetchInfoFrom(financeUrl('info.json'))
|
|
} catch (remoteErr) {
|
|
// 兜底: 同源内置快照 (ifinance 未开 CORS / 临时不可达时仍可渲染分类)
|
|
try {
|
|
info = await fetchInfoFrom(FALLBACK_INFO_URL)
|
|
} catch {
|
|
throw remoteErr
|
|
}
|
|
}
|
|
memoInfo = info
|
|
writeLocalInfo(info)
|
|
return info
|
|
})()
|
|
try {
|
|
return await inflight
|
|
} finally {
|
|
inflight = null
|
|
}
|
|
}
|
|
|
|
// 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: '' }
|
|
})
|
|
|
|
useEffect(() => {
|
|
let alive = true
|
|
setState((s) => ({ ...s, loading: true }))
|
|
fetchIfinanceInfo()
|
|
.then((info) => { if (alive) setState({ info, loading: false, error: '' }) })
|
|
.catch((e) => {
|
|
if (!alive) return
|
|
const cached = readLocalInfo()
|
|
setState({
|
|
info: cached,
|
|
loading: false,
|
|
error: cached ? '' : (e?.message || '外接模块加载失败'),
|
|
})
|
|
})
|
|
return () => { alive = false }
|
|
}, [])
|
|
|
|
return state
|
|
}
|