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 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 [currentMonth, setCurrentMonth] = useState(() => value ? toTz(value) : nowInTz());
@@ -83,16 +83,15 @@ export function DatePicker({ value, onChange }) {
<AnimatePresence>
{isOpen && (
<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 }}
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"
style={{
position: 'absolute',
top: '100%',
...(position === 'top' ? { bottom: '100%', marginBottom: 8 } : { top: '100%', marginTop: 8 }),
left: 0,
width: '100%',
marginTop: 8,
padding: 12,
zIndex: 10
}}

View File

@@ -267,6 +267,20 @@ export default function FundCard({
{masked ? '******' : `¥${profit.amount.toFixed(2)}`}
</span>
</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 }}>
<span className="label">当日收益</span>
<span

View File

@@ -1,15 +1,27 @@
'use client';
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 {
Dialog,
DialogContent,
DialogTitle,
} 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 }) {
const [mode, setMode] = useState('amount'); // 'amount' | 'share'
const [dateMode, setDateMode] = useState('date'); // 'date' | 'days'
const dwjz = fund?.dwjz || fund?.gsz || 0;
const dwjzRef = useRef(dwjz);
@@ -21,10 +33,12 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
const [cost, setCost] = useState('');
const [amount, setAmount] = useState('');
const [profit, setProfit] = useState('');
const [firstPurchaseDate, setFirstPurchaseDate] = useState('');
const [holdingDaysInput, setHoldingDaysInput] = useState('');
const holdingSig = useMemo(() => {
if (!holding) return '';
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}`;
return `${holding.id ?? ''}|${holding.share ?? ''}|${holding.cost ?? ''}|${holding.firstPurchaseDate ?? ''}`;
}, [holding]);
useEffect(() => {
@@ -33,6 +47,14 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
const c = holding.cost || 0;
setShare(String(s));
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;
if (price > 0) {
@@ -42,7 +64,6 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
setProfit(p.toFixed(2));
}
}
// 只在“切换持仓/初次打开”时初始化,避免净值刷新覆盖用户输入
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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) => {
e.preventDefault();
@@ -94,9 +150,12 @@ export default function HoldingEditModal({ fund, holding, onClose, onSave, onOpe
finalCost = finalShare > 0 ? principal / finalShare : 0;
}
const trimmedDate = firstPurchaseDate ? firstPurchaseDate.trim() : '';
onSave({
share: finalShare,
cost: finalCost
cost: finalCost,
...(trimmedDate && { firstPurchaseDate: trimmedDate })
});
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 }}>
<button type="button" className="button secondary" onClick={onClose} style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}>取消</button>
<button

View File

@@ -35,6 +35,7 @@ const MOBILE_NON_FROZEN_COLUMN_IDS = [
'yesterdayChangePercent',
'estimateChangePercent',
'totalChangePercent',
'holdingDays',
'todayProfit',
'holdingProfit',
'latestNav',
@@ -47,6 +48,7 @@ const MOBILE_COLUMN_HEADERS = {
yesterdayChangePercent: '昨日涨幅',
estimateChangePercent: '估值涨幅',
totalChangePercent: '估算收益',
holdingDays: '持有天数',
todayProfit: '当日收益',
holdingProfit: '持有收益',
};
@@ -238,6 +240,7 @@ export default function MobileFundTable({
MOBILE_NON_FROZEN_COLUMN_IDS.forEach((id) => { o[id] = true; });
// 新增列:默认隐藏(用户可在表格设置中开启)
o.relatedSector = false;
o.holdingDays = false;
return o;
})();
@@ -253,6 +256,7 @@ export default function MobileFundTable({
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
const next = { ...vis };
if (next.relatedSector === undefined) next.relatedSector = false;
if (next.holdingDays === undefined) next.holdingDays = false;
return next;
}
return defaultVisibility;
@@ -442,6 +446,7 @@ export default function MobileFundTable({
yesterdayChangePercent: 72,
estimateChangePercent: 80,
totalChangePercent: 80,
holdingDays: 64,
todayProfit: 80,
holdingProfit: 80,
};
@@ -515,6 +520,7 @@ export default function MobileFundTable({
allVisible[id] = true;
});
allVisible.relatedSector = false;
allVisible.holdingDays = false;
setMobileColumnVisibility(allVisible);
};
const handleToggleMobileColumnVisibility = (columnId, visible) => {
@@ -849,6 +855,23 @@ export default function MobileFundTable({
},
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',
header: '当日收益',
@@ -1019,7 +1042,7 @@ export default function MobileFundTable({
const getAlignClass = (columnId) => {
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';
};

View File

@@ -43,6 +43,7 @@ const NON_FROZEN_COLUMN_IDS = [
'estimateChangePercent',
'totalChangePercent',
'holdingAmount',
'holdingDays',
'todayProfit',
'holdingProfit',
'latestNav',
@@ -57,6 +58,7 @@ const COLUMN_HEADERS = {
estimateChangePercent: '估值涨幅',
totalChangePercent: '估算收益',
holdingAmount: '持仓金额',
holdingDays: '持有天数',
todayProfit: '当日收益',
holdingProfit: '持有收益',
};
@@ -289,13 +291,15 @@ export default function PcFundTable({
if (vis && typeof vis === 'object' && Object.keys(vis).length > 0) {
const next = { ...vis };
if (next.relatedSector === undefined) next.relatedSector = false;
if (next.holdingDays === undefined) next.holdingDays = false;
return next;
}
const allVisible = {};
NON_FROZEN_COLUMN_IDS.forEach((id) => { allVisible[id] = true; });
// 新增列:默认隐藏(用户可在表格设置中开启)
allVisible.relatedSector = false;
return allVisible;
allVisible.relatedSector = false;
allVisible.holdingDays = false;
return allVisible;
})();
const columnSizing = (() => {
const s = currentGroupPc?.pcTableColumns;
@@ -367,6 +371,7 @@ export default function PcFundTable({
allVisible[id] = true;
});
allVisible.relatedSector = false;
allVisible.holdingDays = false;
setColumnVisibility(allVisible);
};
const handleToggleColumnVisibility = (columnId, visible) => {
@@ -849,6 +854,28 @@ export default function PcFundTable({
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',
header: '当日收益',

View File

@@ -794,6 +794,9 @@ export default function HomePage() {
const holdingAmount =
amount == null ? '未设置' : `¥${amount.toFixed(2)}`;
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 todayProfit =
@@ -869,6 +872,7 @@ export default function HomePage() {
estimateProfitPercent,
holdingAmount,
holdingAmountValue,
holdingDaysValue,
todayProfit,
todayProfitPercent,
todayProfitValue,