diff --git a/app/api/fund/route.js b/app/api/fund/route.js deleted file mode 100644 index e5d98da..0000000 --- a/app/api/fund/route.js +++ /dev/null @@ -1,111 +0,0 @@ -import { NextResponse } from 'next/server'; - -const CORS_HEADERS = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', -}; - -export async function OPTIONS() { - return new NextResponse(null, { - status: 204, - headers: CORS_HEADERS, - }); -} - -async function fetchGZ(code) { - const url = `https://fundgz.1234567.com.cn/js/${code}.js`; - const res = await fetch(url, { cache: 'no-store' }); - if (!res.ok) throw new Error('估值接口异常'); - const text = await res.text(); - const m = text.match(/jsonpgz\((.*)\);/); - if (!m) throw new Error('估值数据解析失败'); - const json = JSON.parse(m[1]); - const gszzlNum = Number(json.gszzl); - return { - code: json.fundcode, - name: json.name, - dwjz: json.dwjz, - gsz: json.gsz, - gztime: json.gztime, - gszzl: Number.isFinite(gszzlNum) ? gszzlNum : json.gszzl - }; -} - -function stripHtml(s) { - return s.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); -} - -function parseHoldings(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); -} - -async function fetchHoldings(code) { - const url = `https://fundf10.eastmoney.com/FundArchivesDatas.aspx?type=jjcc&code=${code}&topline=10&year=&month=&rt=${Date.now()}`; - const res = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0', - 'Accept': '*/*' - }, - cache: 'no-store' - }); - if (!res.ok) throw new Error('重仓接口异常'); - const text = await res.text(); - // The response wraps HTML in var apdfund_...=...; try to extract inner HTML - const m = text.match(//i) || text.match(/content:\s*'([\s\S]*?)'/i); - const html = m ? (m[0].startsWith(' - - - + + + ); } @@ -62,31 +62,12 @@ export default function HomePage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const timerRef = useRef(null); + const [manualRefreshing, setManualRefreshing] = useState(false); + + // 刷新频率状态 const [refreshMs, setRefreshMs] = useState(30000); const [settingsOpen, setSettingsOpen] = useState(false); const [tempSeconds, setTempSeconds] = useState(30); - const [manualRefreshing, setManualRefreshing] = useState(false); - - // --- 新增:API 地址与数据源模式 --- - const [useJsonp, setUseJsonp] = useState(false); - const [apiBase, setApiBase] = useState('/api'); - const [tempApiBase, setTempApiBase] = useState('/api'); - const [tempUseJsonp, setTempUseJsonp] = useState(false); - - // 构造绝对路径的工具函数,避免 GitHub Pages basePath 干扰 - const buildUrl = (path) => { - // 如果 apiBase 是绝对地址,直接拼接 - if (apiBase.startsWith('http://') || apiBase.startsWith('https://')) { - const base = apiBase.replace(/\/$/, ''); - const p = path.startsWith('/') ? path : `/${path}`; - return `${base}${p}`; - } - // 否则拼接当前 origin 确保是绝对地址 - const origin = typeof window !== 'undefined' ? window.location.origin : ''; - const base = apiBase.startsWith('/') ? apiBase : `/${apiBase}`; - const p = path.startsWith('/') ? path : `/${path}`; - return `${origin}${base.replace(/\/$/, '')}${p}`; - }; useEffect(() => { try { @@ -96,16 +77,10 @@ export default function HomePage() { refreshAll(saved.map((f) => f.code)); } const savedMs = parseInt(localStorage.getItem('refreshMs') || '30000', 10); - if (Number.isFinite(savedMs) && savedMs > 0) { + if (Number.isFinite(savedMs) && savedMs >= 5000) { setRefreshMs(savedMs); setTempSeconds(Math.round(savedMs / 1000)); } - const savedApi = localStorage.getItem('apiBase') || '/api'; - setApiBase(savedApi); - setTempApiBase(savedApi); - const savedJsonp = localStorage.getItem('useJsonp') === 'true'; - setUseJsonp(savedJsonp); - setTempUseJsonp(savedJsonp); } catch {} }, []); @@ -132,65 +107,138 @@ export default function HomePage() { }; script.onerror = () => { document.body.removeChild(script); - reject(new Error('脚本加载失败')); + reject(new Error('数据加载失败')); }; document.body.appendChild(script); }); }; const fetchFundData = async (c) => { - if (useJsonp) { - // JSONP 模式:直接请求东方财富 - return new Promise(async (resolve, reject) => { - const gzUrl = `https://fundgz.1234567.com.cn/js/${c}.js?rt=${Date.now()}`; - // 东方财富估值接口固定回调 jsonpgz - window.jsonpgz = (json) => { - 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(() => { - 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] - }); - } - } - resolve({ ...gzData, holdings: holdings.slice(0, 10) }); - }).catch(() => resolve({ ...gzData, holdings: [] })); + 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 }; - loadScript(gzUrl).catch(reject); - }); - } else { - // API 模式:使用绝对路径请求后端 - const res = await fetch(buildUrl(`/fund?code=${encodeURIComponent(c)}`), { cache: 'no-store' }); - if (!res.ok) throw new Error('网络错误'); - return await res.json(); - } + + // 获取重仓股票列表 + 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) => { try { - const updated = await Promise.all(codes.map(c => fetchFundData(c))); - setFunds(updated); - localStorage.setItem('funds', JSON.stringify(updated)); + // 改用串行请求,避免全局回调 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); } @@ -245,14 +293,6 @@ export default function HomePage() { 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); - - setUseJsonp(tempUseJsonp); - localStorage.setItem('useJsonp', String(tempUseJsonp)); - setSettingsOpen(false); }; @@ -308,23 +348,18 @@ export default function HomePage() { 输入基金编号(例如:110022)
- setCode(e.target.value)} inputMode="numeric" - aria-invalid={!!error} /> -
- {error &&
{error}
} + {error &&
{error}
}
@@ -334,7 +369,7 @@ export default function HomePage() {
{funds.map((f) => (
-
+
{f.name} @@ -347,7 +382,6 @@ export default function HomePage() {
前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} +
+ {h.name} +
+ {typeof h.change === 'number' && ( + 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}> + {h.change > 0 ? '+' : ''}{h.change.toFixed(2)}% + + )} + {h.weight} +
))}
@@ -387,14 +425,15 @@ export default function HomePage() {
-
数据源:基金估值与重仓来自东方财富公开接口,可能存在延迟
+
数据源:实时估值与重仓直连东方财富,无需后端,部署即用
+ {settingsOpen && ( -
setSettingsOpen(false)}> +
setSettingsOpen(false)}>
e.stopPropagation()}>
设置 - 配置刷新频率与数据源 + 配置刷新频率
@@ -423,41 +462,6 @@ export default function HomePage() { />
-
-
数据源模式
-
- - -
- {!tempUseJsonp && ( -
-
API 基础地址 (绝对路径)
- setTempApiBase(e.target.value)} - placeholder="/api 或 https://your-backend.com/api" - /> -
- 部署到 GitHub Pages 时,请填写完整的后端域名地址。 -
-
- )} -
-