add:自定义分组展示

This commit is contained in:
hzm
2026-02-03 22:38:32 +08:00
parent 79962bfb0c
commit 073fdcdd7f
2 changed files with 903 additions and 48 deletions

View File

@@ -383,12 +383,12 @@ body {
.tabs {
display: flex;
gap: 8px;
padding: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
gap: 4px;
padding: 4px 0;
background: transparent;
border-radius: 0;
width: fit-content;
backdrop-filter: blur(8px);
backdrop-filter: none;
}
.card.list-mode {
@@ -522,7 +522,7 @@ body {
}
.tabs {
width: 100%;
justify-content: center;
justify-content: flex-start; /* 移动端改为左对齐 */
background: transparent;
border-radius: 0;
backdrop-filter: none;
@@ -531,7 +531,7 @@ body {
}
.tab {
padding: 8px 20px;
padding: 0 20px;
border-radius: 8px;
border: none;
background: transparent;
@@ -540,6 +540,10 @@ body {
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
height: 32px;
line-height: 32px;
display: inline-flex;
align-items: center;
}
.tab:hover {
@@ -802,3 +806,188 @@ body {
position: relative;
z-index: 41;
}
.group-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.group-item.selected {
background: rgba(34, 211, 238, 0.1);
color: var(--primary);
}
.tabs-container {
display: flex;
align-items: center;
gap: 8px;
position: relative;
max-width: 100%;
flex: 1;
min-width: 0;
}
.tabs-scroll-area {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
/* 动态遮罩,通过 data-mask-* 属性控制 */
mask-image: none;
-webkit-mask-image: none;
}
.tabs-scroll-area[data-mask-left="true"][data-mask-right="true"] {
mask-image: linear-gradient(to right, transparent, black 40px, black calc(100% - 40px), transparent);
-webkit-mask-image: linear-gradient(to right, transparent, black 40px, black calc(100% - 40px), transparent);
}
.tabs-scroll-area[data-mask-left="true"][data-mask-right="false"] {
mask-image: linear-gradient(to right, transparent, black 40px, black);
-webkit-mask-image: linear-gradient(to right, transparent, black 40px, black);
}
.tabs-scroll-area[data-mask-left="false"][data-mask-right="true"] {
mask-image: linear-gradient(to right, black, black calc(100% - 40px), transparent);
-webkit-mask-image: linear-gradient(to right, black, black calc(100% - 40px), transparent);
}
.tabs {
display: flex;
overflow-x: auto;
white-space: nowrap;
gap: 4px;
padding: 4px 0; /* 移除左右内边距,由 mask 和 scroll 行为控制 */
scroll-behavior: smooth;
scrollbar-width: none; /* Firefox */
cursor: grab;
user-select: none;
}
.tabs::after {
content: "";
flex: 0 0 16px; /* 末尾留一点空隙,避免贴边 */
}
.tabs:active {
cursor: grabbing;
}
.tabs::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
/* 管理分组弹窗列表 */
.group-manage-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0;
margin: 0;
list-style: none;
}
.group-manage-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
transition: all 0.2s ease;
}
.group-manage-item:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--primary);
}
.group-manage-item.selected {
background: rgba(34, 211, 238, 0.1);
border-color: var(--primary);
}
.group-manage-item.selected .checkbox {
background: var(--primary);
border-color: var(--primary);
}
.group-rename-input {
border: none !important;
box-shadow: none !important;
background: transparent !important;
font-weight: 500;
}
.group-rename-input:focus {
background: rgba(255, 255, 255, 0.05) !important;
}
.drag-handle:active {
cursor: grabbing;
}
.add-group-btn {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px dashed var(--border);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.add-group-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.tabs-nav-btn {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
transition: all 0.2s ease;
flex-shrink: 0;
}
.tabs-nav-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
.tabs-nav-btn.disabled {
opacity: 0.4;
pointer-events: none;
}
.tabs-fixed {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.group-selector-popup {
background: rgba(15, 23, 42, 0.95) !important;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
}
@media (max-width: 768px) {
.tabs-container {
width: 100%;
margin-bottom: 8px;
}
.tabs {
max-width: none !important;
}
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion, AnimatePresence, Reorder } from 'framer-motion';
import { useForm, ValidationError } from '@formspree/react';
import Announcement from "./components/Announcement";
@@ -79,6 +79,14 @@ function CloseIcon(props) {
);
}
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">
@@ -87,6 +95,22 @@ function ListIcon(props) {
);
}
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"}>
@@ -278,6 +302,297 @@ function SuccessModal({ message, onClose }) {
);
}
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) => {
setDeleteConfirm({ id, name });
};
const handleConfirmDelete = () => {
if (deleteConfirm) {
setItems(prev => prev.filter(item => item.id !== deleteConfirm.id));
setDeleteConfirm(null);
}
};
const handleConfirm = () => {
onSave(items);
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"
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">
{items.map((item) => (
<Reorder.Item key={item.id} value={item} className="group-manage-item glass">
<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"
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)' }}
/>
<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>
))}
</Reorder.Group>
)}
</div>
<div style={{ marginTop: 24 }}>
<button className="button" onClick={handleConfirm} style={{ width: '100%' }}>
完成
</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);
@@ -298,7 +613,12 @@ export default function HomePage() {
// 自选状态
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
@@ -320,6 +640,83 @@ export default function HomePage() {
const [showDropdown, setShowDropdown] = useState(false);
const [addResultOpen, setAddResultOpen] = useState(false);
const [addFailures, setAddFailures] = useState([]);
const [groupSelectorFund, setGroupSelectorFund] = useState(null); // { code, rect }
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: '' });
@@ -362,6 +759,81 @@ export default function HomePage() {
});
};
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();
@@ -398,6 +870,11 @@ export default function HomePage() {
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') {
@@ -751,6 +1228,14 @@ export default function HomePage() {
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;
@@ -1039,10 +1524,23 @@ export default function HomePage() {
</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">
<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}
>
<button
className={`tab ${currentTab === 'all' ? 'active' : ''}`}
onClick={() => setCurrentTab('all')}
@@ -1055,8 +1553,43 @@ export default function HomePage() {
>
自选 ({favorites.size})
</button>
{groups.map(g => (
<button
key={g.id}
className={`tab ${currentTab === g.id ? 'active' : ''}`}
onClick={() => setCurrentTab(g.id)}
>
{g.name} ({g.codes.length})
</button>
))}
</div>
</div>
{groups.length > 0 && (
<button
className="icon-button manage-groups-btn"
onClick={() => setGroupManageOpen(true)}
title="管理分组"
>
<SortIcon width="16" height="16" />
</button>
)}
{currentTab !== 'all' && currentTab !== 'fav' && (
<button
className="icon-button"
onClick={() => setAddFundToGroupOpen(true)}
title="添加基金到此分组"
>
<FolderPlusIcon width="16" height="16" />
</button>
)}
<button
className="icon-button add-group-btn"
onClick={() => setGroupModalOpen(true)}
title="新增分组"
>
<PlusIcon width="16" height="16" />
</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' }}>
@@ -1105,10 +1638,17 @@ export default function HomePage() {
</div>
</div>
</div>
)}
{funds.length === 0 ? (
<div className="glass card empty">尚未添加基金</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>
) : (
<AnimatePresence mode="wait">
<motion.div
@@ -1121,19 +1661,7 @@ export default function HomePage() {
>
<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) => (
{displayFunds.map((f) => (
<motion.div
layout="position"
key={f.code}
@@ -1147,6 +1675,18 @@ export default function HomePage() {
{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) => {
@@ -1157,6 +1697,7 @@ export default function HomePage() {
>
<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>
@@ -1173,7 +1714,18 @@ export default function HomePage() {
<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">
<div className="table-cell text-center action-cell" style={{ gap: 4 }}>
<button
className={`icon-button ${groups.some(g => g.codes.includes(f.code)) ? 'active' : ''}`}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setGroupSelectorFund({ code: f.code, rect });
}}
title="管理分组"
style={{ width: '28px', height: '28px' }}
>
<FolderPlusIcon width="14" height="14" />
</button>
<button
className="icon-button danger"
onClick={() => removeFund(f.code)}
@@ -1209,15 +1761,29 @@ export default function HomePage() {
<span>估值时间</span>
<strong>{f.gztime || f.time || '-'}</strong>
</div>
<div className="row" style={{ gap: 4 }}>
<button
className={`icon-button ${groups.some(g => g.codes.includes(f.code)) ? 'active' : ''}`}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
setGroupSelectorFund({ code: f.code, rect });
}}
title="管理分组"
style={{ width: '28px', height: '28px' }}
>
<FolderPlusIcon width="14" height="14" />
</button>
<button
className="icon-button danger"
onClick={() => removeFund(f.code)}
title="删除"
style={{ width: '28px', height: '28px' }}
>
<TrashIcon width="18" height="18" />
<TrashIcon width="14" height="14" />
</button>
</div>
</div>
</div>
<div className="row" style={{ marginBottom: 12 }}>
<Stat label="单位净值" value={f.dwjz ?? '—'} />
@@ -1336,6 +1902,106 @@ export default function HomePage() {
)}
</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>
{groupSelectorFund && (
<div
className="modal-overlay"
style={{ background: 'transparent' }}
onClick={() => setGroupSelectorFund(null)}
>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -10 }}
className="glass card group-selector-popup"
style={{
position: 'fixed',
left: groupSelectorFund.rect.left - 160,
top: groupSelectorFund.rect.top + 35,
width: '200px',
zIndex: 10001,
padding: '8px',
maxHeight: '240px',
overflowY: 'auto'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="muted" style={{ fontSize: '12px', padding: '4px 8px', marginBottom: 4 }}>选择分组</div>
{groups.length === 0 ? (
<div className="muted" style={{ padding: '8px', fontSize: '13px' }}>暂无自定义分组</div>
) : (
<div className="group-list">
{groups.map(g => (
<div
key={g.id}
className={`group-item ${g.codes.includes(groupSelectorFund.code) ? 'selected' : ''}`}
onClick={() => toggleFundInGroup(groupSelectorFund.code, g.id)}
style={{
padding: '8px 12px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'background 0.2s ease'
}}
>
<span>{g.name}</span>
{g.codes.includes(groupSelectorFund.code) && (
<div className="checked-mark" style={{ width: '8px', height: '4px' }} />
)}
</div>
))}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', marginTop: 4, paddingTop: 4 }}>
<button
className="tab"
style={{ width: '100%', textAlign: 'left', padding: '8px 12px', fontSize: '13px' }}
onClick={() => {
setGroupSelectorFund(null);
setGroupModalOpen(true);
}}
>
+ 新增分组
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
<AnimatePresence>
{successModal.open && (
<SuccessModal