'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 ChevronIcon(props) { return ( ); } function StarIcon({ filled, ...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 [refreshing, setRefreshing] = useState(false); // 收起/展开状态 const [collapsedCodes, setCollapsedCodes] = useState(new Set()); // 自选状态 const [favorites, setFavorites] = useState(new Set()); const [currentTab, setCurrentTab] = useState('all'); const toggleFavorite = (code) => { setFavorites(prev => { const next = new Set(prev); if (next.has(code)) { next.delete(code); } else { next.add(code); } localStorage.setItem('favorites', JSON.stringify(Array.from(next))); if (next.size === 0) setCurrentTab('all'); return next; }); }; const toggleCollapse = (code) => { setCollapsedCodes(prev => { const next = new Set(prev); if (next.has(code)) { next.delete(code); } else { next.add(code); } // 同步到本地存储 localStorage.setItem('collapsedCodes', JSON.stringify(Array.from(next))); return next; }); }; 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 >= 5000) { setRefreshMs(savedMs); setTempSeconds(Math.round(savedMs / 1000)); } // 加载收起状态 const savedCollapsed = JSON.parse(localStorage.getItem('collapsedCodes') || '[]'); if (Array.isArray(savedCollapsed)) { setCollapsedCodes(new Set(savedCollapsed)); } // 加载自选状态 const savedFavorites = JSON.parse(localStorage.getItem('favorites') || '[]'); if (Array.isArray(savedFavorites)) { setFavorites(new Set(savedFavorites)); } } 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]); // --- 辅助:JSONP 数据抓取逻辑 --- const loadScript = (url) => { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = url; script.async = true; script.onload = () => { document.body.removeChild(script); resolve(); }; script.onerror = () => { document.body.removeChild(script); reject(new Error('数据加载失败')); }; document.body.appendChild(script); }); }; const fetchFundData = async (c) => { return new Promise(async (resolve, reject) => { // 腾讯接口识别逻辑优化 const getTencentPrefix = (code) => { if (code.startsWith('6') || code.startsWith('9')) return 'sh'; if (code.startsWith('0') || code.startsWith('3')) return 'sz'; if (code.startsWith('4') || code.startsWith('8')) return 'bj'; return 'sz'; }; const gzUrl = `https://fundgz.1234567.com.cn/js/${c}.js?rt=${Date.now()}`; // 使用更安全的方式处理全局回调,避免并发覆盖 const currentCallback = `jsonpgz_${c}_${Math.random().toString(36).slice(2, 7)}`; // 动态拦截并处理 jsonpgz 回调 const scriptGz = document.createElement('script'); // 东方财富接口固定调用 jsonpgz,我们通过修改全局变量临时捕获它 scriptGz.src = gzUrl; const originalJsonpgz = window.jsonpgz; window.jsonpgz = (json) => { window.jsonpgz = originalJsonpgz; // 立即恢复 if (!json || typeof json !== 'object') { reject(new Error('未获取到基金估值数据')); return; } const gszzlNum = Number(json.gszzl); const gzData = { code: json.fundcode, name: json.name, dwjz: json.dwjz, gsz: json.gsz, gztime: json.gztime, gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl }; // 获取重仓股票列表 const holdingsUrl = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${c}&topline=10&year=&month=&rt=${Date.now()}`; loadScript(holdingsUrl).then(async () => { let holdings = []; const html = window.apidata?.content || ''; const rows = html.match(//gi) || []; for (const r of rows) { const cells = (r.match(/([\s\S]*?)<\/td>/gi) || []).map(td => td.replace(/<[^>]*>/g, '').trim()); const codeIdx = cells.findIndex(txt => /^\d{6}$/.test(txt)); const weightIdx = cells.findIndex(txt => /\d+(?:\.\d+)?\s*%/.test(txt)); if (codeIdx >= 0 && weightIdx >= 0) { holdings.push({ code: cells[codeIdx], name: cells[codeIdx + 1] || '', weight: cells[weightIdx], change: null }); } } holdings = holdings.slice(0, 10); if (holdings.length) { try { const tencentCodes = holdings.map(h => `s_${getTencentPrefix(h.code)}${h.code}`).join(','); const quoteUrl = `https://qt.gtimg.cn/q=${tencentCodes}`; await new Promise((resQuote) => { const scriptQuote = document.createElement('script'); scriptQuote.src = quoteUrl; scriptQuote.onload = () => { holdings.forEach(h => { const varName = `v_s_${getTencentPrefix(h.code)}${h.code}`; const dataStr = window[varName]; if (dataStr) { const parts = dataStr.split('~'); // parts[5] 是涨跌幅 if (parts.length > 5) { h.change = parseFloat(parts[5]); } } }); if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote); resQuote(); }; scriptQuote.onerror = () => { if (document.body.contains(scriptQuote)) document.body.removeChild(scriptQuote); resQuote(); }; document.body.appendChild(scriptQuote); }); } catch (e) { console.error('获取股票涨跌幅失败', e); } } resolve({ ...gzData, holdings }); }).catch(() => resolve({ ...gzData, holdings: [] })); }; scriptGz.onerror = () => { window.jsonpgz = originalJsonpgz; if (document.body.contains(scriptGz)) document.body.removeChild(scriptGz); reject(new Error('基金数据加载失败')); }; document.body.appendChild(scriptGz); // 加载完立即移除脚本 setTimeout(() => { if (document.body.contains(scriptGz)) document.body.removeChild(scriptGz); }, 5000); }); }; const refreshAll = async (codes) => { if (refreshing) return; setRefreshing(true); try { // 改用串行请求,避免全局回调 jsonpgz 并发冲突 const updated = []; for (const c of codes) { try { const data = await fetchFundData(c); updated.push(data); } catch (e) { console.error(`刷新基金 ${c} 失败`, e); // 失败时保留旧数据 const old = funds.find(f => f.code === c); if (old) updated.push(old); } } if (updated.length) { setFunds(updated); localStorage.setItem('funds', JSON.stringify(updated)); } } catch (e) { console.error(e); } finally { setRefreshing(false); } }; 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)); // 同步删除展开收起状态 setCollapsedCodes(prev => { if (!prev.has(removeCode)) return prev; const nextSet = new Set(prev); nextSet.delete(removeCode); localStorage.setItem('collapsedCodes', JSON.stringify(Array.from(nextSet))); return nextSet; }); // 同步删除自选状态 setFavorites(prev => { if (!prev.has(removeCode)) return prev; const nextSet = new Set(prev); nextSet.delete(removeCode); localStorage.setItem('favorites', JSON.stringify(Array.from(nextSet))); if (nextSet.size === 0) setCurrentTab('all'); return nextSet; }); }; const manualRefresh = async () => { if (refreshing) return; const codes = funds.map((f) => f.code); if (!codes.length) return; await refreshAll(codes); }; const saveSettings = (e) => { e?.preventDefault?.(); const ms = Math.max(5, Number(tempSeconds)) * 1000; setRefreshMs(ms); localStorage.setItem('refreshMs', String(ms)); setSettingsOpen(false); }; useEffect(() => { const onKey = (ev) => { if (ev.key === 'Escape' && settingsOpen) setSettingsOpen(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [settingsOpen]); return (
{refreshing &&
}
实时基金估值
刷新 {Math.round(refreshMs / 1000)}秒
添加基金 输入基金编号(例如:110022)
setCode(e.target.value)} inputMode="numeric" />
{error &&
{error}
}
{funds.length > 0 && favorites.size > 0 && (
)} {funds.length === 0 ? (
尚未添加基金
) : (
{funds .filter(f => currentTab === 'all' || favorites.has(f.code)) .map((f) => (
{f.name} #{f.code}
估值时间 {f.gztime || f.time || '-'}
toggleCollapse(f.code)} >
前10重仓股票
涨跌幅 / 占比
{Array.isArray(f.holdings) && f.holdings.length ? (
{f.holdings.map((h, idx) => (
{h.name}
{typeof h.change === 'number' && ( 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}> {h.change > 0 ? '+' : ''}{h.change.toFixed(2)}% )} {h.weight}
))}
) : (
暂无重仓数据
)}
))}
)}
数据源:实时估值与重仓直连东方财富,无需后端,部署即用
{settingsOpen && (
setSettingsOpen(false)}>
e.stopPropagation()}>
设置 配置刷新频率
刷新频率
{[10, 30, 60, 120, 300].map((s) => ( ))}
setTempSeconds(Number(e.target.value))} placeholder="自定义秒数" />
)}
); }