feat(DeviceLoan): 添加设备搜索功能并优化借用流程 - 新增设备关键词搜索状态和输入框,支持按设备编码/名称搜索 - 优化借用设备选项过滤逻辑,排除已报废和借用中的设备 - 在打开借用弹窗时清空设备搜索关键词 - 更新借用设备选择下拉框提示文案 feat(DeviceManagement): 添加借用中设备状态 - 在设备状态列表中添加"借用中"状态选项 - 为"借用中"状态配置蓝色标签样式 feat(Messages): 实现群组邀请成员功能 - 添加邀请成员弹窗组件和相关状态管理 - 实现群主邀请成员加入群聊的功能接口调用 - 过滤可邀请用户列表,排除已在群内的成员 - 添加邀请成员按钮,仅群主可见 ```
648 lines
25 KiB
React
648 lines
25 KiB
React
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
|