feat: 新增交易历史并发布0.1.6版本
This commit is contained in:
209
app/components/AddHistoryModal.jsx
Normal file
209
app/components/AddHistoryModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 });
|
||||
|
||||
179
app/components/TransactionHistoryModal.jsx
Normal file
179
app/components/TransactionHistoryModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
app/page.jsx
272
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() {
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user