From 3d2fc36f69a8f9b1c680a80a17fb75d5381ee8f3 Mon Sep 17 00:00:00 2001
From: hzm <934585316@qq.com>
Date: Tue, 24 Feb 2026 11:38:34 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20beta=20=E7=89=88?=
=?UTF-8?q?=E5=AE=9E=E6=97=B6=E4=BC=B0=E5=80=BC=E5=88=86=E6=97=B6=E5=9B=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/components/FundIntradayChart.jsx | 198 +++++++++++++++++++++++++++
app/lib/valuationTimeseries.js | 122 +++++++++++++++++
app/page.jsx | 65 ++++++++-
3 files changed, 384 insertions(+), 1 deletion(-)
create mode 100644 app/components/FundIntradayChart.jsx
create mode 100644 app/lib/valuationTimeseries.js
diff --git a/app/components/FundIntradayChart.jsx b/app/components/FundIntradayChart.jsx
new file mode 100644
index 0000000..4d11516
--- /dev/null
+++ b/app/components/FundIntradayChart.jsx
@@ -0,0 +1,198 @@
+'use client';
+
+import { useMemo } from 'react';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Tooltip,
+ Filler
+} from 'chart.js';
+import { Line } from 'react-chartjs-2';
+
+ChartJS.register(
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Tooltip,
+ Filler
+);
+
+/**
+ * 分时图:展示当日(或最近一次记录日)的估值序列,纵轴为相对参考净值的涨跌幅百分比。
+ * series: Array<{ time: string, value: number, date?: string }>
+ * referenceNav: 参考净值(最新单位净值),用于计算涨跌幅;未传则用当日第一个估值作为参考。
+ */
+export default function FundIntradayChart({ series = [], referenceNav }) {
+ const chartData = useMemo(() => {
+ if (!series.length) return { labels: [], datasets: [] };
+ const labels = series.map((d) => d.time);
+ const values = series.map((d) => d.value);
+ const ref = referenceNav != null && Number.isFinite(Number(referenceNav))
+ ? Number(referenceNav)
+ : values[0];
+ const percentages = values.map((v) => (ref ? ((v - ref) / ref) * 100 : 0));
+ const lastPct = percentages[percentages.length - 1];
+ const riseColor = '#f87171'; // 涨用红色
+ const fallColor = '#34d399'; // 跌用绿色
+ // 以最新点相对参考净值的涨跌定色:涨(>=0)红,跌(<0)绿
+ const lineColor = lastPct != null && lastPct >= 0 ? riseColor : fallColor;
+
+ return {
+ labels,
+ datasets: [
+ {
+ type: 'line',
+ label: '涨跌幅',
+ data: percentages,
+ borderColor: lineColor,
+ backgroundColor: (ctx) => {
+ if (!ctx.chart.ctx) return lineColor + '33';
+ const gradient = ctx.chart.ctx.createLinearGradient(0, 0, 0, 120);
+ gradient.addColorStop(0, lineColor + '33');
+ gradient.addColorStop(1, lineColor + '00');
+ return gradient;
+ },
+ borderWidth: 2,
+ pointRadius: series.length <= 2 ? 3 : 0,
+ pointHoverRadius: 4,
+ fill: true,
+ tension: 0.2
+ }
+ ]
+ };
+ }, [series, referenceNav]);
+
+ const options = useMemo(() => ({
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: { mode: 'index', intersect: false },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ intersect: false,
+ external: () => {}
+ }
+ },
+ scales: {
+ x: {
+ display: true,
+ grid: { display: false },
+ ticks: {
+ color: '#9ca3af',
+ font: { size: 10 },
+ maxTicksLimit: 6
+ }
+ },
+ y: {
+ display: true,
+ position: 'right',
+ grid: { color: '#1f2937', drawBorder: false },
+ ticks: {
+ color: '#9ca3af',
+ font: { size: 10 },
+ callback: (v) => (typeof v === 'number' ? `${v >= 0 ? '+' : ''}${v.toFixed(2)}%` : v)
+ }
+ }
+ },
+ onHover: (event, chartElement) => {
+ event.native.target.style.cursor = chartElement[0] ? 'crosshair' : 'default';
+ }
+ }), []);
+
+ const plugins = useMemo(() => [{
+ id: 'crosshair',
+ afterDraw: (chart) => {
+ const ctx = chart.ctx;
+ const activeElements = chart.tooltip?._active?.length
+ ? chart.tooltip._active
+ : chart.getActiveElements();
+ if (!activeElements?.length) return;
+
+ const activePoint = activeElements[0];
+ const x = activePoint.element.x;
+ const y = activePoint.element.y;
+ const topY = chart.scales.y.top;
+ const bottomY = chart.scales.y.bottom;
+ const leftX = chart.scales.x.left;
+ const rightX = chart.scales.x.right;
+ const index = activePoint.index;
+ const labels = chart.data.labels;
+ const data = chart.data.datasets[0]?.data;
+
+ ctx.save();
+ ctx.setLineDash([3, 3]);
+ ctx.lineWidth = 1;
+ ctx.strokeStyle = '#9ca3af';
+ ctx.moveTo(x, topY);
+ ctx.lineTo(x, bottomY);
+ ctx.moveTo(leftX, y);
+ ctx.lineTo(rightX, y);
+ ctx.stroke();
+
+ const prim = typeof document !== 'undefined'
+ ? (getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || '#22d3ee')
+ : '#22d3ee';
+ const bgText = '#0f172a';
+
+ ctx.font = '10px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ if (labels && index in labels) {
+ const timeStr = String(labels[index]);
+ const tw = ctx.measureText(timeStr).width + 8;
+ ctx.fillStyle = prim;
+ ctx.fillRect(x - tw / 2, bottomY, tw, 16);
+ ctx.fillStyle = bgText;
+ ctx.fillText(timeStr, x, bottomY + 8);
+ }
+ if (data && index in data) {
+ const val = data[index];
+ const valueStr = typeof val === 'number' ? `${val >= 0 ? '+' : ''}${val.toFixed(2)}%` : String(val);
+ const vw = ctx.measureText(valueStr).width + 8;
+ ctx.fillStyle = prim;
+ ctx.fillRect(rightX - vw, y - 8, vw, 16);
+ ctx.fillStyle = bgText;
+ ctx.fillText(valueStr, rightX - vw / 2, y);
+ }
+ ctx.restore();
+ }
+ }], []);
+
+ if (series.length < 2) return null;
+
+ const displayDate = series[0]?.date || series[series.length - 1]?.date;
+
+ return (
+
+
+
+ 实时估值分时(按刷新记录)
+
+ Beta
+
+
+ {displayDate && 估值日期 {displayDate}}
+
+
+
+
+
+ );
+}
diff --git a/app/lib/valuationTimeseries.js b/app/lib/valuationTimeseries.js
new file mode 100644
index 0000000..7f51b0f
--- /dev/null
+++ b/app/lib/valuationTimeseries.js
@@ -0,0 +1,122 @@
+/**
+ * 记录每次调用基金估值接口的结果,用于分时图。
+ * 规则:获取到最新日期的数据时,清掉所有老日期的数据,只保留当日分时点。
+ */
+
+const STORAGE_KEY = 'fundValuationTimeseries';
+
+function getStored() {
+ if (typeof window === 'undefined' || !window.localStorage) return {};
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ const parsed = raw ? JSON.parse(raw) : {};
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+function setStored(data) {
+ if (typeof window === 'undefined' || !window.localStorage) return;
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
+ } catch (e) {
+ console.warn('valuationTimeseries persist failed', e);
+ }
+}
+
+/**
+ * 从 gztime 或 Date 得到日期字符串 YYYY-MM-DD
+ */
+function toDateStr(gztimeOrNow) {
+ if (typeof gztimeOrNow === 'string' && /^\d{4}-\d{2}-\d{2}/.test(gztimeOrNow)) {
+ return gztimeOrNow.slice(0, 10);
+ }
+ try {
+ const d = new Date();
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ return `${y}-${m}-${day}`;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * 记录一条估值。仅当 value 为有效数字时写入。
+ * 数据清理:若当前点所属日期大于已存点的最大日期,则清空该基金下所有旧日期的数据,只保留当日分时。
+ *
+ * @param {string} code - 基金代码
+ * @param {{ gsz?: number | null, gztime?: string | null }} payload - 估值与时间(来自接口)
+ * @returns {Array<{ time: string, value: number, date: string }>} 该基金当前分时序列(按时间升序)
+ */
+export function recordValuation(code, payload) {
+ const value = payload?.gsz != null ? Number(payload.gsz) : NaN;
+ if (!Number.isFinite(value)) return getValuationSeries(code);
+
+ const gztime = payload?.gztime ?? null;
+ const dateStr = toDateStr(gztime);
+ if (!dateStr) return getValuationSeries(code);
+
+ const timeLabel = typeof gztime === 'string' && gztime.length > 10
+ ? gztime.slice(11, 16)
+ : (() => {
+ const d = new Date();
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
+ })();
+
+ const newPoint = { time: timeLabel, value, date: dateStr };
+
+ const all = getStored();
+ const list = Array.isArray(all[code]) ? all[code] : [];
+
+ const existingDates = list.map((p) => p.date).filter(Boolean);
+ const latestStoredDate = existingDates.length ? existingDates.reduce((a, b) => (a > b ? a : b), '') : '';
+
+ let nextList;
+ if (dateStr > latestStoredDate) {
+ nextList = [newPoint];
+ } else if (dateStr === latestStoredDate) {
+ const hasSameTime = list.some((p) => p.time === timeLabel);
+ if (hasSameTime) return list;
+ nextList = [...list, newPoint];
+ } else {
+ return list;
+ }
+
+ all[code] = nextList;
+ setStored(all);
+ return nextList;
+}
+
+/**
+ * 获取某基金的分时序列(只读)
+ * @param {string} code - 基金代码
+ * @returns {Array<{ time: string, value: number, date: string }>}
+ */
+export function getValuationSeries(code) {
+ const all = getStored();
+ const list = Array.isArray(all[code]) ? all[code] : [];
+ return list;
+}
+
+/**
+ * 删除某基金的全部分时数据(如用户删除该基金时调用)
+ * @param {string} code - 基金代码
+ */
+export function clearFund(code) {
+ const all = getStored();
+ if (!(code in all)) return;
+ const next = { ...all };
+ delete next[code];
+ setStored(next);
+}
+
+/**
+ * 获取全部分时数据,用于页面初始 state
+ * @returns {{ [code: string]: Array<{ time: string, value: number, date: string }> }}
+ */
+export function getAllValuationSeries() {
+ return getStored();
+}
diff --git a/app/page.jsx b/app/page.jsx
index 689bfc1..3d44549 100644
--- a/app/page.jsx
+++ b/app/page.jsx
@@ -12,6 +12,7 @@ import timezone from 'dayjs/plugin/timezone';
import Announcement from "./components/Announcement";
import { Stat } from "./components/Common";
import FundTrendChart from "./components/FundTrendChart";
+import FundIntradayChart from "./components/FundIntradayChart";
import { ChevronIcon, CloseIcon, ExitIcon, EyeIcon, EyeOffIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, PinIcon, PinOffIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, TrashIcon, UpdateIcon, UserIcon, CameraIcon } from "./components/Icons";
import AddFundToGroupModal from "./components/AddFundToGroupModal";
import AddResultModal from "./components/AddResultModal";
@@ -37,6 +38,7 @@ import UpdatePromptModal from "./components/UpdatePromptModal";
import WeChatModal from "./components/WeChatModal";
import githubImg from "./assets/github.svg";
import { supabase, isSupabaseConfigured } from './lib/supabase';
+import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries';
import { fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds } from './api/fund';
import packageJson from '../package.json';
@@ -297,6 +299,9 @@ export default function HomePage() {
const [collapsedCodes, setCollapsedCodes] = useState(new Set());
const [collapsedTrends, setCollapsedTrends] = useState(new Set()); // New state for collapsed trend charts
+ // 估值分时序列(每次调用估值接口记录,用于分时图)
+ const [valuationSeries, setValuationSeries] = useState(() => (typeof window !== 'undefined' ? getAllValuationSeries() : {}));
+
// 自选状态
const [favorites, setFavorites] = useState(new Set());
const [groups, setGroups] = useState([]); // [{ id, name, codes: [] }]
@@ -1182,6 +1187,13 @@ export default function HomePage() {
storageHelper.setItem('funds', JSON.stringify(updated));
return updated;
});
+ const nextSeries = {};
+ newFunds.forEach(u => {
+ if (u?.code != null && !u.noValuation && Number.isFinite(Number(u.gsz))) {
+ nextSeries[u.code] = recordValuation(u.code, { gsz: u.gsz, gztime: u.gztime });
+ }
+ });
+ if (Object.keys(nextSeries).length > 0) setValuationSeries(prev => ({ ...prev, ...nextSeries }));
setSuccessModal({ open: true, message: `成功导入 ${successCount} 个基金` });
} else {
if (codes.length > 0 && successCount === 0 && failedCount === 0) {
@@ -1267,6 +1279,7 @@ export default function HomePage() {
}, []);
const storageHelper = useMemo(() => {
+ // 仅以下 key 参与云端同步;fundValuationTimeseries 不同步到云端(测试中功能,暂不同步)
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'transactions', 'viewMode']);
const triggerSync = (key, prevValue, nextValue) => {
if (keys.has(key)) {
@@ -1313,6 +1326,7 @@ export default function HomePage() {
}, [getFundCodesSignature, scheduleSync]);
useEffect(() => {
+ // 仅以下 key 的变更会触发云端同步;fundValuationTimeseries 不在其中
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']);
const onStorage = (e) => {
if (!e.key) return;
@@ -1492,6 +1506,8 @@ export default function HomePage() {
if (Array.isArray(savedTrends)) {
setCollapsedTrends(new Set(savedTrends));
}
+ // 加载估值分时记录(用于分时图)
+ setValuationSeries(getAllValuationSeries());
// 加载自选状态
const savedFavorites = JSON.parse(localStorage.getItem('favorites') || '[]');
if (Array.isArray(savedFavorites)) {
@@ -1853,6 +1869,13 @@ export default function HomePage() {
storageHelper.setItem('funds', JSON.stringify(deduped));
return deduped;
});
+ const nextSeries = {};
+ added.forEach(u => {
+ if (u?.code != null && !u.noValuation && Number.isFinite(Number(u.gsz))) {
+ nextSeries[u.code] = recordValuation(u.code, { gsz: u.gsz, gztime: u.gztime });
+ }
+ });
+ if (Object.keys(nextSeries).length > 0) setValuationSeries(prev => ({ ...prev, ...nextSeries }));
setSuccessModal({ open: true, message: `已导入 ${added.length} 只基金` });
} else {
setSuccessModal({ open: true, message: '未能导入任何基金,请检查截图清晰度' });
@@ -1883,6 +1906,13 @@ export default function HomePage() {
const updated = dedupeByCode([...newFunds, ...funds]);
setFunds(updated);
storageHelper.setItem('funds', JSON.stringify(updated));
+ const nextSeries = {};
+ newFunds.forEach(u => {
+ if (u?.code != null && !u.noValuation && Number.isFinite(Number(u.gsz))) {
+ nextSeries[u.code] = recordValuation(u.code, { gsz: u.gsz, gztime: u.gztime });
+ }
+ });
+ if (Object.keys(nextSeries).length > 0) setValuationSeries(prev => ({ ...prev, ...nextSeries }));
}
setSelectedFunds([]);
@@ -1933,6 +1963,17 @@ export default function HomePage() {
storageHelper.setItem('funds', JSON.stringify(deduped));
return deduped;
});
+ // 记录估值分时:每次刷新写入一条,新日期到来时自动清掉老日期数据
+ const nextSeries = {};
+ updated.forEach(u => {
+ if (u?.code != null && !u.noValuation && Number.isFinite(Number(u.gsz))) {
+ const val = recordValuation(u.code, { gsz: u.gsz, gztime: u.gztime });
+ nextSeries[u.code] = val;
+ }
+ });
+ if (Object.keys(nextSeries).length > 0) {
+ setValuationSeries(prev => ({ ...prev, ...nextSeries }));
+ }
}
} catch (e) {
console.error(e);
@@ -1999,6 +2040,13 @@ export default function HomePage() {
const next = dedupeByCode([...newFunds, ...funds]);
setFunds(next);
storageHelper.setItem('funds', JSON.stringify(next));
+ const nextSeries = {};
+ newFunds.forEach(u => {
+ if (u?.code != null && !u.noValuation && Number.isFinite(Number(u.gsz))) {
+ nextSeries[u.code] = recordValuation(u.code, { gsz: u.gsz, gztime: u.gztime });
+ }
+ });
+ if (Object.keys(nextSeries).length > 0) setValuationSeries(prev => ({ ...prev, ...nextSeries }));
}
setSearchTerm('');
setSelectedFunds([]);
@@ -2081,6 +2129,15 @@ export default function HomePage() {
storageHelper.setItem('transactions', JSON.stringify(next));
return next;
});
+
+ // 同步删除该基金的估值分时数据
+ clearFund(removeCode);
+ setValuationSeries(prev => {
+ if (!(removeCode in prev)) return prev;
+ const next = { ...prev };
+ delete next[removeCode];
+ return next;
+ });
};
const manualRefresh = async () => {
@@ -2230,7 +2287,7 @@ export default function HomePage() {
const collectLocalPayload = (keys = null) => {
try {
const all = {};
-
+ // 不包含 fundValuationTimeseries,该数据暂不同步到云端
if (!keys || keys.has('funds')) {
all.funds = JSON.parse(localStorage.getItem('funds') || '[]');
}
@@ -3772,6 +3829,12 @@ export default function HomePage() {
基于 {Math.round(f.estPricedCoverage * 100)}% 持仓估算
)}
+ {Array.isArray(valuationSeries[f.code]) && valuationSeries[f.code].length >= 2 && (
+
+ )}