add:增加排序和列表模式切换
This commit is contained in:
132
app/globals.css
132
app/globals.css
@@ -367,19 +367,143 @@ body {
|
|||||||
backdrop-filter: blur(8px);
|
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) {
|
@media (max-width: 640px) {
|
||||||
.tabs {
|
.filter-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 60px; /* Navbar height (12px*2 + 24px) */
|
top: 60px; /* Navbar height */
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
width: calc(100% + 32px);
|
width: calc(100% + 32px);
|
||||||
justify-content: center;
|
|
||||||
background: rgba(15, 23, 42, 0.9);
|
background: rgba(15, 23, 42, 0.9);
|
||||||
border-radius: 0;
|
|
||||||
margin: 0 -16px 16px -16px;
|
margin: 0 -16px 16px -16px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
backdrop-filter: blur(16px);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
400
app/page.jsx
400
app/page.jsx
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
function PlusIcon(props) {
|
function PlusIcon(props) {
|
||||||
return (
|
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 }) {
|
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"}>
|
||||||
@@ -94,6 +122,12 @@ export default function HomePage() {
|
|||||||
const [favorites, setFavorites] = useState(new Set());
|
const [favorites, setFavorites] = useState(new Set());
|
||||||
const [currentTab, setCurrentTab] = useState('all');
|
const [currentTab, setCurrentTab] = useState('all');
|
||||||
|
|
||||||
|
// 排序状态
|
||||||
|
const [sortBy, setSortBy] = useState('default'); // default, name, yield, code
|
||||||
|
|
||||||
|
// 视图模式
|
||||||
|
const [viewMode, setViewMode] = useState('card'); // card, list
|
||||||
|
|
||||||
const toggleFavorite = (code) => {
|
const toggleFavorite = (code) => {
|
||||||
setFavorites(prev => {
|
setFavorites(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -144,6 +178,11 @@ export default function HomePage() {
|
|||||||
if (Array.isArray(savedFavorites)) {
|
if (Array.isArray(savedFavorites)) {
|
||||||
setFavorites(new Set(savedFavorites));
|
setFavorites(new Set(savedFavorites));
|
||||||
}
|
}
|
||||||
|
// 加载视图模式
|
||||||
|
const savedViewMode = localStorage.getItem('viewMode');
|
||||||
|
if (savedViewMode === 'card' || savedViewMode === 'list') {
|
||||||
|
setViewMode(savedViewMode);
|
||||||
|
}
|
||||||
} catch {}
|
} 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) => {
|
const addFund = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
@@ -449,117 +494,278 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
{funds.length > 0 && favorites.size > 0 && (
|
{funds.length > 0 && (
|
||||||
<div className="tabs" style={{ marginBottom: 16 }}>
|
<div className="filter-bar" style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||||
<button
|
{favorites.size > 0 ? (
|
||||||
className={`tab ${currentTab === 'all' ? 'active' : ''}`}
|
<div className="tabs">
|
||||||
onClick={() => setCurrentTab('all')}
|
<button
|
||||||
>
|
className={`tab ${currentTab === 'all' ? 'active' : ''}`}
|
||||||
全部 ({funds.length})
|
onClick={() => setCurrentTab('all')}
|
||||||
</button>
|
>
|
||||||
<button
|
全部 ({funds.length})
|
||||||
className={`tab ${currentTab === 'fav' ? 'active' : ''}`}
|
</button>
|
||||||
onClick={() => setCurrentTab('fav')}
|
<button
|
||||||
>
|
className={`tab ${currentTab === 'fav' ? 'active' : ''}`}
|
||||||
自选 ({favorites.size})
|
onClick={() => setCurrentTab('fav')}
|
||||||
</button>
|
>
|
||||||
|
自选 ({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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{funds.length === 0 ? (
|
{funds.length === 0 ? (
|
||||||
<div className="glass card empty">尚未添加基金</div>
|
<div className="glass card empty">尚未添加基金</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid">
|
<AnimatePresence mode="wait">
|
||||||
{funds
|
<motion.div
|
||||||
.filter(f => currentTab === 'all' || favorites.has(f.code))
|
key={viewMode}
|
||||||
.map((f) => (
|
initial={{ opacity: 0, y: 10 }}
|
||||||
<div key={f.code} className="col-6">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="glass card">
|
exit={{ opacity: 0, y: -10 }}
|
||||||
<div className="row" style={{ marginBottom: 10 }}>
|
transition={{ duration: 0.2 }}
|
||||||
<div className="title">
|
className={viewMode === 'card' ? 'grid' : 'table-container glass'}
|
||||||
<button
|
>
|
||||||
className={`icon-button fav-button ${favorites.has(f.code) ? 'active' : ''}`}
|
{viewMode === 'list' && (
|
||||||
onClick={(e) => {
|
<div className="table-header-row">
|
||||||
e.stopPropagation();
|
<div className="table-header-cell">基金名称</div>
|
||||||
toggleFavorite(f.code);
|
<div className="table-header-cell text-right">估值净值</div>
|
||||||
}}
|
<div className="table-header-cell text-right">涨跌幅</div>
|
||||||
title={favorites.has(f.code) ? "取消自选" : "添加自选"}
|
<div className="table-header-cell text-right">估值时间</div>
|
||||||
>
|
<div className="table-header-cell text-center">操作</div>
|
||||||
<StarIcon width="18" height="18" filled={favorites.has(f.code)} />
|
</div>
|
||||||
</button>
|
)}
|
||||||
<span>{f.name}</span>
|
<div className={viewMode === 'card' ? 'grid col-12' : ''} style={viewMode === 'card' ? { gridColumn: 'span 12', gap: 16 } : {}}>
|
||||||
<span className="muted">#{f.code}</span>
|
<AnimatePresence mode="popLayout">
|
||||||
</div>
|
{funds
|
||||||
<div className="actions">
|
.filter(f => currentTab === 'all' || favorites.has(f.code))
|
||||||
<div className="badge-v">
|
.sort((a, b) => {
|
||||||
<span>估值时间</span>
|
if (sortBy === 'yield') {
|
||||||
<strong>{f.gztime || f.time || '-'}</strong>
|
const valA = typeof a.estGszzl === 'number' ? a.estGszzl : (Number(a.gszzl) || 0);
|
||||||
</div>
|
const valB = typeof b.estGszzl === 'number' ? b.estGszzl : (Number(b.gszzl) || 0);
|
||||||
<button
|
return valB - valA;
|
||||||
className="icon-button danger"
|
}
|
||||||
onClick={() => removeFund(f.code)}
|
if (sortBy === 'name') return a.name.localeCompare(b.name, 'zh-CN');
|
||||||
title="删除"
|
if (sortBy === 'code') return a.code.localeCompare(b.code);
|
||||||
>
|
return 0; // default order is the order in the array
|
||||||
<TrashIcon width="18" height="18" />
|
})
|
||||||
</button>
|
.map((f) => (
|
||||||
</div>
|
<motion.div
|
||||||
</div>
|
layout="position"
|
||||||
<div className="row" style={{ marginBottom: 12 }}>
|
key={f.code}
|
||||||
<Stat label="单位净值" value={f.dwjz ?? '—'} />
|
className={viewMode === 'card' ? 'col-6' : 'table-row-wrapper'}
|
||||||
<Stat label="估值净值" value={f.gsz ?? '—'} />
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
<Stat label="涨跌幅" value={typeof f.gszzl === 'number' ? `${f.gszzl.toFixed(2)}%` : f.gszzl ?? '—'} delta={Number(f.gszzl) || 0} />
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
</div>
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
<div
|
transition={{ duration: 0.2 }}
|
||||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
>
|
||||||
className="title"
|
<div className={viewMode === 'card' ? 'glass card' : 'table-row'}>
|
||||||
onClick={() => toggleCollapse(f.code)}
|
{viewMode === 'list' ? (
|
||||||
>
|
<>
|
||||||
<div className="row" style={{ width: '100%', flex: 1 }}>
|
<div className="table-cell name-cell">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<button
|
||||||
<span>前10重仓股票</span>
|
className={`icon-button fav-button ${favorites.has(f.code) ? 'active' : ''}`}
|
||||||
<ChevronIcon
|
onClick={(e) => {
|
||||||
width="16"
|
e.stopPropagation();
|
||||||
height="16"
|
toggleFavorite(f.code);
|
||||||
className="muted"
|
}}
|
||||||
style={{
|
title={favorites.has(f.code) ? "取消自选" : "添加自选"}
|
||||||
transform: collapsedCodes.has(f.code) ? 'rotate(-90deg)' : 'rotate(0deg)',
|
>
|
||||||
transition: 'transform 0.2s ease'
|
<StarIcon width="18" height="18" filled={favorites.has(f.code)} />
|
||||||
}}
|
</button>
|
||||||
/>
|
<div className="title-text">
|
||||||
</div>
|
<span className="name-text">{f.name}</span>
|
||||||
<span className="muted">涨跌幅 / 占比</span>
|
<span className="muted code-text">#{f.code}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{Array.isArray(f.holdings) && f.holdings.length ? (
|
<div className="table-cell text-right value-cell">
|
||||||
<div className={`list ${collapsedCodes.has(f.code) ? 'collapsed' : ''}`} style={{
|
<span style={{ fontWeight: 700 }}>{f.estPricedCoverage > 0.05 ? f.estGsz.toFixed(4) : (f.gsz ?? '—')}</span>
|
||||||
display: collapsedCodes.has(f.code) ? 'none' : 'grid'
|
</div>
|
||||||
}}>
|
<div className="table-cell text-right change-cell">
|
||||||
{f.holdings.map((h, idx) => (
|
<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 }}>
|
||||||
<div className="item" key={idx}>
|
{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 className="name">{h.name}</span>
|
</span>
|
||||||
<div className="values">
|
</div>
|
||||||
{typeof h.change === 'number' && (
|
<div className="table-cell text-right time-cell">
|
||||||
<span className={`badge ${h.change > 0 ? 'up' : h.change < 0 ? 'down' : ''}`} style={{ marginRight: 8 }}>
|
<span className="muted" style={{ fontSize: '12px' }}>{f.gztime || f.time || '-'}</span>
|
||||||
{h.change > 0 ? '+' : ''}{h.change.toFixed(2)}%
|
</div>
|
||||||
</span>
|
<div className="table-cell text-center action-cell">
|
||||||
)}
|
<button
|
||||||
<span className="weight">{h.weight}</span>
|
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>
|
</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>
|
||||||
) : (
|
</motion.div>
|
||||||
<div className="muted" style={{ display: collapsedCodes.has(f.code) ? 'none' : 'block' }}>暂无重仓数据</div>
|
))}
|
||||||
)}
|
</AnimatePresence>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</motion.div>
|
||||||
</div>
|
</AnimatePresence>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="footer">数据源:实时估值与重仓直连东方财富,无需后端,部署即用</div>
|
<div className="footer">
|
||||||
|
<p>数据源:实时估值与重仓直连东方财富,无需后端,部署即用</p>
|
||||||
|
<p>注:估算数据与真实结算数据会有1%左右误差</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={() => setSettingsOpen(false)}>
|
<div className="modal-overlay" role="dialog" aria-modal="true" aria-label="设置" onClick={() => setSettingsOpen(false)}>
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
experimental: {
|
|
||||||
appDir: true
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "real-time-fund",
|
"name": "real-time-fund",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"framer-motion": "^12.29.2",
|
||||||
"next": "14.2.5",
|
"next": "14.2.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1"
|
||||||
@@ -216,6 +217,33 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.2.tgz",
|
||||||
|
"integrity": "sha512-lSNRzBJk4wuIy0emYQ/nfZ7eWhqud2umPKw2QAQki6uKhZPKm2hRQHeQoHTG9MIvfobb+A/LbEWPJU794ZUKrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.29.2",
|
||||||
|
"motion-utils": "^12.29.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@@ -240,6 +268,21 @@
|
|||||||
"loose-envify": "cli.js"
|
"loose-envify": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz",
|
||||||
|
"integrity": "sha512-/k+NuycVV8pykxyiTCoFzIVLA95Nb1BFIVvfSu9L50/6K6qNeAYtkxXILy/LRutt7AzaYDc2myj0wkCVVYAPPA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.29.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
|
||||||
|
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"framer-motion": "^12.29.2",
|
||||||
"next": "14.2.5",
|
"next": "14.2.5",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user