feat: 新增交易历史并发布0.1.6版本

This commit is contained in:
hzm
2026-02-23 23:24:24 +08:00
parent 8c7465b9c8
commit c94e7bedac
8 changed files with 798 additions and 69 deletions

View File

@@ -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 (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="添加历史记录"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex: 1200 }}
>
<motion.div
className="glass card modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
style={{ maxWidth: '420px' }}
onClick={(e) => e.stopPropagation()}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between' }}>
<span>添加历史记录</span>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon />
</button>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: '14px', fontWeight: 600 }}>{fund?.name}</div>
<div className="muted" style={{ fontSize: '12px' }}>{fund?.code}</div>
</div>
<div className="form-group" style={{ marginBottom: 16 }}>
<label className="label">
交易类型 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<div style={{ display: 'flex', gap: 8 }}>
<label
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 8,
border: type === 'buy' ? '1px solid var(--primary)' : '1px solid var(--border)',
background: type === 'buy' ? 'rgba(34,211,238,0.08)' : 'transparent',
cursor: 'pointer',
fontSize: '13px'
}}
>
<input
type="radio"
name="history-type"
value="buy"
checked={type === 'buy'}
onChange={(e) => setType(e.target.value)}
style={{ accentColor: 'var(--primary)' }}
/>
<span>买入</span>
</label>
<label
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
padding: '6px 10px',
borderRadius: 8,
border: type === 'sell' ? '1px solid var(--danger)' : '1px solid var(--border)',
background: type === 'sell' ? 'rgba(248,113,113,0.08)' : 'transparent',
cursor: 'pointer',
fontSize: '13px'
}}
>
<input
type="radio"
name="history-type"
value="sell"
checked={type === 'sell'}
onChange={(e) => setType(e.target.value)}
style={{ accentColor: 'var(--danger)' }}
/>
<span>卖出</span>
</label>
</div>
</div>
<div className="form-group" style={{ marginBottom: 16 }}>
<label className="label">
交易日期 <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<DatePicker value={date} onChange={setDate} />
{loading && <div className="muted" style={{ fontSize: '12px', marginTop: 4 }}>正在获取净值...</div>}
{error && <div style={{ fontSize: '12px', color: 'var(--danger)', marginTop: 4 }}>{error}</div>}
{netValue && !loading && (
<div style={{ fontSize: '12px', color: 'var(--success)', marginTop: 4 }}>
参考净值: {netValue} ({netValueDate})
</div>
)}
</div>
<div className="form-group" style={{ marginBottom: 24 }}>
<label className="label">
金额 (¥) <span style={{ color: 'var(--danger)' }}>*</span>
</label>
<input
type="number"
className="input"
value={amount}
onChange={handleAmountChange}
placeholder="0.00"
step="0.01"
disabled={!netValue}
/>
</div>
<button
className="button primary full-width"
onClick={handleSubmit}
disabled={!type || !date || !netValue || !amount || !share || loading}
>
确认添加
</button>
</motion.div>
</motion.div>
);
}

View File

@@ -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() {
<span>公告</span>
</div>
<div style={{ color: 'var(--text)', lineHeight: '1.6', fontSize: '15px' }}>
因为节前放假因素所以节前不会有大的功能更新调整
综合目前大家的需求以下功能将会在节后上线
为了增加更多用户方便访问, 新增国内加速地址<a className="link-button"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--primary)', textDecoration: 'underline', padding: '0 4px', fontWeight: 600 }} href="https://fund.cc.cd/">https://fund.cc.cd/</a>
<p>节后第一次更新内容如下</p>
<p>1. OCR 识别截图导入基金</p>
<p>2. 基金历史曲线图</p>
<p>3. 买入卖出历史记录</p>
以下内容会在近期更新
<p>1. 定投</p>
<p>2. 自定义内容展示布局</p>
<p>3. 基金历史曲线图</p>
<p>4. 基金实时估值曲线</p>
<p>5. OCR 识别截图导入基金</p>
<p>2. 自定义布局</p>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '8px' }}>

View File

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

View File

@@ -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 (
<motion.div
className="modal-overlay"
@@ -27,6 +27,29 @@ export default function HoldingActionModal({ fund, onClose, onAction }) {
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<SettingsIcon width="20" height="20" />
<span>持仓操作</span>
{hasHistory && (
<button
type="button"
onClick={() => onAction('history')}
style={{
marginLeft: 8,
padding: '4px 8px',
fontSize: '12px',
background: 'rgba(255,255,255,0.1)',
border: 'none',
borderRadius: '4px',
color: 'var(--text)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 4
}}
title="查看交易记录"
>
<span>📜</span>
<span>记录</span>
</button>
)}
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />

View File

@@ -134,7 +134,7 @@ export default function TradeModal({ type, fund, holding, onClose, onConfirm, pe
const handleFinalConfirm = () => {
if (isBuy) {
onConfirm({ share: calcShare === '待确认' ? null : Number(calcShare), price: Number(price), totalCost: Number(amount), date, isAfter3pm, feeRate: Number(feeRate) });
onConfirm({ share: calcShare === '待确认' ? null : Number(calcShare), price: Number(price), totalCost: Number(amount), date: actualDate || date, isAfter3pm, feeRate: Number(feeRate) });
return;
}
onConfirm({ share: Number(share), price: Number(price), date: actualDate || date, isAfter3pm, feeMode, feeValue });

View File

@@ -0,0 +1,179 @@
'use client';
import { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { CloseIcon } from './Icons';
import ConfirmModal from './ConfirmModal';
export default function TransactionHistoryModal({
fund,
transactions = [],
pendingTransactions = [],
onClose,
onDeleteTransaction,
onDeletePending,
onAddHistory
}) {
const [deleteConfirm, setDeleteConfirm] = useState(null); // { type: 'pending' | 'history', item }
// Combine and sort logic if needed, but requirements say "sorted by transaction time".
// Pending transactions are usually "future" or "processing", so they go on top.
// Completed transactions are sorted by date desc.
const sortedTransactions = useMemo(() => {
return [...transactions].sort((a, b) => b.timestamp - a.timestamp);
}, [transactions]);
const handleDeleteClick = (item, type) => {
setDeleteConfirm({ type, item });
};
const handleConfirmDelete = () => {
if (!deleteConfirm) return;
const { type, item } = deleteConfirm;
if (type === 'pending') {
onDeletePending(item.id);
} else {
onDeleteTransaction(item.id);
}
setDeleteConfirm(null);
};
return (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
onClick={onClose}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex: 1100 }} // Higher than TradeModal if stacked, but usually TradeModal closes or this opens on top
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '480px', maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
>
<div className="title" style={{ marginBottom: 20, justifyContent: 'space-between', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '20px' }}>📜</span>
<span>交易记录</span>
</div>
<button className="icon-button" onClick={onClose} style={{ border: 'none', background: 'transparent' }}>
<CloseIcon width="20" height="20" />
</button>
</div>
<div style={{ marginBottom: 16, flexShrink: 0, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div className="fund-name" style={{ fontWeight: 600, fontSize: '16px', marginBottom: 4 }}>{fund?.name}</div>
<div className="muted" style={{ fontSize: '12px' }}>#{fund?.code}</div>
</div>
<button
className="button primary"
onClick={onAddHistory}
style={{ fontSize: '12px', padding: '4px 12px', height: 'auto' }}
>
添加记录
</button>
</div>
<div style={{ overflowY: 'auto', flex: 1, paddingRight: 4 }}>
{/* Pending Transactions */}
{pendingTransactions.length > 0 && (
<div style={{ marginBottom: 20 }}>
<div className="muted" style={{ fontSize: '12px', marginBottom: 8, paddingLeft: 4 }}>待处理队列</div>
{pendingTransactions.map((item) => (
<div key={item.id} style={{ background: 'rgba(230, 162, 60, 0.1)', border: '1px solid rgba(230, 162, 60, 0.2)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
{item.type === 'buy' ? '买入' : '卖出'}
</span>
<span className="muted" style={{ fontSize: '12px' }}>{item.date} {item.isAfter3pm ? '(15:00后)' : ''}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px' }}>
<span className="muted">份额/金额</span>
<span>{item.share ? `${Number(item.share).toFixed(2)}` : `¥${Number(item.amount).toFixed(2)}`}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
<span style={{ color: '#e6a23c' }}>等待净值更新...</span>
<button
className="button secondary"
onClick={() => handleDeleteClick(item, 'pending')}
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto', background: 'rgba(255,255,255,0.1)' }}
>
撤销
</button>
</div>
</div>
))}
</div>
)}
{/* History Transactions */}
<div>
<div className="muted" style={{ fontSize: '12px', marginBottom: 8, paddingLeft: 4 }}>历史记录</div>
{sortedTransactions.length === 0 ? (
<div className="muted" style={{ textAlign: 'center', padding: '20px 0', fontSize: '12px' }}>暂无历史交易记录</div>
) : (
sortedTransactions.map((item) => (
<div key={item.id} style={{ background: 'rgba(255, 255, 255, 0.05)', borderRadius: 8, padding: 12, marginBottom: 8 }}>
<div className="row" style={{ justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontWeight: 600, fontSize: '14px', color: item.type === 'buy' ? 'var(--danger)' : 'var(--success)' }}>
{item.type === 'buy' ? '买入' : '卖出'}
</span>
<span className="muted" style={{ fontSize: '12px' }}>{item.date}</span>
</div>
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginBottom: 2 }}>
<span className="muted">成交份额</span>
<span>{Number(item.share).toFixed(2)} </span>
</div>
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginBottom: 2 }}>
<span className="muted">成交金额</span>
<span>¥{Number(item.amount).toFixed(2)}</span>
</div>
{item.price && (
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginBottom: 2 }}>
<span className="muted">成交净值</span>
<span>{Number(item.price).toFixed(4)}</span>
</div>
)}
<div className="row" style={{ justifyContent: 'space-between', fontSize: '12px', marginTop: 8 }}>
<span className="muted"></span>
<button
className="button secondary"
onClick={() => handleDeleteClick(item, 'history')}
style={{ padding: '2px 8px', fontSize: '10px', height: 'auto', background: 'rgba(255,255,255,0.1)', color: 'var(--muted)' }}
>
删除记录
</button>
</div>
</div>
))
)}
</div>
</div>
</motion.div>
<AnimatePresence>
{deleteConfirm && (
<ConfirmModal
key="delete-confirm"
title={deleteConfirm.type === 'pending' ? "撤销交易" : "删除记录"}
message={deleteConfirm.type === 'pending'
? "确定要撤销这笔待处理交易吗?"
: "确定要删除这条交易记录吗?\n注意删除记录不会恢复已变更的持仓数据。"}
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteConfirm(null)}
confirmText="确认删除"
/>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@@ -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() {
<div className="navbar glass" ref={navbarRef}>
{refreshing && <div className="loading-bar"></div>}
<div className={`brand ${(isSearchFocused || selectedFunds.length > 0) ? 'search-focused-sibling' : ''}`}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="var(--accent)" strokeWidth="2" />
<path d="M5 14c2-4 7-6 14-5" stroke="var(--primary)" strokeWidth="2" />
</svg>
<div
style={{
width: 24,
height: 24,
marginRight: 4,
position: 'relative',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
title={isSyncing ? '正在同步到云端...' : undefined}
>
{/* 同步中图标 */}
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
position: 'absolute',
inset: 0,
margin: 'auto',
opacity: isSyncing ? 1 : 0,
transform: isSyncing ? 'translateY(0px)' : 'translateY(4px)',
transition: 'opacity 0.25s ease, transform 0.25s ease',
}}
>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" stroke="var(--primary)" />
<path d="M12 12v9" stroke="var(--accent)" />
<path d="m16 16-4-4-4 4" stroke="var(--accent)" />
</svg>
{/* 默认图标 */}
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
style={{
position: 'absolute',
inset: 0,
margin: 'auto',
opacity: isSyncing ? 0 : 1,
transform: isSyncing ? 'translateY(-4px)' : 'translateY(0px)',
transition: 'opacity 0.25s ease, transform 0.25s ease',
}}
>
<circle cx="12" cy="12" r="10" stroke="var(--accent)" strokeWidth="2" />
<path d="M5 14c2-4 7-6 14-5" stroke="var(--primary)" strokeWidth="2" />
</svg>
</div>
<span>基估宝</span>
<AnimatePresence>
{isSyncing && (
<motion.div
key="sync-icon"
initial={{ opacity: 0, width: 0, marginLeft: 0 }}
animate={{ opacity: 1, width: 'auto', marginLeft: 8 }}
exit={{ opacity: 0, width: 0, marginLeft: 0 }}
style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', height: 24 }}
title="正在同步到云端..."
>
<motion.svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" stroke="var(--primary)" />
<path d="M12 12v9" stroke="var(--accent)" />
<path d="m16 16-4-4-4 4" stroke="var(--accent)" />
</motion.svg>
</motion.div>
)}
</AnimatePresence>
</div>
<div className={`glass add-fund-section navbar-add-fund ${(isSearchFocused || selectedFunds.length > 0) ? 'search-focused' : ''}`} role="region" aria-label="添加基金">
<div className="search-container" ref={dropdownRef}>
@@ -2736,6 +2903,7 @@ export default function HomePage() {
<UpdateIcon width="14" height="14" />
</div>
)}
{!isMobile && <Image unoptimized alt="项目Github地址" src={githubImg} style={{ width: '30px', height: '30px', cursor: 'pointer' }} onClick={() => window.open("https://github.com/hzm0321/real-time-fund")} />}
{isMobile && (
<button
className="icon-button mobile-search-btn"
@@ -3593,6 +3761,7 @@ export default function HomePage() {
code={f.code}
isExpanded={!collapsedTrends.has(f.code)}
onToggleExpand={() => toggleTrendCollapse(f.code)}
transactions={transactions[f.code] || []}
/>
</>
)}
@@ -3726,6 +3895,7 @@ export default function HomePage() {
fund={actionModal.fund}
onClose={() => setActionModal({ open: false, fund: null })}
onAction={(type) => handleAction(type, actionModal.fund)}
hasHistory={!!transactions[actionModal.fund?.code]?.length}
/>
)}
</AnimatePresence>
@@ -3751,6 +3921,37 @@ export default function HomePage() {
)}
</AnimatePresence>
<AnimatePresence>
{addHistoryModal.open && (
<AddHistoryModal
fund={addHistoryModal.fund}
onClose={() => setAddHistoryModal({ open: false, fund: null })}
onConfirm={handleAddHistory}
/>
)}
</AnimatePresence>
<AnimatePresence>
{historyModal.open && (
<TransactionHistoryModal
fund={historyModal.fund}
transactions={transactions[historyModal.fund?.code] || []}
pendingTransactions={pendingTrades.filter(t => t.fundCode === historyModal.fund?.code)}
onClose={() => setHistoryModal({ open: false, fund: null })}
onDeleteTransaction={(id) => handleDeleteTransaction(historyModal.fund?.code, id)}
onAddHistory={() => setAddHistoryModal({ open: true, fund: historyModal.fund })}
onDeletePending={(id) => {
setPendingTrades(prev => {
const next = prev.filter(t => t.id !== id);
storageHelper.setItem('pendingTrades', JSON.stringify(next));
return next;
});
showToast('已撤销待处理交易', 'success');
}}
/>
)}
</AnimatePresence>
<AnimatePresence>
{clearConfirm && (
<ConfirmModal
@@ -3960,3 +4161,4 @@ export default function HomePage() {
</div>
);
}

View File

@@ -1,6 +1,6 @@
{
"name": "real-time-fund",
"version": "0.1.5",
"version": "0.1.6",
"private": true,
"scripts": {
"dev": "next dev",