feat: 移动端列表模式点击基金名称查看详情
This commit is contained in:
@@ -1,59 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { TrashIcon } from './Icons';
|
||||
|
||||
export default function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) {
|
||||
const content = (
|
||||
<motion.div
|
||||
className="modal-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ zIndex: 10002 }}
|
||||
export default function ConfirmModal({
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = '确定删除',
|
||||
}) {
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="max-w-[400px] flex flex-col gap-5 p-6"
|
||||
>
|
||||
<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' }}>
|
||||
<DialogHeader className="flex flex-row items-center gap-3 text-left">
|
||||
<TrashIcon width="20" height="20" className="shrink-0 text-[var(--danger)]" />
|
||||
<DialogTitle className="flex-1 text-base font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription className="text-left text-sm leading-relaxed text-[var(--muted-foreground)]">
|
||||
{message}
|
||||
</p>
|
||||
<div className="row" style={{ gap: 12 }}>
|
||||
</DialogDescription>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
className="button secondary"
|
||||
type="button"
|
||||
className="button secondary min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0"
|
||||
onClick={onCancel}
|
||||
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="button danger"
|
||||
type="button"
|
||||
className="button danger min-w-0 flex-1 cursor-pointer h-auto min-h-[48px] py-3 sm:h-11 sm:min-h-0 sm:py-0"
|
||||
onClick={onConfirm}
|
||||
style={{ flex: 1 }}
|
||||
autoFocus
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
|
||||
33
app/components/EmptyStateCard.jsx
Normal file
33
app/components/EmptyStateCard.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
export default function EmptyStateCard({
|
||||
fundsLength = 0,
|
||||
currentTab = 'all',
|
||||
onAddToGroup,
|
||||
}) {
|
||||
const isEmpty = fundsLength === 0;
|
||||
const isGroupTab = currentTab !== 'all' && currentTab !== 'fav';
|
||||
|
||||
return (
|
||||
<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 }}>
|
||||
{isEmpty ? '尚未添加基金' : '该分组下暂无数据'}
|
||||
</div>
|
||||
{isGroupTab && fundsLength > 0 && (
|
||||
<button className="button" onClick={onAddToGroup}>
|
||||
添加基金到此分组
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
459
app/components/FundCard.jsx
Normal file
459
app/components/FundCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ function getChartThemeColors(theme) {
|
||||
return CHART_COLORS[theme] || CHART_COLORS.dark;
|
||||
}
|
||||
|
||||
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark' }) {
|
||||
export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [], theme = 'dark', hideHeader = false }) {
|
||||
const [range, setRange] = useState('1m');
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -490,46 +490,8 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
}];
|
||||
}, [theme]); // theme 变化时重算以应用亮色/暗色坐标轴与 crosshair
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||
className="title"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<div className="row" style={{ width: '100%', flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>业绩走势</span>
|
||||
<ChevronIcon
|
||||
width="16"
|
||||
height="16"
|
||||
className="muted"
|
||||
style={{
|
||||
transform: !isExpanded ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
||||
<span style={{ color: lineColor, fontWeight: 600 }}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<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' }}
|
||||
>
|
||||
const chartBlock = (
|
||||
<>
|
||||
<div style={{ position: 'relative', height: 180, width: '100%', touchAction: 'pan-y' }}>
|
||||
{loading && (
|
||||
<div className="chart-overlay" style={{ backdropFilter: 'blur(2px)' }}>
|
||||
@@ -560,9 +522,70 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand, trans
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: hideHeader ? 0 : 16 }} onClick={(e) => e.stopPropagation()}>
|
||||
{!hideHeader && (
|
||||
<div
|
||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||
className="title"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<div className="row" style={{ width: '100%', flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>业绩走势</span>
|
||||
<ChevronIcon
|
||||
width="16"
|
||||
height="16"
|
||||
className="muted"
|
||||
style={{
|
||||
transform: !isExpanded ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
||||
<span style={{ color: lineColor, fontWeight: 600 }}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hideHeader && data.length > 0 && (
|
||||
<div className="row" style={{ marginBottom: 8, justifyContent: 'flex-end' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
||||
<span style={{ color: lineColor, fontWeight: 600 }}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hideHeader ? (
|
||||
chartBlock
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<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' }}
|
||||
>
|
||||
{chartBlock}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
332
app/components/GroupSummary.jsx
Normal file
332
app/components/GroupSummary.jsx
Normal file
@@ -0,0 +1,332 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useMemo, useLayoutEffect } from 'react';
|
||||
import { PinIcon, PinOffIcon, EyeIcon, EyeOffIcon, SwitchIcon } from './Icons';
|
||||
|
||||
// 数字滚动组件(初始化时无动画,后续变更再动画)
|
||||
function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = '', style = {} }) {
|
||||
const [displayValue, setDisplayValue] = useState(value);
|
||||
const previousValue = useRef(value);
|
||||
const isFirstChange = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousValue.current === value) return;
|
||||
|
||||
if (isFirstChange.current) {
|
||||
isFirstChange.current = false;
|
||||
previousValue.current = value;
|
||||
setDisplayValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = previousValue.current;
|
||||
const end = value;
|
||||
const duration = 400;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - progress, 4);
|
||||
const current = start + (end - start) * ease;
|
||||
setDisplayValue(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
previousValue.current = value;
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<span className={className} style={style}>
|
||||
{prefix}
|
||||
{Math.abs(displayValue).toFixed(decimals)}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GroupSummary({
|
||||
funds,
|
||||
holdings,
|
||||
groupName,
|
||||
getProfit,
|
||||
stickyTop,
|
||||
}) {
|
||||
const [showPercent, setShowPercent] = useState(true);
|
||||
const [isMasked, setIsMasked] = useState(false);
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const rowRef = useRef(null);
|
||||
const [assetSize, setAssetSize] = useState(24);
|
||||
const [metricSize, setMetricSize] = useState(18);
|
||||
const [winW, setWinW] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setWinW(window.innerWidth);
|
||||
const onR = () => setWinW(window.innerWidth);
|
||||
window.addEventListener('resize', onR);
|
||||
return () => window.removeEventListener('resize', onR);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let totalAsset = 0;
|
||||
let totalProfitToday = 0;
|
||||
let totalHoldingReturn = 0;
|
||||
let totalCost = 0;
|
||||
let hasHolding = false;
|
||||
let hasAnyTodayData = false;
|
||||
|
||||
funds.forEach((fund) => {
|
||||
const holding = holdings[fund.code];
|
||||
const profit = getProfit(fund, holding);
|
||||
|
||||
if (profit) {
|
||||
hasHolding = true;
|
||||
totalAsset += profit.amount;
|
||||
if (profit.profitToday != null) {
|
||||
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
|
||||
hasAnyTodayData = true;
|
||||
}
|
||||
if (profit.profitTotal !== null) {
|
||||
totalHoldingReturn += profit.profitTotal;
|
||||
if (holding && typeof holding.cost === 'number' && typeof holding.share === 'number') {
|
||||
totalCost += holding.cost * holding.share;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalAsset,
|
||||
totalProfitToday,
|
||||
totalHoldingReturn,
|
||||
hasHolding,
|
||||
returnRate,
|
||||
hasAnyTodayData,
|
||||
};
|
||||
}, [funds, holdings, getProfit]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = rowRef.current;
|
||||
if (!el) return;
|
||||
const height = el.clientHeight;
|
||||
const tooTall = height > 80;
|
||||
if (tooTall) {
|
||||
setAssetSize((s) => Math.max(16, s - 1));
|
||||
setMetricSize((s) => Math.max(12, s - 1));
|
||||
}
|
||||
}, [
|
||||
winW,
|
||||
summary.totalAsset,
|
||||
summary.totalProfitToday,
|
||||
summary.totalHoldingReturn,
|
||||
summary.returnRate,
|
||||
showPercent,
|
||||
assetSize,
|
||||
metricSize,
|
||||
]);
|
||||
|
||||
if (!summary.hasHolding) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={isSticky ? 'group-summary-sticky' : ''}
|
||||
style={isSticky && stickyTop ? { top: stickyTop } : {}}
|
||||
>
|
||||
<div
|
||||
className="glass card group-summary-card"
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
padding: '16px 20px',
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="sticky-toggle-btn"
|
||||
onClick={() => setIsSticky(!isSticky)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: 4,
|
||||
opacity: 0.6,
|
||||
zIndex: 10,
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
{isSticky ? (
|
||||
<PinIcon width="14" height="14" />
|
||||
) : (
|
||||
<PinOffIcon width="14" height="14" />
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
ref={rowRef}
|
||||
className="row"
|
||||
style={{ alignItems: 'flex-end', justifyContent: 'space-between' }}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}
|
||||
>
|
||||
<div className="muted" style={{ fontSize: '12px' }}>
|
||||
{groupName}
|
||||
</div>
|
||||
<button
|
||||
className="fav-button"
|
||||
onClick={() => setIsMasked((value) => !value)}
|
||||
aria-label={isMasked ? '显示资产' : '隐藏资产'}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 2,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{isMasked ? (
|
||||
<EyeOffIcon width="16" height="16" />
|
||||
) : (
|
||||
<EyeIcon width="16" height="16" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
|
||||
{isMasked ? (
|
||||
<span
|
||||
style={{ fontSize: assetSize, position: 'relative', top: 4 }}
|
||||
>
|
||||
******
|
||||
</span>
|
||||
) : (
|
||||
<CountUp value={summary.totalAsset} style={{ fontSize: assetSize }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div
|
||||
className="muted"
|
||||
style={{ fontSize: '12px', marginBottom: 4 }}
|
||||
>
|
||||
当日收益
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
summary.hasAnyTodayData
|
||||
? summary.totalProfitToday > 0
|
||||
? 'up'
|
||||
: summary.totalProfitToday < 0
|
||||
? 'down'
|
||||
: ''
|
||||
: 'muted'
|
||||
}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
{isMasked ? (
|
||||
<span style={{ fontSize: metricSize }}>******</span>
|
||||
) : summary.hasAnyTodayData ? (
|
||||
<>
|
||||
<span style={{ marginRight: 1 }}>
|
||||
{summary.totalProfitToday > 0
|
||||
? '+'
|
||||
: summary.totalProfitToday < 0
|
||||
? '-'
|
||||
: ''}
|
||||
</span>
|
||||
<CountUp
|
||||
value={Math.abs(summary.totalProfitToday)}
|
||||
style={{ fontSize: metricSize }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: metricSize }}>--</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div
|
||||
className="muted"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
marginBottom: 4,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
持有收益{showPercent ? '(%)' : ''}{' '}
|
||||
<SwitchIcon style={{ opacity: 0.4 }} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
summary.totalHoldingReturn > 0
|
||||
? 'up'
|
||||
: summary.totalHoldingReturn < 0
|
||||
? 'down'
|
||||
: ''
|
||||
}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setShowPercent(!showPercent)}
|
||||
title="点击切换金额/百分比"
|
||||
>
|
||||
{isMasked ? (
|
||||
<span style={{ fontSize: metricSize }}>******</span>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ marginRight: 1 }}>
|
||||
{summary.totalHoldingReturn > 0
|
||||
? '+'
|
||||
: summary.totalHoldingReturn < 0
|
||||
? '-'
|
||||
: ''}
|
||||
</span>
|
||||
{showPercent ? (
|
||||
<CountUp
|
||||
value={Math.abs(summary.returnRate)}
|
||||
suffix="%"
|
||||
style={{ fontSize: metricSize }}
|
||||
/>
|
||||
) : (
|
||||
<CountUp
|
||||
value={Math.abs(summary.totalHoldingReturn)}
|
||||
style={{ fontSize: metricSize }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,9 +24,17 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { throttle } from 'lodash';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/drawer';
|
||||
import FitText from './FitText';
|
||||
import FundCard from './FundCard';
|
||||
import MobileSettingModal from './MobileSettingModal';
|
||||
import { ExitIcon, SettingsIcon, StarIcon } from './Icons';
|
||||
import { CloseIcon, ExitIcon, SettingsIcon, StarIcon } from './Icons';
|
||||
|
||||
const MOBILE_NON_FROZEN_COLUMN_IDS = [
|
||||
'yesterdayChangePercent',
|
||||
@@ -95,6 +103,7 @@ function SortableRow({ row, children, isTableDragging, disabled }) {
|
||||
* @param {boolean} [props.refreshing] - 是否刷新中
|
||||
* @param {string} [props.sortBy] - 排序方式,'default' 时长按行触发拖拽排序
|
||||
* @param {(oldIndex: number, newIndex: number) => void} [props.onReorder] - 拖拽排序回调
|
||||
* @param {(row: any) => Object} [props.getFundCardProps] - 给定行返回 FundCard 的 props;传入后点击基金名称将用底部弹框展示卡片视图
|
||||
*/
|
||||
export default function MobileFundTable({
|
||||
data = [],
|
||||
@@ -110,6 +119,9 @@ export default function MobileFundTable({
|
||||
onReorder,
|
||||
onCustomSettingsChange,
|
||||
stickyTop = 0,
|
||||
getFundCardProps,
|
||||
blockDrawerClose = false,
|
||||
closeDrawerRef,
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -119,11 +131,19 @@ export default function MobileFundTable({
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
const ignoreNextDrawerCloseRef = useRef(false);
|
||||
|
||||
const onToggleFavoriteRef = useRef(onToggleFavorite);
|
||||
const onRemoveFromGroupRef = useRef(onRemoveFromGroup);
|
||||
const onHoldingAmountClickRef = useRef(onHoldingAmountClick);
|
||||
|
||||
useEffect(() => {
|
||||
if (closeDrawerRef) {
|
||||
closeDrawerRef.current = () => setCardSheetRow(null);
|
||||
return () => { closeDrawerRef.current = null; };
|
||||
}
|
||||
}, [closeDrawerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
onToggleFavoriteRef.current = onToggleFavorite;
|
||||
onRemoveFromGroupRef.current = onRemoveFromGroup;
|
||||
@@ -277,6 +297,7 @@ export default function MobileFundTable({
|
||||
};
|
||||
|
||||
const [settingModalOpen, setSettingModalOpen] = useState(false);
|
||||
const [cardSheetRow, setCardSheetRow] = useState(null);
|
||||
const tableContainerRef = useRef(null);
|
||||
const portalHeaderRef = useRef(null);
|
||||
const [tableContainerWidth, setTableContainerWidth] = useState(0);
|
||||
@@ -420,8 +441,8 @@ export default function MobileFundTable({
|
||||
setMobileColumnVisibility((prev = {}) => ({ ...prev, [columnId]: visible }));
|
||||
};
|
||||
|
||||
// 移动端名称列:无拖拽把手,长按整行触发排序
|
||||
const MobileFundNameCell = ({ info, showFullFundName }) => {
|
||||
// 移动端名称列:无拖拽把手,长按整行触发排序;点击名称可打开底部卡片弹框(需传入 getFundCardProps)
|
||||
const MobileFundNameCell = ({ info, showFullFundName, onOpenCardSheet }) => {
|
||||
const original = info.row.original || {};
|
||||
const code = original.code;
|
||||
const isUpdated = original.isUpdated;
|
||||
@@ -461,7 +482,22 @@ export default function MobileFundTable({
|
||||
<div className="title-text">
|
||||
<span
|
||||
className={`name-text ${showFullFundName ? 'show-full' : ''}`}
|
||||
title={isUpdated ? '今日净值已更新' : ''}
|
||||
title={isUpdated ? '今日净值已更新' : onOpenCardSheet ? '点击查看卡片' : ''}
|
||||
role={onOpenCardSheet ? 'button' : undefined}
|
||||
tabIndex={onOpenCardSheet ? 0 : undefined}
|
||||
style={onOpenCardSheet ? { cursor: 'pointer' } : undefined}
|
||||
onClick={(e) => {
|
||||
if (onOpenCardSheet) {
|
||||
e.stopPropagation?.();
|
||||
onOpenCardSheet(original);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (onOpenCardSheet && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onOpenCardSheet(original);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{info.getValue() ?? '—'}
|
||||
</span>
|
||||
@@ -547,7 +583,13 @@ export default function MobileFundTable({
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
cell: (info) => <MobileFundNameCell info={info} showFullFundName={showFullFundName} />,
|
||||
cell: (info) => (
|
||||
<MobileFundNameCell
|
||||
info={info}
|
||||
showFullFundName={showFullFundName}
|
||||
onOpenCardSheet={getFundCardProps ? (row) => setCardSheetRow(row) : undefined}
|
||||
/>
|
||||
),
|
||||
meta: { align: 'left', cellClassName: 'name-cell', width: columnWidthMap.fundName },
|
||||
},
|
||||
{
|
||||
@@ -703,7 +745,7 @@ export default function MobileFundTable({
|
||||
meta: { align: 'right', cellClassName: 'holding-cell', width: columnWidthMap.holdingProfit },
|
||||
},
|
||||
],
|
||||
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName]
|
||||
[currentTab, favorites, refreshing, columnWidthMap, showFullFundName, getFundCardProps]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -953,6 +995,55 @@ export default function MobileFundTable({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Drawer
|
||||
open={!!(cardSheetRow && getFundCardProps)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
if (ignoreNextDrawerCloseRef.current) {
|
||||
ignoreNextDrawerCloseRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!blockDrawerClose) setCardSheetRow(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DrawerContent
|
||||
className="h-[77vh] max-h-[88vh] mt-0 flex flex-col"
|
||||
onPointerDownOutside={(e) => {
|
||||
if (blockDrawerClose) return;
|
||||
if (e?.target?.closest?.('[data-slot="dialog-content"], [role="dialog"]')) {
|
||||
ignoreNextDrawerCloseRef.current = true;
|
||||
return;
|
||||
}
|
||||
setCardSheetRow(null);
|
||||
}}
|
||||
>
|
||||
<DrawerHeader className="flex-shrink-0 flex flex-row items-center justify-between gap-2 space-y-0 px-5 pb-4 pt-2 text-left">
|
||||
<DrawerTitle className="text-base font-semibold text-[var(--text)]">
|
||||
基金详情
|
||||
</DrawerTitle>
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button rounded-lg"
|
||||
aria-label="关闭"
|
||||
style={{ padding: 4, borderColor: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="24" height="24" />
|
||||
</button>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-y-auto px-5 pb-8 pt-0"
|
||||
style={{ paddingBottom: 'calc(24px + env(safe-area-inset-bottom, 0px))' }}
|
||||
>
|
||||
{cardSheetRow && getFundCardProps ? (
|
||||
<FundCard {...getFundCardProps(cardSheetRow)} />
|
||||
) : null}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
{!onlyShowHeader && showPortalHeader && ReactDOM.createPortal(renderContent(true), document.body)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnimatePresence, motion, Reorder } from 'framer-motion';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AnimatePresence, Reorder } from 'framer-motion';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerClose,
|
||||
} from '@/components/ui/drawer';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import { CloseIcon, DragIcon, ResetIcon, SettingsIcon } from './Icons';
|
||||
|
||||
/**
|
||||
* 移动端表格个性化设置弹框(底部抽屉)
|
||||
* 移动端表格个性化设置弹框(底部抽屉,基于 Drawer 组件)
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.open - 是否打开
|
||||
* @param {() => void} props.onClose - 关闭回调
|
||||
@@ -38,61 +45,41 @@ export default function MobileSettingModal({
|
||||
if (!open) setResetConfirmOpen(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleReorder = (newItems) => {
|
||||
const newOrder = newItems.map((item) => item.id);
|
||||
onColumnReorder?.(newOrder);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="mobile-setting-overlay"
|
||||
className="mobile-setting-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="个性化设置"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
style={{ zIndex: 10001 }}
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onClose();
|
||||
}}
|
||||
direction="bottom"
|
||||
>
|
||||
<motion.div
|
||||
className="mobile-setting-drawer glass"
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<DrawerContent
|
||||
className="glass"
|
||||
defaultHeight="77vh"
|
||||
minHeight="40vh"
|
||||
maxHeight="90vh"
|
||||
>
|
||||
<div className="mobile-setting-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<DrawerHeader className="mobile-setting-header flex-row items-center justify-between gap-2 py-5 pt-5 text-base font-semibold">
|
||||
<DrawerTitle className="flex items-center gap-2.5 text-left">
|
||||
<SettingsIcon width="20" height="20" />
|
||||
<span>个性化设置</span>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={onClose}
|
||||
</DrawerTitle>
|
||||
<DrawerClose
|
||||
className="icon-button border-none bg-transparent p-1"
|
||||
title="关闭"
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
style={{ borderColor: 'transparent', backgroundColor: 'transparent' }}
|
||||
>
|
||||
<CloseIcon width="20" height="20" />
|
||||
</button>
|
||||
</div>
|
||||
</DrawerClose>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="mobile-setting-body">
|
||||
<div className="mobile-setting-body flex flex-1 flex-col overflow-y-auto">
|
||||
{onToggleShowFullFundName && (
|
||||
<div
|
||||
style={{
|
||||
@@ -105,31 +92,13 @@ export default function MobileSettingModal({
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>展示完整基金名称</span>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button pc-table-column-switch"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleShowFullFundName(!showFullFundName);
|
||||
<Switch
|
||||
checked={!!showFullFundName}
|
||||
onCheckedChange={(checked) => {
|
||||
onToggleShowFullFundName?.(!!checked);
|
||||
}}
|
||||
title={showFullFundName ? '关闭' : '开启'}
|
||||
style={{
|
||||
border: 'none',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span className={`dca-toggle-track ${showFullFundName ? 'enabled' : ''}`}>
|
||||
<span
|
||||
className="dca-toggle-thumb"
|
||||
style={{ left: showFullFundName ? 16 : 2 }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="mobile-setting-subtitle">表头设置</h3>
|
||||
@@ -206,31 +175,13 @@ export default function MobileSettingModal({
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: '14px' }}>{item.header}</span>
|
||||
{onToggleColumnVisibility && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button pc-table-column-switch"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleColumnVisibility(item.id, columnVisibility?.[item.id] === false);
|
||||
<Switch
|
||||
checked={columnVisibility?.[item.id] !== false}
|
||||
onCheckedChange={(checked) => {
|
||||
onToggleColumnVisibility(item.id, !!checked);
|
||||
}}
|
||||
title={columnVisibility?.[item.id] === false ? '显示' : '隐藏'}
|
||||
style={{
|
||||
border: 'none',
|
||||
padding: '0 4px',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span className={`dca-toggle-track ${columnVisibility?.[item.id] !== false ? 'enabled' : ''}`}>
|
||||
<span
|
||||
className="dca-toggle-thumb"
|
||||
style={{ left: columnVisibility?.[item.id] !== false ? 16 : 2 }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</Reorder.Item>
|
||||
))}
|
||||
@@ -238,9 +189,10 @@ export default function MobileSettingModal({
|
||||
</Reorder.Group>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<AnimatePresence>
|
||||
{resetConfirmOpen && (
|
||||
<ConfirmModal
|
||||
key="mobile-reset-confirm"
|
||||
@@ -256,8 +208,6 @@ export default function MobileSettingModal({
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@
|
||||
--sidebar-accent-foreground: #e5e7eb;
|
||||
--sidebar-border: #1f2937;
|
||||
--sidebar-ring: #22d3ee;
|
||||
--drawer-overlay: rgba(2, 6, 23, 0.5);
|
||||
--dialog-overlay: rgba(2, 6, 23, 0.6);
|
||||
--tabs-list-bg: rgba(255, 255, 255, 0.04);
|
||||
--tabs-list-border: transparent;
|
||||
--tabs-trigger-active-bg: rgba(34, 211, 238, 0.12);
|
||||
--tabs-trigger-active-text: var(--primary);
|
||||
--switch-thumb: var(--foreground);
|
||||
}
|
||||
|
||||
/* 亮色主题:ui-ux-pro-max 规范 - 正文 #0F172A、弱化 #475569、玻璃 bg-white/80+、边框可见 */
|
||||
@@ -84,6 +91,13 @@
|
||||
--sidebar-accent-foreground: #0f172a;
|
||||
--sidebar-border: #e2e8f0;
|
||||
--sidebar-ring: #0891b2;
|
||||
--drawer-overlay: rgba(15, 23, 42, 0.25);
|
||||
--dialog-overlay: rgba(15, 23, 42, 0.35);
|
||||
--tabs-list-bg: rgba(0, 0, 0, 0.04);
|
||||
--tabs-list-border: var(--border);
|
||||
--tabs-trigger-active-bg: rgba(8, 145, 178, 0.15);
|
||||
--tabs-trigger-active-text: var(--primary);
|
||||
--switch-thumb: var(--background);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -2033,6 +2047,65 @@ input[type="number"] {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* shadcn Drawer:符合项目规范,适配亮/暗主题 */
|
||||
.drawer-shadow-bottom {
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.drawer-shadow-top {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
[data-theme="light"] .drawer-shadow-bottom {
|
||||
box-shadow: 0 -4px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
[data-theme="light"] .drawer-shadow-top {
|
||||
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
/* shadcn Dialog:符合项目规范(ui-ux-pro-max),适配亮/暗主题,略微玻璃拟态 */
|
||||
[data-slot="dialog-content"] {
|
||||
background: rgba(17, 24, 39, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
[data-theme="light"] [data-slot="dialog-content"] {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.dialog-content-shadow {
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="light"] .dialog-content-shadow {
|
||||
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
[data-slot="dialog-content"] [data-slot="dialog-close"] {
|
||||
cursor: pointer;
|
||||
color: var(--muted-foreground);
|
||||
transition: color 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
[data-slot="dialog-content"] [data-slot="dialog-close"]:hover {
|
||||
color: var(--foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot="dialog-content"] [data-slot="dialog-close"]:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
[data-slot="dialog-title"] {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
[data-slot="dialog-description"] {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.mobile-setting-drawer {
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
@@ -3192,6 +3265,26 @@ input[type="number"] {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: linear-gradient(180deg, #ef4444, #f87171);
|
||||
color: #2b0b0b;
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
box-shadow: 0 10px 20px rgba(248, 113, 113, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="light"] .button.danger {
|
||||
background: linear-gradient(180deg, #ef4444, #dc2626);
|
||||
color: #fff;
|
||||
border-color: rgba(220, 38, 38, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="light"] .button.danger:hover {
|
||||
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.25);
|
||||
}
|
||||
|
||||
/* ========== 移动端响应式 ========== */
|
||||
@media (max-width: 640px) {
|
||||
|
||||
|
||||
559
app/page.jsx
559
app/page.jsx
@@ -13,13 +13,11 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
import { isNumber, isString, isPlainObject } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import Announcement from "./components/Announcement";
|
||||
import { Stat } from "./components/Common";
|
||||
import FundTrendChart from "./components/FundTrendChart";
|
||||
import FundIntradayChart from "./components/FundIntradayChart";
|
||||
import EmptyStateCard from "./components/EmptyStateCard";
|
||||
import FundCard from "./components/FundCard";
|
||||
import GroupSummary from "./components/GroupSummary";
|
||||
import {
|
||||
ChevronIcon,
|
||||
CloseIcon,
|
||||
ExitIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
GridIcon,
|
||||
@@ -32,13 +30,10 @@ import {
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
SortIcon,
|
||||
StarIcon,
|
||||
SunIcon,
|
||||
TrashIcon,
|
||||
UpdateIcon,
|
||||
UserIcon,
|
||||
CameraIcon,
|
||||
SwitchIcon
|
||||
} from "./components/Icons";
|
||||
import AddFundToGroupModal from "./components/AddFundToGroupModal";
|
||||
import AddResultModal from "./components/AddResultModal";
|
||||
@@ -116,214 +111,6 @@ function ScanButton({ onClick, disabled }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 数字滚动组件(初始化时无动画,后续变更再动画)
|
||||
function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = '', style = {} }) {
|
||||
const [displayValue, setDisplayValue] = useState(value);
|
||||
const previousValue = useRef(value);
|
||||
const isFirstChange = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousValue.current === value) return;
|
||||
|
||||
// 首次数值变化(包括从 0/默认值变为实际数据)不做动画,直接跳到目标值
|
||||
if (isFirstChange.current) {
|
||||
isFirstChange.current = false;
|
||||
previousValue.current = value;
|
||||
setDisplayValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = previousValue.current;
|
||||
const end = value;
|
||||
const duration = 400; // 0.4秒动画
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// easeOutQuart
|
||||
const ease = 1 - Math.pow(1 - progress, 4);
|
||||
|
||||
const current = start + (end - start) * ease;
|
||||
setDisplayValue(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
previousValue.current = value;
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<span className={className} style={style}>
|
||||
{prefix}{Math.abs(displayValue).toFixed(decimals)}{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupSummary({ funds, holdings, groupName, getProfit, stickyTop }) {
|
||||
const [showPercent, setShowPercent] = useState(true);
|
||||
const [isMasked, setIsMasked] = useState(false);
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const rowRef = useRef(null);
|
||||
const [assetSize, setAssetSize] = useState(24);
|
||||
const [metricSize, setMetricSize] = useState(18);
|
||||
const [winW, setWinW] = useState(0);
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setWinW(window.innerWidth);
|
||||
const onR = () => setWinW(window.innerWidth);
|
||||
window.addEventListener('resize', onR);
|
||||
return () => window.removeEventListener('resize', onR);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let totalAsset = 0;
|
||||
let totalProfitToday = 0;
|
||||
let totalHoldingReturn = 0;
|
||||
let totalCost = 0;
|
||||
let hasHolding = false;
|
||||
let hasAnyTodayData = false;
|
||||
|
||||
funds.forEach(fund => {
|
||||
const holding = holdings[fund.code];
|
||||
const profit = getProfit(fund, holding);
|
||||
|
||||
if (profit) {
|
||||
hasHolding = true;
|
||||
totalAsset += profit.amount;
|
||||
if (profit.profitToday != null) {
|
||||
// 与卡片展示口径一致:先按“分”四舍五入再汇总,避免浮点误差/逐项舍入差导致不一致
|
||||
totalProfitToday += Math.round(profit.profitToday * 100) / 100;
|
||||
hasAnyTodayData = true;
|
||||
}
|
||||
if (profit.profitTotal !== null) {
|
||||
totalHoldingReturn += profit.profitTotal;
|
||||
if (holding && isNumber(holding.cost) && isNumber(holding.share)) {
|
||||
totalCost += holding.cost * holding.share;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const returnRate = totalCost > 0 ? (totalHoldingReturn / totalCost) * 100 : 0;
|
||||
|
||||
return { totalAsset, totalProfitToday, totalHoldingReturn, hasHolding, returnRate, hasAnyTodayData };
|
||||
}, [funds, holdings, getProfit]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = rowRef.current;
|
||||
if (!el) return;
|
||||
const height = el.clientHeight;
|
||||
// 使用 80px 作为更严格的阈值,因为 margin/padding 可能导致实际占用更高
|
||||
const tooTall = height > 80;
|
||||
if (tooTall) {
|
||||
setAssetSize(s => Math.max(16, s - 1));
|
||||
setMetricSize(s => Math.max(12, s - 1));
|
||||
} else {
|
||||
// 如果高度正常,尝试适当恢复字体大小,但不要超过初始值
|
||||
// 这里的逻辑可以优化:如果当前远小于阈值,可以尝试增大,但为了稳定性,主要处理缩小的场景
|
||||
// 或者:如果高度非常小(例如远小于80),可以尝试+1,但要小心死循环
|
||||
}
|
||||
}, [winW, summary.totalAsset, summary.totalProfitToday, summary.totalHoldingReturn, summary.returnRate, showPercent, assetSize, metricSize]); // 添加 assetSize, metricSize 到依赖,确保逐步缩小生效
|
||||
|
||||
if (!summary.hasHolding) return null;
|
||||
|
||||
return (
|
||||
<div className={isSticky ? "group-summary-sticky" : ""} style={isSticky && stickyTop ? { top: stickyTop } : {}}>
|
||||
<div className="glass card group-summary-card" style={{ marginBottom: 8, padding: '16px 20px', background: 'rgba(255, 255, 255, 0.03)', position: 'relative' }}>
|
||||
<span
|
||||
className="sticky-toggle-btn"
|
||||
onClick={() => setIsSticky(!isSticky)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: 4,
|
||||
opacity: 0.6,
|
||||
zIndex: 10,
|
||||
color: 'var(--muted)'
|
||||
}}
|
||||
>
|
||||
{isSticky ? <PinIcon width="14" height="14" /> : <PinOffIcon width="14" height="14" />}
|
||||
</span>
|
||||
<div ref={rowRef} className="row" style={{ alignItems: 'flex-end', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<div className="muted" style={{ fontSize: '12px' }}>{groupName}</div>
|
||||
<button
|
||||
className="fav-button"
|
||||
onClick={() => setIsMasked(value => !value)}
|
||||
aria-label={isMasked ? '显示资产' : '隐藏资产'}
|
||||
style={{ margin: 0, padding: 2, display: 'inline-flex', alignItems: 'center' }}
|
||||
>
|
||||
{isMasked ? <EyeOffIcon width="16" height="16" /> : <EyeIcon width="16" height="16" />}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 700, fontFamily: 'var(--font-mono)' }}>
|
||||
<span style={{ fontSize: '16px', marginRight: 2 }}>¥</span>
|
||||
{isMasked ? (
|
||||
<span style={{ fontSize: assetSize, position: 'relative', top: 4 }}>******</span>
|
||||
) : (
|
||||
<CountUp value={summary.totalAsset} style={{ fontSize: assetSize }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4 }}>当日收益</div>
|
||||
<div
|
||||
className={summary.hasAnyTodayData ? (summary.totalProfitToday > 0 ? 'up' : summary.totalProfitToday < 0 ? 'down' : '') : 'muted'}
|
||||
style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--font-mono)' }}
|
||||
>
|
||||
{isMasked ? (
|
||||
<span style={{ fontSize: metricSize }}>******</span>
|
||||
) : summary.hasAnyTodayData ? (
|
||||
<>
|
||||
<span style={{ marginRight: 1 }}>{summary.totalProfitToday > 0 ? '+' : summary.totalProfitToday < 0 ? '-' : ''}</span>
|
||||
<CountUp value={Math.abs(summary.totalProfitToday)} style={{ fontSize: metricSize }} />
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: metricSize }}>--</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="muted" style={{ fontSize: '12px', marginBottom: 4, display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 2 }}>持有收益{showPercent ? '(%)' : ''} <SwitchIcon style={{ opacity: 0.4 }} /></div>
|
||||
<div
|
||||
className={summary.totalHoldingReturn > 0 ? 'up' : summary.totalHoldingReturn < 0 ? 'down' : ''}
|
||||
style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--font-mono)', cursor: 'pointer' }}
|
||||
onClick={() => setShowPercent(!showPercent)}
|
||||
title="点击切换金额/百分比"
|
||||
>
|
||||
{isMasked ? (
|
||||
<span style={{ fontSize: metricSize }}>******</span>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ marginRight: 1 }}>{summary.totalHoldingReturn > 0 ? '+' : summary.totalHoldingReturn < 0 ? '-' : ''}</span>
|
||||
{showPercent ? (
|
||||
<CountUp value={Math.abs(summary.returnRate)} suffix="%" style={{ fontSize: metricSize }} />
|
||||
) : (
|
||||
<CountUp value={Math.abs(summary.totalHoldingReturn)} style={{ fontSize: metricSize }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [funds, setFunds] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -531,6 +318,7 @@ export default function HomePage() {
|
||||
const [isTradingDay, setIsTradingDay] = useState(true); // 默认为交易日,通过接口校正
|
||||
const tabsRef = useRef(null);
|
||||
const [fundDeleteConfirm, setFundDeleteConfirm] = useState(null); // { code, name }
|
||||
const fundDetailDrawerCloseRef = useRef(null); // 由 MobileFundTable 注入,用于确认删除时关闭基金详情 Drawer
|
||||
|
||||
const todayStr = formatDate();
|
||||
|
||||
@@ -4129,15 +3917,11 @@ export default function HomePage() {
|
||||
</div>
|
||||
|
||||
{displayFunds.length === 0 ? (
|
||||
<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>
|
||||
<EmptyStateCard
|
||||
fundsLength={funds.length}
|
||||
currentTab={currentTab}
|
||||
onAddToGroup={() => setAddFundToGroupOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<GroupSummary
|
||||
@@ -4250,6 +4034,8 @@ export default function HomePage() {
|
||||
favorites={favorites}
|
||||
sortBy={sortBy}
|
||||
stickyTop={navbarHeight + filterBarHeight - 14}
|
||||
blockDrawerClose={!!fundDeleteConfirm}
|
||||
closeDrawerRef={fundDetailDrawerCloseRef}
|
||||
onReorder={handleReorder}
|
||||
onRemoveFund={(row) => {
|
||||
if (refreshing) return;
|
||||
@@ -4279,6 +4065,37 @@ export default function HomePage() {
|
||||
setPercentModes((prev) => ({ ...prev, [row.code]: !prev[row.code] }));
|
||||
}}
|
||||
onCustomSettingsChange={triggerCustomSettingsSync}
|
||||
getFundCardProps={(row) => {
|
||||
const fund = row?.rawFund || (row ? { code: row.code, name: row.fundName } : null);
|
||||
if (!fund) return {};
|
||||
return {
|
||||
fund,
|
||||
todayStr,
|
||||
currentTab,
|
||||
favorites,
|
||||
dcaPlans,
|
||||
holdings,
|
||||
percentModes,
|
||||
valuationSeries,
|
||||
collapsedCodes,
|
||||
collapsedTrends,
|
||||
transactions,
|
||||
theme,
|
||||
isTradingDay,
|
||||
refreshing,
|
||||
getHoldingProfit,
|
||||
onRemoveFromGroup: removeFundFromCurrentGroup,
|
||||
onToggleFavorite: toggleFavorite,
|
||||
onRemoveFund: requestRemoveFund,
|
||||
onHoldingClick: (f) => setHoldingModal({ open: true, fund: f }),
|
||||
onActionClick: (f) => setActionModal({ open: true, fund: f }),
|
||||
onPercentModeToggle: (code) =>
|
||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })),
|
||||
onToggleCollapse: toggleCollapse,
|
||||
onToggleTrendCollapse: toggleTrendCollapse,
|
||||
layoutMode: 'drawer',
|
||||
};
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout">
|
||||
@@ -4293,274 +4110,33 @@ export default function HomePage() {
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ position: 'relative', overflow: 'hidden' }}
|
||||
>
|
||||
<motion.div
|
||||
className="glass card"
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
>
|
||||
<>
|
||||
<div className="row" style={{ marginBottom: 10 }}>
|
||||
<div className="title">
|
||||
{currentTab !== 'all' && currentTab !== 'fav' ? (
|
||||
<button
|
||||
className="icon-button fav-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFundFromCurrentGroup(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();
|
||||
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`}
|
||||
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 && requestRemoveFund(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 }}>
|
||||
{(() => {
|
||||
const holding = holdings[f.code];
|
||||
const profit = getHoldingProfit(f, holding);
|
||||
|
||||
if (!profit) {
|
||||
return (
|
||||
<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={() => setHoldingModal({ open: true, fund: f })}
|
||||
>
|
||||
未设置 <SettingsIcon width="12" height="12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="stat"
|
||||
style={{ cursor: 'pointer', flexDirection: 'column', gap: 4 }}
|
||||
onClick={() => setActionModal({ open: true, fund: 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();
|
||||
setPercentModes(prev => ({ ...prev, [f.code]: !prev[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;
|
||||
}
|
||||
|
||||
// 如果 jzrq 等于估值日期或在此之前(意味着净值已经更新到了估值日期,或者是历史数据),则隐藏实时估值分时
|
||||
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}
|
||||
<FundCard
|
||||
fund={f}
|
||||
todayStr={todayStr}
|
||||
currentTab={currentTab}
|
||||
favorites={favorites}
|
||||
dcaPlans={dcaPlans}
|
||||
holdings={holdings}
|
||||
percentModes={percentModes}
|
||||
valuationSeries={valuationSeries}
|
||||
collapsedCodes={collapsedCodes}
|
||||
collapsedTrends={collapsedTrends}
|
||||
transactions={transactions}
|
||||
theme={theme}
|
||||
isTradingDay={isTradingDay}
|
||||
refreshing={refreshing}
|
||||
getHoldingProfit={getHoldingProfit}
|
||||
onRemoveFromGroup={removeFundFromCurrentGroup}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onRemoveFund={requestRemoveFund}
|
||||
onHoldingClick={(fund) => setHoldingModal({ open: true, fund })}
|
||||
onActionClick={(fund) => setActionModal({ open: true, fund })}
|
||||
onPercentModeToggle={(code) =>
|
||||
setPercentModes((prev) => ({ ...prev, [code]: !prev[code] }))
|
||||
}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
onToggleTrendCollapse={toggleTrendCollapse}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{f.holdingsIsLastQuarter && Array.isArray(f.holdings) && f.holdings.length > 0 && (
|
||||
<>
|
||||
<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' }}
|
||||
>
|
||||
<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={() => toggleTrendCollapse(f.code)}
|
||||
transactions={transactions[f.code] || []}
|
||||
theme={theme}
|
||||
/>
|
||||
</>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@@ -4579,6 +4155,7 @@ export default function HomePage() {
|
||||
message={`基金 "${fundDeleteConfirm.name}" 存在持仓记录。删除后将移除该基金及其持仓数据,是否继续?`}
|
||||
confirmText="确定删除"
|
||||
onConfirm={() => {
|
||||
fundDetailDrawerCloseRef.current?.();
|
||||
removeFund(fundDeleteConfirm.code);
|
||||
setFundDeleteConfirm(null);
|
||||
}}
|
||||
|
||||
148
components/ui/dialog.jsx
Normal file
148
components/ui/dialog.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-[var(--dialog-overlay)] backdrop-blur-[4px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[16px] border border-[var(--border)] text-[var(--foreground)] p-6 dialog-content-shadow outline-none duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-md p-1.5 text-[var(--muted-foreground)] opacity-70 transition-colors duration-200 hover:opacity-100 hover:text-[var(--foreground)] hover:bg-[var(--secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--card)] disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
{...props}>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<button type="button" className="button secondary px-4 h-11 rounded-xl cursor-pointer">
|
||||
Close
|
||||
</button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold text-[var(--foreground)]", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-[var(--muted-foreground)]", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
222
components/ui/drawer.jsx
Normal file
222
components/ui/drawer.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function parseVhToPx(vhStr) {
|
||||
if (typeof vhStr === "number") return vhStr
|
||||
const match = String(vhStr).match(/^([\d.]+)\s*vh$/)
|
||||
if (!match) return null
|
||||
return (window.innerHeight * Number(match[1])) / 100
|
||||
}
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-[var(--drawer-overlay)] backdrop-blur-[4px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
defaultHeight = "77vh",
|
||||
minHeight = "20vh",
|
||||
maxHeight = "90vh",
|
||||
...props
|
||||
}) {
|
||||
const [heightPx, setHeightPx] = React.useState(() =>
|
||||
typeof window !== "undefined" ? parseVhToPx(defaultHeight) : null
|
||||
);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const dragRef = React.useRef({ startY: 0, startHeight: 0 });
|
||||
|
||||
const minPx = React.useMemo(() => parseVhToPx(minHeight), [minHeight]);
|
||||
const maxPx = React.useMemo(() => parseVhToPx(maxHeight), [maxHeight]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const px = parseVhToPx(defaultHeight);
|
||||
if (px != null) setHeightPx(px);
|
||||
}, [defaultHeight]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const sync = () => {
|
||||
const max = parseVhToPx(maxHeight);
|
||||
const min = parseVhToPx(minHeight);
|
||||
setHeightPx((prev) => {
|
||||
if (prev == null) return parseVhToPx(defaultHeight);
|
||||
const clamped = Math.min(prev, max ?? prev);
|
||||
return Math.max(clamped, min ?? clamped);
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", sync);
|
||||
return () => window.removeEventListener("resize", sync);
|
||||
}, [defaultHeight, minHeight, maxHeight]);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragRef.current = { startY: e.clientY ?? e.touches?.[0]?.clientY, startHeight: heightPx ?? parseVhToPx(defaultHeight) ?? 0 };
|
||||
},
|
||||
[heightPx, defaultHeight]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const move = (e) => {
|
||||
const clientY = e.clientY ?? e.touches?.[0]?.clientY;
|
||||
const { startY, startHeight } = dragRef.current;
|
||||
const delta = startY - clientY;
|
||||
const next = Math.min(maxPx ?? Infinity, Math.max(minPx ?? 0, startHeight + delta));
|
||||
setHeightPx(next);
|
||||
};
|
||||
const up = () => setIsDragging(false);
|
||||
document.addEventListener("mousemove", move, { passive: true });
|
||||
document.addEventListener("mouseup", up);
|
||||
document.addEventListener("touchmove", move, { passive: true });
|
||||
document.addEventListener("touchend", up);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", move);
|
||||
document.removeEventListener("mouseup", up);
|
||||
document.removeEventListener("touchmove", move);
|
||||
document.removeEventListener("touchend", up);
|
||||
};
|
||||
}, [isDragging, minPx, maxPx]);
|
||||
|
||||
const contentStyle = React.useMemo(() => {
|
||||
if (heightPx == null) return undefined;
|
||||
return { height: `${heightPx}px`, maxHeight: maxPx != null ? `${maxPx}px` : undefined };
|
||||
}, [heightPx, maxPx]);
|
||||
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
style={contentStyle}
|
||||
className={cn(
|
||||
"group/drawer-content fixed z-50 flex h-auto flex-col bg-[var(--card)] text-[var(--text)] border-[var(--border)]",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-[var(--radius)] data-[vaul-drawer-direction=top]:border-b drawer-shadow-top",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[88vh] data-[vaul-drawer-direction=bottom]:rounded-t-[20px] data-[vaul-drawer-direction=bottom]:border-t drawer-shadow-bottom",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
"drawer-content-theme",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<div
|
||||
role="separator"
|
||||
aria-label="拖动调整高度"
|
||||
onMouseDown={handlePointerDown}
|
||||
onTouchStart={handlePointerDown}
|
||||
className={cn(
|
||||
"mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-[var(--muted)] cursor-n-resize touch-none select-none",
|
||||
"group-data-[vaul-drawer-direction=bottom]/drawer-content:block",
|
||||
"hover:bg-[var(--muted-foreground)/0.4] active:bg-[var(--muted-foreground)/0.6]"
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 border-b border-[var(--border)] group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
"drawer-header-theme",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("font-semibold text-[var(--text)]", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-sm text-[var(--muted)]", className)}
|
||||
{...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
42
components/ui/switch.jsx
Normal file
42
components/ui/switch.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef(({ className, size = "default", ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 cursor-pointer items-center rounded-full border shadow-xs outline-none",
|
||||
"border-[var(--border)]",
|
||||
"transition-[color,box-shadow,border-color] duration-200 ease-out",
|
||||
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)] focus-visible:ring-opacity-50",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"hover:data-[state=unchecked]:bg-[var(--input)] hover:data-[state=unchecked]:border-[var(--muted)]",
|
||||
"data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||
"data-[state=checked]:border-transparent data-[state=checked]:bg-[var(--primary)]",
|
||||
"data-[state=unchecked]:bg-[var(--input)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full ring-0",
|
||||
"bg-[var(--background)]",
|
||||
"transition-transform duration-200 ease-out",
|
||||
"group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3",
|
||||
"data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=checked]:bg-[var(--primary-foreground)]",
|
||||
"data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-[var(--switch-thumb)]"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
89
components/ui/tabs.jsx
Normal file
89
components/ui/tabs.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn("group/tabs flex gap-2 data-[orientation=horizontal]:flex-col", className)}
|
||||
{...props} />
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-[var(--radius)] p-[3px] text-[var(--muted)] group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none border border-[var(--tabs-list-border)]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-[var(--tabs-list-bg)]",
|
||||
line: "gap-1 bg-transparent border-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all duration-200",
|
||||
"text-[var(--muted)] hover:text-[var(--text)] hover:bg-[var(--tabs-list-bg)]",
|
||||
"focus-visible:border-[var(--ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ring)]/50 focus-visible:outline-1 focus-visible:outline-[var(--ring)]",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start",
|
||||
"group-data-[variant=default]/tabs-list:data-[state=active]:bg-[var(--tabs-trigger-active-bg)] group-data-[variant=default]/tabs-list:data-[state=active]:text-[var(--tabs-trigger-active-text)] group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none",
|
||||
"group-data-[variant=line]/tabs-list:data-[state=active]:text-[var(--tabs-trigger-active-text)]",
|
||||
"after:absolute after:h-0.5 after:bg-[var(--tabs-trigger-active-text)] after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none text-[var(--text)]", className)}
|
||||
{...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -32,7 +32,8 @@
|
||||
"react-dom": "18.3.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
@@ -10719,7 +10720,7 @@
|
||||
},
|
||||
"node_modules/radix-ui": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.4.3.tgz",
|
||||
"integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12656,6 +12657,19 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/vaul/-/vaul-1.1.2.tgz",
|
||||
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/wasm-feature-detect": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"react-dom": "18.3.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tesseract.js": "^5.1.1",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
|
||||
Reference in New Issue
Block a user