This commit is contained in:
mei
2026-08-11 21:30:05 +08:00
parent e2fbaf4a32
commit 8ee98719ef
9 changed files with 518 additions and 219 deletions
+6 -4
View File
@@ -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>
)
}
+73
View File
@@ -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
View File
@@ -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',