feat: 新增 beta 版实时估值分时图

This commit is contained in:
hzm
2026-02-24 11:38:34 +08:00
parent 1db379c048
commit 3d2fc36f69
3 changed files with 384 additions and 1 deletions

View File

@@ -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 (
<div style={{ marginTop: 12, marginBottom: 4 }}>
<div className="muted" style={{ fontSize: 11, marginBottom: 6, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
实时估值分时按刷新记录
<span
style={{
fontSize: 9,
padding: '1px 5px',
borderRadius: 4,
background: 'var(--primary)',
color: '#0f172a',
fontWeight: 600
}}
title="正在测试中的功能"
>
Beta
</span>
</span>
{displayDate && <span style={{ fontSize: 11 }}>估值日期 {displayDate}</span>}
</div>
<div style={{ position: 'relative', height: 100, width: '100%' }}>
<Line data={chartData} options={options} plugins={plugins} />
</div>
</div>
);
}

View File

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

View File

@@ -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)}% 持仓估算
</div>
)}
{Array.isArray(valuationSeries[f.code]) && valuationSeries[f.code].length >= 2 && (
<FundIntradayChart
series={valuationSeries[f.code]}
referenceNav={f.dwjz != null ? Number(f.dwjz) : undefined}
/>
)}
<div
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
className="title"