'use client'; import { useEffect, useRef, useState, useMemo, useLayoutEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import Image from 'next/image'; import { createWorker } from 'tesseract.js'; import { createAvatar } from '@dicebear/core'; import { identicon } from '@dicebear/collection'; import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import { isNumber, isString, isPlainObject } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import Announcement from "./components/Announcement"; import EmptyStateCard from "./components/EmptyStateCard"; import FundCard from "./components/FundCard"; import GroupSummary from "./components/GroupSummary"; import { CloseIcon, EyeIcon, EyeOffIcon, GridIcon, ListIcon, LoginIcon, LogoutIcon, MoonIcon, PinIcon, PinOffIcon, PlusIcon, SettingsIcon, SortIcon, SunIcon, UpdateIcon, UserIcon, CameraIcon, } from "./components/Icons"; import AddFundToGroupModal from "./components/AddFundToGroupModal"; import AddResultModal from "./components/AddResultModal"; import CloudConfigModal from "./components/CloudConfigModal"; import ConfirmModal from "./components/ConfirmModal"; import DonateModal from "./components/DonateModal"; import FeedbackModal from "./components/FeedbackModal"; import GroupManageModal from "./components/GroupManageModal"; import GroupModal from "./components/GroupModal"; import HoldingEditModal from "./components/HoldingEditModal"; import HoldingActionModal from "./components/HoldingActionModal"; import LoginModal from "./components/LoginModal"; import ScanImportConfirmModal from "./components/ScanImportConfirmModal"; import ScanImportProgressModal from "./components/ScanImportProgressModal"; import ScanPickModal from "./components/ScanPickModal"; import ScanProgressModal from "./components/ScanProgressModal"; import SettingsModal from "./components/SettingsModal"; import SuccessModal from "./components/SuccessModal"; import TradeModal from "./components/TradeModal"; import TransactionHistoryModal from "./components/TransactionHistoryModal"; import AddHistoryModal from "./components/AddHistoryModal"; import UpdatePromptModal from "./components/UpdatePromptModal"; import RefreshButton from "./components/RefreshButton"; import WeChatModal from "./components/WeChatModal"; import DcaModal from "./components/DcaModal"; import githubImg from "./assets/github.svg"; import { supabase, isSupabaseConfigured } from './lib/supabase'; import { toast as sonnerToast } from 'sonner'; import { recordValuation, getAllValuationSeries, clearFund } from './lib/valuationTimeseries'; import { loadHolidaysForYears, isTradingDay as isDateTradingDay } from './lib/tradingCalendar'; import { parseFundTextWithLLM, fetchFundData, fetchLatestRelease, fetchShanghaiIndexDate, fetchSmartFundNetValue, searchFunds } from './api/fund'; import packageJson from '../package.json'; import PcFundTable from './components/PcFundTable'; import MobileFundTable from './components/MobileFundTable'; import { useFundFuzzyMatcher } from './hooks/useFundFuzzyMatcher'; dayjs.extend(utc); dayjs.extend(timezone); dayjs.extend(isSameOrAfter); const DEFAULT_TZ = 'Asia/Shanghai'; const getBrowserTimeZone = () => { if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; return tz || DEFAULT_TZ; } return DEFAULT_TZ; }; const TZ = getBrowserTimeZone(); dayjs.tz.setDefault(TZ); const nowInTz = () => dayjs().tz(TZ); const toTz = (input) => (input ? dayjs.tz(input, TZ) : nowInTz()); const formatDate = (input) => toTz(input).format('YYYY-MM-DD'); function ScanButton({ onClick, disabled }) { return ( ); } export default function HomePage() { const [funds, setFunds] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const timerRef = useRef(null); const refreshCycleStartRef = useRef(Date.now()); const refreshingRef = useRef(false); const isLoggingOutRef = useRef(false); const isExplicitLoginRef = useRef(false); // 刷新频率状态 const [refreshMs, setRefreshMs] = useState(60000); const [settingsOpen, setSettingsOpen] = useState(false); const [tempSeconds, setTempSeconds] = useState(60); const [containerWidth, setContainerWidth] = useState(1200); useEffect(() => { if (typeof window === 'undefined') return; try { const raw = window.localStorage.getItem('customSettings'); if (!raw) return; const parsed = JSON.parse(raw); const w = parsed?.pcContainerWidth; const num = Number(w); if (Number.isFinite(num)) { setContainerWidth(Math.min(2000, Math.max(600, num))); } } catch { } }, []); // 全局刷新状态 const [refreshing, setRefreshing] = useState(false); // 收起/展开状态 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: [] }] const [currentTab, setCurrentTab] = useState('all'); const hasLocalTabInitRef = useRef(false); const [groupModalOpen, setGroupModalOpen] = useState(false); const [groupManageOpen, setGroupManageOpen] = useState(false); const [addFundToGroupOpen, setAddFundToGroupOpen] = useState(false); // 排序状态 const [sortBy, setSortBy] = useState('default'); // default, name, yield, holding const [sortOrder, setSortOrder] = useState('desc'); // asc | desc const [isSortLoaded, setIsSortLoaded] = useState(false); useEffect(() => { if (typeof window !== 'undefined') { const savedSortBy = window.localStorage.getItem('localSortBy'); const savedSortOrder = window.localStorage.getItem('localSortOrder'); if (savedSortBy) setSortBy(savedSortBy); if (savedSortOrder) setSortOrder(savedSortOrder); setIsSortLoaded(true); } }, []); useEffect(() => { if (typeof window !== 'undefined' && isSortLoaded) { window.localStorage.setItem('localSortBy', sortBy); window.localStorage.setItem('localSortOrder', sortOrder); } }, [sortBy, sortOrder, isSortLoaded]); // 视图模式 const [viewMode, setViewMode] = useState('card'); // card, list // 全局隐藏金额状态(影响分组汇总、列表和卡片) const [maskAmounts, setMaskAmounts] = useState(false); // 用户认证状态 const [user, setUser] = useState(null); 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); const [loginModalOpen, setLoginModalOpen] = useState(false); const [loginEmail, setLoginEmail] = useState(''); const [loginLoading, setLoginLoading] = useState(false); const [loginError, setLoginError] = useState(''); const [loginSuccess, setLoginSuccess] = useState(''); const [loginOtp, setLoginOtp] = useState(''); const userAvatar = useMemo(() => { if (!user?.id) return ''; return createAvatar(identicon, { seed: user.id, size: 80 }).toDataUri(); }, [user?.id]); // 反馈弹窗状态 const [feedbackOpen, setFeedbackOpen] = useState(false); const [feedbackNonce, setFeedbackNonce] = useState(0); const [weChatOpen, setWeChatOpen] = useState(false); // 搜索相关状态 const [searchTerm, setSearchTerm] = useState(''); const [isSearchFocused, setIsSearchFocused] = useState(false); const [searchResults, setSearchResults] = useState([]); const [selectedFunds, setSelectedFunds] = useState([]); const [isSearching, setIsSearching] = useState(false); const searchTimeoutRef = useRef(null); const dropdownRef = useRef(null); const inputRef = useRef(null); const [showDropdown, setShowDropdown] = useState(false); const [addResultOpen, setAddResultOpen] = useState(false); const [addFailures, setAddFailures] = useState([]); // 动态计算 Navbar 和 FilterBar 高度 const navbarRef = useRef(null); const filterBarRef = useRef(null); const containerRef = useRef(null); const [navbarHeight, setNavbarHeight] = useState(0); const [filterBarHeight, setFilterBarHeight] = useState(0); // 主题初始固定为 dark,避免 SSR 与客户端首屏不一致导致 hydration 报错;真实偏好由 useLayoutEffect 在首帧前恢复 const [theme, setTheme] = useState('dark'); const [showThemeTransition, setShowThemeTransition] = useState(false); const handleThemeToggle = useCallback(() => { setTheme((t) => (t === 'dark' ? 'light' : 'dark')); setShowThemeTransition(true); }, []); // 首帧前同步主题(与 layout 中脚本设置的 data-theme 一致),减少图标闪烁 useLayoutEffect(() => { try { const fromDom = document.documentElement.getAttribute('data-theme'); if (fromDom === 'light' || fromDom === 'dark') { setTheme(fromDom); return; } const fromStorage = localStorage.getItem('theme'); if (fromStorage === 'light' || fromStorage === 'dark') { setTheme(fromStorage); document.documentElement.setAttribute('data-theme', fromStorage); } } catch { } }, []); useEffect(() => { const updateHeights = () => { if (navbarRef.current) { setNavbarHeight(navbarRef.current.offsetHeight); } if (filterBarRef.current) { setFilterBarHeight(filterBarRef.current.offsetHeight); } }; // 初始延迟一下,确保渲染完成 const timer = setTimeout(updateHeights, 100); window.addEventListener('resize', updateHeights); return () => { window.removeEventListener('resize', updateHeights); clearTimeout(timer); }; }, [groups, currentTab]); // groups 或 tab 变化可能导致 filterBar 高度变化 const handleMobileSearchClick = (e) => { e?.preventDefault(); e?.stopPropagation(); setIsSearchFocused(true); // 等待动画完成后聚焦,避免 iOS 键盘弹出问题 setTimeout(() => { inputRef.current?.focus(); }, 350); }; const [holdingModal, setHoldingModal] = useState({ open: false, fund: null }); const [actionModal, setActionModal] = useState({ open: false, fund: null }); const [tradeModal, setTradeModal] = useState({ open: false, fund: null, type: 'buy' }); // type: 'buy' | 'sell' const [dcaModal, setDcaModal] = useState({ open: false, fund: null }); const [clearConfirm, setClearConfirm] = useState(null); // { fund } const [donateOpen, setDonateOpen] = useState(false); const [holdings, setHoldings] = useState({}); // { [code]: { share: number, cost: number } } const [pendingTrades, setPendingTrades] = useState([]); // [{ id, fundCode, share, date, ... }] const [transactions, setTransactions] = useState({}); // { [code]: [{ id, type, amount, share, price, date, timestamp }] } const [dcaPlans, setDcaPlans] = useState({}); // { [code]: { amount, feeRate, cycle, firstDate, enabled } } const [historyModal, setHistoryModal] = useState({ open: false, fund: null }); const [addHistoryModal, setAddHistoryModal] = useState({ open: false, fund: null }); const [percentModes, setPercentModes] = useState({}); // { [code]: boolean } const holdingsRef = useRef(holdings); const pendingTradesRef = useRef(pendingTrades); useEffect(() => { holdingsRef.current = holdings; pendingTradesRef.current = pendingTrades; }, [holdings, pendingTrades]); const [isTradingDay, setIsTradingDay] = useState(true); // 默认为交易日,通过接口校正 const tabsRef = useRef(null); const [fundDeleteConfirm, setFundDeleteConfirm] = useState(null); // { code, name } const fundDetailDrawerCloseRef = useRef(null); // 由 MobileFundTable 注入,用于确认删除时关闭基金详情 Drawer const fundDetailDialogCloseRef = useRef(null); // 由 PcFundTable 注入,用于确认删除时关闭基金详情 Dialog const todayStr = formatDate(); const [isMobile, setIsMobile] = useState(false); const [hoveredPcRowCode, setHoveredPcRowCode] = useState(null); // PC 列表行悬浮高亮 const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false); useEffect(() => { if (typeof window !== 'undefined') { const checkMobile = () => setIsMobile(window.innerWidth <= 640); checkMobile(); window.addEventListener('resize', checkMobile); return () => window.removeEventListener('resize', checkMobile); } }, []); // 检查更新 const [hasUpdate, setHasUpdate] = useState(false); const [latestVersion, setLatestVersion] = useState(''); const [updateContent, setUpdateContent] = useState(''); const [isSyncing, setIsSyncing] = useState(false); useEffect(() => { // 未配置 GitHub 最新版本接口地址时,不进行更新检查 if (!process.env.NEXT_PUBLIC_GITHUB_LATEST_RELEASE_URL) return; const checkUpdate = async () => { try { const data = await fetchLatestRelease(); if (!data?.tagName) return; const remoteVersion = data.tagName.replace(/^v/, ''); if (remoteVersion !== packageJson.version) { setHasUpdate(true); setLatestVersion(remoteVersion); setUpdateContent(data.body || ''); } } catch (e) { console.error('Check update failed:', e); } }; checkUpdate(); const interval = setInterval(checkUpdate, 30 * 60 * 1000); // 30 minutes return () => clearInterval(interval); }, []); // 存储当前被划开的基金代码 const [swipedFundCode, setSwipedFundCode] = useState(null); // 点击页面其他区域时收起删除按钮 useEffect(() => { const handleClickOutside = (e) => { // 检查点击事件是否来自删除按钮 // 如果点击的是 .swipe-action-bg 或其子元素,不执行收起逻辑 if (e.target.closest('.swipe-action-bg')) { return; } if (swipedFundCode) { setSwipedFundCode(null); } }; if (swipedFundCode) { document.addEventListener('click', handleClickOutside); document.addEventListener('touchstart', handleClickOutside); } return () => { document.removeEventListener('click', handleClickOutside); document.removeEventListener('touchstart', handleClickOutside); }; }, [swipedFundCode]); // 检查交易日状态 const checkTradingDay = async () => { const now = nowInTz(); const isWeekend = now.day() === 0 || now.day() === 6; // 周末直接判定为非交易日 if (isWeekend) { setIsTradingDay(false); return; } // 工作日通过上证指数判断是否为节假日 // 接口返回示例: v_sh000001="1~上证指数~...~20260205150000~..." // 第30位是时间字段 try { const dateStr = await fetchShanghaiIndexDate(); if (!dateStr) { setIsTradingDay(!isWeekend); return; } const currentStr = todayStr.replace(/-/g, ''); if (dateStr === currentStr) { setIsTradingDay(true); } else { const minutes = now.hour() * 60 + now.minute(); if (minutes >= 9 * 60 + 30) { setIsTradingDay(false); } else { setIsTradingDay(true); } } } catch (e) { setIsTradingDay(!isWeekend); } }; useEffect(() => { checkTradingDay(); // 每30分钟检查一次 const timer = setInterval(checkTradingDay, 60000 * 30); return () => clearInterval(timer); }, []); // 计算持仓收益 const getHoldingProfit = useCallback((fund, holding) => { if (!holding || !isNumber(holding.share)) return null; const hasTodayData = fund.jzrq === todayStr; const hasTodayValuation = isString(fund.gztime) && fund.gztime.startsWith(todayStr); const canCalcTodayProfit = hasTodayData || hasTodayValuation; // 如果是交易日且9点以后,且今日净值未出,则强制使用估值(隐藏涨跌幅列模式) const useValuation = isTradingDay && !hasTodayData; let currentNav; let profitToday; if (!useValuation) { // 使用确权净值 (dwjz) currentNav = Number(fund.dwjz); if (!currentNav) return null; if (canCalcTodayProfit) { const amount = holding.share * currentNav; // 优先用 zzl (真实涨跌幅), 降级用 gszzl // 若 gztime 日期 > jzrq,说明估值更新晚于净值日期,优先使用 gszzl 计算当日盈亏 const gz = isString(fund.gztime) ? toTz(fund.gztime) : null; const jz = isString(fund.jzrq) ? toTz(fund.jzrq) : null; const preferGszzl = !!gz && !!jz && gz.isValid() && jz.isValid() && gz.startOf('day').isAfter(jz.startOf('day')); let rate; if (preferGszzl) { rate = Number(fund.gszzl); } else { const zzl = fund.zzl !== undefined ? Number(fund.zzl) : Number.NaN; rate = Number.isFinite(zzl) ? zzl : Number(fund.gszzl); } if (!Number.isFinite(rate)) rate = 0; profitToday = amount - (amount / (1 + rate / 100)); } else { profitToday = null; } } else { // 否则使用估值 currentNav = fund.estPricedCoverage > 0.05 ? fund.estGsz : (isNumber(fund.gsz) ? fund.gsz : Number(fund.dwjz)); if (!currentNav) return null; if (canCalcTodayProfit) { const amount = holding.share * currentNav; // 估值涨幅 const gzChange = fund.estPricedCoverage > 0.05 ? fund.estGszzl : (Number(fund.gszzl) || 0); profitToday = amount - (amount / (1 + gzChange / 100)); } else { profitToday = null; } } // 持仓金额 const amount = holding.share * currentNav; // 总收益 = (当前净值 - 成本价) * 份额 const profitTotal = isNumber(holding.cost) ? (currentNav - holding.cost) * holding.share : null; return { amount, profitToday, profitTotal }; }, [isTradingDay, todayStr]); // 过滤和排序后的基金列表 const displayFunds = useMemo( () => { let filtered = funds.filter(f => { if (currentTab === 'all') return true; if (currentTab === 'fav') return favorites.has(f.code); const group = groups.find(g => g.id === currentTab); return group ? group.codes.includes(f.code) : true; }); if (currentTab !== 'all' && currentTab !== 'fav' && sortBy === 'default') { const group = groups.find(g => g.id === currentTab); if (group && group.codes) { const codeMap = new Map(group.codes.map((code, index) => [code, index])); filtered.sort((a, b) => { const indexA = codeMap.get(a.code) ?? Number.MAX_SAFE_INTEGER; const indexB = codeMap.get(b.code) ?? Number.MAX_SAFE_INTEGER; return indexA - indexB; }); } } return filtered.sort((a, b) => { if (sortBy === 'yield') { const valA = isNumber(a.estGszzl) ? a.estGszzl : (a.gszzl ?? a.zzl ?? 0); const valB = isNumber(b.estGszzl) ? b.estGszzl : (b.gszzl ?? a.zzl ?? 0); return sortOrder === 'asc' ? valA - valB : valB - valA; } if (sortBy === 'holding') { const pa = getHoldingProfit(a, holdings[a.code]); const pb = getHoldingProfit(b, holdings[b.code]); const valA = pa?.profitTotal ?? Number.NEGATIVE_INFINITY; const valB = pb?.profitTotal ?? Number.NEGATIVE_INFINITY; return sortOrder === 'asc' ? valA - valB : valB - valA; } if (sortBy === 'name') { return sortOrder === 'asc' ? a.name.localeCompare(b.name, 'zh-CN') : b.name.localeCompare(a.name, 'zh-CN'); } return 0; }); }, [funds, currentTab, favorites, groups, sortBy, sortOrder, holdings, getHoldingProfit], ); // PC 端表格数据(用于 PcFundTable) const pcFundTableData = useMemo( () => displayFunds.map((f) => { const hasTodayData = f.jzrq === todayStr; const latestNav = f.dwjz != null && f.dwjz !== '' ? (typeof f.dwjz === 'number' ? Number(f.dwjz).toFixed(4) : String(f.dwjz)) : '—'; const estimateNav = f.noValuation ? '—' : (f.estPricedCoverage > 0.05 ? (f.estGsz != null ? Number(f.estGsz).toFixed(4) : '—') : (f.gsz != null ? (typeof f.gsz === 'number' ? Number(f.gsz).toFixed(4) : String(f.gsz)) : '—')); const yesterdayChangePercent = f.zzl != null && f.zzl !== '' ? `${f.zzl > 0 ? '+' : ''}${Number(f.zzl).toFixed(2)}%` : '—'; const yesterdayChangeValue = f.zzl != null && f.zzl !== '' ? Number(f.zzl) : null; const yesterdayDate = f.jzrq || '-'; const estimateChangePercent = f.noValuation ? '—' : (f.estPricedCoverage > 0.05 ? (f.estGszzl != null ? `${f.estGszzl > 0 ? '+' : ''}${Number(f.estGszzl).toFixed(2)}%` : '—') : (isNumber(f.gszzl) ? `${f.gszzl > 0 ? '+' : ''}${Number(f.gszzl).toFixed(2)}%` : (f.gszzl ?? '—'))); const estimateChangeValue = f.noValuation ? null : (f.estPricedCoverage > 0.05 ? (isNumber(f.estGszzl) ? Number(f.estGszzl) : null) : (isNumber(f.gszzl) ? Number(f.gszzl) : null)); const estimateTime = f.noValuation ? (f.jzrq || '-') : (f.gztime || f.time || '-'); const hasTodayEstimate = !f.noValuation && isString(f.gztime) && f.gztime.startsWith(todayStr); const holding = holdings[f.code]; const profit = getHoldingProfit(f, holding); const amount = profit ? profit.amount : null; const holdingAmount = amount == null ? '未设置' : `¥${amount.toFixed(2)}`; const holdingAmountValue = amount; const profitToday = profit ? profit.profitToday : null; const todayProfit = profitToday == null ? '' : `${profitToday > 0 ? '+' : profitToday < 0 ? '-' : ''}¥${Math.abs(profitToday).toFixed(2)}`; const todayProfitValue = profitToday; const total = profit ? profit.profitTotal : null; const principal = holding && isNumber(holding.cost) && isNumber(holding.share) ? holding.cost * holding.share : 0; const todayProfitPercent = profitToday != null && principal > 0 ? `${profitToday > 0 ? '+' : profitToday < 0 ? '-' : ''}${Math.abs((profitToday / principal) * 100).toFixed(2)}%` : ''; const holdingProfit = total == null ? '' : `${total > 0 ? '+' : total < 0 ? '-' : ''}¥${Math.abs(total).toFixed(2)}`; const holdingProfitPercent = total != null && principal > 0 ? `${total > 0 ? '+' : total < 0 ? '-' : ''}${Math.abs((total / principal) * 100).toFixed(2)}%` : ''; const holdingProfitValue = total; const holdingProfitPercentValue = total != null && principal > 0 ? (total / principal) * 100 : null; const hasEstimatePercent = hasTodayEstimate && estimateChangeValue != null; const hasHoldingPercent = holdingProfitPercentValue != null; const fallbackEstimateProfitPercentValue = hasEstimatePercent || hasHoldingPercent ? (hasEstimatePercent ? estimateChangeValue : 0) + (hasHoldingPercent ? holdingProfitPercentValue : 0) : null; const estimateProfitPercentValue = hasTodayData ? holdingProfitPercentValue : fallbackEstimateProfitPercentValue; const estimateProfitValue = hasTodayData ? total : (estimateProfitPercentValue != null && principal > 0 ? principal * (estimateProfitPercentValue / 100) : null); const estimateProfit = estimateProfitValue == null ? '' : `${estimateProfitValue > 0 ? '+' : estimateProfitValue < 0 ? '-' : ''}¥${Math.abs(estimateProfitValue).toFixed(2)}`; const estimateProfitPercent = estimateProfitPercentValue == null ? '' : `${estimateProfitPercentValue > 0 ? '+' : ''}${estimateProfitPercentValue.toFixed(2)}%`; return { rawFund: f, code: f.code, fundName: f.name, isUpdated: f.jzrq === todayStr, hasDca: dcaPlans[f.code]?.enabled === true, latestNav, latestNavDate: yesterdayDate, estimateNav, estimateNavDate: estimateTime, yesterdayChangePercent, yesterdayChangeValue, yesterdayDate, estimateChangePercent, estimateChangeValue, estimateChangeMuted: f.noValuation, estimateTime, hasTodayEstimate, totalChangePercent: estimateProfitPercent, estimateProfit, estimateProfitValue, estimateProfitPercent, holdingAmount, holdingAmountValue, todayProfit, todayProfitPercent, todayProfitValue, holdingProfit, holdingProfitPercent, holdingProfitValue, }; }), [displayFunds, holdings, isTradingDay, todayStr, getHoldingProfit, dcaPlans], ); // 自动滚动选中 Tab 到可视区域 useEffect(() => { if (!tabsRef.current) return; if (currentTab === 'all') { tabsRef.current.scrollTo({ left: 0, behavior: 'smooth' }); return; } const activeTab = tabsRef.current.querySelector('.tab.active'); if (activeTab) { activeTab.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); } }, [currentTab]); // 鼠标拖拽滚动逻辑 const [isDragging, setIsDragging] = useState(false); const [canLeft, setCanLeft] = useState(false); const [canRight, setCanRight] = useState(false); const handleSaveHolding = (code, data) => { setHoldings(prev => { const next = { ...prev }; if (data.share === null && data.cost === null) { delete next[code]; } else { next[code] = data; } storageHelper.setItem('holdings', JSON.stringify(next)); return next; }); setHoldingModal({ open: false, fund: null }); }; const handleAction = (type, fund) => { if (type !== 'history') { setActionModal({ open: false, fund: null }); } if (type === 'edit') { setHoldingModal({ open: true, fund }); } else if (type === 'clear') { setClearConfirm({ fund }); } else if (type === 'buy' || type === 'sell') { setTradeModal({ open: true, fund, type }); } else if (type === 'history') { setHistoryModal({ open: true, fund }); } else if (type === 'dca') { setDcaModal({ open: true, fund }); } }; const handleClearConfirm = () => { if (clearConfirm?.fund) { handleSaveHolding(clearConfirm.fund.code, { share: null, cost: null }); } setClearConfirm(null); }; const processPendingQueue = async () => { const currentPending = pendingTradesRef.current; if (currentPending.length === 0) return; let stateChanged = false; let tempHoldings = { ...holdingsRef.current }; const processedIds = new Set(); const newTransactions = []; for (const trade of currentPending) { let queryDate = trade.date; if (trade.isAfter3pm) { queryDate = toTz(trade.date).add(1, 'day').format('YYYY-MM-DD'); } // 尝试获取智能净值 const result = await fetchSmartFundNetValue(trade.fundCode, queryDate); if (result && result.value > 0) { // 成功获取,执行交易 const current = tempHoldings[trade.fundCode] || { share: 0, cost: 0 }; let newShare, newCost; let tradeShare = 0; let tradeAmount = 0; if (trade.type === 'buy') { const feeRate = trade.feeRate || 0; const netAmount = trade.amount / (1 + feeRate / 100); const share = netAmount / result.value; newShare = current.share + share; newCost = (current.cost * current.share + trade.amount) / newShare; tradeShare = share; tradeAmount = trade.amount; } else { newShare = Math.max(0, current.share - trade.share); newCost = current.cost; if (newShare === 0) newCost = 0; tradeShare = trade.share; tradeAmount = trade.share * result.value; } tempHoldings[trade.fundCode] = { share: newShare, cost: newCost }; stateChanged = true; processedIds.add(trade.id); // 记录交易历史 newTransactions.push({ id: trade.id, fundCode: trade.fundCode, type: trade.type, share: tradeShare, amount: tradeAmount, price: result.value, date: result.date, // 使用获取到净值的日期 isAfter3pm: trade.isAfter3pm, isDca: !!trade.isDca, timestamp: Date.now() }); } } if (stateChanged) { setHoldings(tempHoldings); storageHelper.setItem('holdings', JSON.stringify(tempHoldings)); setPendingTrades(prev => { const next = prev.filter(t => !processedIds.has(t.id)); storageHelper.setItem('pendingTrades', JSON.stringify(next)); return next; }); setTransactions(prev => { const nextState = { ...prev }; newTransactions.forEach(tx => { const current = nextState[tx.fundCode] || []; // 避免重复添加 (虽然 id 应该唯一) if (!current.some(t => t.id === tx.id)) { nextState[tx.fundCode] = [tx, ...current].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); } }); storageHelper.setItem('transactions', JSON.stringify(nextState)); return nextState; }); showToast(`已处理 ${processedIds.size} 笔待定交易`, 'success'); } }; const handleDeleteTransaction = (fundCode, transactionId) => { setTransactions(prev => { const current = prev[fundCode] || []; const next = current.filter(t => t.id !== transactionId); const nextState = { ...prev, [fundCode]: next }; storageHelper.setItem('transactions', JSON.stringify(nextState)); return nextState; }); showToast('交易记录已删除', 'success'); }; const handleAddHistory = (data) => { const fundCode = data.fundCode; // 添加历史记录仅作补录展示,不修改真实持仓金额与份额 setTransactions(prev => { const current = prev[fundCode] || []; const record = { id: uuidv4(), type: data.type, share: data.share, amount: data.amount, price: data.price, date: data.date, isAfter3pm: false, // 历史记录通常不需要此标记,或者默认为 false isDca: false, isHistoryOnly: true, // 仅记录,不参与持仓计算 timestamp: data.timestamp || Date.now() } // 按时间倒序排列 const next = [record, ...current].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); const nextState = { ...prev, [fundCode]: next }; storageHelper.setItem('transactions', JSON.stringify(nextState)); return nextState; }); showToast('历史记录已添加', 'success'); setAddHistoryModal({ open: false, fund: null }); }; const handleTrade = (fund, data) => { // 如果没有价格(API失败),加入待处理队列 if (!data.price || data.price === 0) { const pending = { id: uuidv4(), fundCode: fund.code, fundName: fund.name, type: tradeModal.type, share: data.share, amount: data.totalCost, feeRate: tradeModal.type === 'buy' ? data.feeRate : 0, // Buy needs feeRate feeMode: data.feeMode, feeValue: data.feeValue, date: data.date, isAfter3pm: data.isAfter3pm, isDca: false, timestamp: Date.now() }; const next = [...pendingTrades, pending]; setPendingTrades(next); storageHelper.setItem('pendingTrades', JSON.stringify(next)); setTradeModal({ open: false, fund: null, type: 'buy' }); showToast('净值暂未更新,已加入待处理队列', 'info'); return; } const current = holdings[fund.code] || { share: 0, cost: 0 }; const isBuy = tradeModal.type === 'buy'; let newShare, newCost; if (isBuy) { newShare = current.share + data.share; // 如果传递了 totalCost(即买入总金额),则用它来计算新成本 // 否则回退到用 share * price 计算(减仓或旧逻辑) const buyCost = data.totalCost !== undefined ? data.totalCost : (data.price * data.share); // 加权平均成本 = (原持仓成本 * 原份额 + 本次买入总花费) / 新总份额 // 注意:这里默认将手续费也计入成本(如果 totalCost 包含了手续费) newCost = (current.cost * current.share + buyCost) / newShare; } else { newShare = Math.max(0, current.share - data.share); // 减仓不改变单位成本,只减少份额 newCost = current.cost; if (newShare === 0) newCost = 0; } handleSaveHolding(fund.code, { share: newShare, cost: newCost }); setTransactions(prev => { const current = prev[fund.code] || []; const record = { id: uuidv4(), type: tradeModal.type, share: data.share, amount: isBuy ? data.totalCost : (data.share * data.price), price: data.price, date: data.date, isAfter3pm: data.isAfter3pm, isDca: false, timestamp: Date.now() }; const next = [record, ...current]; const nextState = { ...prev, [fund.code]: next }; storageHelper.setItem('transactions', JSON.stringify(nextState)); return nextState; }); setTradeModal({ open: false, fund: null, type: 'buy' }); }; const handleMouseDown = (e) => { if (!tabsRef.current) return; setIsDragging(true); }; const handleMouseLeaveOrUp = () => { setIsDragging(false); }; const handleMouseMove = (e) => { if (!isDragging || !tabsRef.current) return; e.preventDefault(); tabsRef.current.scrollLeft -= e.movementX; }; const handleWheel = (e) => { if (!tabsRef.current) return; const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; tabsRef.current.scrollLeft += delta; }; const updateTabOverflow = () => { if (!tabsRef.current) return; const el = tabsRef.current; setCanLeft(el.scrollLeft > 0); setCanRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 1); }; useEffect(() => { updateTabOverflow(); const onResize = () => updateTabOverflow(); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [groups, funds.length, favorites.size]); // 成功提示弹窗 const [successModal, setSuccessModal] = useState({ open: false, message: '' }); // 轻提示 (Toast) const [toast, setToast] = useState({ show: false, message: '', type: 'info' }); // type: 'info' | 'success' | 'error' const toastTimeoutRef = useRef(null); const showToast = (message, type = 'info') => { if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current); setToast({ show: true, message, type }); toastTimeoutRef.current = setTimeout(() => { setToast((prev) => ({ ...prev, show: false })); }, 3000); }; // 定投计划自动生成买入队列的逻辑会在 storageHelper 定义之后实现 const handleOpenLogin = () => { setUserMenuOpen(false); if (!isSupabaseConfigured) { showToast('未配置 Supabase,无法登录', 'error'); return; } setLoginModalOpen(true); }; const [updateModalOpen, setUpdateModalOpen] = useState(false); const [scanModalOpen, setScanModalOpen] = useState(false); // 扫描弹窗 const [scanConfirmModalOpen, setScanConfirmModalOpen] = useState(false); // 扫描确认弹窗 const [scannedFunds, setScannedFunds] = useState([]); // 扫描到的基金 const [selectedScannedCodes, setSelectedScannedCodes] = useState(new Set()); // 选中的扫描代码 const [isScanning, setIsScanning] = useState(false); const [isScanImporting, setIsScanImporting] = useState(false); const [scanImportProgress, setScanImportProgress] = useState({ current: 0, total: 0, success: 0, failed: 0 }); const [scanProgress, setScanProgress] = useState({ stage: 'ocr', current: 0, total: 0 }); // stage: ocr | verify const [isOcrScan, setIsOcrScan] = useState(false); // 是否为拍照/图片识别触发的弹框 const abortScanRef = useRef(false); // 终止扫描标记 const fileInputRef = useRef(null); const ocrWorkerRef = useRef(null); const { resolveFundCodeByFuzzy } = useFundFuzzyMatcher(); const handleScanClick = () => { setScanModalOpen(true); }; const handleScanPick = () => { if (fileInputRef.current) { fileInputRef.current.click(); } }; const cancelScan = () => { abortScanRef.current = true; setIsScanning(false); setScanProgress({ stage: 'ocr', current: 0, total: 0 }); if (ocrWorkerRef.current) { try { ocrWorkerRef.current.terminate(); } catch (e) {} ocrWorkerRef.current = null; } if (fileInputRef.current) fileInputRef.current.value = ''; }; const processFiles = async (files) => { if (!files?.length) return; setIsScanning(true); setScanModalOpen(false); // 关闭选择弹窗 abortScanRef.current = false; setScanProgress({ stage: 'ocr', current: 0, total: files.length }); try { let worker = ocrWorkerRef.current; if (!worker) { const cdnBases = [ 'https://01kjzb6fhx9f8rjstc8c21qadx.esa.staticdn.net/npm', 'https://fastly.jsdelivr.net/npm', 'https://cdn.jsdelivr.net/npm', ]; const coreCandidates = [ 'tesseract-core-simd-lstm.wasm.js', 'tesseract-core-lstm.wasm.js', ]; let lastErr = null; for (const base of cdnBases) { for (const coreFile of coreCandidates) { try { worker = await createWorker('chi_sim+eng', 1, { workerPath: `${base}/tesseract.js@v5.1.1/dist/worker.min.js`, corePath: `${base}/tesseract.js-core@v5.1.1/${coreFile}`, }); lastErr = null; break; } catch (e) { lastErr = e; } } if (!lastErr) break; } if (lastErr) throw lastErr; ocrWorkerRef.current = worker; } const recognizeWithTimeout = async (file, ms) => { let timer = null; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('OCR_TIMEOUT')), ms); }); try { return await Promise.race([worker.recognize(file), timeout]); } finally { if (timer) clearTimeout(timer); } }; const searchFundsWithTimeout = async (val, ms) => { let timer = null; const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve([]), ms); }); try { return await Promise.race([searchFunds(val), timeout]); } catch (e) { return []; } finally { if (timer) clearTimeout(timer); } }; const allFundsData = []; // 存储所有解析出的基金信息,格式为 [{fundCode, fundName, holdAmounts, holdGains}] const addedFundCodes = new Set(); // 用于去重 for (let i = 0; i < files.length; i++) { if (abortScanRef.current) break; const f = files[i]; // 更新进度:正在处理第 i+1 张 setScanProgress(prev => ({ ...prev, current: i + 1 })); let text = ''; try { const res = await recognizeWithTimeout(f, 30000); text = res?.data?.text || ''; } catch (e) { if (String(e?.message || '').includes('OCR_TIMEOUT')) { if (worker) { try { await worker.terminate(); } catch (err) {} ocrWorkerRef.current = null; } throw e; } text = ''; } // 提取到 text 内容,调用大模型 api 进行解析,获取基金数据(fundCode 可能为空) const fundsResString = await parseFundTextWithLLM(text); let fundsRes = null; // 格式为 [{"fundCode": "000001", "fundName": "浙商债券","holdAmounts": "99.99", "holdGains": "99.99"}] try { fundsRes = JSON.parse(fundsResString); } catch (e) { console.error(e); } // 处理大模型解析结果,根据 fundCode 去重 if (Array.isArray(fundsRes) && fundsRes.length > 0) { fundsRes.forEach((fund) => { const code = fund.fundCode || ''; const name = (fund.fundName || '').trim(); if (code && !addedFundCodes.has(code)) { addedFundCodes.add(code); allFundsData.push({ fundCode: code, fundName: name, holdAmounts: fund.holdAmounts || '', holdGains: fund.holdGains || '' }); } else if (!code && name) { // fundCode 为空但有名称,后续需要通过名称搜索基金代码 allFundsData.push({ fundCode: '', fundName: name, holdAmounts: fund.holdAmounts || '', holdGains: fund.holdGains || '' }); } }); } } if (abortScanRef.current) { return; } // 处理没有基金代码但有名称的情况,通过名称搜索基金代码 const fundsWithoutCode = allFundsData.filter(f => !f.fundCode && f.fundName); if (fundsWithoutCode.length > 0) { setScanProgress({ stage: 'verify', current: 0, total: fundsWithoutCode.length }); for (let i = 0; i < fundsWithoutCode.length; i++) { if (abortScanRef.current) break; const fundItem = fundsWithoutCode[i]; setScanProgress(prev => ({ ...prev, current: i + 1 })); try { const list = await searchFundsWithTimeout(fundItem.fundName, 8000); // 只有当搜索结果「有且仅有一条」时,才认为名称匹配是唯一且有效的 if (Array.isArray(list) && list.length === 1) { const found = list[0]; if (found && found.CODE && !addedFundCodes.has(found.CODE)) { addedFundCodes.add(found.CODE); fundItem.fundCode = found.CODE; } } else { // 使用 fuse.js 读取 Public 中的 allFunds 数据进行模糊匹配,补充搜索接口的不足 try { const fuzzyCode = await resolveFundCodeByFuzzy(fundItem.fundName); if (fuzzyCode && !addedFundCodes.has(fuzzyCode)) { addedFundCodes.add(fuzzyCode); fundItem.fundCode = fuzzyCode; } } catch (e) { } } } catch (e) { } } } // 过滤出有基金代码的记录 const validFunds = allFundsData.filter(f => f.fundCode); const codes = validFunds.map(f => f.fundCode).sort(); setScanProgress({ stage: 'verify', current: 0, total: codes.length }); const existingCodes = new Set(funds.map(f => f.code)); const results = []; for (let i = 0; i < codes.length; i++) { if (abortScanRef.current) break; const code = codes[i]; const fundInfo = validFunds.find(f => f.fundCode === code); setScanProgress(prev => ({ ...prev, current: i + 1 })); let found = null; try { const list = await searchFundsWithTimeout(code, 8000); found = Array.isArray(list) ? list.find(d => d.CODE === code) : null; } catch (e) { found = null; } const alreadyAdded = existingCodes.has(code); const ok = !!found && !alreadyAdded; results.push({ code, name: found ? (found.NAME || found.SHORTNAME || '') : (fundInfo?.fundName || ''), status: alreadyAdded ? 'added' : (ok ? 'ok' : 'invalid'), holdAmounts: fundInfo?.holdAmounts || '', holdGains: fundInfo?.holdGains || '' }); } if (abortScanRef.current) { return; } setScannedFunds(results); setSelectedScannedCodes(new Set(results.filter(r => r.status === 'ok').map(r => r.code))); setIsOcrScan(true); setScanConfirmModalOpen(true); } catch (err) { if (!abortScanRef.current) { console.error('OCR Error:', err); showToast('图片识别失败,请重试或更换更清晰的截图', 'error'); } } finally { setIsScanning(false); setScanProgress({ stage: 'ocr', current: 0, total: 0 }); if (fileInputRef.current) fileInputRef.current.value = ''; } }; const handleFilesUpload = (event) => { processFiles(Array.from(event.target.files || [])); }; const handleFilesDrop = (files) => { processFiles(files); }; const toggleScannedCode = (code) => { setSelectedScannedCodes(prev => { const next = new Set(prev); if (next.has(code)) next.delete(code); else next.add(code); return next; }); }; const confirmScanImport = async (targetGroupId = 'all') => { const codes = Array.from(selectedScannedCodes); if (codes.length === 0) { showToast('请至少选择一个基金代码', 'error'); return; } setScanConfirmModalOpen(false); setIsScanImporting(true); setScanImportProgress({ current: 0, total: codes.length, success: 0, failed: 0 }); const parseAmount = (val) => { if (!val) return null; const num = parseFloat(String(val).replace(/,/g, '')); return isNaN(num) ? null : num; }; try { const newFunds = []; const newHoldings = {}; let successCount = 0; let failedCount = 0; for (let i = 0; i < codes.length; i++) { const code = codes[i]; setScanImportProgress(prev => ({ ...prev, current: i + 1 })); if (funds.some(existing => existing.code === code)) continue; try { const data = await fetchFundData(code); newFunds.push(data); const scannedFund = scannedFunds.find(f => f.code === code); const holdAmounts = parseAmount(scannedFund?.holdAmounts); const holdGains = parseAmount(scannedFund?.holdGains); const dwjz = data?.dwjz || data?.gsz || 0; if (holdAmounts !== null && dwjz > 0) { const share = holdAmounts / dwjz; const profit = holdGains !== null ? holdGains : 0; const principal = holdAmounts - profit; const cost = share > 0 ? principal / share : 0; newHoldings[code] = { share: Number(share.toFixed(2)), cost: Number(cost.toFixed(4)) }; } successCount++; setScanImportProgress(prev => ({ ...prev, success: prev.success + 1 })); } catch (e) { failedCount++; setScanImportProgress(prev => ({ ...prev, failed: prev.failed + 1 })); } } if (newFunds.length > 0) { setFunds(prev => { const updated = dedupeByCode([...newFunds, ...prev]); storageHelper.setItem('funds', JSON.stringify(updated)); return updated; }); if (Object.keys(newHoldings).length > 0) { setHoldings(prev => { const next = { ...prev, ...newHoldings }; storageHelper.setItem('holdings', JSON.stringify(next)); return 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 })); if (targetGroupId === 'fav') { setFavorites(prev => { const next = new Set(prev); codes.forEach(code => next.add(code)); storageHelper.setItem('favorites', JSON.stringify(Array.from(next))); return next; }); setCurrentTab('fav'); } else if (targetGroupId && targetGroupId !== 'all') { setGroups(prev => { const updated = prev.map(g => { if (g.id === targetGroupId) { return { ...g, codes: Array.from(new Set([...g.codes, ...codes])) }; } return g; }); storageHelper.setItem('groups', JSON.stringify(updated)); return updated; }); setCurrentTab(targetGroupId); } else { setCurrentTab('all'); } setSuccessModal({ open: true, message: `成功导入 ${successCount} 个基金` }); } else { if (codes.length > 0 && successCount === 0 && failedCount === 0) { setSuccessModal({ open: true, message: '识别的基金已全部添加' }); } else { showToast('未能导入任何基金', 'info'); } } } catch (e) { showToast('导入失败', 'error'); } finally { setIsScanImporting(false); setScanImportProgress({ current: 0, total: 0, success: 0, failed: 0 }); setScannedFunds([]); setSelectedScannedCodes(new Set()); } }; const [cloudConfigModal, setCloudConfigModal] = useState({ open: false, userId: null }); const syncDebounceRef = useRef(null); const lastSyncedRef = useRef(''); const skipSyncRef = useRef(false); const userIdRef = useRef(null); const dirtyKeysRef = useRef(new Set()); // 记录发生变化的字段 useEffect(() => { const handleClickOutside = (event) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { setShowDropdown(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); useEffect(() => { userIdRef.current = user?.id || null; }, [user]); const getFundCodesSignature = useCallback((value, extraFields = []) => { try { const list = Array.isArray(value) ? value : JSON.parse(value || '[]'); if (!Array.isArray(list)) return ''; const fields = Array.from(new Set([ 'jzrq', 'dwjz', ...(Array.isArray(extraFields) ? extraFields : []) ])); const items = list.map((item) => { if (!item?.code) return null; const extras = fields.map((field) => item?.[field] || '').join(':'); return `${item.code}:${extras}`; }).filter(Boolean); return Array.from(new Set(items)).join('|'); } catch (e) { return ''; } }, []); const scheduleSync = useCallback(() => { if (!userIdRef.current) return; if (skipSyncRef.current) return; if (syncDebounceRef.current) clearTimeout(syncDebounceRef.current); syncDebounceRef.current = setTimeout(() => { // 收集脏数据 const dirtyKeys = new Set(dirtyKeysRef.current); // 如果没有脏数据,且不是首次同步(可以增加其他判断),则不处理 // 但这里 scheduleSync 通常是由 storage 触发,所以应该有脏数据 // 除非是初次加载 if (dirtyKeys.size === 0) { // Fallback to full sync if needed, or just return // 这里为了保险,如果是空的,我们做全量 // 但通常 dirtyKeysRef 应该被填充了 } const payload = collectLocalPayload(dirtyKeys.size > 0 ? dirtyKeys : null); // 清空脏数据标记 dirtyKeysRef.current.clear(); // 计算 hash 比较是否真的变了(对于部分更新,这个比较可能意义不大,除非我们也部分比较) // 这里简化逻辑:如果是部分更新,直接发送 if (dirtyKeys.size > 0) { syncUserConfig(userIdRef.current, false, payload, true); } else { const next = getComparablePayload(payload); if (next === lastSyncedRef.current) return; lastSyncedRef.current = next; syncUserConfig(userIdRef.current, false, payload, false); } }, 1000 * 5); // 往云端同步的防抖时间 }, []); const storageHelper = useMemo(() => { // 仅以下 key 参与云端同步;fundValuationTimeseries 不同步到云端(测试中功能,暂不同步) const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'transactions', 'dcaPlans', 'customSettings']); const triggerSync = (key, prevValue, nextValue) => { if (keys.has(key)) { // 标记为脏数据 dirtyKeysRef.current.add(key); if (key === 'funds') { const prevSig = getFundCodesSignature(prevValue); const nextSig = getFundCodesSignature(nextValue); if (prevSig === nextSig) return; } if (!skipSyncRef.current) { const now = nowInTz().toISOString(); window.localStorage.setItem('localUpdatedAt', now); setLastSyncTime(now); } scheduleSync(); } }; return { setItem: (key, value) => { const prevValue = key === 'funds' ? window.localStorage.getItem(key) : null; window.localStorage.setItem(key, value); if (key === 'localUpdatedAt') { setLastSyncTime(value); } triggerSync(key, prevValue, value); }, removeItem: (key) => { const prevValue = key === 'funds' ? window.localStorage.getItem(key) : null; window.localStorage.removeItem(key); triggerSync(key, prevValue, null); }, clear: () => { window.localStorage.clear(); if (!skipSyncRef.current) { const now = nowInTz().toISOString(); window.localStorage.setItem('localUpdatedAt', now); setLastSyncTime(now); } scheduleSync(); } }; }, [getFundCodesSignature, scheduleSync]); useEffect(() => { // 仅以下 key 的变更会触发云端同步;fundValuationTimeseries 不在其中 const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'collapsedTrends', 'refreshMs', 'holdings', 'pendingTrades', 'dcaPlans', 'customSettings']); const onStorage = (e) => { if (!e.key) return; if (e.key === 'localUpdatedAt') { setLastSyncTime(e.newValue); } if (!keys.has(e.key)) return; if (e.key === 'funds') { const prevSig = getFundCodesSignature(e.oldValue); const nextSig = getFundCodesSignature(e.newValue); if (prevSig === nextSig) return; } scheduleSync(); }; window.addEventListener('storage', onStorage); return () => { window.removeEventListener('storage', onStorage); if (syncDebounceRef.current) clearTimeout(syncDebounceRef.current); }; }, [getFundCodesSignature, scheduleSync]); const triggerCustomSettingsSync = useCallback(() => { queueMicrotask(() => { dirtyKeysRef.current.add('customSettings'); if (!skipSyncRef.current) { const now = nowInTz().toISOString(); window.localStorage.setItem('localUpdatedAt', now); setLastSyncTime(now); } scheduleSync(); }); }, [scheduleSync]); const applyViewMode = useCallback((mode) => { if (mode !== 'card' && mode !== 'list') return; if (mode !== viewMode) { window.scrollTo({ top: 0, behavior: 'smooth' }); } setViewMode(mode); storageHelper.setItem('viewMode', mode); }, [storageHelper, viewMode]); const toggleFavorite = (code) => { setFavorites(prev => { const next = new Set(prev); if (next.has(code)) { next.delete(code); } else { next.add(code); } storageHelper.setItem('favorites', JSON.stringify(Array.from(next))); if (next.size === 0) setCurrentTab('all'); return next; }); }; const toggleCollapse = (code) => { setCollapsedCodes(prev => { const next = new Set(prev); if (next.has(code)) { next.delete(code); } else { next.add(code); } // 同步到本地存储 storageHelper.setItem('collapsedCodes', JSON.stringify(Array.from(next))); return next; }); }; 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 scheduleDcaTrades = useCallback(async () => { if (!isTradingDay) return; if (!isPlainObject(dcaPlans)) return; const codesSet = new Set(funds.map((f) => f.code)); if (codesSet.size === 0) return; const today = toTz(todayStr).startOf('day'); const nextPlans = { ...dcaPlans }; const newPending = []; // 预加载回溯区间内所有年份的节假日数据 const years = new Set([today.year()]); Object.values(dcaPlans).forEach((plan) => { if (plan?.firstDate) years.add(toTz(plan.firstDate).year()); if (plan?.lastDate) years.add(toTz(plan.lastDate).year()); }); await loadHolidaysForYears([...years]); Object.entries(dcaPlans).forEach(([code, plan]) => { if (!plan || !plan.enabled) return; if (!codesSet.has(code)) return; const amount = Number(plan.amount); const feeRate = Number(plan.feeRate) || 0; if (!amount || amount <= 0) return; const cycle = plan.cycle || 'monthly'; if (!plan.firstDate) return; const first = toTz(plan.firstDate).startOf('day'); if (today.isBefore(first, 'day')) return; const last = plan.lastDate ? toTz(plan.lastDate).startOf('day') : null; // 回溯补单:从 lastDate (若不存在则从 firstDate 前一天) 开始,按周期一直推到今天 let anchor = last ? last : first.clone().subtract(1, 'day'); let current = anchor; let lastGenerated = null; const stepOnce = () => { if (cycle === 'daily') return current.add(1, 'day'); if (cycle === 'weekly') return current.add(1, 'week'); if (cycle === 'biweekly') return current.add(2, 'week'); if (cycle === 'monthly') return current.add(1, 'month'); return current.add(1, 'day'); }; // 循环生成所有 <= today 的应扣款日 while (true) { current = stepOnce(); if (current.isAfter(today, 'day')) break; if (current.isBefore(first, 'day')) continue; // 回溯补单:严格判断该日是否为 A股交易日(排除周末、法定节假日) if (!isDateTradingDay(current)) continue; const dateStr = current.format('YYYY-MM-DD'); const pending = { id: `dca_${code}_${dateStr}_${Date.now()}`, fundCode: code, fundName: (funds.find(f => f.code === code) || {}).name, type: 'buy', share: null, amount, feeRate, feeMode: undefined, feeValue: undefined, date: dateStr, isAfter3pm: false, isDca: true, timestamp: Date.now() }; newPending.push(pending); lastGenerated = current; } if (lastGenerated) { nextPlans[code] = { ...plan, lastDate: lastGenerated.format('YYYY-MM-DD') }; } }); if (newPending.length === 0) { if (JSON.stringify(nextPlans) !== JSON.stringify(dcaPlans)) { setDcaPlans(nextPlans); storageHelper.setItem('dcaPlans', JSON.stringify(nextPlans)); } return; } setDcaPlans(nextPlans); storageHelper.setItem('dcaPlans', JSON.stringify(nextPlans)); setPendingTrades(prev => { const merged = [...(prev || []), ...newPending]; storageHelper.setItem('pendingTrades', JSON.stringify(merged)); return merged; }); showToast(`已生成 ${newPending.length} 笔定投买入`, 'success'); }, [isTradingDay, dcaPlans, funds, todayStr, storageHelper]); useEffect(() => { if (!isTradingDay) return; scheduleDcaTrades().catch((e) => { console.error('[scheduleDcaTrades]', e); }); }, [isTradingDay, scheduleDcaTrades]); const handleAddGroup = (name) => { const newGroup = { id: `group_${Date.now()}`, name, codes: [] }; const next = [...groups, newGroup]; setGroups(next); storageHelper.setItem('groups', JSON.stringify(next)); setCurrentTab(newGroup.id); setGroupModalOpen(false); }; const handleRemoveGroup = (id) => { const next = groups.filter(g => g.id !== id); setGroups(next); storageHelper.setItem('groups', JSON.stringify(next)); if (currentTab === id) setCurrentTab('all'); try { const raw = window.localStorage.getItem('customSettings'); const parsed = raw ? JSON.parse(raw) : {}; if (parsed && typeof parsed === 'object' && parsed[id] !== undefined) { delete parsed[id]; window.localStorage.setItem('customSettings', JSON.stringify(parsed)); triggerCustomSettingsSync(); } } catch { } }; const handleUpdateGroups = (newGroups) => { const removedIds = groups.filter((g) => !newGroups.find((ng) => ng.id === g.id)).map((g) => g.id); setGroups(newGroups); storageHelper.setItem('groups', JSON.stringify(newGroups)); // 如果当前选中的分组被删除了,切换回“全部” if (currentTab !== 'all' && currentTab !== 'fav' && !newGroups.find(g => g.id === currentTab)) { setCurrentTab('all'); } if (removedIds.length > 0) { try { const raw = window.localStorage.getItem('customSettings'); const parsed = raw ? JSON.parse(raw) : {}; if (parsed && typeof parsed === 'object') { let changed = false; removedIds.forEach((groupId) => { if (parsed[groupId] !== undefined) { delete parsed[groupId]; changed = true; } }); if (changed) { window.localStorage.setItem('customSettings', JSON.stringify(parsed)); triggerCustomSettingsSync(); } } } catch { } } }; const handleAddFundsToGroup = (codes) => { if (!codes || codes.length === 0) return; const next = groups.map(g => { if (g.id === currentTab) { return { ...g, codes: Array.from(new Set([...g.codes, ...codes])) }; } return g; }); setGroups(next); storageHelper.setItem('groups', JSON.stringify(next)); setAddFundToGroupOpen(false); setSuccessModal({ open: true, message: `成功添加 ${codes.length} 支基金` }); }; const removeFundFromCurrentGroup = (code) => { const next = groups.map(g => { if (g.id === currentTab) { return { ...g, codes: g.codes.filter(c => c !== code) }; } return g; }); setGroups(next); storageHelper.setItem('groups', JSON.stringify(next)); }; const toggleFundInGroup = (code, groupId) => { const next = groups.map(g => { if (g.id === groupId) { const has = g.codes.includes(code); return { ...g, codes: has ? g.codes.filter(c => c !== code) : [...g.codes, code] }; } return g; }); setGroups(next); storageHelper.setItem('groups', JSON.stringify(next)); }; const handleReorder = (oldIndex, newIndex) => { const movedItem = displayFunds[oldIndex]; const targetItem = displayFunds[newIndex]; if (!movedItem || !targetItem) return; if (currentTab === 'all' || currentTab === 'fav') { const newFunds = [...funds]; const fromIndex = newFunds.findIndex(f => f.code === movedItem.code); if (fromIndex === -1) return; // Remove moved item const [removed] = newFunds.splice(fromIndex, 1); // Find target index in the array (after removal) const toIndex = newFunds.findIndex(f => f.code === targetItem.code); if (toIndex === -1) { // If target not found (should not happen), put it back newFunds.splice(fromIndex, 0, removed); return; } if (oldIndex < newIndex) { // Moving down, insert after target newFunds.splice(toIndex + 1, 0, removed); } else { // Moving up, insert before target newFunds.splice(toIndex, 0, removed); } setFunds(newFunds); storageHelper.setItem('funds', JSON.stringify(newFunds)); } else { const groupIndex = groups.findIndex(g => g.id === currentTab); if (groupIndex > -1) { const group = groups[groupIndex]; const newCodes = [...group.codes]; const fromIndex = newCodes.indexOf(movedItem.code); const toIndex = newCodes.indexOf(targetItem.code); if (fromIndex !== -1 && toIndex !== -1) { newCodes.splice(fromIndex, 1); newCodes.splice(toIndex, 0, movedItem.code); const newGroups = [...groups]; newGroups[groupIndex] = { ...group, codes: newCodes }; setGroups(newGroups); storageHelper.setItem('groups', JSON.stringify(newGroups)); } } } }; // 按 code 去重,保留第一次出现的项,避免列表重复 const dedupeByCode = (list) => { const seen = new Set(); return list.filter((f) => { const c = f?.code; if (!c || seen.has(c)) return false; seen.add(c); return true; }); }; useEffect(() => { let cancelled = false; const init = async () => { try { // 已登录用户:不在此处调用 refreshAll,等 fetchCloudConfig 完成后由 applyCloudConfig 统一刷新 let shouldRefreshFromLocal = true; if (isSupabaseConfigured) { const { data, error } = await supabase.auth.getSession(); if (!cancelled && !error && data?.session?.user) { shouldRefreshFromLocal = false; } } if (cancelled) return; const saved = JSON.parse(localStorage.getItem('funds') || '[]'); if (Array.isArray(saved) && saved.length) { const deduped = dedupeByCode(saved); setFunds(deduped); storageHelper.setItem('funds', JSON.stringify(deduped)); const codes = Array.from(new Set(deduped.map((f) => f.code))); if (codes.length && shouldRefreshFromLocal) refreshAll(codes); } const savedMs = parseInt(localStorage.getItem('refreshMs') || '30000', 10); if (Number.isFinite(savedMs) && savedMs >= 5000) { setRefreshMs(savedMs); setTempSeconds(Math.round(savedMs / 1000)); } // 加载收起状态 const savedCollapsed = JSON.parse(localStorage.getItem('collapsedCodes') || '[]'); if (Array.isArray(savedCollapsed)) { setCollapsedCodes(new Set(savedCollapsed)); } // 加载业绩走势收起状态 const savedTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]'); if (Array.isArray(savedTrends)) { setCollapsedTrends(new Set(savedTrends)); } // 加载估值分时记录(用于分时图) setValuationSeries(getAllValuationSeries()); // 加载自选状态 const savedFavorites = JSON.parse(localStorage.getItem('favorites') || '[]'); if (Array.isArray(savedFavorites)) { setFavorites(new Set(savedFavorites)); } // 加载待处理交易 const savedPending = JSON.parse(localStorage.getItem('pendingTrades') || '[]'); if (Array.isArray(savedPending)) { setPendingTrades(savedPending); } // 加载分组状态 const savedGroups = JSON.parse(localStorage.getItem('groups') || '[]'); if (Array.isArray(savedGroups)) { setGroups(savedGroups); } // 读取用户上次选择的分组(仅本地存储,不同步云端) const savedTab = localStorage.getItem('currentTab'); if ( savedTab === 'all' || savedTab === 'fav' || (savedTab && Array.isArray(savedGroups) && savedGroups.some((g) => g?.id === savedTab)) ) { setCurrentTab(savedTab); } else if (savedTab) { setCurrentTab('all'); } // 加载持仓数据 const savedHoldings = JSON.parse(localStorage.getItem('holdings') || '{}'); if (isPlainObject(savedHoldings)) { setHoldings(savedHoldings); } const savedTransactions = JSON.parse(localStorage.getItem('transactions') || '{}'); if (isPlainObject(savedTransactions)) { setTransactions(savedTransactions); } const savedDcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}'); if (isPlainObject(savedDcaPlans)) { setDcaPlans(savedDcaPlans); } const savedViewMode = localStorage.getItem('viewMode'); if (savedViewMode === 'card' || savedViewMode === 'list') { setViewMode(savedViewMode); } const savedTheme = localStorage.getItem('theme'); if (savedTheme === 'light' || savedTheme === 'dark') { setTheme(savedTheme); } } catch { } if (!cancelled) { hasLocalTabInitRef.current = true; } }; init(); return () => { cancelled = true; }; }, [isSupabaseConfigured]); // 记录用户当前选择的分组(仅本地存储,不同步云端) useEffect(() => { if (!hasLocalTabInitRef.current) return; try { localStorage.setItem('currentTab', currentTab); } catch { } }, [currentTab]); // 主题同步到 document 并持久化 useEffect(() => { document.documentElement.setAttribute('data-theme', theme); try { localStorage.setItem('theme', theme); } catch { } }, [theme]); // 初始化认证状态监听 useEffect(() => { if (!isSupabaseConfigured) { setUser(null); setUserMenuOpen(false); return; } const clearAuthState = () => { setUser(null); setUserMenuOpen(false); }; const handleSession = async (session, event, isExplicitLogin = false) => { if (!session?.user) { if (event === 'SIGNED_OUT' && !isLoggingOutRef.current) { setLoginError('会话已过期,请重新登录'); setLoginModalOpen(true); } isLoggingOutRef.current = false; clearAuthState(); return; } if (session.expires_at && session.expires_at * 1000 <= Date.now()) { isLoggingOutRef.current = true; await supabase.auth.signOut({ scope: 'local' }); try { const storageKeys = Object.keys(localStorage); storageKeys.forEach((key) => { if (key === 'supabase.auth.token' || (key.startsWith('sb-') && key.endsWith('-auth-token'))) { storageHelper.removeItem(key); } }); } catch { } try { const sessionKeys = Object.keys(sessionStorage); sessionKeys.forEach((key) => { if (key === 'supabase.auth.token' || (key.startsWith('sb-') && key.endsWith('-auth-token'))) { sessionStorage.removeItem(key); } }); } catch { } clearAuthState(); setLoginError('会话已过期,请重新登录'); showToast('会话已过期,请重新登录', 'error'); setLoginModalOpen(true); return; } setUser(session.user); if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED' || event === 'INITIAL_SESSION') { setLoginModalOpen(false); setLoginEmail(''); setLoginSuccess(''); setLoginError(''); } // 仅在明确的登录动作(SIGNED_IN)时检查冲突;INITIAL_SESSION(刷新页面等)不检查,直接以云端为准 fetchCloudConfig(session.user.id, isExplicitLogin); }; supabase.auth.getSession().then(async ({ data, error }) => { if (error) { clearAuthState(); return; } await handleSession(data?.session ?? null, 'INITIAL_SESSION'); }); const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => { // INITIAL_SESSION 会由 getSession() 主动触发,这里不再重复处理 if (event === 'INITIAL_SESSION') return; const isExplicitLogin = event === 'SIGNED_IN' && isExplicitLoginRef.current; await handleSession(session ?? null, event, isExplicitLogin); if (event === 'SIGNED_IN') { isExplicitLoginRef.current = false; } }); return () => subscription.unsubscribe(); }, []); // 实时同步 // useEffect(() => { // if (!isSupabaseConfigured || !user?.id) return; // const channel = supabase // .channel(`user-configs-${user.id}`) // .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => { // const incoming = payload?.new?.data; // if (!isPlainObject(incoming)) return; // const incomingComparable = getComparablePayload(incoming); // if (!incomingComparable || incomingComparable === lastSyncedRef.current) return; // await applyCloudConfig(incoming, payload.new.updated_at); // }) // .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => { // const incoming = payload?.new?.data; // if (!isPlainObject(incoming)) return; // const incomingComparable = getComparablePayload(incoming); // if (!incomingComparable || incomingComparable === lastSyncedRef.current) return; // await applyCloudConfig(incoming, payload.new.updated_at); // }) // .subscribe(); // return () => { // supabase.removeChannel(channel); // }; // }, [user?.id]); const handleSendOtp = async (e) => { e.preventDefault(); setLoginError(''); setLoginSuccess(''); if (!isSupabaseConfigured) { showToast('未配置 Supabase,无法登录', 'error'); return; } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!loginEmail.trim()) { setLoginError('请输入邮箱地址'); return; } if (!emailRegex.test(loginEmail.trim())) { setLoginError('请输入有效的邮箱地址'); return; } setLoginLoading(true); try { const { error } = await supabase.auth.signInWithOtp({ email: loginEmail.trim(), options: { shouldCreateUser: true } }); if (error) throw error; setLoginSuccess('验证码已发送,请查收邮箱输入验证码完成注册/登录'); } catch (err) { if (err.message?.includes('rate limit')) { setLoginError('请求过于频繁,请稍后再试'); } else if (err.message?.includes('network')) { setLoginError('网络错误,请检查网络连接'); } else { setLoginError(err.message || '发送验证码失败,请稍后再试'); } } finally { setLoginLoading(false); } }; const handleVerifyEmailOtp = async () => { setLoginError(''); if (!loginOtp || loginOtp.length < 4) { setLoginError('请输入邮箱中的验证码'); return; } if (!isSupabaseConfigured) { showToast('未配置 Supabase,无法登录', 'error'); return; } try { isExplicitLoginRef.current = true; setLoginLoading(true); const { data, error } = await supabase.auth.verifyOtp({ email: loginEmail.trim(), token: loginOtp.trim(), type: 'email' }); if (error) throw error; if (data?.user) { setLoginModalOpen(false); setLoginEmail(''); setLoginOtp(''); setLoginSuccess(''); setLoginError(''); } } catch (err) { setLoginError(err.message || '验证失败,请检查验证码或稍后再试'); isExplicitLoginRef.current = false; } setLoginLoading(false); }; // 登出 const handleLogout = async () => { isLoggingOutRef.current = true; if (!isSupabaseConfigured) { setLoginModalOpen(false); setLoginError(''); setLoginSuccess(''); setLoginEmail(''); setLoginOtp(''); setUserMenuOpen(false); setUser(null); return; } try { const { data: { session } } = await supabase.auth.getSession(); if (session) { const { error } = await supabase.auth.signOut({ scope: 'local' }); if (error && error.code !== 'session_not_found') { throw error; } } } catch (err) { showToast(err.message, 'error') console.error('登出失败', err); } finally { try { await supabase.auth.signOut({ scope: 'local' }); } catch { } try { const storageKeys = Object.keys(localStorage); storageKeys.forEach((key) => { if (key === 'supabase.auth.token' || (key.startsWith('sb-') && key.endsWith('-auth-token'))) { storageHelper.removeItem(key); } }); } catch { } try { const sessionKeys = Object.keys(sessionStorage); sessionKeys.forEach((key) => { if (key === 'supabase.auth.token' || (key.startsWith('sb-') && key.endsWith('-auth-token'))) { sessionStorage.removeItem(key); } }); } catch { } setLoginModalOpen(false); setLoginError(''); setLoginSuccess(''); setLoginEmail(''); setLoginOtp(''); setUserMenuOpen(false); setUser(null); } }; // 关闭用户菜单(点击外部时) const userMenuRef = useRef(null); useEffect(() => { const handleClickOutside = (event) => { if (userMenuRef.current && !userMenuRef.current.contains(event.target)) { setUserMenuOpen(false); } }; if (userMenuOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => document.removeEventListener('mousedown', handleClickOutside); }, [userMenuOpen]); useEffect(() => { refreshCycleStartRef.current = Date.now(); if (timerRef.current) clearInterval(timerRef.current); timerRef.current = setInterval(() => { const codes = Array.from(new Set(funds.map((f) => f.code))); if (codes.length) refreshAll(codes); }, refreshMs); return () => { if (timerRef.current) clearInterval(timerRef.current); }; }, [funds, refreshMs]); const performSearch = async (val) => { if (!val.trim()) { setSearchResults([]); return; } setIsSearching(true); try { const fundsOnly = await searchFunds(val); setSearchResults(fundsOnly); } catch (e) { console.error('搜索失败', e); } finally { setIsSearching(false); } }; const handleSearchInput = (e) => { const val = e.target.value; setSearchTerm(val); if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); searchTimeoutRef.current = setTimeout(() => performSearch(val), 300); }; const toggleSelectFund = (fund) => { setSelectedFunds(prev => { const exists = prev.find(f => f.CODE === fund.CODE); if (exists) { return prev.filter(f => f.CODE !== fund.CODE); } return [...prev, fund]; }); }; const handleScanImportConfirm = async (codes) => { if (!Array.isArray(codes) || codes.length === 0) return; const uniqueCodes = Array.from(new Set(codes)); const toAdd = uniqueCodes.filter(c => !funds.some(f => f.code === c)); if (toAdd.length === 0) { setSuccessModal({ open: true, message: '识别的基金已全部添加' }); return; } setLoading(true); try { const added = []; for (const code of toAdd) { try { const data = await fetchFundData(code); if (data && data.code) { added.push(data); } } catch (e) { console.error(`通过识别导入基金 ${code} 失败`, e); } } if (added.length > 0) { setFunds(prev => { const merged = [...prev, ...added]; const deduped = Array.from(new Map(merged.map(f => [f.code, f])).values()); 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: '未能导入任何基金,请检查截图清晰度' }); } } finally { setLoading(false); } }; const batchAddFunds = async () => { if (selectedFunds.length === 0) return; setLoading(true); setError(''); try { const newFunds = []; for (const f of selectedFunds) { if (funds.some(existing => existing.code === f.CODE)) continue; try { const data = await fetchFundData(f.CODE); newFunds.push(data); } catch (e) { console.error(`添加基金 ${f.CODE} 失败`, e); } } if (newFunds.length > 0) { 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([]); setSearchTerm(''); setSearchResults([]); } catch (e) { setError('批量添加失败'); } finally { setLoading(false); } }; const refreshAll = async (codes) => { if (refreshingRef.current) return; refreshingRef.current = true; setRefreshing(true); const uniqueCodes = Array.from(new Set(codes)); try { const updated = []; for (const c of uniqueCodes) { try { const data = await fetchFundData(c); updated.push(data); } catch (e) { console.error(`刷新基金 ${c} 失败`, e); // 失败时从当前 state 中寻找旧数据 setFunds(prev => { const old = prev.find((f) => f.code === c); if (old) updated.push(old); return prev; }); } } if (updated.length > 0) { setFunds(prev => { // 将更新后的数据合并回当前最新的 state 中,防止覆盖掉刚刚导入的数据 const merged = [...prev]; updated.forEach(u => { const idx = merged.findIndex(f => f.code === u.code); if (idx > -1) { merged[idx] = u; } else { merged.push(u); } }); const deduped = dedupeByCode(merged); 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); } finally { refreshingRef.current = false; setRefreshing(false); refreshCycleStartRef.current = Date.now(); try { await processPendingQueue(); }catch (e) { showToast('待交易队列计算出错', 'error') } } }; const toggleViewMode = () => { const nextMode = viewMode === 'card' ? 'list' : 'card'; applyViewMode(nextMode); }; const requestRemoveFund = (fund) => { const h = holdings[fund.code]; const hasHolding = h && isNumber(h.share) && h.share > 0; if (hasHolding) { setFundDeleteConfirm({ code: fund.code, name: fund.name }); } else { fundDetailDrawerCloseRef.current?.(); fundDetailDialogCloseRef.current?.(); removeFund(fund.code); } }; const addFund = async (e) => { e?.preventDefault?.(); setError(''); const manualTokens = String(searchTerm || '') .split(/[^0-9A-Za-z]+/) .map(t => t.trim()) .filter(t => t.length > 0); const selectedCodes = Array.from(new Set([ ...selectedFunds.map(f => f.CODE), ...manualTokens.filter(t => /^\d{6}$/.test(t)) ])); if (selectedCodes.length === 0) { setError('请输入或选择基金代码'); return; } const nameMap = {}; selectedFunds.forEach(f => { nameMap[f.CODE] = f.NAME; }); const fundsToConfirm = selectedCodes.map(code => ({ code, name: nameMap[code] || '', status: funds.some(f => f.code === code) ? 'added' : 'pending' })); const pendingCodes = fundsToConfirm.filter(f => f.status === 'pending').map(f => f.code); if (pendingCodes.length === 0) { setError('所选基金已全部添加'); return; } setScannedFunds(fundsToConfirm); setSelectedScannedCodes(new Set(pendingCodes)); setIsOcrScan(false); setScanConfirmModalOpen(true); setSearchTerm(''); setSelectedFunds([]); setShowDropdown(false); inputRef.current?.blur(); setIsSearchFocused(false); }; const removeFund = (removeCode) => { const next = funds.filter((f) => f.code !== removeCode); setFunds(next); storageHelper.setItem('funds', JSON.stringify(next)); // 同步删除分组中的失效代码 const nextGroups = groups.map(g => ({ ...g, codes: g.codes.filter(c => c !== removeCode) })); setGroups(nextGroups); storageHelper.setItem('groups', JSON.stringify(nextGroups)); // 同步删除展开收起状态 setCollapsedCodes(prev => { if (!prev.has(removeCode)) return prev; const nextSet = new Set(prev); nextSet.delete(removeCode); storageHelper.setItem('collapsedCodes', JSON.stringify(Array.from(nextSet))); 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; const nextSet = new Set(prev); nextSet.delete(removeCode); storageHelper.setItem('favorites', JSON.stringify(Array.from(nextSet))); if (nextSet.size === 0) setCurrentTab('all'); return nextSet; }); // 同步删除持仓数据 setHoldings(prev => { if (!prev[removeCode]) return prev; const next = { ...prev }; delete next[removeCode]; storageHelper.setItem('holdings', JSON.stringify(next)); return next; }); // 同步删除待处理交易 setPendingTrades(prev => { const next = prev.filter((trade) => trade?.fundCode !== removeCode); storageHelper.setItem('pendingTrades', JSON.stringify(next)); return next; }); // 同步删除该基金的交易记录 setTransactions(prev => { if (!prev[removeCode]) return prev; const next = { ...prev }; delete next[removeCode]; 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; }); // 同步删除该基金的定投计划 setDcaPlans(prev => { if (!prev || !prev[removeCode]) return prev; const next = { ...prev }; delete next[removeCode]; storageHelper.setItem('dcaPlans', JSON.stringify(next)); return next; }); }; const manualRefresh = async () => { if (refreshingRef.current) return; const codes = Array.from(new Set(funds.map((f) => f.code))); if (!codes.length) return; await refreshAll(codes); }; const saveSettings = (e, secondsOverride) => { e?.preventDefault?.(); const seconds = secondsOverride ?? tempSeconds; const ms = Math.max(30, Number(seconds)) * 1000; setTempSeconds(Math.round(ms / 1000)); setRefreshMs(ms); storageHelper.setItem('refreshMs', String(ms)); const w = Math.min(2000, Math.max(600, Number(containerWidth) || 1200)); setContainerWidth(w); try { const raw = window.localStorage.getItem('customSettings'); const parsed = raw ? JSON.parse(raw) : {}; window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: w })); triggerCustomSettingsSync(); } catch { } setSettingsOpen(false); }; const handleResetContainerWidth = () => { setContainerWidth(1200); try { const raw = window.localStorage.getItem('customSettings'); const parsed = raw ? JSON.parse(raw) : {}; window.localStorage.setItem('customSettings', JSON.stringify({ ...parsed, pcContainerWidth: 1200 })); triggerCustomSettingsSync(); } catch { } }; const importFileRef = useRef(null); const [importMsg, setImportMsg] = useState(''); const normalizeCode = (value) => String(value || '').trim(); const normalizeNumber = (value) => { if (value === null || value === undefined || value === '') return null; const num = Number(value); return Number.isFinite(num) ? num : null; }; function getComparablePayload(payload) { if (!isPlainObject(payload)) return ''; const rawFunds = Array.isArray(payload.funds) ? payload.funds : []; const fundCodes = rawFunds .map((fund) => normalizeCode(fund?.code || fund?.CODE)) .filter(Boolean); const uniqueFundCodes = Array.from(new Set(fundCodes)).sort(); const favorites = Array.isArray(payload.favorites) ? Array.from(new Set(payload.favorites.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort() : []; const collapsedCodes = Array.isArray(payload.collapsedCodes) ? 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) => { const id = normalizeCode(group?.id); if (!id) return null; const name = isString(group?.name) ? group.name : ''; const codes = Array.isArray(group?.codes) ? Array.from(new Set(group.codes.map(normalizeCode).filter((code) => uniqueFundCodes.includes(code)))).sort() : []; return { id, name, codes }; }) .filter(Boolean) .sort((a, b) => a.id.localeCompare(b.id)) : []; const holdingsSource = isPlainObject(payload.holdings) ? payload.holdings : {}; const holdings = {}; Object.keys(holdingsSource) .map(normalizeCode) .filter((code) => uniqueFundCodes.includes(code)) .sort() .forEach((code) => { const value = holdingsSource[code] || {}; const share = normalizeNumber(value.share); const cost = normalizeNumber(value.cost); if (share === null && cost === null) return; holdings[code] = { share, cost }; }); const pendingTrades = Array.isArray(payload.pendingTrades) ? payload.pendingTrades .map((trade) => { const fundCode = normalizeCode(trade?.fundCode); if (!fundCode) return null; return { id: trade?.id ? String(trade.id) : '', fundCode, type: trade?.type || '', share: normalizeNumber(trade?.share), amount: normalizeNumber(trade?.amount), feeRate: normalizeNumber(trade?.feeRate), feeMode: trade?.feeMode || '', feeValue: normalizeNumber(trade?.feeValue), date: trade?.date || '', isAfter3pm: !!trade?.isAfter3pm, isDca: !!trade?.isDca }; }) .filter((trade) => trade && uniqueFundCodes.includes(trade.fundCode)) .sort((a, b) => { const keyA = a.id || `${a.fundCode}|${a.type}|${a.date}|${a.share ?? ''}|${a.amount ?? ''}|${a.feeMode}|${a.feeValue ?? ''}|${a.feeRate ?? ''}|${a.isAfter3pm ? 1 : 0}|${a.isDca ? 1 : 0}`; const keyB = b.id || `${b.fundCode}|${b.type}|${b.date}|${b.share ?? ''}|${b.amount ?? ''}|${b.feeMode}|${b.feeValue ?? ''}|${b.feeRate ?? ''}|${b.isAfter3pm ? 1 : 0}|${b.isDca ? 1 : 0}`; return keyA.localeCompare(keyB); }) : []; const transactionsSource = isPlainObject(payload.transactions) ? payload.transactions : {}; const transactions = {}; Object.keys(transactionsSource) .map(normalizeCode) .filter((code) => uniqueFundCodes.includes(code)) .sort() .forEach((code) => { const list = Array.isArray(transactionsSource[code]) ? transactionsSource[code] : []; const normalized = list .map((t) => { const id = t?.id ? String(t.id) : ''; const type = t?.type || ''; const share = normalizeNumber(t?.share); const amount = normalizeNumber(t?.amount); const price = normalizeNumber(t?.price); const date = t?.date || ''; const timestamp = Number.isFinite(t?.timestamp) ? t.timestamp : 0; const isDca = !!t?.isDca; return { id, type, share, amount, price, date, timestamp, isDca }; }) .filter((t) => t.id || t.timestamp) .sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); if (normalized.length > 0) transactions[code] = normalized; }); const dcaSource = isPlainObject(payload.dcaPlans) ? payload.dcaPlans : {}; const dcaPlans = {}; Object.keys(dcaSource) .map(normalizeCode) .filter((code) => uniqueFundCodes.includes(code)) .sort() .forEach((code) => { const plan = dcaSource[code] || {}; const amount = normalizeNumber(plan.amount); const feeRate = normalizeNumber(plan.feeRate); const cycle = ['daily', 'weekly', 'biweekly', 'monthly'].includes(plan.cycle) ? plan.cycle : ''; const firstDate = plan.firstDate ? String(plan.firstDate) : ''; const enabled = !!plan.enabled; const weeklyDay = normalizeNumber(plan.weeklyDay); const monthlyDay = normalizeNumber(plan.monthlyDay); const lastDate = plan.lastDate ? String(plan.lastDate) : ''; if (amount === null && feeRate === null && !cycle && !firstDate && !enabled && weeklyDay === null && monthlyDay === null && !lastDate) return; dcaPlans[code] = { amount, feeRate, cycle, firstDate, enabled, weeklyDay: weeklyDay !== null ? weeklyDay : null, monthlyDay: monthlyDay !== null ? monthlyDay : null, lastDate }; }); const customSettings = isPlainObject(payload.customSettings) ? payload.customSettings : {}; return JSON.stringify({ funds: uniqueFundCodes, favorites, groups, collapsedCodes, collapsedTrends, refreshMs: Number.isFinite(payload.refreshMs) ? payload.refreshMs : 30000, holdings, pendingTrades, transactions, dcaPlans, customSettings }); } const collectLocalPayload = (keys = null) => { try { const all = {}; // 不包含 fundValuationTimeseries,该数据暂不同步到云端 if (!keys || keys.has('funds')) { all.funds = JSON.parse(localStorage.getItem('funds') || '[]'); } if (!keys || keys.has('favorites')) { all.favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); } if (!keys || keys.has('groups')) { all.groups = JSON.parse(localStorage.getItem('groups') || '[]'); } if (!keys || keys.has('collapsedCodes')) { all.collapsedCodes = JSON.parse(localStorage.getItem('collapsedCodes') || '[]'); } if (!keys || keys.has('collapsedTrends')) { all.collapsedTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]'); } if (!keys || keys.has('refreshMs')) { all.refreshMs = parseInt(localStorage.getItem('refreshMs') || '30000', 10); } if (!keys || keys.has('holdings')) { all.holdings = JSON.parse(localStorage.getItem('holdings') || '{}'); } if (!keys || keys.has('pendingTrades')) { all.pendingTrades = JSON.parse(localStorage.getItem('pendingTrades') || '[]'); } if (!keys || keys.has('transactions')) { all.transactions = JSON.parse(localStorage.getItem('transactions') || '{}'); } if (!keys || keys.has('dcaPlans')) { all.dcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}'); } if (!keys || keys.has('customSettings')) { try { all.customSettings = JSON.parse(localStorage.getItem('customSettings') || '{}'); } catch { all.customSettings = {}; } } // 如果是全量收集(keys 为 null),进行完整的数据清洗和验证逻辑 if (!keys) { const fundCodes = new Set( Array.isArray(all.funds) ? all.funds.map((f) => f?.code).filter(Boolean) : [] ); const cleanedHoldings = isPlainObject(all.holdings) ? Object.entries(all.holdings).reduce((acc, [code, value]) => { if (!fundCodes.has(code) || !isPlainObject(value)) return acc; const parsedShare = isNumber(value.share) ? value.share : isString(value.share) ? Number(value.share) : NaN; const parsedCost = isNumber(value.cost) ? value.cost : isString(value.cost) ? Number(value.cost) : NaN; const nextShare = Number.isFinite(parsedShare) ? parsedShare : null; const nextCost = Number.isFinite(parsedCost) ? parsedCost : null; if (nextShare === null && nextCost === null) return acc; acc[code] = { ...value, share: nextShare, cost: nextCost }; return acc; }, {}) : {}; const cleanedFavorites = Array.isArray(all.favorites) ? all.favorites.filter((code) => fundCodes.has(code)) : []; const cleanedCollapsed = Array.isArray(all.collapsedCodes) ? all.collapsedCodes.filter((code) => fundCodes.has(code)) : []; const cleanedCollapsedTrends = Array.isArray(all.collapsedTrends) ? all.collapsedTrends.filter((code) => fundCodes.has(code)) : []; const cleanedGroups = Array.isArray(all.groups) ? all.groups.map(g => ({ ...g, codes: Array.isArray(g.codes) ? g.codes.filter(c => fundCodes.has(c)) : [] })) : []; const cleanedDcaPlans = isPlainObject(all.dcaPlans) ? Object.entries(all.dcaPlans).reduce((acc, [code, plan]) => { if (!fundCodes.has(code) || !isPlainObject(plan)) return acc; acc[code] = plan; return acc; }, {}) : {}; return { funds: all.funds, favorites: cleanedFavorites, groups: cleanedGroups, collapsedCodes: cleanedCollapsed, collapsedTrends: cleanedCollapsedTrends, refreshMs: all.refreshMs, holdings: cleanedHoldings, pendingTrades: all.pendingTrades, transactions: all.transactions, dcaPlans: cleanedDcaPlans, customSettings: isPlainObject(all.customSettings) ? all.customSettings : {} }; } // 如果是部分收集,直接返回读取到的字段 return all; } catch { // 安全回退:如果是增量更新失败,返回空对象避免覆盖;全量更新则返回默认空配置 if (keys) return {}; return { funds: [], favorites: [], groups: [], collapsedCodes: [], collapsedTrends: [], refreshMs: 30000, holdings: {}, pendingTrades: [], transactions: {}, dcaPlans: {}, customSettings: {}, exportedAt: nowInTz().toISOString() }; } }; const applyCloudConfig = async (cloudData, cloudUpdatedAt) => { if (!isPlainObject(cloudData)) return; skipSyncRef.current = true; try { if (cloudUpdatedAt) { storageHelper.setItem('localUpdatedAt', cloudUpdatedAt); } const nextFunds = Array.isArray(cloudData.funds) ? dedupeByCode(cloudData.funds) : []; setFunds(nextFunds); storageHelper.setItem('funds', JSON.stringify(nextFunds)); const nextFundCodes = new Set(nextFunds.map((f) => f.code)); const nextFavorites = Array.isArray(cloudData.favorites) ? cloudData.favorites : []; setFavorites(new Set(nextFavorites)); storageHelper.setItem('favorites', JSON.stringify(nextFavorites)); const nextGroups = Array.isArray(cloudData.groups) ? cloudData.groups : []; setGroups(nextGroups); storageHelper.setItem('groups', JSON.stringify(nextGroups)); const nextCollapsed = Array.isArray(cloudData.collapsedCodes) ? cloudData.collapsedCodes : []; setCollapsedCodes(new Set(nextCollapsed)); storageHelper.setItem('collapsedCodes', JSON.stringify(nextCollapsed)); const nextRefreshMs = Number.isFinite(cloudData.refreshMs) && cloudData.refreshMs >= 5000 ? cloudData.refreshMs : 30000; setRefreshMs(nextRefreshMs); setTempSeconds(Math.round(nextRefreshMs / 1000)); storageHelper.setItem('refreshMs', String(nextRefreshMs)); const nextHoldings = isPlainObject(cloudData.holdings) ? cloudData.holdings : {}; setHoldings(nextHoldings); storageHelper.setItem('holdings', JSON.stringify(nextHoldings)); const nextPendingTrades = Array.isArray(cloudData.pendingTrades) ? cloudData.pendingTrades.filter((trade) => trade && nextFundCodes.has(trade.fundCode)) : []; setPendingTrades(nextPendingTrades); storageHelper.setItem('pendingTrades', JSON.stringify(nextPendingTrades)); const nextTransactions = isPlainObject(cloudData.transactions) ? cloudData.transactions : {}; setTransactions(nextTransactions); storageHelper.setItem('transactions', JSON.stringify(nextTransactions)); const cloudDca = isPlainObject(cloudData.dcaPlans) ? cloudData.dcaPlans : {}; const nextDcaPlans = Object.entries(cloudDca).reduce((acc, [code, plan]) => { if (!nextFundCodes.has(code) || !isPlainObject(plan)) return acc; acc[code] = plan; return acc; }, {}); setDcaPlans(nextDcaPlans); storageHelper.setItem('dcaPlans', JSON.stringify(nextDcaPlans)); if (isPlainObject(cloudData.customSettings)) { try { const merged = { ...JSON.parse(localStorage.getItem('customSettings') || '{}'), ...cloudData.customSettings }; window.localStorage.setItem('customSettings', JSON.stringify(merged)); } catch { } } if (nextFunds.length) { const codes = Array.from(new Set(nextFunds.map((f) => f.code))); if (codes.length) await refreshAll(codes); // 刷新完成后,强制同步本地localStorage 的 funds 数据到云端 const currentUserId = userIdRef.current || user?.id; if (currentUserId) { try { const latestFunds = JSON.parse(localStorage.getItem('funds') || '[]'); const localSig = getFundCodesSignature(latestFunds, ['gztime']); const cloudSig = getFundCodesSignature(Array.isArray(cloudData.funds) ? cloudData.funds : [], ['gztime']); if (localSig !== cloudSig) { await syncUserConfig( currentUserId, false, { funds: Array.isArray(latestFunds) ? latestFunds : [] }, true ); } } catch (e) { console.error('刷新后强制同步 funds 到云端失败', e); } } } const payload = collectLocalPayload(); lastSyncedRef.current = getComparablePayload(payload); } finally { skipSyncRef.current = false; } }; const fetchCloudConfig = async (userId, checkConflict = false) => { if (!userId) return; try { const { data: meta, error: metaError } = await supabase .from('user_configs') .select(`id, updated_at${checkConflict ? ', data' : ''}`) .eq('user_id', userId) .maybeSingle(); if (metaError) throw metaError; if (!meta?.id) { const { error: insertError } = await supabase .from('user_configs') .insert({ user_id: userId }); if (insertError) throw insertError; setCloudConfigModal({ open: true, userId, type: 'empty' }); return; } if (checkConflict) { setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: meta.data }); return; } const localUpdatedAt = window.localStorage.getItem('localUpdatedAt'); if (localUpdatedAt && meta.updated_at && new Date(meta.updated_at) < new Date(localUpdatedAt)) { return; } const { data, error } = await supabase .from('user_configs') .select('id, data, updated_at') .eq('user_id', userId) .maybeSingle(); if (error) throw error; if (data?.data && isPlainObject(data.data) && Object.keys(data.data).length > 0) { const localPayload = collectLocalPayload(); const localComparable = getComparablePayload(localPayload); const cloudComparable = getComparablePayload(data.data); if (localComparable !== cloudComparable) { // 如果数据不一致 if (checkConflict) { // 只有明确要求检查冲突时才提示(例如刚登录时) setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data }); return; } // 否则直接覆盖本地(例如已登录状态下的刷新) await applyCloudConfig(data.data, data.updated_at); return; } await applyCloudConfig(data.data, data.updated_at); return; } setCloudConfigModal({ open: true, userId, type: 'empty' }); } catch (e) { console.error('获取云端配置失败', e); } }; const syncUserConfig = async (userId, showTip = true, payload = null, isPartial = false) => { if (!userId) { showToast(`userId 不存在,请重新登录`, 'error'); return; } try { setIsSyncing(true); const dataToSync = payload || collectLocalPayload(); // Fallback to full sync if no payload const now = nowInTz().toISOString(); if (isPartial) { // 增量更新:使用 RPC 调用 const { error: rpcError } = await supabase.rpc('update_user_config_partial', { payload: dataToSync }); if (rpcError) { console.error('增量同步失败,尝试全量同步', rpcError); // RPC 失败回退到全量更新 const fullPayload = collectLocalPayload(); const { error } = await supabase .from('user_configs') .upsert( { user_id: userId, data: fullPayload, updated_at: now }, { onConflict: 'user_id' } ); if (error) throw error; } } else { // 全量更新 const { error } = await supabase .from('user_configs') .upsert( { user_id: userId, data: dataToSync, updated_at: now }, { onConflict: 'user_id' } ); if (error) throw error; } storageHelper.setItem('localUpdatedAt', now); if (showTip) { setSuccessModal({ open: true, message: '已同步云端配置' }); } } catch (e) { console.error('同步云端配置异常', e); // 临时关闭同步异常提示 // showToast(`同步云端配置异常:${e}`, 'error'); } finally { setIsSyncing(false); } }; const handleSyncLocalConfig = async () => { const userId = cloudConfigModal.userId; setCloudConfigModal({ open: false, userId: null }); await syncUserConfig(userId); }; const exportLocalData = async () => { try { const payload = { funds: JSON.parse(localStorage.getItem('funds') || '[]'), favorites: JSON.parse(localStorage.getItem('favorites') || '[]'), groups: JSON.parse(localStorage.getItem('groups') || '[]'), collapsedCodes: JSON.parse(localStorage.getItem('collapsedCodes') || '[]'), collapsedTrends: JSON.parse(localStorage.getItem('collapsedTrends') || '[]'), refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10), viewMode: localStorage.getItem('viewMode') === 'list' ? 'list' : 'card', holdings: JSON.parse(localStorage.getItem('holdings') || '{}'), pendingTrades: JSON.parse(localStorage.getItem('pendingTrades') || '[]'), transactions: JSON.parse(localStorage.getItem('transactions') || '{}'), dcaPlans: JSON.parse(localStorage.getItem('dcaPlans') || '{}'), exportedAt: nowInTz().toISOString() }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); if (window.showSaveFilePicker) { const handle = await window.showSaveFilePicker({ suggestedName: `realtime-fund-config-${Date.now()}.json`, types: [{ description: 'JSON', accept: { 'application/json': ['.json'] } }] }); const writable = await handle.createWritable(); await writable.write(blob); await writable.close(); setSuccessModal({ open: true, message: '导出成功' }); setSettingsOpen(false); return; } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `realtime-fund-config-${Date.now()}.json`; let done = false; const finish = () => { if (done) return; done = true; URL.revokeObjectURL(url); setSuccessModal({ open: true, message: '导出成功' }); setSettingsOpen(false); }; const onVisibility = () => { if (document.visibilityState === 'hidden') return; finish(); document.removeEventListener('visibilitychange', onVisibility); }; document.addEventListener('visibilitychange', onVisibility, { once: true }); a.click(); setTimeout(finish, 3000); } catch (err) { console.error('Export error:', err); } }; const handleImportFileChange = async (e) => { try { const file = e.target.files?.[0]; if (!file) return; const text = await file.text(); const data = JSON.parse(text); if (isPlainObject(data)) { // 从 localStorage 读取最新数据进行合并,防止状态滞后导致的数据丢失 const currentFunds = JSON.parse(localStorage.getItem('funds') || '[]'); const currentFavorites = JSON.parse(localStorage.getItem('favorites') || '[]'); const currentGroups = JSON.parse(localStorage.getItem('groups') || '[]'); const currentCollapsed = JSON.parse(localStorage.getItem('collapsedCodes') || '[]'); const currentTrends = JSON.parse(localStorage.getItem('collapsedTrends') || '[]'); const currentPendingTrades = JSON.parse(localStorage.getItem('pendingTrades') || '[]'); const currentDcaPlans = JSON.parse(localStorage.getItem('dcaPlans') || '{}'); let mergedFunds = currentFunds; let appendedCodes = []; if (Array.isArray(data.funds)) { const incomingFunds = dedupeByCode(data.funds); const existingCodes = new Set(currentFunds.map(f => f.code)); const newItems = incomingFunds.filter(f => f && f.code && !existingCodes.has(f.code)); appendedCodes = newItems.map(f => f.code); mergedFunds = [...currentFunds, ...newItems]; setFunds(mergedFunds); storageHelper.setItem('funds', JSON.stringify(mergedFunds)); } if (Array.isArray(data.favorites)) { const mergedFav = Array.from(new Set([...currentFavorites, ...data.favorites])); setFavorites(new Set(mergedFav)); storageHelper.setItem('favorites', JSON.stringify(mergedFav)); } if (Array.isArray(data.groups)) { // 合并分组:如果 ID 相同则合并 codes,否则添加新分组 const mergedGroups = [...currentGroups]; data.groups.forEach(incomingGroup => { const existingIdx = mergedGroups.findIndex(g => g.id === incomingGroup.id); if (existingIdx > -1) { mergedGroups[existingIdx] = { ...mergedGroups[existingIdx], codes: Array.from(new Set([...mergedGroups[existingIdx].codes, ...(incomingGroup.codes || [])])) }; } else { mergedGroups.push(incomingGroup); } }); setGroups(mergedGroups); storageHelper.setItem('groups', JSON.stringify(mergedGroups)); } if (Array.isArray(data.collapsedCodes)) { const mergedCollapsed = Array.from(new Set([...currentCollapsed, ...data.collapsedCodes])); setCollapsedCodes(new Set(mergedCollapsed)); storageHelper.setItem('collapsedCodes', JSON.stringify(mergedCollapsed)); } if (Array.isArray(data.collapsedTrends)) { const mergedTrends = Array.from(new Set([...currentTrends, ...data.collapsedTrends])); setCollapsedTrends(new Set(mergedTrends)); storageHelper.setItem('collapsedTrends', JSON.stringify(mergedTrends)); } if (isNumber(data.refreshMs) && data.refreshMs >= 5000) { setRefreshMs(data.refreshMs); setTempSeconds(Math.round(data.refreshMs / 1000)); storageHelper.setItem('refreshMs', String(data.refreshMs)); } if (data.viewMode === 'card' || data.viewMode === 'list') { applyViewMode(data.viewMode); } if (isPlainObject(data.holdings)) { const mergedHoldings = { ...JSON.parse(localStorage.getItem('holdings') || '{}'), ...data.holdings }; setHoldings(mergedHoldings); storageHelper.setItem('holdings', JSON.stringify(mergedHoldings)); } if (isPlainObject(data.transactions)) { const currentTransactions = JSON.parse(localStorage.getItem('transactions') || '{}'); const mergedTransactions = { ...currentTransactions }; Object.entries(data.transactions).forEach(([code, txs]) => { if (!Array.isArray(txs)) return; const existing = mergedTransactions[code] || []; const existingIds = new Set(existing.map(t => t.id)); const newTxs = txs.filter(t => !existingIds.has(t.id)); mergedTransactions[code] = [...existing, ...newTxs].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); }); setTransactions(mergedTransactions); storageHelper.setItem('transactions', JSON.stringify(mergedTransactions)); } if (Array.isArray(data.pendingTrades)) { const existingPending = Array.isArray(currentPendingTrades) ? currentPendingTrades : []; const incomingPending = data.pendingTrades.filter((trade) => trade && trade.fundCode); const fundCodeSet = new Set(mergedFunds.map((f) => f.code)); const keyOf = (trade) => { if (trade?.id) return `id:${trade.id}`; return `k:${trade?.fundCode || ''}:${trade?.type || ''}:${trade?.date || ''}:${trade?.share || ''}:${trade?.amount || ''}:${trade?.isAfter3pm ? 1 : 0}`; }; const mergedPendingMap = new Map(); existingPending.forEach((trade) => { if (!trade || !fundCodeSet.has(trade.fundCode)) return; mergedPendingMap.set(keyOf(trade), trade); }); incomingPending.forEach((trade) => { if (!fundCodeSet.has(trade.fundCode)) return; mergedPendingMap.set(keyOf(trade), trade); }); const mergedPending = Array.from(mergedPendingMap.values()); setPendingTrades(mergedPending); storageHelper.setItem('pendingTrades', JSON.stringify(mergedPending)); } if (isPlainObject(data.dcaPlans)) { const mergedDca = { ...(isPlainObject(currentDcaPlans) ? currentDcaPlans : {}), ...data.dcaPlans }; setDcaPlans(mergedDca); storageHelper.setItem('dcaPlans', JSON.stringify(mergedDca)); } // 导入成功后,仅刷新新追加的基金 if (appendedCodes.length) { // 这里需要确保 refreshAll 不会因为闭包问题覆盖掉刚刚合并好的 mergedFunds // 我们直接传入所有代码执行一次全量刷新是最稳妥的,或者修改 refreshAll 支持增量更新 const allCodes = mergedFunds.map(f => f.code); await refreshAll(allCodes); } setSuccessModal({ open: true, message: '导入成功' }); setSettingsOpen(false); // 导入成功自动关闭设置弹框 if (importFileRef.current) importFileRef.current.value = ''; } } catch (err) { console.error('Import error:', err); setImportMsg('导入失败,请检查文件格式'); setTimeout(() => setImportMsg(''), 4000); if (importFileRef.current) importFileRef.current.value = ''; } }; useEffect(() => { const isAnyModalOpen = settingsOpen || feedbackOpen || addResultOpen || addFundToGroupOpen || groupManageOpen || groupModalOpen || successModal.open || cloudConfigModal.open || logoutConfirmOpen || holdingModal.open || actionModal.open || tradeModal.open || dcaModal.open || addHistoryModal.open || historyModal.open || loginModalOpen || !!clearConfirm || donateOpen || !!fundDeleteConfirm || updateModalOpen || weChatOpen || scanModalOpen || scanConfirmModalOpen || isScanning || isScanImporting; if (isAnyModalOpen) { containerRef.current.style.overflow = 'hidden'; } else { containerRef.current.style.overflow = ''; } return () => { containerRef.current.style.overflow = ''; }; }, [ settingsOpen, feedbackOpen, addResultOpen, addFundToGroupOpen, groupManageOpen, groupModalOpen, successModal.open, cloudConfigModal.open, logoutConfirmOpen, holdingModal.open, actionModal.open, tradeModal.open, dcaModal.open, addHistoryModal.open, historyModal.open, loginModalOpen, clearConfirm, donateOpen, fundDeleteConfirm, updateModalOpen, weChatOpen, scanModalOpen, scanConfirmModalOpen, isScanning, isScanImporting ]); useEffect(() => { const onKey = (ev) => { if (ev.key === 'Escape' && settingsOpen) setSettingsOpen(false); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [settingsOpen]); const getGroupName = () => { if (currentTab === 'all') return '全部资产'; if (currentTab === 'fav') return '自选资产'; const group = groups.find(g => g.id === currentTab); return group ? `${group.name}资产` : '分组资产'; }; return (
{showThemeTransition && ( setShowThemeTransition(false)} /> )}
{refreshing &&
}
0) ? 'search-focused-sibling' : ''}`}>
{/* 同步中图标 */} {/* 默认图标 */}
基估宝
0) ? 'search-focused' : ''}`} role="region" aria-label="添加基金">
{selectedFunds.length > 0 && (
{selectedFunds.map(fund => (
{fund.NAME}
))}
)}
{ setShowDropdown(true); setIsSearchFocused(true); }} onBlur={() => { // 延迟关闭,以允许点击搜索结果 setTimeout(() => setIsSearchFocused(false), 200); }} style={{ flex: 1 }} />
{isSearching &&
}
{showDropdown && (searchTerm.trim() || searchResults.length > 0) && ( {searchResults.length > 0 ? (
{searchResults.map((fund) => { const isSelected = selectedFunds.some(f => f.CODE === fund.CODE); const isAlreadyAdded = funds.some(f => f.code === fund.CODE); return (
e.preventDefault()} onClick={() => { if (isAlreadyAdded) return; toggleSelectFund(fund); }} >
{fund.NAME} #{fund.CODE} | {fund.TYPE}
{isAlreadyAdded ? ( 已添加 ) : (
{isSelected &&
}
)}
); })}
) : searchTerm.trim() && !isSearching ? (
未找到相关基金
) : null} )}
{error &&
{error}
}
0) ? 'search-focused-sibling' : ''}`}> {hasUpdate && (
setUpdateModalOpen(true)} >
)} 项目Github地址 window.open("https://github.com/hzm0321/real-time-fund")} /> {isMobile && ( )} {/* setSettingsOpen(true)}*/} {/* title="设置"*/} {/* hidden*/} {/*>*/} {/* */} {/**/} {/* 用户菜单 */}
{userMenuOpen && ( {user ? ( <>
{userAvatar ? ( 用户头像 ) : ( (user.email?.charAt(0).toUpperCase() || 'U') )}
{user.email} 已登录 {lastSyncTime && ( 同步于 {dayjs(lastSyncTime).format('MM-DD HH:mm')} )}
) : ( <> )} )}
setCurrentTab('all')} transition={{ type: 'spring', stiffness: 500, damping: 30, mass: 1 }} > 全部 ({funds.length}) setCurrentTab('fav')} transition={{ type: 'spring', stiffness: 500, damping: 30, mass: 1 }} > 自选 ({favorites.size}) {groups.map(g => ( setCurrentTab(g.id)} transition={{ type: 'spring', stiffness: 500, damping: 30, mass: 1 }} > {g.name} ({g.codes.length}) ))}
{groups.length > 0 && ( )}
排序
{[ { id: 'default', label: '默认' }, { id: 'yield', label: '涨跌幅' }, { id: 'holding', label: '持有收益' }, { id: 'name', label: '名称' }, ].map((s) => ( ))}
{displayFunds.length === 0 ? ( setAddFundToGroupOpen(true)} /> ) : ( <> setMaskAmounts((v) => !v)} /> {currentTab !== 'all' && currentTab !== 'fav' && ( setAddFundToGroupOpen(true)} style={{ width: '100%', height: '48px', border: '2px dashed var(--border)', background: 'transparent', borderRadius: '12px', color: 'var(--muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', marginBottom: '16px', cursor: 'pointer', transition: 'all 0.2s ease', fontSize: '14px', fontWeight: 500 }} onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--primary)'; e.currentTarget.style.color = 'var(--primary)'; e.currentTarget.style.background = 'rgba(34, 211, 238, 0.05)'; }} onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.color = 'var(--muted)'; e.currentTarget.style.background = 'transparent'; }} > 添加基金到此分组 )}
{/* PC 列表:使用 PcFundTable + 右侧冻结操作列 */} {viewMode === 'list' && !isMobile && (
{ if (refreshing) return; if (!row || !row.code) return; requestRemoveFund({ code: row.code, name: row.fundName }); }} onToggleFavorite={(row) => { if (!row || !row.code) return; toggleFavorite(row.code); }} onRemoveFromGroup={(row) => { if (!row || !row.code) return; removeFundFromCurrentGroup(row.code); }} onHoldingAmountClick={(row, meta) => { if (!row || !row.code) return; const fund = row.rawFund || { code: row.code, name: row.fundName }; if (meta?.hasHolding) { setActionModal({ open: true, fund }); } else { setHoldingModal({ open: true, fund }); } }} onHoldingProfitClick={(row) => { if (!row || !row.code) return; if (row.holdingProfitValue == null) return; setPercentModes(prev => ({ ...prev, [row.code]: !prev[row.code] })); }} onCustomSettingsChange={triggerCustomSettingsSync} closeDialogRef={fundDetailDialogCloseRef} blockDialogClose={!!fundDeleteConfirm} masked={maskAmounts} getFundCardProps={(row) => { const fund = row?.rawFund || (row ? { code: row.code, name: row.fundName } : null); if (!fund) return {}; return { fund, todayStr, currentTab, favorites, dcaPlans, holdings, percentModes, valuationSeries, collapsedCodes, collapsedTrends, transactions, theme, isTradingDay, refreshing, getHoldingProfit, onRemoveFromGroup: removeFundFromCurrentGroup, onToggleFavorite: toggleFavorite, onRemoveFund: requestRemoveFund, onHoldingClick: (f) => setHoldingModal({ open: true, fund: f }), onActionClick: (f) => setActionModal({ open: true, fund: f }), onPercentModeToggle: (code) => setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })), onToggleCollapse: toggleCollapse, onToggleTrendCollapse: toggleTrendCollapse, masked: maskAmounts, layoutMode: 'drawer', }; }} />
)} {viewMode === 'list' && isMobile && ( { if (refreshing) return; if (!row || !row.code) return; requestRemoveFund({ code: row.code, name: row.fundName }); }} onToggleFavorite={(row) => { if (!row || !row.code) return; toggleFavorite(row.code); }} onRemoveFromGroup={(row) => { if (!row || !row.code) return; removeFundFromCurrentGroup(row.code); }} onHoldingAmountClick={(row, meta) => { if (!row || !row.code) return; const fund = row.rawFund || { code: row.code, name: row.fundName }; if (meta?.hasHolding) { setActionModal({ open: true, fund }); } else { setHoldingModal({ open: true, fund }); } }} onHoldingProfitClick={(row) => { if (!row || !row.code) return; if (row.holdingProfitValue == null) return; setPercentModes((prev) => ({ ...prev, [row.code]: !prev[row.code] })); }} onCustomSettingsChange={triggerCustomSettingsSync} getFundCardProps={(row) => { const fund = row?.rawFund || (row ? { code: row.code, name: row.fundName } : null); if (!fund) return {}; return { fund, todayStr, currentTab, favorites, dcaPlans, holdings, percentModes, valuationSeries, collapsedCodes, collapsedTrends, transactions, theme, isTradingDay, refreshing, getHoldingProfit, onRemoveFromGroup: removeFundFromCurrentGroup, onToggleFavorite: toggleFavorite, onRemoveFund: requestRemoveFund, onHoldingClick: (f) => setHoldingModal({ open: true, fund: f }), onActionClick: (f) => setActionModal({ open: true, fund: f }), onPercentModeToggle: (code) => setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })), onToggleCollapse: toggleCollapse, onToggleTrendCollapse: toggleTrendCollapse, masked: maskAmounts, layoutMode: 'drawer', }; }} masked={maskAmounts} /> )} {viewMode === 'card' && displayFunds.map((f) => ( setHoldingModal({ open: true, fund })} onActionClick={(fund) => setActionModal({ open: true, fund })} onPercentModeToggle={(code) => setPercentModes((prev) => ({ ...prev, [code]: !prev[code] })) } onToggleCollapse={toggleCollapse} onToggleTrendCollapse={toggleTrendCollapse} masked={maskAmounts} /> ))}
)}
{fundDeleteConfirm && ( { fundDetailDrawerCloseRef.current?.(); fundDetailDialogCloseRef.current?.(); removeFund(fundDeleteConfirm.code); setFundDeleteConfirm(null); }} onCancel={() => setFundDeleteConfirm(null)} /> )} {logoutConfirmOpen && ( } confirmText="确认登出" onConfirm={() => { setLogoutConfirmOpen(false); handleLogout(); }} onCancel={() => setLogoutConfirmOpen(false)} /> )}

数据源:实时估值与重仓直连东方财富,仅供个人学习及参考使用。数据可能存在延迟,不作为任何投资建议

注:估算数据与真实结算数据会有1%左右误差,非股票型基金误差较大

遇到任何问题或需求建议可

{feedbackOpen && ( setFeedbackOpen(false)} user={user} onOpenWeChat={() => setWeChatOpen(true)} /> )} {weChatOpen && ( setWeChatOpen(false)} /> )} {addResultOpen && ( setAddResultOpen(false)} /> )} {addFundToGroupOpen && ( g.id === currentTab)?.codes || []} holdings={holdings} onClose={() => setAddFundToGroupOpen(false)} onAdd={handleAddFundsToGroup} /> )} {actionModal.open && ( setActionModal({ open: false, fund: null })} onAction={(type) => handleAction(type, actionModal.fund)} hasHistory={!!transactions[actionModal.fund?.code]?.length} /> )} {tradeModal.open && ( setTradeModal({ open: false, fund: null, type: 'buy' })} onConfirm={(data) => handleTrade(tradeModal.fund, data)} pendingTrades={pendingTrades} onDeletePending={(id) => { setPendingTrades(prev => { const next = prev.filter(t => t.id !== id); storageHelper.setItem('pendingTrades', JSON.stringify(next)); return next; }); showToast('已撤销待处理交易', 'success'); }} /> )} {dcaModal.open && ( setDcaModal({ open: false, fund: null })} onConfirm={(config) => { const code = config?.fundCode || dcaModal.fund?.code; if (!code) { setDcaModal({ open: false, fund: null }); return; } setDcaPlans(prev => { const next = { ...(prev || {}) }; next[code] = { amount: config.amount, feeRate: config.feeRate, cycle: config.cycle, firstDate: config.firstDate, weeklyDay: config.weeklyDay ?? null, monthlyDay: config.monthlyDay ?? null, enabled: config.enabled !== false }; storageHelper.setItem('dcaPlans', JSON.stringify(next)); return next; }); setDcaModal({ open: false, fund: null }); showToast('已保存定投计划', 'success'); }} /> )} {addHistoryModal.open && ( setAddHistoryModal({ open: false, fund: null })} onConfirm={handleAddHistory} /> )} {historyModal.open && ( t.fundCode === historyModal.fund?.code)} onClose={() => setHistoryModal({ open: false, fund: null })} onDeleteTransaction={(id) => handleDeleteTransaction(historyModal.fund?.code, id)} onAddHistory={() => setAddHistoryModal({ open: true, fund: historyModal.fund })} onDeletePending={(id) => { setPendingTrades(prev => { const next = prev.filter(t => t.id !== id); storageHelper.setItem('pendingTrades', JSON.stringify(next)); return next; }); showToast('已撤销待处理交易', 'success'); }} /> )} {clearConfirm && ( setClearConfirm(null)} confirmText="确认清空" /> )} {holdingModal.open && ( setHoldingModal({ open: false, fund: null })} onSave={(data) => handleSaveHolding(holdingModal.fund?.code, data)} /> )} {donateOpen && ( setDonateOpen(false)} /> )} {groupManageOpen && ( setGroupManageOpen(false)} onSave={handleUpdateGroups} /> )} {groupModalOpen && ( setGroupModalOpen(false)} onConfirm={handleAddGroup} /> )} {successModal.open && ( setSuccessModal({ open: false, message: '' })} /> )} {cloudConfigModal.open && ( { if (cloudConfigModal.type === 'conflict' && cloudConfigModal.cloudData) { applyCloudConfig(cloudConfigModal.cloudData); } setCloudConfigModal({ open: false, userId: null }); }} /> )} {scanModalOpen && ( setScanModalOpen(false)} onPick={handleScanPick} onFilesDrop={handleFilesDrop} isScanning={isScanning} /> )} {scanConfirmModalOpen && ( setScanConfirmModalOpen(false)} onToggle={toggleScannedCode} onConfirm={confirmScanImport} refreshing={refreshing} groups={groups} isOcrScan={isOcrScan} /> )} {settingsOpen && ( setSettingsOpen(false)} tempSeconds={tempSeconds} setTempSeconds={setTempSeconds} saveSettings={saveSettings} exportLocalData={exportLocalData} importFileRef={importFileRef} handleImportFileChange={handleImportFileChange} importMsg={importMsg} isMobile={isMobile} containerWidth={containerWidth} setContainerWidth={setContainerWidth} onResetContainerWidth={handleResetContainerWidth} /> )} {/* 更新提示弹窗 */} {updateModalOpen && ( setUpdateModalOpen(false)} onRefresh={() => window.location.reload()} /> )} {isScanning && ( )} {isScanImporting && ( )} {/* 登录模态框 */} {loginModalOpen && ( { setLoginModalOpen(false); setLoginError(''); setLoginSuccess(''); setLoginEmail(''); setLoginOtp(''); }} loginEmail={loginEmail} setLoginEmail={setLoginEmail} loginOtp={loginOtp} setLoginOtp={setLoginOtp} loginLoading={loginLoading} loginError={loginError} loginSuccess={loginSuccess} handleSendOtp={handleSendOtp} handleVerifyEmailOtp={handleVerifyEmailOtp} /> )} {/* 全局轻提示 Toast */} {toast.show && ( {toast.type === 'error' && ( )} {toast.type === 'success' && ( )} {toast.message} )}
); }