diff --git a/app/components/AddHistoryModal.jsx b/app/components/AddHistoryModal.jsx new file mode 100644 index 0000000..071b110 --- /dev/null +++ b/app/components/AddHistoryModal.jsx @@ -0,0 +1,209 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { CloseIcon } from './Icons'; +import { fetchSmartFundNetValue } from '../api/fund'; +import { DatePicker } from './Common'; + +export default function AddHistoryModal({ fund, onClose, onConfirm }) { + const [type, setType] = useState(''); + const [date, setDate] = useState(new Date().toISOString().split('T')[0]); + const [amount, setAmount] = useState(''); + const [share, setShare] = useState(''); + const [netValue, setNetValue] = useState(null); + const [netValueDate, setNetValueDate] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!fund || !date) return; + + const getNetValue = async () => { + setLoading(true); + setError(null); + setNetValue(null); + setNetValueDate(null); + + try { + const result = await fetchSmartFundNetValue(fund.code, date); + if (result && result.value) { + setNetValue(result.value); + setNetValueDate(result.date); + } else { + setError('未找到该日期的净值数据'); + } + } catch (err) { + console.error(err); + setError('获取净值失败'); + } finally { + setLoading(false); + } + }; + + const timer = setTimeout(getNetValue, 500); + return () => clearTimeout(timer); + }, [fund, date]); + + // Recalculate share when netValue变化或金额变化 + useEffect(() => { + if (netValue && amount) { + setShare((parseFloat(amount) / netValue).toFixed(2)); + } + }, [netValue, amount]); + + const handleAmountChange = (e) => { + const val = e.target.value; + setAmount(val); + if (netValue && val) { + setShare((parseFloat(val) / netValue).toFixed(2)); + } else if (!val) { + setShare(''); + } + }; + + const handleSubmit = () => { + if (!type || !date || !netValue || !amount || !share) return; + + onConfirm({ + fundCode: fund.code, + type, + date: netValueDate, // Use the date from net value to be precise + amount: parseFloat(amount), + share: parseFloat(share), + price: netValue, + timestamp: new Date(netValueDate).getTime() + }); + onClose(); + }; + + return ( + + e.stopPropagation()} + > +
+ 添加历史记录 + +
+ +
+
{fund?.name}
+
{fund?.code}
+
+ +
+ +
+ + +
+
+ +
+ + + {loading &&
正在获取净值...
} + {error &&
{error}
} + {netValue && !loading && ( +
+ 参考净值: {netValue} ({netValueDate}) +
+ )} +
+ +
+ + +
+ + +
+
+ ); +} diff --git a/app/components/Announcement.jsx b/app/components/Announcement.jsx index 1f56e3a..952fe89 100644 --- a/app/components/Announcement.jsx +++ b/app/components/Announcement.jsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v7'; +const ANNOUNCEMENT_KEY = 'hasClosedAnnouncement_v8'; export default function Announcement() { const [isVisible, setIsVisible] = useState(false); @@ -63,13 +63,17 @@ export default function Announcement() { 公告
- 因为节前放假因素,所以节前不会有大的功能更新调整。 - 综合目前大家的需求,以下功能将会在节后上线: + 为了增加更多用户方便访问, 新增国内加速地址:https://fund.cc.cd/ +

节后第一次更新内容如下:

+

1. OCR 识别截图导入基金。

+

2. 基金历史曲线图。

+

3. 买入、卖出历史记录。

+ 以下内容会在近期更新:

1. 定投。

-

2. 自定义内容展示布局。

-

3. 基金历史曲线图。

-

4. 基金实时估值曲线。

-

5. OCR 识别截图导入基金。

+

2. 自定义布局。

diff --git a/app/components/FundTrendChart.jsx b/app/components/FundTrendChart.jsx index bcaf8cf..4be4ac2 100644 --- a/app/components/FundTrendChart.jsx +++ b/app/components/FundTrendChart.jsx @@ -29,7 +29,7 @@ ChartJS.register( Filler ); -export default function FundTrendChart({ code, isExpanded, onToggleExpand }) { +export default function FundTrendChart({ code, isExpanded, onToggleExpand, transactions = [] }) { const [range, setRange] = useState('1m'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); @@ -94,10 +94,30 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand }) { const firstValue = data.length > 0 ? data[0].value : 1; const percentageData = data.map(d => ((d.value - firstValue) / firstValue) * 100); + // Map transaction dates to chart indices + const dateToIndex = new Map(data.map((d, i) => [d.date, i])); + const buyPoints = new Array(data.length).fill(null); + const sellPoints = new Array(data.length).fill(null); + + transactions.forEach(t => { + // Simple date matching (assuming formats match) + // If formats differ, dayjs might be needed + const idx = dateToIndex.get(t.date); + if (idx !== undefined) { + const val = percentageData[idx]; + if (t.type === 'buy') { + buyPoints[idx] = val; + } else { + sellPoints[idx] = val; + } + } + }); + return { labels: data.map(d => d.date), datasets: [ { + type: 'line', label: '涨跌幅', data: percentageData, borderColor: lineColor, @@ -112,11 +132,36 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand }) { pointRadius: 0, pointHoverRadius: 4, fill: true, - tension: 0.2 + tension: 0.2, + order: 2 + }, + { + type: 'line', // Use line type with showLine: false to simulate scatter on Category scale + label: '买入', + data: buyPoints, + borderColor: '#ef4444', // Red + backgroundColor: '#ef4444', + pointStyle: 'circle', + pointRadius: 2.5, + pointHoverRadius: 4, + showLine: false, + order: 1 + }, + { + type: 'line', + label: '卖出', + data: sellPoints, + borderColor: '#22c55e', // Green + backgroundColor: '#22c55e', + pointStyle: 'circle', + pointRadius: 2.5, + pointHoverRadius: 4, + showLine: false, + order: 1 } ] }; - }, [data, lineColor]); + }, [data, lineColor, transactions]); const options = useMemo(() => { return { @@ -178,21 +223,69 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand }) { const plugins = useMemo(() => [{ id: 'crosshair', afterDraw: (chart) => { - // 检查是否有激活的点 - let activePoint = null; - if (chart.tooltip?._active?.length) { - activePoint = chart.tooltip._active[0]; - } else { - // 如果 tooltip._active 为空(可能因为 enabled: false 导致内部状态更新机制差异), - // 尝试从 getActiveElements 获取,这在 Chart.js 3+ 中是推荐方式 - const activeElements = chart.getActiveElements(); - if (activeElements && activeElements.length) { - activePoint = activeElements[0]; - } + const ctx = chart.ctx; + const datasets = chart.data.datasets; + const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee'; + + // Helper function to draw point label + const drawPointLabel = (datasetIndex, index, text, bgColor, textColor = '#ffffff', yOffset = 0) => { + const meta = chart.getDatasetMeta(datasetIndex); + if (!meta.data[index]) return; + const element = meta.data[index]; + // Check if element is visible/not skipped + if (element.skip) return; + + const x = element.x; + const y = element.y + yOffset; + + ctx.save(); + ctx.font = 'bold 11px sans-serif'; + const labelWidth = ctx.measureText(text).width + 12; + + // Draw label above the point + ctx.globalAlpha = 0.8; + ctx.fillStyle = bgColor; + ctx.fillRect(x - labelWidth/2, y - 24, labelWidth, 18); + + ctx.globalAlpha = 1.0; + ctx.fillStyle = textColor; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, x, y - 15); + ctx.restore(); + }; + + // 1. Draw default labels for first buy and sell points + // Index 1 is Buy, Index 2 is Sell + if (datasets[1] && datasets[1].data) { + const firstBuyIndex = datasets[1].data.findIndex(v => v !== null && v !== undefined); + if (firstBuyIndex !== -1) { + // Check collision with Sell + let sellIndex = -1; + if (datasets[2] && datasets[2].data) { + sellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined); + } + const isCollision = (firstBuyIndex === sellIndex); + drawPointLabel(1, firstBuyIndex, '买入', '#ef4444', '#ffffff', isCollision ? -20 : 0); + } + } + if (datasets[2] && datasets[2].data) { + const firstSellIndex = datasets[2].data.findIndex(v => v !== null && v !== undefined); + if (firstSellIndex !== -1) { + drawPointLabel(2, firstSellIndex, '卖出', primaryColor); + } } - if (activePoint) { - const ctx = chart.ctx; + // 2. Handle active elements (hover crosshair) + let activeElements = []; + if (chart.tooltip?._active?.length) { + activeElements = chart.tooltip._active; + } else { + activeElements = chart.getActiveElements(); + } + + if (activeElements && activeElements.length) { + const activePoint = activeElements[0]; const x = activePoint.element.x; const y = activePoint.element.y; const topY = chart.scales.y.top; @@ -210,28 +303,22 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand }) { ctx.moveTo(x, topY); ctx.lineTo(x, bottomY); - // Draw horizontal line + // Draw horizontal line (based on first point - usually the main line) ctx.moveTo(leftX, y); ctx.lineTo(rightX, y); ctx.stroke(); - // 获取 --primary 颜色 - const primaryColor = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee'; - // Draw labels ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - // 获取数据点 - // 优先使用 chart.data 中的数据,避免闭包过时问题 - // activePoint.index 是当前数据集中的索引 + // Draw Axis Labels based on the first point (main line) const datasetIndex = activePoint.datasetIndex; const index = activePoint.index; const labels = chart.data.labels; - const datasets = chart.data.datasets; if (labels && datasets && datasets[datasetIndex] && datasets[datasetIndex].data) { const dateStr = labels[index]; @@ -256,6 +343,31 @@ export default function FundTrendChart({ code, isExpanded, onToggleExpand }) { } } + // Check for collision between Buy (1) and Sell (2) in active elements + const activeBuy = activeElements.find(e => e.datasetIndex === 1); + const activeSell = activeElements.find(e => e.datasetIndex === 2); + const isCollision = activeBuy && activeSell && activeBuy.index === activeSell.index; + + // Iterate through all active points to find transaction points and draw their labels + activeElements.forEach(element => { + const dsIndex = element.datasetIndex; + // Only for transaction datasets (index > 0) + if (dsIndex > 0 && datasets[dsIndex]) { + const label = datasets[dsIndex].label; + // Determine background color based on dataset index + // 1 = Buy (Red), 2 = Sell (Theme Color) + const bgColor = dsIndex === 1 ? '#ef4444' : primaryColor; + + // If collision, offset Buy label upwards + let yOffset = 0; + if (isCollision && dsIndex === 1) { + yOffset = -20; + } + + drawPointLabel(dsIndex, element.index, label, bgColor, '#ffffff', yOffset); + } + }); + ctx.restore(); } } diff --git a/app/components/HoldingActionModal.jsx b/app/components/HoldingActionModal.jsx index 68086f1..2f5f14d 100644 --- a/app/components/HoldingActionModal.jsx +++ b/app/components/HoldingActionModal.jsx @@ -3,7 +3,7 @@ import { motion } from 'framer-motion'; import { CloseIcon, SettingsIcon } from './Icons'; -export default function HoldingActionModal({ fund, onClose, onAction }) { +export default function HoldingActionModal({ fund, onClose, onAction, hasHistory }) { return ( 持仓操作 + {hasHistory && ( + + )}
+ + +
+
+
{fund?.name}
+
#{fund?.code}
+
+ +
+ +
+ {/* Pending Transactions */} + {pendingTransactions.length > 0 && ( +
+
待处理队列
+ {pendingTransactions.map((item) => ( +
+
+ + {item.type === 'buy' ? '买入' : '卖出'} + + {item.date} {item.isAfter3pm ? '(15:00后)' : ''} +
+
+ 份额/金额 + {item.share ? `${Number(item.share).toFixed(2)} 份` : `¥${Number(item.amount).toFixed(2)}`} +
+
+ 等待净值更新... + +
+
+ ))} +
+ )} + + {/* History Transactions */} +
+
历史记录
+ {sortedTransactions.length === 0 ? ( +
暂无历史交易记录
+ ) : ( + sortedTransactions.map((item) => ( +
+
+ + {item.type === 'buy' ? '买入' : '卖出'} + + {item.date} +
+
+ 成交份额 + {Number(item.share).toFixed(2)} 份 +
+
+ 成交金额 + ¥{Number(item.amount).toFixed(2)} +
+ {item.price && ( +
+ 成交净值 + {Number(item.price).toFixed(4)} +
+ )} +
+ + +
+
+ )) + )} +
+
+ + + + + {deleteConfirm && ( + setDeleteConfirm(null)} + confirmText="确认删除" + /> + )} + + + ); +} diff --git a/app/page.jsx b/app/page.jsx index 246de72..1f3899d 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -31,8 +31,11 @@ import ScanProgressModal from "./components/ScanProgressModal"; import SettingsModal from "./components/SettingsModal"; import SuccessModal from "./components/SuccessModal"; import TradeModal from "./components/TradeModal"; +import TransactionHistoryModal from "./components/TransactionHistoryModal"; +import AddHistoryModal from "./components/AddHistoryModal"; import UpdatePromptModal from "./components/UpdatePromptModal"; import WeChatModal from "./components/WeChatModal"; +import githubImg from "./assets/github.svg"; import { supabase, isSupabaseConfigured } from './lib/supabase'; import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds } from './api/fund'; import packageJson from '../package.json'; @@ -395,6 +398,9 @@ export default function HomePage() { const [donateOpen, setDonateOpen] = useState(false); const [holdings, setHoldings] = useState({}); // { [code]: { share: number, cost: number } } const [pendingTrades, setPendingTrades] = useState([]); // [{ id, fundCode, share, date, ... }] + const [transactions, setTransactions] = useState({}); // { [code]: [{ id, type, amount, share, price, date, timestamp }] } + const [historyModal, setHistoryModal] = useState({ open: false, fund: null }); + const [addHistoryModal, setAddHistoryModal] = useState({ open: false, fund: null }); const [percentModes, setPercentModes] = useState({}); // { [code]: boolean } const holdingsRef = useRef(holdings); @@ -643,13 +649,18 @@ export default function HomePage() { }; const handleAction = (type, fund) => { - setActionModal({ open: false, fund: null }); + if (type !== 'history') { + 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 }); + } else if (type === 'history') { + setHistoryModal({ open: true, fund }); } }; @@ -667,6 +678,7 @@ export default function HomePage() { let stateChanged = false; let tempHoldings = { ...holdingsRef.current }; const processedIds = new Set(); + const newTransactions = []; for (const trade of currentPending) { let queryDate = trade.date; @@ -682,21 +694,43 @@ export default function HomePage() { const current = tempHoldings[trade.fundCode] || { share: 0, cost: 0 }; let newShare, newCost; + let tradeShare = 0; + let tradeAmount = 0; + 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; + + tradeShare = share; + tradeAmount = trade.amount; } else { newShare = Math.max(0, current.share - trade.share); newCost = current.cost; if (newShare === 0) newCost = 0; + + tradeShare = trade.share; + tradeAmount = trade.share * result.value; } tempHoldings[trade.fundCode] = { share: newShare, cost: newCost }; stateChanged = true; processedIds.add(trade.id); + + // 记录交易历史 + newTransactions.push({ + id: trade.id, + fundCode: trade.fundCode, + type: trade.type, + share: tradeShare, + amount: tradeAmount, + price: result.value, + date: result.date, // 使用获取到净值的日期 + isAfter3pm: trade.isAfter3pm, + timestamp: Date.now() + }); } } @@ -710,10 +744,76 @@ export default function HomePage() { return next; }); + setTransactions(prev => { + const nextState = { ...prev }; + newTransactions.forEach(tx => { + const current = nextState[tx.fundCode] || []; + // 避免重复添加 (虽然 id 应该唯一) + if (!current.some(t => t.id === tx.id)) { + nextState[tx.fundCode] = [tx, ...current].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); + } + }); + storageHelper.setItem('transactions', JSON.stringify(nextState)); + return nextState; + }); + showToast(`已处理 ${processedIds.size} 笔待定交易`, 'success'); } }; + const handleDeleteTransaction = (fundCode, transactionId) => { + setTransactions(prev => { + const current = prev[fundCode] || []; + const next = current.filter(t => t.id !== transactionId); + const nextState = { ...prev, [fundCode]: next }; + storageHelper.setItem('transactions', JSON.stringify(nextState)); + return nextState; + }); + showToast('交易记录已删除', 'success'); + }; + + const handleAddHistory = (data) => { + const fundCode = data.fundCode; + const current = holdings[fundCode] || { share: 0, cost: 0 }; + const isBuy = data.type === 'buy'; + + let newShare, newCost; + + if (isBuy) { + newShare = current.share + data.share; + // 加权平均成本 + const buyCost = data.amount; // amount is total cost + 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(fundCode, { share: newShare, cost: newCost }); + + setTransactions(prev => { + const current = prev[fundCode] || []; + const record = { + id: crypto.randomUUID(), + type: data.type, + share: data.share, + amount: data.amount, + price: data.price, + date: data.date, + isAfter3pm: false, // 历史记录通常不需要此标记,或者默认为 false + timestamp: data.timestamp || Date.now() + }; + // 按时间倒序排列 + const next = [record, ...current].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); + const nextState = { ...prev, [fundCode]: next }; + storageHelper.setItem('transactions', JSON.stringify(nextState)); + return nextState; + }); + showToast('历史记录已添加', 'success'); + setAddHistoryModal({ open: false, fund: null }); + }; + const handleTrade = (fund, data) => { // 如果没有价格(API失败),加入待处理队列 if (!data.price || data.price === 0) { @@ -764,6 +864,25 @@ export default function HomePage() { } handleSaveHolding(fund.code, { share: newShare, cost: newCost }); + + setTransactions(prev => { + const current = prev[fund.code] || []; + const record = { + id: crypto.randomUUID(), + type: tradeModal.type, + share: data.share, + amount: isBuy ? data.totalCost : (data.share * data.price), + price: data.price, + date: data.date, + isAfter3pm: data.isAfter3pm, + timestamp: Date.now() + }; + const next = [record, ...current]; + const nextState = { ...prev, [fund.code]: next }; + storageHelper.setItem('transactions', JSON.stringify(nextState)); + return nextState; + }); + setTradeModal({ open: false, fund: null, type: 'buy' }); }; @@ -1137,7 +1256,7 @@ export default function HomePage() { }, []); const storageHelper = useMemo(() => { - const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']); + const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'transactions', 'viewMode']); const triggerSync = (key, prevValue, nextValue) => { if (keys.has(key)) { // 标记为脏数据 @@ -1382,6 +1501,10 @@ export default function HomePage() { if (savedHoldings && typeof savedHoldings === 'object') { setHoldings(savedHoldings); } + const savedTransactions = JSON.parse(localStorage.getItem('transactions') || '{}'); + if (savedTransactions && typeof savedTransactions === 'object') { + setTransactions(savedTransactions); + } const savedViewMode = localStorage.getItem('viewMode'); if (savedViewMode === 'card' || savedViewMode === 'list') { setViewMode(savedViewMode); @@ -2070,6 +2193,9 @@ export default function HomePage() { if (!keys || keys.has('pendingTrades')) { all.pendingTrades = JSON.parse(localStorage.getItem('pendingTrades') || '[]'); } + if (!keys || keys.has('transactions')) { + all.transactions = JSON.parse(localStorage.getItem('transactions') || '{}'); + } // 如果是全量收集(keys 为 null),进行完整的数据清洗和验证逻辑 if (!keys) { @@ -2195,6 +2321,10 @@ export default function HomePage() { setPendingTrades(nextPendingTrades); storageHelper.setItem('pendingTrades', JSON.stringify(nextPendingTrades)); + const nextTransactions = cloudData.transactions && typeof cloudData.transactions === 'object' ? cloudData.transactions : {}; + setTransactions(nextTransactions); + storageHelper.setItem('transactions', JSON.stringify(nextTransactions)); + if (nextFunds.length) { const codes = Array.from(new Set(nextFunds.map((f) => f.code))); if (codes.length) await refreshAll(codes); @@ -2344,6 +2474,7 @@ export default function HomePage() { viewMode: localStorage.getItem('viewMode') === 'list' ? 'list' : 'card', holdings: JSON.parse(localStorage.getItem('holdings') || '{}'), pendingTrades: JSON.parse(localStorage.getItem('pendingTrades') || '[]'), + transactions: JSON.parse(localStorage.getItem('transactions') || '{}'), exportedAt: nowInTz().toISOString() }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); @@ -2463,6 +2594,20 @@ export default function HomePage() { storageHelper.setItem('holdings', JSON.stringify(mergedHoldings)); } + if (data.transactions && typeof data.transactions === 'object') { + const currentTransactions = JSON.parse(localStorage.getItem('transactions') || '{}'); + const mergedTransactions = { ...currentTransactions }; + Object.entries(data.transactions).forEach(([code, txs]) => { + if (!Array.isArray(txs)) return; + const existing = mergedTransactions[code] || []; + const existingIds = new Set(existing.map(t => t.id)); + const newTxs = txs.filter(t => !existingIds.has(t.id)); + mergedTransactions[code] = [...existing, ...newTxs].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); + }); + setTransactions(mergedTransactions); + storageHelper.setItem('transactions', JSON.stringify(mergedTransactions)); + } + if (Array.isArray(data.pendingTrades)) { const existingPending = Array.isArray(currentPendingTrades) ? currentPendingTrades : []; const incomingPending = data.pendingTrades.filter((trade) => trade && trade.fundCode); @@ -2579,40 +2724,62 @@ export default function HomePage() {
{refreshing &&
}
0) ? 'search-focused-sibling' : ''}`}> - - - - +
+ {/* 同步中图标 */} + + + + + + {/* 默认图标 */} + + + + +
基估宝 - - {isSyncing && ( - - - - - - - - )} -
0) ? 'search-focused' : ''}`} role="region" aria-label="添加基金">
@@ -2736,6 +2903,7 @@ export default function HomePage() {
)} + {!isMobile && 项目Github地址 window.open("https://github.com/hzm0321/real-time-fund")} />} {isMobile && (