Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ebc4cc053 | ||
|
|
1271627ef9 | ||
|
|
8ee98719ef | ||
|
|
e2fbaf4a32 |
@@ -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'
|
||||
@@ -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
|
||||
-105
@@ -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
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "device-management-client",
|
||||
"private": true,
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+61
-8
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Layout, Menu, theme, Button, Space, Dropdown, message, Drawer, Avatar, Modal, Form, Input, Badge, Tooltip } from 'antd'
|
||||
import { Layout, Menu, theme, ConfigProvider, Button, Space, Dropdown, message, Drawer, Avatar, Modal, Form, Input, Badge, Tooltip } from 'antd'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
DesktopOutlined,
|
||||
@@ -13,20 +13,24 @@ import {
|
||||
DownOutlined,
|
||||
UserSwitchOutlined,
|
||||
KeyOutlined,
|
||||
MessageOutlined
|
||||
MessageOutlined,
|
||||
MoonOutlined,
|
||||
SunOutlined,
|
||||
SwapOutlined
|
||||
} from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import Login from './pages/Login'
|
||||
import Weather from './components/Weather'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import DeviceManagement from './pages/DeviceManagement'
|
||||
import DeviceLoan from './pages/DeviceLoan'
|
||||
import PersonnelManagement from './pages/PersonnelManagement'
|
||||
import NotificationManagement from './pages/NotificationManagement'
|
||||
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
|
||||
|
||||
@@ -41,8 +45,36 @@ function useIsMobile() {
|
||||
return isMobile
|
||||
}
|
||||
|
||||
// 深色模式 Hook:优先用户手动选择(localStorage),否则跟随系统 prefers-color-scheme
|
||||
function useThemeMode() {
|
||||
const [mode, setMode] = useState(() => localStorage.getItem('themeMode') || 'auto')
|
||||
const [sysDark, setSysDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = (e) => setSysDark(e.matches)
|
||||
if (mq.addEventListener) mq.addEventListener('change', onChange)
|
||||
else mq.addListener(onChange)
|
||||
return () => {
|
||||
if (mq.removeEventListener) mq.removeEventListener('change', onChange)
|
||||
else mq.removeListener(onChange)
|
||||
}
|
||||
}, [])
|
||||
const isDark = mode === 'dark' || (mode === 'auto' && sysDark)
|
||||
useEffect(() => {
|
||||
// 供 index.css 中 [data-theme='dark'] 变量覆盖使用
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light')
|
||||
}, [isDark])
|
||||
const toggle = () => {
|
||||
const next = isDark ? 'light' : 'dark'
|
||||
setMode(next)
|
||||
localStorage.setItem('themeMode', next)
|
||||
}
|
||||
return { isDark, toggle }
|
||||
}
|
||||
|
||||
function App() {
|
||||
const isMobile = useIsMobile()
|
||||
const { isDark, toggle: toggleTheme } = useThemeMode()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [currentPage, setCurrentPage] = useState('dashboard')
|
||||
@@ -124,10 +156,12 @@ function App() {
|
||||
{ key: 'dashboard', icon: <DashboardOutlined />, label: '系统首页' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'devices', icon: <DesktopOutlined />, label: '设备管理' },
|
||||
{ key: 'loans', icon: <SwapOutlined />, label: '设备借用' },
|
||||
{ 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个核心入口)
|
||||
@@ -145,19 +179,19 @@ function App() {
|
||||
case 'about': return <About />
|
||||
case 'guide': return <Guide />
|
||||
case 'devices': return <DeviceManagement user={user} />
|
||||
case 'loans': return <DeviceLoan user={user} />
|
||||
case 'personnel': return <PersonnelManagement user={user} />
|
||||
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: '软件下载',
|
||||
devices: '设备管理', personnel: '人员管理', accounts: '账户管理',
|
||||
dashboard: '系统首页', about: '软件简介', guide: '使用说明',
|
||||
devices: '设备管理', loans: '设备借用', personnel: '人员管理', accounts: '账户管理',
|
||||
notifications: '通知管理', messages: '消息对话'
|
||||
}
|
||||
return titles[currentPage] || '系统首页'
|
||||
@@ -240,8 +274,15 @@ function App() {
|
||||
return <div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>加载中...</div>
|
||||
}
|
||||
|
||||
// antd 主题:深色/浅色算法切换;登录页固定浅色(白卡片渐变背景)
|
||||
const antdTheme = { algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm }
|
||||
|
||||
if (!user) {
|
||||
return <Login onLogin={handleLogin} />
|
||||
return (
|
||||
<ConfigProvider theme={{ algorithm: theme.defaultAlgorithm }}>
|
||||
<Login onLogin={handleLogin} />
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const menuContent = (
|
||||
@@ -262,13 +303,17 @@ function App() {
|
||||
// ============ 移动端布局 ============
|
||||
if (isMobile) {
|
||||
return (
|
||||
<ConfigProvider theme={antdTheme}>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header className="mobile-header">
|
||||
<Button type="text" icon={<MenuOutlined />} onClick={() => setDrawerVisible(true)} style={{ color: '#fff' }} />
|
||||
<span className="mobile-title">{getPageTitle()}</span>
|
||||
<Space size={4}>
|
||||
<Button type="text" icon={isDark ? <SunOutlined /> : <MoonOutlined />} onClick={toggleTheme} style={{ color: '#fff' }} />
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" trigger={['click']}>
|
||||
<Avatar style={{ backgroundColor: '#7c3aed' }} icon={<UserOutlined />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
<div className="mobile-info-bar">
|
||||
@@ -304,12 +349,15 @@ function App() {
|
||||
</nav>
|
||||
|
||||
{pwdModal}
|
||||
<PushService user={user} />
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 桌面端布局 ============
|
||||
return (
|
||||
<ConfigProvider theme={antdTheme}>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider trigger={null} collapsible collapsed={collapsed} theme="dark">
|
||||
<div className="logo-area">
|
||||
@@ -348,6 +396,9 @@ function App() {
|
||||
<Space size="large" align="center">
|
||||
<Weather />
|
||||
<div className="greeting-text">{user.display_name || user.username},{getGreeting()}!</div>
|
||||
<Tooltip title={isDark ? '切换浅色模式' : '切换深色模式'}>
|
||||
<Button type="text" icon={isDark ? <SunOutlined /> : <MoonOutlined />} onClick={toggleTheme} />
|
||||
</Tooltip>
|
||||
<Button type="text" icon={<KeyOutlined />} onClick={() => setPwdModalVisible(true)}>
|
||||
修改密码
|
||||
</Button>
|
||||
@@ -365,7 +416,9 @@ function App() {
|
||||
</Layout>
|
||||
|
||||
{pwdModal}
|
||||
<PushService user={user} />
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
+158
-31
@@ -4,8 +4,47 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ==================== 主题变量(深色模式适配) ==================== */
|
||||
:root {
|
||||
--page-bg: #f5f7fa;
|
||||
--card-bg: #ffffff;
|
||||
--text-main: #374151;
|
||||
--text-sub: #666666;
|
||||
--text-hint: #999999;
|
||||
--text-faint: #bbbbbb;
|
||||
--border: #e8e8e8;
|
||||
--border-light: #f0f0f0;
|
||||
--hover-bg: #f5f5f5;
|
||||
--active-bg: #f0ebff;
|
||||
--chat-bg: #fafafa;
|
||||
--tip-bg: #e6f7ff;
|
||||
--tip-border: #91d5ff;
|
||||
--warn-bg: #fffbe6;
|
||||
--warn-border: #ffe58f;
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--page-bg: #141414;
|
||||
--card-bg: #1f1f1f;
|
||||
--text-main: rgba(255, 255, 255, 0.85);
|
||||
--text-sub: rgba(255, 255, 255, 0.65);
|
||||
--text-hint: rgba(255, 255, 255, 0.45);
|
||||
--text-faint: rgba(255, 255, 255, 0.3);
|
||||
--border: #303030;
|
||||
--border-light: #303030;
|
||||
--hover-bg: #2a2a2a;
|
||||
--active-bg: #2a2140;
|
||||
--chat-bg: #181818;
|
||||
--tip-bg: #111a2c;
|
||||
--tip-border: #153450;
|
||||
--warn-bg: #2b2111;
|
||||
--warn-border: #594214;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--page-bg);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -79,12 +118,12 @@ body {
|
||||
.about-page h2 {
|
||||
font-size: 20px;
|
||||
margin: 24px 0 12px;
|
||||
color: #333;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.about-page p {
|
||||
line-height: 1.8;
|
||||
color: #666;
|
||||
color: var(--text-sub);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -95,7 +134,7 @@ body {
|
||||
|
||||
.about-page li {
|
||||
line-height: 2;
|
||||
color: #666;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.guide-page {
|
||||
@@ -113,20 +152,20 @@ body {
|
||||
.guide-page h2 {
|
||||
font-size: 20px;
|
||||
margin: 24px 0 12px;
|
||||
color: #333;
|
||||
color: var(--text-main);
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e8e8e8;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.guide-page h3 {
|
||||
font-size: 16px;
|
||||
margin: 16px 0 8px;
|
||||
color: #444;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.guide-page p {
|
||||
line-height: 1.8;
|
||||
color: #666;
|
||||
color: var(--text-sub);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -138,20 +177,20 @@ body {
|
||||
|
||||
.guide-page li {
|
||||
line-height: 2;
|
||||
color: #666;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.guide-page .tip {
|
||||
background: #e6f7ff;
|
||||
border: 1px solid #91d5ff;
|
||||
background: var(--tip-bg);
|
||||
border: 1px solid var(--tip-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.guide-page .warning {
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
background: var(--warn-bg);
|
||||
border: 1px solid var(--warn-border);
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin: 12px 0;
|
||||
@@ -315,7 +354,7 @@ body {
|
||||
/* ==================== 顶部问候语 ==================== */
|
||||
.greeting-text {
|
||||
font-size: 15px;
|
||||
color: #374151;
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -351,21 +390,21 @@ body {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #f5f7fa;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
background: var(--page-bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mobile-greeting {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
padding: 12px;
|
||||
padding-bottom: 76px; /* 为底部导航留空间 */
|
||||
background: #f5f7fa;
|
||||
background: var(--page-bg);
|
||||
}
|
||||
|
||||
/* 底部导航 */
|
||||
@@ -375,8 +414,8 @@ body {
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
background: var(--card-bg);
|
||||
border-top: 1px solid var(--border);
|
||||
z-index: 100;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
@@ -391,7 +430,7 @@ body {
|
||||
padding: 8px 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #8c8c8c;
|
||||
color: var(--text-hint);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
@@ -527,7 +566,7 @@ body {
|
||||
|
||||
.messages-list {
|
||||
width: 320px;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
border-right: 1px solid var(--border-light);
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -540,13 +579,13 @@ body {
|
||||
}
|
||||
|
||||
.conv-item:hover {
|
||||
background: #f5f5f5;
|
||||
background: var(--hover-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.conv-item-active,
|
||||
.conv-item-active:hover {
|
||||
background: #f0ebff;
|
||||
background: var(--active-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@@ -560,14 +599,14 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
background: var(--chat-bg);
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
@@ -583,15 +622,15 @@ body {
|
||||
|
||||
.chat-msg-sender {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
color: var(--text-hint);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 70%;
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -605,16 +644,104 @@ body {
|
||||
|
||||
.chat-msg-time {
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
color: var(--text-faint);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.chat-msg-status {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chat-msg-read {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.chat-msg-unread {
|
||||
color: var(--text-hint);
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 16px 12px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.chat-toolbar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.emoji-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 2px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emoji-item:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.chat-bubble-media {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-light);
|
||||
color: inherit;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.chat-file-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.chat-file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-file-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-main);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-file-size {
|
||||
font-size: 12px;
|
||||
color: var(--text-hint);
|
||||
}
|
||||
|
||||
.chat-file-download {
|
||||
color: #7c3aed;
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.chat-placeholder {
|
||||
|
||||
@@ -5,7 +5,7 @@ function About() {
|
||||
return (
|
||||
<div className="about-page">
|
||||
<Card>
|
||||
<h1>影视星河 · 设备管理系统 v2.1</h1>
|
||||
<h1>影视星河 · 设备管理系统 v5.0</h1>
|
||||
<Divider />
|
||||
|
||||
<h2>软件概述</h2>
|
||||
@@ -19,6 +19,7 @@ function About() {
|
||||
<li><strong>登录认证:</strong>支持账号密码登录,内置默认管理员账户,登录后根据时间智能问候</li>
|
||||
<li><strong>每日天气:</strong>顶部导航栏实时显示当前城市天气、温度、湿度等信息</li>
|
||||
<li><strong>设备管理:</strong>支持设备的添加、编辑、删除、查询等全生命周期管理,涵盖笔记本电脑、台式机、显示器、打印机、网络设备等各类IT资产</li>
|
||||
<li><strong>设备借用:</strong>支持设备借用登记与归还管理,可上传借用/归还照片,从已有设备与人员中选择,服务器自动记录借用与归还时间</li>
|
||||
<li><strong>人员管理:</strong>维护组织架构中的人员信息,包括部门、职位、联系方式等,便于设备责任分配</li>
|
||||
<li><strong>通知管理:</strong>支持发布各类通知、公告和提醒,可设置优先级和发送范围,确保重要信息及时传达</li>
|
||||
<li><strong>数据概览:</strong>提供直观的仪表盘,实时展示设备状态、人员情况和通知统计</li>
|
||||
@@ -62,12 +63,26 @@ function About() {
|
||||
|
||||
<h2>版本信息</h2>
|
||||
<ul>
|
||||
<li>当前版本:v2.1</li>
|
||||
<li>当前版本:v5.0</li>
|
||||
<li>发布日期:2026年8月</li>
|
||||
<li>许可证:MIT License</li>
|
||||
</ul>
|
||||
|
||||
<h2>更新日志</h2>
|
||||
<h3>v5.0(2026年8月)</h3>
|
||||
<ol>
|
||||
<li>新增设备借用功能:支持上传借用照片,从已有设备中选择借用设备,从已有成员中选择借用人</li>
|
||||
<li>新增设备归还功能:支持上传归还照片,设备与人员可从已有数据中选择</li>
|
||||
<li>服务器自动存储借用时间与归还时间</li>
|
||||
<li>苹果端适配 Liquid Glass 液态玻璃设计(iOS 26/27)</li>
|
||||
<li>优化苹果端退出后台后需重新登录的问题</li>
|
||||
<li>修复苹果端底部导航栏触控困难的问题</li>
|
||||
</ol>
|
||||
<h3>v2.2(2026年8月)</h3>
|
||||
<ol>
|
||||
<li>添加群聊删除人员的功能</li>
|
||||
<li>修复创建群聊显示框闪退的bug</li>
|
||||
</ol>
|
||||
<h3>v2.1(2026年8月)</h3>
|
||||
<ol>
|
||||
<li>添加用户修改密码功能</li>
|
||||
|
||||
@@ -120,7 +120,7 @@ function Dashboard({ onNavigate }) {
|
||||
<h3>系统信息</h3>
|
||||
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||
<Col span={8}>
|
||||
<p><strong>系统版本:</strong>v2.1</p>
|
||||
<p><strong>系统版本:</strong>v5.0</p>
|
||||
<p><strong>系统名称:</strong>设备管理系统</p>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card, Table, Tag, Button, Modal, Form, Select, Upload, Input, Space,
|
||||
Image, message, Empty, Row, Col, Statistic
|
||||
} from 'antd'
|
||||
import {
|
||||
PlusOutlined, SwapOutlined, CameraOutlined, CheckCircleOutlined, ClockCircleOutlined
|
||||
} from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import { getApiBase } from '../apiConfig'
|
||||
|
||||
// 借用/归还照片地址(图片无法携带 Authorization 头,走查询参数 token)
|
||||
const photoUrl = (fileId) => {
|
||||
if (!fileId) return ''
|
||||
const token = localStorage.getItem('token') || ''
|
||||
return `${getApiBase()}/api/loans/photo/${fileId}?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
function DeviceLoan({ user }) {
|
||||
const [loans, setLoans] = useState([])
|
||||
const [devices, setDevices] = useState([])
|
||||
const [personnel, setPersonnel] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [deviceKw, setDeviceKw] = useState('')
|
||||
|
||||
// 借用弹窗
|
||||
const [loanOpen, setLoanOpen] = useState(false)
|
||||
const [loanForm] = Form.useForm()
|
||||
const [loanPhoto, setLoanPhoto] = useState('')
|
||||
const [loanPhotoPreview, setLoanPhotoPreview] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 归还弹窗
|
||||
const [returnOpen, setReturnOpen] = useState(false)
|
||||
const [returnTarget, setReturnTarget] = useState(null)
|
||||
const [returnForm] = Form.useForm()
|
||||
const [returnPhoto, setReturnPhoto] = useState('')
|
||||
const [returnPhotoPreview, setReturnPhotoPreview] = useState('')
|
||||
|
||||
const loadLoans = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await axios.get('/api/loans')
|
||||
if (res.data.success) setLoans(res.data.data || [])
|
||||
} catch (err) {
|
||||
message.error(err.response?.data?.message || '加载借用记录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLoans()
|
||||
axios.get('/api/devices').then(res => {
|
||||
if (res.data.success) setDevices(res.data.data || [])
|
||||
}).catch(() => {})
|
||||
axios.get('/api/personnel').then(res => {
|
||||
if (res.data.success) setPersonnel(res.data.data || [])
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// 上传照片(复用通用文件上传接口,返回 fileId)
|
||||
const uploadPhoto = async (file) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res = await axios.post('/api/chat/upload', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
if (!res.data.success) throw new Error(res.data.message || '上传失败')
|
||||
return res.data.data.fileId
|
||||
}
|
||||
|
||||
// ============ 借用 ============
|
||||
const openLoanModal = () => {
|
||||
loanForm.resetFields()
|
||||
setDeviceKw('')
|
||||
setLoanPhoto('')
|
||||
setLoanPhotoPreview('')
|
||||
setLoanOpen(true)
|
||||
}
|
||||
|
||||
const handleLoanPhoto = async (file) => {
|
||||
try {
|
||||
const fileId = await uploadPhoto(file)
|
||||
setLoanPhoto(fileId)
|
||||
setLoanPhotoPreview(photoUrl(fileId))
|
||||
message.success('借用照片上传成功')
|
||||
} catch (err) {
|
||||
message.error(err.response?.data?.message || err.message || '照片上传失败')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const submitLoan = async () => {
|
||||
try {
|
||||
const values = await loanForm.validateFields()
|
||||
setSubmitting(true)
|
||||
const res = await axios.post('/api/loans', {
|
||||
device_id: values.device_id,
|
||||
borrower: values.borrower,
|
||||
borrower_id: personnel.find(p => p.name === values.borrower)?.id || '',
|
||||
photo: loanPhoto,
|
||||
remark: values.remark || ''
|
||||
})
|
||||
if (res.data.success) {
|
||||
message.success(res.data.message || '借用成功')
|
||||
setLoanOpen(false)
|
||||
loadLoans()
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response) message.error(err.response.data?.message || '借用失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 归还 ============
|
||||
const openReturnModal = (loan) => {
|
||||
setReturnTarget(loan)
|
||||
setReturnPhoto('')
|
||||
setReturnPhotoPreview('')
|
||||
returnForm.setFieldsValue({
|
||||
device_id: loan.device_id,
|
||||
borrower: loan.borrower
|
||||
})
|
||||
setReturnOpen(true)
|
||||
}
|
||||
|
||||
const handleReturnPhoto = async (file) => {
|
||||
try {
|
||||
const fileId = await uploadPhoto(file)
|
||||
setReturnPhoto(fileId)
|
||||
setReturnPhotoPreview(photoUrl(fileId))
|
||||
message.success('归还照片上传成功')
|
||||
} catch (err) {
|
||||
message.error(err.response?.data?.message || err.message || '照片上传失败')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const submitReturn = async () => {
|
||||
try {
|
||||
const values = await returnForm.validateFields()
|
||||
setSubmitting(true)
|
||||
const res = await axios.post(`/api/loans/${returnTarget.id}/return`, {
|
||||
device_id: values.device_id,
|
||||
borrower: values.borrower,
|
||||
borrower_id: personnel.find(p => p.name === values.borrower)?.id || '',
|
||||
photo: returnPhoto
|
||||
})
|
||||
if (res.data.success) {
|
||||
message.success(res.data.message || '归还成功')
|
||||
setReturnOpen(false)
|
||||
loadLoans()
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response) message.error(err.response.data?.message || '归还失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 列表 ============
|
||||
const filtered = loans.filter(l => {
|
||||
if (statusFilter && l.status !== statusFilter) return false
|
||||
if (keyword) {
|
||||
const k = keyword.toLowerCase()
|
||||
const hit = [l.device_name, l.borrower, l.created_by]
|
||||
.some(v => (v || '').toLowerCase().includes(k))
|
||||
if (!hit) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const loaning = loans.filter(l => l.status === '借用中').length
|
||||
const returned = loans.filter(l => l.status === '已归还').length
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '设备', dataIndex: 'device_name', key: 'device',
|
||||
render: (v) => <strong>{v || '-'}</strong>
|
||||
},
|
||||
{
|
||||
title: '借用人', dataIndex: 'borrower', key: 'borrower',
|
||||
render: (v, r) => {
|
||||
const p = personnel.find(x => x.name === r.borrower)
|
||||
return p ? `${v}(${p.department || '-'})` : v
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
||||
render: (v) => v === '已归还'
|
||||
? <Tag color="green">已归还</Tag>
|
||||
: <Tag color="orange">借用中</Tag>
|
||||
},
|
||||
{ title: '借用时间', dataIndex: 'loan_time', key: 'loan_time', width: 170 },
|
||||
{ title: '归还时间', dataIndex: 'return_time', key: 'return_time', width: 170, render: (v) => v || '-' },
|
||||
{
|
||||
title: '借用照片', dataIndex: 'loan_photo', key: 'loan_photo', width: 90,
|
||||
render: (v) => v
|
||||
? <Image src={photoUrl(v)} width={56} height={56} style={{ objectFit: 'cover', borderRadius: 6 }} />
|
||||
: <span style={{ color: '#999' }}>-</span>
|
||||
},
|
||||
{
|
||||
title: '归还照片', dataIndex: 'return_photo', key: 'return_photo', width: 90,
|
||||
render: (v) => v
|
||||
? <Image src={photoUrl(v)} width={56} height={56} style={{ objectFit: 'cover', borderRadius: 6 }} />
|
||||
: <span style={{ color: '#999' }}>-</span>
|
||||
},
|
||||
{ title: '经办人', dataIndex: 'created_by', key: 'created_by', width: 110, render: (v) => v || '-' },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100,
|
||||
render: (_, r) => r.status === '借用中'
|
||||
? <Button type="primary" size="small" icon={<SwapOutlined />} onClick={() => openReturnModal(r)}>归还</Button>
|
||||
: null
|
||||
}
|
||||
]
|
||||
|
||||
// 借用选项:排除已报废与借用中(已借用设备不能二次借用),支持按设备编码/名称搜索
|
||||
const kw = deviceKw.trim().toLowerCase()
|
||||
const loanDeviceOptions = devices
|
||||
.filter(d => d.status !== '已报废' && d.status !== '借用中')
|
||||
.filter(d => !kw || (d.serial_number || '').toLowerCase().includes(kw) || (d.name || '').toLowerCase().includes(kw))
|
||||
.map(d => ({ value: d.id, label: `${d.name}(编号 ${d.serial_number || '-'} / ${d.status})` }))
|
||||
|
||||
const returnDeviceOptions = devices
|
||||
.filter(d => d.status !== '已报废')
|
||||
.map(d => ({ value: d.id, label: `${d.name}(编号 ${d.serial_number || '-'} / ${d.status})` }))
|
||||
|
||||
const personnelOptions = personnel.map(p => ({
|
||||
value: p.name,
|
||||
label: `${p.name}${p.department ? ' · ' + p.department : ''}${p.position ? ' · ' + p.position : ''}`
|
||||
}))
|
||||
|
||||
// 照片上传按钮(借用/归还共用样式)
|
||||
const photoUploadBtn = (preview, beforeUpload, text) => (
|
||||
<Space align="start">
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={beforeUpload}
|
||||
>
|
||||
<Button icon={<CameraOutlined />}>{text}</Button>
|
||||
</Upload>
|
||||
{preview && (
|
||||
<Image src={preview} width={64} height={64} style={{ objectFit: 'cover', borderRadius: 6 }} />
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="借用记录总数" value={loans.length} prefix={<ClockCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="借用中" value={loaning} valueStyle={{ color: '#fa8c16' }} prefix={<SwapOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="已归还" value={returned} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: '借用中', label: '借用中' },
|
||||
{ value: '已归还', label: '已归还' }
|
||||
]}
|
||||
/>
|
||||
<Input.Search
|
||||
placeholder="搜索设备 / 借用人"
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
onSearch={setKeyword}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openLoanModal}>借用设备</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={filtered}
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无借用记录" /> }}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 借用弹窗 */}
|
||||
<Modal
|
||||
title="借用设备"
|
||||
open={loanOpen}
|
||||
onOk={submitLoan}
|
||||
onCancel={() => setLoanOpen(false)}
|
||||
okText="确认借用"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
width={520}
|
||||
>
|
||||
<Form form={loanForm} layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item label="搜索设备编码">
|
||||
<Input.Search
|
||||
placeholder="输入或扫描设备编码 / 名称查找设备"
|
||||
allowClear
|
||||
value={deviceKw}
|
||||
onChange={e => setDeviceKw(e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="device_id" label="借用设备" rules={[{ required: true, message: '请选择设备' }]}>
|
||||
<Select showSearch optionFilterProp="label" placeholder="从可借设备中选择(借用中设备不可二次借用)" options={loanDeviceOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="borrower" label="借用人" rules={[{ required: true, message: '请选择借用人' }]}>
|
||||
<Select showSearch optionFilterProp="label" placeholder="从已有成员中选择" options={personnelOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item label="借用照片">
|
||||
{photoUploadBtn(loanPhotoPreview, handleLoanPhoto, loanPhoto ? '重新上传照片' : '上传借用照片')}
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="选填,如借用用途等" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<p style={{ color: '#999', fontSize: 12 }}>确认借用后,服务器将自动记录借用时间。</p>
|
||||
</Modal>
|
||||
|
||||
{/* 归还弹窗 */}
|
||||
<Modal
|
||||
title="归还设备"
|
||||
open={returnOpen}
|
||||
onOk={submitReturn}
|
||||
onCancel={() => setReturnOpen(false)}
|
||||
okText="确认归还"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
width={520}
|
||||
>
|
||||
<Form form={returnForm} layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="device_id" label="归还设备" rules={[{ required: true, message: '请选择设备' }]}>
|
||||
<Select showSearch optionFilterProp="label" placeholder="从已有设备中选择" options={returnDeviceOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="borrower" label="归还人" rules={[{ required: true, message: '请选择归还人' }]}>
|
||||
<Select showSearch optionFilterProp="label" placeholder="从已有成员中选择" options={personnelOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item label="归还照片">
|
||||
{photoUploadBtn(returnPhotoPreview, handleReturnPhoto, returnPhoto ? '重新上传照片' : '上传归还照片')}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<p style={{ color: '#999', fontSize: 12 }}>确认归还后,服务器将自动记录归还时间。</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeviceLoan
|
||||
@@ -9,7 +9,7 @@ const { Option } = Select
|
||||
const { TextArea } = Input
|
||||
|
||||
const deviceTypes = ['笔记本电脑', '台式机', '显示器', '打印机', '网络设备', '服务器', '投影仪', '其他']
|
||||
const deviceStatuses = ['正常', '维修中', '已报废', '闲置']
|
||||
const deviceStatuses = ['正常', '维修中', '已报废', '闲置', '借用中']
|
||||
|
||||
function DeviceManagement({ user }) {
|
||||
const isAdminUser = can(user, 'device:edit')
|
||||
@@ -229,7 +229,8 @@ function DeviceManagement({ user }) {
|
||||
'正常': 'green',
|
||||
'维修中': 'orange',
|
||||
'已报废': 'red',
|
||||
'闲置': 'default'
|
||||
'闲置': 'default',
|
||||
'借用中': 'blue'
|
||||
}
|
||||
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>
|
||||
}
|
||||
|
||||
+333
-35
@@ -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 } 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, 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)
|
||||
@@ -25,23 +44,33 @@ function Messages({ user }) {
|
||||
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([])
|
||||
}
|
||||
|
||||
@@ -75,13 +104,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) }
|
||||
}, [])
|
||||
|
||||
// 新消息自动滚动到底部
|
||||
@@ -107,6 +149,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 {
|
||||
@@ -148,6 +219,63 @@ function Messages({ user }) {
|
||||
|
||||
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: (
|
||||
@@ -234,22 +362,105 @@ function Messages({ user }) {
|
||||
<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>}
|
||||
<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">
|
||||
<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)}
|
||||
@@ -258,7 +469,8 @@ function Messages({ user }) {
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button type="primary" icon={<SendOutlined />} loading={sending} onClick={handleSend}>发送</Button>
|
||||
<Button type="primary" icon={<SendOutlined />} loading={sending || uploading} onClick={handleSend}>发送</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -267,35 +479,10 @@ function Messages({ user }) {
|
||||
</div>
|
||||
)
|
||||
|
||||
// ============ 布局 ============
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="page-content">
|
||||
<Card bodyStyle={{ padding: 12 }}>
|
||||
{activeConv ? chatWindow : conversationList}
|
||||
</Card>
|
||||
|
||||
<CreateConvModal />
|
||||
</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>
|
||||
|
||||
<CreateConvModal />
|
||||
</div>
|
||||
)
|
||||
|
||||
// ============ 发起对话 / 建群弹窗 ============
|
||||
function CreateConvModal() {
|
||||
return (
|
||||
// 注意:这里必须用普通 JSX 变量而非内部函数组件,
|
||||
// 否则轮询重新渲染时组件类型变化会导致弹窗被卸载重建(闪退)
|
||||
const createModal = (
|
||||
<Modal
|
||||
title="发起对话"
|
||||
open={createVisible}
|
||||
@@ -307,6 +494,7 @@ function Messages({ user }) {
|
||||
<Form.Item name="tab" noStyle>
|
||||
<Tabs
|
||||
centered
|
||||
destroyInactiveTabPane
|
||||
items={[
|
||||
{
|
||||
key: 'direct',
|
||||
@@ -343,7 +531,117 @@ function Messages({ user }) {
|
||||
</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
|
||||
|
||||
+54
@@ -125,6 +125,25 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// 设备借用记录表(借用/归还时间由服务器自动记录)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS device_loans (
|
||||
id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT DEFAULT '',
|
||||
borrower_id TEXT DEFAULT '',
|
||||
borrower TEXT DEFAULT '',
|
||||
loan_photo TEXT DEFAULT '',
|
||||
return_photo TEXT DEFAULT '',
|
||||
status TEXT DEFAULT '借用中',
|
||||
loan_time TEXT DEFAULT (datetime('now', 'localtime')),
|
||||
return_time TEXT DEFAULT '',
|
||||
remark TEXT DEFAULT '',
|
||||
created_by TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
`);
|
||||
|
||||
// 账户操作日志表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_logs (
|
||||
@@ -171,6 +190,41 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// 聊天附件表(上传的图片/视频/文件)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
uploader_id TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
stored_name TEXT NOT NULL,
|
||||
file_size INTEGER DEFAULT 0,
|
||||
file_mime TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||
)
|
||||
`);
|
||||
|
||||
// 迁移:消息表补充媒体类型列(兼容旧库)
|
||||
const msgCols = db.prepare('PRAGMA table_info(messages)').all().map(c => 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) {
|
||||
|
||||
Generated
+5
-5
@@ -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": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "device-management-system",
|
||||
"version": "2.1.0",
|
||||
"version": "5.0.0",
|
||||
"description": "影视星河设备管理系统 - Device Management System",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 -> 时间戳),用于判断在线状态
|
||||
@@ -23,6 +48,11 @@ function dropSession(token) {
|
||||
sessionActivity.delete(token);
|
||||
}
|
||||
|
||||
// 下线时间列(兼容旧库:已存在则忽略)
|
||||
try {
|
||||
db.prepare("ALTER TABLE users ADD COLUMN last_logout TEXT DEFAULT ''").run();
|
||||
} catch (e) { /* 列已存在 */ }
|
||||
|
||||
// 收集当前在线用户(用户ID -> 最近活跃时间戳,取最新)
|
||||
function getOnlineUserMap() {
|
||||
const now = Date.now();
|
||||
@@ -50,7 +80,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 +118,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 +313,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 +341,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);
|
||||
@@ -445,6 +526,85 @@ app.get('/api/personnel/stats/summary', (req, res) => {
|
||||
res.json({ success: true, data: { total, active, withAccount, departments } });
|
||||
});
|
||||
|
||||
// ==================== 设备借用/归还 API ====================
|
||||
|
||||
// 借用记录列表(可按状态/设备筛选,借用时间倒序)
|
||||
app.get('/api/loans', (req, res) => {
|
||||
const { status, device_id } = req.query;
|
||||
const conds = [];
|
||||
const params = [];
|
||||
if (status) { conds.push('status = ?'); params.push(status); }
|
||||
if (device_id) { conds.push('device_id = ?'); params.push(device_id); }
|
||||
const where = conds.length ? ' WHERE ' + conds.join(' AND ') : '';
|
||||
const rows = db.prepare(`SELECT * FROM device_loans${where} ORDER BY loan_time DESC, rowid DESC LIMIT 200`).all(...params);
|
||||
res.json({ success: true, data: rows });
|
||||
});
|
||||
|
||||
// 创建借用(服务器自动记录借用时间)
|
||||
app.post('/api/loans', (req, res) => {
|
||||
const { device_id, borrower, borrower_id, photo, remark } = req.body || {};
|
||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id || '');
|
||||
if (!device) {
|
||||
return res.status(400).json({ success: false, message: '请选择要借用的设备' });
|
||||
}
|
||||
if (!borrower || !String(borrower).trim()) {
|
||||
return res.status(400).json({ success: false, message: '请选择借用人' });
|
||||
}
|
||||
const id = uuidv4();
|
||||
db.prepare(`
|
||||
INSERT INTO device_loans (id, device_id, device_name, borrower_id, borrower, loan_photo, remark, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, device.id, device.name, borrower_id || '', String(borrower).trim(), photo || '', remark || '', getOperator(req));
|
||||
const row = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(id);
|
||||
logDeviceAction(device.id, device.name, '借用', `${row.borrower} 借走设备`, getOperator(req));
|
||||
pushToRole('admin', 'device_loan', '设备借用', `${row.borrower} 借用了设备「${device.name}」`, req.user.id);
|
||||
res.json({ success: true, data: row, message: '借用成功' });
|
||||
});
|
||||
|
||||
// 归还设备(服务器自动记录归还时间;设备/借用人可重新选择)
|
||||
app.post('/api/loans/:id/return', (req, res) => {
|
||||
const loan = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(req.params.id);
|
||||
if (!loan) {
|
||||
return res.status(404).json({ success: false, message: '借用记录不存在' });
|
||||
}
|
||||
if (loan.status === '已归还') {
|
||||
return res.status(400).json({ success: false, message: '该设备已归还,请勿重复操作' });
|
||||
}
|
||||
const { device_id, borrower, borrower_id, photo } = req.body || {};
|
||||
// 归还时可重新选择设备(从已有设备中选择)
|
||||
let deviceId = loan.device_id;
|
||||
let deviceName = loan.device_name;
|
||||
if (device_id && device_id !== loan.device_id) {
|
||||
const d = db.prepare('SELECT id, name FROM devices WHERE id = ?').get(device_id);
|
||||
if (d) { deviceId = d.id; deviceName = d.name; }
|
||||
}
|
||||
const borrowerName = (borrower && String(borrower).trim()) || loan.borrower;
|
||||
db.prepare(`
|
||||
UPDATE device_loans
|
||||
SET device_id = ?, device_name = ?, borrower = ?, borrower_id = ?,
|
||||
return_photo = ?, status = '已归还', return_time = datetime('now', 'localtime')
|
||||
WHERE id = ?
|
||||
`).run(deviceId, deviceName, borrowerName, borrower_id || loan.borrower_id, photo || '', loan.id);
|
||||
const row = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(loan.id);
|
||||
logDeviceAction(deviceId, deviceName, '归还', `${borrowerName} 归还设备`, getOperator(req));
|
||||
pushToRole('admin', 'device_return', '设备归还', `${borrowerName} 已归还设备「${deviceName}」`, req.user.id);
|
||||
res.json({ success: true, data: row, message: '归还成功' });
|
||||
});
|
||||
|
||||
// 查看借用/归还照片(所有登录用户可见)
|
||||
app.get('/api/loans/photo/:fileId', (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(req.params.fileId);
|
||||
if (!file) {
|
||||
return res.status(404).json({ success: false, message: '照片不存在' });
|
||||
}
|
||||
const filePath = path.join(uploadsDir, file.stored_name);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ success: false, message: '照片文件已丢失' });
|
||||
}
|
||||
res.setHeader('Content-Type', file.file_mime || 'image/jpeg');
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
// ==================== 通知管理 API ====================
|
||||
|
||||
// 获取所有通知
|
||||
@@ -705,11 +865,74 @@ 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();
|
||||
const rows = db.prepare('SELECT id, username, display_name, role, last_login, last_logout FROM users ORDER BY created_at ASC').all();
|
||||
const onlineMap = getOnlineUserMap();
|
||||
const data = rows.map(u => ({ ...u, is_online: onlineMap.has(u.id) }));
|
||||
const data = rows.map(u => ({
|
||||
...u,
|
||||
is_online: onlineMap.has(u.id),
|
||||
last_active: onlineMap.has(u.id) ? formatActive(onlineMap.get(u.id)) : null
|
||||
}));
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
@@ -737,7 +960,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 +1028,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 +1042,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 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);
|
||||
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;
|
||||
});
|
||||
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,12 +1152,48 @@ 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);
|
||||
res.json({ success: true, message: conv.type === 'group' ? '群组已解散' : '会话已删除' });
|
||||
});
|
||||
|
||||
// 移除群成员(群主可移除任意成员,群主本人不可被移除)
|
||||
app.delete('/api/chat/conversations/:id/members/:userId', (req, res) => {
|
||||
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
|
||||
if (!conv || conv.type !== 'group') {
|
||||
return res.status(404).json({ success: false, message: '群聊不存在' });
|
||||
}
|
||||
if (!isConversationMember(conv.id, req.user.id)) {
|
||||
return res.status(403).json({ success: false, message: '您不是该群聊的成员' });
|
||||
}
|
||||
const targetId = req.params.userId;
|
||||
if (targetId === conv.creator_id) {
|
||||
return res.status(400).json({ success: false, message: '群主不能被移除' });
|
||||
}
|
||||
if (!isConversationMember(conv.id, targetId)) {
|
||||
return res.status(404).json({ success: false, message: '该用户不在群聊中' });
|
||||
}
|
||||
if (req.user.id !== conv.creator_id) {
|
||||
return res.status(403).json({ success: false, message: '只有群主可以移除群成员' });
|
||||
}
|
||||
db.prepare('DELETE FROM conversation_members WHERE conversation_id = ? AND user_id = ?').run(conv.id, targetId);
|
||||
res.json({ success: true, message: '已将该成员移出群聊' });
|
||||
});
|
||||
|
||||
// ==================== 认证 API ====================
|
||||
|
||||
// 登录
|
||||
@@ -906,6 +1233,11 @@ app.get('/api/auth/me', (req, res) => {
|
||||
// 登出
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
const user = token ? sessions.get(token) : null;
|
||||
if (user) {
|
||||
// 记录下线时间
|
||||
db.prepare("UPDATE users SET last_logout=datetime('now','localtime') WHERE id=?").run(user.id);
|
||||
}
|
||||
if (token) dropSession(token);
|
||||
res.json({ success: true, message: '登出成功' });
|
||||
});
|
||||
@@ -928,6 +1260,47 @@ app.patch('/api/auth/password', (req, res) => {
|
||||
res.json({ success: true, message: '密码修改成功,请使用新密码重新登录' });
|
||||
});
|
||||
|
||||
// ==================== App 版本检查与更新 ====================
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS app_versions (
|
||||
platform TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL,
|
||||
url TEXT DEFAULT '',
|
||||
size INTEGER DEFAULT 0,
|
||||
notes TEXT DEFAULT '',
|
||||
released_at TEXT
|
||||
)`);
|
||||
|
||||
// 首次启动播种当前四端 v4.0.0 版本信息(仅空表时写入)
|
||||
['windows', 'android', 'harmony', 'ios'].forEach(p => {
|
||||
db.prepare("INSERT OR IGNORE INTO app_versions(platform, version, url, size, notes, released_at) VALUES(?, '4.0.0', '', 0, '影视星河设备管理系统 v4.0.0', datetime('now','localtime'))").run(p);
|
||||
});
|
||||
|
||||
// 查询各端最新版本(客户端登录后检查更新用)
|
||||
app.get('/api/app/version/latest', (req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM app_versions').all();
|
||||
const map = {};
|
||||
rows.forEach(r => { map[r.platform] = r; });
|
||||
res.json({ success: true, data: map });
|
||||
});
|
||||
|
||||
// 管理员发布新版本:{ platform: windows|android|harmony|ios, version, url, size, notes }
|
||||
app.post('/api/app/version', requireAdmin, (req, res) => {
|
||||
const { platform, version, url, size, notes } = req.body || {};
|
||||
const platforms = ['windows', 'android', 'harmony', 'ios'];
|
||||
if (!platforms.includes(platform)) {
|
||||
return res.status(400).json({ success: false, message: '平台取值必须为 windows/android/harmony/ios' });
|
||||
}
|
||||
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
|
||||
return res.status(400).json({ success: false, message: '版本号格式必须为 x.y.z' });
|
||||
}
|
||||
db.prepare(`INSERT INTO app_versions(platform, version, url, size, notes, released_at)
|
||||
VALUES(?,?,?,?,?,datetime('now','localtime'))
|
||||
ON CONFLICT(platform) DO UPDATE SET version=excluded.version, url=excluded.url, size=excluded.size, notes=excluded.notes, released_at=excluded.released_at`)
|
||||
.run(platform, version, url || '', size || 0, notes || '');
|
||||
res.json({ success: true, message: platform + ' 端 v' + version + ' 已发布' });
|
||||
});
|
||||
|
||||
// ==================== 天气 API ====================
|
||||
|
||||
function getWeatherDesc(code) {
|
||||
|
||||
Reference in New Issue
Block a user