feat: 新增持有天数
This commit is contained in:
@@ -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
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
|
||||
@@ -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: '当日收益',
|
||||
|
||||
Reference in New Issue
Block a user