Files
dms/client/src/pages/Messages.jsx
T

428 lines
15 KiB
React
Raw Normal View History

2026-08-11 10:03:40 +08:00
import React, { useState, useEffect, useRef } from 'react'
import { Card, Button, Input, List, Badge, Modal, Form, Select, Tabs, Avatar, Empty, message, Popconfirm, Space, Tag, Tooltip } from 'antd'
2026-08-11 12:19:20 +08:00
import { PlusOutlined, SendOutlined, TeamOutlined, UserOutlined, ArrowLeftOutlined, DeleteOutlined, SettingOutlined, UserDeleteOutlined } from '@ant-design/icons'
2026-08-11 10:03:40 +08:00
import axios from 'axios'
const { TextArea } = Input
// 移动端检测
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)
2026-08-11 12:19:20 +08:00
const [membersVisible, setMembersVisible] = useState(false)
2026-08-11 10:03:40 +08:00
const [createForm] = Form.useForm()
const [creating, setCreating] = useState(false)
const bottomRef = useRef(null)
const activeConvRef = useRef(null)
const lastMsgIdRef = useRef(0)
// 打开会话时同步到 ref(供轮询闭包使用)
const openConversation = (conv) => {
setActiveConv(conv)
activeConvRef.current = conv
lastMsgIdRef.current = 0
setMessages([])
}
const closeConversation = () => {
setActiveConv(null)
activeConvRef.current = null
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) { /* 静默 */ }
}
// 会话列表 5 秒轮询;打开的会话 3 秒轮询消息
useEffect(() => {
fetchConversations()
fetchUsers()
const convTimer = setInterval(fetchConversations, 5000)
const msgTimer = setInterval(() => fetchMessages(activeConvRef.current), 3000)
return () => { clearInterval(convTimer); clearInterval(msgTimer) }
}, [])
// 新消息自动滚动到底部
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)
}
}
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
2026-08-11 12:19:20 +08:00
// 群主移除群成员
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 || '操作失败')
}
}
2026-08-11 10:03:40 +08:00
const otherOptions = allUsers.filter(u => u.id !== user?.id).map(u => ({
value: u.id,
label: (
<span>
<Badge status={u.is_online ? 'success' : 'default'} />
{u.display_name || u.username}
<span style={{ color: '#999', fontSize: 12 }}>({u.username})</span>
</span>
)
}))
// ============ 会话列表 ============
const conversationList = (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontWeight: 500 }}>我的会话</span>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); setCreateVisible(true) }}>
发起对话 / 建群
</Button>
</div>
{conversations.length === 0 ? (
<Empty description="暂无会话,点击右上角发起对话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
dataSource={conversations}
renderItem={(conv) => (
<List.Item
className={`conv-item ${activeConv?.id === conv.id ? 'conv-item-active' : ''}`}
onClick={() => openConversation(conv)}
style={{ cursor: 'pointer', padding: '10px 8px' }}
extra={
<Space size={4}>
{conv.unread > 0 && <Badge count={conv.unread} />}
{canDelete(conv) && (
<Popconfirm
title={conv.type === 'group' ? '确定解散该群组吗?' : '确定删除该会话吗?'}
onConfirm={(e) => { e?.stopPropagation(); handleDeleteConv(conv) }}
onCancel={(e) => e?.stopPropagation()}
okText="确定"
cancelText="取消"
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
)}
</Space>
}
>
<List.Item.Meta
avatar={
<Avatar
style={{ backgroundColor: conv.type === 'group' ? '#faad14' : '#7c3aed' }}
icon={conv.type === 'group' ? <TeamOutlined /> : <UserOutlined />}
/>
}
title={
<span>
{conv.name || '未命名'}
{conv.type === 'group' && <Tag style={{ marginLeft: 8 }}>{conv.member_count}人</Tag>}
</span>
}
description={
conv.last_message
? `${conv.last_message.sender_name}:${conv.last_message.content.slice(0, 20)}`
: '暂无消息'
}
/>
</List.Item>
)}
/>
)}
</>
)
// ============ 聊天窗口 ============
const chatWindow = activeConv ? (
<div className="chat-window">
<div className="chat-header">
{isMobile && (
<Button type="text" icon={<ArrowLeftOutlined />} onClick={closeConversation} style={{ marginRight: 4 }} />
)}
<span style={{ fontWeight: 500 }}>{activeConv.name || '未命名'}</span>
{activeConv.type === 'group' && (
<Tooltip title={activeConv.members.map(m => m.display_name || m.username).join('、')}>
<Tag style={{ marginLeft: 8 }}>{activeConv.member_count}人</Tag>
</Tooltip>
)}
2026-08-11 12:19:20 +08:00
{activeConv.type === 'group' && activeConv.creator_id === user?.id && (
<Button size="small" type="text" icon={<SettingOutlined />} onClick={() => setMembersVisible(true)} style={{ marginLeft: 'auto' }}>
成员管理
</Button>
)}
2026-08-11 10:03:40 +08:00
</div>
<div className="chat-messages">
{messages.length === 0 && <Empty description="还没有消息,发送第一条吧" image={Empty.PRESENTED_IMAGE_SIMPLE} style={{ marginTop: 40 }} />}
{messages.map(m => {
const mine = m.sender_id === user?.id
return (
<div key={m.id} className={`chat-msg ${mine ? 'chat-msg-mine' : ''}`}>
{!mine && <div className="chat-msg-sender">{m.sender_name}</div>}
<div className={`chat-bubble ${mine ? 'chat-bubble-mine' : ''}`}>{m.content}</div>
<div className="chat-msg-time">{m.created_at?.slice(5, 16)}</div>
</div>
)
})}
<div ref={bottomRef} />
</div>
<div className="chat-input">
<TextArea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={(e) => { if (!e.shiftKey) { e.preventDefault(); handleSend() } }}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
autoSize={{ minRows: 1, maxRows: 4 }}
style={{ flex: 1 }}
/>
<Button type="primary" icon={<SendOutlined />} loading={sending} onClick={handleSend}>发送</Button>
</div>
</div>
) : (
<div className="chat-placeholder">
<Empty description="选择一个会话开始聊天" />
</div>
)
2026-08-11 12:19:20 +08:00
// ============ 发起对话 / 建群弹窗 ============
// 注意:这里必须用普通 JSX 变量而非内部函数组件,
// 否则轮询重新渲染时组件类型变化会导致弹窗被卸载重建(闪退)
const createModal = (
<Modal
title="发起对话"
open={createVisible}
onCancel={() => setCreateVisible(false)}
footer={null}
width={480}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ tab: 'direct' }}>
<Form.Item name="tab" noStyle>
<Tabs
centered
items={[
{
key: 'direct',
label: '单聊',
children: (
<Form.Item name="userId" label="选择对话对象" rules={[{ required: true, message: '请选择对话对象' }]}>
<Select placeholder="选择用户(绿点为在线)" options={otherOptions} showSearch optionFilterProp="label" />
</Form.Item>
)
},
{
key: 'group',
label: '创建群聊',
children: (
<>
<Form.Item name="name" label="群名称" rules={[{ required: true, message: '请输入群名称' }]}>
<Input placeholder="例如:拍摄组工作群" />
</Form.Item>
<Form.Item name="memberIds" label="群成员(除自己外至少选1人)" rules={[{ required: true, message: '请选择群成员' }]}>
<Select mode="multiple" placeholder="选择群成员" options={otherOptions} showSearch optionFilterProp="label" />
</Form.Item>
</>
)
}
]}
/>
</Form.Item>
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => setCreateVisible(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={creating}>确定</Button>
</Space>
</div>
</Form>
</Modal>
)
// ============ 群成员管理弹窗(仅群主可见) ============
const membersModal = (
<Modal
title={`群成员管理(${activeConv?.member_count || 0}人)`}
open={membersVisible}
onCancel={() => setMembersVisible(false)}
footer={null}
width={420}
>
<List
dataSource={activeConv?.members || []}
renderItem={(m) => (
<List.Item
actions={
m.id !== activeConv?.creator_id
? [
<Popconfirm
key="del"
title={`确定将 ${m.display_name || m.username} 移出群聊吗?`}
onConfirm={() => handleRemoveMember(m.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" size="small" danger icon={<UserDeleteOutlined />} />
</Popconfirm>
]
: []
}
>
<List.Item.Meta
avatar={<Avatar icon={<UserOutlined />} />}
title={
<span>
{m.display_name || m.username}
{m.id === activeConv?.creator_id && <Tag color="gold" style={{ marginLeft: 8 }}>群主</Tag>}
{m.id === user?.id && <Tag style={{ marginLeft: 8 }}>我</Tag>}
</span>
}
/>
</List.Item>
)}
/>
</Modal>
)
2026-08-11 10:03:40 +08:00
// ============ 布局 ============
if (isMobile) {
return (
<div className="page-content">
<Card bodyStyle={{ padding: 12 }}>
{activeConv ? chatWindow : conversationList}
</Card>
2026-08-11 12:19:20 +08:00
{createModal}
{membersModal}
2026-08-11 10:03:40 +08:00
</div>
)
}
return (
<div className="page-content">
<Card bodyStyle={{ padding: 0 }}>
<div className="messages-layout">
<div className="messages-list">{conversationList}</div>
<div className="messages-chat">{chatWindow}</div>
</div>
</Card>
2026-08-11 12:19:20 +08:00
{createModal}
{membersModal}
2026-08-11 10:03:40 +08:00
</div>
)
}
export default Messages