'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 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 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));
}
} 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; // 立即恢复
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;
});
};
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)}秒
{funds.length === 0 ? (
尚未添加基金
) : (
{funds.map((f) => (
{f.name}
#{f.code}
估值时间
{f.gztime || f.time || '-'}
toggleCollapse(f.code)}
>
{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="自定义秒数"
/>
)}
);
}
|