diff --git a/app/components/AnalyticsGate.jsx b/app/components/AnalyticsGate.jsx index 10be6cf..34e988a 100644 --- a/app/components/AnalyticsGate.jsx +++ b/app/components/AnalyticsGate.jsx @@ -1,10 +1,10 @@ 'use client'; -import { useLayoutEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import Script from 'next/script'; export default function AnalyticsGate({ GA_ID }) { const [enabled, setEnabled] = useState(false); - useLayoutEffect(() => { + useEffect(() => { try { const href = window.location.href || ''; setEnabled(href.includes('hzm0321')); diff --git a/app/globals.css b/app/globals.css index dd6c118..5a49783 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1309,17 +1309,6 @@ input[type="number"] { /* ========== 移动端响应式 ========== */ @media (max-width: 640px) { - .user-menu-dropdown { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - min-width: 100%; - border-radius: 20px 20px 0 0; - padding: 16px; - padding-bottom: calc(16px + env(safe-area-inset-bottom)); - } .user-menu-header { padding: 16px 8px; diff --git a/app/lib/supabase.js b/app/lib/supabase.js index 6e63f63..afa4f00 100644 --- a/app/lib/supabase.js +++ b/app/lib/supabase.js @@ -2,7 +2,7 @@ import { createClient } from '@supabase/supabase-js'; // Supabase 配置 // 注意:此处使用 publishable key,可安全在客户端使用 -const supabaseUrl = 'https://mouvsqlmgymsaxikvqsh.supabase.co/auth/v1/otp'; +const supabaseUrl = 'https://mouvsqlmgymsaxikvqsh.supabase.co'; const supabaseAnonKey = 'sb_publishable_c5f58knbVz8UgOh6L88MUQ_p9j8c1Q-'; export const supabase = createClient(supabaseUrl, supabaseAnonKey, { diff --git a/app/page.jsx b/app/page.jsx index 729ecd5..66ae98e 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -36,6 +36,14 @@ function SettingsIcon(props) { ); } +function CloudIcon(props) { + return ( + + + + ); +} + function RefreshIcon(props) { return ( @@ -1177,6 +1185,51 @@ function SuccessModal({ message, onClose }) { ); } +function CloudConfigModal({ onConfirm, onCancel }) { + return ( + + e.stopPropagation()} + > +
+
+ + 云端暂无配置 +
+ +
+

+ 是否将本地配置同步到云端? +

+
+ + +
+ + + ); +} + function ConfirmModal({ title, message, onConfirm, onCancel, confirmText = "确定删除" }) { return ( { if (typeof window !== 'undefined') { const checkMobile = () => setIsMobile(window.innerWidth <= 640); @@ -1863,6 +1918,8 @@ export default function HomePage() { const now = new Date(); const isAfter9 = now.getHours() >= 9; const hasTodayData = fund.jzrq === todayStr; + const hasTodayValuation = typeof fund.gztime === 'string' && fund.gztime.startsWith(todayStr); + const canCalcTodayProfit = hasTodayData || hasTodayValuation; // 如果是交易日且9点以后,且今日净值未出,则强制使用估值(隐藏涨跌幅列模式) const useValuation = isTradingDay && isAfter9 && !hasTodayData; @@ -1875,10 +1932,14 @@ export default function HomePage() { currentNav = Number(fund.dwjz); if (!currentNav) return null; - const amount = holding.share * currentNav; - // 优先用 zzl (真实涨跌幅), 降级用 gszzl - const rate = fund.zzl !== undefined ? Number(fund.zzl) : (Number(fund.gszzl) || 0); - profitToday = amount - (amount / (1 + rate / 100)); + if (canCalcTodayProfit) { + const amount = holding.share * currentNav; + // 优先用 zzl (真实涨跌幅), 降级用 gszzl + const rate = fund.zzl !== undefined ? Number(fund.zzl) : (Number(fund.gszzl) || 0); + profitToday = amount - (amount / (1 + rate / 100)); + } else { + profitToday = null; + } } else { // 否则使用估值 currentNav = fund.estPricedCoverage > 0.05 @@ -1887,10 +1948,14 @@ export default function HomePage() { if (!currentNav) return null; - const amount = holding.share * currentNav; - // 估值涨跌幅 - const gzChange = fund.estPricedCoverage > 0.05 ? fund.estGszzl : (Number(fund.gszzl) || 0); - profitToday = amount - (amount / (1 + gzChange / 100)); + 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; + } } // 持仓金额 @@ -2053,6 +2118,11 @@ export default function HomePage() { // 成功提示弹窗 const [successModal, setSuccessModal] = useState({ open: false, message: '' }); + const [cloudConfigModal, setCloudConfigModal] = useState({ open: false, userId: null }); + const syncDebounceRef = useRef(null); + const lastSyncedRef = useRef(''); + const skipSyncRef = useRef(false); + const userIdRef = useRef(null); useEffect(() => { const handleClickOutside = (event) => { @@ -2064,6 +2134,56 @@ export default function HomePage() { return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + useEffect(() => { + userIdRef.current = user?.id || null; + }, [user]); + + useEffect(() => { + const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings']); + const scheduleSync = () => { + if (!userIdRef.current) return; + if (skipSyncRef.current) return; + if (syncDebounceRef.current) clearTimeout(syncDebounceRef.current); + syncDebounceRef.current = setTimeout(() => { + const payload = collectLocalPayload(); + const next = getComparablePayload(payload); + if (next === lastSyncedRef.current) return; + lastSyncedRef.current = next; + syncUserConfig(userIdRef.current, false); + }, 9000); + }; + + const originalSetItem = localStorage.setItem.bind(localStorage); + const originalRemoveItem = localStorage.removeItem.bind(localStorage); + const originalClear = localStorage.clear.bind(localStorage); + + localStorage.setItem = (key, value) => { + originalSetItem(key, value); + if (keys.has(key)) scheduleSync(); + }; + localStorage.removeItem = (key) => { + originalRemoveItem(key); + if (keys.has(key)) scheduleSync(); + }; + localStorage.clear = () => { + originalClear(); + scheduleSync(); + }; + + const onStorage = (e) => { + if (!e.key || keys.has(e.key)) scheduleSync(); + }; + window.addEventListener('storage', onStorage); + + return () => { + localStorage.setItem = originalSetItem; + localStorage.removeItem = originalRemoveItem; + localStorage.clear = originalClear; + window.removeEventListener('storage', onStorage); + if (syncDebounceRef.current) clearTimeout(syncDebounceRef.current); + }; + }, []); + const toggleFavorite = (code) => { setFavorites(prev => { const next = new Set(prev); @@ -2208,11 +2328,6 @@ export default function HomePage() { if (Array.isArray(savedGroups)) { setGroups(savedGroups); } - // 加载视图模式 - const savedViewMode = localStorage.getItem('viewMode'); - if (savedViewMode === 'card' || savedViewMode === 'list') { - setViewMode(savedViewMode); - } // 加载持仓数据 const savedHoldings = JSON.parse(localStorage.getItem('holdings') || '{}'); if (savedHoldings && typeof savedHoldings === 'object') { @@ -2226,6 +2341,9 @@ export default function HomePage() { // 获取当前 session supabase.auth.getSession().then(({ data: { session } }) => { setUser(session?.user ?? null); + if (session?.user) { + fetchCloudConfig(session.user.id); + } }); // 监听认证状态变化 @@ -2236,19 +2354,42 @@ export default function HomePage() { setLoginEmail(''); setLoginSuccess(''); setLoginError(''); + fetchCloudConfig(session.user.id); } }); return () => subscription.unsubscribe(); }, []); - // 发送魔术链接邮件 - const handleSendMagicLink = async (e) => { + useEffect(() => { + if (!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 (!incoming || typeof incoming !== 'object') return; + const incomingComparable = getComparablePayload(incoming); + if (!incomingComparable || incomingComparable === lastSyncedRef.current) return; + await applyCloudConfig(incoming); + }) + .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => { + const incoming = payload?.new?.data; + if (!incoming || typeof incoming !== 'object') return; + const incomingComparable = getComparablePayload(incoming); + if (!incomingComparable || incomingComparable === lastSyncedRef.current) return; + await applyCloudConfig(incoming); + }) + .subscribe(); + return () => { + supabase.removeChannel(channel); + }; + }, [user?.id]); + + const handleSendOtp = async (e) => { e.preventDefault(); setLoginError(''); setLoginSuccess(''); - // 简单的邮箱格式验证 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!loginEmail.trim()) { setLoginError('请输入邮箱地址'); @@ -2264,29 +2405,63 @@ export default function HomePage() { const { error } = await supabase.auth.signInWithOtp({ email: loginEmail.trim(), options: { - emailRedirectTo: window.location.origin + shouldCreateUser: true } }); if (error) throw error; - setLoginSuccess('验证邮件已发送,请查收邮箱并点击链接完成登录'); + setLoginSuccess('验证码已发送,请查收邮箱输入验证码完成注册/登录'); } catch (err) { if (err.message?.includes('rate limit')) { setLoginError('请求过于频繁,请稍后再试'); } else if (err.message?.includes('network')) { setLoginError('网络错误,请检查网络连接'); } else { - setLoginError(err.message || '发送验证邮件失败,请稍后再试'); + setLoginError(err.message || '发送验证码失败,请稍后再试'); } } finally { setLoginLoading(false); } }; + const handleVerifyEmailOtp = async () => { + setLoginError(''); + if (!loginOtp || loginOtp.length < 4) { + setLoginError('请输入邮箱中的验证码'); + return; + } + try { + 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(''); + fetchCloudConfig(data.user.id); + } + } catch (err) { + setLoginError(err.message || '验证失败,请检查验证码或稍后再试'); + } + setLoginLoading(false); + }; + // 登出 const handleLogout = async () => { try { - await supabase.auth.signOut(); + const { error } = await supabase.auth.signOut(); + if (error?.code === 'session_not_found') { + await supabase.auth.signOut({ scope: 'local' }); + } else if (error) { + throw error; + } setUserMenuOpen(false); + setUser(null); } catch (err) { console.error('登出失败', err); } @@ -2769,7 +2944,6 @@ export default function HomePage() { const toggleViewMode = () => { const nextMode = viewMode === 'card' ? 'list' : 'card'; setViewMode(nextMode); - localStorage.setItem('viewMode', nextMode); }; const requestRemoveFund = (fund) => { @@ -2893,6 +3067,187 @@ export default function HomePage() { const importFileRef = useRef(null); const [importMsg, setImportMsg] = useState(''); + function getComparablePayload(payload) { + if (!payload || typeof payload !== 'object') return ''; + return JSON.stringify({ + funds: Array.isArray(payload.funds) ? payload.funds : [], + favorites: Array.isArray(payload.favorites) ? payload.favorites : [], + groups: Array.isArray(payload.groups) ? payload.groups : [], + collapsedCodes: Array.isArray(payload.collapsedCodes) ? payload.collapsedCodes : [], + refreshMs: Number.isFinite(payload.refreshMs) ? payload.refreshMs : 30000, + holdings: payload.holdings && typeof payload.holdings === 'object' ? payload.holdings : {} + }); + } + + const collectLocalPayload = () => { + try { + const funds = JSON.parse(localStorage.getItem('funds') || '[]'); + const favorites = JSON.parse(localStorage.getItem('favorites') || '[]'); + const groups = JSON.parse(localStorage.getItem('groups') || '[]'); + const collapsedCodes = JSON.parse(localStorage.getItem('collapsedCodes') || '[]'); + const fundCodes = new Set( + Array.isArray(funds) + ? funds.map((f) => f?.code).filter(Boolean) + : [] + ); + const holdings = JSON.parse(localStorage.getItem('holdings') || '{}'); + const cleanedHoldings = holdings && typeof holdings === 'object' && !Array.isArray(holdings) + ? Object.entries(holdings).reduce((acc, [code, value]) => { + if (!fundCodes.has(code) || !value || typeof value !== 'object') return acc; + const parsedShare = typeof value.share === 'number' + ? value.share + : typeof value.share === 'string' + ? Number(value.share) + : NaN; + const parsedCost = typeof value.cost === 'number' + ? value.cost + : typeof value.cost === 'string' + ? 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(favorites) + ? favorites.filter((code) => fundCodes.has(code)) + : []; + const cleanedCollapsed = Array.isArray(collapsedCodes) + ? collapsedCodes.filter((code) => fundCodes.has(code)) + : []; + const cleanedGroups = Array.isArray(groups) + ? groups.map((group) => ({ + ...group, + codes: Array.isArray(group?.codes) + ? group.codes.filter((code) => fundCodes.has(code)) + : [] + })) + : []; + return { + version: 1, + funds, + favorites: cleanedFavorites, + groups: cleanedGroups, + collapsedCodes: cleanedCollapsed, + refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10), + holdings: cleanedHoldings, + exportedAt: new Date().toISOString() + }; + } catch { + return { + version: 1, + funds: [], + favorites: [], + groups: [], + collapsedCodes: [], + refreshMs: 30000, + holdings: {}, + exportedAt: new Date().toISOString() + }; + } + }; + + const applyCloudConfig = async (cloudData) => { + if (!cloudData || typeof cloudData !== 'object') return; + skipSyncRef.current = true; + try { + const nextFunds = Array.isArray(cloudData.funds) ? dedupeByCode(cloudData.funds) : []; + setFunds(nextFunds); + localStorage.setItem('funds', JSON.stringify(nextFunds)); + + const nextFavorites = Array.isArray(cloudData.favorites) ? cloudData.favorites : []; + setFavorites(new Set(nextFavorites)); + localStorage.setItem('favorites', JSON.stringify(nextFavorites)); + + const nextGroups = Array.isArray(cloudData.groups) ? cloudData.groups : []; + setGroups(nextGroups); + localStorage.setItem('groups', JSON.stringify(nextGroups)); + + const nextCollapsed = Array.isArray(cloudData.collapsedCodes) ? cloudData.collapsedCodes : []; + setCollapsedCodes(new Set(nextCollapsed)); + localStorage.setItem('collapsedCodes', JSON.stringify(nextCollapsed)); + + const nextRefreshMs = Number.isFinite(cloudData.refreshMs) && cloudData.refreshMs >= 5000 ? cloudData.refreshMs : 30000; + setRefreshMs(nextRefreshMs); + setTempSeconds(Math.round(nextRefreshMs / 1000)); + localStorage.setItem('refreshMs', String(nextRefreshMs)); + + if (cloudData.viewMode === 'card' || cloudData.viewMode === 'list') { + setViewMode(cloudData.viewMode); + } + + const nextHoldings = cloudData.holdings && typeof cloudData.holdings === 'object' ? cloudData.holdings : {}; + setHoldings(nextHoldings); + localStorage.setItem('holdings', JSON.stringify(nextHoldings)); + + if (nextFunds.length) { + const codes = Array.from(new Set(nextFunds.map((f) => f.code))); + if (codes.length) await refreshAll(codes); + } + + const payload = collectLocalPayload(); + lastSyncedRef.current = getComparablePayload(payload); + } finally { + skipSyncRef.current = false; + } + }; + + const fetchCloudConfig = async (userId) => { + if (!userId) return; + try { + const { data, error } = await supabase + .from('user_configs') + .select('id, data') + .eq('user_id', userId) + .maybeSingle(); + if (error) throw error; + if (!data?.id) { + const { error: insertError } = await supabase + .from('user_configs') + .insert({ user_id: userId }); + if (insertError) throw insertError; + setCloudConfigModal({ open: true, userId }); + return; + } + if (data?.data && typeof data.data === 'object' && Object.keys(data.data).length > 0) { + await applyCloudConfig(data.data); + return; + } + setCloudConfigModal({ open: true, userId }); + } catch (e) { + console.error('获取云端配置失败', e); + } + }; + + const syncUserConfig = async (userId, showTip = true) => { + if (!userId) return; + try { + const payload = collectLocalPayload(); + const { error: updateError } = await supabase + .from('user_configs') + .update({ data: payload, updated_at: new Date().toISOString() }) + .eq('user_id', userId); + if (updateError) throw updateError; + if (showTip) { + setSuccessModal({ open: true, message: '已同步云端配置' }); + } + } catch (e) { + console.error('同步云端配置异常', e); + } + }; + + const handleSyncLocalConfig = async () => { + const userId = cloudConfigModal.userId; + setCloudConfigModal({ open: false, userId: null }); + await syncUserConfig(userId); + }; + const exportLocalData = async () => { try { const payload = { @@ -2902,7 +3257,7 @@ export default function HomePage() { groups: JSON.parse(localStorage.getItem('groups') || '[]'), collapsedCodes: JSON.parse(localStorage.getItem('collapsedCodes') || '[]'), refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10), - viewMode: localStorage.getItem('viewMode') || 'card', + viewMode, holdings: JSON.parse(localStorage.getItem('holdings') || '{}'), exportedAt: new Date().toISOString() }; @@ -3007,7 +3362,6 @@ export default function HomePage() { } if (data.viewMode === 'card' || data.viewMode === 'list') { setViewMode(data.viewMode); - localStorage.setItem('viewMode', data.viewMode); } if (data.holdings && typeof data.holdings === 'object') { @@ -3045,6 +3399,8 @@ export default function HomePage() { groupManageOpen || groupModalOpen || successModal.open || + cloudConfigModal.open || + logoutConfirmOpen || holdingModal.open || actionModal.open || tradeModal.open || @@ -3069,6 +3425,8 @@ export default function HomePage() { groupManageOpen, groupModalOpen, successModal.open, + cloudConfigModal.open, + logoutConfirmOpen, holdingModal.open, actionModal.open, tradeModal.open, @@ -3177,7 +3535,13 @@ export default function HomePage() { 设置 - @@ -3382,7 +3746,7 @@ export default function HomePage() {