diff --git a/app/api/fund/route.js b/app/api/fund/route.js index 2c22ba7..622ca99 100644 --- a/app/api/fund/route.js +++ b/app/api/fund/route.js @@ -1,5 +1,16 @@ 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,Accept', + 'Access-Control-Max-Age': '86400' +}; + +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' }); @@ -75,18 +86,18 @@ export async function GET(req) { const { searchParams } = new URL(req.url); const code = (searchParams.get('code') || '').trim(); if (!code) { - return NextResponse.json({ error: '缺少基金编号' }, { status: 400 }); + return NextResponse.json({ error: '缺少基金编号' }, { status: 400, headers: CORS_HEADERS }); } const [gz, holdings] = await Promise.allSettled([fetchGZ(code), fetchHoldings(code)]); if (gz.status !== 'fulfilled') { - return NextResponse.json({ error: gz.reason?.message || '基金估值获取失败' }, { status: 404 }); + return NextResponse.json({ error: gz.reason?.message || '基金估值获取失败' }, { status: 404, headers: CORS_HEADERS }); } const data = { ...gz.value, holdings: holdings.status === 'fulfilled' ? holdings.value : [] }; - return NextResponse.json(data, { status: 200 }); + return NextResponse.json(data, { status: 200, headers: CORS_HEADERS }); } catch (e) { - return NextResponse.json({ error: e.message || '服务异常' }, { status: 500 }); + return NextResponse.json({ error: e.message || '服务异常' }, { status: 500, headers: CORS_HEADERS }); } } diff --git a/app/page.jsx b/app/page.jsx index 862d148..7612ab0 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -66,6 +66,15 @@ export default function HomePage() { 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 { @@ -79,6 +88,13 @@ export default function HomePage() { 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 {} }, []); @@ -93,15 +109,139 @@ export default function HomePage() { }; }, [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(async (c) => { - const res = await fetch(`/api/fund?code=${encodeURIComponent(c)}`, { cache: 'no-store' }); - if (!res.ok) throw new Error('网络错误'); - const data = await res.json(); - return data; - }) + codes.map((c) => fetchFundData(c)) ); setFunds(updated); localStorage.setItem('funds', JSON.stringify(updated)); @@ -124,9 +264,7 @@ export default function HomePage() { } setLoading(true); try { - const res = await fetch(`/api/fund?code=${encodeURIComponent(clean)}`, { cache: 'no-store' }); - if (!res.ok) throw new Error('基金未找到或接口异常'); - const data = await res.json(); + const data = await fetchFundData(clean); const next = [data, ...funds]; setFunds(next); localStorage.setItem('funds', JSON.stringify(next)); @@ -161,6 +299,10 @@ 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); + localStorage.setItem('useJsonp', useJsonp ? 'true' : 'false'); setSettingsOpen(false); }; @@ -297,13 +439,48 @@ export default function HomePage() {
数据源:基金估值与重仓来自东方财富公开接口,可能存在延迟
{settingsOpen && ( -
setSettingsOpen(false)}> +
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) => (