Files
real-time-fund/app/page.jsx
2026-01-31 21:29:46 +08:00

521 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useRef, useState } from 'react';
function PlusIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function TrashIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3 6h18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M8 6l1-2h6l1 2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M6 6l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M10 11v6M14 11v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function SettingsIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2" />
<path d="M19.4 15a7.97 7.97 0 0 0 .1-2l2-1.5-2-3.5-2.3.5a8.02 8.02 0 0 0-1.7-1l-.4-2.3h-4l-.4 2.3a8.02 8.02 0 0 0-1.7 1l-2.3-.5-2 3.5 2 1.5a7.97 7.97 0 0 0 .1 2l-2 1.5 2 3.5 2.3-.5a8.02 8.02 0 0 0 1.7 1l.4 2.3h4l.4-2.3a8.02 8.02 0 0 0 1.7-1l2.3.5 2-3.5-2-1.5z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function RefreshIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M4 12a8 8 0 0 1 12.5-6.9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M16 5h3v3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M20 12a8 8 0 0 1-12.5 6.9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<path d="M8 19H5v-3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function Stat({ label, value, delta }) {
const dir = delta > 0 ? 'up' : delta < 0 ? 'down' : '';
return (
<div className="stat">
<span className="label">{label}</span>
<span className={`value ${dir}`}>{value}</span>
{typeof delta === 'number' && (
<span className={`badge ${dir}`}>
{delta > 0 ? '↗' : delta < 0 ? '↘' : '—'} {Math.abs(delta).toFixed(2)}%
</span>
)}
</div>
);
}
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(/<table[\s\S]*?<\/table>/i);
const table = tableMatch ? tableMatch[0] : html;
const rows = table.match(/<tr[\s\S]*?<\/tr>/gi) || [];
for (const r of rows) {
const cells = [...r.matchAll(/<td[\s\S]*?>([\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[^>]*?>([^<]+)<\/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:'<table>...</table>'}
// 先清理可能残留的变量
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(/<table[\s\S]*<\/table>/i) || content.match(/content:\s*'([\s\S]*?)'/i);
const html = m ? (m[0].startsWith('<table') ? m[0] : m[1]) : content;
const list = parseHoldingsHtml(html || '');
resolve(list);
} catch (e) {
// 兜底:无法解析时返回空数组
resolve([]);
}
});
};
const fetchFundData = async (c) => {
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 (
<div className="container content">
<div className="navbar glass">
<div className="brand">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="var(--accent)" strokeWidth="2" />
<path d="M5 14c2-4 7-6 14-5" stroke="var(--primary)" strokeWidth="2" />
</svg>
<span>实时基金估值</span>
</div>
<div className="actions">
<div className="badge" title="当前刷新频率">
<span>刷新</span>
<strong>{Math.round(refreshMs / 1000)}</strong>
</div>
<button
className="icon-button"
aria-label="立即刷新"
onClick={manualRefresh}
disabled={manualRefreshing || funds.length === 0}
aria-busy={manualRefreshing}
title="立即刷新"
>
<RefreshIcon className={manualRefreshing ? 'spin' : ''} width="18" height="18" />
</button>
<button
className="icon-button"
aria-label="打开设置"
onClick={() => setSettingsOpen(true)}
title="设置"
>
<SettingsIcon width="18" height="18" />
</button>
</div>
</div>
<div className="grid">
<div className="col-12 glass card" role="region" aria-label="添加基金">
<div className="title" style={{ marginBottom: 12 }}>
<PlusIcon width="20" height="20" />
<span>添加基金</span>
<span className="muted">输入基金编号例如110022</span>
</div>
<form className="form" onSubmit={addFund}>
<label htmlFor="fund-code" className="muted" style={{ position: 'absolute', left: -9999 }}>
基金编号
</label>
<input
id="fund-code"
className="input"
placeholder="基金编号"
value={code}
onChange={(e) => setCode(e.target.value)}
inputMode="numeric"
aria-invalid={!!error}
/>
<button className="button" type="submit" disabled={loading} aria-busy={loading}>
{loading ? '添加中…' : '添加'}
</button>
</form>
{error && <div className="muted" role="alert" style={{ marginTop: 8, color: 'var(--danger)' }}>{error}</div>}
</div>
<div className="col-12">
{funds.length === 0 ? (
<div className="glass card empty">尚未添加基金</div>
) : (
<div className="grid">
{funds.map((f) => (
<div key={f.code} className="col-6">
<div className="glass card" role="article" aria-label={`${f.name} 基金信息`}>
<div className="row" style={{ marginBottom: 10 }}>
<div className="title">
<span>{f.name}</span>
<span className="muted">#{f.code}</span>
</div>
<div className="actions">
<div className="badge">
<span>估值时间</span>
<strong>{f.gztime || f.time || '-'}</strong>
</div>
<button
className="icon-button danger"
aria-label={`删除基金 ${f.code}`}
onClick={() => removeFund(f.code)}
title="删除"
>
<TrashIcon width="18" height="18" />
</button>
</div>
</div>
<div className="row" style={{ marginBottom: 12 }}>
<Stat label="单位净值" value={f.dwjz ?? '—'} />
<Stat label="估值净值" value={f.gsz ?? '—'} />
<Stat label="涨跌幅" value={typeof f.gszzl === 'number' ? `${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—'} delta={Number(f.gszzl) || 0} />
</div>
<div style={{ marginBottom: 8 }} className="title">
<span>前10重仓股票</span>
<span className="muted">持仓占比</span>
</div>
{Array.isArray(f.holdings) && f.holdings.length ? (
<div className="list" role="list">
{f.holdings.map((h, idx) => (
<div className="item" role="listitem" key={idx}>
<span className="name">
{h.name ? h.name : h.code}
{h.code ? ` (${h.code})` : ''}
</span>
<span className="weight">{h.weight}</span>
</div>
))}
</div>
) : (
<div className="muted">暂无重仓数据</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="footer">数据源基金估值与重仓来自东方财富公开接口可能存在延迟</div>
{settingsOpen && (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={() => setSettingsOpen(false)}>
<div className="glass card modal" onClick={(e) => e.stopPropagation()}>
<div className="title" style={{ marginBottom: 12 }}>
<SettingsIcon width="20" height="20" />
<span>设置</span>
<span className="muted">数据源与刷新频率</span>
</div>
<div className="title" style={{ marginBottom: 8 }}>
<span>数据源</span>
<span className="muted">跨域环境推荐 JSONP 直连</span>
</div>
<div className="chips" style={{ marginBottom: 12 }}>
{[
{ key: 'api', label: '后端 API推荐', active: !useJsonp },
{ key: 'jsonp', label: 'JSONP 直连', active: useJsonp }
].map((opt) => (
<button
key={opt.key}
type="button"
className={`chip ${opt.active ? 'active' : ''}`}
onClick={() => setUseJsonp(opt.key === 'jsonp')}
aria-pressed={opt.active}
>
{opt.label}
</button>
))}
</div>
{!useJsonp && (
<div className="form" style={{ marginBottom: 12 }}>
<label htmlFor="api-base" className="muted" style={{ position: 'absolute', left: -9999 }}>
API 基础地址
</label>
<input
id="api-base"
className="input"
type="text"
value={tempApiBase}
onChange={(e) => setTempApiBase(e.target.value)}
placeholder="/api 或 https://你的域名/api"
/>
</div>
)}
<div className="chips" style={{ marginBottom: 12 }}>
{[10, 30, 60, 120, 300].map((s) => (
<button
key={s}
type="button"
className={`chip ${tempSeconds === s ? 'active' : ''}`}
onClick={() => setTempSeconds(s)}
aria-pressed={tempSeconds === s}
>
{s}
</button>
))}
</div>
<form onSubmit={saveSettings}>
<div className="form" style={{ marginBottom: 12 }}>
<label htmlFor="refresh-seconds" className="muted" style={{ position: 'absolute', left: -9999 }}>
自定义刷新秒数
</label>
<input
id="refresh-seconds"
className="input"
type="number"
min="5"
step="5"
value={tempSeconds}
onChange={(e) => setTempSeconds(Number(e.target.value))}
placeholder="秒数≥5"
/>
<button className="button" type="submit">保存</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}