Files
real-time-fund/app/page.jsx
2026-02-02 12:42:21 +08:00

949 lines
39 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';
import { motion, AnimatePresence } from 'framer-motion';
import { useForm, ValidationError } from '@formspree/react';
import Announcement from "./components/Announcement";
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" />
<path d="M8 19H5v-3" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
function ChevronIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function SortIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M3 7h18M6 12h12M9 17h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
function GridIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
<rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
<rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
<rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
function CloseIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function ListIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function StarIcon({ filled, ...props }) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={filled ? "var(--accent)" : "none"}>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" stroke="var(--accent)" 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>
</div>
);
}
function FeedbackModal({ onClose }) {
const [state, handleSubmit] = useForm("xdadgvjd");
const onSubmit = (e) => {
const form = e?.target;
const nicknameInput = form?.elements?.namedItem?.('nickname');
if (nicknameInput && typeof nicknameInput.value === 'string') {
const v = nicknameInput.value.trim();
if (!v) nicknameInput.value = '匿名';
}
return handleSubmit(e);
};
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="意见反馈"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal feedback-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>意见反馈</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
{state.succeeded ? (
<div className="success-message" style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: '48px', marginBottom: 16 }}>🎉</div>
<h3 style={{ marginBottom: 8 }}>感谢您的反馈</h3>
<p className="muted">我们已收到您的建议会尽快查看</p>
<button className="button" onClick={onClose} style={{ marginTop: 24, width: '100%' }}>
关闭
</button>
</div>
) : (
<form onSubmit={onSubmit} className="feedback-form">
<div className="form-group" style={{ marginBottom: 16 }}>
<label htmlFor="nickname" className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
您的昵称可选
</label>
<input
id="nickname"
type="text"
name="nickname"
className="input"
placeholder="匿名"
style={{ width: '100%' }}
/>
<ValidationError prefix="Nickname" field="nickname" errors={state.errors} className="error-text" />
</div>
<div className="form-group" style={{ marginBottom: 20 }}>
<label htmlFor="message" className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
反馈内容
</label>
<textarea
id="message"
name="message"
className="input"
required
placeholder="请描述您遇到的问题或建议..."
style={{ width: '100%', minHeight: '120px', padding: '12px', resize: 'vertical' }}
/>
<ValidationError prefix="Message" field="message" errors={state.errors} className="error-text" />
</div>
<button className="button" type="submit" disabled={state.submitting} style={{ width: '100%' }}>
{state.submitting ? '发送中...' : '提交反馈'}
</button>
</form>
)}
</motion.div>
</motion.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 refreshingRef = useRef(false);
// 刷新频率状态
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 [favorites, setFavorites] = useState(new Set());
const [currentTab, setCurrentTab] = useState('all');
// 排序状态
const [sortBy, setSortBy] = useState('default'); // default, name, yield, code
// 视图模式
const [viewMode, setViewMode] = useState('card'); // card, list
// 反馈弹窗状态
const [feedbackOpen, setFeedbackOpen] = useState(false);
const [feedbackNonce, setFeedbackNonce] = useState(0);
const toggleFavorite = (code) => {
setFavorites(prev => {
const next = new Set(prev);
if (next.has(code)) {
next.delete(code);
} else {
next.add(code);
}
localStorage.setItem('favorites', JSON.stringify(Array.from(next)));
if (next.size === 0) setCurrentTab('all');
return next;
});
};
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;
});
};
// 按 code 去重,保留第一次出现的项,避免列表重复
const dedupeByCode = (list) => {
const seen = new Set();
return list.filter((f) => {
const c = f?.code;
if (!c || seen.has(c)) return false;
seen.add(c);
return true;
});
};
useEffect(() => {
try {
const saved = JSON.parse(localStorage.getItem('funds') || '[]');
if (Array.isArray(saved) && saved.length) {
const deduped = dedupeByCode(saved);
setFunds(deduped);
localStorage.setItem('funds', JSON.stringify(deduped));
const codes = Array.from(new Set(deduped.map((f) => f.code)));
if (codes.length) refreshAll(codes);
}
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));
}
// 加载自选状态
const savedFavorites = JSON.parse(localStorage.getItem('favorites') || '[]');
if (Array.isArray(savedFavorites)) {
setFavorites(new Set(savedFavorites));
}
// 加载视图模式
const savedViewMode = localStorage.getItem('viewMode');
if (savedViewMode === 'card' || savedViewMode === 'list') {
setViewMode(savedViewMode);
}
} catch {}
}, []);
useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
const codes = Array.from(new Set(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; // 立即恢复
if (!json || typeof json !== 'object') {
reject(new Error('未获取到基金估值数据'));
return;
}
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(/<tr[\s\S]*?<\/tr>/gi) || [];
for (const r of rows) {
const cells = (r.match(/<td[\s\S]*?>([\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 (refreshingRef.current) return;
refreshingRef.current = true;
setRefreshing(true);
const uniqueCodes = Array.from(new Set(codes));
try {
const updated = [];
for (const c of uniqueCodes) {
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);
}
}
const deduped = dedupeByCode(updated);
if (deduped.length) {
setFunds(deduped);
localStorage.setItem('funds', JSON.stringify(deduped));
}
} catch (e) {
console.error(e);
} finally {
refreshingRef.current = false;
setRefreshing(false);
}
};
const toggleViewMode = () => {
const nextMode = viewMode === 'card' ? 'list' : 'card';
setViewMode(nextMode);
localStorage.setItem('viewMode', nextMode);
};
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;
});
// 同步删除自选状态
setFavorites(prev => {
if (!prev.has(removeCode)) return prev;
const nextSet = new Set(prev);
nextSet.delete(removeCode);
localStorage.setItem('favorites', JSON.stringify(Array.from(nextSet)));
if (nextSet.size === 0) setCurrentTab('all');
return nextSet;
});
};
const manualRefresh = async () => {
if (refreshingRef.current) return;
const codes = Array.from(new Set(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 (
<div className="container content">
<Announcement />
<div className="navbar glass">
{refreshing && <div className="loading-bar"></div>}
<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={refreshing || funds.length === 0}
aria-busy={refreshing}
title="立即刷新"
>
<RefreshIcon className={refreshing ? '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 add-fund-section" 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}>
<input
className="input"
placeholder="基金编号"
value={code}
onChange={(e) => setCode(e.target.value)}
inputMode="numeric"
/>
<button className="button" type="submit" disabled={loading}>
{loading ? '添加中…' : '添加'}
</button>
</form>
{error && <div className="muted" style={{ marginTop: 8, color: 'var(--danger)' }}>{error}</div>}
</div>
<div className="col-12">
{funds.length > 0 && (
<div className="filter-bar" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
{favorites.size > 0 ? (
<div className="tabs">
<button
className={`tab ${currentTab === 'all' ? 'active' : ''}`}
onClick={() => setCurrentTab('all')}
>
全部 ({funds.length})
</button>
<button
className={`tab ${currentTab === 'fav' ? 'active' : ''}`}
onClick={() => setCurrentTab('fav')}
>
自选 ({favorites.size})
</button>
</div>
) : <div />}
<div className="sort-group" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div className="view-toggle" style={{ display: 'flex', background: 'rgba(255,255,255,0.05)', borderRadius: '10px', padding: '2px' }}>
<button
className={`icon-button ${viewMode === 'card' ? 'active' : ''}`}
onClick={() => { setViewMode('card'); localStorage.setItem('viewMode', 'card'); }}
style={{ border: 'none', width: '32px', height: '32px', background: viewMode === 'card' ? 'var(--primary)' : 'transparent', color: viewMode === 'card' ? '#05263b' : 'var(--muted)' }}
title="卡片视图"
>
<GridIcon width="16" height="16" />
</button>
<button
className={`icon-button ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => { setViewMode('list'); localStorage.setItem('viewMode', 'list'); }}
style={{ border: 'none', width: '32px', height: '32px', background: viewMode === 'list' ? 'var(--primary)' : 'transparent', color: viewMode === 'list' ? '#05263b' : 'var(--muted)' }}
title="表格视图"
>
<ListIcon width="16" height="16" />
</button>
</div>
<div className="divider" style={{ width: '1px', height: '20px', background: 'var(--border)' }} />
<div className="sort-items" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="muted" style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 4 }}>
<SortIcon width="14" height="14" />
排序
</span>
<div className="chips">
{[
{ id: 'default', label: '默认' },
{ id: 'yield', label: '涨跌幅' },
{ id: 'name', label: '名称' },
{ id: 'code', label: '代码' }
].map((s) => (
<button
key={s.id}
className={`chip ${sortBy === s.id ? 'active' : ''}`}
onClick={() => setSortBy(s.id)}
style={{ height: '28px', fontSize: '12px', padding: '0 10px' }}
>
{s.label}
</button>
))}
</div>
</div>
</div>
</div>
)}
{funds.length === 0 ? (
<div className="glass card empty">尚未添加基金</div>
) : (
<AnimatePresence mode="wait">
<motion.div
key={viewMode}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className={viewMode === 'card' ? 'grid' : 'table-container glass'}
>
<div className={viewMode === 'card' ? 'grid col-12' : ''} style={viewMode === 'card' ? { gridColumn: 'span 12', gap: 16 } : {}}>
<AnimatePresence mode="popLayout">
{funds
.filter(f => currentTab === 'all' || favorites.has(f.code))
.sort((a, b) => {
if (sortBy === 'yield') {
const valA = typeof a.estGszzl === 'number' ? a.estGszzl : (Number(a.gszzl) || 0);
const valB = typeof b.estGszzl === 'number' ? b.estGszzl : (Number(b.gszzl) || 0);
return valB - valA;
}
if (sortBy === 'name') return a.name.localeCompare(b.name, 'zh-CN');
if (sortBy === 'code') return a.code.localeCompare(b.code);
return 0; // default order is the order in the array
})
.map((f) => (
<motion.div
layout="position"
key={f.code}
className={viewMode === 'card' ? 'col-6' : 'table-row-wrapper'}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<div className={viewMode === 'card' ? 'glass card' : 'table-row'}>
{viewMode === 'list' ? (
<>
<div className="table-cell name-cell">
<button
className={`icon-button fav-button ${favorites.has(f.code) ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
toggleFavorite(f.code);
}}
title={favorites.has(f.code) ? "取消自选" : "添加自选"}
>
<StarIcon width="18" height="18" filled={favorites.has(f.code)} />
</button>
<div className="title-text">
<span className="name-text">{f.name}</span>
<span className="muted code-text">#{f.code}</span>
</div>
</div>
<div className="table-cell text-right value-cell">
<span style={{ fontWeight: 700 }}>{f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')}</span>
</div>
<div className="table-cell text-right change-cell">
<span className={f.estPricedCoverage > 0.05 ? (f.estGszzl > 0 ? 'up' : f.estGszzl < 0 ? 'down' : '') : (Number(f.gszzl) > 0 ? 'up' : Number(f.gszzl) < 0 ? 'down' : '')} style={{ fontWeight: 700 }}>
{f.estPricedCoverage > 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (typeof f.gszzl === 'number' ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')}
</span>
</div>
<div className="table-cell text-right time-cell">
<span className="muted" style={{ fontSize: '12px' }}>{f.gztime || f.time || '-'}</span>
</div>
<div className="table-cell text-center action-cell">
<button
className="icon-button danger"
onClick={() => removeFund(f.code)}
title="删除"
style={{ width: '28px', height: '28px' }}
>
<TrashIcon width="14" height="14" />
</button>
</div>
</>
) : (
<>
<div className="row" style={{ marginBottom: 10 }}>
<div className="title">
<button
className={`icon-button fav-button ${favorites.has(f.code) ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
toggleFavorite(f.code);
}}
title={favorites.has(f.code) ? "取消自选" : "添加自选"}
>
<StarIcon width="18" height="18" filled={favorites.has(f.code)} />
</button>
<div className="title-text">
<span>{f.name}</span>
<span className="muted">#{f.code}</span>
</div>
</div>
<div className="actions">
<div className="badge-v">
<span>估值时间</span>
<strong>{f.gztime || f.time || '-'}</strong>
</div>
<button
className="icon-button danger"
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.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')} />
<Stat
label="涨跌幅"
value={f.estPricedCoverage > 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (typeof f.gszzl === 'number' ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')}
delta={f.estPricedCoverage > 0.05 ? f.estGszzl : (Number(f.gszzl) || 0)}
/>
</div>
{f.estPricedCoverage > 0.05 && (
<div style={{ fontSize: '10px', color: 'var(--muted)', marginTop: -8, marginBottom: 10, textAlign: 'right' }}>
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算
</div>
)}
<div
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
className="title"
onClick={() => toggleCollapse(f.code)}
>
<div className="row" style={{ width: '100%', flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>前10重仓股票</span>
<ChevronIcon
width="16"
height="16"
className="muted"
style={{
transform: collapsedCodes.has(f.code) ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease'
}}
/>
</div>
<span className="muted">涨跌幅 / 占比</span>
</div>
</div>
<AnimatePresence>
{!collapsedCodes.has(f.code) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{Array.isArray(f.holdings) && f.holdings.length ? (
<div className="list">
{f.holdings.map((h, idx) => (
<div className="item" key={idx}>
<span className="name">{h.name}</span>
<div className="values">
{typeof h.change === 'number' && (
<span className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}>
{h.change > 0 ? '+' : ''}{h.change.toFixed(2)}%
</span>
)}
<span className="weight">{h.weight}</span>
</div>
</div>
))}
</div>
) : (
<div className="muted" style={{ padding: '8px 0' }}>暂无重仓数据</div>
)}
</motion.div>
)}
</AnimatePresence>
</>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</motion.div>
</AnimatePresence>
)}
</div>
</div>
<div className="footer">
<p>数据源实时估值与重仓直连东方财富仅供个人学习及参考使用数据可能存在延迟不作为任何投资建议
</p>
<p>估算数据与真实结算数据会有1%左右误差</p>
<div style={{ marginTop: 12, opacity: 0.8 }}>
<p>
遇到任何问题或需求建议可
<button
className="link-button"
onClick={() => {
setFeedbackNonce((n) => n + 1);
setFeedbackOpen(true);
}}
style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: '0 4px', textDecoration: 'underline', fontSize: 'inherit', fontWeight: 600 }}
>
点此提交反馈
</button>
</p>
</div>
</div>
<AnimatePresence>
{feedbackOpen && (
<FeedbackModal
key={feedbackNonce}
onClose={() => setFeedbackOpen(false)}
/>
)}
</AnimatePresence>
{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="form-group" style={{ marginBottom: 16 }}>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>刷新频率</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>
<input
className="input"
type="number"
min="5"
step="5"
value={tempSeconds}
onChange={(e) => setTempSeconds(Number(e.target.value))}
placeholder="自定义秒数"
/>
</div>
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
<button className="button" onClick={saveSettings}>保存并关闭</button>
</div>
</div>
</div>
)}
</div>
);
}