add:自定义分组展示
This commit is contained in:
203
app/globals.css
203
app/globals.css
@@ -383,12 +383,12 @@ body {
|
|||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
padding: 4px;
|
padding: 4px 0;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: transparent;
|
||||||
border-radius: 12px;
|
border-radius: 0;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.list-mode {
|
.card.list-mode {
|
||||||
@@ -522,7 +522,7 @@ body {
|
|||||||
}
|
}
|
||||||
.tabs {
|
.tabs {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
justify-content: flex-start; /* 移动端改为左对齐 */
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
@@ -531,7 +531,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 8px 20px;
|
padding: 0 20px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -540,6 +540,10 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
@@ -802,3 +806,188 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 41;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
748
app/page.jsx
748
app/page.jsx
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
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 { useForm, ValidationError } from '@formspree/react';
|
||||||
import Announcement from "./components/Announcement";
|
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) {
|
function ListIcon(props) {
|
||||||
return (
|
return (
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<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 }) {
|
function StarIcon({ filled, ...props }) {
|
||||||
return (
|
return (
|
||||||
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={filled ? "var(--accent)" : "none"}>
|
<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() {
|
export default function HomePage() {
|
||||||
const [funds, setFunds] = useState([]);
|
const [funds, setFunds] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -298,7 +613,12 @@ export default function HomePage() {
|
|||||||
|
|
||||||
// 自选状态
|
// 自选状态
|
||||||
const [favorites, setFavorites] = useState(new Set());
|
const [favorites, setFavorites] = useState(new Set());
|
||||||
|
const [groups, setGroups] = useState([]); // [{ id, name, codes: [] }]
|
||||||
const [currentTab, setCurrentTab] = useState('all');
|
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 [sortBy, setSortBy] = useState('default'); // default, name, yield, code
|
||||||
@@ -320,6 +640,83 @@ export default function HomePage() {
|
|||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [addResultOpen, setAddResultOpen] = useState(false);
|
const [addResultOpen, setAddResultOpen] = useState(false);
|
||||||
const [addFailures, setAddFailures] = useState([]);
|
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: '' });
|
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 去重,保留第一次出现的项,避免列表重复
|
// 按 code 去重,保留第一次出现的项,避免列表重复
|
||||||
const dedupeByCode = (list) => {
|
const dedupeByCode = (list) => {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -398,6 +870,11 @@ export default function HomePage() {
|
|||||||
if (Array.isArray(savedFavorites)) {
|
if (Array.isArray(savedFavorites)) {
|
||||||
setFavorites(new Set(savedFavorites));
|
setFavorites(new Set(savedFavorites));
|
||||||
}
|
}
|
||||||
|
// 加载分组状态
|
||||||
|
const savedGroups = JSON.parse(localStorage.getItem('groups') || '[]');
|
||||||
|
if (Array.isArray(savedGroups)) {
|
||||||
|
setGroups(savedGroups);
|
||||||
|
}
|
||||||
// 加载视图模式
|
// 加载视图模式
|
||||||
const savedViewMode = localStorage.getItem('viewMode');
|
const savedViewMode = localStorage.getItem('viewMode');
|
||||||
if (savedViewMode === 'card' || savedViewMode === 'list') {
|
if (savedViewMode === 'card' || savedViewMode === 'list') {
|
||||||
@@ -751,6 +1228,14 @@ export default function HomePage() {
|
|||||||
setFunds(next);
|
setFunds(next);
|
||||||
localStorage.setItem('funds', JSON.stringify(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 => {
|
setCollapsedCodes(prev => {
|
||||||
if (!prev.has(removeCode)) return prev;
|
if (!prev.has(removeCode)) return prev;
|
||||||
@@ -1039,10 +1524,23 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-12">
|
<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 }}>
|
||||||
<div className="filter-bar" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
<div className="tabs-container">
|
||||||
{favorites.size > 0 ? (
|
<div
|
||||||
<div className="tabs">
|
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
|
<button
|
||||||
className={`tab ${currentTab === 'all' ? 'active' : ''}`}
|
className={`tab ${currentTab === 'all' ? 'active' : ''}`}
|
||||||
onClick={() => setCurrentTab('all')}
|
onClick={() => setCurrentTab('all')}
|
||||||
@@ -1055,10 +1553,45 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
自选 ({favorites.size})
|
自选 ({favorites.size})
|
||||||
</button>
|
</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>
|
||||||
) : <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 className="sort-group" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<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' }}>
|
<div className="view-toggle" style={{ display: 'flex', background: 'rgba(255,255,255,0.05)', borderRadius: '10px', padding: '2px' }}>
|
||||||
<button
|
<button
|
||||||
className={`icon-button ${viewMode === 'card' ? 'active' : ''}`}
|
className={`icon-button ${viewMode === 'card' ? 'active' : ''}`}
|
||||||
@@ -1105,10 +1638,17 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{funds.length === 0 ? (
|
{displayFunds.length === 0 ? (
|
||||||
<div className="glass card empty">尚未添加基金</div>
|
<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">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<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 } : {}}>
|
<div className={viewMode === 'card' ? 'grid col-12' : ''} style={viewMode === 'card' ? { gridColumn: 'span 12', gap: 16 } : {}}>
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{funds
|
{displayFunds.map((f) => (
|
||||||
.filter(f => currentTab === 'all' || favorites.has(f.code))
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (sortBy === 'yield') {
|
|
||||||
const valA = typeof a.estGszzl === 'number' ? a.estGszzl : (Number(a.gszzl) || 0);
|
|
||||||
const valB = typeof b.estGszzl === 'number' ? b.estGszzl : (Number(b.gszzl) || 0);
|
|
||||||
return valB - valA;
|
|
||||||
}
|
|
||||||
if (sortBy === 'name') return a.name.localeCompare(b.name, 'zh-CN');
|
|
||||||
if (sortBy === 'code') return a.code.localeCompare(b.code);
|
|
||||||
return 0; // default order is the order in the array
|
|
||||||
})
|
|
||||||
.map((f) => (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
layout="position"
|
layout="position"
|
||||||
key={f.code}
|
key={f.code}
|
||||||
@@ -1147,16 +1675,29 @@ export default function HomePage() {
|
|||||||
{viewMode === 'list' ? (
|
{viewMode === 'list' ? (
|
||||||
<>
|
<>
|
||||||
<div className="table-cell name-cell">
|
<div className="table-cell name-cell">
|
||||||
<button
|
{currentTab !== 'all' && currentTab !== 'fav' ? (
|
||||||
className={`icon-button fav-button ${favorites.has(f.code) ? 'active' : ''}`}
|
<button
|
||||||
onClick={(e) => {
|
className="icon-button fav-button"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
toggleFavorite(f.code);
|
e.stopPropagation();
|
||||||
}}
|
removeFundFromCurrentGroup(f.code);
|
||||||
title={favorites.has(f.code) ? "取消自选" : "添加自选"}
|
}}
|
||||||
>
|
title="从当前分组移除"
|
||||||
<StarIcon width="18" height="18" filled={favorites.has(f.code)} />
|
>
|
||||||
</button>
|
<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">
|
<div className="title-text">
|
||||||
<span className="name-text">{f.name}</span>
|
<span className="name-text">{f.name}</span>
|
||||||
<span className="muted code-text">#{f.code}</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">
|
<div className="table-cell text-right time-cell">
|
||||||
<span className="muted" style={{ fontSize: '12px' }}>{f.gztime || f.time || '-'}</span>
|
<span className="muted" style={{ fontSize: '12px' }}>{f.gztime || f.time || '-'}</span>
|
||||||
</div>
|
</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
|
<button
|
||||||
className="icon-button danger"
|
className="icon-button danger"
|
||||||
onClick={() => removeFund(f.code)}
|
onClick={() => removeFund(f.code)}
|
||||||
@@ -1209,13 +1761,27 @@ export default function HomePage() {
|
|||||||
<span>估值时间</span>
|
<span>估值时间</span>
|
||||||
<strong>{f.gztime || f.time || '-'}</strong>
|
<strong>{f.gztime || f.time || '-'}</strong>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="row" style={{ gap: 4 }}>
|
||||||
className="icon-button danger"
|
<button
|
||||||
onClick={() => removeFund(f.code)}
|
className={`icon-button ${groups.some(g => g.codes.includes(f.code)) ? 'active' : ''}`}
|
||||||
title="删除"
|
onClick={(e) => {
|
||||||
>
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
<TrashIcon width="18" height="18" />
|
setGroupSelectorFund({ code: f.code, rect });
|
||||||
</button>
|
}}
|
||||||
|
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="14" height="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1336,6 +1902,106 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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>
|
<AnimatePresence>
|
||||||
{successModal.open && (
|
{successModal.open && (
|
||||||
<SuccessModal
|
<SuccessModal
|
||||||
|
|||||||
Reference in New Issue
Block a user