From 073fdcdd7ffc19defe75e12b2735f99fb9d8aa61 Mon Sep 17 00:00:00 2001 From: hzm <934585316@qq.com> Date: Tue, 3 Feb 2026 22:38:32 +0800 Subject: [PATCH] =?UTF-8?q?add=EF=BC=9A=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=88=86=E7=BB=84=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/globals.css | 203 ++++++++++++- app/page.jsx | 748 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 903 insertions(+), 48 deletions(-) diff --git a/app/globals.css b/app/globals.css index c8cfc62..b9d6b78 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; + } +} diff --git a/app/page.jsx b/app/page.jsx index 2475a5a..8a07eba 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -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 ( + + + + ); +} + function ListIcon(props) { return ( @@ -87,6 +95,22 @@ function ListIcon(props) { ); } +function DragIcon(props) { + return ( + + + + ); +} + +function FolderPlusIcon(props) { + return ( + + + + ); +} + function StarIcon({ filled, ...props }) { return ( @@ -278,6 +302,297 @@ function SuccessModal({ message, onClose }) { ); } +function ConfirmModal({ title, message, onConfirm, onCancel }) { + return ( + + e.stopPropagation()} + > +
+ + {title} +
+

+ {message} +

+
+ + +
+ + + ); +} + +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 ( + + e.stopPropagation()} + > +
+
+ + 管理分组 +
+ +
+ +
+ {items.length === 0 ? ( +
+
📂
+

暂无自定义分组

+
+ ) : ( + + {items.map((item) => ( + +
+ +
+ handleRename(item.id, e.target.value)} + placeholder="分组名称" + style={{ flex: 1, height: '36px', fontSize: '14px', background: 'rgba(0,0,0,0.2)' }} + /> + +
+ ))} +
+ )} +
+ +
+ +
+
+ + + {deleteConfirm && ( + setDeleteConfirm(null)} + /> + )} + +
+ ); +} + +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 ( + + e.stopPropagation()} + > +
+
+ + 添加基金到分组 +
+ +
+ +
+ {availableFunds.length === 0 ? ( +
+

所有基金已在该分组中

+
+ ) : ( +
+ {availableFunds.map((fund) => ( +
toggleSelect(fund.code)} + style={{ cursor: 'pointer' }} + > +
+ {selected.has(fund.code) &&
} +
+
+
{fund.name}
+
#{fund.code}
+
+
+ ))} +
+ )} +
+ +
+ + +
+ + + ); +} + +function GroupModal({ onClose, onConfirm }) { + const [name, setName] = useState(''); + return ( + + e.stopPropagation()} + > +
+
+ + 新增分组 +
+ +
+
+ + { + const v = e.target.value || ''; + // 限制最多 8 个字符(兼容中英文),超出部分自动截断 + setName(v.slice(0, 8)); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && name.trim()) onConfirm(name.trim()); + }} + /> +
+
+ + +
+
+
+ ); +} + 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() {
- {funds.length > 0 && ( -
- {favorites.size > 0 ? ( -
+
+
+
+
+ {groups.map(g => ( + + ))}
- ) :
} +
+ {groups.length > 0 && ( + + )} + {currentTab !== 'all' && currentTab !== 'fav' && ( + + )} + +
-
+
- )} - {funds.length === 0 ? ( -
尚未添加基金
+ {displayFunds.length === 0 ? ( +
+
📂
+
{funds.length === 0 ? '尚未添加基金' : '该分组下暂无数据'}
+ {currentTab !== 'all' && currentTab !== 'fav' && funds.length > 0 && ( + + )} +
) : (
- {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) => (
- + {currentTab !== 'all' && currentTab !== 'fav' ? ( + + ) : ( + + )}
{f.name} #{f.code} @@ -1173,7 +1714,18 @@ export default function HomePage() {
{f.gztime || f.time || '-'}
-
+
+
- +
+ + +
@@ -1336,6 +1902,106 @@ export default function HomePage() { )} + + {addFundToGroupOpen && ( + g.id === currentTab)?.codes || []} + onClose={() => setAddFundToGroupOpen(false)} + onAdd={handleAddFundsToGroup} + /> + )} + + + + {groupManageOpen && ( + setGroupManageOpen(false)} + onSave={handleUpdateGroups} + /> + )} + + + + {groupModalOpen && ( + setGroupModalOpen(false)} + onConfirm={handleAddGroup} + /> + )} + + + + {groupSelectorFund && ( +
setGroupSelectorFund(null)} + > + e.stopPropagation()} + > +
选择分组
+ {groups.length === 0 ? ( +
暂无自定义分组
+ ) : ( +
+ {groups.map(g => ( +
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' + }} + > + {g.name} + {g.codes.includes(groupSelectorFund.code) && ( +
+ )} +
+ ))} +
+ )} +
+ +
+ +
+ )} + + {successModal.open && (