update
This commit is contained in:
+6
-4
@@ -26,7 +26,7 @@ import AccountManagement from './pages/AccountManagement'
|
||||
import Messages from './pages/Messages'
|
||||
import About from './pages/About'
|
||||
import Guide from './pages/Guide'
|
||||
import Download from './pages/Download'
|
||||
import PushService from './components/PushService'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
|
||||
@@ -127,7 +127,8 @@ function App() {
|
||||
{ key: 'personnel', icon: <TeamOutlined />, label: '人员管理' },
|
||||
...(user?.role === 'admin' ? [{ key: 'accounts', icon: <UserSwitchOutlined />, label: '账户管理' }] : []),
|
||||
{ key: 'notifications', icon: <BellOutlined />, label: '通知管理' },
|
||||
{ key: 'download', icon: <InfoCircleOutlined />, label: '软件下载' }
|
||||
{ key: 'about', icon: <InfoCircleOutlined />, label: '软件简介' },
|
||||
{ key: 'guide', icon: <BookOutlined />, label: '使用说明' }
|
||||
]
|
||||
|
||||
// 移动端底部导航(5个核心入口)
|
||||
@@ -149,14 +150,13 @@ function App() {
|
||||
case 'accounts': return user?.role === 'admin' ? <AccountManagement user={user} /> : <Dashboard onNavigate={setCurrentPage} />
|
||||
case 'notifications': return <NotificationManagement user={user} />
|
||||
case 'messages': return <Messages user={user} />
|
||||
case 'download': return <Download />
|
||||
default: return <Dashboard onNavigate={setCurrentPage} />
|
||||
}
|
||||
}
|
||||
|
||||
const getPageTitle = () => {
|
||||
const titles = {
|
||||
dashboard: '系统首页', about: '软件简介', guide: '使用说明',download: '软件下载',
|
||||
dashboard: '系统首页', about: '软件简介', guide: '使用说明',
|
||||
devices: '设备管理', personnel: '人员管理', accounts: '账户管理',
|
||||
notifications: '通知管理', messages: '消息对话'
|
||||
}
|
||||
@@ -304,6 +304,7 @@ function App() {
|
||||
</nav>
|
||||
|
||||
{pwdModal}
|
||||
<PushService user={user} />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -365,6 +366,7 @@ function App() {
|
||||
</Layout>
|
||||
|
||||
{pwdModal}
|
||||
<PushService user={user} />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { notification } from 'antd'
|
||||
import axios from 'axios'
|
||||
|
||||
// 分发到原生推送通道:
|
||||
// 1. 安卓 APK 的 JS 桥(window.AndroidNotify.push)
|
||||
// 2. 鸿蒙 HAP 的 javaScriptProxy 桥(window.HmosNotify.push)
|
||||
// 3. HTML5 Notification(Electron 桌面端与浏览器直接支持)
|
||||
function nativeNotify(title, body) {
|
||||
try {
|
||||
if (window.AndroidNotify && typeof window.AndroidNotify.push === 'function') {
|
||||
window.AndroidNotify.push(title, body)
|
||||
return
|
||||
}
|
||||
if (window.HmosNotify && typeof window.HmosNotify.push === 'function') {
|
||||
window.HmosNotify.push(title, body)
|
||||
return
|
||||
}
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(title, { body })
|
||||
}
|
||||
} catch (e) { /* 原生通道失败时已有应用内通知兜底 */ }
|
||||
}
|
||||
|
||||
// 全局推送服务:登录后挂载,轮询后端推送事件(聊天消息/设备报修/维修完成)
|
||||
function PushService({ user }) {
|
||||
const cursorRef = useRef(0)
|
||||
const firstRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
// 游标按用户隔离存储,切换账号不串推送
|
||||
const key = 'push_cursor_' + user.id
|
||||
cursorRef.current = parseInt(localStorage.getItem(key) || '0')
|
||||
firstRef.current = true
|
||||
|
||||
// HTML5 通知权限(桌面 Electron/浏览器有效;移动端 WebView 会静默忽略)
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
try { Notification.requestPermission() } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/push/events', { params: { after: cursorRef.current } })
|
||||
if (!res.data.success || !res.data.data.length) return
|
||||
const events = res.data.data
|
||||
const isFirstSync = firstRef.current && cursorRef.current === 0
|
||||
firstRef.current = false
|
||||
for (const ev of events) {
|
||||
cursorRef.current = ev.id
|
||||
// 首次使用只同步游标不弹通知,避免历史事件刷屏
|
||||
if (isFirstSync) continue
|
||||
notification.open({
|
||||
message: ev.title,
|
||||
description: ev.body,
|
||||
placement: 'bottomRight',
|
||||
duration: 5
|
||||
})
|
||||
nativeNotify(ev.title, ev.body)
|
||||
}
|
||||
localStorage.setItem(key, String(cursorRef.current))
|
||||
} catch (e) { /* 静默 */ }
|
||||
}
|
||||
|
||||
poll()
|
||||
const timer = setInterval(poll, 8000)
|
||||
return () => clearInterval(timer)
|
||||
}, [user?.id])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default PushService
|
||||
+163
-15
@@ -1,10 +1,29 @@
|
||||
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'
|
||||
import { PlusOutlined, SendOutlined, TeamOutlined, UserOutlined, ArrowLeftOutlined, DeleteOutlined, SettingOutlined, UserDeleteOutlined } from '@ant-design/icons'
|
||||
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, 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)
|
||||
@@ -28,21 +47,28 @@ function Messages({ user }) {
|
||||
const [membersVisible, setMembersVisible] = useState(false)
|
||||
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([])
|
||||
}
|
||||
|
||||
@@ -76,13 +102,26 @@ function Messages({ user }) {
|
||||
} catch (err) { /* 静默 */ }
|
||||
}
|
||||
|
||||
// 会话列表 5 秒轮询;打开的会话 3 秒轮询消息
|
||||
// 拉取当前打开会话的已读回执,合并进消息列表
|
||||
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)
|
||||
return () => { clearInterval(convTimer); clearInterval(msgTimer) }
|
||||
const readTimer = setInterval(() => fetchReadStatus(activeConvRef.current), 4000)
|
||||
return () => { clearInterval(convTimer); clearInterval(msgTimer); clearInterval(readTimer) }
|
||||
}, [])
|
||||
|
||||
// 新消息自动滚动到底部
|
||||
@@ -108,6 +147,35 @@ function Messages({ user }) {
|
||||
}
|
||||
}
|
||||
|
||||
// 选择并发送图片/视频/文件:先上传拿 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 {
|
||||
@@ -270,26 +338,105 @@ function Messages({ user }) {
|
||||
{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>}
|
||||
<div className={`chat-bubble ${mine ? 'chat-bubble-mine' : ''}`}>{m.content}</div>
|
||||
<div className="chat-msg-time">{m.created_at?.slice(5, 16)}</div>
|
||||
{body}
|
||||
<div className="chat-msg-time">
|
||||
{m.created_at?.slice(5, 16)}
|
||||
{readEl}
|
||||
</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 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>
|
||||
) : (
|
||||
@@ -313,6 +460,7 @@ function Messages({ user }) {
|
||||
<Form.Item name="tab" noStyle>
|
||||
<Tabs
|
||||
centered
|
||||
destroyInactiveTabPane
|
||||
items={[
|
||||
{
|
||||
key: 'direct',
|
||||
|
||||
Reference in New Issue
Block a user