This commit is contained in:
mei
2026-08-11 10:03:40 +08:00
parent 2086bd79cc
commit fb326279fa
25 changed files with 2590 additions and 2379 deletions
+57
View File
@@ -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'
-65
View File
@@ -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
-52
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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
-44
View File
@@ -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
+312 -310
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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>
) )
} }
+2 -2
View File
@@ -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' }} />
} }
+144
View File
@@ -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%;
}
}
+15 -2
View File
@@ -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>
) )
+330
View File
@@ -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
+1 -1
View File
@@ -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}>
+166 -14
View File
@@ -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,25 +88,25 @@ 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()
const res = await axios.patch(`/api/devices/${repairDevice.id}/repair`, { remark: values.remark })
if (res.data.success) { if (res.data.success) {
message.success(res.data.message) message.success(res.data.message)
setRepairModalVisible(false)
fetchDevices() fetchDevices()
} }
} catch (err) { } catch (err) {
message.error(err.response?.data?.message || '报修失败') if (err.response) message.error(err.response.data?.message || '报修失败')
} }
} }
})
}
// 管理员:打开修改维修状态弹窗 // 管理员:打开修改维修状态弹窗
const handleOpenStatus = (record) => { const handleOpenStatus = (record) => {
@@ -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>
) )
} }
+22 -3
View File
@@ -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>
+349
View File
@@ -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
+18 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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');
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

+7 -8
View File
@@ -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', () => {
+614 -1849
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -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,
+227 -6
View File
@@ -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 ====================