diff --git a/app/globals.css b/app/globals.css index 2d05191..90bf1a7 100644 --- a/app/globals.css +++ b/app/globals.css @@ -729,6 +729,14 @@ input[type="number"] { padding: 16px; } +.pending-list { + scrollbar-width: none; +} + +.pending-list::-webkit-scrollbar { + display: none; +} + .chips { display: flex; flex-wrap: wrap; diff --git a/app/page.jsx b/app/page.jsx index 242a8e1..bf885ad 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -10,9 +10,92 @@ import weixinImg from "./assets/weixin.jpg"; import githubImg from "./assets/github.svg"; import { supabase } from './lib/supabase'; import packageJson from '../package.json'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.tz.setDefault('Asia/Shanghai'); + +const TZ = 'Asia/Shanghai'; +const nowInTz = () => dayjs().tz(TZ); +const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz()); +const formatDate = (input) => toTz(input).format('YYYY-MM-DD'); + +// 全局 JSONP/Script 加载辅助函数 +const loadScript = (url) => { + return new Promise((resolve, reject) => { + if (typeof document === 'undefined') return resolve(); + const script = document.createElement('script'); + script.src = url; + script.async = true; + script.onload = () => { + if (document.body.contains(script)) document.body.removeChild(script); + resolve(); + }; + script.onerror = () => { + if (document.body.contains(script)) document.body.removeChild(script); + reject(new Error('加载失败')); + }; + document.body.appendChild(script); + }); +}; + +// 获取指定日期的基金净值 +const fetchFundNetValue = async (code, date) => { + // 使用东方财富 F10 接口获取历史净值 HTML + const url = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=1&per=1&sdate=${date}&edate=${date}`; + try { + await loadScript(url); + if (window.apidata && window.apidata.content) { + const content = window.apidata.content; + if (content.includes('暂无数据')) return null; + + // 解析 HTML 表格 + // 格式: 日期单位净值... + const rows = content.split(''); + for (const row of rows) { + if (row.includes(`${date}`)) { + // 找到对应日期的行,提取单元格 + const cells = row.match(/]*>(.*?)<\/td>/g); + if (cells && cells.length >= 2) { + // 第二列是单位净值 (cells[1]) + const valStr = cells[1].replace(/<[^>]+>/g, ''); + const val = parseFloat(valStr); + return isNaN(val) ? null : val; + } + } + } + } + return null; + } catch (e) { + console.error('获取净值失败', e); + return null; + } +}; + +const fetchSmartFundNetValue = async (code, startDate) => { + const today = nowInTz().startOf('day'); + + let current = toTz(startDate).startOf('day'); + + for (let i = 0; i < 30; i++) { + if (current.isAfter(today)) break; + + const dateStr = current.format('YYYY-MM-DD'); + const val = await fetchFundNetValue(code, dateStr); + if (val !== null) { + return { date: dateStr, value: val }; + } + + current = current.add(1, 'day'); + } + return null; +}; function PlusIcon(props) { - return ( + return ( @@ -194,7 +277,7 @@ function CalendarIcon(props) { function DatePicker({ value, onChange }) { const [isOpen, setIsOpen] = useState(false); - const [currentMonth, setCurrentMonth] = useState(() => value ? new Date(value) : new Date()); + const [currentMonth, setCurrentMonth] = useState(() => value ? toTz(value) : nowInTz()); // 点击外部关闭 useEffect(() => { @@ -203,37 +286,35 @@ function DatePicker({ value, onChange }) { return () => window.removeEventListener('click', close); }, [isOpen]); - const year = currentMonth.getFullYear(); - const month = currentMonth.getMonth(); // 0-11 + const year = currentMonth.year(); + const month = currentMonth.month(); const handlePrevMonth = (e) => { e.stopPropagation(); - setCurrentMonth(new Date(year, month - 1, 1)); + setCurrentMonth(currentMonth.subtract(1, 'month').startOf('month')); }; const handleNextMonth = (e) => { e.stopPropagation(); - setCurrentMonth(new Date(year, month + 1, 1)); + setCurrentMonth(currentMonth.add(1, 'month').startOf('month')); }; const handleSelect = (e, day) => { e.stopPropagation(); - const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const dateStr = formatDate(`${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); + const today = nowInTz().startOf('day'); + const selectedDate = toTz(dateStr).startOf('day'); - if (selectedDate > today) return; // 禁止选择未来日期 + if (selectedDate.isAfter(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 daysInMonth = currentMonth.daysInMonth(); + const firstDayOfWeek = currentMonth.startOf('month').day(); const days = []; for (let i = 0; i < firstDayOfWeek; i++) days.push(null); @@ -300,13 +381,12 @@ function DatePicker({ value, onChange }) { ))} {days.map((d, i) => { if (!d) return
; - const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + const dateStr = formatDate(`${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; + const today = nowInTz().startOf('day'); + const current = toTz(dateStr).startOf('day'); + const isToday = current.isSame(today); + const isFuture = current.isAfter(today); return (
- -
+ {!showPendingList && !showConfirm && currentPendingTrades.length > 0 && ( +
setShowPendingList(true)} + > + ⚠️ 当前有 {currentPendingTrades.length} 笔待处理交易 + 查看详情 > +
+ )} + + {showPendingList ? ( +
+
+ +
+
+ {currentPendingTrades.map((trade, idx) => ( +
+
+ + {trade.type === 'buy' ? '买入' : '卖出'} + + {trade.date} {trade.isAfter3pm ? '(15:00后)' : ''} +
+
+ 份额/金额 + {trade.share ? `${trade.share} 份` : `¥${trade.amount}`} +
+
+ 状态 +
+ 等待净值更新... + +
+
+
+ ))} +
+
+ ) : ( + <> + {!showConfirm && (
{fund?.name}
#{fund?.code}
+ )} + {showConfirm ? ( + isBuy ? ( +
+
+
+ 基金名称 + {fund?.name} +
+
+ 买入金额 + ¥{Number(amount).toFixed(2)} +
+
+ 买入费率 + {Number(feeRate).toFixed(2)}% +
+
+ 参考净值 + {loadingPrice ? '查询中...' : (price ? `¥${Number(price).toFixed(4)}` : '待查询 (加入队列)')} +
+
+ 预估份额 + {calcShare === '待确认' ? '待确认' : `${Number(calcShare).toFixed(2)} 份`} +
+
+ 买入日期 + {date} +
+
+ 交易时段 + {isAfter3pm ? '15:00后' : '15:00前'} +
+
+ {loadingPrice ? '正在获取该日净值...' : `*基于${price === getEstimatePrice() ? '当前净值/估值' : '当日净值'}测算`} +
+
+ + {holding && calcShare !== '待确认' && ( +
+
持仓变化预览
+
+
+
持有份额
+
+ {holding.share.toFixed(2)} + + {(holding.share + Number(calcShare)).toFixed(2)} +
+
+ {price ? ( +
+
持有市值 (估)
+
+ ¥{(holding.share * Number(price)).toFixed(2)} + + ¥{((holding.share + Number(calcShare)) * Number(price)).toFixed(2)} +
+
+ ) : null} +
+
+ )} + +
+ + +
+
+ ) : ( +
+
+
+ 基金名称 + {fund?.name} +
+
+ 卖出份额 + {sellShare.toFixed(2)} 份 +
+
+ 预估卖出单价 + {loadingPrice ? '查询中...' : (price ? `¥${sellPrice.toFixed(4)}` : '待查询 (加入队列)')} +
+
+ 卖出费率/费用 + {feeMode === 'rate' ? `${feeValue}%` : `¥${feeValue}`} +
+
+ 预估手续费 + {price ? `¥${sellFee.toFixed(2)}` : '待计算'} +
+
+ 卖出日期 + {date} +
+
+ 预计回款 + {loadingPrice ? '计算中...' : (price ? `¥${estimatedReturn.toFixed(2)}` : '待计算')} +
+
+ {loadingPrice ? '正在获取该日净值...' : `*基于${price === getEstimatePrice() ? '当前净值/估值' : '当日净值'}测算`} +
+
+ + {holding && ( +
+
持仓变化预览
+
+
+
持有份额
+
+ {holding.share.toFixed(2)} + + {(holding.share - sellShare).toFixed(2)} +
+
+ {price ? ( +
+
持有市值 (估)
+
+ ¥{(holding.share * sellPrice).toFixed(2)} + + ¥{((holding.share - sellShare) * sellPrice).toFixed(2)} +
+
+ ) : null} +
+
+ )} + +
+ + +
+
+ ) + ) : (
{isBuy ? ( <> @@ -825,20 +1234,17 @@ function TradeModal({ type, fund, onClose, onConfirm }) { 15:00后
-
- {isAfter3pm ? '将在下一个交易日确认份额' : '将在当日确认份额'} -
- {price && calcShare !== null && ( -
-
- 预计确认份额 - {calcShare.toFixed(2)} 份 -
-
计算基于当前净值/估值:¥{Number(price).toFixed(4)}
-
- )} +
+ {loadingPrice ? ( + 正在查询净值数据... + ) : price === 0 ? null : ( +
+ 参考净值: {Number(price).toFixed(4)} +
+ )} +
) : ( <> @@ -852,9 +1258,134 @@ function TradeModal({ type, fund, onClose, onConfirm }) { onChange={setShare} step={1} min={0} - placeholder="请输入卖出份额" + placeholder={holding ? `最多可卖 ${availableShare.toFixed(2)} 份` : "请输入卖出份额"} /> + {holding && holding.share > 0 && ( +
+ {[ + { label: '1/4', value: 0.25 }, + { label: '1/3', value: 1/3 }, + { label: '1/2', value: 0.5 }, + { label: '全部', value: 1 } + ].map((opt) => ( + + ))} +
+ )} + {holding && ( +
+ 当前持仓: {holding.share.toFixed(2)} 份 {pendingSellShare > 0 && 冻结: {pendingSellShare.toFixed(2)} 份} +
+ )} + + +
+
+
+ + +
+
+ +
+
+
+ + +
+
+ +
+ +
+ + +
+
+ +
+ {loadingPrice ? ( + 正在查询净值数据... + ) : price === 0 ? null : ( +
+ 参考净值: {price.toFixed(4)} +
+ )}
)} @@ -864,14 +1395,32 @@ function TradeModal({ type, fund, onClose, onConfirm }) { + )} + + )} + + {revokeTrade && ( + { + onDeletePending?.(revokeTrade.id); + setRevokeTrade(null); + }} + onCancel={() => setRevokeTrade(null)} + confirmText="确认撤销" + /> + )} + ); } @@ -944,13 +1493,14 @@ function HoldingEditModal({ fund, holding, onClose, onSave }) { if (mode === 'share') { if (!share || !cost) return; - finalShare = Number(share); + finalShare = Number(Number(share).toFixed(2)); finalCost = Number(cost); } else { if (!amount || !dwjz) return; const a = Number(amount); const p = Number(profit || 0); - finalShare = a / dwjz; + const rawShare = a / dwjz; + finalShare = Number(rawShare.toFixed(2)); const principal = a - p; finalCost = finalShare > 0 ? principal / finalShare : 0; } @@ -1254,7 +1804,10 @@ function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确 className="modal-overlay" role="dialog" aria-modal="true" - onClick={onCancel} + onClick={(e) => { + e.stopPropagation(); + onCancel(); + }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} @@ -1318,7 +1871,7 @@ function GroupManageModal({ groups, onClose, onSave }) { const handleAddRow = () => { const newGroup = { - id: `group_${Date.now()}`, + id: `group_${nowInTz().valueOf()}`, name: '', codes: [] }; @@ -1835,13 +2388,22 @@ export default function HomePage() { const [clearConfirm, setClearConfirm] = useState(null); // { fund } const [donateOpen, setDonateOpen] = useState(false); const [holdings, setHoldings] = useState({}); // { [code]: { share: number, cost: number } } + const [pendingTrades, setPendingTrades] = useState([]); // [{ id, fundCode, share, date, ... }] const [percentModes, setPercentModes] = useState({}); // { [code]: boolean } + + const holdingsRef = useRef(holdings); + const pendingTradesRef = useRef(pendingTrades); + + useEffect(() => { + holdingsRef.current = holdings; + pendingTradesRef.current = pendingTrades; + }, [holdings, pendingTrades]); + const [isTradingDay, setIsTradingDay] = useState(true); // 默认为交易日,通过接口校正 const tabsRef = useRef(null); const [fundDeleteConfirm, setFundDeleteConfirm] = useState(null); // { code, name } - const today = new Date(); - const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + const todayStr = formatDate(); const [isMobile, setIsMobile] = useState(false); const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false); @@ -1914,8 +2476,8 @@ export default function HomePage() { // 检查交易日状态 const checkTradingDay = () => { - const now = new Date(); - const isWeekend = now.getDay() === 0 || now.getDay() === 6; + const now = nowInTz(); + const isWeekend = now.day() === 0 || now.day() === 6; // 周末直接判定为非交易日 if (isWeekend) { @@ -1941,7 +2503,7 @@ export default function HomePage() { } else { // 日期不匹配 (显示的是旧数据) // 如果已经过了 09:30 还是旧数据,说明今天休市 - const minutes = now.getHours() * 60 + now.getMinutes(); + const minutes = now.hour() * 60 + now.minute(); if (minutes >= 9 * 60 + 30) { setIsTradingDay(false); } else { @@ -1972,8 +2534,8 @@ export default function HomePage() { const getHoldingProfit = (fund, holding) => { if (!holding || typeof holding.share !== 'number') return null; - const now = new Date(); - const isAfter9 = now.getHours() >= 9; + const now = nowInTz(); + const isAfter9 = now.hour() >= 9; const hasTodayData = fund.jzrq === todayStr; const hasTodayValuation = typeof fund.gztime === 'string' && fund.gztime.startsWith(todayStr); const canCalcTodayProfit = hasTodayData || hasTodayValuation; @@ -2110,7 +2672,87 @@ export default function HomePage() { setClearConfirm(null); }; + const processPendingQueue = async () => { + const currentPending = pendingTradesRef.current; + if (currentPending.length === 0) return; + + let stateChanged = false; + let tempHoldings = { ...holdingsRef.current }; + const processedIds = new Set(); + + for (const trade of currentPending) { + let queryDate = trade.date; + if (trade.isAfter3pm) { + queryDate = toTz(trade.date).add(1, 'day').format('YYYY-MM-DD'); + } + + // 尝试获取智能净值 + const result = await fetchSmartFundNetValue(trade.fundCode, queryDate); + + if (result && result.value > 0) { + // 成功获取,执行交易 + const current = tempHoldings[trade.fundCode] || { share: 0, cost: 0 }; + + let newShare, newCost; + if (trade.type === 'buy') { + const feeRate = trade.feeRate || 0; + const netAmount = trade.amount / (1 + feeRate / 100); + const share = netAmount / result.value; + newShare = current.share + share; + newCost = (current.cost * current.share + trade.amount) / newShare; + } else { + newShare = Math.max(0, current.share - trade.share); + newCost = current.cost; + if (newShare === 0) newCost = 0; + } + + tempHoldings[trade.fundCode] = { share: newShare, cost: newCost }; + stateChanged = true; + processedIds.add(trade.id); + } + } + + if (stateChanged) { + setHoldings(tempHoldings); + storageHelper.setItem('holdings', JSON.stringify(tempHoldings)); + + setPendingTrades(prev => { + const next = prev.filter(t => !processedIds.has(t.id)); + storageHelper.setItem('pendingTrades', JSON.stringify(next)); + return next; + }); + + showToast(`已处理 ${processedIds.size} 笔待定交易`, 'success'); + } + }; + const handleTrade = (fund, data) => { + // 如果没有价格(API失败),加入待处理队列 + if (!data.price || data.price === 0) { + const pending = { + id: crypto.randomUUID(), + fundCode: fund.code, + fundName: fund.name, + type: tradeModal.type, + share: data.share, + amount: data.totalCost, + feeRate: tradeModal.type === 'buy' ? data.feeRate : 0, // Buy needs feeRate + feeMode: data.feeMode, + feeValue: data.feeValue, + date: data.date, + isAfter3pm: data.isAfter3pm, + timestamp: Date.now() + }; + + const next = [...pendingTrades, pending]; + setPendingTrades(next); + storageHelper.setItem('pendingTrades', JSON.stringify(next)); + + setTradeModal({ open: false, fund: null, type: 'buy' }); + showToast('净值暂未更新,已加入待处理队列', 'info'); + return; + } + const current = holdings[fund.code] || { share: 0, cost: 0 }; const isBuy = tradeModal.type === 'buy'; @@ -2222,11 +2864,11 @@ export default function HomePage() { }, []); const storageHelper = useMemo(() => { - const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings']); + const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings', 'pendingTrades']); const triggerSync = (key) => { if (keys.has(key)) { if (!skipSyncRef.current) { - window.localStorage.setItem('localUpdatedAt', new Date().toISOString()); + window.localStorage.setItem('localUpdatedAt', nowInTz().toISOString()); } scheduleSync(); } @@ -2243,7 +2885,7 @@ export default function HomePage() { clear: () => { window.localStorage.clear(); if (!skipSyncRef.current) { - window.localStorage.setItem('localUpdatedAt', new Date().toISOString()); + window.localStorage.setItem('localUpdatedAt', nowInTz().toISOString()); } scheduleSync(); } @@ -2251,7 +2893,7 @@ export default function HomePage() { }, [scheduleSync]); useEffect(() => { - const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings']); + const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings', 'pendingTrades']); const onStorage = (e) => { if (!e.key || keys.has(e.key)) scheduleSync(); }; @@ -2401,6 +3043,11 @@ export default function HomePage() { if (Array.isArray(savedFavorites)) { setFavorites(new Set(savedFavorites)); } + // 加载待处理交易 + const savedPending = JSON.parse(localStorage.getItem('pendingTrades') || '[]'); + if (Array.isArray(savedPending)) { + setPendingTrades(savedPending); + } // 加载分组状态 const savedGroups = JSON.parse(localStorage.getItem('groups') || '[]'); if (Array.isArray(savedGroups)) { @@ -3086,6 +3733,11 @@ export default function HomePage() { } finally { refreshingRef.current = false; setRefreshing(false); + try { + await processPendingQueue(); + }catch (e) { + showToast('待交易队列计算出错', 'error') + } } }; @@ -3195,6 +3847,13 @@ export default function HomePage() { storageHelper.setItem('holdings', JSON.stringify(next)); return next; }); + + // 同步删除待处理交易 + setPendingTrades(prev => { + const next = prev.filter((trade) => trade?.fundCode !== removeCode); + storageHelper.setItem('pendingTrades', JSON.stringify(next)); + return next; + }); }; const manualRefresh = async () => { @@ -3223,7 +3882,8 @@ export default function HomePage() { groups: Array.isArray(payload.groups) ? payload.groups : [], collapsedCodes: Array.isArray(payload.collapsedCodes) ? payload.collapsedCodes : [], refreshMs: Number.isFinite(payload.refreshMs) ? payload.refreshMs : 30000, - holdings: payload.holdings && typeof payload.holdings === 'object' ? payload.holdings : {} + holdings: payload.holdings && typeof payload.holdings === 'object' ? payload.holdings : {}, + pendingTrades: Array.isArray(payload.pendingTrades) ? payload.pendingTrades : [] }); } @@ -3239,6 +3899,7 @@ export default function HomePage() { : [] ); const holdings = JSON.parse(localStorage.getItem('holdings') || '{}'); + const pendingTrades = JSON.parse(localStorage.getItem('pendingTrades') || '[]'); const cleanedHoldings = holdings && typeof holdings === 'object' && !Array.isArray(holdings) ? Object.entries(holdings).reduce((acc, [code, value]) => { if (!fundCodes.has(code) || !value || typeof value !== 'object') return acc; @@ -3277,6 +3938,9 @@ export default function HomePage() { : [] })) : []; + const cleanedPendingTrades = Array.isArray(pendingTrades) + ? pendingTrades.filter((trade) => trade && fundCodes.has(trade.fundCode)) + : []; return { funds, favorites: cleanedFavorites, @@ -3284,7 +3948,8 @@ export default function HomePage() { collapsedCodes: cleanedCollapsed, refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10), holdings: cleanedHoldings, - exportedAt: new Date().toISOString() + pendingTrades: cleanedPendingTrades, + exportedAt: nowInTz().toISOString() }; } catch { return { @@ -3294,7 +3959,8 @@ export default function HomePage() { collapsedCodes: [], refreshMs: 30000, holdings: {}, - exportedAt: new Date().toISOString() + pendingTrades: [], + exportedAt: nowInTz().toISOString() }; } }; @@ -3304,11 +3970,12 @@ export default function HomePage() { skipSyncRef.current = true; try { if (cloudUpdatedAt) { - storageHelper.setItem('localUpdatedAt', new Date(cloudUpdatedAt).toISOString()); + storageHelper.setItem('localUpdatedAt', toTz(cloudUpdatedAt).toISOString()); } const nextFunds = Array.isArray(cloudData.funds) ? dedupeByCode(cloudData.funds) : []; setFunds(nextFunds); storageHelper.setItem('funds', JSON.stringify(nextFunds)); + const nextFundCodes = new Set(nextFunds.map((f) => f.code)); const nextFavorites = Array.isArray(cloudData.favorites) ? cloudData.favorites : []; setFavorites(new Set(nextFavorites)); @@ -3335,6 +4002,12 @@ export default function HomePage() { setHoldings(nextHoldings); storageHelper.setItem('holdings', JSON.stringify(nextHoldings)); + const nextPendingTrades = Array.isArray(cloudData.pendingTrades) + ? cloudData.pendingTrades.filter((trade) => trade && nextFundCodes.has(trade.fundCode)) + : []; + setPendingTrades(nextPendingTrades); + storageHelper.setItem('pendingTrades', JSON.stringify(nextPendingTrades)); + if (nextFunds.length) { const codes = Array.from(new Set(nextFunds.map((f) => f.code))); if (codes.length) await refreshAll(codes); @@ -3393,7 +4066,7 @@ export default function HomePage() { try { setIsSyncing(true); const payload = collectLocalPayload(); - const now = new Date().toISOString(); + const now = nowInTz().toISOString(); const { data: upsertData, error: updateError } = await supabase .from('user_configs') .upsert( @@ -3439,7 +4112,8 @@ export default function HomePage() { collapsedCodes: JSON.parse(localStorage.getItem('collapsedCodes') || '[]'), refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10), holdings: JSON.parse(localStorage.getItem('holdings') || '{}'), - exportedAt: new Date().toISOString() + pendingTrades: JSON.parse(localStorage.getItem('pendingTrades') || '[]'), + exportedAt: nowInTz().toISOString() }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); if (window.showSaveFilePicker) { @@ -3491,6 +4165,7 @@ export default function HomePage() { const currentFavorites = JSON.parse(localStorage.getItem('favorites') || '[]'); const currentGroups = JSON.parse(localStorage.getItem('groups') || '[]'); const currentCollapsed = JSON.parse(localStorage.getItem('collapsedCodes') || '[]'); + const currentPendingTrades = JSON.parse(localStorage.getItem('pendingTrades') || '[]'); let mergedFunds = currentFunds; let appendedCodes = []; @@ -3550,6 +4225,28 @@ export default function HomePage() { storageHelper.setItem('holdings', JSON.stringify(mergedHoldings)); } + if (Array.isArray(data.pendingTrades)) { + const existingPending = Array.isArray(currentPendingTrades) ? currentPendingTrades : []; + const incomingPending = data.pendingTrades.filter((trade) => trade && trade.fundCode); + const fundCodeSet = new Set(mergedFunds.map((f) => f.code)); + const keyOf = (trade) => { + if (trade?.id) return `id:${trade.id}`; + return `k:${trade?.fundCode || ''}:${trade?.type || ''}:${trade?.date || ''}:${trade?.share || ''}:${trade?.amount || ''}:${trade?.isAfter3pm ? 1 : 0}`; + }; + const mergedPendingMap = new Map(); + existingPending.forEach((trade) => { + if (!trade || !fundCodeSet.has(trade.fundCode)) return; + mergedPendingMap.set(keyOf(trade), trade); + }); + incomingPending.forEach((trade) => { + if (!fundCodeSet.has(trade.fundCode)) return; + mergedPendingMap.set(keyOf(trade), trade); + }); + const mergedPending = Array.from(mergedPendingMap.values()); + setPendingTrades(mergedPending); + storageHelper.setItem('pendingTrades', JSON.stringify(mergedPending)); + } + // 导入成功后,仅刷新新追加的基金 if (appendedCodes.length) { // 这里需要确保 refreshAll 不会因为闭包问题覆盖掉刚刚合并好的 mergedFunds @@ -4244,8 +4941,8 @@ export default function HomePage() { {(() => { - const now = new Date(); - const isAfter9 = now.getHours() >= 9; + const now = nowInTz(); + const isAfter9 = now.hour() >= 9; const hasTodayData = f.jzrq === todayStr; const shouldHideChange = isTradingDay && isAfter9 && !hasTodayData; @@ -4461,8 +5158,8 @@ export default function HomePage() { ) : ( <> {(() => { - const now = new Date(); - const isAfter9 = now.getHours() >= 9; + const now = nowInTz(); + const isAfter9 = now.hour() >= 9; const hasTodayData = f.jzrq === todayStr; const shouldHideChange = isTradingDay && isAfter9 && !hasTodayData; @@ -4741,8 +5438,18 @@ export default function HomePage() { setTradeModal({ open: false, fund: null, type: 'buy' })} onConfirm={(data) => handleTrade(tradeModal.fund, data)} + pendingTrades={pendingTrades} + onDeletePending={(id) => { + setPendingTrades(prev => { + const next = prev.filter(t => t.id !== id); + storageHelper.setItem('pendingTrades', JSON.stringify(next)); + return next; + }); + showToast('已撤销待处理交易', 'success'); + }} /> )} diff --git a/package-lock.json b/package-lock.json index 82682bd..4568666 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "real-time-fund", - "version": "0.1.0", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-time-fund", - "version": "0.1.0", + "version": "0.1.3", "dependencies": { "@dicebear/collection": "^9.3.1", "@dicebear/core": "^9.3.1", "@supabase/supabase-js": "^2.78.0", + "dayjs": "^1.11.19", "framer-motion": "^12.29.2", "next": "^16.1.5", "react": "18.3.1", @@ -1212,6 +1213,12 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", diff --git a/package.json b/package.json index 23f774b..f4e1680 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@dicebear/collection": "^9.3.1", "@dicebear/core": "^9.3.1", "@supabase/supabase-js": "^2.78.0", + "dayjs": "^1.11.19", "framer-motion": "^12.29.2", "next": "^16.1.5", "react": "18.3.1",