From cd3972400196bf25d7891d08e2f98a0dffce8578 Mon Sep 17 00:00:00 2001 From: hzm <934585316@qq.com> Date: Tue, 17 Feb 2026 18:13:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=9F=BA=E9=87=91?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=87=80=E5=80=BC=E8=B6=8B=E5=8A=BF=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/fund.js | 85 ++++++++ app/components/FundTrendChart.jsx | 349 ++++++++++++++++++++++++++++++ app/lib/cacheRequest.js | 52 +++++ app/page.jsx | 57 ++++- package-lock.json | 30 +++ package.json | 2 + 6 files changed, 572 insertions(+), 3 deletions(-) create mode 100644 app/components/FundTrendChart.jsx create mode 100644 app/lib/cacheRequest.js diff --git a/app/api/fund.js b/app/api/fund.js index d5805b5..a0014d8 100644 --- a/app/api/fund.js +++ b/app/api/fund.js @@ -412,3 +412,88 @@ export const submitFeedback = async (formData) => { }); return response.json(); }; + +let historyQueue = Promise.resolve(); + +export const fetchFundHistory = async (code, range = '1m') => { + if (typeof window === 'undefined') return []; + + const end = nowInTz(); + let start = end.clone(); + + switch (range) { + case '1m': start = start.subtract(1, 'month'); break; + case '3m': start = start.subtract(3, 'month'); break; + case '6m': start = start.subtract(6, 'month'); break; + case '1y': start = start.subtract(1, 'year'); break; + case '3y': start = start.subtract(3, 'year'); break; + default: start = start.subtract(1, 'month'); + } + + const sdate = start.format('YYYY-MM-DD'); + const edate = end.format('YYYY-MM-DD'); + const per = 49; + + return new Promise((resolve) => { + historyQueue = historyQueue.then(async () => { + let allData = []; + let page = 1; + let totalPages = 1; + + try { + const parseContent = (content) => { + if (!content) return []; + const rows = content.split(''); + const data = []; + for (const row of rows) { + const cells = row.match(/]*>(.*?)<\/td>/g); + if (cells && cells.length >= 2) { + const dateStr = cells[0].replace(/<[^>]+>/g, '').trim(); + const valStr = cells[1].replace(/<[^>]+>/g, '').trim(); + const val = parseFloat(valStr); + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !isNaN(val)) { + data.push({ date: dateStr, value: val }); + } + } + } + return data; + }; + + // Fetch first page to get metadata + const firstUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`; + await loadScript(firstUrl); + + if (!window.apidata || !window.apidata.content || window.apidata.content.includes('暂无数据')) { + resolve([]); + return; + } + + // Parse total pages + if (window.apidata.pages) { + totalPages = parseInt(window.apidata.pages, 10) || 1; + } + + allData = allData.concat(parseContent(window.apidata.content)); + + // Fetch remaining pages + for (page = 2; page <= totalPages; page++) { + const nextUrl = `https://fundf10.eastmoney.com/F10DataApi.aspx?type=lsjz&code=${code}&page=${page}&per=${per}&sdate=${sdate}&edate=${edate}`; + await loadScript(nextUrl); + if (window.apidata && window.apidata.content) { + allData = allData.concat(parseContent(window.apidata.content)); + } + } + + // The data comes in reverse chronological order (newest first), so we need to reverse it for the chart (oldest first) + resolve(allData.reverse()); + + } catch (e) { + console.error('Fetch history error:', e); + resolve([]); + } + }).catch((e) => { + console.error('Queue error:', e); + resolve([]); + }); + }); +}; diff --git a/app/components/FundTrendChart.jsx b/app/components/FundTrendChart.jsx new file mode 100644 index 0000000..ca82ab7 --- /dev/null +++ b/app/components/FundTrendChart.jsx @@ -0,0 +1,349 @@ +'use client'; + +import { useState, useEffect, useMemo, useRef } from 'react'; +import { fetchFundHistory } from '../api/fund'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChevronIcon } from './Icons'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler +} from 'chart.js'; +import { Line } from 'react-chartjs-2'; +import {cachedRequest} from "../lib/cacheRequest"; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + Filler +); + +export default function FundTrendChart({ code, isExpanded, onToggleExpand }) { + const [range, setRange] = useState('1m'); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const chartRef = useRef(null); + + useEffect(() => { + // If collapsed, don't fetch data unless we have no data yet + if (!isExpanded && data.length > 0) return; + + let active = true; + setLoading(true); + setError(null); + const cacheKey = `fund_history_${code}_${range}`; + + if (isExpanded) { + cachedRequest( + () => fetchFundHistory(code, range), + cacheKey, + { cacheTime: 10 * 60 * 1000 } + ) + .then(res => { + if (active) { + setData(res || []); + setLoading(false); + } + }) + .catch(err => { + if (active) { + setError(err); + setLoading(false); + } + }); + + } + return () => { active = false; }; + }, [code, range, isExpanded]); + + const ranges = [ + { label: '近1月', value: '1m' }, + { label: '近3月', value: '3m' }, + { label: '近6月', value: '6m' }, + { label: '近1年', value: '1y' }, + ]; + + const change = useMemo(() => { + if (!data.length) return 0; + const first = data[0].value; + const last = data[data.length - 1].value; + return ((last - first) / first) * 100; + }, [data]); + + // Red for up, Green for down (CN market style) + // Hardcoded hex values from globals.css for Chart.js + const upColor = '#f87171'; // --danger + const downColor = '#34d399'; // --success + const lineColor = change >= 0 ? upColor : downColor; + + const chartData = useMemo(() => { + return { + labels: data.map(d => d.date), + datasets: [ + { + label: '单位净值', + data: data.map(d => d.value), + borderColor: lineColor, + backgroundColor: (context) => { + const ctx = context.chart.ctx; + const gradient = ctx.createLinearGradient(0, 0, 0, 200); + gradient.addColorStop(0, `${lineColor}33`); // 20% opacity + gradient.addColorStop(1, `${lineColor}00`); // 0% opacity + return gradient; + }, + borderWidth: 2, + pointRadius: 0, + pointHoverRadius: 4, + fill: true, + tension: 0.2 + } + ] + }; + }, [data, lineColor]); + + const options = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + tooltip: { + enabled: false, // 禁用默认 Tooltip,使用自定义绘制 + mode: 'index', + intersect: false, + external: () => {} // 禁用外部 HTML tooltip + } + }, + scales: { + x: { + display: true, + grid: { + display: false, + drawBorder: false + }, + ticks: { + color: '#9ca3af', + font: { size: 10 }, + maxTicksLimit: 4, + maxRotation: 0 + }, + border: { display: false } + }, + y: { + display: true, + position: 'right', + grid: { + color: '#1f2937', + drawBorder: false, + tickLength: 0 + }, + ticks: { + color: '#9ca3af', + font: { size: 10 }, + count: 5 + }, + border: { display: false } + } + }, + interaction: { + mode: 'index', + intersect: false, + }, + onHover: (event, chartElement) => { + event.native.target.style.cursor = chartElement[0] ? 'crosshair' : 'default'; + } + }; + }, []); + + 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]; + } + } + + if (activePoint) { + const ctx = chart.ctx; + 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; + + ctx.save(); + ctx.beginPath(); + ctx.setLineDash([3, 3]); + ctx.lineWidth = 1; + ctx.strokeStyle = '#9ca3af'; + + // Draw vertical line + ctx.moveTo(x, topY); + ctx.lineTo(x, bottomY); + + // Draw horizontal 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 是当前数据集中的索引 + 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]; + const value = datasets[datasetIndex].data[index]; + + if (dateStr !== undefined && value !== undefined) { + // X axis label (date) + const textWidth = ctx.measureText(dateStr).width + 8; + ctx.fillStyle = primaryColor; + ctx.fillRect(x - textWidth / 2, bottomY, textWidth, 16); + ctx.fillStyle = '#0f172a'; // --background + ctx.fillText(dateStr, x, bottomY + 8); + + // Y axis label (value) + const valueStr = typeof value === 'number' ? value.toFixed(4) : value; + const valWidth = ctx.measureText(valueStr).width + 8; + ctx.fillStyle = primaryColor; + ctx.fillRect(rightX - valWidth, y - 8, valWidth, 16); + ctx.fillStyle = '#0f172a'; // --background + ctx.textAlign = 'center'; + ctx.fillText(valueStr, rightX - valWidth / 2, y); + } + } + + ctx.restore(); + } + } + }], []); // 移除 data 依赖,因为我们直接从 chart 实例读取数据 + + return ( +
e.stopPropagation()}> +
+
+
+ 业绩走势 + +
+ {data.length > 0 && ( +
+ {ranges.find(r => r.value === range)?.label}涨跌幅 + + {change > 0 ? '+' : ''}{change.toFixed(2)}% + +
+ )} +
+
+ + + {isExpanded && ( + +
+ {loading && ( +
+ 加载中... +
+ )} + + {!loading && data.length === 0 && ( +
+ 暂无数据 +
+ )} + + {data.length > 0 && ( + + )} +
+ +
+ {ranges.map(r => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/app/lib/cacheRequest.js b/app/lib/cacheRequest.js new file mode 100644 index 0000000..58b8341 --- /dev/null +++ b/app/lib/cacheRequest.js @@ -0,0 +1,52 @@ + +const _cacheResultMap = new Map(); +const _cachePendingPromiseMap = new Map(); + +/** + * 对函数式写法的请求进行缓存 + * @param request + * @param cacheKey + * @param options + */ +export async function cachedRequest( + request, + cacheKey, + options +) { + const { + cacheResultMap = _cacheResultMap, + cachePendingPromiseMap = _cachePendingPromiseMap, + cacheTime = 1000 * 10, + } = options ?? {}; + let result; + // 如果有缓存直接返回 + if (cacheResultMap.has(cacheKey)) { + result = cacheResultMap.get(cacheKey); + } else if (cachePendingPromiseMap.has(cacheKey)) { + // 如果没有缓存,需要判断是否存在相同正在发起的请求 + result = await cachePendingPromiseMap.get(cacheKey); + } else { + // 发起真实请求 + cachePendingPromiseMap.set(cacheKey, request()); + result = await cachePendingPromiseMap.get(cacheKey); + cacheResultMap.set(cacheKey, result); + // 设置清除缓存时间 + if (cacheTime > 0) { + setTimeout(() => { + cacheResultMap.delete(cacheKey); + }, cacheTime); + } + // 得到请求结果后 清理请求 + cachePendingPromiseMap.delete(cacheKey); + } + return result; +} + +/** + * 清除缓存的请求结果 + * @param key + */ +export function clearCachedRequest(key) { + _cacheResultMap.delete(key); + _cachePendingPromiseMap.delete(key); +} diff --git a/app/page.jsx b/app/page.jsx index 55e97ec..097ccff 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -9,6 +9,7 @@ import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import Announcement from "./components/Announcement"; import { DatePicker, DonateTabs, NumericInput, Stat } from "./components/Common"; +import FundTrendChart from "./components/FundTrendChart"; import { ChevronIcon, CloseIcon, CloudIcon, DragIcon, ExitIcon, EyeIcon, EyeOffIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, MailIcon, PinIcon, PinOffIcon, PlusIcon, RefreshIcon, SettingsIcon, SortIcon, StarIcon, TrashIcon, UpdateIcon, UserIcon } from "./components/Icons"; import weChatGroupImg from "./assets/weChatGroup.png"; import { supabase, isSupabaseConfigured } from './lib/supabase'; @@ -1903,6 +1904,7 @@ export default function HomePage() { // 收起/展开状态 const [collapsedCodes, setCollapsedCodes] = useState(new Set()); + const [collapsedTrends, setCollapsedTrends] = useState(new Set()); // New state for collapsed trend charts // 自选状态 const [favorites, setFavorites] = useState(new Set()); @@ -1925,9 +1927,14 @@ export default function HomePage() { const [lastSyncTime, setLastSyncTime] = useState(null); useEffect(() => { + // 优先使用服务端返回的时间,如果没有则使用本地存储的时间 + // 这里只设置初始值,后续更新由接口返回的时间驱动 const stored = window.localStorage.getItem('localUpdatedAt'); if (stored) { setLastSyncTime(stored); + } else { + // 如果没有存储的时间,暂时设为 null,等待接口返回 + setLastSyncTime(null); } }, []); const [userMenuOpen, setUserMenuOpen] = useState(false); @@ -2484,7 +2491,7 @@ export default function HomePage() { }, []); const storageHelper = useMemo(() => { - const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']); + const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']); const triggerSync = (key, prevValue, nextValue) => { if (keys.has(key)) { if (key === 'funds') { @@ -2527,7 +2534,7 @@ export default function HomePage() { }, [getFundCodesSignature, scheduleSync]); useEffect(() => { - const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']); + const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'viewMode']); const onStorage = (e) => { if (!e.key) return; if (e.key === 'localUpdatedAt') { @@ -2582,6 +2589,19 @@ export default function HomePage() { }); }; + const toggleTrendCollapse = (code) => { + setCollapsedTrends(prev => { + const next = new Set(prev); + if (next.has(code)) { + next.delete(code); + } else { + next.add(code); + } + storageHelper.setItem('collapsedTrends', JSON.stringify(Array.from(next))); + return next; + }); + }; + const handleAddGroup = (name) => { const newGroup = { id: `group_${Date.now()}`, @@ -2688,6 +2708,11 @@ export default function HomePage() { if (Array.isArray(savedCollapsed)) { setCollapsedCodes(new Set(savedCollapsed)); } + // 加载业绩走势收起状态 + const savedTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]'); + if (Array.isArray(savedTrends)) { + setCollapsedTrends(new Set(savedTrends)); + } // 加载自选状态 const savedFavorites = JSON.parse(localStorage.getItem('favorites') || '[]'); if (Array.isArray(savedFavorites)) { @@ -2781,6 +2806,8 @@ export default function HomePage() { }); const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => { + // INITIAL_SESSION 会由 getSession() 主动触发,这里不再重复处理 + if (event === 'INITIAL_SESSION') return; await handleSession(session ?? null, event); }); @@ -3172,6 +3199,15 @@ export default function HomePage() { return nextSet; }); + // 同步删除业绩走势收起状态 + setCollapsedTrends(prev => { + if (!prev.has(removeCode)) return prev; + const nextSet = new Set(prev); + nextSet.delete(removeCode); + storageHelper.setItem('collapsedTrends', JSON.stringify(Array.from(nextSet))); + return nextSet; + }); + // 同步删除自选状态 setFavorites(prev => { if (!prev.has(removeCode)) return prev; @@ -3240,6 +3276,10 @@ export default function HomePage() { ? Array.from(new Set(payload.collapsedCodes.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort() : []; + const collapsedTrends = Array.isArray(payload.collapsedTrends) + ? Array.from(new Set(payload.collapsedTrends.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort() + : []; + const groups = Array.isArray(payload.groups) ? payload.groups .map((group) => { @@ -3304,6 +3344,7 @@ export default function HomePage() { favorites, groups, collapsedCodes, + collapsedTrends, refreshMs: Number.isFinite(payload.refreshMs) ? payload.refreshMs : 30000, holdings, pendingTrades, @@ -3317,6 +3358,7 @@ export default function HomePage() { const favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); const groups = JSON.parse(localStorage.getItem('groups') || '[]'); const collapsedCodes = JSON.parse(localStorage.getItem('collapsedCodes') || '[]'); + const collapsedTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]'); const viewMode = localStorage.getItem('viewMode') === 'list' ? 'list' : 'card'; const fundCodes = new Set( Array.isArray(funds) @@ -3355,6 +3397,9 @@ export default function HomePage() { const cleanedCollapsed = Array.isArray(collapsedCodes) ? collapsedCodes.filter((code) => fundCodes.has(code)) : []; + const cleanedCollapsedTrends = Array.isArray(collapsedTrends) + ? collapsedTrends.filter((code) => fundCodes.has(code)) + : []; const cleanedGroups = Array.isArray(groups) ? groups.map((group) => ({ ...group, @@ -3371,6 +3416,7 @@ export default function HomePage() { favorites: cleanedFavorites, groups: cleanedGroups, collapsedCodes: cleanedCollapsed, + collapsedTrends: cleanedCollapsedTrends, refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10), holdings: cleanedHoldings, pendingTrades: cleanedPendingTrades, @@ -3397,7 +3443,7 @@ export default function HomePage() { skipSyncRef.current = true; try { if (cloudUpdatedAt) { - storageHelper.setItem('localUpdatedAt', toTz(cloudUpdatedAt).toISOString()); + storageHelper.setItem('localUpdatedAt', cloudUpdatedAt); } const nextFunds = Array.isArray(cloudData.funds) ? dedupeByCode(cloudData.funds) : []; setFunds(nextFunds); @@ -4763,6 +4809,11 @@ export default function HomePage() { )} + toggleTrendCollapse(f.code)} + /> )} diff --git a/package-lock.json b/package-lock.json index 6251ced..ada1d06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,12 @@ "@dicebear/collection": "^9.3.1", "@dicebear/core": "^9.3.1", "@supabase/supabase-js": "^2.78.0", + "chart.js": "^4.5.1", "dayjs": "^1.11.19", "framer-motion": "^12.29.2", "next": "^16.1.5", "react": "18.3.1", + "react-chartjs-2": "^5.3.1", "react-dom": "18.3.1" }, "devDependencies": { @@ -963,6 +965,12 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@next/env": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", @@ -1254,6 +1262,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -1453,6 +1473,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", diff --git a/package.json b/package.json index 6795e5f..f18f919 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "@dicebear/collection": "^9.3.1", "@dicebear/core": "^9.3.1", "@supabase/supabase-js": "^2.78.0", + "chart.js": "^4.5.1", "dayjs": "^1.11.19", "framer-motion": "^12.29.2", "next": "^16.1.5", "react": "18.3.1", + "react-chartjs-2": "^5.3.1", "react-dom": "18.3.1" }, "engines": {