主要更新: 1. 按H5网页端完全重构匹配功能(match页面) - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募 - 资源对接等类型弹出手机号/微信号输入框 - 去掉重新匹配按钮,改为返回按钮 2. 修复所有卡片对齐和宽度问题 - 目录页附录卡片居中 - 首页阅读进度卡片满宽度 - 我的页面菜单卡片对齐 - 推广中心分享卡片统一宽度 3. 修复目录页图标和文字对齐 - section-icon固定40rpx宽高 - section-title与图标垂直居中 4. 更新真实完整文章标题(62篇) - 从book目录读取真实markdown文件名 - 替换之前的简化标题 5. 新增文章数据API - /api/db/chapters - 获取完整书籍结构 - 支持按ID获取单篇文章内容
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import { Bell, X, CheckCircle, AlertCircle, Clock, Wallet, Gift, Info } from 'lucide-react'
|
|
import { useStore } from '@/lib/store'
|
|
|
|
// 消息类型
|
|
interface NotificationMessage {
|
|
messageId: string
|
|
type: string
|
|
data: {
|
|
message?: string
|
|
title?: string
|
|
content?: string
|
|
amount?: number
|
|
commission?: number
|
|
daysRemaining?: number
|
|
[key: string]: unknown
|
|
}
|
|
timestamp: string
|
|
}
|
|
|
|
interface RealtimeNotificationProps {
|
|
onNewMessage?: (message: NotificationMessage) => void
|
|
}
|
|
|
|
export function RealtimeNotification({ onNewMessage }: RealtimeNotificationProps) {
|
|
const { user, isLoggedIn } = useStore()
|
|
const [notifications, setNotifications] = useState<NotificationMessage[]>([])
|
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
const [showPanel, setShowPanel] = useState(false)
|
|
const [lastTimestamp, setLastTimestamp] = useState(new Date().toISOString())
|
|
|
|
// 获取消息
|
|
const fetchMessages = useCallback(async () => {
|
|
if (!isLoggedIn || !user?.id) return
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/distribution/messages?userId=${user.id}&since=${encodeURIComponent(lastTimestamp)}`
|
|
)
|
|
|
|
if (!response.ok) return
|
|
|
|
const data = await response.json()
|
|
|
|
if (data.success && data.messages?.length > 0) {
|
|
setNotifications(prev => {
|
|
const newMessages = data.messages.filter(
|
|
(m: NotificationMessage) => !prev.some(p => p.messageId === m.messageId)
|
|
)
|
|
|
|
if (newMessages.length > 0) {
|
|
// 更新未读数
|
|
setUnreadCount(c => c + newMessages.length)
|
|
|
|
// 显示Toast通知
|
|
newMessages.forEach((msg: NotificationMessage) => {
|
|
showToast(msg)
|
|
onNewMessage?.(msg)
|
|
})
|
|
|
|
// 更新最后时间戳
|
|
const latestTime = newMessages.reduce(
|
|
(max: string, m: NotificationMessage) => m.timestamp > max ? m.timestamp : max,
|
|
lastTimestamp
|
|
)
|
|
setLastTimestamp(latestTime)
|
|
|
|
return [...newMessages, ...prev].slice(0, 50) // 保留最近50条
|
|
}
|
|
|
|
return prev
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error('[RealtimeNotification] 获取消息失败:', error)
|
|
}
|
|
}, [isLoggedIn, user?.id, lastTimestamp, onNewMessage])
|
|
|
|
// 轮询获取消息
|
|
useEffect(() => {
|
|
if (!isLoggedIn || !user?.id) return
|
|
|
|
// 立即获取一次
|
|
fetchMessages()
|
|
|
|
// 每5秒轮询一次
|
|
const intervalId = setInterval(fetchMessages, 5000)
|
|
|
|
return () => clearInterval(intervalId)
|
|
}, [isLoggedIn, user?.id, fetchMessages])
|
|
|
|
// 显示Toast通知
|
|
const showToast = (message: NotificationMessage) => {
|
|
// 创建Toast元素
|
|
const toast = document.createElement('div')
|
|
toast.className = 'fixed top-20 right-4 z-[100] animate-in slide-in-from-right duration-300'
|
|
toast.innerHTML = `
|
|
<div class="bg-[#1a1a2e] border border-gray-700 rounded-xl p-4 shadow-xl max-w-sm">
|
|
<div class="flex items-start gap-3">
|
|
<div class="w-10 h-10 rounded-full ${getIconBgClass(message.type)} flex items-center justify-center flex-shrink-0">
|
|
${getIconSvg(message.type)}
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-white font-medium text-sm">${getTitle(message.type)}</p>
|
|
<p class="text-gray-400 text-xs mt-1 line-clamp-2">${message.data.message || message.data.content || ''}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
document.body.appendChild(toast)
|
|
|
|
// 3秒后移除
|
|
setTimeout(() => {
|
|
toast.classList.add('animate-out', 'slide-out-to-right')
|
|
setTimeout(() => toast.remove(), 300)
|
|
}, 3000)
|
|
}
|
|
|
|
// 获取图标背景色
|
|
const getIconBgClass = (type: string): string => {
|
|
switch (type) {
|
|
case 'binding_expiring':
|
|
return 'bg-orange-500/20'
|
|
case 'binding_expired':
|
|
return 'bg-red-500/20'
|
|
case 'binding_converted':
|
|
case 'earnings_added':
|
|
return 'bg-green-500/20'
|
|
case 'withdrawal_completed':
|
|
return 'bg-[#38bdac]/20'
|
|
case 'withdrawal_rejected':
|
|
return 'bg-red-500/20'
|
|
default:
|
|
return 'bg-blue-500/20'
|
|
}
|
|
}
|
|
|
|
// 获取图标SVG
|
|
const getIconSvg = (type: string): string => {
|
|
switch (type) {
|
|
case 'binding_expiring':
|
|
return '<svg class="w-5 h-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
|
|
case 'binding_expired':
|
|
return '<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
|
|
case 'binding_converted':
|
|
case 'earnings_added':
|
|
return '<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
|
|
case 'withdrawal_completed':
|
|
return '<svg class="w-5 h-5 text-[#38bdac]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
|
|
case 'withdrawal_rejected':
|
|
return '<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
|
|
default:
|
|
return '<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
|
|
}
|
|
}
|
|
|
|
// 获取标题
|
|
const getTitle = (type: string): string => {
|
|
switch (type) {
|
|
case 'binding_expiring':
|
|
return '绑定即将过期'
|
|
case 'binding_expired':
|
|
return '绑定已过期'
|
|
case 'binding_converted':
|
|
return '用户已付款'
|
|
case 'earnings_added':
|
|
return '收益增加'
|
|
case 'withdrawal_approved':
|
|
return '提现已通过'
|
|
case 'withdrawal_completed':
|
|
return '提现已到账'
|
|
case 'withdrawal_rejected':
|
|
return '提现被拒绝'
|
|
case 'system_notice':
|
|
return '系统通知'
|
|
default:
|
|
return '消息通知'
|
|
}
|
|
}
|
|
|
|
// 获取图标组件
|
|
const getIcon = (type: string) => {
|
|
switch (type) {
|
|
case 'binding_expiring':
|
|
return <Clock className="w-5 h-5 text-orange-400" />
|
|
case 'binding_expired':
|
|
return <AlertCircle className="w-5 h-5 text-red-400" />
|
|
case 'binding_converted':
|
|
case 'earnings_added':
|
|
return <Gift className="w-5 h-5 text-green-400" />
|
|
case 'withdrawal_completed':
|
|
return <CheckCircle className="w-5 h-5 text-[#38bdac]" />
|
|
case 'withdrawal_rejected':
|
|
return <AlertCircle className="w-5 h-5 text-red-400" />
|
|
default:
|
|
return <Info className="w-5 h-5 text-blue-400" />
|
|
}
|
|
}
|
|
|
|
// 标记消息已读
|
|
const markAsRead = async () => {
|
|
if (!user?.id || notifications.length === 0) return
|
|
|
|
const messageIds = notifications.slice(0, 10).map(n => n.messageId)
|
|
|
|
try {
|
|
await fetch('/api/distribution/messages', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userId: user.id, messageIds }),
|
|
})
|
|
|
|
setUnreadCount(0)
|
|
} catch (error) {
|
|
console.error('[RealtimeNotification] 标记已读失败:', error)
|
|
}
|
|
}
|
|
|
|
// 打开面板时标记已读
|
|
const handleOpenPanel = () => {
|
|
setShowPanel(true)
|
|
if (unreadCount > 0) {
|
|
markAsRead()
|
|
}
|
|
}
|
|
|
|
if (!isLoggedIn || !user) return null
|
|
|
|
return (
|
|
<>
|
|
{/* 通知铃铛按钮 */}
|
|
<button
|
|
onClick={handleOpenPanel}
|
|
className="relative p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
|
>
|
|
<Bell className="w-5 h-5 text-white" />
|
|
{unreadCount > 0 && (
|
|
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-xs text-white font-bold">
|
|
{unreadCount > 9 ? '9+' : unreadCount}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* 通知面板 */}
|
|
{showPanel && (
|
|
<div className="fixed inset-0 z-50">
|
|
<div
|
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
onClick={() => setShowPanel(false)}
|
|
/>
|
|
|
|
<div className="absolute top-16 right-4 w-80 max-h-[70vh] bg-[#1a1a2e] border border-gray-700 rounded-xl shadow-2xl overflow-hidden animate-in slide-in-from-top-2 duration-200">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
|
<h3 className="text-white font-semibold">消息通知</h3>
|
|
<button
|
|
onClick={() => setShowPanel(false)}
|
|
className="p-1 rounded-full hover:bg-white/10 transition-colors"
|
|
>
|
|
<X className="w-5 h-5 text-gray-400" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 消息列表 */}
|
|
<div className="max-h-[50vh] overflow-auto">
|
|
{notifications.length === 0 ? (
|
|
<div className="py-12 text-center">
|
|
<Bell className="w-10 h-10 text-gray-600 mx-auto mb-2" />
|
|
<p className="text-gray-500 text-sm">暂无消息</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-700/50">
|
|
{notifications.map((notification) => (
|
|
<div
|
|
key={notification.messageId}
|
|
className="p-4 hover:bg-white/5 transition-colors"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className={`w-10 h-10 rounded-full ${getIconBgClass(notification.type)} flex items-center justify-center flex-shrink-0`}>
|
|
{getIcon(notification.type)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-white font-medium text-sm">
|
|
{getTitle(notification.type)}
|
|
</p>
|
|
<p className="text-gray-400 text-xs mt-1 line-clamp-2">
|
|
{notification.data.message || notification.data.content || ''}
|
|
</p>
|
|
<p className="text-gray-500 text-xs mt-2">
|
|
{new Date(notification.timestamp).toLocaleString('zh-CN')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|