feat: 移动端列表模式点击基金名称查看详情

This commit is contained in:
hzm
2026-03-08 14:52:55 +08:00
parent 354936c9af
commit e97de8744a
15 changed files with 1885 additions and 815 deletions

459
app/components/FundCard.jsx Normal file
View File

@@ -0,0 +1,459 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import { isNumber, isString } from 'lodash';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Stat } from './Common';
import FundTrendChart from './FundTrendChart';
import FundIntradayChart from './FundIntradayChart';
import {
ChevronIcon,
ExitIcon,
SettingsIcon,
StarIcon,
SwitchIcon,
TrashIcon,
} from './Icons';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(isSameOrAfter);
const DEFAULT_TZ = 'Asia/Shanghai';
const getBrowserTimeZone = () => {
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz || DEFAULT_TZ;
}
return DEFAULT_TZ;
};
const TZ = getBrowserTimeZone();
const toTz = (input) => (input ? dayjs.tz(input, TZ) : dayjs().tz(TZ));
export default function FundCard({
fund: f,
todayStr,
currentTab,
favorites,
dcaPlans,
holdings,
percentModes,
valuationSeries,
collapsedCodes,
collapsedTrends,
transactions,
theme,
isTradingDay,
refreshing,
getHoldingProfit,
onRemoveFromGroup,
onToggleFavorite,
onRemoveFund,
onHoldingClick,
onActionClick,
onPercentModeToggle,
onToggleCollapse,
onToggleTrendCollapse,
layoutMode = 'card', // 'card' | 'drawer'drawer 时前10重仓与业绩走势以 Tabs 展示
}) {
const holding = holdings[f?.code];
const profit = getHoldingProfit?.(f, holding) ?? null;
const hasHoldings = f.holdingsIsLastQuarter && Array.isArray(f.holdings) && f.holdings.length > 0;
const style = layoutMode === 'drawer' ? { border: 'none', boxShadow: 'none' } : {};
return (
<motion.div
className="glass card"
style={{
position: 'relative',
zIndex: 1,
...style,
}}
>
<div className="row" style={{ marginBottom: 10 }}>
<div className="title">
{currentTab !== 'all' && currentTab !== 'fav' ? (
<button
className="icon-button fav-button"
onClick={(e) => {
e.stopPropagation();
onRemoveFromGroup?.(f.code);
}}
title="从当前分组移除"
>
<ExitIcon width="18" height="18" style={{ transform: 'rotate(180deg)' }} />
</button>
) : (
<button
className={`icon-button fav-button ${favorites?.has(f.code) ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
onToggleFavorite?.(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"
title={f.jzrq === todayStr ? '今日净值已更新' : ''}
>
{f.name}
</span>
<span className="muted">
#{f.code}
{dcaPlans?.[f.code]?.enabled === true && <span className="dca-indicator"></span>}
{f.jzrq === todayStr && <span className="updated-indicator"></span>}
</span>
</div>
</div>
<div className="actions">
<div className="badge-v">
<span>{f.noValuation ? '净值日期' : '估值时间'}</span>
<strong>{f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-')}</strong>
</div>
<div className="row" style={{ gap: 4 }}>
<button
className="icon-button danger"
onClick={() => !refreshing && onRemoveFund?.(f)}
title="删除"
disabled={refreshing}
style={{
width: '28px',
height: '28px',
opacity: refreshing ? 0.6 : 1,
cursor: refreshing ? 'not-allowed' : 'pointer',
}}
>
<TrashIcon width="14" height="14" />
</button>
</div>
</div>
</div>
<div className="row" style={{ marginBottom: 12 }}>
<Stat label="单位净值" value={f.dwjz ?? '—'} />
{f.noValuation ? (
<Stat
label="涨跌幅"
value={
f.zzl !== undefined && f.zzl !== null
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
: '—'
}
delta={f.zzl}
/>
) : (
<>
{(() => {
const hasTodayData = f.jzrq === todayStr;
let isYesterdayChange = false;
let isPreviousTradingDay = false;
if (!hasTodayData && isString(f.jzrq)) {
const today = toTz(todayStr).startOf('day');
const jzDate = toTz(f.jzrq).startOf('day');
const yesterday = today.clone().subtract(1, 'day');
if (jzDate.isSame(yesterday, 'day')) {
isYesterdayChange = true;
} else if (jzDate.isBefore(yesterday, 'day')) {
isPreviousTradingDay = true;
}
}
const shouldHideChange =
isTradingDay && !hasTodayData && !isYesterdayChange && !isPreviousTradingDay;
if (shouldHideChange) return null;
const changeLabel = hasTodayData ? '涨跌幅' : '昨日涨幅';
return (
<Stat
label={changeLabel}
value={
f.zzl !== undefined
? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%`
: ''
}
delta={f.zzl}
/>
);
})()}
<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)}%`
: isNumber(f.gszzl)
? `${f.gszzl > 0 ? '+' : ''}${f.gszzl.toFixed(2)}%`
: f.gszzl ?? '—'
}
delta={f.estPricedCoverage > 0.05 ? f.estGszzl : Number(f.gszzl) || 0}
/>
</>
)}
</div>
<div className="row" style={{ marginBottom: 12 }}>
{!profit ? (
<div
className="stat"
style={{ flexDirection: 'column', gap: 4 }}
>
<span className="label">持仓金额</span>
<div
className="value muted"
style={{
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: 4,
cursor: 'pointer',
}}
onClick={() => onHoldingClick?.(f)}
>
未设置 <SettingsIcon width="12" height="12" />
</div>
</div>
) : (
<>
<div
className="stat"
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }}
onClick={() => onActionClick?.(f)}
>
<span
className="label"
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
持仓金额 <SettingsIcon width="12" height="12" style={{ opacity: 0.7 }} />
</span>
<span className="value">¥{profit.amount.toFixed(2)}</span>
</div>
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
<span className="label">当日收益</span>
<span
className={`value ${
profit.profitToday != null
? profit.profitToday > 0
? 'up'
: profit.profitToday < 0
? 'down'
: ''
: 'muted'
}`}
>
{profit.profitToday != null
? `${profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥${Math.abs(profit.profitToday).toFixed(2)}`
: '--'}
</span>
</div>
{profit.profitTotal !== null && (
<div
className="stat"
onClick={(e) => {
e.stopPropagation();
onPercentModeToggle?.(f.code);
}}
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }}
title="点击切换金额/百分比"
>
<span
className="label"
style={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
持有收益{percentModes?.[f.code] ? '(%)' : ''}
<SwitchIcon />
</span>
<span
className={`value ${
profit.profitTotal > 0 ? 'up' : profit.profitTotal < 0 ? 'down' : ''
}`}
>
{profit.profitTotal > 0 ? '+' : profit.profitTotal < 0 ? '-' : ''}
{percentModes?.[f.code]
? `${Math.abs(
holding?.cost * holding?.share
? (profit.profitTotal / (holding.cost * holding.share)) * 100
: 0,
).toFixed(2)}%`
: `¥${Math.abs(profit.profitTotal).toFixed(2)}`}
</span>
</div>
)}
</>
)}
</div>
{f.estPricedCoverage > 0.05 && (
<div
style={{
fontSize: '10px',
color: 'var(--muted)',
marginTop: -8,
marginBottom: 10,
textAlign: 'right',
}}
>
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算
</div>
)}
{(() => {
const showIntraday =
Array.isArray(valuationSeries?.[f.code]) && valuationSeries[f.code].length >= 2;
if (!showIntraday) return null;
if (
f.gztime &&
toTz(todayStr).startOf('day').isAfter(toTz(f.gztime).startOf('day'))
) {
return null;
}
if (
f.jzrq &&
f.gztime &&
toTz(f.jzrq).startOf('day').isSameOrAfter(toTz(f.gztime).startOf('day'))
) {
return null;
}
return (
<FundIntradayChart
key={`${f.code}-intraday-${theme}`}
series={valuationSeries[f.code]}
referenceNav={f.dwjz != null ? Number(f.dwjz) : undefined}
theme={theme}
/>
);
})()}
{layoutMode === 'drawer' ? (
<Tabs defaultValue={hasHoldings ? 'holdings' : 'trend'} className="w-full">
<TabsList className={`w-full ${hasHoldings ? 'grid grid-cols-2' : ''}`}>
{hasHoldings && (
<TabsTrigger value="holdings">前10重仓股票</TabsTrigger>
)}
<TabsTrigger value="trend">业绩走势</TabsTrigger>
</TabsList>
{hasHoldings && (
<TabsContent value="holdings" className="mt-3 outline-none">
<div className="list">
{f.holdings.map((h, idx) => (
<div className="item" key={idx}>
<span className="name">{h.name}</span>
<div className="values">
{isNumber(h.change) && (
<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>
</TabsContent>
)}
<TabsContent value="trend" className="mt-3 outline-none">
<FundTrendChart
key={`${f.code}-${theme}`}
code={f.code}
isExpanded
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
transactions={transactions?.[f.code] || []}
theme={theme}
hideHeader
/>
</TabsContent>
</Tabs>
) : (
<>
{hasHoldings && (
<>
<div
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
className="title"
onClick={() => onToggleCollapse?.(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' }}
>
<div className="list">
{f.holdings.map((h, idx) => (
<div className="item" key={idx}>
<span className="name">{h.name}</span>
<div className="values">
{isNumber(h.change) && (
<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>
</motion.div>
)}
</AnimatePresence>
</>
)}
<FundTrendChart
key={`${f.code}-${theme}`}
code={f.code}
isExpanded={!collapsedTrends?.has(f.code)}
onToggleExpand={() => onToggleTrendCollapse?.(f.code)}
transactions={transactions?.[f.code] || []}
theme={theme}
/>
</>
)}
</motion.div>
);
}