import React, { useState, useEffect, useRef } from 'react' import { Card, Button, Input, List, Badge, Modal, Form, Select, Tabs, Avatar, Empty, message, Popconfirm, Space, Tag, Tooltip, Popover, Image } from 'antd' import { PlusOutlined, SendOutlined, TeamOutlined, UserOutlined, ArrowLeftOutlined, DeleteOutlined, SettingOutlined, UserDeleteOutlined, UserAddOutlined, CheckCircleFilled, ClockCircleOutlined, SmileOutlined, PictureOutlined, VideoCameraOutlined, PaperClipOutlined, FileOutlined, DownloadOutlined } from '@ant-design/icons' import axios from 'axios' const { TextArea } = Input // 常用表情面板 const EMOJIS = ['😀', '😄', '😂', '🤣', '😊', '😍', '😘', '😎', '🤔', '😅', '😭', '😡', '👍', '👎', '👏', '🙏', '🤝', '💪', '❤️', '💔', '🎉', '🎂', '🌹', '☕', '🍺', '⚽', '🌙', '☀️', '⛅', '🌈', '🔥', '✨', '💯', '✅', '❌', '⚠️', '📷', '🎬', '🎤', '📱', '💻', '🚗', '✈️', '⏰', '📅', '💰', '🎁', '🏆'] // 文件大小格式化 function formatSize(size) { if (!size && size !== 0) return '' if (size < 1024) return size + ' B' if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB' if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(1) + ' MB' return (size / 1024 / 1024 / 1024).toFixed(2) + ' GB' } // 附件访问地址:拼上 baseURL 与 token(img/video/下载链接无法携带 Authorization 头) function fileSrc(url) { if (!url) return '' const token = localStorage.getItem('token') || '' return axios.getUri({ url }) + (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token) } // 移动端检测 function useIsMobile() { const [isMobile, setIsMobile] = useState(window.innerWidth < 768) useEffect(() => { const onResize = () => setIsMobile(window.innerWidth < 768) window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, []) return isMobile } function Messages({ user }) { const isMobile = useIsMobile() const [conversations, setConversations] = useState([]) const [allUsers, setAllUsers] = useState([]) const [activeConv, setActiveConv] = useState(null) const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState('') const [sending, setSending] = useState(false) const [createVisible, setCreateVisible] = useState(false) const [membersVisible, setMembersVisible] = useState(false) const [inviteVisible, setInviteVisible] = useState(false) const [inviteIds, setInviteIds] = useState([]) const [createForm] = Form.useForm() const [creating, setCreating] = useState(false) const [uploading, setUploading] = useState(false) const imgInputRef = useRef(null) const videoInputRef = useRef(null) const fileInputRef = useRef(null) const bottomRef = useRef(null) const activeConvRef = useRef(null) const lastMsgIdRef = useRef(0) const readStatusRef = useRef({}) // 打开会话时同步到 ref(供轮询闭包使用) const openConversation = (conv) => { setActiveConv(conv) activeConvRef.current = conv lastMsgIdRef.current = 0 readStatusRef.current = {} setMessages([]) } const closeConversation = () => { setActiveConv(null) activeConvRef.current = null readStatusRef.current = {} setMessages([]) } const fetchConversations = async () => { try { const res = await axios.get('/api/chat/conversations') if (res.data.success) setConversations(res.data.data) } catch (err) { /* 静默 */ } } const fetchUsers = async () => { try { const res = await axios.get('/api/chat/users') if (res.data.success) setAllUsers(res.data.data) } catch (err) { /* 静默 */ } } // 拉取当前打开会话的消息(首次全量,之后增量) const fetchMessages = async (conv) => { if (!conv) return try { const after = lastMsgIdRef.current const res = await axios.get(`/api/chat/conversations/${conv.id}/messages`, { params: after ? { after } : {} }) if (res.data.success) { const rows = res.data.data if (rows.length > 0) { lastMsgIdRef.current = rows[rows.length - 1].id setMessages(prev => after > 0 ? [...prev, ...rows] : rows) } } } catch (err) { /* 静默 */ } } // 拉取当前打开会话的已读回执,合并进消息列表 const fetchReadStatus = async (conv) => { if (!conv) return try { const res = await axios.get(`/api/chat/conversations/${conv.id}/read-status`) if (res.data.success) { readStatusRef.current = res.data.data setMessages(prev => prev.map(m => res.data.data[m.id] ? { ...m, read_status: res.data.data[m.id] } : m)) } } catch (err) { /* 静默 */ } } // 会话列表 5 秒轮询;打开的会话 3 秒轮询消息,4 秒轮询已读回执 useEffect(() => { fetchConversations() fetchUsers() const convTimer = setInterval(fetchConversations, 5000) const msgTimer = setInterval(() => fetchMessages(activeConvRef.current), 3000) const readTimer = setInterval(() => fetchReadStatus(activeConvRef.current), 4000) return () => { clearInterval(convTimer); clearInterval(msgTimer); clearInterval(readTimer) } }, []) // 新消息自动滚动到底部 useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages.length]) const handleSend = async () => { const content = inputValue.trim() if (!content || !activeConv || sending) return setSending(true) try { const res = await axios.post(`/api/chat/conversations/${activeConv.id}/messages`, { content }) if (res.data.success) { setInputValue('') await fetchMessages(activeConvRef.current) fetchConversations() } } catch (err) { message.error(err.response?.data?.message || '发送失败') } finally { setSending(false) } } // 选择并发送图片/视频/文件:先上传拿 fileId,再发媒体消息 const handlePickMedia = async (type, file) => { if (!file || !activeConv || uploading) return if (file.size > 100 * 1024 * 1024) { message.error('文件超过 100MB 限制') return } setUploading(true) const hide = message.loading(type === 'image' ? '图片上传中…' : type === 'video' ? '视频上传中…' : '文件上传中…', 0) try { const fd = new FormData() fd.append('file', file) const up = await axios.post('/api/chat/upload', fd) if (!up.data.success) throw new Error(up.data.message) const res = await axios.post(`/api/chat/conversations/${activeConv.id}/messages`, { type, fileId: up.data.data.fileId, content: up.data.data.fileName }) if (res.data.success) { await fetchMessages(activeConvRef.current) fetchConversations() } } catch (err) { message.error(err.response?.data?.message || err.message || '发送失败') } finally { hide() setUploading(false) } } const handleCreate = async (values) => { setCreating(true) try { const payload = values.tab === 'group' ? { type: 'group', name: values.name, memberIds: values.memberIds } : { type: 'direct', userId: values.userId } const res = await axios.post('/api/chat/conversations', payload) if (res.data.success) { message.success(res.data.message) setCreateVisible(false) createForm.resetFields() await fetchConversations() // 自动打开新会话 const list = (await axios.get('/api/chat/conversations')).data if (list.success) { const conv = list.data.find(c => c.id === res.data.data.id) if (conv) openConversation(conv) } } } catch (err) { message.error(err.response?.data?.message || '创建失败') } finally { setCreating(false) } } const handleDeleteConv = async (conv) => { try { const res = await axios.delete(`/api/chat/conversations/${conv.id}`) if (res.data.success) { message.success(res.data.message) if (activeConv?.id === conv.id) closeConversation() fetchConversations() } } catch (err) { message.error(err.response?.data?.message || '删除失败') } } const canDelete = (conv) => conv.type === 'direct' || conv.creator_id === user?.id // 群主移除群成员 const handleRemoveMember = async (memberId) => { try { const res = await axios.delete(`/api/chat/conversations/${activeConv.id}/members/${memberId}`) if (res.data.success) { message.success(res.data.message) setMembersVisible(false) // 刷新会话列表与当前会话 const list = await axios.get('/api/chat/conversations') if (list.data.success) { setConversations(list.data.data) const conv = list.data.data.find(c => c.id === activeConv.id) if (conv) { setActiveConv(conv) activeConvRef.current = conv } else { closeConversation() } } } } catch (err) { message.error(err.response?.data?.message || '操作失败') } } // 群主邀请成员加入群聊(拉人) const handleInviteMembers = async () => { if (!inviteIds.length) { message.warning('请选择要邀请的成员') return } try { const res = await axios.post(`/api/chat/conversations/${activeConv.id}/members`, { user_ids: inviteIds }) if (res.data.success) { message.success(res.data.message) setInviteVisible(false) setInviteIds([]) const list = await axios.get('/api/chat/conversations') if (list.data.success) { setConversations(list.data.data) const conv = list.data.data.find(c => c.id === activeConv.id) if (conv) { setActiveConv(conv) activeConvRef.current = conv } } } } catch (err) { message.error(err.response?.data?.message || '邀请失败') } } // 可邀请的用户(尚未在群内) const inviteOptions = allUsers .filter(u => !(activeConv?.members || []).some(m => m.id === u.id)) .map(u => ({ value: u.id, label: `${u.display_name || u.username}${u.role === 'admin' ? '(管理员)' : ''}` })) const otherOptions = allUsers.filter(u => u.id !== user?.id).map(u => ({ value: u.id, label: ( {u.display_name || u.username} ({u.username}) ) })) // ============ 会话列表 ============ const conversationList = ( <>
我的会话
{conversations.length === 0 ? ( ) : ( ( openConversation(conv)} style={{ cursor: 'pointer', padding: '10px 8px' }} extra={ {conv.unread > 0 && } {canDelete(conv) && ( { e?.stopPropagation(); handleDeleteConv(conv) }} onCancel={(e) => e?.stopPropagation()} okText="确定" cancelText="取消" > )}
{messages.length === 0 && } {messages.map(m => { const mine = m.sender_id === user?.id const rs = m.read_status const allRead = rs && rs.total_others > 0 && rs.read_count >= rs.total_others let readEl = null if (mine && rs && rs.total_others > 0) { if (activeConv.type === 'group' && !allRead) { const names = rs.unread_users.map(u => u.name).slice(0, 5).join('、') const extra = rs.unread_users.length > 5 ? ` 等${rs.unread_users.length}人` : '' readEl = ( 未读({rs.read_count}/{rs.total_others}人已读) ) } else { readEl = allRead ? 已读 : 未读 } } // 按消息类型渲染内容:图片/视频/文件/文本 let body if (m.msg_type === 'image' && m.file_url) { body = (
) } else if (m.msg_type === 'video' && m.file_url) { body = (
) } else if (m.msg_type === 'file' && m.file_url) { body = (
{m.file_name || m.content}
{formatSize(m.file_size)}
) } else { body =
{m.content}
} return (
{!mine &&
{m.sender_name}
} {body}
{m.created_at?.slice(5, 16)} {readEl}
) })}
{EMOJIS.map(e => ( setInputValue(v => v + e)}>{e} ))}
} >
{ handlePickMedia('image', e.target.files?.[0]); e.target.value = '' }} /> { handlePickMedia('video', e.target.files?.[0]); e.target.value = '' }} /> { handlePickMedia('file', e.target.files?.[0]); e.target.value = '' }} />