feat: 新增持有天数

This commit is contained in:
hzm
2026-03-22 14:52:29 +08:00
parent 303071f639
commit 84a720164c
6 changed files with 181 additions and 12 deletions

View File

@@ -27,7 +27,7 @@ const nowInTz = () => dayjs().tz(TZ);
const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz()); const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz());
const formatDate = (input) => toTz(input).format('YYYY-MM-DD'); const formatDate = (input) => toTz(input).format('YYYY-MM-DD');
export function DatePicker({ value, onChange }) { export function DatePicker({ value, onChange, position = 'bottom' }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(() => value ? toTz(value) : nowInTz()); const [currentMonth, setCurrentMonth] = useState(() => value ? toTz(value) : nowInTz());
@@ -83,16 +83,15 @@ export function DatePicker({ value, onChange }) {
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
<motion.div <motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }} initial={{ opacity: 0, y: position === 'top' ? -10 : 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }} exit={{ opacity: 0, y: position === 'top' ? -10 : 10, scale: 0.95 }}
className="date-picker-dropdown glass card" className="date-picker-dropdown glass card"
style={{ style={{
position: 'absolute', position: 'absolute',
top: '100%', ...(position === 'top' ? { bottom: '100%', marginBottom: 8 } : { top: '100%', marginTop: 8 }),
left: 0, left: 0,
width: '100%', width: '100%',
marginTop: 8,
padding: 12, padding: 12,
zIndex: 10 zIndex: 10
}} }}

View File

@@ -267,6 +267,20 @@ export default function FundCard({
{masked ? '******' : `¥${profit.amount.toFixed(2)}`} {masked ? '******' : `¥${profit.amount.toFixed(2)}`}
</span> </span>
</div> </div>
{holding?.firstPurchaseDate && !masked && (() => {
const today = dayjs.tz(todayStr, TZ);
const purchaseDate = dayjs.tz(holding.firstPurchaseDate, TZ);
if (!purchaseDate.isValid()) return null;
const days = today.diff(purchaseDate, 'day');
return (
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
<span className="label">持有天数</span>
<span className="value">
{days}
</span>
</div>
);
})()}
<div className="stat" style={{ flexDirection: 'column', gap: 4 }}> <div className="stat" style={{ flexDirection: 'column', gap: 4 }}>
<span className="label">当日收益</span> <span className="label">当日收益</span>
<span <span

View File

@@ -1,15 +1,27 @@
'use client'; 'use client';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { CloseIcon, SettingsIcon } from './Icons'; import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { CloseIcon, SettingsIcon, SwitchIcon } from './Icons';
import { DatePicker } from './Common';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
dayjs.extend(utc);
dayjs.extend(timezone);
const TZ = typeof Intl !== 'undefined' && Intl.DateTimeFormat
? (Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai')
: 'Asia/Shanghai';
export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpenTrade }) { export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpenTrade }) {
const [mode, setMode] = useState('amount'); // 'amount' | 'share' const [mode, setMode] = useState('amount'); // 'amount' | 'share'
const [dateMode, setDateMode] = useState('date'); // 'date' | 'days'
const dwjz = fund?.dwjz || fund?.gsz || 0; const dwjz = fund?.dwjz || fund?.gsz || 0;
const dwjzRef = useRef(dwjz); const dwjzRef = useRef(dwjz);
@@ -21,10 +33,12 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
const [cost, setCost] = useState(''); const [cost, setCost] = useState('');
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [profit, setProfit] = useState(''); const [profit, setProfit] = useState('');
const [firstPurchaseDate, setFirstPurchaseDate] = useState('');
const [holdingDaysInput, setHoldingDaysInput] = useState('');
const holdingSig = useMemo(() => { const holdingSig = useMemo(() => {
if (!holding) return ''; if (!holding) return '';
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}`; return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}|${holding.firstPurchaseDate ?? ''}`;
}, [holding]); }, [holding]);
useEffect(() => { useEffect(() => {
@@ -33,6 +47,14 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
const c = holding.cost || 0; const c = holding.cost || 0;
setShare(String(s)); setShare(String(s));
setCost(String(c)); setCost(String(c));
setFirstPurchaseDate(holding.firstPurchaseDate || '');
if (holding.firstPurchaseDate) {
const days = dayjs.tz(undefined, TZ).diff(dayjs.tz(holding.firstPurchaseDate, TZ), 'day');
setHoldingDaysInput(days > 0 ? String(days) : '');
} else {
setHoldingDaysInput('');
}
const price = dwjzRef.current; const price = dwjzRef.current;
if (price > 0) { if (price > 0) {
@@ -42,7 +64,6 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
setProfit(p.toFixed(2)); setProfit(p.toFixed(2));
} }
} }
// 只在“切换持仓/初次打开”时初始化,避免净值刷新覆盖用户输入
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [holdingSig]); }, [holdingSig]);
@@ -74,6 +95,41 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
} }
}; };
const handleDateModeToggle = () => {
const newMode = dateMode === 'date' ? 'days' : 'date';
setDateMode(newMode);
if (newMode === 'days' && firstPurchaseDate) {
const days = dayjs.tz(undefined, TZ).diff(dayjs.tz(firstPurchaseDate, TZ), 'day');
setHoldingDaysInput(days > 0 ? String(days) : '');
} else if (newMode === 'date' && holdingDaysInput) {
const days = parseInt(holdingDaysInput, 10);
if (Number.isFinite(days) && days >= 0) {
const date = dayjs.tz(undefined, TZ).subtract(days, 'day').format('YYYY-MM-DD');
setFirstPurchaseDate(date);
}
}
};
const handleHoldingDaysChange = (value) => {
setHoldingDaysInput(value);
const days = parseInt(value, 10);
if (Number.isFinite(days) && days >= 0) {
const date = dayjs.tz(undefined, TZ).subtract(days, 'day').format('YYYY-MM-DD');
setFirstPurchaseDate(date);
}
};
const handleFirstPurchaseDateChange = (value) => {
setFirstPurchaseDate(value);
if (value) {
const days = dayjs.tz(undefined, TZ).diff(dayjs.tz(value, TZ), 'day');
setHoldingDaysInput(days > 0 ? String(days) : '');
} else {
setHoldingDaysInput('');
}
};
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
@@ -94,9 +150,12 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
finalCost = finalShare > 0 ? principal / finalShare : 0; finalCost = finalShare > 0 ? principal / finalShare : 0;
} }
const trimmedDate = firstPurchaseDate ? firstPurchaseDate.trim() : '';
onSave({ onSave({
share: finalShare, share: finalShare,
cost: finalCost cost: finalCost,
...(trimmedDate && { firstPurchaseDate: trimmedDate })
}); });
onClose(); onClose();
}; };
@@ -255,6 +314,49 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
</> </>
)} )}
<div className="form-group" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span className="muted" style={{ fontSize: '14px' }}>
{dateMode === 'date' ? '首次买入日期' : '持有天数'}
</span>
<button
type="button"
onClick={handleDateModeToggle}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
background: 'rgba(255,255,255,0.06)',
border: 'none',
borderRadius: 6,
padding: '4px 8px',
fontSize: '12px',
color: 'var(--primary)',
cursor: 'pointer',
}}
title={dateMode === 'date' ? '切换到持有天数' : '切换到日期'}
>
<SwitchIcon />
{dateMode === 'date' ? '按天数' : '按日期'}
</button>
</div>
{dateMode === 'date' ? (
<DatePicker value={firstPurchaseDate} onChange={handleFirstPurchaseDateChange} position="top" />
) : (
<input
type="number"
inputMode="numeric"
min="0"
step="1"
className="input"
value={holdingDaysInput}
onChange={(e) => handleHoldingDaysChange(e.target.value)}
placeholder="请输入持有天数"
style={{ width: '100%' }}
/>
)}
</div>
<div className="row" style={{ gap: 12 }}> <div className="row" style={{ gap: 12 }}>
<button type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button> <button type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
<button <button

View File

@@ -35,6 +35,7 @@ const MOBILE_NON_FROZEN_COLUMN_IDS = [
'yesterdayChangePercent', 'yesterdayChangePercent',
'estimateChangePercent', 'estimateChangePercent',
'totalChangePercent', 'totalChangePercent',
'holdingDays',
'todayProfit', 'todayProfit',
'holdingProfit', 'holdingProfit',
'latestNav', 'latestNav',
@@ -47,6 +48,7 @@ const MOBILE_COLUMN_HEADERS = {
yesterdayChangePercent: '昨日涨幅', yesterdayChangePercent: '昨日涨幅',
estimateChangePercent: '估值涨幅', estimateChangePercent: '估值涨幅',
totalChangePercent: '估算收益', totalChangePercent: '估算收益',
holdingDays: '持有天数',
todayProfit: '当日收益', todayProfit: '当日收益',
holdingProfit: '持有收益', holdingProfit: '持有收益',
}; };
@@ -238,6 +240,7 @@ export default function MobileFundTable({
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; }); MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
// 新增列:默认隐藏(用户可在表格设置中开启) // 新增列:默认隐藏(用户可在表格设置中开启)
o.relatedSector = false; o.relatedSector = false;
o.holdingDays = false;
return o; return o;
})(); })();
@@ -253,6 +256,7 @@ export default function MobileFundTable({
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) { if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
const next = { ...vis }; const next = { ...vis };
if (next.relatedSector === undefined) next.relatedSector = false; if (next.relatedSector === undefined) next.relatedSector = false;
if (next.holdingDays === undefined) next.holdingDays = false;
return next; return next;
} }
return defaultVisibility; return defaultVisibility;
@@ -442,6 +446,7 @@ export default function MobileFundTable({
yesterdayChangePercent: 72, yesterdayChangePercent: 72,
estimateChangePercent: 80, estimateChangePercent: 80,
totalChangePercent: 80, totalChangePercent: 80,
holdingDays: 64,
todayProfit: 80, todayProfit: 80,
holdingProfit: 80, holdingProfit: 80,
}; };
@@ -515,6 +520,7 @@ export default function MobileFundTable({
allVisible[id] = true; allVisible[id] = true;
}); });
allVisible.relatedSector = false; allVisible.relatedSector = false;
allVisible.holdingDays = false;
setMobileColumnVisibility(allVisible); setMobileColumnVisibility(allVisible);
}; };
const handleToggleMobileColumnVisibility = (columnId, visible) => { const handleToggleMobileColumnVisibility = (columnId, visible) => {
@@ -849,6 +855,23 @@ export default function MobileFundTable({
}, },
meta: { align: 'right', cellClassName: 'total-change-cell', width: columnWidthMap.totalChangePercent }, meta: { align: 'right', cellClassName: 'total-change-cell', width: columnWidthMap.totalChangePercent },
}, },
{
accessorKey: 'holdingDays',
header: '持有天数',
cell: (info) => {
const original = info.row.original || {};
const value = original.holdingDaysValue;
if (value == null) {
return <div className="muted" style={{ textAlign: 'right', fontSize: '12px' }}></div>;
}
return (
<div style={{ fontWeight: 700, textAlign: 'right' }}>
{value}
</div>
);
},
meta: { align: 'right', cellClassName: 'holding-days-cell', width: columnWidthMap.holdingDays ?? 64 },
},
{ {
accessorKey: 'todayProfit', accessorKey: 'todayProfit',
header: '当日收益', header: '当日收益',
@@ -1019,7 +1042,7 @@ export default function MobileFundTable({
const getAlignClass = (columnId) => { const getAlignClass = (columnId) => {
if (columnId === 'fundName') return ''; if (columnId === 'fundName') return '';
if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'totalChangePercent', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right'; if (['latestNav', 'estimateNav', 'yesterdayChangePercent', 'estimateChangePercent', 'totalChangePercent', 'holdingDays', 'todayProfit', 'holdingProfit'].includes(columnId)) return 'text-right';
return 'text-right'; return 'text-right';
}; };

View File

@@ -43,6 +43,7 @@ const NON_FROZEN_COLUMN_IDS = [
'estimateChangePercent', 'estimateChangePercent',
'totalChangePercent', 'totalChangePercent',
'holdingAmount', 'holdingAmount',
'holdingDays',
'todayProfit', 'todayProfit',
'holdingProfit', 'holdingProfit',
'latestNav', 'latestNav',
@@ -57,6 +58,7 @@ const COLUMN_HEADERS = {
estimateChangePercent: '估值涨幅', estimateChangePercent: '估值涨幅',
totalChangePercent: '估算收益', totalChangePercent: '估算收益',
holdingAmount: '持仓金额', holdingAmount: '持仓金额',
holdingDays: '持有天数',
todayProfit: '当日收益', todayProfit: '当日收益',
holdingProfit: '持有收益', holdingProfit: '持有收益',
}; };
@@ -289,12 +291,14 @@ export default function PcFundTable({
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) { if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
const next = { ...vis }; const next = { ...vis };
if (next.relatedSector === undefined) next.relatedSector = false; if (next.relatedSector === undefined) next.relatedSector = false;
if (next.holdingDays === undefined) next.holdingDays = false;
return next; return next;
} }
const allVisible = {}; const allVisible = {};
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; }); NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
// 新增列:默认隐藏(用户可在表格设置中开启) // 新增列:默认隐藏(用户可在表格设置中开启)
allVisible.relatedSector = false; allVisible.relatedSector = false;
allVisible.holdingDays = false;
return allVisible; return allVisible;
})(); })();
const columnSizing = (() => { const columnSizing = (() => {
@@ -367,6 +371,7 @@ export default function PcFundTable({
allVisible[id] = true; allVisible[id] = true;
}); });
allVisible.relatedSector = false; allVisible.relatedSector = false;
allVisible.holdingDays = false;
setColumnVisibility(allVisible); setColumnVisibility(allVisible);
}; };
const handleToggleColumnVisibility = (columnId, visible) => { const handleToggleColumnVisibility = (columnId, visible) => {
@@ -849,6 +854,28 @@ export default function PcFundTable({
cellClassName: 'holding-amount-cell', cellClassName: 'holding-amount-cell',
}, },
}, },
{
accessorKey: 'holdingDays',
header: '持有天数',
size: 100,
minSize: 80,
cell: (info) => {
const original = info.row.original || {};
const value = original.holdingDaysValue;
if (value == null) {
return <div className="muted" style={{ textAlign: 'right', fontSize: '12px' }}></div>;
}
return (
<div style={{ fontWeight: 700, textAlign: 'right' }}>
{value}
</div>
);
},
meta: {
align: 'right',
cellClassName: 'holding-days-cell',
},
},
{ {
accessorKey: 'todayProfit', accessorKey: 'todayProfit',
header: '当日收益', header: '当日收益',

View File

@@ -794,6 +794,9 @@ export default function HomePage() {
const holdingAmount = const holdingAmount =
amount == null ? '未设置' : `¥${amount.toFixed(2)}`; amount == null ? '未设置' : `¥${amount.toFixed(2)}`;
const holdingAmountValue = amount; const holdingAmountValue = amount;
const holdingDaysValue = holding?.firstPurchaseDate
? dayjs.tz(todayStr, TZ).diff(dayjs.tz(holding.firstPurchaseDate, TZ), 'day')
: null;
const profitToday = profit ? profit.profitToday : null; const profitToday = profit ? profit.profitToday : null;
const todayProfit = const todayProfit =
@@ -869,6 +872,7 @@ export default function HomePage() {
estimateProfitPercent, estimateProfitPercent,
holdingAmount, holdingAmount,
holdingAmountValue, holdingAmountValue,
holdingDaysValue,
todayProfit, todayProfit,
todayProfitPercent, todayProfitPercent,
todayProfitValue, todayProfitValue,