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 && ( + + )}