diff --git a/app/globals.css b/app/globals.css index a3c452e..4cc8eb4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -138,6 +138,16 @@ body { box-shadow: 0 0 0 3px rgba(96,165,250,0.2); } +/* 美化并隐藏原生数字输入的上下按钮,使用自定义按钮 */ +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +input[type="number"] { + -moz-appearance: textfield; +} + .button { height: 44px; padding: 0 16px; diff --git a/app/page.jsx b/app/page.jsx index b23273a..459642f 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useMemo } from 'react'; import { motion, AnimatePresence, Reorder } from 'framer-motion'; import Announcement from "./components/Announcement"; @@ -118,6 +118,215 @@ function StarIcon({ filled, ...props }) { ); } +function CalendarIcon(props) { + return ( + + + + + + + ); +} + +function DatePicker({ value, onChange }) { + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(() => value ? new Date(value) : new Date()); + + // 点击外部关闭 + useEffect(() => { + const close = () => setIsOpen(false); + if (isOpen) window.addEventListener('click', close); + return () => window.removeEventListener('click', close); + }, [isOpen]); + + const year = currentMonth.getFullYear(); + const month = currentMonth.getMonth(); // 0-11 + + const handlePrevMonth = (e) => { + e.stopPropagation(); + setCurrentMonth(new Date(year, month - 1, 1)); + }; + + const handleNextMonth = (e) => { + e.stopPropagation(); + setCurrentMonth(new Date(year, month + 1, 1)); + }; + + const handleSelect = (e, day) => { + e.stopPropagation(); + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + + // 检查是否是未来日期 + const today = new Date(); + today.setHours(0, 0, 0, 0); + const selectedDate = new Date(dateStr); + + if (selectedDate > today) return; // 禁止选择未来日期 + + onChange(dateStr); + setIsOpen(false); + }; + + // 生成日历数据 + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const firstDayOfWeek = new Date(year, month, 1).getDay(); // 0(Sun)-6(Sat) + + const days = []; + for (let i = 0; i < firstDayOfWeek; i++) days.push(null); + for (let i = 1; i <= daysInMonth; i++) days.push(i); + + return ( +
e.stopPropagation()}> +
setIsOpen(!isOpen)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 12px', + height: '40px', + background: 'rgba(0,0,0,0.2)', + borderRadius: '8px', + cursor: 'pointer', + border: '1px solid transparent', + transition: 'all 0.2s' + }} + > + {value || '选择日期'} + +
+ + + {isOpen && ( + +
+ + {year}年 {month + 1}月 + +
+ +
+ {['日', '一', '二', '三', '四', '五', '六'].map(d => ( +
{d}
+ ))} + {days.map((d, i) => { + if (!d) return
; + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + const isSelected = value === dateStr; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const current = new Date(dateStr); + const isToday = current.getTime() === today.getTime(); + const isFuture = current > today; + + return ( +
!isFuture && handleSelect(e, d)} + style={{ + height: 28, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '13px', + borderRadius: '6px', + cursor: isFuture ? 'not-allowed' : 'pointer', + background: isSelected ? 'var(--primary)' : isToday ? 'rgba(255,255,255,0.1)' : 'transparent', + color: isFuture ? 'var(--muted)' : isSelected ? '#000' : 'var(--text)', + fontWeight: isSelected || isToday ? 600 : 400, + opacity: isFuture ? 0.3 : 1 + }} + onMouseEnter={(e) => { + if (!isSelected && !isFuture) e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; + }} + onMouseLeave={(e) => { + if (!isSelected && !isFuture) e.currentTarget.style.background = isToday ? 'rgba(255,255,255,0.1)' : 'transparent'; + }} + > + {d} +
+ ); + })} +
+ + )} + +
+ ); +} + +function MinusIcon(props) { + return ( + + + + ); +} + +function NumericInput({ value, onChange, step = 1, min = 0, placeholder }) { + const decimals = String(step).includes('.') ? String(step).split('.')[1].length : 0; + const fmt = (n) => Number(n).toFixed(decimals); + const inc = () => { + const v = parseFloat(value); + const base = isNaN(v) ? 0 : v; + const next = base + step; + onChange(fmt(next)); + }; + const dec = () => { + const v = parseFloat(value); + const base = isNaN(v) ? 0 : v; + const next = Math.max(min, base - step); + onChange(fmt(next)); + }; + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + style={{ width: '100%', paddingRight: 56 }} + /> +
+ + +
+
+ ); +} + function Stat({ label, value, delta }) { const dir = delta > 0 ? 'up' : delta < 0 ? 'down' : ''; return ( @@ -265,6 +474,378 @@ function FeedbackModal({ onClose }) { ); } +function HoldingActionModal({ fund, onClose, onAction }) { + return ( + + e.stopPropagation()} + style={{ maxWidth: '320px' }} + > +
+
+ + 持仓操作 +
+ +
+ +
+
{fund?.name}
+
#{fund?.code}
+
+ +
+ + + + +
+
+
+ ); +} + +function TradeModal({ type, fund, onClose, onConfirm }) { + const isBuy = type === 'buy'; + const [share, setShare] = useState(''); + const [amount, setAmount] = useState(''); + const [feeRate, setFeeRate] = useState('0'); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [isAfter3pm, setIsAfter3pm] = useState(new Date().getHours() >= 15); + const [calcShare, setCalcShare] = useState(null); + const price = fund?.estPricedCoverage > 0.05 ? fund?.estGsz : (typeof fund?.gsz === 'number' ? fund?.gsz : Number(fund?.dwjz)); + + useEffect(() => { + if (!isBuy) return; + const a = parseFloat(amount); + const f = parseFloat(feeRate); + const p = parseFloat(price); + if (a > 0 && p > 0 && !isNaN(f)) { + const netAmount = a / (1 + f / 100); + const s = netAmount / p; + setCalcShare(s); + } else { + setCalcShare(null); + } + }, [isBuy, amount, feeRate, price]); + + const handleSubmit = (e) => { + e.preventDefault(); + if (isBuy) { + if (!amount || !feeRate || !date || calcShare === null || !price) return; + onConfirm({ share: calcShare, price: Number(price), totalCost: Number(amount), date, isAfter3pm }); + } else { + if (!share || !price) return; + onConfirm({ share: Number(share), price: Number(price) }); + } + }; + + const isValid = isBuy + ? (!!amount && !!feeRate && !!date && calcShare !== null) + : (!!share && !!price); + + return ( + + e.stopPropagation()} + style={{ maxWidth: '420px' }} + > +
+
+ {isBuy ? '📥' : '📤'} + {isBuy ? '加仓' : '减仓'} +
+ +
+ +
+
{fund?.name}
+
#{fund?.code}
+
+ +
+ {isBuy ? ( + <> +
+ +
+ +
+
+ +
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+ + +
+
+ {isAfter3pm ? '将在下一个交易日确认份额' : '将在当日确认份额'} +
+
+ + {price && calcShare !== null && ( +
+
+ 预计确认份额 + {calcShare.toFixed(2)} 份 +
+
计算基于当前净值/估值:¥{Number(price).toFixed(4)}
+
+ )} + + ) : ( + <> +
+ +
+ +
+
+ + )} + +
+ + +
+
+
+
+ ); +} + +function HoldingEditModal({ fund, holding, onClose, onSave }) { + const [share, setShare] = useState(holding?.share || ''); + const [cost, setCost] = useState(holding?.cost || ''); + + const handleSubmit = (e) => { + e.preventDefault(); + if (!share || !cost) return; // 简单校验 + onSave({ + share: Number(share), + cost: Number(cost) + }); + onClose(); + }; + + const isValid = share && cost && !isNaN(share) && !isNaN(cost); + + return ( + + e.stopPropagation()} + style={{ maxWidth: '400px' }} + > +
+
+ + 设置持仓 +
+ +
+ +
+
{fund?.name}
+
#{fund?.code}
+
+ +
+
+ + setShare(e.target.value)} + placeholder="请输入持有份额" + style={{ + width: '100%', + border: !share ? '1px solid var(--danger)' : undefined + }} + autoFocus + /> +
+ +
+ + setCost(e.target.value)} + placeholder="请输入持仓成本价" + style={{ + width: '100%', + border: !cost ? '1px solid var(--danger)' : undefined + }} + /> +
+ +
+ + +
+
+
+
+ ); +} + function AddResultModal({ failures, onClose }) { return (
- +
@@ -713,6 +1294,82 @@ function GroupModal({ onClose, onConfirm }) { ); } +function GroupSummary({ funds, holdings, groupName, getProfit }) { + const [showPercent, setShowPercent] = useState(false); + + const summary = useMemo(() => { + let totalAsset = 0; + let totalProfitToday = 0; + let totalHoldingReturn = 0; + let totalCost = 0; + let hasHolding = false; + + funds.forEach(fund => { + const holding = holdings[fund.code]; + const profit = getProfit(fund, holding); + + if (profit) { + hasHolding = true; + totalAsset += profit.amount; + totalProfitToday += profit.profitToday; + 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 }; + }, [funds, holdings, getProfit]); + + if (!summary.hasHolding) return null; + + return ( +
+
+
+
{groupName}
+
+ ¥ + {summary.totalAsset.toFixed(2)} +
+
+
+
+
当日收益
+
0 ? 'up' : summary.totalProfitToday < 0 ? 'down' : ''} + style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--font-mono)' }} + > + {summary.totalProfitToday > 0 ? '+' : summary.totalProfitToday < 0 ? '-' : ''} + ¥{Math.abs(summary.totalProfitToday).toFixed(2)} +
+
+
+
持有收益{showPercent ? '(%)' : ''}
+
0 ? 'up' : summary.totalHoldingReturn < 0 ? 'down' : ''} + style={{ fontSize: '18px', fontWeight: 700, fontFamily: 'var(--font-mono)', cursor: 'pointer' }} + onClick={() => setShowPercent(!showPercent)} + title="点击切换金额/百分比" + > + {summary.totalHoldingReturn > 0 ? '+' : summary.totalHoldingReturn < 0 ? '-' : ''} + {showPercent + ? `${Math.abs(summary.returnRate).toFixed(2)}%` + : `¥${Math.abs(summary.totalHoldingReturn).toFixed(2)}` + } +
+
+
+
+
+ ); +} + export default function HomePage() { const [funds, setFunds] = useState([]); const [loading, setLoading] = useState(false); @@ -760,6 +1417,12 @@ export default function HomePage() { const [showDropdown, setShowDropdown] = useState(false); const [addResultOpen, setAddResultOpen] = useState(false); const [addFailures, setAddFailures] = useState([]); + const [holdingModal, setHoldingModal] = useState({ open: false, fund: null }); + const [actionModal, setActionModal] = useState({ open: false, fund: null }); + const [tradeModal, setTradeModal] = useState({ open: false, fund: null, type: 'buy' }); // type: 'buy' | 'sell' + const [clearConfirm, setClearConfirm] = useState(null); // { fund } + const [holdings, setHoldings] = useState({}); // { [code]: { share: number, cost: number } } + const [percentModes, setPercentModes] = useState({}); // { [code]: boolean } const tabsRef = useRef(null); // 过滤和排序后的基金列表 @@ -801,6 +1464,95 @@ export default function HomePage() { const [canLeft, setCanLeft] = useState(false); const [canRight, setCanRight] = useState(false); + // 计算持仓收益 + const getHoldingProfit = (fund, holding) => { + if (!holding || typeof holding.share !== 'number') return null; + + // 当前净值 + const currentNav = fund.estPricedCoverage > 0.05 + ? fund.estGsz + : (typeof fund.gsz === 'number' ? fund.gsz : Number(fund.dwjz)); + + if (!currentNav) return null; + + // 持仓金额 = 份额 * 当前净值 + const amount = holding.share * currentNav; + + // 估算收益 = 份额 * (当前净值 - 昨日净值) + // 注意:这里用估值涨跌幅计算当日盈亏 + const profitToday = amount * (fund.estPricedCoverage > 0.05 ? fund.estGszzl : (Number(fund.gszzl) || 0)) / 100; + + // 总收益 = (当前净值 - 成本价) * 份额 + const profitTotal = typeof holding.cost === 'number' + ? (currentNav - holding.cost) * holding.share + : null; + + return { + amount, + profitToday, + profitTotal + }; + }; + + const handleSaveHolding = (code, data) => { + setHoldings(prev => { + const next = { ...prev }; + if (data.share === null && data.cost === null) { + delete next[code]; + } else { + next[code] = data; + } + localStorage.setItem('holdings', JSON.stringify(next)); + return next; + }); + setHoldingModal({ open: false, fund: null }); + }; + + const handleAction = (type, fund) => { + setActionModal({ open: false, fund: null }); + if (type === 'edit') { + setHoldingModal({ open: true, fund }); + } else if (type === 'clear') { + setClearConfirm({ fund }); + } else if (type === 'buy' || type === 'sell') { + setTradeModal({ open: true, fund, type }); + } + }; + + const handleClearConfirm = () => { + if (clearConfirm?.fund) { + handleSaveHolding(clearConfirm.fund.code, { share: null, cost: null }); + } + setClearConfirm(null); + }; + + const handleTrade = (fund, data) => { + const current = holdings[fund.code] || { share: 0, cost: 0 }; + const isBuy = tradeModal.type === 'buy'; + + let newShare, newCost; + + if (isBuy) { + newShare = current.share + data.share; + + // 如果传递了 totalCost(即买入总金额),则用它来计算新成本 + // 否则回退到用 share * price 计算(减仓或旧逻辑) + const buyCost = data.totalCost !== undefined ? data.totalCost : (data.price * data.share); + + // 加权平均成本 = (原持仓成本 * 原份额 + 本次买入总花费) / 新总份额 + // 注意:这里默认将手续费也计入成本(如果 totalCost 包含了手续费) + newCost = (current.cost * current.share + buyCost) / newShare; + } else { + newShare = Math.max(0, current.share - data.share); + // 减仓不改变单位成本,只减少份额 + newCost = current.cost; + if (newShare === 0) newCost = 0; + } + + handleSaveHolding(fund.code, { share: newShare, cost: newCost }); + setTradeModal({ open: false, fund: null, type: 'buy' }); + }; + const handleMouseDown = (e) => { if (!tabsRef.current) return; setIsDragging(true); @@ -999,6 +1751,11 @@ export default function HomePage() { if (savedViewMode === 'card' || savedViewMode === 'list') { setViewMode(savedViewMode); } + // 加载持仓数据 + const savedHoldings = JSON.parse(localStorage.getItem('holdings') || '{}'); + if (savedHoldings && typeof savedHoldings === 'object') { + setHoldings(savedHoldings); + } } catch {} }, []); @@ -1373,6 +2130,15 @@ export default function HomePage() { if (nextSet.size === 0) setCurrentTab('all'); return nextSet; }); + + // 同步删除持仓数据 + setHoldings(prev => { + if (!prev[removeCode]) return prev; + const next = { ...prev }; + delete next[removeCode]; + localStorage.setItem('holdings', JSON.stringify(next)); + return next; + }); }; const manualRefresh = async () => { @@ -1403,6 +2169,7 @@ export default function HomePage() { collapsedCodes: JSON.parse(localStorage.getItem('collapsedCodes') || '[]'), refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10), viewMode: localStorage.getItem('viewMode') || 'card', + holdings: JSON.parse(localStorage.getItem('holdings') || '{}'), exportedAt: new Date().toISOString() }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); @@ -1509,6 +2276,12 @@ export default function HomePage() { localStorage.setItem('viewMode', data.viewMode); } + if (data.holdings && typeof data.holdings === 'object') { + const mergedHoldings = { ...JSON.parse(localStorage.getItem('holdings') || '{}'), ...data.holdings }; + setHoldings(mergedHoldings); + localStorage.setItem('holdings', JSON.stringify(mergedHoldings)); + } + // 导入成功后,仅刷新新追加的基金 if (appendedCodes.length) { // 这里需要确保 refreshAll 不会因为闭包问题覆盖掉刚刚合并好的 mergedFunds @@ -1537,7 +2310,11 @@ export default function HomePage() { addFundToGroupOpen || groupManageOpen || groupModalOpen || - successModal.open; + successModal.open || + holdingModal.open || + actionModal.open || + tradeModal.open || + !!clearConfirm; if (isAnyModalOpen) { document.body.style.overflow = 'hidden'; @@ -1558,6 +2335,13 @@ export default function HomePage() { return () => window.removeEventListener('keydown', onKey); }, [settingsOpen]); + const getGroupName = () => { + if (currentTab === 'all') return '全部资产'; + if (currentTab === 'fav') return '自选资产'; + const group = groups.find(g => g.id === currentTab); + return group ? `${group.name}资产` : '分组资产'; + }; + return (
@@ -1821,18 +2605,52 @@ export default function HomePage() {
) : ( <> + + {currentTab !== 'all' && currentTab !== 'fav' && ( -
- -
+ setAddFundToGroupOpen(true)} + style={{ + width: '100%', + height: '48px', + border: '2px dashed rgba(255,255,255,0.1)', + background: 'transparent', + borderRadius: '12px', + color: 'var(--muted)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + marginBottom: '16px', + cursor: 'pointer', + transition: 'all 0.2s ease', + fontSize: '14px', + fontWeight: 500 + }} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = 'var(--primary)'; + e.currentTarget.style.color = 'var(--primary)'; + e.currentTarget.style.background = 'rgba(34, 211, 238, 0.05)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)'; + e.currentTarget.style.color = 'var(--muted)'; + e.currentTarget.style.background = 'transparent'; + }} + > + + 添加基金到此分组 + )} + 0.05 ? f.estGszzl : (Number(f.gszzl) || 0)} />
+ +
+ {(() => { + const holding = holdings[f.code]; + const profit = getHoldingProfit(f, holding); + + if (!profit) { + return ( +
+ 持仓金额 +
setHoldingModal({ open: true, fund: f })} + > + 未设置 +
+
+ ); + } + + return ( + <> +
setActionModal({ open: true, fund: f })} + > + + 持仓金额 + + ¥{profit.amount.toFixed(2)} +
+
+ 当日盈亏 + 0 ? 'up' : profit.profitToday < 0 ? 'down' : ''}`}> + {profit.profitToday > 0 ? '+' : profit.profitToday < 0 ? '-' : ''}¥{Math.abs(profit.profitToday).toFixed(2)} + +
+ {profit.profitTotal !== null && ( +
{ + e.stopPropagation(); + setPercentModes(prev => ({ ...prev, [f.code]: !prev[f.code] })); + }} + style={{ cursor: 'pointer' }} + title="点击切换金额/百分比" + > + 持有收益{percentModes[f.code] ? '(%)' : ''} + 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)}` + } + +
+ )} + + ); + })()} +
+ {f.estPricedCoverage > 0.05 && (
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算 @@ -2088,6 +2970,50 @@ export default function HomePage() { )} + + {actionModal.open && ( + setActionModal({ open: false, fund: null })} + onAction={(type) => handleAction(type, actionModal.fund)} + /> + )} + + + + {tradeModal.open && ( + setTradeModal({ open: false, fund: null, type: 'buy' })} + onConfirm={(data) => handleTrade(tradeModal.fund, data)} + /> + )} + + + + {clearConfirm && ( + setClearConfirm(null)} + confirmText="确认清空" + /> + )} + + + + {holdingModal.open && ( + setHoldingModal({ open: false, fund: null })} + onSave={(data) => handleSaveHolding(holdingModal.fund?.code, data)} + /> + )} + + {groupManageOpen && (