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: (
被邀请成员将收到系统通知,并立即加入群聊。