From f0a95ac19f50a778d6d36f5176a75b18771d2f12 Mon Sep 17 00:00:00 2001
From: hzm <934585316@qq.com>
Date: Sun, 8 Feb 2026 19:29:45 +0800
Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E5=A2=9E=E5=8A=A0=E5=8A=A0?=
=?UTF-8?q?=E5=87=8F=E4=BB=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/globals.css | 8 +
app/page.jsx | 859 ++++++++++++++++++++++++++++++++++++++++++----
package-lock.json | 11 +-
package.json | 1 +
4 files changed, 801 insertions(+), 78 deletions(-)
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 表格
+ // 格式:
');
+ 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 ? (
+
+
+ setShowPendingList(false)}
+ style={{ padding: '4px 8px', fontSize: '12px' }}
+ >
+ < 返回
+
+
+
+ {currentPendingTrades.map((trade, idx) => (
+
+
+
+ {trade.type === 'buy' ? '买入' : '卖出'}
+
+ {trade.date} {trade.isAfter3pm ? '(15:00后)' : ''}
+
+
+ 份额/金额
+ {trade.share ? `${trade.share} 份` : `¥${trade.amount}`}
+
+
+ 状态
+
+ 等待净值更新...
+ setRevokeTrade(trade)}
+ style={{
+ padding: '2px 8px',
+ fontSize: '10px',
+ height: 'auto',
+ background: 'rgba(255,255,255,0.1)',
+ color: 'var(--text)'
+ }}
+ >
+ 撤销
+
+
+
+
+ ))}
+
+
+ ) : (
+ <>
+ {!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}
+
+
+ )}
+
+
+ setShowConfirm(false)}
+ style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
+ >
+ 返回修改
+
+
+ {loadingPrice ? '请稍候' : (price ? '确认买入' : '加入待处理队列')}
+
+
+
+ ) : (
+
+
+
+ 基金名称
+ {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}
+
+
+ )}
+
+
+ setShowConfirm(false)}
+ style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
+ >
+ 返回修改
+
+
+ {loadingPrice ? '请稍候' : (price ? '确认卖出' : '加入待处理队列')}
+
+
+
+ )
+ ) : (
-
- {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) => (
+ handleSetShareFraction(opt.value)}
+ style={{
+ flex: 1,
+ padding: '4px 8px',
+ fontSize: '12px',
+ background: 'rgba(255,255,255,0.1)',
+ border: 'none',
+ borderRadius: '4px',
+ color: 'var(--text)',
+ cursor: 'pointer'
+ }}
+ >
+ {opt.label}
+
+ ))}
+
+ )}
+ {holding && (
+
+ 当前持仓: {holding.share.toFixed(2)} 份 {pendingSellShare > 0 && 冻结: {pendingSellShare.toFixed(2)} 份}
+
+ )}
+
+
+
+
+
+
+ {
+ setFeeMode(m => m === 'rate' ? 'amount' : 'rate');
+ setFeeValue('0');
+ }}
+ style={{
+ background: 'none',
+ border: 'none',
+ color: 'var(--primary)',
+ fontSize: '12px',
+ cursor: 'pointer',
+ padding: 0
+ }}
+ >
+ 切换为{feeMode === 'rate' ? '金额' : '费率'}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setIsAfter3pm(false)}
+ style={{
+ flex: 1,
+ border: 'none',
+ background: !isAfter3pm ? 'var(--primary)' : 'transparent',
+ color: !isAfter3pm ? '#05263b' : 'var(--muted)',
+ borderRadius: '6px',
+ fontSize: '12px',
+ cursor: 'pointer',
+ padding: '6px 8px'
+ }}
+ >
+ 15:00前
+
+ setIsAfter3pm(true)}
+ style={{
+ flex: 1,
+ border: 'none',
+ background: isAfter3pm ? 'var(--primary)' : 'transparent',
+ color: isAfter3pm ? '#05263b' : 'var(--muted)',
+ borderRadius: '6px',
+ fontSize: '12px',
+ cursor: 'pointer',
+ padding: '6px 8px'
+ }}
+ >
+ 15:00后
+
+
+
+
+
+ {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",
|