add: 初始化基金估值页面

This commit is contained in:
hzm
2026-01-31 21:04:50 +08:00
parent 356be8a07f
commit fe2c21527b
38 changed files with 4395 additions and 0 deletions

92
app/api/fund/route.js Normal file
View File

@@ -0,0 +1,92 @@
import { NextResponse } from 'next/server';
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(/<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);
}
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(/<table[\s\S]*<\/table>/i) || text.match(/content:\s*'([\s\S]*?)'/i);
const html = m ? (m[0].startsWith('<table') ? m[0] : m[1]) : text;
return parseHoldings(html);
}
export async function GET(req) {
try {
const { searchParams } = new URL(req.url);
const code = (searchParams.get('code') || '').trim();
if (!code) {
return NextResponse.json({ error: '缺少基金编号' }, { status: 400 });
}
const [gz, holdings] = await Promise.allSettled([fetchGZ(code), fetchHoldings(code)]);
if (gz.status !== 'fulfilled') {
return NextResponse.json({ error: gz.reason?.message || '基金估值获取失败' }, { status: 404 });
}
const data = {
...gz.value,
holdings: holdings.status === 'fulfilled' ? holdings.value : []
};
return NextResponse.json(data, { status: 200 });
} catch (e) {
return NextResponse.json({ error: e.message || '服务异常' }, { status: 500 });
}
}

299
app/globals.css Normal file
View File

@@ -0,0 +1,299 @@
:root {
--bg: #0f172a;
--card: #111827;
--text: #e5e7eb;
--muted: #9ca3af;
--primary: #22d3ee;
--accent: #60a5fa;
--success: #34d399;
--danger: #f87171;
--border: #1f2937;
}
* {
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
background: radial-gradient(1200px 600px at 10% -10%, rgba(96,165,250,0.15), transparent 40%) , radial-gradient(1000px 500px at 90% 0%, rgba(34,211,238,0.12), transparent 45%), var(--bg);
color: var(--text);
}
.container {
max-width: 1120px;
margin: 0 auto;
padding: 24px;
}
.glass {
background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0,0,0,0.25);
backdrop-filter: blur(8px);
}
.title {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
letter-spacing: 0.2px;
}
.muted {
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 16px;
}
.col-12 { grid-column: span 12; }
.col-6 { grid-column: span 6; }
.col-4 { grid-column: span 4; }
.col-3 { grid-column: span 3; }
@media (max-width: 1024px) {
.col-6, .col-4, .col-3 { grid-column: span 12; }
}
.navbar {
position: fixed;
top: 16px;
left: 16px;
right: 16px;
z-index: 50;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.content {
padding-top: 84px;
}
.form {
display: flex;
gap: 12px;
align-items: center;
}
.input {
flex: 1;
height: 44px;
padding: 0 14px;
border-radius: 12px;
border: 1px solid var(--border);
background: #0b1220;
color: var(--text);
outline: none;
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(96,165,250,0.2);
}
.button {
height: 44px;
padding: 0 16px;
border-radius: 12px;
border: 1px solid var(--border);
background: linear-gradient(180deg, #0ea5e9, #22d3ee);
color: #05263b;
font-weight: 600;
cursor: pointer;
transition: transform 150ms ease, box-shadow 200ms ease;
}
.button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(34,211,238,0.25);
}
.button:active {
transform: translateY(0);
}
.card {
padding: 16px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.badge {
display: inline-flex;
gap: 8px;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: #0b1220;
font-size: 12px;
}
.stat {
display: flex;
align-items: baseline;
gap: 8px;
}
.stat .label {
font-size: 12px;
color: var(--muted);
}
.stat .value {
font-size: 20px;
font-weight: 700;
}
.up { color: var(--success); }
.down { color: var(--danger); }
.list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
@media (max-width: 640px) {
.list { grid-template-columns: 1fr; }
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: #0b1220;
}
.item .name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.item .weight {
font-weight: 600;
color: var(--accent);
}
.empty {
padding: 24px;
text-align: center;
color: var(--muted);
}
.footer {
margin-top: 24px;
font-size: 12px;
color: var(--muted);
text-align: center;
}
.actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.icon-button {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
border: 1px solid var(--border);
background: #0b1220;
color: var(--muted);
cursor: pointer;
transition: box-shadow 200ms ease, border-color 200ms ease, transform 150ms ease, color 200ms ease;
}
.icon-button:hover {
color: var(--text);
transform: translateY(-1px);
border-color: var(--accent);
}
.icon-button:active {
transform: translateY(0);
}
.icon-button.danger {
background: linear-gradient(180deg, #ef4444, #f87171);
color: #2b0b0b;
}
.icon-button.danger:hover {
box-shadow: 0 10px 20px rgba(248,113,113,0.25);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(2,6,23,0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 60;
}
.modal {
width: 560px;
max-width: 92vw;
padding: 16px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: #0b1220;
color: var(--text);
cursor: pointer;
transition: border-color 200ms ease, transform 150ms ease;
}
.chip:hover { transform: translateY(-1px); border-color: var(--accent); }
.chip.active { background: linear-gradient(180deg, #0ea5e9, #22d3ee); color: #05263b; border-color: transparent; }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin 0.8s linear infinite;
}
.icon-button[aria-busy="true"] {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(96,165,250,0.15);
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.spin {
animation: none;
}
}

19
app/layout.jsx Normal file
View File

@@ -0,0 +1,19 @@
import './globals.css';
export const metadata = {
title: '实时基金估值',
description: '输入基金编号添加基金实时显示估值与前10重仓'
};
export default function RootLayout({ children }) {
return (
<html lang="zh-CN">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</head>
<body>
{children}
</body>
</html>
);
}

343
app/page.jsx Normal file
View File

@@ -0,0 +1,343 @@
'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);
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));
}
} 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 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;
})
);
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 res = await fetch(`/api/fund?code=${encodeURIComponent(clean)}`, { cache: 'no-store' });
if (!res.ok) throw new Error('基金未找到或接口异常');
const data = await res.json();
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));
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="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>
);
}