'use client'; import { useEffect, useRef, useState } from 'react'; function PlusIcon(props) { return ( ); } function TrashIcon(props) { return ( ); } function SettingsIcon(props) { return ( ); } function RefreshIcon(props) { return ( ); } function Stat({ label, value, delta }) { const dir = delta > 0 ? 'up' : delta < 0 ? 'down' : ''; return (
{label} {value} {typeof delta === 'number' && ( {delta > 0 ? '↗' : delta < 0 ? '↘' : '—'} {Math.abs(delta).toFixed(2)}% )}
); } export default function HomePage() { const [funds, setFunds] = useState([]); const [code, setCode] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const timerRef = useRef(null); const [refreshMs, setRefreshMs] = useState(30000); const [settingsOpen, setSettingsOpen] = useState(false); const [tempSeconds, setTempSeconds] = useState(30); const [manualRefreshing, setManualRefreshing] = useState(false); const [useJsonp, setUseJsonp] = useState(false); const [apiBase, setApiBase] = useState('/api'); const [tempApiBase, setTempApiBase] = useState('/api'); const buildUrl = (path) => { const base = (apiBase || '/api').replace(/\/$/, ''); const p = path.startsWith('/') ? path : `/${path}`; return `${base}${p}`; }; useEffect(() => { try { const saved = JSON.parse(localStorage.getItem('funds') || '[]'); if (Array.isArray(saved) && saved.length) { setFunds(saved); refreshAll(saved.map((f) => f.code)); } const savedMs = parseInt(localStorage.getItem('refreshMs') || '30000', 10); if (Number.isFinite(savedMs) && savedMs > 0) { setRefreshMs(savedMs); setTempSeconds(Math.round(savedMs / 1000)); } const savedApi = localStorage.getItem('apiBase'); if (savedApi && typeof savedApi === 'string') { setApiBase(savedApi); setTempApiBase(savedApi); } const savedJsonp = localStorage.getItem('useJsonp'); if (savedJsonp === 'true') setUseJsonp(true); } catch {} }, []); useEffect(() => { if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => { const codes = funds.map((f) => f.code); if (codes.length) refreshAll(codes); }, refreshMs); return () => { if (timerRef.current) clearInterval(timerRef.current); }; }, [funds, refreshMs]); const loadScript = (src, timeoutMs = 8000) => { return new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = src; s.async = true; let done = false; const clear = () => { if (s.parentNode) s.parentNode.removeChild(s); }; const timer = setTimeout(() => { if (done) return; done = true; clear(); reject(new Error('JSONP 加载超时')); }, timeoutMs); s.onload = () => { if (done) return; done = true; clearTimeout(timer); clear(); resolve(); }; s.onerror = () => { if (done) return; done = true; clearTimeout(timer); clear(); reject(new Error('JSONP 加载失败')); }; document.head.appendChild(s); }); }; const getFundGZByJsonp = async (c) => { return new Promise(async (resolve, reject) => { const prev = window.jsonpgz; try { window.jsonpgz = (json) => { try { const gszzlNum = Number(json.gszzl); const data = { code: json.fundcode, name: json.name, dwjz: json.dwjz, gsz: json.gsz, gztime: json.gztime, gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl }; resolve(data); } catch (e) { reject(e); } finally { window.jsonpgz = prev; } }; await loadScript(`https://fundgz.1234567.com.cn/js/${encodeURIComponent(c)}.js`); // 如果脚本未触发回调 setTimeout(() => { reject(new Error('估值 JSONP 未返回')); window.jsonpgz = prev; }, 0); } catch (e) { window.jsonpgz = prev; reject(e); } }); }; const stripHtml = (s) => s.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); const parseHoldingsHtml = (html) => { const list = []; const tableMatch = html.match(//i); const table = tableMatch ? tableMatch[0] : html; const rows = table.match(//gi) || []; for (const r of rows) { const cells = [...r.matchAll(/([\s\S]*?)<\/td>/gi)].map((m) => stripHtml(m[1])); if (!cells.length) continue; const codeIdx = cells.findIndex((c) => /^\d{6}$/.test(c)); const weightIdx = cells.findIndex((c) => /\d+(?:\.\d+)?\s*%/.test(c)); const code = codeIdx >= 0 ? cells[codeIdx] : null; const name = codeIdx >= 0 && codeIdx + 1 < cells.length ? cells[codeIdx + 1] : null; const weight = weightIdx >= 0 ? cells[weightIdx].replace(/\s+/g, '') : null; if (code && (name || name === '') && weight) { list.push({ code, name, weight }); } else { const anchorNameMatch = r.match(/]*?>([^<]+)<\/a>/i); const altName = anchorNameMatch ? stripHtml(anchorNameMatch[1]) : null; const codeMatch = r.match(/(\d{6})/); const weightMatch = r.match(/(\d+(?:\.\d+)?)\s*%/); const fallbackCode = codeMatch ? codeMatch[1] : null; const fallbackWeight = weightMatch ? `${weightMatch[1]}%` : null; if ((code || fallbackCode) && (name || altName) && (weight || fallbackWeight)) { list.push({ code: code || fallbackCode, name: name || altName, weight: weight || fallbackWeight }); } } } return list.slice(0, 10); }; const getHoldingsByJsonp = async (c) => { return new Promise(async (resolve, reject) => { try { // Eastmoney 返回形如 var apidata={content:'...
'} // 先清理可能残留的变量 delete window.apidata; await loadScript(`https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${encodeURIComponent(c)}&topline=10&year=&month=&rt=${Date.now()}`); const content = window.apidata?.content || ''; const m = content.match(//i) || content.match(/content:\s*'([\s\S]*?)'/i); const html = m ? (m[0].startsWith(' { if (useJsonp) { const gz = await getFundGZByJsonp(c); const holdings = await getHoldingsByJsonp(c); return { ...gz, holdings }; } const res = await fetch(buildUrl(`/fund?code=${encodeURIComponent(c)}`), { cache: 'no-store' }); if (!res.ok) throw new Error('网络错误'); return await res.json(); }; const refreshAll = async (codes) => { try { const updated = await Promise.all( codes.map((c) => fetchFundData(c)) ); setFunds(updated); localStorage.setItem('funds', JSON.stringify(updated)); } catch (e) { console.error(e); } }; const addFund = async (e) => { e.preventDefault(); setError(''); const clean = code.trim(); if (!clean) { setError('请输入基金编号'); return; } if (funds.some((f) => f.code === clean)) { setError('该基金已添加'); return; } setLoading(true); try { const data = await fetchFundData(clean); const next = [data, ...funds]; setFunds(next); localStorage.setItem('funds', JSON.stringify(next)); setCode(''); } catch (e) { setError(e.message || '添加失败'); } finally { setLoading(false); } }; const removeFund = (removeCode) => { const next = funds.filter((f) => f.code !== removeCode); setFunds(next); localStorage.setItem('funds', JSON.stringify(next)); }; const manualRefresh = async () => { if (manualRefreshing) return; const codes = funds.map((f) => f.code); if (!codes.length) return; setManualRefreshing(true); try { await refreshAll(codes); } finally { setManualRefreshing(false); } }; const saveSettings = (e) => { e?.preventDefault?.(); const ms = Math.max(5, Number(tempSeconds)) * 1000; setRefreshMs(ms); localStorage.setItem('refreshMs', String(ms)); const nextApi = (tempApiBase || '/api').trim(); setApiBase(nextApi); localStorage.setItem('apiBase', nextApi); localStorage.setItem('useJsonp', useJsonp ? 'true' : 'false'); setSettingsOpen(false); }; useEffect(() => { const onKey = (ev) => { if (ev.key === 'Escape' && settingsOpen) setSettingsOpen(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [settingsOpen]); return (
实时基金估值
刷新 {Math.round(refreshMs / 1000)}秒
添加基金 输入基金编号(例如:110022)
setCode(e.target.value)} inputMode="numeric" aria-invalid={!!error} />
{error &&
{error}
}
{funds.length === 0 ? (
尚未添加基金
) : (
{funds.map((f) => (
{f.name} #{f.code}
估值时间 {f.gztime || f.time || '-'}
前10重仓股票 持仓占比
{Array.isArray(f.holdings) && f.holdings.length ? (
{f.holdings.map((h, idx) => (
{h.name ? h.name : h.code} {h.code ? ` (${h.code})` : ''} {h.weight}
))}
) : (
暂无重仓数据
)}
))}
)}
数据源:基金估值与重仓来自东方财富公开接口,可能存在延迟
{settingsOpen && (
setSettingsOpen(false)}>
e.stopPropagation()}>
设置 数据源与刷新频率
数据源 跨域环境推荐 JSONP 直连
{[ { key: 'api', label: '后端 API(推荐)', active: !useJsonp }, { key: 'jsonp', label: 'JSONP 直连', active: useJsonp } ].map((opt) => ( ))}
{!useJsonp && (
setTempApiBase(e.target.value)} placeholder="/api 或 https://你的域名/api" />
)}
{[10, 30, 60, 120, 300].map((s) => ( ))}
setTempSeconds(Number(e.target.value))} placeholder="秒数(≥5)" />
)}
); }