add:增加排序和列表模式切换

This commit is contained in:
hzm
2026-02-01 17:34:45 +08:00
parent 85830cee07
commit 26e995ec95
5 changed files with 475 additions and 104 deletions

View File

@@ -367,19 +367,143 @@ body {
backdrop-filter: blur(8px);
}
.card.list-mode {
padding: 12px 16px;
position: relative;
}
.table-container {
padding: 0;
overflow: hidden;
grid-column: span 12;
}
.table-row-wrapper {
width: 100%;
}
.table-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr 60px;
align-items: center;
gap: 12px;
padding: 12px 24px !important;
border-bottom: 1px solid var(--border);
transition: background-color 0.2s ease;
}
.table-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.table-row:last-child {
border-bottom: none;
}
.table-header-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1.5fr 60px;
gap: 12px;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid var(--border);
}
.table-header-cell {
font-size: 13px;
color: var(--text);
font-weight: 700;
letter-spacing: 0.5px;
}
.table-cell {
display: flex;
align-items: center;
}
.text-right { text-align: right; justify-content: flex-end; }
.text-center { text-align: center; justify-content: center; }
.name-cell {
gap: 8px;
overflow: hidden;
}
.title-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.name-text {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.code-text {
font-size: 11px;
}
@media (max-width: 768px) {
.table-header-row {
display: none;
}
.table-row {
grid-template-columns: 1fr 80px 80px;
grid-template-areas:
"name value change"
"name time action";
gap: 4px 12px;
padding: 12px !important;
}
.name-cell { grid-area: name; }
.value-cell { grid-area: value; }
.change-cell { grid-area: change; }
.time-cell {
grid-area: time;
justify-content: flex-end;
}
.action-cell {
grid-area: action;
justify-content: flex-end;
}
.table-cell.time-cell span {
font-size: 10px !important;
}
}
.stat-compact .up { color: var(--danger); }
.stat-compact .down { color: var(--success); }
.filter-bar {
transition: all 0.3s ease;
}
@media (max-width: 640px) {
.tabs {
.filter-bar {
position: sticky;
top: 60px; /* Navbar height (12px*2 + 24px) */
top: 60px; /* Navbar height */
z-index: 40;
width: calc(100% + 32px);
justify-content: center;
background: rgba(15, 23, 42, 0.9);
border-radius: 0;
margin: 0 -16px 16px -16px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
backdrop-filter: blur(16px);
display: flex;
flex-direction: column;
align-items: center !important;
}
.tabs {
width: 100%;
justify-content: center;
background: transparent;
border-radius: 0;
backdrop-filter: none;
padding: 0;
}
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
function PlusIcon(props) {
return (
@@ -49,6 +50,33 @@ function ChevronIcon(props) {
);
}
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 ListIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function StarIcon({ filled, ...props }) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill={filled ? "var(--accent)" : "none"}>
@@ -94,6 +122,12 @@ export default function HomePage() {
const [favorites, setFavorites] = useState(new Set());
const [currentTab, setCurrentTab] = useState('all');
// 排序状态
const [sortBy, setSortBy] = useState('default'); // default, name, yield, code
// 视图模式
const [viewMode, setViewMode] = useState('card'); // card, list
const toggleFavorite = (code) => {
setFavorites(prev => {
const next = new Set(prev);
@@ -144,6 +178,11 @@ export default function HomePage() {
if (Array.isArray(savedFavorites)) {
setFavorites(new Set(savedFavorites));
}
// 加载视图模式
const savedViewMode = localStorage.getItem('viewMode');
if (savedViewMode === 'card' || savedViewMode === 'list') {
setViewMode(savedViewMode);
}
} catch {}
}, []);
@@ -315,6 +354,12 @@ export default function HomePage() {
}
};
const toggleViewMode = () => {
const nextMode = viewMode === 'card' ? 'list' : 'card';
setViewMode(nextMode);
localStorage.setItem('viewMode', nextMode);
};
const addFund = async (e) => {
e.preventDefault();
setError('');
@@ -449,117 +494,278 @@ export default function HomePage() {
</div>
<div className="col-12">
{funds.length > 0 && favorites.size > 0 && (
<div className="tabs" style={{ marginBottom: 16 }}>
<button
className={`tab ${currentTab === 'all' ? 'active' : ''}`}
onClick={() => setCurrentTab('all')}
>
全部 ({funds.length})
</button>
<button
className={`tab ${currentTab === 'fav' ? 'active' : ''}`}
onClick={() => setCurrentTab('fav')}
>
自选 ({favorites.size})
</button>
{funds.length > 0 && (
<div className="filter-bar" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
{favorites.size > 0 ? (
<div className="tabs">
<button
className={`tab ${currentTab === 'all' ? 'active' : ''}`}
onClick={() => setCurrentTab('all')}
>
全部 ({funds.length})
</button>
<button
className={`tab ${currentTab === 'fav' ? 'active' : ''}`}
onClick={() => setCurrentTab('fav')}
>
自选 ({favorites.size})
</button>
</div>
) : <div />}
<div className="sort-group" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div className="view-toggle" style={{ display: 'flex', background: 'rgba(255,255,255,0.05)', borderRadius: '10px', padding: '2px' }}>
<button
className={`icon-button ${viewMode === 'card' ? 'active' : ''}`}
onClick={() => { setViewMode('card'); localStorage.setItem('viewMode', 'card'); }}
style={{ border: 'none', width: '32px', height: '32px', background: viewMode === 'card' ? 'var(--primary)' : 'transparent', color: viewMode === 'card' ? '#05263b' : 'var(--muted)' }}
title="卡片视图"
>
<GridIcon width="16" height="16" />
</button>
<button
className={`icon-button ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => { setViewMode('list'); localStorage.setItem('viewMode', 'list'); }}
style={{ border: 'none', width: '32px', height: '32px', background: viewMode === 'list' ? 'var(--primary)' : 'transparent', color: viewMode === 'list' ? '#05263b' : 'var(--muted)' }}
title="表格视图"
>
<ListIcon width="16" height="16" />
</button>
</div>
<div className="divider" style={{ width: '1px', height: '20px', background: 'var(--border)' }} />
<div className="sort-items" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="muted" style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: 4 }}>
<SortIcon width="14" height="14" />
排序
</span>
<div className="chips">
{[
{ id: 'default', label: '默认' },
{ id: 'yield', label: '涨跌幅' },
{ id: 'name', label: '名称' },
{ id: 'code', label: '代码' }
].map((s) => (
<button
key={s.id}
className={`chip ${sortBy === s.id ? 'active' : ''}`}
onClick={() => setSortBy(s.id)}
style={{ height: '28px', fontSize: '12px', padding: '0 10px' }}
>
{s.label}
</button>
))}
</div>
</div>
</div>
</div>
)}
{funds.length === 0 ? (
<div className="glass card empty">尚未添加基金</div>
) : (
<div className="grid">
{funds
.filter(f => currentTab === 'all' || favorites.has(f.code))
.map((f) => (
<div key={f.code} className="col-6">
<div className="glass card">
<div className="row" style={{ marginBottom: 10 }}>
<div className="title">
<button
className={`icon-button fav-button ${favorites.has(f.code) ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
toggleFavorite(f.code);
}}
title={favorites.has(f.code) ? "取消自选" : "添加自选"}
>
<StarIcon width="18" height="18" filled={favorites.has(f.code)} />
</button>
<span>{f.name}</span>
<span className="muted">#{f.code}</span>
</div>
<div className="actions">
<div className="badge-v">
<span>估值时间</span>
<strong>{f.gztime || f.time || '-'}</strong>
</div>
<button
className="icon-button danger"
onClick={() => removeFund(f.code)}
title="删除"
>
<TrashIcon width="18" height="18" />
</button>
</div>
</div>
<div className="row" style={{ marginBottom: 12 }}>
<Stat label="单位净值" value={f.dwjz ?? '—'} />
<Stat label="估值净值" value={f.gsz ?? '—'} />
<Stat label="涨跌幅" value={typeof f.gszzl === 'number' ? `${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—'} delta={Number(f.gszzl) || 0} />
</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>
{Array.isArray(f.holdings) && f.holdings.length ? (
<div className={`list ${collapsedCodes.has(f.code) ? 'collapsed' : ''}`} style={{
display: collapsedCodes.has(f.code) ? 'none' : 'grid'
}}>
{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>
<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'}
>
{viewMode === 'list' && (
<div className="table-header-row">
<div className="table-header-cell">基金名称</div>
<div className="table-header-cell text-right">估值净值</div>
<div className="table-header-cell text-right">涨跌幅</div>
<div className="table-header-cell text-right">估值时间</div>
<div className="table-header-cell text-center">操作</div>
</div>
)}
<div className={viewMode === 'card' ? 'grid col-12' : ''} style={viewMode === 'card' ? { gridColumn: 'span 12', gap: 16 } : {}}>
<AnimatePresence mode="popLayout">
{funds
.filter(f => currentTab === 'all' || favorites.has(f.code))
.sort((a, b) => {
if (sortBy === 'yield') {
const valA = typeof a.estGszzl === 'number' ? a.estGszzl : (Number(a.gszzl) || 0);
const valB = typeof b.estGszzl === 'number' ? b.estGszzl : (Number(b.gszzl) || 0);
return valB - valA;
}
if (sortBy === 'name') return a.name.localeCompare(b.name, 'zh-CN');
if (sortBy === 'code') return a.code.localeCompare(b.code);
return 0; // default order is the order in the array
})
.map((f) => (
<motion.div
layout="position"
key={f.code}
className={viewMode === 'card' ? 'col-6' : 'table-row-wrapper'}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<div className={viewMode === 'card' ? 'glass card' : 'table-row'}>
{viewMode === 'list' ? (
<>
<div className="table-cell name-cell">
<button
className={`icon-button fav-button ${favorites.has(f.code) ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
toggleFavorite(f.code);
}}
title={favorites.has(f.code) ? "取消自选" : "添加自选"}
>
<StarIcon width="18" height="18" filled={favorites.has(f.code)} />
</button>
<div className="title-text">
<span className="name-text">{f.name}</span>
<span className="muted code-text">#{f.code}</span>
</div>
</div>
<div className="table-cell text-right value-cell">
<span style={{ fontWeight: 700 }}>{f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '')}</span>
</div>
<div className="table-cell text-right change-cell">
<span className={f.estPricedCoverage > 0.05 ? (f.estGszzl > 0 ? 'up' : f.estGszzl < 0 ? 'down' : '') : (Number(f.gszzl) > 0 ? 'up' : Number(f.gszzl) < 0 ? 'down' : '')} style={{ fontWeight: 700 }}>
{f.estPricedCoverage > 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (typeof f.gszzl === 'number' ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')}
</span>
</div>
<div className="table-cell text-right time-cell">
<span className="muted" style={{ fontSize: '12px' }}>{f.gztime || f.time || '-'}</span>
</div>
<div className="table-cell text-center action-cell">
<button
className="icon-button danger"
onClick={() => removeFund(f.code)}
title="删除"
style={{ width: '28px', height: '28px' }}
>
<TrashIcon width="14" height="14" />
</button>
</div>
</>
) : (
<>
<div className="row" style={{ marginBottom: 10 }}>
<div className="title">
<button
className={`icon-button fav-button ${favorites.has(f.code) ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
toggleFavorite(f.code);
}}
title={favorites.has(f.code) ? "取消自选" : "添加自选"}
>
<StarIcon width="18" height="18" filled={favorites.has(f.code)} />
</button>
<div className="title-text">
<span>{f.name}</span>
<span className="muted">#{f.code}</span>
</div>
</div>
<div className="actions">
<div className="badge-v">
<span>估值时间</span>
<strong>{f.gztime || f.time || '-'}</strong>
</div>
<button
className="icon-button danger"
onClick={() => removeFund(f.code)}
title="删除"
>
<TrashIcon width="18" height="18" />
</button>
</div>
</div>
))}
<div className="row" style={{ marginBottom: 12 }}>
<Stat label="单位净值" value={f.dwjz ?? '—'} />
<Stat label="估值净值" value={f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')} />
<Stat
label="涨跌幅"
value={f.estPricedCoverage > 0.05 ? `${f.estGszzl > 0 ? '+' : ''}${f.estGszzl.toFixed(2)}%` : (typeof f.gszzl === 'number' ? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—')}
delta={f.estPricedCoverage > 0.05 ? f.estGszzl : (Number(f.gszzl) || 0)}
/>
</div>
{f.estPricedCoverage > 0.05 && (
<div style={{ fontSize: '10px', color: 'var(--muted)', marginTop: -8, marginBottom: 10, textAlign: 'right' }}>
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算
</div>
)}
<div
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
className="title"
onClick={() => toggleCollapse(f.code)}
>
<div className="row" style={{ width: '100%', flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span>前10重仓股票</span>
<ChevronIcon
width="16"
height="16"
className="muted"
style={{
transform: collapsedCodes.has(f.code) ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease'
}}
/>
</div>
<span className="muted">涨跌幅 / 占比</span>
</div>
</div>
<AnimatePresence>
{!collapsedCodes.has(f.code) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
{Array.isArray(f.holdings) && f.holdings.length ? (
<div className="list">
{f.holdings.map((h, idx) => (
<div className="item" key={idx}>
<span className="name">{h.name}</span>
<div className="values">
{typeof h.change === 'number' && (
<span className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}>
{h.change > 0 ? '+' : ''}{h.change.toFixed(2)}%
</span>
)}
<span className="weight">{h.weight}</span>
</div>
</div>
))}
</div>
) : (
<div className="muted" style={{ padding: '8px 0' }}>暂无重仓数据</div>
)}
</motion.div>
)}
</AnimatePresence>
</>
)}
</div>
) : (
<div className="muted" style={{ display: collapsedCodes.has(f.code) ? 'none' : 'block' }}>暂无重仓数据</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
))}
</div>
</motion.div>
</AnimatePresence>
)}
</div>
</div>
<div className="footer">数据源实时估值与重仓直连东方财富无需后端部署即用</div>
<div className="footer">
<p>数据源实时估值与重仓直连东方财富无需后端部署即用</p>
<p>估算数据与真实结算数据会有1%左右误差</p>
</div>
{settingsOpen && (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={() => setSettingsOpen(false)}>