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}
+
+
+
+
+
+ );
+}
+
+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}
+
+
+
+
+
+ );
+}
+
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 && (