Files
dms/client/src/pages/Messages.jsx
T
mei 7ebc4cc053 ```
feat(DeviceLoan): 添加设备搜索功能并优化借用流程

- 新增设备关键词搜索状态和输入框,支持按设备编码/名称搜索
- 优化借用设备选项过滤逻辑,排除已报废和借用中的设备
- 在打开借用弹窗时清空设备搜索关键词
- 更新借用设备选择下拉框提示文案

feat(DeviceManagement): 添加借用中设备状态

- 在设备状态列表中添加"借用中"状态选项
- 为"借用中"状态配置蓝色标签样式

feat(Messages): 实现群组邀请成员功能

- 添加邀请成员弹窗组件和相关状态管理
- 实现群主邀请成员加入群聊的功能接口调用
- 过滤可邀请用户列表,排除已在群内的成员
- 添加邀请成员按钮,仅群主可见
```
2026-08-15 09:42:45 +08:00

648 lines
25 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: (
<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>
)}
{activeConv.type === 'group' && activeConv.creator_id === user?.id && (
<Button size="small" type="text" icon={<SettingOutlined />} onClick={() => setMembersVisible(true)} style={{ marginLeft: 'auto' }}>
成员管理
</Button>
)}
</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
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 = (
<Tooltip title={`未读:${names}${extra}`}>
<span className="chat-msg-status chat-msg-unread">
<ClockCircleOutlined /> 未读({rs.read_count}/{rs.total_others}人已读)
</span>
</Tooltip>
)
} else {
readEl = allRead
? <span className="chat-msg-status chat-msg-read"><CheckCircleFilled /> 已读</span>
: <span className="chat-msg-status chat-msg-unread"><ClockCircleOutlined /> 未读</span>
}
}
// 按消息类型渲染内容:图片/视频/文件/文本
let body
if (m.msg_type === 'image' && m.file_url) {
body = (
<div className="chat-bubble chat-bubble-media">
<Image src={fileSrc(m.file_url)} style={{ maxWidth: 240, maxHeight: 240, borderRadius: 6, display: 'block' }} />
</div>
)
} else if (m.msg_type === 'video' && m.file_url) {
body = (
<div className="chat-bubble chat-bubble-media">
<video src={fileSrc(m.file_url)} controls preload="metadata" style={{ maxWidth: 320, maxHeight: 240, borderRadius: 6, display: 'block' }} />
</div>
)
} else if (m.msg_type === 'file' && m.file_url) {
body = (
<div className="chat-bubble chat-bubble-media chat-file-card">
<FileOutlined style={{ fontSize: 28, color: '#7c3aed' }} />
<div className="chat-file-info">
<div className="chat-file-name">{m.file_name || m.content}</div>
<div className="chat-file-size">{formatSize(m.file_size)}</div>
</div>
<a href={fileSrc(m.file_url)} download={m.file_name} className="chat-file-download" title="下载">
<DownloadOutlined />
</a>
</div>
)
} else {
body = <div className={`chat-bubble ${mine ? 'chat-bubble-mine' : ''}`}>{m.content}</div>
}
return (
<div key={m.id} className={`chat-msg ${mine ? 'chat-msg-mine' : ''}`}>
{!mine && <div className="chat-msg-sender">{m.sender_name}</div>}
{body}
<div className="chat-msg-time">
{m.created_at?.slice(5, 16)}
{readEl}
</div>
</div>
)
})}
<div ref={bottomRef} />
</div>
<div className="chat-input">
<div className="chat-toolbar">
<Popover
trigger="click"
placement="topLeft"
content={
<div className="emoji-panel">
{EMOJIS.map(e => (
<span key={e} className="emoji-item" onClick={() => setInputValue(v => v + e)}>{e}</span>
))}
</div>
}
>
<Button type="text" size="small" icon={<SmileOutlined />} title="表情" />
</Popover>
<Button type="text" size="small" icon={<PictureOutlined />} title="发送图片" onClick={() => imgInputRef.current?.click()} />
<Button type="text" size="small" icon={<VideoCameraOutlined />} title="发送视频" onClick={() => videoInputRef.current?.click()} />
<Button type="text" size="small" icon={<PaperClipOutlined />} title="发送文件" onClick={() => fileInputRef.current?.click()} />
</div>
<input ref={imgInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { handlePickMedia('image', e.target.files?.[0]); e.target.value = '' }} />
<input ref={videoInputRef} type="file" accept="video/*" style={{ display: 'none' }}
onChange={(e) => { handlePickMedia('video', e.target.files?.[0]); e.target.value = '' }} />
<input ref={fileInputRef} type="file" style={{ display: 'none' }}
onChange={(e) => { handlePickMedia('file', e.target.files?.[0]); e.target.value = '' }} />
<div className="chat-input-row">
<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 || uploading} onClick={handleSend}>发送</Button>
</div>
</div>
</div>
) : (
<div className="chat-placeholder">
<Empty description="选择一个会话开始聊天" />
</div>
)
// ============ 发起对话 / 建群弹窗 ============
// 注意:这里必须用普通 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
destroyInactiveTabPane
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}
>
{activeConv?.creator_id === user?.id && (
<Button
type="primary"
icon={<UserAddOutlined />}
block
style={{ marginBottom: 12 }}
onClick={() => { setInviteIds([]); setInviteVisible(true) }}
>
邀请成员(拉人入群)
</Button>
)}
<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>
)
// ============ 邀请成员弹窗(仅群主,拉人) ============
const inviteModal = (
<Modal
title="邀请成员加入群聊"
open={inviteVisible}
onCancel={() => setInviteVisible(false)}
onOk={handleInviteMembers}
okText="邀请"
cancelText="取消"
width={420}
>
<Select
mode="multiple"
placeholder="选择要拉入群聊的成员"
value={inviteIds}
onChange={setInviteIds}
options={inviteOptions}
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
/>
<p style={{ color: '#999', fontSize: 12, marginTop: 8 }}>被邀请成员将收到系统通知,并立即加入群聊。</p>
</Modal>
)
// ============ 布局 ============
if (isMobile) {
return (
<div className="page-content">
<Card bodyStyle={{ padding: 12 }}>
{activeConv ? chatWindow : conversationList}
</Card>
{createModal}
{membersModal}
{inviteModal}
</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>
{createModal}
{membersModal}
{inviteModal}
</div>
)
}
export default Messages