2151 lines
83 KiB
JavaScript
2151 lines
83 KiB
JavaScript
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
||
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 ExitIcon(props) {
|
||
return (
|
||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" 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 DragIcon(props) {
|
||
return (
|
||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||
<path d="M4 8h16M4 12h16M4 16h16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function FolderPlusIcon(props) {
|
||
return (
|
||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||
<path d="M9 13h6m-3-3v6m-9-4V5a2 2 0 0 1 2-2h4l2 3h6a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" 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 [submitting, setSubmitting] = useState(false);
|
||
const [succeeded, setSucceeded] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
const onSubmit = async (e) => {
|
||
e.preventDefault();
|
||
setSubmitting(true);
|
||
setError("");
|
||
|
||
const formData = new FormData(e.target);
|
||
const nickname = formData.get("nickname")?.trim();
|
||
if (!nickname) {
|
||
formData.set("nickname", "匿名");
|
||
}
|
||
|
||
// Web3Forms Access Key
|
||
formData.append("access_key", "c390fbb1-77e0-4aab-a939-caa75edc7319");
|
||
formData.append("subject", "基估宝 - 用户反馈");
|
||
|
||
try {
|
||
const response = await fetch("https://api.web3forms.com/submit", {
|
||
method: "POST",
|
||
body: formData
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
setSucceeded(true);
|
||
} else {
|
||
setError(data.message || "提交失败,请稍后再试");
|
||
}
|
||
} catch (err) {
|
||
setError("网络错误,请检查您的连接");
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
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>
|
||
|
||
{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%' }}
|
||
/>
|
||
</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' }}
|
||
/>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="error-text" style={{ marginBottom: 16, textAlign: 'center' }}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<button className="button" type="submit" disabled={submitting} style={{ width: '100%' }}>
|
||
{submitting ? '发送中...' : '提交反馈'}
|
||
</button>
|
||
</form>
|
||
)}
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function AddResultModal({ failures, onClose }) {
|
||
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"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="title" style={{ marginBottom: 12, 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>
|
||
<div className="muted" style={{ marginBottom: 12, fontSize: '14px' }}>
|
||
未获取到估值数据的基金如下:
|
||
</div>
|
||
<div className="list">
|
||
{failures.map((it, idx) => (
|
||
<div className="item" key={idx}>
|
||
<span className="name">{it.name || '未知名称'}</span>
|
||
<div className="values">
|
||
<span className="badge">#{it.code}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 16 }}>
|
||
<button className="button" onClick={onClose}>知道了</button>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function SuccessModal({ message, onClose }) {
|
||
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"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="success-message" style={{ textAlign: 'center', padding: '20px 0' }}>
|
||
<div style={{ fontSize: '48px', marginBottom: 16 }}>🎉</div>
|
||
<h3 style={{ marginBottom: 8 }}>{message}</h3>
|
||
<p className="muted">操作已完成,您可以继续使用。</p>
|
||
<button className="button" onClick={onClose} style={{ marginTop: 24, width: '100%' }}>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function ConfirmModal({ title, message, onConfirm, onCancel }) {
|
||
return (
|
||
<motion.div
|
||
className="modal-overlay"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
onClick={onCancel}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
style={{ zIndex: 10002 }}
|
||
>
|
||
<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"
|
||
style={{ maxWidth: '400px' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="title" style={{ marginBottom: 12 }}>
|
||
<TrashIcon width="20" height="20" className="danger" />
|
||
<span>{title}</span>
|
||
</div>
|
||
<p className="muted" style={{ marginBottom: 24, fontSize: '14px', lineHeight: '1.6' }}>
|
||
{message}
|
||
</p>
|
||
<div className="row" style={{ gap: 12 }}>
|
||
<button className="button secondary" onClick={onCancel} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
||
<button className="button danger" onClick={onConfirm} style={{ flex: 1 }}>确定删除</button>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function GroupManageModal({ groups, onClose, onSave }) {
|
||
const [items, setItems] = useState(groups);
|
||
const [deleteConfirm, setDeleteConfirm] = useState(null); // { id, name }
|
||
|
||
const handleReorder = (newOrder) => {
|
||
setItems(newOrder);
|
||
};
|
||
|
||
const handleRename = (id, newName) => {
|
||
const truncatedName = (newName || '').slice(0, 8);
|
||
setItems(prev => prev.map(item => item.id === id ? { ...item, name: truncatedName } : item));
|
||
};
|
||
|
||
const handleDeleteClick = (id, name) => {
|
||
const itemToDelete = items.find(it => it.id === id);
|
||
const isNew = !groups.find(g => g.id === id);
|
||
const isEmpty = itemToDelete && (!itemToDelete.codes || itemToDelete.codes.length === 0);
|
||
|
||
if (isNew || isEmpty) {
|
||
setItems(prev => prev.filter(item => item.id !== id));
|
||
} else {
|
||
setDeleteConfirm({ id, name });
|
||
}
|
||
};
|
||
|
||
const handleConfirmDelete = () => {
|
||
if (deleteConfirm) {
|
||
setItems(prev => prev.filter(item => item.id !== deleteConfirm.id));
|
||
setDeleteConfirm(null);
|
||
}
|
||
};
|
||
|
||
const handleAddRow = () => {
|
||
const newGroup = {
|
||
id: `group_${Date.now()}`,
|
||
name: '',
|
||
codes: []
|
||
};
|
||
setItems(prev => [...prev, newGroup]);
|
||
};
|
||
|
||
const handleConfirm = () => {
|
||
const hasEmpty = items.some(it => !it.name.trim());
|
||
if (hasEmpty) return;
|
||
onSave(items);
|
||
onClose();
|
||
};
|
||
|
||
const isAllValid = items.every(it => it.name.trim() !== '');
|
||
|
||
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"
|
||
style={{ maxWidth: '500px', width: '90vw' }}
|
||
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>
|
||
|
||
<div className="group-manage-list-container" style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: '4px' }}>
|
||
{items.length === 0 ? (
|
||
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
|
||
<div style={{ fontSize: '32px', marginBottom: 12, opacity: 0.5 }}>📂</div>
|
||
<p>暂无自定义分组</p>
|
||
</div>
|
||
) : (
|
||
<Reorder.Group axis="y" values={items} onReorder={handleReorder} className="group-manage-list">
|
||
<AnimatePresence mode="popLayout">
|
||
{items.map((item) => (
|
||
<Reorder.Item
|
||
key={item.id}
|
||
value={item}
|
||
className="group-manage-item glass"
|
||
layout
|
||
initial={{ opacity: 0, scale: 0.98 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.98 }}
|
||
transition={{
|
||
type: 'spring',
|
||
stiffness: 500,
|
||
damping: 35,
|
||
mass: 1,
|
||
layout: { duration: 0.2 }
|
||
}}
|
||
>
|
||
<div className="drag-handle" style={{ cursor: 'grab', display: 'flex', alignItems: 'center', padding: '0 8px' }}>
|
||
<DragIcon width="18" height="18" className="muted" />
|
||
</div>
|
||
<input
|
||
className={`input group-rename-input ${!item.name.trim() ? 'error' : ''}`}
|
||
value={item.name}
|
||
onChange={(e) => handleRename(item.id, e.target.value)}
|
||
placeholder="请输入分组名称..."
|
||
style={{
|
||
flex: 1,
|
||
height: '36px',
|
||
fontSize: '14px',
|
||
background: 'rgba(0,0,0,0.2)',
|
||
border: !item.name.trim() ? '1px solid var(--danger)' : 'none'
|
||
}}
|
||
/>
|
||
<button
|
||
className="icon-button danger"
|
||
onClick={() => handleDeleteClick(item.id, item.name)}
|
||
title="删除分组"
|
||
style={{ width: '36px', height: '36px', flexShrink: 0 }}
|
||
>
|
||
<TrashIcon width="16" height="16" />
|
||
</button>
|
||
</Reorder.Item>
|
||
))}
|
||
</AnimatePresence>
|
||
</Reorder.Group>
|
||
)}
|
||
<button
|
||
className="add-group-row-btn"
|
||
onClick={handleAddRow}
|
||
style={{
|
||
width: '100%',
|
||
marginTop: 12,
|
||
padding: '10px',
|
||
borderRadius: '12px',
|
||
border: '1px dashed var(--border)',
|
||
background: 'rgba(255,255,255,0.02)',
|
||
color: 'var(--muted)',
|
||
fontSize: '14px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '8px',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.2s ease'
|
||
}}
|
||
>
|
||
<PlusIcon width="16" height="16" />
|
||
<span>新增分组</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ marginTop: 24 }}>
|
||
{!isAllValid && (
|
||
<div className="error-text" style={{ marginBottom: 12, textAlign: 'center' }}>
|
||
所有分组名称均不能为空
|
||
</div>
|
||
)}
|
||
<button
|
||
className="button"
|
||
onClick={handleConfirm}
|
||
disabled={!isAllValid}
|
||
style={{ width: '100%', opacity: isAllValid ? 1 : 0.6 }}
|
||
>
|
||
完成
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
|
||
<AnimatePresence>
|
||
{deleteConfirm && (
|
||
<ConfirmModal
|
||
title="删除确认"
|
||
message={`确定要删除分组 "${deleteConfirm.name}" 吗?分组内的基金不会被删除。`}
|
||
onConfirm={handleConfirmDelete}
|
||
onCancel={() => setDeleteConfirm(null)}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function AddFundToGroupModal({ allFunds, currentGroupCodes, onClose, onAdd }) {
|
||
const [selected, setSelected] = useState(new Set());
|
||
|
||
// 过滤出未在当前分组中的基金
|
||
const availableFunds = (allFunds || []).filter(f => !(currentGroupCodes || []).includes(f.code));
|
||
|
||
const toggleSelect = (code) => {
|
||
setSelected(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(code)) next.delete(code);
|
||
else next.add(code);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
return (
|
||
<motion.div
|
||
className="modal-overlay"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
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"
|
||
style={{ maxWidth: '500px', width: '90vw' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<PlusIcon 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>
|
||
|
||
<div className="group-manage-list-container" style={{ maxHeight: '50vh', overflowY: 'auto', paddingRight: '4px' }}>
|
||
{availableFunds.length === 0 ? (
|
||
<div className="empty-state muted" style={{ textAlign: 'center', padding: '40px 0' }}>
|
||
<p>所有基金已在该分组中</p>
|
||
</div>
|
||
) : (
|
||
<div className="group-manage-list">
|
||
{availableFunds.map((fund) => (
|
||
<div
|
||
key={fund.code}
|
||
className={`group-manage-item glass ${selected.has(fund.code) ? 'selected' : ''}`}
|
||
onClick={() => toggleSelect(fund.code)}
|
||
style={{ cursor: 'pointer' }}
|
||
>
|
||
<div className="checkbox" style={{ marginRight: 12 }}>
|
||
{selected.has(fund.code) && <div className="checked-mark" />}
|
||
</div>
|
||
<div className="fund-info" style={{ flex: 1 }}>
|
||
<div style={{ fontWeight: 600 }}>{fund.name}</div>
|
||
<div className="muted" style={{ fontSize: '12px' }}>#{fund.code}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="row" style={{ marginTop: 24, gap: 12 }}>
|
||
<button className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
||
<button
|
||
className="button"
|
||
onClick={() => onAdd(Array.from(selected))}
|
||
disabled={selected.size === 0}
|
||
style={{ flex: 1 }}
|
||
>
|
||
确定 ({selected.size})
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function GroupModal({ onClose, onConfirm }) {
|
||
const [name, setName] = useState('');
|
||
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"
|
||
style={{ maxWidth: '400px' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
<PlusIcon 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>
|
||
<div className="form-group" style={{ marginBottom: 20 }}>
|
||
<label className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>分组名称(最多 8 个字)</label>
|
||
<input
|
||
className="input"
|
||
autoFocus
|
||
placeholder="请输入分组名称..."
|
||
value={name}
|
||
onChange={(e) => {
|
||
const v = e.target.value || '';
|
||
// 限制最多 8 个字符(兼容中英文),超出部分自动截断
|
||
setName(v.slice(0, 8));
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && name.trim()) onConfirm(name.trim());
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="row" style={{ gap: 12 }}>
|
||
<button className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
|
||
<button className="button" onClick={() => name.trim() && onConfirm(name.trim())} disabled={!name.trim()} style={{ flex: 1 }}>确定</button>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
export default function HomePage() {
|
||
const [funds, setFunds] = 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 [groups, setGroups] = useState([]); // [{ id, name, codes: [] }]
|
||
const [currentTab, setCurrentTab] = useState('all');
|
||
const [groupModalOpen, setGroupModalOpen] = useState(false);
|
||
const [groupManageOpen, setGroupManageOpen] = useState(false);
|
||
const [addFundToGroupOpen, setAddFundToGroupOpen] = useState(false);
|
||
const [editingGroup, setEditingGroup] = useState(null);
|
||
|
||
// 排序状态
|
||
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 [searchTerm, setSearchTerm] = useState('');
|
||
const [searchResults, setSearchResults] = useState([]);
|
||
const [selectedFunds, setSelectedFunds] = useState([]);
|
||
const [isSearching, setIsSearching] = useState(false);
|
||
const searchTimeoutRef = useRef(null);
|
||
const dropdownRef = useRef(null);
|
||
const [showDropdown, setShowDropdown] = useState(false);
|
||
const [addResultOpen, setAddResultOpen] = useState(false);
|
||
const [addFailures, setAddFailures] = useState([]);
|
||
const tabsRef = useRef(null);
|
||
|
||
// 过滤和排序后的基金列表
|
||
const displayFunds = funds
|
||
.filter(f => {
|
||
if (currentTab === 'all') return true;
|
||
if (currentTab === 'fav') return favorites.has(f.code);
|
||
const group = groups.find(g => g.id === currentTab);
|
||
return group ? group.codes.includes(f.code) : true;
|
||
})
|
||
.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;
|
||
});
|
||
|
||
// 自动滚动选中 Tab 到可视区域
|
||
useEffect(() => {
|
||
if (!tabsRef.current) return;
|
||
if (currentTab === 'all') {
|
||
tabsRef.current.scrollTo({ left: 0, behavior: 'smooth' });
|
||
return;
|
||
}
|
||
const activeTab = tabsRef.current.querySelector('.tab.active');
|
||
if (activeTab) {
|
||
activeTab.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||
}
|
||
}, [currentTab]);
|
||
|
||
// 鼠标拖拽滚动逻辑
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
// Removed startX and scrollLeft state as we use movementX now
|
||
const [tabsOverflow, setTabsOverflow] = useState(false);
|
||
const [canLeft, setCanLeft] = useState(false);
|
||
const [canRight, setCanRight] = useState(false);
|
||
|
||
const handleMouseDown = (e) => {
|
||
if (!tabsRef.current) return;
|
||
setIsDragging(true);
|
||
};
|
||
|
||
const handleMouseLeaveOrUp = () => {
|
||
setIsDragging(false);
|
||
};
|
||
|
||
const handleMouseMove = (e) => {
|
||
if (!isDragging || !tabsRef.current) return;
|
||
e.preventDefault();
|
||
tabsRef.current.scrollLeft -= e.movementX;
|
||
};
|
||
|
||
const handleWheel = (e) => {
|
||
if (!tabsRef.current) return;
|
||
const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
|
||
tabsRef.current.scrollLeft += delta;
|
||
};
|
||
|
||
const updateTabOverflow = () => {
|
||
if (!tabsRef.current) return;
|
||
const el = tabsRef.current;
|
||
setTabsOverflow(el.scrollWidth > el.clientWidth);
|
||
setCanLeft(el.scrollLeft > 0);
|
||
setCanRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1);
|
||
};
|
||
|
||
useEffect(() => {
|
||
updateTabOverflow();
|
||
const onResize = () => updateTabOverflow();
|
||
window.addEventListener('resize', onResize);
|
||
return () => window.removeEventListener('resize', onResize);
|
||
}, [groups, funds.length, favorites.size]);
|
||
|
||
// 成功提示弹窗
|
||
const [successModal, setSuccessModal] = useState({ open: false, message: '' });
|
||
|
||
useEffect(() => {
|
||
const handleClickOutside = (event) => {
|
||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||
setShowDropdown(false);
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||
}, []);
|
||
|
||
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;
|
||
});
|
||
};
|
||
|
||
const handleAddGroup = (name) => {
|
||
const newGroup = {
|
||
id: `group_${Date.now()}`,
|
||
name,
|
||
codes: []
|
||
};
|
||
const next = [...groups, newGroup];
|
||
setGroups(next);
|
||
localStorage.setItem('groups', JSON.stringify(next));
|
||
setCurrentTab(newGroup.id);
|
||
setGroupModalOpen(false);
|
||
};
|
||
|
||
const handleRemoveGroup = (id) => {
|
||
const next = groups.filter(g => g.id !== id);
|
||
setGroups(next);
|
||
localStorage.setItem('groups', JSON.stringify(next));
|
||
if (currentTab === id) setCurrentTab('all');
|
||
};
|
||
|
||
const handleUpdateGroups = (newGroups) => {
|
||
setGroups(newGroups);
|
||
localStorage.setItem('groups', JSON.stringify(newGroups));
|
||
// 如果当前选中的分组被删除了,切换回“全部”
|
||
if (currentTab !== 'all' && currentTab !== 'fav' && !newGroups.find(g => g.id === currentTab)) {
|
||
setCurrentTab('all');
|
||
}
|
||
};
|
||
|
||
const handleAddFundsToGroup = (codes) => {
|
||
if (!codes || codes.length === 0) return;
|
||
const next = groups.map(g => {
|
||
if (g.id === currentTab) {
|
||
return {
|
||
...g,
|
||
codes: Array.from(new Set([...g.codes, ...codes]))
|
||
};
|
||
}
|
||
return g;
|
||
});
|
||
setGroups(next);
|
||
localStorage.setItem('groups', JSON.stringify(next));
|
||
setAddFundToGroupOpen(false);
|
||
setSuccessModal({ open: true, message: `成功添加 ${codes.length} 支基金` });
|
||
};
|
||
|
||
const removeFundFromCurrentGroup = (code) => {
|
||
const next = groups.map(g => {
|
||
if (g.id === currentTab) {
|
||
return {
|
||
...g,
|
||
codes: g.codes.filter(c => c !== code)
|
||
};
|
||
}
|
||
return g;
|
||
});
|
||
setGroups(next);
|
||
localStorage.setItem('groups', JSON.stringify(next));
|
||
};
|
||
|
||
const toggleFundInGroup = (code, groupId) => {
|
||
const next = groups.map(g => {
|
||
if (g.id === groupId) {
|
||
const has = g.codes.includes(code);
|
||
return {
|
||
...g,
|
||
codes: has ? g.codes.filter(c => c !== code) : [...g.codes, code]
|
||
};
|
||
}
|
||
return g;
|
||
});
|
||
setGroups(next);
|
||
localStorage.setItem('groups', JSON.stringify(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 savedGroups = JSON.parse(localStorage.getItem('groups') || '[]');
|
||
if (Array.isArray(savedGroups)) {
|
||
setGroups(savedGroups);
|
||
}
|
||
// 加载视图模式
|
||
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 performSearch = async (val) => {
|
||
if (!val.trim()) {
|
||
setSearchResults([]);
|
||
return;
|
||
}
|
||
setIsSearching(true);
|
||
// 使用 JSONP 方式获取数据,添加 callback 参数
|
||
const callbackName = `SuggestData_${Date.now()}`;
|
||
const url = `https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=${encodeURIComponent(val)}&callback=${callbackName}&_=${Date.now()}`;
|
||
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
window[callbackName] = (data) => {
|
||
if (data && data.Datas) {
|
||
// 过滤出基金类型的数据 (CATEGORY 为 700 是公募基金)
|
||
const fundsOnly = data.Datas.filter(d =>
|
||
d.CATEGORY === 700 ||
|
||
d.CATEGORY === "700" ||
|
||
d.CATEGORYDESC === "基金"
|
||
);
|
||
setSearchResults(fundsOnly);
|
||
}
|
||
delete window[callbackName];
|
||
resolve();
|
||
};
|
||
|
||
const script = document.createElement('script');
|
||
script.src = url;
|
||
script.async = true;
|
||
script.onload = () => {
|
||
if (document.body.contains(script)) document.body.removeChild(script);
|
||
};
|
||
script.onerror = () => {
|
||
if (document.body.contains(script)) document.body.removeChild(script);
|
||
delete window[callbackName];
|
||
reject(new Error('搜索请求失败'));
|
||
};
|
||
document.body.appendChild(script);
|
||
});
|
||
} catch (e) {
|
||
console.error('搜索失败', e);
|
||
} finally {
|
||
setIsSearching(false);
|
||
}
|
||
};
|
||
|
||
const handleSearchInput = (e) => {
|
||
const val = e.target.value;
|
||
setSearchTerm(val);
|
||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||
searchTimeoutRef.current = setTimeout(() => performSearch(val), 300);
|
||
};
|
||
|
||
const toggleSelectFund = (fund) => {
|
||
setSelectedFunds(prev => {
|
||
const exists = prev.find(f => f.CODE === fund.CODE);
|
||
if (exists) {
|
||
return prev.filter(f => f.CODE !== fund.CODE);
|
||
}
|
||
return [...prev, fund];
|
||
});
|
||
};
|
||
|
||
const batchAddFunds = async () => {
|
||
if (selectedFunds.length === 0) return;
|
||
setLoading(true);
|
||
setError('');
|
||
|
||
try {
|
||
const newFunds = [];
|
||
for (const f of selectedFunds) {
|
||
if (funds.some(existing => existing.code === f.CODE)) continue;
|
||
try {
|
||
const data = await fetchFundData(f.CODE);
|
||
newFunds.push(data);
|
||
} catch (e) {
|
||
console.error(`添加基金 ${f.CODE} 失败`, e);
|
||
}
|
||
}
|
||
|
||
if (newFunds.length > 0) {
|
||
const updated = dedupeByCode([...newFunds, ...funds]);
|
||
setFunds(updated);
|
||
localStorage.setItem('funds', JSON.stringify(updated));
|
||
}
|
||
|
||
setSelectedFunds([]);
|
||
setSearchTerm('');
|
||
setSearchResults([]);
|
||
} catch (e) {
|
||
setError('批量添加失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
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);
|
||
// 失败时从当前 state 中寻找旧数据
|
||
setFunds(prev => {
|
||
const old = prev.find((f) => f.code === c);
|
||
if (old) updated.push(old);
|
||
return prev;
|
||
});
|
||
}
|
||
}
|
||
|
||
if (updated.length > 0) {
|
||
setFunds(prev => {
|
||
// 将更新后的数据合并回当前最新的 state 中,防止覆盖掉刚刚导入的数据
|
||
const merged = [...prev];
|
||
updated.forEach(u => {
|
||
const idx = merged.findIndex(f => f.code === u.code);
|
||
if (idx > -1) {
|
||
merged[idx] = u;
|
||
} else {
|
||
merged.push(u);
|
||
}
|
||
});
|
||
const deduped = dedupeByCode(merged);
|
||
localStorage.setItem('funds', JSON.stringify(deduped));
|
||
return 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 manualTokens = String(searchTerm || '')
|
||
.split(/[^0-9A-Za-z]+/)
|
||
.map(t => t.trim())
|
||
.filter(t => t.length > 0);
|
||
const selectedCodes = Array.from(new Set([
|
||
...selectedFunds.map(f => f.CODE),
|
||
...manualTokens.filter(t => /^\d{6}$/.test(t))
|
||
]));
|
||
if (selectedCodes.length === 0) {
|
||
setError('请输入或选择基金代码');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const newFunds = [];
|
||
const failures = [];
|
||
const nameMap = {};
|
||
selectedFunds.forEach(f => { nameMap[f.CODE] = f.NAME; });
|
||
for (const c of selectedCodes) {
|
||
if (funds.some((f) => f.code === c)) continue;
|
||
try {
|
||
const data = await fetchFundData(c);
|
||
newFunds.push(data);
|
||
} catch (err) {
|
||
failures.push({ code: c, name: nameMap[c] });
|
||
}
|
||
}
|
||
if (newFunds.length === 0) {
|
||
setError('未添加任何新基金');
|
||
} else {
|
||
const next = dedupeByCode([...newFunds, ...funds]);
|
||
setFunds(next);
|
||
localStorage.setItem('funds', JSON.stringify(next));
|
||
}
|
||
setSearchTerm('');
|
||
setSelectedFunds([]);
|
||
setShowDropdown(false);
|
||
if (failures.length > 0) {
|
||
setAddFailures(failures);
|
||
setAddResultOpen(true);
|
||
}
|
||
} 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 nextGroups = groups.map(g => ({
|
||
...g,
|
||
codes: g.codes.filter(c => c !== removeCode)
|
||
}));
|
||
setGroups(nextGroups);
|
||
localStorage.setItem('groups', JSON.stringify(nextGroups));
|
||
|
||
// 同步删除展开收起状态
|
||
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);
|
||
};
|
||
|
||
const importFileRef = useRef(null);
|
||
const [importMsg, setImportMsg] = useState('');
|
||
|
||
const exportLocalData = async () => {
|
||
try {
|
||
const payload = {
|
||
version: 1,
|
||
funds: JSON.parse(localStorage.getItem('funds') || '[]'),
|
||
favorites: JSON.parse(localStorage.getItem('favorites') || '[]'),
|
||
groups: JSON.parse(localStorage.getItem('groups') || '[]'),
|
||
collapsedCodes: JSON.parse(localStorage.getItem('collapsedCodes') || '[]'),
|
||
refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10),
|
||
viewMode: localStorage.getItem('viewMode') || 'card',
|
||
exportedAt: new Date().toISOString()
|
||
};
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||
if (window.showSaveFilePicker) {
|
||
const handle = await window.showSaveFilePicker({
|
||
suggestedName: `realtime-fund-config-${Date.now()}.json`,
|
||
types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }]
|
||
});
|
||
const writable = await handle.createWritable();
|
||
await writable.write(blob);
|
||
await writable.close();
|
||
setSuccessModal({ open: true, message: '导出成功' });
|
||
setSettingsOpen(false);
|
||
return;
|
||
}
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `realtime-fund-config-${Date.now()}.json`;
|
||
let done = false;
|
||
const finish = () => {
|
||
if (done) return;
|
||
done = true;
|
||
URL.revokeObjectURL(url);
|
||
setSuccessModal({ open: true, message: '导出成功' });
|
||
setSettingsOpen(false);
|
||
};
|
||
const onVisibility = () => {
|
||
if (document.visibilityState === 'hidden') return;
|
||
finish();
|
||
document.removeEventListener('visibilitychange', onVisibility);
|
||
};
|
||
document.addEventListener('visibilitychange', onVisibility, { once: true });
|
||
a.click();
|
||
setTimeout(finish, 3000);
|
||
} catch (err) {
|
||
console.error('Export error:', err);
|
||
}
|
||
};
|
||
|
||
const handleImportFileChange = async (e) => {
|
||
try {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const text = await file.text();
|
||
const data = JSON.parse(text);
|
||
if (data && typeof data === 'object') {
|
||
// 从 localStorage 读取最新数据进行合并,防止状态滞后导致的数据丢失
|
||
const currentFunds = JSON.parse(localStorage.getItem('funds') || '[]');
|
||
const currentFavorites = JSON.parse(localStorage.getItem('favorites') || '[]');
|
||
const currentGroups = JSON.parse(localStorage.getItem('groups') || '[]');
|
||
const currentCollapsed = JSON.parse(localStorage.getItem('collapsedCodes') || '[]');
|
||
|
||
let mergedFunds = currentFunds;
|
||
let appendedCodes = [];
|
||
|
||
if (Array.isArray(data.funds)) {
|
||
const incomingFunds = dedupeByCode(data.funds);
|
||
const existingCodes = new Set(currentFunds.map(f => f.code));
|
||
const newItems = incomingFunds.filter(f => f && f.code && !existingCodes.has(f.code));
|
||
appendedCodes = newItems.map(f => f.code);
|
||
mergedFunds = [...currentFunds, ...newItems];
|
||
setFunds(mergedFunds);
|
||
localStorage.setItem('funds', JSON.stringify(mergedFunds));
|
||
}
|
||
|
||
if (Array.isArray(data.favorites)) {
|
||
const mergedFav = Array.from(new Set([...currentFavorites, ...data.favorites]));
|
||
setFavorites(new Set(mergedFav));
|
||
localStorage.setItem('favorites', JSON.stringify(mergedFav));
|
||
}
|
||
|
||
if (Array.isArray(data.groups)) {
|
||
// 合并分组:如果 ID 相同则合并 codes,否则添加新分组
|
||
const mergedGroups = [...currentGroups];
|
||
data.groups.forEach(incomingGroup => {
|
||
const existingIdx = mergedGroups.findIndex(g => g.id === incomingGroup.id);
|
||
if (existingIdx > -1) {
|
||
mergedGroups[existingIdx] = {
|
||
...mergedGroups[existingIdx],
|
||
codes: Array.from(new Set([...mergedGroups[existingIdx].codes, ...(incomingGroup.codes || [])]))
|
||
};
|
||
} else {
|
||
mergedGroups.push(incomingGroup);
|
||
}
|
||
});
|
||
setGroups(mergedGroups);
|
||
localStorage.setItem('groups', JSON.stringify(mergedGroups));
|
||
}
|
||
|
||
if (Array.isArray(data.collapsedCodes)) {
|
||
const mergedCollapsed = Array.from(new Set([...currentCollapsed, ...data.collapsedCodes]));
|
||
setCollapsedCodes(new Set(mergedCollapsed));
|
||
localStorage.setItem('collapsedCodes', JSON.stringify(mergedCollapsed));
|
||
}
|
||
|
||
if (typeof data.refreshMs === 'number' && data.refreshMs >= 5000) {
|
||
setRefreshMs(data.refreshMs);
|
||
setTempSeconds(Math.round(data.refreshMs / 1000));
|
||
localStorage.setItem('refreshMs', String(data.refreshMs));
|
||
}
|
||
if (data.viewMode === 'card' || data.viewMode === 'list') {
|
||
setViewMode(data.viewMode);
|
||
localStorage.setItem('viewMode', data.viewMode);
|
||
}
|
||
|
||
// 导入成功后,仅刷新新追加的基金
|
||
if (appendedCodes.length) {
|
||
// 这里需要确保 refreshAll 不会因为闭包问题覆盖掉刚刚合并好的 mergedFunds
|
||
// 我们直接传入所有代码执行一次全量刷新是最稳妥的,或者修改 refreshAll 支持增量更新
|
||
const allCodes = mergedFunds.map(f => f.code);
|
||
await refreshAll(allCodes);
|
||
}
|
||
|
||
setSuccessModal({ open: true, message: '导入成功' });
|
||
setSettingsOpen(false); // 导入成功自动关闭设置弹框
|
||
if (importFileRef.current) importFileRef.current.value = '';
|
||
}
|
||
} catch (err) {
|
||
console.error('Import error:', err);
|
||
setImportMsg('导入失败,请检查文件格式');
|
||
setTimeout(() => setImportMsg(''), 4000);
|
||
if (importFileRef.current) importFileRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
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">搜索并选择基金(支持名称或代码)</span>
|
||
</div>
|
||
|
||
<div className="search-container" ref={dropdownRef}>
|
||
<form className="form" onSubmit={addFund}>
|
||
<div className="search-input-wrapper" style={{ flex: 1, gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
{selectedFunds.length > 0 && (
|
||
<div className="selected-inline-chips">
|
||
{selectedFunds.map(fund => (
|
||
<div key={fund.CODE} className="fund-chip">
|
||
<span>{fund.NAME}</span>
|
||
<button onClick={() => toggleSelectFund(fund)} className="remove-chip">
|
||
<CloseIcon width="14" height="14" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<input
|
||
className="input"
|
||
placeholder="搜索基金名称或代码..."
|
||
value={searchTerm}
|
||
onChange={handleSearchInput}
|
||
onFocus={() => setShowDropdown(true)}
|
||
/>
|
||
{isSearching && <div className="search-spinner" />}
|
||
</div>
|
||
<button className="button" type="submit" disabled={loading}>
|
||
{loading ? '添加中…' : '添加'}
|
||
</button>
|
||
</form>
|
||
|
||
<AnimatePresence>
|
||
{showDropdown && (searchTerm.trim() || searchResults.length > 0) && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
className="search-dropdown glass"
|
||
>
|
||
{searchResults.length > 0 ? (
|
||
<div className="search-results">
|
||
{searchResults.map((fund) => {
|
||
const isSelected = selectedFunds.some(f => f.CODE === fund.CODE);
|
||
const isAlreadyAdded = funds.some(f => f.code === fund.CODE);
|
||
return (
|
||
<div
|
||
key={fund.CODE}
|
||
className={`search-item ${isSelected ? 'selected' : ''} ${isAlreadyAdded ? 'added' : ''}`}
|
||
onClick={() => {
|
||
if (isAlreadyAdded) return;
|
||
toggleSelectFund(fund);
|
||
}}
|
||
>
|
||
<div className="fund-info">
|
||
<span className="fund-name">{fund.NAME}</span>
|
||
<span className="fund-code muted">#{fund.CODE} | {fund.TYPE}</span>
|
||
</div>
|
||
{isAlreadyAdded ? (
|
||
<span className="added-label">已添加</span>
|
||
) : (
|
||
<div className="checkbox">
|
||
{isSelected && <div className="checked-mark" />}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : searchTerm.trim() && !isSearching ? (
|
||
<div className="no-results muted">未找到相关基金</div>
|
||
) : null}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
|
||
|
||
{error && <div className="muted" style={{ marginTop: 8, color: 'var(--danger)' }}>{error}</div>}
|
||
</div>
|
||
|
||
<div className="col-12">
|
||
<div className="filter-bar" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||
<div className="tabs-container">
|
||
<div
|
||
className="tabs-scroll-area"
|
||
data-mask-left={canLeft}
|
||
data-mask-right={canRight}
|
||
>
|
||
<div
|
||
className="tabs"
|
||
ref={tabsRef}
|
||
onMouseDown={handleMouseDown}
|
||
onMouseLeave={handleMouseLeaveOrUp}
|
||
onMouseUp={handleMouseLeaveOrUp}
|
||
onMouseMove={handleMouseMove}
|
||
onWheel={handleWheel}
|
||
onScroll={updateTabOverflow}
|
||
>
|
||
<AnimatePresence mode="popLayout">
|
||
<motion.button
|
||
layout
|
||
initial={{ opacity: 0, scale: 0.8 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.8 }}
|
||
key="all"
|
||
className={`tab ${currentTab === 'all' ? 'active' : ''}`}
|
||
onClick={() => setCurrentTab('all')}
|
||
transition={{ type: 'spring', stiffness: 500, damping: 30, mass: 1 }}
|
||
>
|
||
全部 ({funds.length})
|
||
</motion.button>
|
||
<motion.button
|
||
layout
|
||
initial={{ opacity: 0, scale: 0.8 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.8 }}
|
||
key="fav"
|
||
className={`tab ${currentTab === 'fav' ? 'active' : ''}`}
|
||
onClick={() => setCurrentTab('fav')}
|
||
transition={{ type: 'spring', stiffness: 500, damping: 30, mass: 1 }}
|
||
>
|
||
自选 ({favorites.size})
|
||
</motion.button>
|
||
{groups.map(g => (
|
||
<motion.button
|
||
layout
|
||
initial={{ opacity: 0, scale: 0.8 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.8 }}
|
||
key={g.id}
|
||
className={`tab ${currentTab === g.id ? 'active' : ''}`}
|
||
onClick={() => setCurrentTab(g.id)}
|
||
transition={{ type: 'spring', stiffness: 500, damping: 30, mass: 1 }}
|
||
>
|
||
{g.name} ({g.codes.length})
|
||
</motion.button>
|
||
))}
|
||
</AnimatePresence>
|
||
</div>
|
||
</div>
|
||
{groups.length > 0 && (
|
||
<button
|
||
className="icon-button manage-groups-btn"
|
||
onClick={() => setGroupManageOpen(true)}
|
||
title="管理分组"
|
||
>
|
||
<SortIcon width="16" height="16" />
|
||
</button>
|
||
)}
|
||
<button
|
||
className="icon-button add-group-btn"
|
||
onClick={() => setGroupModalOpen(true)}
|
||
title="新增分组"
|
||
>
|
||
<PlusIcon width="16" height="16" />
|
||
</button>
|
||
</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>
|
||
|
||
{displayFunds.length === 0 ? (
|
||
<div className="glass card empty" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '60px 20px' }}>
|
||
<div style={{ fontSize: '48px', marginBottom: 16, opacity: 0.5 }}>📂</div>
|
||
<div className="muted" style={{ marginBottom: 20 }}>{funds.length === 0 ? '尚未添加基金' : '该分组下暂无数据'}</div>
|
||
{currentTab !== 'all' && currentTab !== 'fav' && funds.length > 0 && (
|
||
<button className="button" onClick={() => setAddFundToGroupOpen(true)}>
|
||
添加基金到此分组
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<>
|
||
{currentTab !== 'all' && currentTab !== 'fav' && (
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||
<button
|
||
className="button"
|
||
onClick={() => setAddFundToGroupOpen(true)}
|
||
style={{ height: '32px', fontSize: '13px', padding: '0 12px', display: 'flex', alignItems: 'center', gap: '6px' }}
|
||
>
|
||
<PlusIcon width="16" height="16" />
|
||
<span>添加基金</span>
|
||
</button>
|
||
</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">
|
||
{displayFunds.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">
|
||
{currentTab !== 'all' && currentTab !== 'fav' ? (
|
||
<button
|
||
className="icon-button fav-button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
removeFundFromCurrentGroup(f.code);
|
||
}}
|
||
title="从当前分组移除"
|
||
>
|
||
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||
</button>
|
||
) : (
|
||
<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" style={{ gap: 4 }}>
|
||
<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">
|
||
{currentTab !== 'all' && currentTab !== 'fav' ? (
|
||
<button
|
||
className="icon-button fav-button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
removeFundFromCurrentGroup(f.code);
|
||
}}
|
||
title="从当前分组移除"
|
||
>
|
||
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
|
||
</button>
|
||
) : (
|
||
<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>
|
||
<div className="row" style={{ gap: 4 }}>
|
||
<button
|
||
className="icon-button danger"
|
||
onClick={() => removeFund(f.code)}
|
||
title="删除"
|
||
style={{ width: '28px', height: '28px' }}
|
||
>
|
||
<TrashIcon width="14" height="14" />
|
||
</button>
|
||
</div>
|
||
</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>
|
||
<AnimatePresence>
|
||
{addResultOpen && (
|
||
<AddResultModal
|
||
failures={addFailures}
|
||
onClose={() => setAddResultOpen(false)}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<AnimatePresence>
|
||
{addFundToGroupOpen && (
|
||
<AddFundToGroupModal
|
||
allFunds={funds}
|
||
currentGroupCodes={groups.find(g => g.id === currentTab)?.codes || []}
|
||
onClose={() => setAddFundToGroupOpen(false)}
|
||
onAdd={handleAddFundsToGroup}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<AnimatePresence>
|
||
{groupManageOpen && (
|
||
<GroupManageModal
|
||
groups={groups}
|
||
onClose={() => setGroupManageOpen(false)}
|
||
onSave={handleUpdateGroups}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<AnimatePresence>
|
||
{groupModalOpen && (
|
||
<GroupModal
|
||
onClose={() => setGroupModalOpen(false)}
|
||
onConfirm={handleAddGroup}
|
||
/>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<AnimatePresence>
|
||
{successModal.open && (
|
||
<SuccessModal
|
||
message={successModal.message}
|
||
onClose={() => setSuccessModal({ open: false, message: '' })}
|
||
/>
|
||
)}
|
||
</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="form-group" style={{ marginBottom: 16 }}>
|
||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>数据导出</div>
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<button type="button" className="button" onClick={exportLocalData}>导出配置</button>
|
||
</div>
|
||
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem', marginTop: 26 }}>数据导入</div>
|
||
<div className="row" style={{ gap: 8, marginTop: 8 }}>
|
||
<button type="button" className="button" onClick={() => importFileRef.current?.click?.()}>导入配置</button>
|
||
</div>
|
||
<input
|
||
ref={importFileRef}
|
||
type="file"
|
||
accept="application/json"
|
||
style={{ display: 'none' }}
|
||
onChange={handleImportFileChange}
|
||
/>
|
||
{importMsg && (
|
||
<div className="muted" style={{ marginTop: 8 }}>
|
||
{importMsg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="row" style={{ justifyContent: 'flex-end', marginTop: 24 }}>
|
||
<button className="button" onClick={saveSettings}>保存并关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|