feat: 新增 beta 版实时估值分时图
This commit is contained in:
198
app/components/FundIntradayChart.jsx
Normal file
198
app/components/FundIntradayChart.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
app/lib/valuationTimeseries.js
Normal file
122
app/lib/valuationTimeseries.js
Normal 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();
|
||||
}
|
||||
65
app/page.jsx
65
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)}% 持仓估算
|
||||
</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"
|
||||
|
||||
Reference in New Issue
Block a user