15 Commits

Author SHA1 Message Date
hzm
28352e87c1 feat:发布0.1.3 2026-02-08 08:01:12 +08:00
hzm
ed5fd98c7c feat:调整 localStorage 监听方案 2026-02-08 07:57:34 +08:00
hzm
003271a684 feat: 数据如果正在同步到云,增加图标提示 2026-02-08 07:30:35 +08:00
hzm
7772de1acf fix: 完善会话过期逻辑 2026-02-08 07:14:43 +08:00
hzm
4d4b931e30 fix: 远端退出登录接口异常导致本地无法清理登录信息的问题 2026-02-08 06:49:46 +08:00
hzm
406f14150d fix: 已登录用户本地与云端不一致则提示 2026-02-08 06:38:22 +08:00
hzm
2964cb2318 fix: 补充 userId 不存在错误提示的情况 2026-02-08 06:16:09 +08:00
hzm
ff4a10c84d fix: 补充同步失败错误提示的情况 2026-02-08 06:03:00 +08:00
hzm
d69bf547ac fix: 界面展示同步失败错误提示 2026-02-08 05:51:43 +08:00
hzm
fe5577265a fix: 界面展示同步失败错误提示 2026-02-08 05:44:56 +08:00
hzm
e7b28dfb30 feat:token 过期重新登录 2026-02-08 05:32:53 +08:00
hzm
8fdbd2505b feat:重新上线登录测试版功能 2026-02-07 22:25:48 +08:00
hzm
1049719e3f feat:发布 0.1.1 紧急修复版本 2026-02-07 20:44:57 +08:00
hzm
6b5c69ee53 feat:临时关闭登录功能 2026-02-07 20:44:31 +08:00
hzm
8a62e7383d feat:完善版本更新检查逻辑 2026-02-07 16:03:33 +08:00
2 changed files with 299 additions and 119 deletions

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useRef, useState, useMemo, useLayoutEffect } from 'react'; import { useEffect, useRef, useState, useMemo, useLayoutEffect, useCallback } from 'react';
import { motion, AnimatePresence, Reorder } from 'framer-motion'; import { motion, AnimatePresence, Reorder } from 'framer-motion';
import { createAvatar } from '@dicebear/core'; import { createAvatar } from '@dicebear/core';
import { glass } from '@dicebear/collection'; import { glass } from '@dicebear/collection';
@@ -345,7 +345,7 @@ function DatePicker({ value, onChange }) {
} }
function DonateTabs() { function DonateTabs() {
const [method, setMethod] = useState('alipay'); // alipay, wechat const [method, setMethod] = useState('wechat'); // alipay, wechat
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
@@ -472,7 +472,7 @@ function Stat({ label, value, delta }) {
); );
} }
function FeedbackModal({ onClose }) { function FeedbackModal({ onClose, user }) {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [succeeded, setSucceeded] = useState(false); const [succeeded, setSucceeded] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -563,7 +563,7 @@ function FeedbackModal({ onClose }) {
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</div> </div>
<input type="hidden" name="email" value={user?.email || ''} />
<div className="form-group" style={{ marginBottom: 20 }}> <div className="form-group" style={{ marginBottom: 20 }}>
<label htmlFor="message" className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}> <label htmlFor="message" className="muted" style={{ display: 'block', marginBottom: 8, fontSize: '14px' }}>
反馈内容 反馈内容
@@ -1232,7 +1232,7 @@ function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) {
</div> </div>
<p className="muted" style={{ marginBottom: 20, fontSize: '14px', lineHeight: '1.6' }}> <p className="muted" style={{ marginBottom: 20, fontSize: '14px', lineHeight: '1.6' }}>
{isConflict {isConflict
? '检测到本地配置云端更新,请选择操作:' ? '检测到本地配置云端不一致,请选择操作:'
: '是否将本地配置同步到云端?'} : '是否将本地配置同步到云端?'}
</p> </p>
<div className="row" style={{ flexDirection: 'column', gap: 12 }}> <div className="row" style={{ flexDirection: 'column', gap: 12 }}>
@@ -1625,7 +1625,7 @@ function CountUp({ value, prefix = '', suffix = '', decimals = 2, className = ''
const start = previousValue.current; const start = previousValue.current;
const end = value; const end = value;
const duration = 1000; // 1秒动画 const duration = 600; // 0.6秒动画
const startTime = performance.now(); const startTime = performance.now();
const animate = (currentTime) => { const animate = (currentTime) => {
@@ -1768,6 +1768,7 @@ export default function HomePage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const timerRef = useRef(null); const timerRef = useRef(null);
const refreshingRef = useRef(false); const refreshingRef = useRef(false);
const isLoggingOutRef = useRef(false);
// 刷新频率状态 // 刷新频率状态
const [refreshMs, setRefreshMs] = useState(30000); const [refreshMs, setRefreshMs] = useState(30000);
@@ -1856,12 +1857,13 @@ export default function HomePage() {
// 检查更新 // 检查更新
const [hasUpdate, setHasUpdate] = useState(false); const [hasUpdate, setHasUpdate] = useState(false);
const [latestVersion, setLatestVersion] = useState(''); const [latestVersion, setLatestVersion] = useState('');
const [updateContent, setUpdateContent] = useState('');
const [isSyncing, setIsSyncing] = useState(false);
useEffect(() => { useEffect(() => {
const checkUpdate = async () => { const checkUpdate = async () => {
try { try {
const res = await fetch('https://api.github.com/repos/hzm0321/real-time-fund/releases/latest'); const res = await fetch('https://api.github.com/repos/hzm0321/real-time-fund/releases/latest');
console.log(packageJson.version)
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
if (data.tag_name) { if (data.tag_name) {
@@ -1869,6 +1871,7 @@ export default function HomePage() {
if (remoteVersion !== packageJson.version) { if (remoteVersion !== packageJson.version) {
setHasUpdate(true); setHasUpdate(true);
setLatestVersion(remoteVersion); setLatestVersion(remoteVersion);
setUpdateContent(data.body || '');
} }
} }
} catch (e) { } catch (e) {
@@ -2083,7 +2086,7 @@ export default function HomePage() {
} else { } else {
next[code] = data; next[code] = data;
} }
localStorage.setItem('holdings', JSON.stringify(next)); storageHelper.setItem('holdings', JSON.stringify(next));
return next; return next;
}); });
setHoldingModal({ open: false, fund: null }); setHoldingModal({ open: false, fund: null });
@@ -2172,6 +2175,18 @@ export default function HomePage() {
// 成功提示弹窗 // 成功提示弹窗
const [successModal, setSuccessModal] = useState({ open: false, message: '' }); 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);
};
const [updateModalOpen, setUpdateModalOpen] = useState(false); const [updateModalOpen, setUpdateModalOpen] = useState(false);
const [cloudConfigModal, setCloudConfigModal] = useState({ open: false, userId: null }); const [cloudConfigModal, setCloudConfigModal] = useState({ open: false, userId: null });
const syncDebounceRef = useRef(null); const syncDebounceRef = useRef(null);
@@ -2193,64 +2208,59 @@ export default function HomePage() {
userIdRef.current = user?.id || null; userIdRef.current = user?.id || null;
}, [user]); }, [user]);
const scheduleSync = useCallback(() => {
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);
}, 2000);
}, []);
const storageHelper = useMemo(() => {
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings']);
const triggerSync = (key) => {
if (keys.has(key)) {
if (!skipSyncRef.current) {
window.localStorage.setItem('localUpdatedAt', new Date().toISOString());
}
scheduleSync();
}
};
return {
setItem: (key, value) => {
window.localStorage.setItem(key, value);
triggerSync(key);
},
removeItem: (key) => {
window.localStorage.removeItem(key);
triggerSync(key);
},
clear: () => {
window.localStorage.clear();
if (!skipSyncRef.current) {
window.localStorage.setItem('localUpdatedAt', new Date().toISOString());
}
scheduleSync();
}
};
}, [scheduleSync]);
useEffect(() => { useEffect(() => {
const keys = new Set(['funds', 'favorites', 'groups', 'collapsedCodes', 'refreshMs', 'holdings']); 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);
}, 2000);
};
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)) {
if (!skipSyncRef.current) {
originalSetItem('localUpdatedAt', new Date().toISOString());
}
scheduleSync();
}
};
localStorage.removeItem = (key) => {
originalRemoveItem(key);
if (keys.has(key)) {
if (!skipSyncRef.current) {
originalSetItem('localUpdatedAt', new Date().toISOString());
}
scheduleSync();
}
};
localStorage.clear = () => {
originalClear();
if (!skipSyncRef.current) {
originalSetItem('localUpdatedAt', new Date().toISOString());
}
scheduleSync();
};
const onStorage = (e) => { const onStorage = (e) => {
if (!e.key || keys.has(e.key)) scheduleSync(); if (!e.key || keys.has(e.key)) scheduleSync();
}; };
window.addEventListener('storage', onStorage); window.addEventListener('storage', onStorage);
return () => { return () => {
localStorage.setItem = originalSetItem;
localStorage.removeItem = originalRemoveItem;
localStorage.clear = originalClear;
window.removeEventListener('storage', onStorage); window.removeEventListener('storage', onStorage);
if (syncDebounceRef.current) clearTimeout(syncDebounceRef.current); if (syncDebounceRef.current) clearTimeout(syncDebounceRef.current);
}; };
}, []); }, [scheduleSync]);
const toggleFavorite = (code) => { const toggleFavorite = (code) => {
setFavorites(prev => { setFavorites(prev => {
@@ -2260,7 +2270,7 @@ export default function HomePage() {
} else { } else {
next.add(code); next.add(code);
} }
localStorage.setItem('favorites', JSON.stringify(Array.from(next))); storageHelper.setItem('favorites', JSON.stringify(Array.from(next)));
if (next.size === 0) setCurrentTab('all'); if (next.size === 0) setCurrentTab('all');
return next; return next;
}); });
@@ -2275,7 +2285,7 @@ export default function HomePage() {
next.add(code); next.add(code);
} }
// 同步到本地存储 // 同步到本地存储
localStorage.setItem('collapsedCodes', JSON.stringify(Array.from(next))); storageHelper.setItem('collapsedCodes', JSON.stringify(Array.from(next)));
return next; return next;
}); });
}; };
@@ -2288,7 +2298,7 @@ export default function HomePage() {
}; };
const next = [...groups, newGroup]; const next = [...groups, newGroup];
setGroups(next); setGroups(next);
localStorage.setItem('groups', JSON.stringify(next)); storageHelper.setItem('groups', JSON.stringify(next));
setCurrentTab(newGroup.id); setCurrentTab(newGroup.id);
setGroupModalOpen(false); setGroupModalOpen(false);
}; };
@@ -2296,13 +2306,13 @@ export default function HomePage() {
const handleRemoveGroup = (id) => { const handleRemoveGroup = (id) => {
const next = groups.filter(g => g.id !== id); const next = groups.filter(g => g.id !== id);
setGroups(next); setGroups(next);
localStorage.setItem('groups', JSON.stringify(next)); storageHelper.setItem('groups', JSON.stringify(next));
if (currentTab === id) setCurrentTab('all'); if (currentTab === id) setCurrentTab('all');
}; };
const handleUpdateGroups = (newGroups) => { const handleUpdateGroups = (newGroups) => {
setGroups(newGroups); setGroups(newGroups);
localStorage.setItem('groups', JSON.stringify(newGroups)); storageHelper.setItem('groups', JSON.stringify(newGroups));
// 如果当前选中的分组被删除了,切换回“全部” // 如果当前选中的分组被删除了,切换回“全部”
if (currentTab !== 'all' && currentTab !== 'fav' && !newGroups.find(g => g.id === currentTab)) { if (currentTab !== 'all' && currentTab !== 'fav' && !newGroups.find(g => g.id === currentTab)) {
setCurrentTab('all'); setCurrentTab('all');
@@ -2321,7 +2331,7 @@ export default function HomePage() {
return g; return g;
}); });
setGroups(next); setGroups(next);
localStorage.setItem('groups', JSON.stringify(next)); storageHelper.setItem('groups', JSON.stringify(next));
setAddFundToGroupOpen(false); setAddFundToGroupOpen(false);
setSuccessModal({ open: true, message: `成功添加 ${codes.length} 支基金` }); setSuccessModal({ open: true, message: `成功添加 ${codes.length} 支基金` });
}; };
@@ -2337,7 +2347,7 @@ export default function HomePage() {
return g; return g;
}); });
setGroups(next); setGroups(next);
localStorage.setItem('groups', JSON.stringify(next)); storageHelper.setItem('groups', JSON.stringify(next));
}; };
const toggleFundInGroup = (code, groupId) => { const toggleFundInGroup = (code, groupId) => {
@@ -2352,7 +2362,7 @@ export default function HomePage() {
return g; return g;
}); });
setGroups(next); setGroups(next);
localStorage.setItem('groups', JSON.stringify(next)); storageHelper.setItem('groups', JSON.stringify(next));
}; };
// 按 code 去重,保留第一次出现的项,避免列表重复 // 按 code 去重,保留第一次出现的项,避免列表重复
@@ -2372,7 +2382,7 @@ export default function HomePage() {
if (Array.isArray(saved) && saved.length) { if (Array.isArray(saved) && saved.length) {
const deduped = dedupeByCode(saved); const deduped = dedupeByCode(saved);
setFunds(deduped); setFunds(deduped);
localStorage.setItem('funds', JSON.stringify(deduped)); storageHelper.setItem('funds', JSON.stringify(deduped));
const codes = Array.from(new Set(deduped.map((f) => f.code))); const codes = Array.from(new Set(deduped.map((f) => f.code)));
if (codes.length) refreshAll(codes); if (codes.length) refreshAll(codes);
} }
@@ -2413,12 +2423,37 @@ export default function HomePage() {
const handleSession = async (session, event) => { const handleSession = async (session, event) => {
if (!session?.user) { if (!session?.user) {
if (event === 'SIGNED_OUT' && !isLoggingOutRef.current) {
setLoginError('会话已过期,请重新登录');
setLoginModalOpen(true);
}
isLoggingOutRef.current = false;
clearAuthState(); clearAuthState();
return; return;
} }
if (session.expires_at && session.expires_at * 1000 <= Date.now()) { if (session.expires_at && session.expires_at * 1000 <= Date.now()) {
isLoggingOutRef.current = true;
await supabase.auth.signOut({ scope: 'local' }); 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(); clearAuthState();
setLoginError('会话已过期,请重新登录');
showToast('会话已过期,请重新登录', 'error');
setLoginModalOpen(true);
return; return;
} }
setUser(session.user); setUser(session.user);
@@ -2538,24 +2573,43 @@ export default function HomePage() {
// 登出 // 登出
const handleLogout = async () => { const handleLogout = async () => {
isLoggingOutRef.current = true;
try { try {
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
if (!session) { if (session) {
await supabase.auth.signOut({ scope: 'local' }); const { error } = await supabase.auth.signOut({ scope: 'local' });
setUserMenuOpen(false); if (error && error.code !== 'session_not_found') {
setUser(null); throw error;
return; }
} }
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) { } catch (err) {
showToast(err.message, 'error')
console.error('登出失败', err); 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); setUserMenuOpen(false);
setUser(null); setUser(null);
} }
@@ -2975,7 +3029,7 @@ export default function HomePage() {
if (newFunds.length > 0) { if (newFunds.length > 0) {
const updated = dedupeByCode([...newFunds, ...funds]); const updated = dedupeByCode([...newFunds, ...funds]);
setFunds(updated); setFunds(updated);
localStorage.setItem('funds', JSON.stringify(updated)); storageHelper.setItem('funds', JSON.stringify(updated));
} }
setSelectedFunds([]); setSelectedFunds([]);
@@ -3023,7 +3077,7 @@ export default function HomePage() {
} }
}); });
const deduped = dedupeByCode(merged); const deduped = dedupeByCode(merged);
localStorage.setItem('funds', JSON.stringify(deduped)); storageHelper.setItem('funds', JSON.stringify(deduped));
return deduped; return deduped;
}); });
} }
@@ -3085,7 +3139,7 @@ export default function HomePage() {
} else { } else {
const next = dedupeByCode([...newFunds, ...funds]); const next = dedupeByCode([...newFunds, ...funds]);
setFunds(next); setFunds(next);
localStorage.setItem('funds', JSON.stringify(next)); storageHelper.setItem('funds', JSON.stringify(next));
} }
setSearchTerm(''); setSearchTerm('');
setSelectedFunds([]); setSelectedFunds([]);
@@ -3104,7 +3158,7 @@ export default function HomePage() {
const removeFund = (removeCode) => { const removeFund = (removeCode) => {
const next = funds.filter((f) => f.code !== removeCode); const next = funds.filter((f) => f.code !== removeCode);
setFunds(next); setFunds(next);
localStorage.setItem('funds', JSON.stringify(next)); storageHelper.setItem('funds', JSON.stringify(next));
// 同步删除分组中的失效代码 // 同步删除分组中的失效代码
const nextGroups = groups.map(g => ({ const nextGroups = groups.map(g => ({
@@ -3112,14 +3166,14 @@ export default function HomePage() {
codes: g.codes.filter(c => c !== removeCode) codes: g.codes.filter(c => c !== removeCode)
})); }));
setGroups(nextGroups); setGroups(nextGroups);
localStorage.setItem('groups', JSON.stringify(nextGroups)); storageHelper.setItem('groups', JSON.stringify(nextGroups));
// 同步删除展开收起状态 // 同步删除展开收起状态
setCollapsedCodes(prev => { setCollapsedCodes(prev => {
if (!prev.has(removeCode)) return prev; if (!prev.has(removeCode)) return prev;
const nextSet = new Set(prev); const nextSet = new Set(prev);
nextSet.delete(removeCode); nextSet.delete(removeCode);
localStorage.setItem('collapsedCodes', JSON.stringify(Array.from(nextSet))); storageHelper.setItem('collapsedCodes', JSON.stringify(Array.from(nextSet)));
return nextSet; return nextSet;
}); });
@@ -3128,7 +3182,7 @@ export default function HomePage() {
if (!prev.has(removeCode)) return prev; if (!prev.has(removeCode)) return prev;
const nextSet = new Set(prev); const nextSet = new Set(prev);
nextSet.delete(removeCode); nextSet.delete(removeCode);
localStorage.setItem('favorites', JSON.stringify(Array.from(nextSet))); storageHelper.setItem('favorites', JSON.stringify(Array.from(nextSet)));
if (nextSet.size === 0) setCurrentTab('all'); if (nextSet.size === 0) setCurrentTab('all');
return nextSet; return nextSet;
}); });
@@ -3138,7 +3192,7 @@ export default function HomePage() {
if (!prev[removeCode]) return prev; if (!prev[removeCode]) return prev;
const next = { ...prev }; const next = { ...prev };
delete next[removeCode]; delete next[removeCode];
localStorage.setItem('holdings', JSON.stringify(next)); storageHelper.setItem('holdings', JSON.stringify(next));
return next; return next;
}); });
}; };
@@ -3154,7 +3208,7 @@ export default function HomePage() {
e?.preventDefault?.(); e?.preventDefault?.();
const ms = Math.max(10, Number(tempSeconds)) * 1000; const ms = Math.max(10, Number(tempSeconds)) * 1000;
setRefreshMs(ms); setRefreshMs(ms);
localStorage.setItem('refreshMs', String(ms)); storageHelper.setItem('refreshMs', String(ms));
setSettingsOpen(false); setSettingsOpen(false);
}; };
@@ -3224,7 +3278,6 @@ export default function HomePage() {
})) }))
: []; : [];
return { return {
version: 1,
funds, funds,
favorites: cleanedFavorites, favorites: cleanedFavorites,
groups: cleanedGroups, groups: cleanedGroups,
@@ -3235,7 +3288,6 @@ export default function HomePage() {
}; };
} catch { } catch {
return { return {
version: 1,
funds: [], funds: [],
favorites: [], favorites: [],
groups: [], groups: [],
@@ -3252,28 +3304,28 @@ export default function HomePage() {
skipSyncRef.current = true; skipSyncRef.current = true;
try { try {
if (cloudUpdatedAt) { if (cloudUpdatedAt) {
localStorage.setItem('localUpdatedAt', new Date(cloudUpdatedAt).toISOString()); storageHelper.setItem('localUpdatedAt', new Date(cloudUpdatedAt).toISOString());
} }
const nextFunds = Array.isArray(cloudData.funds) ? dedupeByCode(cloudData.funds) : []; const nextFunds = Array.isArray(cloudData.funds) ? dedupeByCode(cloudData.funds) : [];
setFunds(nextFunds); setFunds(nextFunds);
localStorage.setItem('funds', JSON.stringify(nextFunds)); storageHelper.setItem('funds', JSON.stringify(nextFunds));
const nextFavorites = Array.isArray(cloudData.favorites) ? cloudData.favorites : []; const nextFavorites = Array.isArray(cloudData.favorites) ? cloudData.favorites : [];
setFavorites(new Set(nextFavorites)); setFavorites(new Set(nextFavorites));
localStorage.setItem('favorites', JSON.stringify(nextFavorites)); storageHelper.setItem('favorites', JSON.stringify(nextFavorites));
const nextGroups = Array.isArray(cloudData.groups) ? cloudData.groups : []; const nextGroups = Array.isArray(cloudData.groups) ? cloudData.groups : [];
setGroups(nextGroups); setGroups(nextGroups);
localStorage.setItem('groups', JSON.stringify(nextGroups)); storageHelper.setItem('groups', JSON.stringify(nextGroups));
const nextCollapsed = Array.isArray(cloudData.collapsedCodes) ? cloudData.collapsedCodes : []; const nextCollapsed = Array.isArray(cloudData.collapsedCodes) ? cloudData.collapsedCodes : [];
setCollapsedCodes(new Set(nextCollapsed)); setCollapsedCodes(new Set(nextCollapsed));
localStorage.setItem('collapsedCodes', JSON.stringify(nextCollapsed)); storageHelper.setItem('collapsedCodes', JSON.stringify(nextCollapsed));
const nextRefreshMs = Number.isFinite(cloudData.refreshMs) && cloudData.refreshMs >= 5000 ? cloudData.refreshMs : 30000; const nextRefreshMs = Number.isFinite(cloudData.refreshMs) && cloudData.refreshMs >= 5000 ? cloudData.refreshMs : 30000;
setRefreshMs(nextRefreshMs); setRefreshMs(nextRefreshMs);
setTempSeconds(Math.round(nextRefreshMs / 1000)); setTempSeconds(Math.round(nextRefreshMs / 1000));
localStorage.setItem('refreshMs', String(nextRefreshMs)); storageHelper.setItem('refreshMs', String(nextRefreshMs));
if (cloudData.viewMode === 'card' || cloudData.viewMode === 'list') { if (cloudData.viewMode === 'card' || cloudData.viewMode === 'list') {
setViewMode(cloudData.viewMode); setViewMode(cloudData.viewMode);
@@ -3281,7 +3333,7 @@ export default function HomePage() {
const nextHoldings = cloudData.holdings && typeof cloudData.holdings === 'object' ? cloudData.holdings : {}; const nextHoldings = cloudData.holdings && typeof cloudData.holdings === 'object' ? cloudData.holdings : {};
setHoldings(nextHoldings); setHoldings(nextHoldings);
localStorage.setItem('holdings', JSON.stringify(nextHoldings)); storageHelper.setItem('holdings', JSON.stringify(nextHoldings));
if (nextFunds.length) { if (nextFunds.length) {
const codes = Array.from(new Set(nextFunds.map((f) => f.code))); const codes = Array.from(new Set(nextFunds.map((f) => f.code)));
@@ -3313,10 +3365,13 @@ export default function HomePage() {
return; return;
} }
if (data?.data && typeof data.data === 'object' && Object.keys(data.data).length > 0) { if (data?.data && typeof data.data === 'object' && Object.keys(data.data).length > 0) {
const cloudTime = new Date(data.updated_at || 0).getTime(); const localPayload = collectLocalPayload();
const localTime = new Date(localStorage.getItem('localUpdatedAt') || 0).getTime(); const localComparable = getComparablePayload(localPayload);
const cloudComparable = getComparablePayload(data.data);
if (localTime > cloudTime + 2000) {
if (localComparable !== cloudComparable) {
// 如果数据不一致,无论时间戳如何,都提示用户
// 用户可以选择使用本地数据覆盖云端,或者使用云端数据覆盖本地
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data }); setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data });
return; return;
} }
@@ -3331,11 +3386,15 @@ export default function HomePage() {
}; };
const syncUserConfig = async (userId, showTip = true) => { const syncUserConfig = async (userId, showTip = true) => {
if (!userId) return; if (!userId) {
showToast(`userId 不存在,请重新登录`, 'error');
return;
}
try { try {
setIsSyncing(true);
const payload = collectLocalPayload(); const payload = collectLocalPayload();
const now = new Date().toISOString(); const now = new Date().toISOString();
const { error: updateError } = await supabase const { data: upsertData, error: updateError } = await supabase
.from('user_configs') .from('user_configs')
.upsert( .upsert(
{ {
@@ -3344,16 +3403,24 @@ export default function HomePage() {
updated_at: now updated_at: now
}, },
{ onConflict: 'user_id' } { onConflict: 'user_id' }
); )
.select();
if (updateError) throw updateError; if (updateError) throw updateError;
if (!upsertData || upsertData.length === 0) {
throw new Error('同步失败:未写入任何数据,请检查账号状态或重新登录');
}
localStorage.setItem('localUpdatedAt', now); storageHelper.setItem('localUpdatedAt', now);
if (showTip) { if (showTip) {
setSuccessModal({ open: true, message: '已同步云端配置' }); setSuccessModal({ open: true, message: '已同步云端配置' });
} }
} catch (e) { } catch (e) {
console.error('同步云端配置异常', e); console.error('同步云端配置异常', e);
showToast(`同步云端配置异常:${e}`, 'error');
} finally {
setIsSyncing(false);
} }
}; };
@@ -3366,13 +3433,11 @@ export default function HomePage() {
const exportLocalData = async () => { const exportLocalData = async () => {
try { try {
const payload = { const payload = {
version: 1,
funds: JSON.parse(localStorage.getItem('funds') || '[]'), funds: JSON.parse(localStorage.getItem('funds') || '[]'),
favorites: JSON.parse(localStorage.getItem('favorites') || '[]'), favorites: JSON.parse(localStorage.getItem('favorites') || '[]'),
groups: JSON.parse(localStorage.getItem('groups') || '[]'), groups: JSON.parse(localStorage.getItem('groups') || '[]'),
collapsedCodes: JSON.parse(localStorage.getItem('collapsedCodes') || '[]'), collapsedCodes: JSON.parse(localStorage.getItem('collapsedCodes') || '[]'),
refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10), refreshMs: parseInt(localStorage.getItem('refreshMs') || '30000', 10),
viewMode,
holdings: JSON.parse(localStorage.getItem('holdings') || '{}'), holdings: JSON.parse(localStorage.getItem('holdings') || '{}'),
exportedAt: new Date().toISOString() exportedAt: new Date().toISOString()
}; };
@@ -3437,13 +3502,13 @@ export default function HomePage() {
appendedCodes = newItems.map(f => f.code); appendedCodes = newItems.map(f => f.code);
mergedFunds = [...currentFunds, ...newItems]; mergedFunds = [...currentFunds, ...newItems];
setFunds(mergedFunds); setFunds(mergedFunds);
localStorage.setItem('funds', JSON.stringify(mergedFunds)); storageHelper.setItem('funds', JSON.stringify(mergedFunds));
} }
if (Array.isArray(data.favorites)) { if (Array.isArray(data.favorites)) {
const mergedFav = Array.from(new Set([...currentFavorites, ...data.favorites])); const mergedFav = Array.from(new Set([...currentFavorites, ...data.favorites]));
setFavorites(new Set(mergedFav)); setFavorites(new Set(mergedFav));
localStorage.setItem('favorites', JSON.stringify(mergedFav)); storageHelper.setItem('favorites', JSON.stringify(mergedFav));
} }
if (Array.isArray(data.groups)) { if (Array.isArray(data.groups)) {
@@ -3461,19 +3526,19 @@ export default function HomePage() {
} }
}); });
setGroups(mergedGroups); setGroups(mergedGroups);
localStorage.setItem('groups', JSON.stringify(mergedGroups)); storageHelper.setItem('groups', JSON.stringify(mergedGroups));
} }
if (Array.isArray(data.collapsedCodes)) { if (Array.isArray(data.collapsedCodes)) {
const mergedCollapsed = Array.from(new Set([...currentCollapsed, ...data.collapsedCodes])); const mergedCollapsed = Array.from(new Set([...currentCollapsed, ...data.collapsedCodes]));
setCollapsedCodes(new Set(mergedCollapsed)); setCollapsedCodes(new Set(mergedCollapsed));
localStorage.setItem('collapsedCodes', JSON.stringify(mergedCollapsed)); storageHelper.setItem('collapsedCodes', JSON.stringify(mergedCollapsed));
} }
if (typeof data.refreshMs === 'number' && data.refreshMs >= 5000) { if (typeof data.refreshMs === 'number' && data.refreshMs >= 5000) {
setRefreshMs(data.refreshMs); setRefreshMs(data.refreshMs);
setTempSeconds(Math.round(data.refreshMs / 1000)); setTempSeconds(Math.round(data.refreshMs / 1000));
localStorage.setItem('refreshMs', String(data.refreshMs)); storageHelper.setItem('refreshMs', String(data.refreshMs));
} }
if (data.viewMode === 'card' || data.viewMode === 'list') { if (data.viewMode === 'card' || data.viewMode === 'list') {
setViewMode(data.viewMode); setViewMode(data.viewMode);
@@ -3482,7 +3547,7 @@ export default function HomePage() {
if (data.holdings && typeof data.holdings === 'object') { if (data.holdings && typeof data.holdings === 'object') {
const mergedHoldings = { ...JSON.parse(localStorage.getItem('holdings') || '{}'), ...data.holdings }; const mergedHoldings = { ...JSON.parse(localStorage.getItem('holdings') || '{}'), ...data.holdings };
setHoldings(mergedHoldings); setHoldings(mergedHoldings);
localStorage.setItem('holdings', JSON.stringify(mergedHoldings)); storageHelper.setItem('holdings', JSON.stringify(mergedHoldings));
} }
// 导入成功后,仅刷新新追加的基金 // 导入成功后,仅刷新新追加的基金
@@ -3577,12 +3642,40 @@ export default function HomePage() {
<path d="M5 14c2-4 7-6 14-5" stroke="var(--primary)" strokeWidth="2" /> <path d="M5 14c2-4 7-6 14-5" stroke="var(--primary)" strokeWidth="2" />
</svg> </svg>
<span>基估宝</span> <span>基估宝</span>
<AnimatePresence>
{isSyncing && (
<motion.div
key="sync-icon"
initial={{ opacity: 0, width: 0, marginLeft: 0 }}
animate={{ opacity: 1, width: 'auto', marginLeft: 8 }}
exit={{ opacity: 0, width: 0, marginLeft: 0 }}
style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}
title="正在同步到云端..."
>
<motion.svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
>
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" stroke="var(--primary)" />
<path d="M12 12v9" stroke="var(--accent)" />
<path d="m16 16-4-4-4 4" stroke="var(--accent)" />
</motion.svg>
</motion.div>
)}
</AnimatePresence>
</div> </div>
<div className="actions"> <div className="actions">
<img alt="项目Github地址" src={githubImg.src} style={{ width: '30px', height: '30px', cursor: 'pointer' }} onClick={() => window.open("https://github.com/hzm0321/real-time-fund")} />
{hasUpdate && ( {hasUpdate && (
<div <div
className="badge" className="badge"
title={`发现新版本 ${latestVersion},点击前往下载`} title={`发现新版本 ${latestVersion},点击前往下载`}
style={{ cursor: 'pointer', borderColor: 'var(--success)', color: 'var(--success)' }} style={{ cursor: 'pointer', borderColor: 'var(--success)', color: 'var(--success)' }}
onClick={() => setUpdateModalOpen(true)} onClick={() => setUpdateModalOpen(true)}
@@ -3590,6 +3683,7 @@ export default function HomePage() {
<UpdateIcon width="14" height="14" /> <UpdateIcon width="14" height="14" />
</div> </div>
)} )}
<img alt="项目Github地址" src={githubImg.src} style={{ width: '30px', height: '30px', cursor: 'pointer' }} onClick={() => window.open("https://github.com/hzm0321/real-time-fund")} />
<div className="badge" title="当前刷新频率"> <div className="badge" title="当前刷新频率">
<span>刷新</span> <span>刷新</span>
<strong>{Math.round(refreshMs / 1000)}</strong> <strong>{Math.round(refreshMs / 1000)}</strong>
@@ -3604,6 +3698,15 @@ export default function HomePage() {
> >
<RefreshIcon className={refreshing ? 'spin' : ''} width="18" height="18" /> <RefreshIcon className={refreshing ? 'spin' : ''} width="18" height="18" />
</button> </button>
{/*<button*/}
{/* className="icon-button"*/}
{/* aria-label="打开设置"*/}
{/* onClick={() => setSettingsOpen(true)}*/}
{/* title="设置"*/}
{/* hidden*/}
{/*>*/}
{/* <SettingsIcon width="18" height="18" />*/}
{/*</button>*/}
{/* 用户菜单 */} {/* 用户菜单 */}
<div className="user-menu-container" ref={userMenuRef}> <div className="user-menu-container" ref={userMenuRef}>
<button <button
@@ -4599,6 +4702,7 @@ export default function HomePage() {
<FeedbackModal <FeedbackModal
key={feedbackNonce} key={feedbackNonce}
onClose={() => setFeedbackOpen(false)} onClose={() => setFeedbackOpen(false)}
user={user}
/> />
)} )}
</AnimatePresence> </AnimatePresence>
@@ -4837,9 +4941,28 @@ export default function HomePage() {
<UpdateIcon width="20" height="20" style={{color: 'var(--success)'}} /> <UpdateIcon width="20" height="20" style={{color: 'var(--success)'}} />
<span>更新提示</span> <span>更新提示</span>
</div> </div>
<p className="muted" style={{ marginBottom: 24, fontSize: '14px', lineHeight: '1.6' }}> <div style={{ marginBottom: 24 }}>
检测到新版本是否刷新浏览器以更新 <p className="muted" style={{ fontSize: '14px', lineHeight: '1.6', marginBottom: 12 }}>
</p> 检测到新版本是否刷新浏览器以更新
<br/>
更新内容如下
</p>
{updateContent && (
<div style={{
background: 'rgba(0,0,0,0.2)',
padding: '12px',
borderRadius: '8px',
fontSize: '13px',
lineHeight: '1.5',
maxHeight: '200px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
border: '1px solid rgba(255,255,255,0.1)'
}}>
{updateContent}
</div>
)}
</div>
<div className="row" style={{ gap: 12 }}> <div className="row" style={{ gap: 12 }}>
<button <button
className="button secondary" className="button secondary"
@@ -4884,6 +5007,18 @@ export default function HomePage() {
<form onSubmit={handleSendOtp}> <form onSubmit={handleSendOtp}>
<div className="form-group" style={{ marginBottom: 16 }}> <div className="form-group" style={{ marginBottom: 16 }}>
<div style={{
marginBottom: 12,
padding: '8px 12px',
background: 'rgba(230, 162, 60, 0.1)',
border: '1px solid rgba(230, 162, 60, 0.2)',
borderRadius: '4px',
fontSize: '0.8rem',
color: '#e6a23c',
lineHeight: '1.4'
}}>
登录功能目前正在测试使用过程中如遇到问题欢迎大家在 <a href="https://github.com/hzm0321/real-time-fund/issues" target="_blank" style={{ textDecoration: 'underline', color: 'inherit' }}>Github</a> 上反馈
</div>
<div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}> <div className="muted" style={{ marginBottom: 8, fontSize: '0.8rem' }}>
请输入邮箱我们将发送验证码到您的邮箱 请输入邮箱我们将发送验证码到您的邮箱
</div> </div>
@@ -4953,6 +5088,51 @@ export default function HomePage() {
</div> </div>
</div> </div>
)} )}
{/* 全局轻提示 Toast */}
<AnimatePresence>
{toast.show && (
<motion.div
initial={{ opacity: 0, y: -20, x: '-50%' }}
animate={{ opacity: 1, y: 0, x: '-50%' }}
exit={{ opacity: 0, y: -20, x: '-50%' }}
style={{
position: 'fixed',
top: 24,
left: '50%',
zIndex: 9999,
padding: '10px 20px',
background: toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' :
toast.type === 'success' ? 'rgba(34, 197, 94, 0.9)' :
'rgba(30, 41, 59, 0.9)',
color: '#fff',
borderRadius: '8px',
backdropFilter: 'blur(8px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
fontSize: '14px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 8,
maxWidth: '90vw',
whiteSpace: 'nowrap'
}}
>
{toast.type === 'error' && (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2"/>
<path d="M12 8v4M12 16h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
)}
{toast.type === 'success' && (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 6L9 17l-5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{toast.message}
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "real-time-fund", "name": "real-time-fund",
"version": "0.1.0", "version": "0.1.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",