update
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
# 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,65 +0,0 @@
|
|||||||
@echo off
|
|
||||||
echo ==========================================
|
|
||||||
echo 设备管理系统 - 手机App打包脚本(Android)
|
|
||||||
echo ==========================================
|
|
||||||
echo.
|
|
||||||
echo 前置条件:
|
|
||||||
echo 1. 已安装 Node.js 18+
|
|
||||||
echo 2. 已安装 Android Studio 及 Android SDK
|
|
||||||
echo 3. 已配置 JAVA_HOME / ANDROID_HOME 环境变量
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo [1/4] 安装前端依赖...
|
|
||||||
cd client
|
|
||||||
call npm install
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 前端依赖安装失败!
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [2/4] 构建前端...
|
|
||||||
call npm run build
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 前端构建失败!
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [3/4] 添加 Android 平台(首次运行需要)...
|
|
||||||
call npx capacitor add android
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 提示: 如已添加过 android 平台可忽略此错误
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [4/4] 同步前端资源到 Android 工程...
|
|
||||||
call npx capacitor sync android
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 同步失败!
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ==========================================
|
|
||||||
echo 同步完成!
|
|
||||||
echo.
|
|
||||||
echo 接下来生成APK有两种方式:
|
|
||||||
echo.
|
|
||||||
echo 方式一(推荐): 打开 Android Studio 打包
|
|
||||||
echo npx capacitor open android
|
|
||||||
echo 然后在 Android Studio 中:
|
|
||||||
echo Build -^> Build Bundle(s)/APK(s) -^> Build APK(s)
|
|
||||||
echo.
|
|
||||||
echo 方式二: 命令行打包(需配置gradle)
|
|
||||||
echo cd android
|
|
||||||
echo gradlew assembleDebug
|
|
||||||
echo.
|
|
||||||
echo APK输出位置:
|
|
||||||
echo android\app\build\outputs\apk\debug\app-debug.apk
|
|
||||||
echo ==========================================
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
@echo off
|
|
||||||
echo ==========================================
|
|
||||||
echo 设备管理系统 - Windows EXE 打包脚本
|
|
||||||
echo ==========================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo [1/4] 安装后端依赖...
|
|
||||||
call npm install
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 后端依赖安装失败!
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [2/4] 安装前端依赖...
|
|
||||||
cd client
|
|
||||||
call npm install
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 前端依赖安装失败!
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [3/4] 构建前端...
|
|
||||||
call npm run build
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 前端构建失败!
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [4/4] 打包 Electron 应用...
|
|
||||||
call npx electron-builder --win
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo Electron 打包失败!
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ==========================================
|
|
||||||
echo 打包完成!
|
|
||||||
echo.
|
|
||||||
echo EXE安装包位于: dist-electron 目录
|
|
||||||
echo ==========================================
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 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
@@ -0,0 +1,105 @@
|
|||||||
|
# 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,44 +0,0 @@
|
|||||||
@echo off
|
|
||||||
echo ==========================================
|
|
||||||
echo 设备管理系统 - 服务器部署构建脚本
|
|
||||||
echo ==========================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo [1/3] 安装后端依赖...
|
|
||||||
call npm install
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 后端依赖安装失败!
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [2/3] 安装前端依赖...
|
|
||||||
cd client
|
|
||||||
call npm install
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 前端依赖安装失败!
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo [3/3] 构建前端...
|
|
||||||
call npm run build
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 前端构建失败!
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ==========================================
|
|
||||||
echo 构建完成!
|
|
||||||
echo.
|
|
||||||
echo 启动服务器: npm start
|
|
||||||
echo 访问地址: http://localhost:3001
|
|
||||||
echo ==========================================
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
Generated
+312
-310
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "device-management-client",
|
"name": "device-management-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "2.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+124
-7
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Layout, Menu, theme, Button, Space, Dropdown, message, Drawer, Avatar } from 'antd'
|
import { Layout, Menu, theme, Button, Space, Dropdown, message, Drawer, Avatar, Modal, Form, Input, Badge, Tooltip } from 'antd'
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
DownOutlined
|
DownOutlined,
|
||||||
|
UserSwitchOutlined,
|
||||||
|
KeyOutlined,
|
||||||
|
MessageOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
@@ -19,6 +22,8 @@ import Dashboard from './pages/Dashboard'
|
|||||||
import DeviceManagement from './pages/DeviceManagement'
|
import DeviceManagement from './pages/DeviceManagement'
|
||||||
import PersonnelManagement from './pages/PersonnelManagement'
|
import PersonnelManagement from './pages/PersonnelManagement'
|
||||||
import NotificationManagement from './pages/NotificationManagement'
|
import NotificationManagement from './pages/NotificationManagement'
|
||||||
|
import AccountManagement from './pages/AccountManagement'
|
||||||
|
import Messages from './pages/Messages'
|
||||||
import About from './pages/About'
|
import About from './pages/About'
|
||||||
import Guide from './pages/Guide'
|
import Guide from './pages/Guide'
|
||||||
|
|
||||||
@@ -42,6 +47,9 @@ function App() {
|
|||||||
const [currentPage, setCurrentPage] = useState('dashboard')
|
const [currentPage, setCurrentPage] = useState('dashboard')
|
||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [checkingAuth, setCheckingAuth] = useState(true)
|
const [checkingAuth, setCheckingAuth] = useState(true)
|
||||||
|
const [pwdModalVisible, setPwdModalVisible] = useState(false)
|
||||||
|
const [pwdForm] = Form.useForm()
|
||||||
|
const [unreadTotal, setUnreadTotal] = useState(0)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
token: { colorBgContainer, borderRadiusLG }
|
token: { colorBgContainer, borderRadiusLG }
|
||||||
@@ -69,6 +77,23 @@ function App() {
|
|||||||
return () => window.removeEventListener('auth-expired', onExpired)
|
return () => window.removeEventListener('auth-expired', onExpired)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 轮询消息未读总数(侧边栏徽标)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return
|
||||||
|
const fetchUnread = () => {
|
||||||
|
axios.get('/api/chat/conversations')
|
||||||
|
.then(res => {
|
||||||
|
if (res.data.success) {
|
||||||
|
setUnreadTotal(res.data.data.reduce((s, c) => s + (c.unread || 0), 0))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
fetchUnread()
|
||||||
|
const timer = setInterval(fetchUnread, 10000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [user?.id])
|
||||||
|
|
||||||
const handleLogin = (userData) => {
|
const handleLogin = (userData) => {
|
||||||
setUser(userData)
|
setUser(userData)
|
||||||
setCurrentPage('dashboard')
|
setCurrentPage('dashboard')
|
||||||
@@ -96,20 +121,20 @@ function App() {
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: 'dashboard', icon: <DashboardOutlined />, label: '系统首页' },
|
{ key: 'dashboard', icon: <DashboardOutlined />, label: '系统首页' },
|
||||||
{ key: 'about', icon: <InfoCircleOutlined />, label: '软件简介' },
|
|
||||||
{ key: 'guide', icon: <BookOutlined />, label: '使用说明' },
|
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{ key: 'devices', icon: <DesktopOutlined />, label: '设备管理' },
|
{ key: 'devices', icon: <DesktopOutlined />, label: '设备管理' },
|
||||||
{ key: 'personnel', icon: <TeamOutlined />, label: '人员管理' },
|
{ key: 'personnel', icon: <TeamOutlined />, label: '人员管理' },
|
||||||
|
...(user?.role === 'admin' ? [{ key: 'accounts', icon: <UserSwitchOutlined />, label: '账户管理' }] : []),
|
||||||
{ key: 'notifications', icon: <BellOutlined />, label: '通知管理' }
|
{ key: 'notifications', icon: <BellOutlined />, label: '通知管理' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 移动端底部导航(4个核心入口)
|
// 移动端底部导航(5个核心入口)
|
||||||
const bottomNavItems = [
|
const bottomNavItems = [
|
||||||
{ key: 'dashboard', icon: <DashboardOutlined />, label: '首页' },
|
{ key: 'dashboard', icon: <DashboardOutlined />, label: '首页' },
|
||||||
{ key: 'devices', icon: <DesktopOutlined />, label: '设备' },
|
{ key: 'devices', icon: <DesktopOutlined />, label: '设备' },
|
||||||
{ key: 'personnel', icon: <TeamOutlined />, label: '人员' },
|
{ key: 'personnel', icon: <TeamOutlined />, label: '人员' },
|
||||||
{ key: 'notifications', icon: <BellOutlined />, label: '通知' }
|
{ key: 'notifications', icon: <BellOutlined />, label: '通知' },
|
||||||
|
{ key: 'messages', icon: <MessageOutlined />, label: '消息' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
@@ -119,7 +144,9 @@ function App() {
|
|||||||
case 'guide': return <Guide />
|
case 'guide': return <Guide />
|
||||||
case 'devices': return <DeviceManagement user={user} />
|
case 'devices': return <DeviceManagement user={user} />
|
||||||
case 'personnel': return <PersonnelManagement 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 'notifications': return <NotificationManagement user={user} />
|
||||||
|
case 'messages': return <Messages user={user} />
|
||||||
default: return <Dashboard onNavigate={setCurrentPage} />
|
default: return <Dashboard onNavigate={setCurrentPage} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,14 +154,82 @@ function App() {
|
|||||||
const getPageTitle = () => {
|
const getPageTitle = () => {
|
||||||
const titles = {
|
const titles = {
|
||||||
dashboard: '系统首页', about: '软件简介', guide: '使用说明',
|
dashboard: '系统首页', about: '软件简介', guide: '使用说明',
|
||||||
devices: '设备管理', personnel: '人员管理', notifications: '通知管理'
|
devices: '设备管理', personnel: '人员管理', accounts: '账户管理',
|
||||||
|
notifications: '通知管理', messages: '消息对话'
|
||||||
}
|
}
|
||||||
return titles[currentPage] || '系统首页'
|
return titles[currentPage] || '系统首页'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 修改自己的密码(所有用户可用,成功后需用新密码重新登录)
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
try {
|
||||||
|
const values = await pwdForm.validateFields()
|
||||||
|
const res = await axios.patch('/api/auth/password', {
|
||||||
|
oldPassword: values.oldPassword,
|
||||||
|
newPassword: values.newPassword
|
||||||
|
})
|
||||||
|
if (res.data.success) {
|
||||||
|
setPwdModalVisible(false)
|
||||||
|
pwdForm.resetFields()
|
||||||
|
// 后端已使当前会话失效,清除本地登录态回到登录页
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
setUser(null)
|
||||||
|
message.success(res.data.message)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response) message.error(err.response.data?.message || '修改失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pwdModal = (
|
||||||
|
<Modal
|
||||||
|
title="修改密码"
|
||||||
|
open={pwdModalVisible}
|
||||||
|
onOk={handleChangePassword}
|
||||||
|
onCancel={() => setPwdModalVisible(false)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
<Form form={pwdForm} layout="vertical" style={{ marginTop: 12 }}>
|
||||||
|
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
|
||||||
|
<Input.Password placeholder="请输入原密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="newPassword"
|
||||||
|
label="新密码"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入新密码' },
|
||||||
|
{ min: 6, message: '密码不少于6位' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="请输入新密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="confirmPassword"
|
||||||
|
label="确认新密码"
|
||||||
|
dependencies={['newPassword']}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请再次输入新密码' },
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (!value || getFieldValue('newPassword') === value) return Promise.resolve()
|
||||||
|
return Promise.reject(new Error('两次输入的密码不一致'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="请再次输入新密码" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
|
||||||
const userMenuItems = [
|
const userMenuItems = [
|
||||||
{ key: 'role', label: `角色:${user?.role === 'admin' ? '管理员' : '普通用户'}`, icon: <UserOutlined /> },
|
{ key: 'role', label: `角色:${user?.role === 'admin' ? '管理员' : '普通用户'}`, icon: <UserOutlined /> },
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
|
{ key: 'changePwd', label: '修改密码', icon: <KeyOutlined />, onClick: () => setPwdModalVisible(true) },
|
||||||
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, danger: true, onClick: () => handleLogout() }
|
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, danger: true, onClick: () => handleLogout() }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -204,6 +299,8 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{pwdModal}
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -226,6 +323,21 @@ function App() {
|
|||||||
items={menuItems}
|
items={menuItems}
|
||||||
onClick={({ key }) => setCurrentPage(key)}
|
onClick={({ key }) => setCurrentPage(key)}
|
||||||
/>
|
/>
|
||||||
|
<div className="sider-footer">
|
||||||
|
<Tooltip title={collapsed ? '消息对话' : ''} placement="right">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
block
|
||||||
|
className={currentPage === 'messages' ? 'sider-footer-active' : ''}
|
||||||
|
onClick={() => setCurrentPage('messages')}
|
||||||
|
>
|
||||||
|
<Badge count={unreadTotal} size="small" offset={collapsed ? [12, -8] : [8, 0]}>
|
||||||
|
<MessageOutlined style={{ fontSize: 16 }} />
|
||||||
|
</Badge>
|
||||||
|
{!collapsed && <span style={{ marginLeft: 10 }}>消息对话</span>}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</Sider>
|
</Sider>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Header style={{ padding: '0 24px', background: colorBgContainer, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
<Header style={{ padding: '0 24px', background: colorBgContainer, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
|
||||||
@@ -233,6 +345,9 @@ function App() {
|
|||||||
<Space size="large" align="center">
|
<Space size="large" align="center">
|
||||||
<Weather />
|
<Weather />
|
||||||
<div className="greeting-text">{user.display_name || user.username},{getGreeting()}!</div>
|
<div className="greeting-text">{user.display_name || user.username},{getGreeting()}!</div>
|
||||||
|
<Button type="text" icon={<KeyOutlined />} onClick={() => setPwdModalVisible(true)}>
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
<Button type="text" icon={<UserOutlined />} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<Button type="text" icon={<UserOutlined />} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
{user.display_name || user.username}
|
{user.display_name || user.username}
|
||||||
@@ -245,6 +360,8 @@ function App() {
|
|||||||
{renderPage()}
|
{renderPage()}
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
{pwdModal}
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Tag, Spin, Tooltip } from 'antd'
|
import { Tag, Spin, Tooltip } from 'antd'
|
||||||
import { CloudOutlined, SunOutlined, CloudRain, ThunderboltOutlined } from '@ant-design/icons'
|
import { CloudOutlined, SunOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
function Weather() {
|
function Weather() {
|
||||||
@@ -27,7 +27,7 @@ function Weather() {
|
|||||||
const getWeatherIcon = (code) => {
|
const getWeatherIcon = (code) => {
|
||||||
if (code === 0) return <SunOutlined style={{ color: '#faad14' }} />
|
if (code === 0) return <SunOutlined style={{ color: '#faad14' }} />
|
||||||
if ([95, 96, 99].includes(code)) return <ThunderboltOutlined style={{ color: '#722ed1' }} />
|
if ([95, 96, 99].includes(code)) return <ThunderboltOutlined style={{ color: '#722ed1' }} />
|
||||||
if ([61, 63, 65, 80, 81, 82].includes(code)) return <CloudRainOutlined style={{ color: '#1890ff' }} />
|
if ([61, 63, 65, 80, 81, 82].includes(code)) return <CloudOutlined style={{ color: '#1890ff' }} />
|
||||||
return <CloudOutlined style={{ color: '#8c8c8c' }} />
|
return <CloudOutlined style={{ color: '#8c8c8c' }} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -490,3 +490,147 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== 侧边栏底部固定按钮 ==================== */
|
||||||
|
.ant-layout-sider-children {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-footer .ant-btn {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-footer .ant-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-footer .sider-footer-active,
|
||||||
|
.sider-footer .sider-footer-active:hover {
|
||||||
|
color: #fff;
|
||||||
|
background: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 消息对话页面 ==================== */
|
||||||
|
.messages-layout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-list {
|
||||||
|
width: 320px;
|
||||||
|
border-right: 1px solid #f0f0f0;
|
||||||
|
padding: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-chat {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-item-active,
|
||||||
|
.conv-item-active:hover {
|
||||||
|
background: #f0ebff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-window {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-mine {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-sender {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble-mine {
|
||||||
|
background: #7c3aed;
|
||||||
|
border-color: #7c3aed;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #bbb;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-placeholder {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chat-window {
|
||||||
|
height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 82%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function About() {
|
|||||||
return (
|
return (
|
||||||
<div className="about-page">
|
<div className="about-page">
|
||||||
<Card>
|
<Card>
|
||||||
<h1>影视星河 · 设备管理系统 v1.0.0</h1>
|
<h1>影视星河 · 设备管理系统 v2.1</h1>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<h2>软件概述</h2>
|
<h2>软件概述</h2>
|
||||||
@@ -62,10 +62,23 @@ function About() {
|
|||||||
|
|
||||||
<h2>版本信息</h2>
|
<h2>版本信息</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>当前版本:v1.0.0</li>
|
<li>当前版本:v2.1</li>
|
||||||
<li>发布日期:2026年8月</li>
|
<li>发布日期:2026年8月</li>
|
||||||
<li>许可证:MIT License</li>
|
<li>许可证:MIT License</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h2>更新日志</h2>
|
||||||
|
<h3>v2.1(2026年8月)</h3>
|
||||||
|
<ol>
|
||||||
|
<li>添加用户修改密码功能</li>
|
||||||
|
<li>添加用户聊天建群和在线状态</li>
|
||||||
|
<li>优化页面布局</li>
|
||||||
|
<li>修复已知问题</li>
|
||||||
|
</ol>
|
||||||
|
<h3>v1.0.0(2026年8月)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>首个正式版本:设备管理、人员管理、通知管理、账户管理、数据概览、天气服务等核心功能上线</li>
|
||||||
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Table, Button, Input, Select, Space, Tag, Modal, Form, message, Popconfirm, Card, Badge } from 'antd'
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, KeyOutlined } from '@ant-design/icons'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { roleLabel } from '../permissions'
|
||||||
|
|
||||||
|
const { Option } = Select
|
||||||
|
|
||||||
|
function AccountManagement({ user }) {
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [logs, setLogs] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [logsLoading, setLogsLoading] = useState(false)
|
||||||
|
const [modalVisible, setModalVisible] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState(null)
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [filterRole, setFilterRole] = useState('')
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [resetForm] = Form.useForm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
fetchLogs()
|
||||||
|
}, [keyword, filterRole])
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = {}
|
||||||
|
if (keyword) params.keyword = keyword
|
||||||
|
if (filterRole) params.role = filterRole
|
||||||
|
const res = await axios.get('/api/users', { params })
|
||||||
|
if (res.data.success) setUsers(res.data.data)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err.response?.data?.message || '获取账户列表失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每 30 秒自动刷新在线状态
|
||||||
|
const fetchUsersRef = useRef(fetchUsers)
|
||||||
|
fetchUsersRef.current = fetchUsers
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => fetchUsersRef.current(), 30000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
setLogsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/users/logs')
|
||||||
|
if (res.data.success) setLogs(res.data.data)
|
||||||
|
} catch (err) {
|
||||||
|
// 静默失败
|
||||||
|
} finally {
|
||||||
|
setLogsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingUser(null)
|
||||||
|
form.resetFields()
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
setEditingUser(record)
|
||||||
|
form.setFieldsValue({
|
||||||
|
username: record.username,
|
||||||
|
role: record.role,
|
||||||
|
display_name: record.display_name
|
||||||
|
})
|
||||||
|
setModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingUser) {
|
||||||
|
const res = await axios.put(`/api/users/${editingUser.id}`, values)
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(res.data.message)
|
||||||
|
setModalVisible(false)
|
||||||
|
fetchUsers()
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await axios.post('/api/users', values)
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(res.data.message)
|
||||||
|
setModalVisible(false)
|
||||||
|
fetchUsers()
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response) message.error(err.response.data?.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.delete(`/api/users/${id}`)
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(res.data.message)
|
||||||
|
fetchUsers()
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err.response?.data?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenReset = (record) => {
|
||||||
|
resetForm.resetFields()
|
||||||
|
Modal.confirm({
|
||||||
|
title: `重置密码:${record.username}`,
|
||||||
|
icon: null,
|
||||||
|
content: (
|
||||||
|
<Form form={resetForm} layout="vertical" style={{ marginTop: 12 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label="新密码"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入新密码' },
|
||||||
|
{ min: 6, message: '密码不少于6位' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="请输入新密码" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
okText: '确认重置',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const values = await resetForm.validateFields()
|
||||||
|
const res = await axios.patch(`/api/users/${record.id}/reset-password`, values)
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(res.data.message)
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response) message.error(err.response.data?.message || '重置失败')
|
||||||
|
return Promise.reject()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearLogs = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.delete('/api/users/logs/clear')
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(res.data.message)
|
||||||
|
fetchLogs()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error('清空失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '用户名', dataIndex: 'username', key: 'username', width: 140 },
|
||||||
|
{ title: '显示名', dataIndex: 'display_name', key: 'display_name', width: 120, render: (t) => t || '-' },
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
key: 'role',
|
||||||
|
width: 100,
|
||||||
|
render: (role) => <Tag color={role === 'admin' ? 'gold' : 'blue'}>{roleLabel(role)}</Tag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '在线状态',
|
||||||
|
key: 'online',
|
||||||
|
width: 150,
|
||||||
|
render: (_, record) => (
|
||||||
|
<div>
|
||||||
|
<Badge
|
||||||
|
status={record.is_online ? 'success' : 'default'}
|
||||||
|
text={record.is_online ? '在线' : '离线'}
|
||||||
|
/>
|
||||||
|
{record.is_online && record.last_active && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999' }}>最近活跃:{record.last_active}</div>
|
||||||
|
)}
|
||||||
|
{!record.is_online && record.last_login && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999' }}>上次登录:{record.last_login}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '关联人员',
|
||||||
|
key: 'personnel',
|
||||||
|
width: 160,
|
||||||
|
render: (_, record) => record.personnel_name
|
||||||
|
? <span>{record.personnel_name}{record.department ? `(${record.department})` : ''}</span>
|
||||||
|
: <Tag>独立账户</Tag>
|
||||||
|
},
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 220,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" icon={<KeyOutlined />} size="small" onClick={() => handleOpenReset(record)}>重置密码</Button>
|
||||||
|
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要删除此账户吗?"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
disabled={record.id === user?.id}
|
||||||
|
>
|
||||||
|
<Button type="link" danger icon={<DeleteOutlined />} size="small" disabled={record.id === user?.id}>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const logColumns = [
|
||||||
|
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
|
||||||
|
{ title: '账户', dataIndex: 'username', key: 'username', width: 120 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
dataIndex: 'action',
|
||||||
|
key: 'action',
|
||||||
|
width: 100,
|
||||||
|
render: (a) => <Tag color={a === '删除' ? 'red' : a === '创建' ? 'green' : 'blue'}>{a}</Tag>
|
||||||
|
},
|
||||||
|
{ title: '详情', dataIndex: 'detail', key: 'detail', render: (t) => t || '-' },
|
||||||
|
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 120, render: (t) => t || '-' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content">
|
||||||
|
<Card title="账户管理" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="search-bar">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户名/显示名"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 220 }}
|
||||||
|
onPressEnter={fetchUsers}
|
||||||
|
/>
|
||||||
|
<Select placeholder="角色" value={filterRole || undefined} onChange={(v) => setFilterRole(v || '')} allowClear style={{ width: 140 }}>
|
||||||
|
<Option value="admin">管理员</Option>
|
||||||
|
<Option value="user">普通用户</Option>
|
||||||
|
</Select>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => { fetchUsers(); fetchLogs() }}>刷新</Button>
|
||||||
|
<Space style={{ marginLeft: 'auto' }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>创建账户</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<span>共 {users.length} 个账户,当前在线 {users.filter(u => u.is_online).length} 个</span>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={users}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="账户操作日志" extra={
|
||||||
|
<Popconfirm title="确定要清空所有账户日志吗?" onConfirm={handleClearLogs} okText="确定" cancelText="取消">
|
||||||
|
<Button size="small" danger>清空日志</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
}>
|
||||||
|
<Table
|
||||||
|
columns={logColumns}
|
||||||
|
dataSource={logs}
|
||||||
|
rowKey="id"
|
||||||
|
loading={logsLoading}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 8, showTotal: (total) => `共 ${total} 条` }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingUser ? '编辑账户' : '创建账户'}
|
||||||
|
open={modalVisible}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
onCancel={() => setModalVisible(false)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input placeholder="登录用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
{!editingUser && (
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label="密码"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入密码' },
|
||||||
|
{ min: 6, message: '密码不少于6位' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="登录密码" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Form.Item name="display_name" label="显示名">
|
||||||
|
<Input placeholder="界面显示的名称(默认同用户名)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="role" label="角色" initialValue="user">
|
||||||
|
<Select>
|
||||||
|
<Option value="user">普通账户</Option>
|
||||||
|
<Option value="admin">管理员账户</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
{editingUser && <div style={{ fontSize: 12, color: '#999' }}>如需修改密码,请使用列表中的"重置密码"功能。</div>}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountManagement
|
||||||
@@ -120,7 +120,7 @@ function Dashboard({ onNavigate }) {
|
|||||||
<h3>系统信息</h3>
|
<h3>系统信息</h3>
|
||||||
<Row gutter={16} style={{ marginTop: 12 }}>
|
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<p><strong>系统版本:</strong>v1.0.0</p>
|
<p><strong>系统版本:</strong>v2.1</p>
|
||||||
<p><strong>系统名称:</strong>设备管理系统</p>
|
<p><strong>系统名称:</strong>设备管理系统</p>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { Table, Button, Input, Select, Space, Tag, Modal, Form, DatePicker, message, Popconfirm, Card } from 'antd'
|
import { Table, Button, Input, Select, Space, Tag, Modal, Form, DatePicker, message, Popconfirm, Card } from 'antd'
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ToolOutlined, SyncOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ToolOutlined, SyncOutlined, HistoryOutlined, StopOutlined, RollbackOutlined } from '@ant-design/icons'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { can } from '../permissions'
|
import { can } from '../permissions'
|
||||||
@@ -24,8 +24,18 @@ function DeviceManagement({ user }) {
|
|||||||
const [filterStatus, setFilterStatus] = useState('')
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
const [statusModalVisible, setStatusModalVisible] = useState(false)
|
const [statusModalVisible, setStatusModalVisible] = useState(false)
|
||||||
const [statusDevice, setStatusDevice] = useState(null)
|
const [statusDevice, setStatusDevice] = useState(null)
|
||||||
|
const [scrapModalVisible, setScrapModalVisible] = useState(false)
|
||||||
|
const [scrapDevice, setScrapDevice] = useState(null)
|
||||||
|
const [repairModalVisible, setRepairModalVisible] = useState(false)
|
||||||
|
const [repairDevice, setRepairDevice] = useState(null)
|
||||||
|
const [recordsModalVisible, setRecordsModalVisible] = useState(false)
|
||||||
|
const [recordsDevice, setRecordsDevice] = useState(null)
|
||||||
|
const [records, setRecords] = useState([])
|
||||||
|
const [recordsLoading, setRecordsLoading] = useState(false)
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [statusForm] = Form.useForm()
|
const [statusForm] = Form.useForm()
|
||||||
|
const [scrapForm] = Form.useForm()
|
||||||
|
const [repairForm] = Form.useForm()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDevices()
|
fetchDevices()
|
||||||
@@ -78,24 +88,24 @@ function DeviceManagement({ user }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRepair = async (record) => {
|
const handleRepair = (record) => {
|
||||||
Modal.confirm({
|
setRepairDevice(record)
|
||||||
title: `设备报修:${record.name}`,
|
repairForm.resetFields()
|
||||||
content: '确认将该设备标记为“维修中”吗?可填写报修说明。',
|
setRepairModalVisible(true)
|
||||||
okText: '确认报修',
|
}
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
const handleRepairSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.patch(`/api/devices/${record.id}/repair`, { remark: '' })
|
const values = await repairForm.validateFields()
|
||||||
if (res.data.success) {
|
const res = await axios.patch(`/api/devices/${repairDevice.id}/repair`, { remark: values.remark })
|
||||||
message.success(res.data.message)
|
if (res.data.success) {
|
||||||
fetchDevices()
|
message.success(res.data.message)
|
||||||
}
|
setRepairModalVisible(false)
|
||||||
} catch (err) {
|
fetchDevices()
|
||||||
message.error(err.response?.data?.message || '报修失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
} catch (err) {
|
||||||
|
if (err.response) message.error(err.response.data?.message || '报修失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理员:打开修改维修状态弹窗
|
// 管理员:打开修改维修状态弹窗
|
||||||
@@ -120,6 +130,65 @@ function DeviceManagement({ user }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 管理员:打开报废弹窗
|
||||||
|
const handleOpenScrap = (record) => {
|
||||||
|
setScrapDevice(record)
|
||||||
|
scrapForm.resetFields()
|
||||||
|
setScrapModalVisible(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员:提交报废
|
||||||
|
const handleScrapSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await scrapForm.validateFields()
|
||||||
|
const res = await axios.post(`/api/devices/${scrapDevice.id}/scrap`, { reason: values.reason })
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(res.data.message)
|
||||||
|
setScrapModalVisible(false)
|
||||||
|
fetchDevices()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response) message.error(err.response.data?.message || '报废失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员:恢复报废设备
|
||||||
|
const handleRestore = (record) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `恢复设备:${record.name}`,
|
||||||
|
content: '确认撤销报废,将状态恢复为“闲置”吗?',
|
||||||
|
okText: '确认恢复',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`/api/devices/${record.id}/restore`)
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(res.data.message)
|
||||||
|
fetchDevices()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err.response?.data?.message || '恢复失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看维修/保养记录
|
||||||
|
const handleShowRecords = async (record) => {
|
||||||
|
setRecordsDevice(record)
|
||||||
|
setRecords([])
|
||||||
|
setRecordsModalVisible(true)
|
||||||
|
setRecordsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`/api/devices/${record.id}/records`)
|
||||||
|
if (res.data.success) setRecords(res.data.data)
|
||||||
|
} catch (err) {
|
||||||
|
message.error('获取记录失败')
|
||||||
|
} finally {
|
||||||
|
setRecordsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
@@ -177,16 +246,23 @@ function DeviceManagement({ user }) {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: isAdminUser ? 280 : 100,
|
width: isAdminUser ? 360 : 150,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space>
|
<Space wrap>
|
||||||
{canRepair && record.status !== '维修中' && record.status !== '已报废' && (
|
{canRepair && record.status !== '维修中' && record.status !== '已报废' && (
|
||||||
<Button type="link" icon={<ToolOutlined />} size="small" onClick={() => handleRepair(record)}>报修</Button>
|
<Button type="link" icon={<ToolOutlined />} size="small" onClick={() => handleRepair(record)}>报修</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button type="link" icon={<HistoryOutlined />} size="small" onClick={() => handleShowRecords(record)}>记录</Button>
|
||||||
{isAdminUser && (
|
{isAdminUser && (
|
||||||
<Button type="link" icon={<SyncOutlined />} size="small" onClick={() => handleOpenStatus(record)}>维修状态</Button>
|
<Button type="link" icon={<SyncOutlined />} size="small" onClick={() => handleOpenStatus(record)}>维修状态</Button>
|
||||||
)}
|
)}
|
||||||
|
{isAdminUser && record.status !== '已报废' && (
|
||||||
|
<Button type="link" danger icon={<StopOutlined />} size="small" onClick={() => handleOpenScrap(record)}>报废</Button>
|
||||||
|
)}
|
||||||
|
{isAdminUser && record.status === '已报废' && (
|
||||||
|
<Button type="link" icon={<RollbackOutlined />} size="small" onClick={() => handleRestore(record)}>恢复</Button>
|
||||||
|
)}
|
||||||
{isAdminUser && (
|
{isAdminUser && (
|
||||||
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
|
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
|
||||||
)}
|
)}
|
||||||
@@ -287,6 +363,28 @@ function DeviceManagement({ user }) {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="设备报修"
|
||||||
|
open={repairModalVisible}
|
||||||
|
onOk={handleRepairSubmit}
|
||||||
|
onCancel={() => setRepairModalVisible(false)}
|
||||||
|
okText="确认报修"
|
||||||
|
cancelText="取消"
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
{repairDevice && (
|
||||||
|
<>
|
||||||
|
<p style={{ color: '#666' }}>设备:<strong>{repairDevice.name}</strong>({repairDevice.type})</p>
|
||||||
|
<Form form={repairForm} layout="vertical">
|
||||||
|
<Form.Item name="remark" label="故障说明">
|
||||||
|
<TextArea rows={3} placeholder="如:开机无显示、打印卡纸等(选填)" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<div style={{ fontSize: 12, color: '#999' }}>确认后设备状态将变为“维修中”,并记入维修记录。</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="修改维修状态"
|
title="修改维修状态"
|
||||||
open={statusModalVisible}
|
open={statusModalVisible}
|
||||||
@@ -310,6 +408,60 @@ function DeviceManagement({ user }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="设备报废"
|
||||||
|
open={scrapModalVisible}
|
||||||
|
onOk={handleScrapSubmit}
|
||||||
|
onCancel={() => setScrapModalVisible(false)}
|
||||||
|
okText="确认报废"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
{scrapDevice && (
|
||||||
|
<>
|
||||||
|
<p style={{ color: '#666' }}>设备:<strong>{scrapDevice.name}</strong>({scrapDevice.type})</p>
|
||||||
|
<Form form={scrapForm} layout="vertical">
|
||||||
|
<Form.Item name="reason" label="报废原因" rules={[{ required: true, message: '请填写报废原因' }]}>
|
||||||
|
<TextArea rows={3} placeholder="如:达到使用年限、无法修复等" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<div style={{ fontSize: 12, color: '#999' }}>报废后设备状态变为“已报废”,如需撤销可点击“恢复”。</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={recordsDevice ? `维修记录:${recordsDevice.name}` : '维修记录'}
|
||||||
|
open={recordsModalVisible}
|
||||||
|
onCancel={() => setRecordsModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
{recordsDevice && recordsDevice.status === '已报废' && (
|
||||||
|
<p style={{ color: '#cf1322', fontSize: 13 }}>
|
||||||
|
报废信息:{recordsDevice.scrap_reason || '未填写原因'} 报废日期:{recordsDevice.scrap_date || '-'} 操作人:{recordsDevice.scrap_operator || '-'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{records.length === 0 && !recordsLoading ? (
|
||||||
|
<p style={{ color: '#999', textAlign: 'center', padding: 24 }}>暂无维修记录</p>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
dataSource={records}
|
||||||
|
loading={recordsLoading}
|
||||||
|
pagination={false}
|
||||||
|
columns={[
|
||||||
|
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 150 },
|
||||||
|
{ title: '类型', dataIndex: 'type', key: 'type', width: 90, render: (t) => <Tag color={t === '报修' ? 'orange' : t === '报废' ? 'red' : 'green'}>{t}</Tag> },
|
||||||
|
{ title: '说明', dataIndex: 'remark', key: 'remark', render: (t) => t || '-' },
|
||||||
|
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100, render: (t) => t || '-' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,8 +89,16 @@ function Guide() {
|
|||||||
|
|
||||||
<h3>6.6 设备报修与维修状态修改</h3>
|
<h3>6.6 设备报修与维修状态修改</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>设备报修(普通账户+管理员):</strong>点击操作列的“报修”按钮,确认后设备状态即变为“维修中”。</li>
|
<li><strong>设备报修(普通账户+管理员):</strong>点击操作列的“报修”按钮,填写故障说明后确认,设备状态即变为“维修中”,并记入维修记录。</li>
|
||||||
<li><strong>修改维修状态(仅管理员):</strong>点击操作列的“维修状态”按钮,在弹窗中选择新状态(正常/维修中/已报废/闲置)后确定。例如维修完成后,可将状态从“维修中”改回“正常”。</li>
|
<li><strong>修改维修状态(仅管理员):</strong>点击操作列的“维修状态”按钮,在弹窗中选择新状态(正常/维修中/已报废/闲置)后确定。维修中改回正常时会自动写入一条“维修完成”记录。</li>
|
||||||
|
<li><strong>查看记录:</strong>点击“记录”按钮可查看该设备的报修、维修完成、报废等历史记录。</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>6.7 报废管理(仅管理员)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>报废:</strong>点击操作列的“报废”按钮,填写报废原因后确认。设备状态变为“已报废”,并记录报废日期与操作人。</li>
|
||||||
|
<li><strong>恢复:</strong>已报废设备可点击“恢复”撤销报废,状态回到“闲置”。</li>
|
||||||
|
<li><strong>筛选查看:</strong>可通过顶部“设备状态”筛选器选择“已报废”查看所有报废设备。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>七、人员管理</h2>
|
<h2>七、人员管理</h2>
|
||||||
@@ -129,6 +137,17 @@ function Guide() {
|
|||||||
</ul>
|
</ul>
|
||||||
<div className="warning">注意:普通账户登录后,界面上仅显示其有权限的操作按钮,后端也会做权限校验。</div>
|
<div className="warning">注意:普通账户登录后,界面上仅显示其有权限的操作按钮,后端也会做权限校验。</div>
|
||||||
|
|
||||||
|
<h3>7.7 账户管理模块(仅管理员)</h3>
|
||||||
|
<p>管理员在左侧菜单可进入“账户管理”页面,集中管理所有登录账户:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>创建账户:</strong>可创建不关联人员的独立账户,指定用户名、密码和角色</li>
|
||||||
|
<li><strong>编辑:</strong>修改账户的用户名、显示名和角色(不能降级最后一个管理员)</li>
|
||||||
|
<li><strong>重置密码:</strong>忘记密码时由管理员直接重置</li>
|
||||||
|
<li><strong>删除:</strong>删除账户(不能删除当前登录账户和最后一个管理员),被删除账户的在线会话会立即失效</li>
|
||||||
|
<li><strong>操作日志:</strong>页面下方展示账户的创建、修改、删除、重置密码等审计记录</li>
|
||||||
|
</ul>
|
||||||
|
<div className="tip">任何用户都可以点击右上角头像菜单中的“修改密码”来自行修改自己的登录密码。</div>
|
||||||
|
|
||||||
<h2>八、通知管理</h2>
|
<h2>八、通知管理</h2>
|
||||||
<h3>8.1 查看通知列表</h3>
|
<h3>8.1 查看通知列表</h3>
|
||||||
<p>进入"通知管理"页面后,可以看到所有发布的通知,包含标题、类型、优先级、阅读状态等信息。</p>
|
<p>进入"通知管理"页面后,可以看到所有发布的通知,包含标题、类型、优先级、阅读状态等信息。</p>
|
||||||
@@ -139,7 +158,7 @@ function Guide() {
|
|||||||
<li>填写通知标题、内容</li>
|
<li>填写通知标题、内容</li>
|
||||||
<li>选择通知类型(通知、公告、提醒)</li>
|
<li>选择通知类型(通知、公告、提醒)</li>
|
||||||
<li>选择优先级(普通、高)</li>
|
<li>选择优先级(普通、高)</li>
|
||||||
<li>选择目标范围(全部、技术部、行政部等)</li>
|
<li>选择目标范围(全部、拍摄组、后勤组、监督组)</li>
|
||||||
<li>点击"确定"发布</li>
|
<li>点击"确定"发布</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
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 axios from 'axios'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
|
||||||
|
// 移动端检测
|
||||||
|
function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768)
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => setIsMobile(window.innerWidth < 768)
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
return () => window.removeEventListener('resize', onResize)
|
||||||
|
}, [])
|
||||||
|
return isMobile
|
||||||
|
}
|
||||||
|
|
||||||
|
function Messages({ user }) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [conversations, setConversations] = useState([])
|
||||||
|
const [allUsers, setAllUsers] = useState([])
|
||||||
|
const [activeConv, setActiveConv] = useState(null)
|
||||||
|
const [messages, setMessages] = useState([])
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [createVisible, setCreateVisible] = useState(false)
|
||||||
|
const [createForm] = Form.useForm()
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const bottomRef = useRef(null)
|
||||||
|
const activeConvRef = useRef(null)
|
||||||
|
const lastMsgIdRef = useRef(0)
|
||||||
|
|
||||||
|
// 打开会话时同步到 ref(供轮询闭包使用)
|
||||||
|
const openConversation = (conv) => {
|
||||||
|
setActiveConv(conv)
|
||||||
|
activeConvRef.current = conv
|
||||||
|
lastMsgIdRef.current = 0
|
||||||
|
setMessages([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeConversation = () => {
|
||||||
|
setActiveConv(null)
|
||||||
|
activeConvRef.current = null
|
||||||
|
setMessages([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchConversations = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/chat/conversations')
|
||||||
|
if (res.data.success) setConversations(res.data.data)
|
||||||
|
} catch (err) { /* 静默 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/api/chat/users')
|
||||||
|
if (res.data.success) setAllUsers(res.data.data)
|
||||||
|
} catch (err) { /* 静默 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取当前打开会话的消息(首次全量,之后增量)
|
||||||
|
const fetchMessages = async (conv) => {
|
||||||
|
if (!conv) return
|
||||||
|
try {
|
||||||
|
const after = lastMsgIdRef.current
|
||||||
|
const res = await axios.get(`/api/chat/conversations/${conv.id}/messages`, { params: after ? { after } : {} })
|
||||||
|
if (res.data.success) {
|
||||||
|
const rows = res.data.data
|
||||||
|
if (rows.length > 0) {
|
||||||
|
lastMsgIdRef.current = rows[rows.length - 1].id
|
||||||
|
setMessages(prev => after > 0 ? [...prev, ...rows] : rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) { /* 静默 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会话列表 5 秒轮询;打开的会话 3 秒轮询消息
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConversations()
|
||||||
|
fetchUsers()
|
||||||
|
const convTimer = setInterval(fetchConversations, 5000)
|
||||||
|
const msgTimer = setInterval(() => fetchMessages(activeConvRef.current), 3000)
|
||||||
|
return () => { clearInterval(convTimer); clearInterval(msgTimer) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 新消息自动滚动到底部
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}, [messages.length])
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const content = inputValue.trim()
|
||||||
|
if (!content || !activeConv || sending) return
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`/api/chat/conversations/${activeConv.id}/messages`, { content })
|
||||||
|
if (res.data.success) {
|
||||||
|
setInputValue('')
|
||||||
|
await fetchMessages(activeConvRef.current)
|
||||||
|
fetchConversations()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err.response?.data?.message || '发送失败')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async (values) => {
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const payload = values.tab === 'group'
|
||||||
|
? { type: 'group', name: values.name, memberIds: values.memberIds }
|
||||||
|
: { type: 'direct', userId: values.userId }
|
||||||
|
const res = await axios.post('/api/chat/conversations', payload)
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(res.data.message)
|
||||||
|
setCreateVisible(false)
|
||||||
|
createForm.resetFields()
|
||||||
|
await fetchConversations()
|
||||||
|
// 自动打开新会话
|
||||||
|
const list = (await axios.get('/api/chat/conversations')).data
|
||||||
|
if (list.success) {
|
||||||
|
const conv = list.data.find(c => c.id === res.data.data.id)
|
||||||
|
if (conv) openConversation(conv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err.response?.data?.message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConv = async (conv) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.delete(`/api/chat/conversations/${conv.id}`)
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(res.data.message)
|
||||||
|
if (activeConv?.id === conv.id) closeConversation()
|
||||||
|
fetchConversations()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err.response?.data?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDelete = (conv) => conv.type === 'direct' || conv.creator_id === user?.id
|
||||||
|
|
||||||
|
const otherOptions = allUsers.filter(u => u.id !== user?.id).map(u => ({
|
||||||
|
value: u.id,
|
||||||
|
label: (
|
||||||
|
<span>
|
||||||
|
<Badge status={u.is_online ? 'success' : 'default'} />
|
||||||
|
{u.display_name || u.username}
|
||||||
|
<span style={{ color: '#999', fontSize: 12 }}>({u.username})</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ============ 会话列表 ============
|
||||||
|
const conversationList = (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<span style={{ fontWeight: 500 }}>我的会话</span>
|
||||||
|
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); setCreateVisible(true) }}>
|
||||||
|
发起对话 / 建群
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{conversations.length === 0 ? (
|
||||||
|
<Empty description="暂无会话,点击右上角发起对话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={conversations}
|
||||||
|
renderItem={(conv) => (
|
||||||
|
<List.Item
|
||||||
|
className={`conv-item ${activeConv?.id === conv.id ? 'conv-item-active' : ''}`}
|
||||||
|
onClick={() => openConversation(conv)}
|
||||||
|
style={{ cursor: 'pointer', padding: '10px 8px' }}
|
||||||
|
extra={
|
||||||
|
<Space size={4}>
|
||||||
|
{conv.unread > 0 && <Badge count={conv.unread} />}
|
||||||
|
{canDelete(conv) && (
|
||||||
|
<Popconfirm
|
||||||
|
title={conv.type === 'group' ? '确定解散该群组吗?' : '确定删除该会话吗?'}
|
||||||
|
onConfirm={(e) => { e?.stopPropagation(); handleDeleteConv(conv) }}
|
||||||
|
onCancel={(e) => e?.stopPropagation()}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={
|
||||||
|
<Avatar
|
||||||
|
style={{ backgroundColor: conv.type === 'group' ? '#faad14' : '#7c3aed' }}
|
||||||
|
icon={conv.type === 'group' ? <TeamOutlined /> : <UserOutlined />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
{conv.name || '未命名'}
|
||||||
|
{conv.type === 'group' && <Tag style={{ marginLeft: 8 }}>{conv.member_count}人</Tag>}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
conv.last_message
|
||||||
|
? `${conv.last_message.sender_name}:${conv.last_message.content.slice(0, 20)}`
|
||||||
|
: '暂无消息'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============ 聊天窗口 ============
|
||||||
|
const chatWindow = activeConv ? (
|
||||||
|
<div className="chat-window">
|
||||||
|
<div className="chat-header">
|
||||||
|
{isMobile && (
|
||||||
|
<Button type="text" icon={<ArrowLeftOutlined />} onClick={closeConversation} style={{ marginRight: 4 }} />
|
||||||
|
)}
|
||||||
|
<span style={{ fontWeight: 500 }}>{activeConv.name || '未命名'}</span>
|
||||||
|
{activeConv.type === 'group' && (
|
||||||
|
<Tooltip title={activeConv.members.map(m => m.display_name || m.username).join('、')}>
|
||||||
|
<Tag style={{ marginLeft: 8 }}>{activeConv.member_count}人</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</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
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
<div className="chat-input">
|
||||||
|
<TextArea
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onPressEnter={(e) => { if (!e.shiftKey) { e.preventDefault(); handleSend() } }}
|
||||||
|
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||||
|
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<SendOutlined />} loading={sending} onClick={handleSend}>发送</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="chat-placeholder">
|
||||||
|
<Empty description="选择一个会话开始聊天" />
|
||||||
|
</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 (
|
||||||
|
<Modal
|
||||||
|
title="发起对话"
|
||||||
|
open={createVisible}
|
||||||
|
onCancel={() => setCreateVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ tab: 'direct' }}>
|
||||||
|
<Form.Item name="tab" noStyle>
|
||||||
|
<Tabs
|
||||||
|
centered
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'direct',
|
||||||
|
label: '单聊',
|
||||||
|
children: (
|
||||||
|
<Form.Item name="userId" label="选择对话对象" rules={[{ required: true, message: '请选择对话对象' }]}>
|
||||||
|
<Select placeholder="选择用户(绿点为在线)" options={otherOptions} showSearch optionFilterProp="label" />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'group',
|
||||||
|
label: '创建群聊',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Form.Item name="name" label="群名称" rules={[{ required: true, message: '请输入群名称' }]}>
|
||||||
|
<Input placeholder="例如:拍摄组工作群" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="memberIds" label="群成员(除自己外至少选1人)" rules={[{ required: true, message: '请选择群成员' }]}>
|
||||||
|
<Select mode="multiple" placeholder="选择群成员" options={otherOptions} showSearch optionFilterProp="label" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setCreateVisible(false)}>取消</Button>
|
||||||
|
<Button type="primary" htmlType="submit" loading={creating}>确定</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Messages
|
||||||
@@ -9,7 +9,7 @@ const { TextArea } = Input
|
|||||||
|
|
||||||
const notifTypes = ['通知', '公告', '提醒']
|
const notifTypes = ['通知', '公告', '提醒']
|
||||||
const priorities = ['普通', '高']
|
const priorities = ['普通', '高']
|
||||||
const targets = ['全部', '技术部', '行政部', '财务部', '市场部', '人事部', '运营部']
|
const targets = ['全部', '拍摄组', '后勤组', '监督组']
|
||||||
|
|
||||||
function NotificationManagement({ user }) {
|
function NotificationManagement({ user }) {
|
||||||
const manageAllowed = can(user, 'notification:manage')
|
const manageAllowed = can(user, 'notification:manage')
|
||||||
@@ -95,6 +95,18 @@ function NotificationManagement({ user }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClearRead = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.delete('/api/notifications/read/clear')
|
||||||
|
if (res.data.success) {
|
||||||
|
message.success(res.data.message)
|
||||||
|
fetchNotifications()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err.response?.data?.message || '清空失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields()
|
const values = await form.validateFields()
|
||||||
@@ -246,6 +258,11 @@ function NotificationManagement({ user }) {
|
|||||||
全部已读 ({unreadCount})
|
全部已读 ({unreadCount})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{manageAllowed && (
|
||||||
|
<Popconfirm title="确定要清空所有已读通知吗?" onConfirm={handleClearRead} okText="确定" cancelText="取消">
|
||||||
|
<Button danger size="small">清空已读</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
{manageAllowed && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>发布通知</Button>}
|
{manageAllowed && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>发布通知</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+38
@@ -86,6 +86,10 @@ function initDatabase() {
|
|||||||
if (!userCols.some(c => c.name === 'personnel_id')) {
|
if (!userCols.some(c => c.name === 'personnel_id')) {
|
||||||
db.exec('ALTER TABLE users ADD COLUMN personnel_id TEXT');
|
db.exec('ALTER TABLE users ADD COLUMN personnel_id TEXT');
|
||||||
}
|
}
|
||||||
|
// 用户表增加最后登录时间字段(用于在线状态展示)
|
||||||
|
if (!userCols.some(c => c.name === 'last_login')) {
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN last_login TEXT');
|
||||||
|
}
|
||||||
|
|
||||||
// 设备表增加报废相关字段
|
// 设备表增加报废相关字段
|
||||||
const deviceCols = db.prepare('PRAGMA table_info(devices)').all();
|
const deviceCols = db.prepare('PRAGMA table_info(devices)').all();
|
||||||
@@ -133,6 +137,40 @@ function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// 会话表(单聊 direct / 群聊 group)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT DEFAULT '',
|
||||||
|
creator_id TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 会话成员表(last_read_message_id 用于未读计数)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT DEFAULT 'member',
|
||||||
|
last_read_message_id INTEGER DEFAULT 0,
|
||||||
|
joined_at TEXT DEFAULT (datetime('now', 'localtime')),
|
||||||
|
PRIMARY KEY (conversation_id, user_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 消息表(自增 id 兼作排序游标)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
sender_id TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now', 'localtime'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// 插入示例数据
|
// 插入示例数据
|
||||||
const deviceCount = db.prepare('SELECT COUNT(*) as count FROM devices').get();
|
const deviceCount = db.prepare('SELECT COUNT(*) as count FROM devices').get();
|
||||||
if (deviceCount.count === 0) {
|
if (deviceCount.count === 0) {
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
// Download electron prebuild tarball via node (follows redirects)
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const url = process.argv[2];
|
||||||
|
const out = process.argv[3];
|
||||||
|
|
||||||
|
function download(u, cb) {
|
||||||
|
https.get(u, { headers: { 'User-Agent': 'node' } }, (res) => {
|
||||||
|
if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
|
||||||
|
return download(res.headers.location, cb);
|
||||||
|
}
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return cb(new Error('HTTP ' + res.statusCode));
|
||||||
|
}
|
||||||
|
const ws = fs.createWriteStream(out);
|
||||||
|
res.pipe(ws);
|
||||||
|
ws.on('finish', () => ws.close(() => cb(null)));
|
||||||
|
ws.on('error', cb);
|
||||||
|
}).on('error', cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
download(url, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('download failed:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('downloaded:', out, fs.statSync(out).size, 'bytes');
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
+7
-8
@@ -1,6 +1,9 @@
|
|||||||
const { app, BrowserWindow } = require('electron');
|
const { app, BrowserWindow } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
// 远程服务器地址(数据与此服务器同步)
|
||||||
|
const SERVER_URL = 'http://47.104.244.29:3001';
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
@@ -10,7 +13,7 @@ function createWindow() {
|
|||||||
minWidth: 1024,
|
minWidth: 1024,
|
||||||
minHeight: 680,
|
minHeight: 680,
|
||||||
title: '影视星河设备管理系统',
|
title: '影视星河设备管理系统',
|
||||||
icon: path.join(__dirname, 'icon.ico'),
|
icon: path.join(__dirname, 'icon.png'),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true
|
contextIsolation: true
|
||||||
@@ -18,8 +21,8 @@ function createWindow() {
|
|||||||
autoHideMenuBar: true
|
autoHideMenuBar: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// 加载本地服务器
|
// 加载远程服务器页面
|
||||||
mainWindow.loadURL('http://localhost:3001');
|
mainWindow.loadURL(SERVER_URL);
|
||||||
|
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
@@ -27,11 +30,7 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
// 启动后端服务器
|
createWindow();
|
||||||
require('../server.js');
|
|
||||||
|
|
||||||
// 等待服务器启动后创建窗口
|
|
||||||
setTimeout(createWindow, 1500);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
|||||||
Generated
+614
-1849
File diff suppressed because it is too large
Load Diff
+9
-8
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "device-management-system",
|
"name": "device-management-system",
|
||||||
"version": "1.0.0",
|
"version": "2.1.0",
|
||||||
"description": "影视星河设备管理系统 - Device Management System",
|
"description": "影视星河设备管理系统 - Device Management System",
|
||||||
"main": "electron/main.js",
|
"main": "electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"electron:build:linux": "npm run build && electron-builder --linux"
|
"electron:build:linux": "npm run build && electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^12.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
@@ -23,22 +23,23 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"electron": "^28.1.0",
|
"electron": "42.8.1",
|
||||||
"electron-builder": "^24.9.1",
|
"electron-builder": "^24.9.1",
|
||||||
"vite": "^8.2.1",
|
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.devicemanager.app",
|
"appId": "com.devicemanager.app",
|
||||||
"productName": "影视星河设备管理系统",
|
"productName": "影视星河设备管理系统",
|
||||||
|
"npmRebuild": false,
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron"
|
"output": "dist-electron"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"server.js",
|
|
||||||
"database.js",
|
|
||||||
"electron/**/*",
|
"electron/**/*",
|
||||||
"client/dist/**/*"
|
"!node_modules/**",
|
||||||
|
"!server.js",
|
||||||
|
"!database.js",
|
||||||
|
"!client/**"
|
||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icon": "electron/icon.ico"
|
"icon": "electron/icon.png"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
|
|||||||
@@ -9,6 +9,40 @@ const PORT = process.env.PORT || 3001;
|
|||||||
|
|
||||||
// 内存会话存储
|
// 内存会话存储
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
// 会话最近活跃时间(token -> 时间戳),用于判断在线状态
|
||||||
|
const sessionActivity = new Map();
|
||||||
|
// 最近活跃时间在此窗口内视为在线
|
||||||
|
const ONLINE_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
function touchSession(token) {
|
||||||
|
sessionActivity.set(token, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropSession(token) {
|
||||||
|
sessions.delete(token);
|
||||||
|
sessionActivity.delete(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集当前在线用户(用户ID -> 最近活跃时间戳,取最新)
|
||||||
|
function getOnlineUserMap() {
|
||||||
|
const now = Date.now();
|
||||||
|
const map = new Map();
|
||||||
|
for (const [token, sessionUser] of sessions.entries()) {
|
||||||
|
const active = sessionActivity.get(token) || 0;
|
||||||
|
if (now - active <= ONLINE_WINDOW_MS) {
|
||||||
|
if (!map.has(sessionUser.id) || active > map.get(sessionUser.id)) {
|
||||||
|
map.set(sessionUser.id, active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActive(ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 登录验证中间件(仅对 /api 路由生效)
|
// 登录验证中间件(仅对 /api 路由生效)
|
||||||
function authMiddleware(req, res, next) {
|
function authMiddleware(req, res, next) {
|
||||||
@@ -21,6 +55,7 @@ function authMiddleware(req, res, next) {
|
|||||||
return res.status(401).json({ success: false, message: '未登录或登录已过期' });
|
return res.status(401).json({ success: false, message: '未登录或登录已过期' });
|
||||||
}
|
}
|
||||||
req.user = sessions.get(token);
|
req.user = sessions.get(token);
|
||||||
|
touchSession(token);
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +557,7 @@ function logUserAction(username, action, detail, operator) {
|
|||||||
app.get('/api/users', requireAdmin, (req, res) => {
|
app.get('/api/users', requireAdmin, (req, res) => {
|
||||||
const { keyword, role } = req.query;
|
const { keyword, role } = req.query;
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT u.id, u.username, u.role, u.display_name, u.created_at, u.personnel_id, p.name AS personnel_name, p.department
|
SELECT u.id, u.username, u.role, u.display_name, u.created_at, u.last_login, u.personnel_id, p.name AS personnel_name, p.department
|
||||||
FROM users u LEFT JOIN personnel p ON u.personnel_id = p.id
|
FROM users u LEFT JOIN personnel p ON u.personnel_id = p.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
`;
|
`;
|
||||||
@@ -536,7 +571,12 @@ app.get('/api/users', requireAdmin, (req, res) => {
|
|||||||
params.push(role);
|
params.push(role);
|
||||||
}
|
}
|
||||||
sql += ' ORDER BY u.created_at ASC';
|
sql += ' ORDER BY u.created_at ASC';
|
||||||
res.json({ success: true, data: db.prepare(sql).all(...params) });
|
const onlineMap = getOnlineUserMap();
|
||||||
|
const data = db.prepare(sql).all(...params).map(u => {
|
||||||
|
const active = onlineMap.get(u.id);
|
||||||
|
return { ...u, is_online: !!active, last_active: active ? formatActive(active) : null };
|
||||||
|
});
|
||||||
|
res.json({ success: true, data });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 账户操作日志(管理员)
|
// 账户操作日志(管理员)
|
||||||
@@ -550,7 +590,8 @@ app.get('/api/users/stats/summary', requireAdmin, (req, res) => {
|
|||||||
const total = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
const total = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||||
const admins = db.prepare("SELECT COUNT(*) as count FROM users WHERE role='admin'").get().count;
|
const admins = db.prepare("SELECT COUNT(*) as count FROM users WHERE role='admin'").get().count;
|
||||||
const users = total - admins;
|
const users = total - admins;
|
||||||
res.json({ success: true, data: { total, admins, users } });
|
const online = getOnlineUserMap().size;
|
||||||
|
res.json({ success: true, data: { total, admins, users, online } });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 创建独立账户(管理员)
|
// 创建独立账户(管理员)
|
||||||
@@ -640,7 +681,7 @@ app.delete('/api/users/:id', requireAdmin, (req, res) => {
|
|||||||
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
|
||||||
// 使关联的会话失效
|
// 使关联的会话失效
|
||||||
for (const [token, sessionUser] of sessions.entries()) {
|
for (const [token, sessionUser] of sessions.entries()) {
|
||||||
if (sessionUser.id === target.id) sessions.delete(token);
|
if (sessionUser.id === target.id) dropSession(token);
|
||||||
}
|
}
|
||||||
logUserAction(target.username, '删除', '账户已删除', getOperator(req));
|
logUserAction(target.username, '删除', '账户已删除', getOperator(req));
|
||||||
res.json({ success: true, message: '账户删除成功' });
|
res.json({ success: true, message: '账户删除成功' });
|
||||||
@@ -652,6 +693,180 @@ app.delete('/api/users/logs/clear', requireAdmin, (req, res) => {
|
|||||||
res.json({ success: true, message: '账户日志已清空' });
|
res.json({ success: true, message: '账户日志已清空' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 消息对话 API ====================
|
||||||
|
|
||||||
|
// 校验当前用户是否为会话成员
|
||||||
|
function isConversationMember(convId, userId) {
|
||||||
|
return !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id = ? AND user_id = ?').get(convId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserDisplayName(userId) {
|
||||||
|
const u = db.prepare('SELECT display_name, username FROM users WHERE id = ?').get(userId);
|
||||||
|
return u ? (u.display_name || u.username) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部用户简表(选择聊天对象/建群成员,含在线状态)
|
||||||
|
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 onlineMap = getOnlineUserMap();
|
||||||
|
const data = rows.map(u => ({ ...u, is_online: onlineMap.has(u.id) }));
|
||||||
|
res.json({ success: true, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当前用户的会话列表(含最后一条消息与未读数)
|
||||||
|
app.get('/api/chat/conversations', (req, res) => {
|
||||||
|
const me = req.user.id;
|
||||||
|
const convs = db.prepare(`
|
||||||
|
SELECT c.id, c.type, c.name, c.creator_id, c.created_at, m.last_read_message_id
|
||||||
|
FROM conversations c JOIN conversation_members m ON m.conversation_id = c.id
|
||||||
|
WHERE m.user_id = ?
|
||||||
|
`).all(me);
|
||||||
|
const data = convs.map(c => {
|
||||||
|
const members = db.prepare(`
|
||||||
|
SELECT u.id, u.display_name, u.username
|
||||||
|
FROM conversation_members m JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.conversation_id = ?
|
||||||
|
`).all(c.id);
|
||||||
|
const last = db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT 1').get(c.id);
|
||||||
|
const unread = db.prepare('SELECT COUNT(*) as count FROM messages WHERE conversation_id = ? AND id > ? AND sender_id != ?')
|
||||||
|
.get(c.id, c.last_read_message_id || 0, me).count;
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
type: c.type,
|
||||||
|
name: c.type === 'group' ? c.name : ((members.find(x => x.id !== me) || {}).display_name || ''),
|
||||||
|
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,
|
||||||
|
unread
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// 按最后消息时间倒序(无消息的按创建时间)
|
||||||
|
data.sort((a, b) => (b.last_message?.created_at || b.created_at || '').localeCompare(a.last_message?.created_at || a.created_at || ''));
|
||||||
|
res.json({ success: true, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建会话:单聊(type=direct + userId,已有则复用)或群聊(type=group + name + memberIds)
|
||||||
|
app.post('/api/chat/conversations', (req, res) => {
|
||||||
|
const { type, userId, name, memberIds } = req.body;
|
||||||
|
const me = req.user;
|
||||||
|
|
||||||
|
if (type === 'direct') {
|
||||||
|
if (!userId || userId === me.id) {
|
||||||
|
return res.status(400).json({ success: false, message: '请选择对话对象' });
|
||||||
|
}
|
||||||
|
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
|
||||||
|
if (!target) {
|
||||||
|
return res.status(404).json({ success: false, message: '用户不存在' });
|
||||||
|
}
|
||||||
|
// 复用已有的单聊会话
|
||||||
|
const existing = db.prepare(`
|
||||||
|
SELECT c.id FROM conversations c WHERE c.type = 'direct'
|
||||||
|
AND EXISTS (SELECT 1 FROM conversation_members m WHERE m.conversation_id = c.id AND m.user_id = ?)
|
||||||
|
AND EXISTS (SELECT 1 FROM conversation_members m WHERE m.conversation_id = c.id AND m.user_id = ?)
|
||||||
|
`).get(me.id, userId);
|
||||||
|
if (existing) {
|
||||||
|
return res.json({ success: true, data: { id: existing.id }, message: '会话已存在' });
|
||||||
|
}
|
||||||
|
const id = uuidv4();
|
||||||
|
db.prepare("INSERT INTO conversations (id, type, creator_id) VALUES (?, 'direct', ?)").run(id, me.id);
|
||||||
|
const insMember = db.prepare('INSERT INTO conversation_members (conversation_id, user_id, role) VALUES (?, ?, ?)');
|
||||||
|
insMember.run(id, me.id, 'member');
|
||||||
|
insMember.run(id, userId, 'member');
|
||||||
|
return res.json({ success: true, data: { id }, message: '会话创建成功' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'group') {
|
||||||
|
if (!name || !String(name).trim()) {
|
||||||
|
return res.status(400).json({ success: false, message: '请输入群名称' });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(memberIds) || memberIds.filter(id => id !== me.id).length < 1) {
|
||||||
|
return res.status(400).json({ success: false, message: '请至少选择1名其他群成员' });
|
||||||
|
}
|
||||||
|
const id = uuidv4();
|
||||||
|
db.prepare("INSERT INTO conversations (id, type, name, creator_id) VALUES (?, 'group', ?, ?)").run(id, String(name).trim(), me.id);
|
||||||
|
const insMember = db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id, user_id, role) VALUES (?, ?, ?)');
|
||||||
|
insMember.run(id, me.id, 'owner');
|
||||||
|
for (const uid of memberIds) {
|
||||||
|
if (uid !== me.id && db.prepare('SELECT id FROM users WHERE id = ?').get(uid)) {
|
||||||
|
insMember.run(id, uid, 'member');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logUserAction(me.username, '创建群组', `创建群组:${String(name).trim()}`, me.display_name || me.username);
|
||||||
|
return res.json({ success: true, data: { id }, message: '群组创建成功' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(400).json({ success: false, message: '不支持的会话类型' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取会话消息(默认最新 100 条;after 游标增量拉取),同时标记已读
|
||||||
|
app.get('/api/chat/conversations/:id/messages', (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 after = parseInt(req.query.after) || 0;
|
||||||
|
const limit = Math.min(parseInt(req.query.limit) || 100, 200);
|
||||||
|
let rows;
|
||||||
|
if (after > 0) {
|
||||||
|
rows = db.prepare('SELECT * FROM messages WHERE conversation_id = ? AND id > ? ORDER BY id ASC LIMIT ?').all(req.params.id, after, limit);
|
||||||
|
} else {
|
||||||
|
rows = db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT ?').all(req.params.id, limit).reverse();
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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 字)' });
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
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 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除/解散会话(群聊仅群主可解散,单聊任一方可删除)
|
||||||
|
app.delete('/api/chat/conversations/:id', (req, res) => {
|
||||||
|
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
|
||||||
|
if (!conv) {
|
||||||
|
return res.status(404).json({ success: false, message: '会话不存在' });
|
||||||
|
}
|
||||||
|
if (!isConversationMember(req.params.id, req.user.id)) {
|
||||||
|
return res.status(403).json({ success: false, message: '您不是该会话的成员' });
|
||||||
|
}
|
||||||
|
if (conv.type === 'group' && conv.creator_id !== req.user.id) {
|
||||||
|
return res.status(403).json({ success: false, message: '只有群主可以解散群组' });
|
||||||
|
}
|
||||||
|
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' ? '群组已解散' : '会话已删除' });
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== 认证 API ====================
|
// ==================== 认证 API ====================
|
||||||
|
|
||||||
// 登录
|
// 登录
|
||||||
@@ -668,6 +883,9 @@ app.post('/api/auth/login', (req, res) => {
|
|||||||
|
|
||||||
const token = uuidv4();
|
const token = uuidv4();
|
||||||
sessions.set(token, user);
|
sessions.set(token, user);
|
||||||
|
touchSession(token);
|
||||||
|
// 记录最后登录时间
|
||||||
|
db.prepare("UPDATE users SET last_login=datetime('now','localtime') WHERE id=?").run(user.id);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -688,7 +906,7 @@ app.get('/api/auth/me', (req, res) => {
|
|||||||
// 登出
|
// 登出
|
||||||
app.post('/api/auth/logout', (req, res) => {
|
app.post('/api/auth/logout', (req, res) => {
|
||||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
if (token) sessions.delete(token);
|
if (token) dropSession(token);
|
||||||
res.json({ success: true, message: '登出成功' });
|
res.json({ success: true, message: '登出成功' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -704,7 +922,10 @@ app.patch('/api/auth/password', (req, res) => {
|
|||||||
}
|
}
|
||||||
db.prepare('UPDATE users SET password=? WHERE id=?').run(newPassword, req.user.id);
|
db.prepare('UPDATE users SET password=? WHERE id=?').run(newPassword, req.user.id);
|
||||||
logUserAction(dbUser.username, '修改密码', '用户自行修改密码', dbUser.display_name || dbUser.username);
|
logUserAction(dbUser.username, '修改密码', '用户自行修改密码', dbUser.display_name || dbUser.username);
|
||||||
res.json({ success: true, message: '密码修改成功' });
|
// 修改成功后使当前会话失效,要求用新密码重新登录
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (token) dropSession(token);
|
||||||
|
res.json({ success: true, message: '密码修改成功,请使用新密码重新登录' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==================== 天气 API ====================
|
// ==================== 天气 API ====================
|
||||||
|
|||||||
Reference in New Issue
Block a user