feat:增加更新提示

This commit is contained in:
hzm
2026-02-07 15:41:17 +08:00
parent 8cc325b38a
commit 374b34fcf5
2 changed files with 175 additions and 26 deletions

View File

@@ -1,9 +1,9 @@
import Script from 'next/script';
import './globals.css'; import './globals.css';
import AnalyticsGate from './components/AnalyticsGate'; import AnalyticsGate from './components/AnalyticsGate';
import packageJson from '../package.json';
export const metadata = { export const metadata = {
title: '基估宝', title: `基估宝 V${packageJson.version}`,
description: '输入基金编号添加基金实时显示估值与前10重仓' description: '输入基金编号添加基金实时显示估值与前10重仓'
}; };

View File

@@ -9,6 +9,7 @@ import zhifubaoImg from "./assets/zhifubao.jpg";
import weixinImg from "./assets/weixin.jpg"; import weixinImg from "./assets/weixin.jpg";
import githubImg from "./assets/github.svg"; import githubImg from "./assets/github.svg";
import { supabase } from './lib/supabase'; import { supabase } from './lib/supabase';
import packageJson from '../package.json';
function PlusIcon(props) { function PlusIcon(props) {
return ( return (
@@ -18,6 +19,16 @@ function PlusIcon(props) {
); );
} }
function UpdateIcon(props) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="7 10 12 15 17 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function TrashIcon(props) { function TrashIcon(props) {
return ( return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
@@ -1187,14 +1198,15 @@ function SuccessModal({ message, onClose }) {
); );
} }
function CloudConfigModal({ onConfirm, onCancel }) { function CloudConfigModal({ onConfirm, onCancel, type = 'empty' }) {
const isConflict = type === 'conflict';
return ( return (
<motion.div <motion.div
className="modal-overlay" className="modal-overlay"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="云端同步提示" aria-label={isConflict ? "配置冲突提示" : "云端同步提示"}
onClick={onCancel} onClick={isConflict ? undefined : onCancel}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
@@ -1210,21 +1222,25 @@ function CloudConfigModal({ onConfirm, onCancel }) {
<div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}> <div className="title" style={{ marginBottom: 12, justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<CloudIcon width="20" height="20" /> <CloudIcon width="20" height="20" />
<span>云端暂无配置</span> <span>{isConflict ? '发现配置冲突' : '云端暂无配置'}</span>
</div> </div>
<button className="icon-button" onClick={onCancel} style={{ border: 'none', background: 'transparent' }}> {!isConflict && (
<CloseIcon width="20" height="20" /> <button className="icon-button" onClick={onCancel} style={{ border: 'none', background: 'transparent' }}>
</button> <CloseIcon width="20" height="20" />
</button>
)}
</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
? '检测到本地配置比云端更新,请选择操作:'
: '是否将本地配置同步到云端?'}
</p> </p>
<div className="row" style={{ flexDirection: 'column', gap: 12 }}> <div className="row" style={{ flexDirection: 'column', gap: 12 }}>
<button className="button" onClick={onConfirm}> <button className="button" onClick={onConfirm}>
同步本地到云端 {isConflict ? '保留本地 (覆盖云端)' : '同步本地到云端'}
</button> </button>
<button className="button secondary" onClick={onCancel}> <button className="button secondary" onClick={onCancel}>
暂不同步 {isConflict ? '使用云端 (覆盖本地)' : '暂不同步'}
</button> </button>
</div> </div>
</motion.div> </motion.div>
@@ -1837,6 +1853,34 @@ export default function HomePage() {
} }
}, []); }, []);
// 检查更新
const [hasUpdate, setHasUpdate] = useState(false);
const [latestVersion, setLatestVersion] = useState('');
useEffect(() => {
const checkUpdate = async () => {
try {
const res = await fetch('https://api.github.com/repos/hzm0321/real-time-fund/releases/latest');
console.log(packageJson.version)
if (!res.ok) return;
const data = await res.json();
if (data.tag_name) {
const remoteVersion = data.tag_name.replace(/^v/, '');
if (remoteVersion !== packageJson.version) {
setHasUpdate(true);
setLatestVersion(remoteVersion);
}
}
} catch (e) {
console.error('Check update failed:', e);
}
};
checkUpdate();
const interval = setInterval(checkUpdate, 10 * 60 * 1000); // 10 minutes
return () => clearInterval(interval);
}, []);
// 存储当前被划开的基金代码 // 存储当前被划开的基金代码
const [swipedFundCode, setSwipedFundCode] = useState(null); const [swipedFundCode, setSwipedFundCode] = useState(null);
@@ -2128,6 +2172,7 @@ export default function HomePage() {
// 成功提示弹窗 // 成功提示弹窗
const [successModal, setSuccessModal] = useState({ open: false, message: '' }); const [successModal, setSuccessModal] = useState({ open: false, message: '' });
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);
const lastSyncedRef = useRef(''); const lastSyncedRef = useRef('');
@@ -2169,14 +2214,27 @@ export default function HomePage() {
localStorage.setItem = (key, value) => { localStorage.setItem = (key, value) => {
originalSetItem(key, value); originalSetItem(key, value);
if (keys.has(key)) scheduleSync(); if (keys.has(key)) {
if (!skipSyncRef.current) {
originalSetItem('localUpdatedAt', new Date().toISOString());
}
scheduleSync();
}
}; };
localStorage.removeItem = (key) => { localStorage.removeItem = (key) => {
originalRemoveItem(key); originalRemoveItem(key);
if (keys.has(key)) scheduleSync(); if (keys.has(key)) {
if (!skipSyncRef.current) {
originalSetItem('localUpdatedAt', new Date().toISOString());
}
scheduleSync();
}
}; };
localStorage.clear = () => { localStorage.clear = () => {
originalClear(); originalClear();
if (!skipSyncRef.current) {
originalSetItem('localUpdatedAt', new Date().toISOString());
}
scheduleSync(); scheduleSync();
}; };
@@ -2397,14 +2455,14 @@ export default function HomePage() {
if (!incoming || typeof incoming !== 'object') return; if (!incoming || typeof incoming !== 'object') return;
const incomingComparable = getComparablePayload(incoming); const incomingComparable = getComparablePayload(incoming);
if (!incomingComparable || incomingComparable === lastSyncedRef.current) return; if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
await applyCloudConfig(incoming); 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) => { .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'user_configs', filter: `user_id=eq.${user.id}` }, async (payload) => {
const incoming = payload?.new?.data; const incoming = payload?.new?.data;
if (!incoming || typeof incoming !== 'object') return; if (!incoming || typeof incoming !== 'object') return;
const incomingComparable = getComparablePayload(incoming); const incomingComparable = getComparablePayload(incoming);
if (!incomingComparable || incomingComparable === lastSyncedRef.current) return; if (!incomingComparable || incomingComparable === lastSyncedRef.current) return;
await applyCloudConfig(incoming); await applyCloudConfig(incoming, payload.new.updated_at);
}) })
.subscribe(); .subscribe();
return () => { return () => {
@@ -2498,6 +2556,8 @@ export default function HomePage() {
setUser(null); setUser(null);
} catch (err) { } catch (err) {
console.error('登出失败', err); console.error('登出失败', err);
setUserMenuOpen(false);
setUser(null);
} }
}; };
@@ -3187,10 +3247,13 @@ export default function HomePage() {
} }
}; };
const applyCloudConfig = async (cloudData) => { const applyCloudConfig = async (cloudData, cloudUpdatedAt) => {
if (!cloudData || typeof cloudData !== 'object') return; if (!cloudData || typeof cloudData !== 'object') return;
skipSyncRef.current = true; skipSyncRef.current = true;
try { try {
if (cloudUpdatedAt) {
localStorage.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)); localStorage.setItem('funds', JSON.stringify(nextFunds));
@@ -3237,7 +3300,7 @@ export default function HomePage() {
try { try {
const { data, error } = await supabase const { data, error } = await supabase
.from('user_configs') .from('user_configs')
.select('id, data') .select('id, data, updated_at')
.eq('user_id', userId) .eq('user_id', userId)
.maybeSingle(); .maybeSingle();
if (error) throw error; if (error) throw error;
@@ -3246,14 +3309,22 @@ export default function HomePage() {
.from('user_configs') .from('user_configs')
.insert({ user_id: userId }); .insert({ user_id: userId });
if (insertError) throw insertError; if (insertError) throw insertError;
setCloudConfigModal({ open: true, userId }); setCloudConfigModal({ open: true, userId, type: 'empty' });
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) {
await applyCloudConfig(data.data); const cloudTime = new Date(data.updated_at || 0).getTime();
const localTime = new Date(localStorage.getItem('localUpdatedAt') || 0).getTime();
if (localTime > cloudTime + 2000) {
setCloudConfigModal({ open: true, userId, type: 'conflict', cloudData: data.data });
return;
}
await applyCloudConfig(data.data, data.updated_at);
return; return;
} }
setCloudConfigModal({ open: true, userId }); setCloudConfigModal({ open: true, userId, type: 'empty' });
} catch (e) { } catch (e) {
console.error('获取云端配置失败', e); console.error('获取云端配置失败', e);
} }
@@ -3263,11 +3334,21 @@ export default function HomePage() {
if (!userId) return; if (!userId) return;
try { try {
const payload = collectLocalPayload(); const payload = collectLocalPayload();
const now = new Date().toISOString();
const { error: updateError } = await supabase const { error: updateError } = await supabase
.from('user_configs') .from('user_configs')
.update({ data: payload, updated_at: new Date().toISOString() }) .upsert(
.eq('user_id', userId); {
user_id: userId,
data: payload,
updated_at: now
},
{ onConflict: 'user_id' }
);
if (updateError) throw updateError; if (updateError) throw updateError;
localStorage.setItem('localUpdatedAt', now);
if (showTip) { if (showTip) {
setSuccessModal({ open: true, message: '已同步云端配置' }); setSuccessModal({ open: true, message: '已同步云端配置' });
} }
@@ -3440,7 +3521,8 @@ export default function HomePage() {
tradeModal.open || tradeModal.open ||
!!clearConfirm || !!clearConfirm ||
donateOpen || donateOpen ||
!!fundDeleteConfirm; !!fundDeleteConfirm ||
updateModalOpen;
if (isAnyModalOpen) { if (isAnyModalOpen) {
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
@@ -3465,7 +3547,8 @@ export default function HomePage() {
actionModal.open, actionModal.open,
tradeModal.open, tradeModal.open,
clearConfirm, clearConfirm,
donateOpen donateOpen,
updateModalOpen
]); ]);
useEffect(() => { useEffect(() => {
@@ -3497,6 +3580,16 @@ export default function HomePage() {
</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")} /> <img alt="项目Github地址" src={githubImg.src} style={{ width: '30px', height: '30px', cursor: 'pointer' }} onClick={() => window.open("https://github.com/hzm0321/real-time-fund")} />
{hasUpdate && (
<div
className="badge"
title={`发现新版本 ${latestVersion},点击前往下载`}
style={{ cursor: 'pointer', borderColor: 'var(--success)', color: 'var(--success)' }}
onClick={() => setUpdateModalOpen(true)}
>
<UpdateIcon width="14" height="14" />
</div>
)}
<div className="badge" title="当前刷新频率"> <div className="badge" title="当前刷新频率">
<span>刷新</span> <span>刷新</span>
<strong>{Math.round(refreshMs / 1000)}</strong> <strong>{Math.round(refreshMs / 1000)}</strong>
@@ -4636,8 +4729,14 @@ export default function HomePage() {
<AnimatePresence> <AnimatePresence>
{cloudConfigModal.open && ( {cloudConfigModal.open && (
<CloudConfigModal <CloudConfigModal
type={cloudConfigModal.type}
onConfirm={handleSyncLocalConfig} onConfirm={handleSyncLocalConfig}
onCancel={() => setCloudConfigModal({ open: false, userId: null })} onCancel={() => {
if (cloudConfigModal.type === 'conflict' && cloudConfigModal.cloudData) {
applyCloudConfig(cloudConfigModal.cloudData);
}
setCloudConfigModal({ open: false, userId: null });
}}
/> />
)} )}
</AnimatePresence> </AnimatePresence>
@@ -4712,6 +4811,56 @@ export default function HomePage() {
</div> </div>
)} )}
{/* 更新提示弹窗 */}
<AnimatePresence>
{updateModalOpen && (
<motion.div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="更新提示"
onClick={() => setUpdateModalOpen(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ zIndex: 10002 }}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="glass card modal"
style={{ maxWidth: '400px' }}
onClick={(e) => e.stopPropagation()}
>
<div className="title" style={{ marginBottom: 12 }}>
<UpdateIcon width="20" height="20" style={{color: 'var(--success)'}} />
<span>更新提示</span>
</div>
<p className="muted" style={{ marginBottom: 24, fontSize: '14px', lineHeight: '1.6' }}>
检测到新版本是否刷新浏览器以更新
</p>
<div className="row" style={{ gap: 12 }}>
<button
className="button secondary"
onClick={() => setUpdateModalOpen(false)}
style={{ flex: 1, background: 'rgba(255,255,255,0.05)', color: 'var(--text)' }}
>
取消
</button>
<button
className="button"
onClick={() => window.location.reload()}
style={{ flex: 1, background: 'var(--success)', color: '#fff', border: 'none' }}
>
刷新浏览器
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 登录模态框 */} {/* 登录模态框 */}
{loginModalOpen && ( {loginModalOpen && (
<div <div