feat: 添加基金历史净值趋势图表功能
This commit is contained in:
@@ -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('<tr>');
|
||||
const data = [];
|
||||
for (const row of rows) {
|
||||
const cells = row.match(/<td[^>]*>(.*?)<\/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([]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
349
app/components/FundTrendChart.jsx
Normal file
349
app/components/FundTrendChart.jsx
Normal file
@@ -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 (
|
||||
<div style={{ marginTop: 16 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
style={{ marginBottom: 8, cursor: 'pointer', userSelect: 'none' }}
|
||||
className="title"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<div className="row" style={{ width: '100%', flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>业绩走势</span>
|
||||
<ChevronIcon
|
||||
width="16"
|
||||
height="16"
|
||||
className="muted"
|
||||
style={{
|
||||
transform: !isExpanded ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="muted">{ranges.find(r => r.value === range)?.label}涨跌幅</span>
|
||||
<span style={{ color: lineColor, fontWeight: 600 }}>
|
||||
{change > 0 ? '+' : ''}{change.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{ position: 'relative', height: 180, width: '100%' }}>
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.02)', zIndex: 10, backdropFilter: 'blur(2px)'
|
||||
}}>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data.length === 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.02)', zIndex: 10
|
||||
}}>
|
||||
<span className="muted" style={{ fontSize: '12px' }}>暂无数据</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.length > 0 && (
|
||||
<Line ref={chartRef} data={chartData} options={options} plugins={plugins} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 12, justifyContent: 'space-between', background: 'rgba(0,0,0,0.2)', padding: 4, borderRadius: 8 }}>
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={(e) => { e.stopPropagation(); setRange(r.value); }}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 0',
|
||||
fontSize: '11px',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
background: range === r.value ? 'rgba(255,255,255,0.1)' : 'transparent',
|
||||
color: range === r.value ? 'var(--primary)' : 'var(--muted)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
fontWeight: range === r.value ? 600 : 400
|
||||
}}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
app/lib/cacheRequest.js
Normal file
52
app/lib/cacheRequest.js
Normal file
@@ -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);
|
||||
}
|
||||
57
app/page.jsx
57
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() {
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<FundTrendChart
|
||||
code={f.code}
|
||||
isExpanded={!collapsedTrends.has(f.code)}
|
||||
onToggleExpand={() => toggleTrendCollapse(f.code)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user