191 lines
7.9 KiB
TypeScript
191 lines
7.9 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react';
|
|
import { Tag, Typography, Button } from 'antd';
|
|
import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined } from '@ant-design/icons';
|
|
import { getNotifications, markNotificationRead } from '../api';
|
|
import { useAuthStore } from '../store/useAuthStore';
|
|
|
|
interface Notification {
|
|
id: string;
|
|
title: string;
|
|
content: string;
|
|
type: string;
|
|
isRead: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
const typeConfig: Record<string, { gradient: string; icon: React.ReactNode; label: string }> = {
|
|
system: { gradient: 'linear-gradient(135deg, #6366f1, #818cf8)', icon: <ThunderboltOutlined />, label: '系统通知' },
|
|
credit: { gradient: 'linear-gradient(135deg, #f59e0b, #fbbf24)', icon: <StarOutlined />, label: '积分通知' },
|
|
promo: { gradient: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', icon: <GiftOutlined />, label: '活动通知' },
|
|
};
|
|
|
|
const NotificationPopup: React.FC = () => {
|
|
const [visible, setVisible] = useState(false);
|
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const refreshUser = useAuthStore((state) => state.refreshUser);
|
|
const setUserCredits = useAuthStore((state) => state.setUserCredits);
|
|
|
|
const fetchNotifications = useCallback(async () => {
|
|
try {
|
|
const result = await getNotifications(1, 20, false);
|
|
// 接口返回结构:{ credits: { balance: 4317.23 }, items: [...], total: N }
|
|
const data = result.items || [];
|
|
|
|
// 同步积分:result.credits.balance 与当前 user.credits 不一致时,直接替换
|
|
const newBalance = result?.credits?.balance;
|
|
if (typeof newBalance === 'number') {
|
|
const currentUser = useAuthStore.getState().user;
|
|
if (currentUser && currentUser.credits !== newBalance) {
|
|
setUserCredits(newBalance);
|
|
}
|
|
}
|
|
|
|
setNotifications(data);
|
|
if (data.length > 0 && !visible) {
|
|
setVisible(true);
|
|
setCurrentIndex(0);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}, [visible, setUserCredits]);
|
|
|
|
useEffect(() => {
|
|
fetchNotifications();
|
|
const timer = setInterval(fetchNotifications, 30000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
const handleAcknowledge = async () => {
|
|
const current = notifications[currentIndex];
|
|
if (current) {
|
|
try { await markNotificationRead(current.id); } catch { /* ignore */ }
|
|
}
|
|
if (currentIndex < notifications.length - 1) {
|
|
setCurrentIndex(currentIndex + 1);
|
|
} else {
|
|
setVisible(false);
|
|
setNotifications([]);
|
|
setCurrentIndex(0);
|
|
// 刷新用户信息(包括积分)
|
|
try { await refreshUser(); } catch { /* ignore */ }
|
|
}
|
|
};
|
|
|
|
const handleClose = async () => {
|
|
const current = notifications[currentIndex];
|
|
if (current) {
|
|
try { await markNotificationRead(current.id); } catch { /* ignore */ }
|
|
}
|
|
setVisible(false);
|
|
setNotifications([]);
|
|
setCurrentIndex(0);
|
|
};
|
|
|
|
if (!visible || notifications.length === 0) return null;
|
|
|
|
const current = notifications[currentIndex];
|
|
const tc = typeConfig[current.type] || typeConfig.system;
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
|
zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
background: 'rgba(15,15,35,0.6)', backdropFilter: 'blur(8px)',
|
|
}} onClick={handleClose}>
|
|
<div onClick={e => e.stopPropagation()} style={{
|
|
width: 420, borderRadius: 20, overflow: 'hidden',
|
|
boxShadow: '0 32px 80px rgba(0,0,0,0.35)',
|
|
animation: 'slideUp 0.35s cubic-bezier(0.16,1,0.3,1)',
|
|
}}>
|
|
{/* Header */}
|
|
<div style={{
|
|
background: tc.gradient,
|
|
padding: '28px 28px 24px',
|
|
position: 'relative', overflow: 'hidden',
|
|
}}>
|
|
<div style={{ position: 'absolute', right: -20, top: -20, width: 120, height: 120, borderRadius: '50%', background: 'rgba(255,255,255,0.1)' }} />
|
|
<div style={{ position: 'absolute', right: 50, bottom: -30, width: 80, height: 80, borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }} />
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
<div style={{
|
|
width: 48, height: 48, borderRadius: 14,
|
|
background: 'rgba(255,255,255,0.2)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
backdropFilter: 'blur(4px)', fontSize: 22, color: '#fff',
|
|
}}>{tc.icon}</div>
|
|
<div>
|
|
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginBottom: 2 }}>新消息通知</div>
|
|
<div style={{ color: '#fff', fontSize: 17, fontWeight: 700 }}>{current.title}</div>
|
|
</div>
|
|
</div>
|
|
<div onClick={handleClose} style={{
|
|
width: 28, height: 28, borderRadius: 8,
|
|
background: 'rgba(255,255,255,0.15)', cursor: 'pointer',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
transition: 'background 0.2s',
|
|
}}
|
|
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.25)'; }}
|
|
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.15)'; }}
|
|
>
|
|
<CloseOutlined style={{ color: '#fff', fontSize: 12 }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div style={{ background: '#fff', padding: '24px 28px 28px' }}>
|
|
<div style={{
|
|
padding: '18px 20px', background: '#f8fafc', borderRadius: 14,
|
|
marginBottom: 20, border: '1px solid #f0f0f5',
|
|
}}>
|
|
<div style={{ fontSize: 14, color: '#334155', lineHeight: 1.8 }}>
|
|
{current.content}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<Tag color={tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6'}
|
|
style={{ borderRadius: 6, border: 'none', padding: '2px 10px', fontSize: 12 }}>
|
|
{tc.label}
|
|
</Tag>
|
|
{notifications.length > 1 && (
|
|
<span style={{ fontSize: 12, color: '#94a3b8' }}>{currentIndex + 1} / {notifications.length}</span>
|
|
)}
|
|
</div>
|
|
<Button type="primary" onClick={handleAcknowledge} style={{
|
|
borderRadius: 10, fontWeight: 600, height: 38,
|
|
background: tc.gradient, border: 'none',
|
|
paddingLeft: 28, paddingRight: 28,
|
|
boxShadow: `0 6px 16px ${tc.gradient.includes('#6366f1') ? 'rgba(99,102,241,0.3)' : tc.gradient.includes('#f59e0b') ? 'rgba(245,158,11,0.3)' : 'rgba(139,92,246,0.3)'}`,
|
|
}}>
|
|
我已知晓
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Dots */}
|
|
{notifications.length > 1 && (
|
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 6, marginTop: 18 }}>
|
|
{notifications.map((_, i) => (
|
|
<div key={i} style={{
|
|
width: i === currentIndex ? 22 : 6, height: 6, borderRadius: 3,
|
|
background: i === currentIndex ? (tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6') : '#e2e8f0',
|
|
transition: 'all 0.3s ease',
|
|
}} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<style>{`
|
|
@keyframes slideUp {
|
|
from { opacity: 0; transform: translateY(24px) scale(0.97); }
|
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default NotificationPopup;
|