From c94e7bedacdfddaeccfa851ed4d8c55b057bc698 Mon Sep 17 00:00:00 2001
From: hzm <934585316@qq.com>
Date: Mon, 23 Feb 2026 23:24:24 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BA=A4=E6=98=93?=
=?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=B9=B6=E5=8F=91=E5=B8=830.1.6=E7=89=88?=
=?UTF-8?q?=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/components/AddHistoryModal.jsx | 209 ++++++++++++++++
app/components/Announcement.jsx | 18 +-
app/components/FundTrendChart.jsx | 160 ++++++++++--
app/components/HoldingActionModal.jsx | 25 +-
app/components/TradeModal.jsx | 2 +-
app/components/TransactionHistoryModal.jsx | 179 ++++++++++++++
app/page.jsx | 272 ++++++++++++++++++---
package.json | 2 +-
8 files changed, 798 insertions(+), 69 deletions(-)
create mode 100644 app/components/AddHistoryModal.jsx
create mode 100644 app/components/TransactionHistoryModal.jsx
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 && (
+
+ )}