diff --git a/build-apk.ps1 b/build-apk.ps1
deleted file mode 100644
index 3e560e4..0000000
--- a/build-apk.ps1
+++ /dev/null
@@ -1,57 +0,0 @@
-# Manual APK build pipeline: aapt2 -> javac -> d8 -> jar -> zipalign -> apksigner
-$root = 'c:\Users\shz73\Documents\Qoder\2026-08-09\chat-1\device-management-system'
-$jdk = "$root\android-tools\jdk\jdk-17.0.20+8"
-$sdk = "$root\android-sdk"
-$bt = "$sdk\build-tools\33.0.2"
-$androidJar = "$sdk\platforms\android-33\android.jar"
-$proj = "$root\android-app"
-$build = "$proj\build"
-
-$env:JAVA_HOME = $jdk
-$env:Path = "$jdk\bin;" + $env:Path
-
-if (Test-Path $build) { Remove-Item $build -Recurse -Force }
-New-Item -ItemType Directory -Force -Path "$build\gen" | Out-Null
-New-Item -ItemType Directory -Force -Path "$build\classes" | Out-Null
-New-Item -ItemType Directory -Force -Path "$build\dex" | Out-Null
-
-Write-Host '=== 1. aapt2 compile ==='
-& "$bt\aapt2.exe" compile --dir "$proj\res" -o "$build\res.zip"
-if ($LASTEXITCODE -ne 0) { Write-Host 'AAPT2 COMPILE FAILED'; exit 1 }
-
-Write-Host '=== 2. aapt2 link ==='
-& "$bt\aapt2.exe" link -o "$build\app.unsigned.apk" -I $androidJar --manifest "$proj\AndroidManifest.xml" --java "$build\gen" --auto-add-overlay "$build\res.zip"
-if ($LASTEXITCODE -ne 0) { Write-Host 'AAPT2 LINK FAILED'; exit 1 }
-
-Write-Host '=== 3. javac ==='
-$srcs = @(Get-ChildItem -Recurse -Filter *.java "$build\gen" | ForEach-Object { $_.FullName }) + @(Get-ChildItem -Recurse -Filter *.java "$proj\src" | ForEach-Object { $_.FullName })
-& "$jdk\bin\javac.exe" -classpath $androidJar -d "$build\classes" @srcs
-if ($LASTEXITCODE -ne 0) { Write-Host 'JAVAC FAILED'; exit 1 }
-
-Write-Host '=== 4. d8 ==='
-$classes = Get-ChildItem -Recurse -Filter *.class "$build\classes" | ForEach-Object { $_.FullName }
-& "$jdk\bin\java.exe" -cp "$bt\lib\d8.jar" com.android.tools.r8.D8 --output "$build\dex" --lib $androidJar @classes
-if ($LASTEXITCODE -ne 0) { Write-Host 'D8 FAILED'; exit 1 }
-
-Write-Host '=== 5. add classes.dex ==='
-Push-Location "$build\dex"
-& "$jdk\bin\jar.exe" uf "$build\app.unsigned.apk" classes.dex
-Pop-Location
-if ($LASTEXITCODE -ne 0) { Write-Host 'JAR ADD FAILED'; exit 1 }
-
-Write-Host '=== 6. zipalign ==='
-& "$bt\zipalign.exe" -f 4 "$build\app.unsigned.apk" "$build\app-aligned.apk"
-if ($LASTEXITCODE -ne 0) { Write-Host 'ZIPALIGN FAILED'; exit 1 }
-
-Write-Host '=== 7. sign ==='
-$ks = "$build\debug.keystore"
-if (-not (Test-Path $ks)) {
- & "$jdk\bin\keytool.exe" -genkeypair -keystore $ks -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname 'CN=Android Debug,O=Android'
-}
-& "$jdk\bin\java.exe" -jar "$bt\lib\apksigner.jar" sign --ks $ks --ks-pass pass:android --key-pass pass:android --ks-key-alias androiddebugkey --out "$build\yingshi-device-manager.apk" "$build\app-aligned.apk"
-if ($LASTEXITCODE -ne 0) { Write-Host 'SIGN FAILED'; exit 1 }
-
-Write-Host '=== 8. verify ==='
-& "$jdk\bin\java.exe" -jar "$bt\lib\apksigner.jar" verify --verbose "$build\yingshi-device-manager.apk"
-Get-Item "$build\yingshi-device-manager.apk" | Select-Object Name, Length
-Write-Host 'APK-BUILD-DONE'
diff --git a/build-exe2.ps1 b/build-exe2.ps1
deleted file mode 100644
index a10bb1f..0000000
--- a/build-exe2.ps1
+++ /dev/null
@@ -1,13 +0,0 @@
-# Build EXE with signing disabled
-$env:Path = 'C:\Program Files\nodejs;' + $env:Path
-$env:ELECTRON_MIRROR = 'https://npmmirror.com/mirrors/electron/'
-$env:ELECTRON_BUILDER_BINARIES_MIRROR = 'https://npmmirror.com/mirrors/electron-builder-binaries/'
-$env:CSC_IDENTITY_AUTO_DISCOVERY = 'false'
-Set-Location 'c:\Users\shz73\Documents\Qoder\2026-08-09\chat-1\device-management-system'
-
-Write-Host '=== electron-builder --win ==='
-& 'C:\Program Files\nodejs\node.exe' node_modules\electron-builder\out\cli\cli.js --win
-if ($LASTEXITCODE -ne 0) { Write-Host 'electron-builder failed'; exit 1 }
-
-Write-Host '=== output files ==='
-Get-ChildItem dist-electron -File | Select-Object Name, Length
diff --git a/build-hap.ps1 b/build-hap.ps1
deleted file mode 100644
index 120c148..0000000
--- a/build-hap.ps1
+++ /dev/null
@@ -1,105 +0,0 @@
-# HarmonyOS HAP build pipeline (OpenHarmony SDK, no DevEco required)
-$root = 'c:\Users\shz73\Documents\Qoder\2026-08-09\chat-1\device-management-system'
-$env:Path = 'C:\Program Files\nodejs;' + $env:Path
-$ets = "$root\harmonyos-tools\sdk\windows\ets\ets\build-tools\ets-loader"
-$proj = "$root\harmony-app\entry\src\main"
-$build = "$root\harmony-app\build"
-if (Test-Path "$build\cache") { Remove-Item -Recurse -Force "$build\cache" }
-New-Item -ItemType Directory -Force -Path "$build", "$build\intermediates", "$build\cache" | Out-Null
-
-Write-Host '=== webpack ets compile ==='
-$env:compileMode = 'esmodule'
-$env:buildMode = 'debug'
-$env:compilerType = 'ark'
-$env:cachePath = "$build\cache"
-$env:aceBuildJson = "$root\harmony-app\build.json"
-$env:aceModuleJsonPath = "$proj\module.json"
-$env:aceProfilePath = "$proj\resources\base\profile"
-$env:aceModuleRoot = "$proj\ets"
-$env:aceModuleBuild = "$build\intermediates"
-Push-Location $ets
-& 'C:\Program Files\nodejs\node.exe' "$ets\node_modules\webpack\bin\webpack.js" --config "$ets\webpack.debug.config.js" --env buildMode=debug --env compilerType=ark --env compileMode=esmodule --env aceModuleRoot=$proj\ets --env aceModuleBuild="$build\intermediates" --env aceModuleJsonPath="$proj\module.json" --env aceBuildJson="$root\harmony-app\build.json" --env aceProfilePath="$proj\resources\base\profile" --env cachePath="$build\cache"
-Write-Host "webpack exit: $LASTEXITCODE"
-Pop-Location
-if ($LASTEXITCODE -ne 0) { Write-Host 'BUILD FAILED at webpack step'; exit 1 }
-
-$tc = "$root\harmonyos-tools\sdk\windows\toolchains\toolchains"
-$lib = "$tc\lib"
-$jdk = "$root\android-tools\jdk\jdk-17.0.20+8\bin"
-$bundle = 'com.yingshi.devicemgr'
-$finalHap = "$root\harmony-app\影视星河设备管理系统.hap"
-
-Write-Host '=== restool compile resources ==='
-if (Test-Path "$build\res") { Remove-Item -Recurse -Force "$build\res" }
-New-Item -ItemType Directory -Force -Path "$build\res" | Out-Null
-& "$tc\restool.exe" -i $proj -o "$build\res" -p entry -r "$build\res\ResourceTable.txt"
-Write-Host "restool exit: $LASTEXITCODE"
-if ($LASTEXITCODE -ne 0) { exit 1 }
-
-Write-Host '=== assemble hap stage ==='
-$stage = "$build\hap-stage"
-if (Test-Path $stage) { Remove-Item -Recurse -Force $stage }
-New-Item -ItemType Directory -Force -Path $stage | Out-Null
-Copy-Item "$build\intermediates\modules.abc" "$stage\modules.abc"
-$mj = Get-Content "$build\res\module.json" -Raw | ConvertFrom-Json
-$mj.PSObject.Properties.Remove('app')
-[System.IO.File]::WriteAllText("$stage\module.json", ($mj | ConvertTo-Json -Depth 32), [System.Text.UTF8Encoding]::new($false))
-Copy-Item "$build\res\resources.index" "$stage\resources.index"
-Copy-Item "$build\res\resources" "$stage\resources" -Recurse
-
-$packInfo = @{
- summary = @{
- app = @{
- bundleName = $bundle
- debug = $true
- icon = '$media:icon'
- label = '$string:app_name'
- version = @{ code = 1000000; name = '1.0.0' }
- minAPIVersion = 9
- targetAPIVersion = 12
- }
- modules = @(@{
- name = 'entry'
- type = 'entry'
- deviceType = @('phone', 'tablet')
- abilities = @(@{ name = 'EntryAbility'; label = '$string:EntryAbility_label'; visible = $true })
- distro = @{ moduleType = 'entry'; installationFree = $false; deliveryWithInstall = $true; moduleName = 'entry' }
- })
- }
- packages = @(@{ deviceType = @('phone', 'tablet'); moduleType = 'entry'; deliveryWithInstall = $true; name = 'entry' })
-}
-[System.IO.File]::WriteAllText("$stage\pack.info", ($packInfo | ConvertTo-Json -Depth 32), [System.Text.UTF8Encoding]::new($false))
-
-Write-Host '=== zip unsigned hap ==='
-Push-Location $stage
-& "$jdk\jar.exe" -cfM "$build\unsigned.hap" modules.abc module.json pack.info resources.index resources
-Write-Host "jar exit: $LASTEXITCODE"
-Pop-Location
-if ($LASTEXITCODE -ne 0) { exit 1 }
-
-Write-Host '=== prepare debug profile ==='
-$prof = Get-Content "$lib\UnsgnedDebugProfileTemplate.json" -Raw | ConvertFrom-Json
-$prof.uuid = [guid]::NewGuid().ToString()
-$prof.validity.'not-after' = 1893456000
-$prof.'bundle-info'.'bundle-name' = $bundle
-[System.IO.File]::WriteAllText("$build\unsigned-profile.json", ($prof | ConvertTo-Json -Depth 32), [System.Text.UTF8Encoding]::new($false))
-& "$jdk\keytool.exe" -exportcert -rfc -keystore "$lib\OpenHarmony.p12" -storetype PKCS12 -storepass 123456 -alias 'openharmony application ca' -file "$build\app-ca.cer"
-& "$jdk\keytool.exe" -exportcert -rfc -keystore "$lib\OpenHarmony.p12" -storetype PKCS12 -storepass 123456 -alias 'openharmony application root ca' -file "$build\app-root.cer"
-$chain = $prof.'bundle-info'.'development-certificate' + (Get-Content "$build\app-ca.cer" -Raw) + (Get-Content "$build\app-root.cer" -Raw)
-[System.IO.File]::WriteAllText("$build\app-cert.pem", $chain, [System.Text.UTF8Encoding]::new($false))
-
-Write-Host '=== sign profile ==='
-& "$jdk\java.exe" -jar "$lib\hap-sign-tool.jar" sign-profile -mode localSign -keyAlias 'openharmony application profile debug' -keyPwd 123456 -profileCertFile "$lib\OpenHarmonyProfileDebug.pem" -inFile "$build\unsigned-profile.json" -signAlg SHA256withECDSA -keystoreFile "$lib\OpenHarmony.p12" -keystorePwd 123456 -outFile "$build\debug-profile.p7b"
-Write-Host "sign-profile exit: $LASTEXITCODE"
-if ($LASTEXITCODE -ne 0) { exit 1 }
-
-Write-Host '=== sign hap ==='
-& "$jdk\java.exe" -jar "$lib\hap-sign-tool.jar" sign-app -mode localSign -keyAlias 'openharmony application release' -keyPwd 123456 -appCertFile "$build\app-cert.pem" -profileFile "$build\debug-profile.p7b" -inFile "$build\unsigned.hap" -signAlg SHA256withECDSA -keystoreFile "$lib\OpenHarmony.p12" -keystorePwd 123456 -outFile $finalHap -compatibleVersion 9 -inForm zip
-Write-Host "sign-app exit: $LASTEXITCODE"
-if ($LASTEXITCODE -ne 0) { exit 1 }
-
-Write-Host '=== verify ==='
-& "$jdk\java.exe" -jar "$lib\hap-sign-tool.jar" verify-app -inFile $finalHap -outCertChain "$build\verify-certchain.cer" -outProfile "$build\verify-profile.p7b"
-Write-Host "verify exit: $LASTEXITCODE"
-Write-Host 'DONE'
-Get-Item $finalHap | Select-Object FullName, Length
diff --git a/client/src/App.jsx b/client/src/App.jsx
index eff22ff..19bc46a 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -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: , label: '人员管理' },
...(user?.role === 'admin' ? [{ key: 'accounts', icon: , label: '账户管理' }] : []),
{ key: 'notifications', icon: , label: '通知管理' },
- { key: 'download', icon: , label: '软件下载' }
+ { key: 'about', icon: , label: '软件简介' },
+ { key: 'guide', icon: , label: '使用说明' }
]
// 移动端底部导航(5个核心入口)
@@ -149,14 +150,13 @@ function App() {
case 'accounts': return user?.role === 'admin' ? :
case 'notifications': return
case 'messages': return
- case 'download': return
default: return
}
}
const getPageTitle = () => {
const titles = {
- dashboard: '系统首页', about: '软件简介', guide: '使用说明',download: '软件下载',
+ dashboard: '系统首页', about: '软件简介', guide: '使用说明',
devices: '设备管理', personnel: '人员管理', accounts: '账户管理',
notifications: '通知管理', messages: '消息对话'
}
@@ -304,6 +304,7 @@ function App() {
{pwdModal}
+
)
}
@@ -365,6 +366,7 @@ function App() {
{pwdModal}
+
)
}
diff --git a/client/src/components/PushService.jsx b/client/src/components/PushService.jsx
new file mode 100644
index 0000000..1a9b011
--- /dev/null
+++ b/client/src/components/PushService.jsx
@@ -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
diff --git a/client/src/pages/Messages.jsx b/client/src/pages/Messages.jsx
index ad51a1a..0b62d8b 100644
--- a/client/src/pages/Messages.jsx
+++ b/client/src/pages/Messages.jsx
@@ -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 && }
{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 = (
+
+
+ 未读({rs.read_count}/{rs.total_others}人已读)
+
+
+ )
+ } else {
+ readEl = allRead
+ ? 已读
+ : 未读
+ }
+ }
+ // 按消息类型渲染内容:图片/视频/文件/文本
+ let body
+ if (m.msg_type === 'image' && m.file_url) {
+ body = (
+
+
+
+ )
+ } else if (m.msg_type === 'video' && m.file_url) {
+ body = (
+
+
+
+ )
+ } else if (m.msg_type === 'file' && m.file_url) {
+ body = (
+
+
+
+
{m.file_name || m.content}
+
{formatSize(m.file_size)}
+
+
+
+
+
+ )
+ } else {
+ body = {m.content}
+ }
return (
{!mine &&
{m.sender_name}
}
-
{m.content}
-
{m.created_at?.slice(5, 16)}
+ {body}
+
+ {m.created_at?.slice(5, 16)}
+ {readEl}
+
)
})}
+ { handlePickMedia('image', e.target.files?.[0]); e.target.value = '' }} />
+ { handlePickMedia('video', e.target.files?.[0]); e.target.value = '' }} />
+ { handlePickMedia('file', e.target.files?.[0]); e.target.value = '' }} />
+
+
) : (
@@ -313,6 +460,7 @@ function Messages({ user }) {
c.name);
+ if (!msgCols.includes('msg_type')) {
+ db.exec("ALTER TABLE messages ADD COLUMN msg_type TEXT DEFAULT 'text'");
+ }
+ if (!msgCols.includes('file_id')) {
+ db.exec("ALTER TABLE messages ADD COLUMN file_id TEXT DEFAULT ''");
+ }
+
+ // 推送事件表(聊天消息/设备报修/维修完成等,客户端轮询拉取)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS push_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ title TEXT NOT NULL,
+ body TEXT DEFAULT '',
+ created_at TEXT DEFAULT (datetime('now', 'localtime'))
+ )
+ `);
+ db.exec('CREATE INDEX IF NOT EXISTS idx_push_events_user ON push_events (user_id, id)');
+
// 插入示例数据
const deviceCount = db.prepare('SELECT COUNT(*) as count FROM devices').get();
if (deviceCount.count === 0) {
diff --git a/package-lock.json b/package-lock.json
index 4a8ea58..404b26c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "device-management-system",
- "version": "1.0.0",
+ "version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "device-management-system",
- "version": "1.0.0",
+ "version": "2.2.0",
"dependencies": {
"better-sqlite3": "^12.2.0",
"cors": "^2.8.5",
@@ -3306,9 +3306,9 @@
"license": "MIT"
},
"node_modules/multer": {
- "version": "1.4.5-lts.2",
- "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
- "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
+ "version": "1.4.5-lts.1",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz",
+ "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"license": "MIT",
"dependencies": {
diff --git a/server.js b/server.js
index d6faacd..b86ed38 100644
--- a/server.js
+++ b/server.js
@@ -1,12 +1,37 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
+const fs = require('fs');
+const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const db = require('./database');
const app = express();
const PORT = process.env.PORT || 3001;
+// 聊天附件存储目录
+const uploadsDir = path.join(__dirname, 'uploads');
+if (!fs.existsSync(uploadsDir)) {
+ fs.mkdirSync(uploadsDir, { recursive: true });
+}
+
+// multer 磁盘存储:文件名用 uuid + 安全扩展名,单文件上限 100MB
+const MIME_EXT = {
+ 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', 'image/bmp': '.bmp',
+ 'video/mp4': '.mp4', 'video/webm': '.webm', 'video/quicktime': '.mov', 'video/x-msvideo': '.avi', 'video/x-matroska': '.mkv'
+};
+const upload = multer({
+ storage: multer.diskStorage({
+ destination: (req, file, cb) => cb(null, uploadsDir),
+ filename: (req, file, cb) => {
+ const rawExt = path.extname(file.originalname || '').toLowerCase();
+ const ext = MIME_EXT[file.mimetype] || (/^\.[a-z0-9]{1,8}$/.test(rawExt) ? rawExt : '.bin');
+ cb(null, uuidv4() + ext);
+ }
+ }),
+ limits: { fileSize: 100 * 1024 * 1024 }
+});
+
// 内存会话存储
const sessions = new Map();
// 会话最近活跃时间(token -> 时间戳),用于判断在线状态
@@ -50,7 +75,8 @@ function authMiddleware(req, res, next) {
if (publicPaths.some(path => req.path.startsWith(path))) {
return next();
}
- const token = req.headers.authorization?.replace('Bearer ', '');
+ // 优先取请求头 token;img/video/下载链接无法携带 Authorization 头,允许查询参数回退
+ const token = req.headers.authorization?.replace('Bearer ', '') || req.query.token;
if (!token || !sessions.has(token)) {
return res.status(401).json({ success: false, message: '未登录或登录已过期' });
}
@@ -87,6 +113,52 @@ function logDeviceAction(deviceId, deviceName, action, detail, operator) {
`).run(uuidv4(), deviceId, deviceName || '', action, detail || '', operator || '');
}
+// ==================== 推送事件(客户端轮询拉取的原生推送源) ====================
+
+// 定期清理 7 天前的推送事件
+function cleanPushEvents() {
+ db.prepare("DELETE FROM push_events WHERE created_at < datetime('now','localtime','-7 day')").run();
+}
+cleanPushEvents();
+setInterval(cleanPushEvents, 6 * 60 * 60 * 1000);
+
+// 向指定用户写入一条推送事件
+function pushTo(userId, kind, title, body) {
+ if (!userId) return;
+ db.prepare('INSERT INTO push_events (user_id, kind, title, body) VALUES (?, ?, ?, ?)')
+ .run(userId, kind, String(title || ''), String(body || '').slice(0, 200));
+}
+
+// 向多个用户推送(自动去重、跳过发送者)
+function pushToMany(userIds, kind, title, body, excludeId) {
+ const seen = new Set();
+ for (const uid of userIds) {
+ if (!uid || uid === excludeId || seen.has(uid)) continue;
+ seen.add(uid);
+ pushTo(uid, kind, title, body);
+ }
+}
+
+// 向指定角色的全部用户推送
+function pushToRole(role, kind, title, body, excludeId) {
+ const rows = db.prepare('SELECT id FROM users WHERE role = ?').all(role);
+ pushToMany(rows.map(r => r.id), kind, title, body, excludeId);
+}
+
+// 向全部用户推送
+function pushToAll(kind, title, body, excludeId) {
+ const rows = db.prepare('SELECT id FROM users').all();
+ pushToMany(rows.map(r => r.id), kind, title, body, excludeId);
+}
+
+// 客户端拉取自己的新推送事件(after 游标为上次拉到的最大 id)
+app.get('/api/push/events', (req, res) => {
+ const after = parseInt(req.query.after) || 0;
+ const rows = db.prepare('SELECT * FROM push_events WHERE user_id = ? AND id > ? ORDER BY id ASC LIMIT 50')
+ .all(req.user.id, after);
+ res.json({ success: true, data: rows });
+});
+
// 写入维修记录
function addMaintenanceRecord(deviceId, deviceName, type, remark, operator) {
db.prepare(`
@@ -236,6 +308,8 @@ app.patch('/api/devices/:id/repair', (req, res) => {
`).run(req.params.id);
addMaintenanceRecord(req.params.id, device.name, '报修', remark || '', getOperator(req));
logDeviceAction(req.params.id, device.name, '报修', remark || '设备已标记为维修中', getOperator(req));
+ // 推送给全部管理员处理
+ pushToRole('admin', 'repair_request', '设备报修', `${getOperator(req)} 报修:${device.name}${remark ? '(' + remark + ')' : ''}`, req.user.id);
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: '报修成功,设备已标记为维修中' });
});
@@ -262,6 +336,8 @@ app.patch('/api/devices/:id/status', requireAdmin, (req, res) => {
}
if (device.status === '维修中' && status === '正常') {
addMaintenanceRecord(req.params.id, device.name, '维修完成', '维修完成,状态恢复正常', getOperator(req));
+ // 维修完成推送给全部用户
+ pushToAll('repair_done', '设备维修完成', `设备「${device.name}」已维修好,状态恢复正常`, req.user.id);
}
logDeviceAction(req.params.id, device.name, '修改状态', `${device.status} → ${status}`, getOperator(req));
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
@@ -705,6 +781,65 @@ function getUserDisplayName(userId) {
return u ? (u.display_name || u.username) : '';
}
+// 媒体消息在会话列表的预览文案
+function messagePreview(m) {
+ if (m.msg_type === 'image') return '[图片]';
+ if (m.msg_type === 'video') return '[视频]';
+ if (m.msg_type === 'file') return '[文件] ' + (m.content || '');
+ return m.content;
+}
+
+// 上传聊天附件(图片/视频/文件),返回 fileId 供发送消息时引用
+app.post('/api/chat/upload', upload.single('file'), (req, res) => {
+ if (!req.file) {
+ return res.status(400).json({ success: false, message: '未接收到文件' });
+ }
+ const id = uuidv4();
+ db.prepare('INSERT INTO chat_files (id, uploader_id, file_name, stored_name, file_size, file_mime) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(id, req.user.id, req.file.originalname, req.file.filename, req.file.size, req.file.mimetype || '');
+ res.json({
+ success: true,
+ data: { fileId: id, fileName: req.file.originalname, fileSize: req.file.size, fileMime: req.file.mimetype || '' }
+ });
+});
+
+// multer 错误处理(超大文件等)
+app.use('/api/chat/upload', (err, req, res, next) => {
+ if (err) {
+ const msg = err.code === 'LIMIT_FILE_SIZE' ? '文件超过 100MB 限制' : '上传失败';
+ return res.status(400).json({ success: false, message: msg });
+ }
+ next();
+});
+
+// 下载/预览聊天附件:仅上传者可访问,或所在会话的成员可访问
+app.get('/api/chat/files/:id', (req, res) => {
+ const file = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(req.params.id);
+ if (!file) {
+ return res.status(404).json({ success: false, message: '文件不存在' });
+ }
+ if (file.uploader_id !== req.user.id) {
+ const inConv = db.prepare(`
+ SELECT 1 FROM conversation_members m
+ WHERE m.user_id = ? AND EXISTS (
+ SELECT 1 FROM messages msg WHERE msg.file_id = ? AND msg.conversation_id = m.conversation_id
+ )
+ `).get(req.user.id, file.id);
+ if (!inConv) {
+ return res.status(403).json({ success: false, message: '无权访问该文件' });
+ }
+ }
+ const filePath = path.join(uploadsDir, file.stored_name);
+ if (!fs.existsSync(filePath)) {
+ return res.status(404).json({ success: false, message: '文件已丢失' });
+ }
+ const inline = (file.file_mime || '').startsWith('image/') || (file.file_mime || '').startsWith('video/');
+ const encoded = encodeURIComponent(file.file_name);
+ res.setHeader('Content-Type', file.file_mime || 'application/octet-stream');
+ res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encoded}`);
+ fs.createReadStream(filePath).pipe(res);
+});
+
// 全部用户简表(选择聊天对象/建群成员,含在线状态)
app.get('/api/chat/users', (req, res) => {
const rows = db.prepare('SELECT id, username, display_name, role FROM users ORDER BY created_at ASC').all();
@@ -737,7 +872,7 @@ app.get('/api/chat/conversations', (req, res) => {
creator_id: c.creator_id,
member_count: members.length,
members,
- last_message: last ? { content: last.content, created_at: last.created_at, sender_name: getUserDisplayName(last.sender_id) } : null,
+ last_message: last ? { content: messagePreview(last), created_at: last.created_at, sender_name: getUserDisplayName(last.sender_id) } : null,
unread
};
});
@@ -805,6 +940,12 @@ app.get('/api/chat/conversations/:id/messages', (req, res) => {
if (!conv || !isConversationMember(req.params.id, req.user.id)) {
return res.status(404).json({ success: false, message: '会话不存在' });
}
+ // 标记已读到会话最新一条(先于返回,保证本次返回的消息自带已读回执)
+ const latest = db.prepare('SELECT MAX(id) as maxId FROM messages WHERE conversation_id = ?').get(req.params.id);
+ if (latest && latest.maxId) {
+ db.prepare('UPDATE conversation_members SET last_read_message_id = MAX(last_read_message_id, ?) WHERE conversation_id = ? AND user_id = ?')
+ .run(latest.maxId, req.params.id, req.user.id);
+ }
const after = parseInt(req.query.after) || 0;
const limit = Math.min(parseInt(req.query.limit) || 100, 200);
let rows;
@@ -813,40 +954,102 @@ app.get('/api/chat/conversations/:id/messages', (req, res) => {
} else {
rows = db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT ?').all(req.params.id, limit).reverse();
}
+ const readMap = buildReadStatusMap(req.params.id, req.user.id);
+ const fileCache = {};
const senderCache = {};
const data = rows.map(m => {
if (!senderCache[m.sender_id]) senderCache[m.sender_id] = getUserDisplayName(m.sender_id);
- return { ...m, sender_name: senderCache[m.sender_id] };
+ const item = { ...m, sender_name: senderCache[m.sender_id], read_status: readMap[m.id] || null };
+ if (m.file_id) {
+ if (!fileCache[m.file_id]) fileCache[m.file_id] = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(m.file_id);
+ const f = fileCache[m.file_id];
+ if (f) {
+ item.file_name = f.file_name;
+ item.file_size = f.file_size;
+ item.file_mime = f.file_mime;
+ item.file_url = `/api/chat/files/${f.id}`;
+ }
+ }
+ return item;
});
- // 标记已读到最新一条
- const maxId = data.length ? data[data.length - 1].id : after;
- if (maxId > 0) {
- db.prepare('UPDATE conversation_members SET last_read_message_id = MAX(last_read_message_id, ?) WHERE conversation_id = ? AND user_id = ?')
- .run(maxId, req.params.id, req.user.id);
- }
res.json({ success: true, data });
});
-// 发送消息
-app.post('/api/chat/conversations/:id/messages', (req, res) => {
- const content = String(req.body.content || '').trim();
- if (!content) {
- return res.status(400).json({ success: false, message: '消息不能为空' });
- }
- if (content.length > 2000) {
- return res.status(400).json({ success: false, message: '消息过长(最多 2000 字)' });
+// 计算会话内各消息的已读回执:read_count=已读的其他成员数,total_others=其他成员总数,unread_users=未读成员名单
+function buildReadStatusMap(convId, meId) {
+ const members = db.prepare('SELECT user_id, last_read_message_id FROM conversation_members WHERE conversation_id = ?').all(convId);
+ const others = members.filter(m => m.user_id !== meId);
+ const msgs = db.prepare('SELECT id, sender_id FROM messages WHERE conversation_id = ?').all(convId);
+ const nameCache = {};
+ const map = {};
+ for (const m of msgs) {
+ const unreadUsers = others
+ .filter(o => (o.last_read_message_id || 0) < m.id)
+ .map(o => {
+ if (!nameCache[o.user_id]) nameCache[o.user_id] = getUserDisplayName(o.user_id);
+ return { id: o.user_id, name: nameCache[o.user_id] };
+ });
+ map[m.id] = {
+ read_count: others.length - unreadUsers.length,
+ total_others: others.length,
+ unread_users: unreadUsers
+ };
}
+ return map;
+}
+
+// 轻量已读回执轮询(不标记已读,仅返回各消息的已读进度)
+app.get('/api/chat/conversations/:id/read-status', (req, res) => {
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
if (!conv || !isConversationMember(req.params.id, req.user.id)) {
return res.status(404).json({ success: false, message: '会话不存在' });
}
- const result = db.prepare('INSERT INTO messages (conversation_id, sender_id, content) VALUES (?, ?, ?)')
- .run(req.params.id, req.user.id, content);
+ res.json({ success: true, data: buildReadStatusMap(req.params.id, req.user.id) });
+});
+
+// 发送消息:文本(content)或媒体(type=image/video/file + fileId,引用已上传附件)
+app.post('/api/chat/conversations/:id/messages', (req, res) => {
+ const msgType = ['text', 'image', 'video', 'file'].includes(req.body.type) ? req.body.type : 'text';
+ const content = String(req.body.content || '').trim();
+ const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
+ if (!conv || !isConversationMember(req.params.id, req.user.id)) {
+ return res.status(404).json({ success: false, message: '会话不存在' });
+ }
+ let fileId = '';
+ let finalContent = content;
+ if (msgType !== 'text') {
+ const file = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(req.body.fileId);
+ if (!file || file.uploader_id !== req.user.id) {
+ return res.status(400).json({ success: false, message: '附件不存在,请重新上传' });
+ }
+ fileId = file.id;
+ if (!finalContent) finalContent = file.file_name;
+ } else if (!content) {
+ return res.status(400).json({ success: false, message: '消息不能为空' });
+ }
+ if (finalContent.length > 2000) {
+ return res.status(400).json({ success: false, message: '消息过长(最多 2000 字)' });
+ }
+ const result = db.prepare('INSERT INTO messages (conversation_id, sender_id, content, msg_type, file_id) VALUES (?, ?, ?, ?, ?)')
+ .run(req.params.id, req.user.id, finalContent, msgType, fileId);
const msg = db.prepare('SELECT * FROM messages WHERE id = ?').get(result.lastInsertRowid);
// 自己发送的消息直接标记已读
db.prepare('UPDATE conversation_members SET last_read_message_id = ? WHERE conversation_id = ? AND user_id = ?')
.run(msg.id, req.params.id, req.user.id);
- res.json({ success: true, data: { ...msg, sender_name: req.user.display_name || req.user.username } });
+ const data = { ...msg, sender_name: req.user.display_name || req.user.username };
+ if (fileId) {
+ const f = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(fileId);
+ data.file_name = f.file_name;
+ data.file_size = f.file_size;
+ data.file_mime = f.file_mime;
+ data.file_url = `/api/chat/files/${f.id}`;
+ }
+ // 推送给会话其他成员(群聊标题=群名,单聊标题=发送者)
+ const memberRows = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id = ?').all(req.params.id);
+ const senderName = req.user.display_name || req.user.username;
+ const pushTitle = conv.type === 'group' ? (conv.name || '群聊消息') : senderName;
+ pushToMany(memberRows.map(m => m.user_id), 'chat', pushTitle, `${senderName}:${messagePreview(msg)}`, req.user.id);
+ res.json({ success: true, data });
});
// 删除/解散会话(群聊仅群主可解散,单聊任一方可删除)
@@ -861,6 +1064,19 @@ app.delete('/api/chat/conversations/:id', (req, res) => {
if (conv.type === 'group' && conv.creator_id !== req.user.id) {
return res.status(403).json({ success: false, message: '只有群主可以解散群组' });
}
+ // 清理会话内附件的磁盘文件与记录
+ const fileRows = db.prepare(`
+ SELECT f.id, f.stored_name FROM chat_files f
+ WHERE f.id IN (SELECT file_id FROM messages WHERE conversation_id = ? AND file_id != '')
+ AND NOT EXISTS (SELECT 1 FROM messages m2 WHERE m2.file_id = f.id AND m2.conversation_id != ?)
+ `).all(req.params.id, req.params.id);
+ for (const f of fileRows) {
+ const p = path.join(uploadsDir, f.stored_name);
+ if (fs.existsSync(p)) fs.unlinkSync(p);
+ }
+ if (fileRows.length) {
+ db.prepare(`DELETE FROM chat_files WHERE id IN (SELECT file_id FROM messages WHERE conversation_id = ? AND file_id != '')`).run(req.params.id);
+ }
db.prepare('DELETE FROM messages WHERE conversation_id = ?').run(req.params.id);
db.prepare('DELETE FROM conversation_members WHERE conversation_id = ?').run(req.params.id);
db.prepare('DELETE FROM conversations WHERE id = ?').run(req.params.id);