first commit

This commit is contained in:
mei
2026-08-10 22:56:38 +08:00
commit 2086bd79cc
32 changed files with 14110 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist-electron/
data/
*.db
client/dist/
client/node_modules/
client/android/
client/ios/
+65
View File
@@ -0,0 +1,65 @@
@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
@@ -0,0 +1,52 @@
@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
+44
View File
@@ -0,0 +1,44 @@
@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
+20
View File
@@ -0,0 +1,20 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.galaxy.devicemanager',
appName: '影视星河设备管理',
webDir: 'dist',
server: {
// App 内不内置服务器地址,用户在登录页配置服务器地址
androidScheme: 'https'
},
plugins: {
SplashScreen: {
launchShowDuration: 1500,
backgroundColor: '#1a1a2e',
showSpinner: false
}
}
};
export default config;
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#7c3aed" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="设备管理" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/png" href="/assets/logo-icon.png" />
<link rel="apple-touch-icon" href="/assets/logo-icon.png" />
<title>影视星河 · 设备管理系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script>
// 注册 Service Worker(PWA 支持)
if ('serviceWorker' in navigator && location.protocol !== 'file:') {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>
+4287
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "device-management-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"cap:add:android": "capacitor add android",
"cap:add:ios": "capacitor add ios",
"cap:sync": "capacitor sync",
"cap:open:android": "capacitor open android",
"cap:run:android": "capacitor run android",
"build:android": "vite build && capacitor sync android"
},
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@capacitor/android": "^5.7.0",
"@capacitor/cli": "^5.7.0",
"@capacitor/core": "^5.7.0",
"@capacitor/ios": "^5.7.0",
"antd": "^5.12.8",
"axios": "^1.6.5",
"dayjs": "^1.11.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.12"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"name": "影视星河设备管理系统",
"short_name": "设备管理",
"description": "影视星河设备管理系统 - 设备、人员、通知一站式管理",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#7c3aed",
"orientation": "portrait",
"lang": "zh-CN",
"icons": [
{
"src": "/assets/logo-icon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/logo-icon.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
+50
View File
@@ -0,0 +1,50 @@
/* 简易 Service Worker:缓存静态资源,支持离线访问与 PWA 安装 */
const CACHE_NAME = 'dms-cache-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.webmanifest',
'/assets/logo-icon.png',
'/assets/logo-white.png',
'/assets/logo-dark.png'
];
// 安装阶段:预缓存静态资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
// 请求阶段:API 走网络,静态资源优先缓存
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// API 请求不缓存
if (url.pathname.startsWith('/api')) return;
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
// 只缓存同源 GET 静态资源
if (event.request.method === 'GET' && url.origin === self.location.origin) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
});
}).catch(() => caches.match('/index.html'))
);
});
+252
View File
@@ -0,0 +1,252 @@
import React, { useState, useEffect } from 'react'
import { Layout, Menu, theme, Button, Space, Dropdown, message, Drawer, Avatar } from 'antd'
import {
DashboardOutlined,
DesktopOutlined,
TeamOutlined,
BellOutlined,
InfoCircleOutlined,
BookOutlined,
MenuOutlined,
LogoutOutlined,
UserOutlined,
DownOutlined
} from '@ant-design/icons'
import axios from 'axios'
import Login from './pages/Login'
import Weather from './components/Weather'
import Dashboard from './pages/Dashboard'
import DeviceManagement from './pages/DeviceManagement'
import PersonnelManagement from './pages/PersonnelManagement'
import NotificationManagement from './pages/NotificationManagement'
import About from './pages/About'
import Guide from './pages/Guide'
const { Header, Sider, Content } = Layout
// 移动端检测 Hook
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 App() {
const isMobile = useIsMobile()
const [collapsed, setCollapsed] = useState(false)
const [drawerVisible, setDrawerVisible] = useState(false)
const [currentPage, setCurrentPage] = useState('dashboard')
const [user, setUser] = useState(null)
const [checkingAuth, setCheckingAuth] = useState(true)
const {
token: { colorBgContainer, borderRadiusLG }
} = theme.useToken()
// 初始化时恢复登录态
useEffect(() => {
const token = localStorage.getItem('token')
const savedUser = localStorage.getItem('user')
if (token && savedUser) {
axios.get('/api/auth/me')
.then(res => {
if (res.data.success) setUser(res.data.data)
else handleLogout(false)
})
.catch(() => handleLogout(false))
.finally(() => setCheckingAuth(false))
} else {
setCheckingAuth(false)
}
// 监听登录过期
const onExpired = () => handleLogout(false)
window.addEventListener('auth-expired', onExpired)
return () => window.removeEventListener('auth-expired', onExpired)
}, [])
const handleLogin = (userData) => {
setUser(userData)
setCurrentPage('dashboard')
}
const handleLogout = async (callApi = true) => {
if (callApi) {
try { await axios.post('/api/auth/logout') } catch (err) { /* ignore */ }
}
localStorage.removeItem('token')
localStorage.removeItem('user')
setUser(null)
message.success('已安全退出')
}
const getGreeting = () => {
const hour = new Date().getHours()
if (hour < 6) return '晚上好'
if (hour < 9) return '早上好'
if (hour < 12) return '上午好'
if (hour < 14) return '中午好'
if (hour < 18) return '下午好'
return '晚上好'
}
const menuItems = [
{ key: 'dashboard', icon: <DashboardOutlined />, label: '系统首页' },
{ key: 'about', icon: <InfoCircleOutlined />, label: '软件简介' },
{ key: 'guide', icon: <BookOutlined />, label: '使用说明' },
{ type: 'divider' },
{ key: 'devices', icon: <DesktopOutlined />, label: '设备管理' },
{ key: 'personnel', icon: <TeamOutlined />, label: '人员管理' },
{ key: 'notifications', icon: <BellOutlined />, label: '通知管理' }
]
// 移动端底部导航(4个核心入口)
const bottomNavItems = [
{ key: 'dashboard', icon: <DashboardOutlined />, label: '首页' },
{ key: 'devices', icon: <DesktopOutlined />, label: '设备' },
{ key: 'personnel', icon: <TeamOutlined />, label: '人员' },
{ key: 'notifications', icon: <BellOutlined />, label: '通知' }
]
const renderPage = () => {
switch (currentPage) {
case 'dashboard': return <Dashboard onNavigate={setCurrentPage} />
case 'about': return <About />
case 'guide': return <Guide />
case 'devices': return <DeviceManagement user={user} />
case 'personnel': return <PersonnelManagement user={user} />
case 'notifications': return <NotificationManagement user={user} />
default: return <Dashboard onNavigate={setCurrentPage} />
}
}
const getPageTitle = () => {
const titles = {
dashboard: '系统首页', about: '软件简介', guide: '使用说明',
devices: '设备管理', personnel: '人员管理', notifications: '通知管理'
}
return titles[currentPage] || '系统首页'
}
const userMenuItems = [
{ key: 'role', label: `角色:${user?.role === 'admin' ? '管理员' : '普通用户'}`, icon: <UserOutlined /> },
{ type: 'divider' },
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, danger: true, onClick: () => handleLogout() }
]
if (checkingAuth) {
return <div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>加载中...</div>
}
if (!user) {
return <Login onLogin={handleLogin} />
}
const menuContent = (
<>
<div className="logo-area">
<img src="/assets/logo-white.png" alt="影视星河" className="logo-img" />
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[currentPage]}
items={menuItems}
onClick={({ key }) => { setCurrentPage(key); setDrawerVisible(false) }}
/>
</>
)
// ============ 移动端布局 ============
if (isMobile) {
return (
<Layout style={{ minHeight: '100vh' }}>
<Header className="mobile-header">
<Button type="text" icon={<MenuOutlined />} onClick={() => setDrawerVisible(true)} style={{ color: '#fff' }} />
<span className="mobile-title">{getPageTitle()}</span>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" trigger={['click']}>
<Avatar style={{ backgroundColor: '#7c3aed' }} icon={<UserOutlined />} />
</Dropdown>
</Header>
<div className="mobile-info-bar">
<span className="mobile-greeting">{user.display_name || user.username},{getGreeting()}!</span>
<Weather />
</div>
<Drawer
placement="left"
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
width={260}
styles={{ body: { padding: 0, background: '#001529' }, header: { display: 'none' } }}
>
<div style={{ background: '#001529', height: '100%' }}>{menuContent}</div>
</Drawer>
<Content className="mobile-content">
{renderPage()}
</Content>
<nav className="bottom-nav">
{bottomNavItems.map(item => (
<button
key={item.key}
className={`bottom-nav-item ${currentPage === item.key ? 'active' : ''}`}
onClick={() => setCurrentPage(item.key)}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</nav>
</Layout>
)
}
// ============ 桌面端布局 ============
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed} theme="dark">
<div className="logo-area">
{collapsed ? (
<img src="/assets/logo-icon.png" alt="logo" className="logo-icon" />
) : (
<img src="/assets/logo-white.png" alt="影视星河" className="logo-img" />
)}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[currentPage]}
items={menuItems}
onClick={({ key }) => setCurrentPage(key)}
/>
</Sider>
<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)' }}>
<span style={{ fontSize: 18, fontWeight: 500 }}>{getPageTitle()}</span>
<Space size="large" align="center">
<Weather />
<div className="greeting-text">{user.display_name || user.username},{getGreeting()}!</div>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{user.display_name || user.username}
<DownOutlined style={{ fontSize: 10 }} />
</Button>
</Dropdown>
</Space>
</Header>
<Content style={{ margin: 24, padding: 24, background: colorBgContainer, borderRadius: borderRadiusLG, minHeight: 280 }}>
{renderPage()}
</Content>
</Layout>
</Layout>
)
}
export default App
+69
View File
@@ -0,0 +1,69 @@
import axios from 'axios'
// 判断是否运行在原生 App 环境(Capacitor)
export const isNative = () => {
return typeof window !== 'undefined' && window.Capacitor && window.Capacitor.isNativePlatform()
}
// 获取服务器地址
// - Web 部署 / 开发模式:使用相对路径(同源)
// - 原生 App:使用用户配置的服务器地址
export const getApiBase = () => {
const stored = localStorage.getItem('serverUrl')
if (isNative() && stored) {
return stored.replace(/\/$/, '')
}
if (stored && stored.trim() !== '') {
return stored.replace(/\/$/, '')
}
return ''
}
// 设置服务器地址
export const setApiBase = (url) => {
if (url && url.trim() !== '') {
localStorage.setItem('serverUrl', url.trim())
} else {
localStorage.removeItem('serverUrl')
}
applyApiBase()
}
// 应用 baseURL
export const applyApiBase = () => {
axios.defaults.baseURL = getApiBase()
}
// 初始化 axios 全局配置
export const initAxios = () => {
applyApiBase()
// 请求拦截器:附加 token
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:处理 401 未登录
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
// 登录接口本身的 401 不触发登出
const url = error.config?.url || ''
if (!url.includes('/auth/login')) {
localStorage.removeItem('token')
localStorage.removeItem('user')
// 触发自定义事件通知 App 重新登录
window.dispatchEvent(new CustomEvent('auth-expired'))
}
}
return Promise.reject(error)
}
)
}
export default axios
+54
View File
@@ -0,0 +1,54 @@
import React, { useState, useEffect } from 'react'
import { Tag, Spin, Tooltip } from 'antd'
import { CloudOutlined, SunOutlined, CloudRain, ThunderboltOutlined } from '@ant-design/icons'
import axios from 'axios'
function Weather() {
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchWeather()
}, [])
const fetchWeather = async () => {
try {
const res = await axios.get('/api/weather/current?city=beijing')
if (res.data.success) {
setWeather(res.data.data)
}
} catch (err) {
console.error('获取天气失败:', err)
} finally {
setLoading(false)
}
}
const getWeatherIcon = (code) => {
if (code === 0) return <SunOutlined style={{ color: '#faad14' }} />
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' }} />
return <CloudOutlined style={{ color: '#8c8c8c' }} />
}
if (loading) {
return <Spin size="small" />
}
if (!weather) {
return null
}
return (
<Tooltip title={`${weather.city} · 湿度 ${weather.current.humidity}% · 风速 ${weather.current.windSpeed}km/h`}>
<Tag icon={getWeatherIcon(weather.current.code)} color="default" style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', fontSize: 14 }}>
<span>{weather.city}</span>
<span style={{ fontWeight: 600 }}>{weather.current.temp}°C</span>
<span>{weather.current.weather}</span>
<span style={{ color: '#999', fontSize: 12 }}>H:{weather.daily.maxTemp}° L:{weather.daily.minTemp}°</span>
</Tag>
</Tooltip>
)
}
export default Weather
+492
View File
@@ -0,0 +1,492 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.logo {
height: 32px;
margin: 16px;
color: #fff;
font-size: 18px;
font-weight: bold;
text-align: center;
line-height: 32px;
white-space: nowrap;
overflow: hidden;
}
.site-layout-content {
padding: 24px;
min-height: 360px;
}
.dashboard-card {
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.dashboard-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-number {
font-size: 36px;
font-weight: bold;
margin: 8px 0;
}
.search-bar {
margin-bottom: 16px;
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.status-tag-normal { color: #52c41a; }
.status-tag-repairing { color: #faad14; }
.status-tag-scrapped { color: #f5222d; }
.status-tag-unused { color: #d9d9d9; }
.about-page {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.about-page h1 {
font-size: 28px;
margin-bottom: 24px;
color: #1890ff;
}
.about-page h2 {
font-size: 20px;
margin: 24px 0 12px;
color: #333;
}
.about-page p {
line-height: 1.8;
color: #666;
margin-bottom: 12px;
}
.about-page ul {
padding-left: 24px;
margin-bottom: 16px;
}
.about-page li {
line-height: 2;
color: #666;
}
.guide-page {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.guide-page h1 {
font-size: 28px;
margin-bottom: 24px;
color: #1890ff;
}
.guide-page h2 {
font-size: 20px;
margin: 24px 0 12px;
color: #333;
padding-bottom: 8px;
border-bottom: 2px solid #e8e8e8;
}
.guide-page h3 {
font-size: 16px;
margin: 16px 0 8px;
color: #444;
}
.guide-page p {
line-height: 1.8;
color: #666;
margin-bottom: 12px;
}
.guide-page ol,
.guide-page ul {
padding-left: 24px;
margin-bottom: 16px;
}
.guide-page li {
line-height: 2;
color: #666;
}
.guide-page .tip {
background: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 4px;
padding: 12px 16px;
margin: 12px 0;
}
.guide-page .warning {
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 4px;
padding: 12px 16px;
margin: 12px 0;
}
/* ==================== 登录页样式 ==================== */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
}
.login-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
overflow: hidden;
}
.login-shape {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
}
.shape-1 {
width: 500px;
height: 500px;
background: #7c3aed;
top: -150px;
right: -100px;
}
.shape-2 {
width: 400px;
height: 400px;
background: #2563eb;
bottom: -100px;
left: -100px;
}
.shape-3 {
width: 300px;
height: 300px;
background: #ec4899;
top: 40%;
left: 30%;
opacity: 0.4;
}
.login-container {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
padding: 24px;
}
.login-brand {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
max-width: 260px;
height: auto;
margin-bottom: 12px;
}
.login-subtitle {
color: rgba(255, 255, 255, 0.85);
font-size: 20px;
font-weight: 500;
letter-spacing: 2px;
margin: 0;
}
.login-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
}
.login-title {
text-align: center;
font-size: 24px;
color: #1f2937;
margin-bottom: 8px;
}
.login-desc {
text-align: center;
color: #6b7280;
margin-bottom: 24px;
}
.login-btn {
height: 44px;
font-size: 16px;
border-radius: 8px;
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
border: none;
}
.login-btn:hover {
background: linear-gradient(135deg, #1d4ed8 0%, #6d28d9 100%) !important;
}
.login-hint {
margin-top: 20px;
padding: 12px;
background: #f3f4f6;
border-radius: 8px;
text-align: center;
color: #6b7280;
font-size: 13px;
}
.login-hint p {
margin: 4px 0;
}
.login-copyright {
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
margin-top: 24px;
}
/* ==================== 侧边栏 Logo ==================== */
.logo-area {
height: 80px;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.logo-img {
max-width: 100%;
max-height: 48px;
object-fit: contain;
}
.logo-icon {
width: 40px;
height: 40px;
object-fit: contain;
}
/* ==================== 顶部问候语 ==================== */
.greeting-text {
font-size: 15px;
color: #374151;
font-weight: 500;
}
@media (max-width: 768px) {
.greeting-text {
display: none;
}
}
/* ==================== 移动端布局 ==================== */
.mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%) !important;
padding: 0 12px !important;
height: 52px;
line-height: 52px;
position: sticky;
top: 0;
z-index: 100;
}
.mobile-title {
color: #fff;
font-size: 17px;
font-weight: 600;
}
.mobile-info-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
background: #f5f7fa;
border-bottom: 1px solid #e8e8e8;
flex-wrap: wrap;
}
.mobile-greeting {
font-size: 14px;
color: #374151;
font-weight: 500;
}
.mobile-content {
padding: 12px;
padding-bottom: 76px; /* 为底部导航留空间 */
background: #f5f7fa;
}
/* 底部导航 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
background: #fff;
border-top: 1px solid #e8e8e8;
z-index: 100;
padding-bottom: env(safe-area-inset-bottom);
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 0;
border: none;
background: none;
color: #8c8c8c;
font-size: 12px;
cursor: pointer;
transition: color 0.2s;
}
.bottom-nav-item .anticon {
font-size: 20px;
}
.bottom-nav-item.active {
color: #7c3aed;
}
/* ==================== 移动端响应式调整 ==================== */
@media (max-width: 768px) {
.site-layout-content {
padding: 12px;
}
.search-bar {
flex-direction: column;
align-items: stretch;
}
.search-bar .ant-input-affix-wrapper,
.search-bar .ant-select,
.search-bar .ant-btn {
width: 100% !important;
}
.table-toolbar {
flex-direction: column;
align-items: stretch;
}
.table-toolbar .ant-btn {
width: 100%;
}
.about-page,
.guide-page {
padding: 8px;
}
.about-page h1,
.guide-page h1 {
font-size: 22px;
}
.about-page h2,
.guide-page h2 {
font-size: 17px;
}
.about-page pre,
.guide-page pre {
font-size: 12px;
overflow-x: auto;
}
.stat-number {
font-size: 26px;
}
.login-logo {
max-width: 200px;
}
.login-container {
padding: 16px;
}
}
/* 表单网格(桌面2列) */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
/* 表单网格(桌面3列) */
.form-grid-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0 16px;
}
/* 表格在小屏下允许横向滚动 */
@media (max-width: 768px) {
.ant-table {
font-size: 13px;
}
.form-grid,
.form-grid-3 {
grid-template-columns: 1fr;
}
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { initAxios } from './apiConfig'
import App from './App'
import './index.css'
// 初始化 axios(baseURL + 拦截器)
initAxios()
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+74
View File
@@ -0,0 +1,74 @@
import React from 'react'
import { Card, Divider } from 'antd'
function About() {
return (
<div className="about-page">
<Card>
<h1>影视星河 · 设备管理系统 v1.0.0</h1>
<Divider />
<h2>软件概述</h2>
<p>
设备管理系统(Device Management System,简称DMS)是一款专为企事业单位设计的现代化设备资产管理软件。
系统集成了设备全生命周期管理、人员管理、通知推送等核心功能,旨在帮助用户高效管理各类IT设备和办公资产。
</p>
<h2>核心功能</h2>
<ul>
<li><strong>登录认证:</strong>支持账号密码登录,内置默认管理员账户,登录后根据时间智能问候</li>
<li><strong>每日天气:</strong>顶部导航栏实时显示当前城市天气、温度、湿度等信息</li>
<li><strong>设备管理:</strong>支持设备的添加、编辑、删除、查询等全生命周期管理,涵盖笔记本电脑、台式机、显示器、打印机、网络设备等各类IT资产</li>
<li><strong>人员管理:</strong>维护组织架构中的人员信息,包括部门、职位、联系方式等,便于设备责任分配</li>
<li><strong>通知管理:</strong>支持发布各类通知、公告和提醒,可设置优先级和发送范围,确保重要信息及时传达</li>
<li><strong>数据概览:</strong>提供直观的仪表盘,实时展示设备状态、人员情况和通知统计</li>
<li><strong>搜索过滤:</strong>支持按关键词、类型、状态等多维度快速检索</li>
<li><strong>品牌标识:</strong>登录页、侧边栏、顶部导航均展示影视星河品牌 Logo</li>
</ul>
<h2>技术架构</h2>
<ul>
<li><strong>前端:</strong>React 18 + Ant Design 5 + Vite 5</li>
<li><strong>后端:</strong>Node.js + Express.js</li>
<li><strong>数据库:</strong>SQLite(轻量级嵌入式数据库,无需额外安装)</li>
<li><strong>桌面端:</strong>Electron(支持打包为Windows/Mac/Linux桌面应用)</li>
</ul>
<h2>部署方式</h2>
<ul>
<li><strong>服务器部署:</strong>通过 Node.js 直接运行,支持任何服务器环境</li>
<li><strong>桌面应用:</strong>打包为 Windows EXE 安装包,支持一键安装使用</li>
<li><strong>手机网页:</strong>响应式设计,手机浏览器直接访问,自动适配移动端布局</li>
<li><strong>手机App:</strong>支持 PWA 安装到桌面,或通过 Capacitor 打包原生 Android APK</li>
<li><strong>局域网部署:</strong>支持在内网服务器上部署,供局域网内所有设备访问</li>
</ul>
<h2>默认管理员账户</h2>
<ul>
<li>用户名:<strong>admin</strong></li>
<li>密码:<strong>admin123</strong></li>
<li>角色:<strong>系统管理员</strong></li>
</ul>
<p className="warning">提示:首次登录后请及时修改默认密码,确保系统安全。</p>
<h2>系统要求</h2>
<ul>
<li>操作系统:Windows 10/11、macOS 10.15+、Linux</li>
<li>内存:最低 2GB,推荐 4GB 以上</li>
<li>存储空间:最低 200MB 可用空间</li>
<li>浏览器:Chrome 80+、Firefox 78+、Edge 80+、Safari 14+</li>
<li>网络:天气功能需要访问互联网(Open-Meteo 免费天气接口)</li>
</ul>
<h2>版本信息</h2>
<ul>
<li>当前版本:v1.0.0</li>
<li>发布日期:2026年8月</li>
<li>许可证:MIT License</li>
</ul>
</Card>
</div>
)
}
export default About
+140
View File
@@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react'
import { Card, Row, Col, Statistic, Spin } from 'antd'
import {
DesktopOutlined,
TeamOutlined,
BellOutlined,
CheckCircleOutlined,
ToolOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons'
import axios from 'axios'
function Dashboard({ onNavigate }) {
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchStats()
}, [])
const fetchStats = async () => {
try {
const res = await axios.get('/api/dashboard/stats')
if (res.data.success) {
setStats(res.data.data)
}
} catch (err) {
console.error('获取统计数据失败:', err)
} finally {
setLoading(false)
}
}
if (loading) {
return <div style={{ textAlign: 'center', padding: 60 }}><Spin size="large" tip="加载中..." /></div>
}
return (
<div>
<h2 style={{ marginBottom: 24 }}>数据概览</h2>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card className="dashboard-card" onClick={() => onNavigate('devices')}>
<Statistic
title="设备总数"
value={stats?.deviceTotal || 0}
prefix={<DesktopOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="dashboard-card">
<Statistic
title="正常运行"
value={stats?.deviceNormal || 0}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="dashboard-card">
<Statistic
title="维修中"
value={stats?.deviceRepairing || 0}
prefix={<ToolOutlined />}
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="dashboard-card">
<Statistic
title="已报废"
value={stats?.deviceScrapped || 0}
prefix={<CloseCircleOutlined />}
valueStyle={{ color: '#f5222d' }}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} sm={12} lg={8}>
<Card className="dashboard-card" onClick={() => onNavigate('personnel')}>
<Statistic
title="人员总数"
value={stats?.personnelTotal || 0}
prefix={<TeamOutlined />}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card className="dashboard-card">
<Statistic
title="在职人员"
value={stats?.personnelActive || 0}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#13c2c2' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card className="dashboard-card" onClick={() => onNavigate('notifications')}>
<Statistic
title="未读通知"
value={stats?.notifUnread || 0}
prefix={<ExclamationCircleOutlined />}
valueStyle={{ color: '#fa541c' }}
suffix={<span style={{ fontSize: 14, color: '#999' }}>/ {stats?.notifTotal || 0}</span>}
/>
</Card>
</Col>
</Row>
<Card style={{ marginTop: 24 }}>
<h3>系统信息</h3>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col span={8}>
<p><strong>系统版本:</strong>v1.0.0</p>
<p><strong>系统名称:</strong>设备管理系统</p>
</Col>
<Col span={8}>
<p><strong>数据库:</strong>SQLite</p>
<p><strong>前端框架:</strong>React + Ant Design</p>
</Col>
<Col span={8}>
<p><strong>后端框架:</strong>Express.js</p>
<p><strong>部署方式:</strong>服务器 / 桌面应用</p>
</Col>
</Row>
</Card>
</div>
)
}
export default Dashboard
+317
View File
@@ -0,0 +1,317 @@
import React, { useState, useEffect } from 'react'
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 axios from 'axios'
import dayjs from 'dayjs'
import { can } from '../permissions'
const { Option } = Select
const { TextArea } = Input
const deviceTypes = ['笔记本电脑', '台式机', '显示器', '打印机', '网络设备', '服务器', '投影仪', '其他']
const deviceStatuses = ['正常', '维修中', '已报废', '闲置']
function DeviceManagement({ user }) {
const isAdminUser = can(user, 'device:edit')
const canRepair = can(user, 'device:repair')
const canAdd = can(user, 'device:add')
const [devices, setDevices] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingDevice, setEditingDevice] = useState(null)
const [keyword, setKeyword] = useState('')
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [statusDevice, setStatusDevice] = useState(null)
const [form] = Form.useForm()
const [statusForm] = Form.useForm()
useEffect(() => {
fetchDevices()
}, [keyword, filterType, filterStatus])
const fetchDevices = async () => {
setLoading(true)
try {
const params = {}
if (keyword) params.keyword = keyword
if (filterType) params.type = filterType
if (filterStatus) params.status = filterStatus
const res = await axios.get('/api/devices', { params })
if (res.data.success) {
setDevices(res.data.data)
}
} catch (err) {
message.error('获取设备列表失败')
} finally {
setLoading(false)
}
}
const handleAdd = () => {
setEditingDevice(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditingDevice(record)
form.setFieldsValue({
...record,
purchase_date: record.purchase_date ? dayjs(record.purchase_date) : null,
warranty_date: record.warranty_date ? dayjs(record.warranty_date) : null
})
setModalVisible(true)
}
const handleDelete = async (id) => {
try {
const res = await axios.delete(`/api/devices/${id}`)
if (res.data.success) {
message.success('设备删除成功')
fetchDevices()
}
} catch (err) {
message.error('删除失败')
}
}
const handleRepair = async (record) => {
Modal.confirm({
title: `设备报修:${record.name}`,
content: '确认将该设备标记为“维修中”吗?可填写报修说明。',
okText: '确认报修',
cancelText: '取消',
onOk: async () => {
try {
const res = await axios.patch(`/api/devices/${record.id}/repair`, { remark: '' })
if (res.data.success) {
message.success(res.data.message)
fetchDevices()
}
} catch (err) {
message.error(err.response?.data?.message || '报修失败')
}
}
})
}
// 管理员:打开修改维修状态弹窗
const handleOpenStatus = (record) => {
setStatusDevice(record)
statusForm.setFieldsValue({ status: record.status })
setStatusModalVisible(true)
}
// 管理员:提交维修状态修改
const handleStatusSubmit = async () => {
try {
const values = await statusForm.validateFields()
const res = await axios.patch(`/api/devices/${statusDevice.id}/status`, values)
if (res.data.success) {
message.success(res.data.message)
setStatusModalVisible(false)
fetchDevices()
}
} catch (err) {
if (err.response) message.error(err.response.data?.message || '修改失败')
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
const data = {
...values,
purchase_date: values.purchase_date ? values.purchase_date.format('YYYY-MM-DD') : '',
warranty_date: values.warranty_date ? values.warranty_date.format('YYYY-MM-DD') : ''
}
if (editingDevice) {
const res = await axios.put(`/api/devices/${editingDevice.id}`, data)
if (res.data.success) {
message.success('设备更新成功')
}
} else {
const res = await axios.post('/api/devices', data)
if (res.data.success) {
message.success('设备添加成功')
}
}
setModalVisible(false)
fetchDevices()
} catch (err) {
if (err.response) {
message.error(err.response.data.message || '操作失败')
}
}
}
const handleReset = () => {
setKeyword('')
setFilterType('')
setFilterStatus('')
}
const getStatusTag = (status) => {
const colorMap = {
'正常': 'green',
'维修中': 'orange',
'已报废': 'red',
'闲置': 'default'
}
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>
}
const columns = [
{ title: '设备名称', dataIndex: 'name', key: 'name', width: 180, ellipsis: true },
{ title: '设备类型', dataIndex: 'type', key: 'type', width: 110 },
{ title: '型号', dataIndex: 'model', key: 'model', width: 140, ellipsis: true },
{ title: '序列号', dataIndex: 'serial_number', key: 'serial_number', width: 130, ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status', width: 90, render: getStatusTag },
{ title: '位置', dataIndex: 'location', key: 'location', width: 110, ellipsis: true },
{ title: '购买日期', dataIndex: 'purchase_date', key: 'purchase_date', width: 110 },
{ title: '负责人', dataIndex: 'responsible_person', key: 'responsible_person', width: 90 },
{
title: '操作',
key: 'action',
width: isAdminUser ? 280 : 100,
fixed: 'right',
render: (_, record) => (
<Space>
{canRepair && record.status !== '维修中' && record.status !== '已报废' && (
<Button type="link" icon={<ToolOutlined />} size="small" onClick={() => handleRepair(record)}>报修</Button>
)}
{isAdminUser && (
<Button type="link" icon={<SyncOutlined />} size="small" onClick={() => handleOpenStatus(record)}>维修状态</Button>
)}
{isAdminUser && (
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
)}
{isAdminUser && (
<Popconfirm title="确定要删除此设备吗?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
</Popconfirm>
)}
</Space>
)
}
]
return (
<div>
<Card style={{ marginBottom: 16 }}>
<div className="search-bar">
<Input
placeholder="搜索设备名称/序列号/位置"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
style={{ width: 240 }}
allowClear
/>
<Select
placeholder="设备类型"
value={filterType || undefined}
onChange={(val) => setFilterType(val || '')}
style={{ width: 140 }}
allowClear
>
{deviceTypes.map(t => <Option key={t} value={t}>{t}</Option>)}
</Select>
<Select
placeholder="设备状态"
value={filterStatus || undefined}
onChange={(val) => setFilterStatus(val || '')}
style={{ width: 120 }}
allowClear
>
{deviceStatuses.map(s => <Option key={s} value={s}>{s}</Option>)}
</Select>
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
</div>
<div className="table-toolbar">
<span>共 {devices.length} 条记录</span>
{canAdd && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加设备</Button>}
</div>
</Card>
<Table
columns={columns}
dataSource={devices}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`
}}
/>
<Modal
title={editingDevice ? '编辑设备' : '添加设备'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={720}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
<Input placeholder="请输入设备名称" />
</Form.Item>
<Form.Item name="type" label="设备类型" rules={[{ required: true, message: '请选择设备类型' }]}>
<Select placeholder="请选择设备类型">
{deviceTypes.map(t => <Option key={t} value={t}>{t}</Option>)}
</Select>
</Form.Item>
<div className="form-grid">
<Form.Item name="model" label="型号"><Input placeholder="请输入型号" /></Form.Item>
<Form.Item name="serial_number" label="序列号"><Input placeholder="请输入序列号" /></Form.Item>
<Form.Item name="status" label="状态" initialValue="正常">
<Select>
{deviceStatuses.map(s => <Option key={s} value={s}>{s}</Option>)}
</Select>
</Form.Item>
<Form.Item name="location" label="位置"><Input placeholder="请输入位置" /></Form.Item>
<Form.Item name="purchase_date" label="购买日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
<Form.Item name="warranty_date" label="保修日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
<Form.Item name="responsible_person" label="负责人"><Input placeholder="请输入负责人" /></Form.Item>
</div>
<Form.Item name="description" label="描述"><TextArea rows={3} placeholder="请输入描述信息" /></Form.Item>
</Form>
</Modal>
<Modal
title="修改维修状态"
open={statusModalVisible}
onOk={handleStatusSubmit}
onCancel={() => setStatusModalVisible(false)}
okText="确定"
cancelText="取消"
width={420}
>
{statusDevice && (
<>
<p style={{ color: '#666' }}>设备:<strong>{statusDevice.name}</strong> 当前状态:{getStatusTag(statusDevice.status)}</p>
<Form form={statusForm} layout="vertical">
<Form.Item name="status" label="新的维修状态" rules={[{ required: true, message: '请选择状态' }]}>
<Select>
{deviceStatuses.map(s => <Option key={s} value={s}>{s}</Option>)}
</Select>
</Form.Item>
</Form>
<div className="tip" style={{ fontSize: 12, color: '#999' }}>例如:维修完成后,将状态从“维修中”改回“正常”。</div>
</>
)}
</Modal>
</div>
)
}
export default DeviceManagement
+252
View File
@@ -0,0 +1,252 @@
import React from 'react'
import { Card, Divider } from 'antd'
function Guide() {
return (
<div className="guide-page">
<Card>
<h1>使用说明</h1>
<Divider />
<h2>一、登录系统</h2>
<ol>
<li>打开系统后进入登录页面</li>
<li>输入用户名和密码</li>
<li>点击"登录"按钮进入系统</li>
</ol>
<p>默认管理员账号:</p>
<ul>
<li>用户名:<strong>admin</strong></li>
<li>密码:<strong>admin123</strong></li>
</ul>
<div className="warning">注意:首次登录后建议立即修改默认密码。</div>
<h2>二、每日天气</h2>
<p>登录成功后,页面顶部导航栏右侧会显示当前城市天气信息,包括:</p>
<ul>
<li>当前温度</li>
<li>天气状况(晴、多云、雨、雪等)</li>
<li>今日最高/最低温度</li>
<li>湿度和风速(鼠标悬停查看详情)</li>
</ul>
<div className="tip">天气数据来自 Open-Meteo 免费天气接口,需要联网才能正常显示。</div>
<h2>三、问候语</h2>
<p>登录后,顶部导航栏会根据当前时间自动显示问候语:</p>
<ul>
<li>06:00 - 09:00:早上好</li>
<li>09:00 - 12:00:上午好</li>
<li>12:00 - 14:00:中午好</li>
<li>14:00 - 18:00:下午好</li>
<li>18:00 - 06:00:晚上好</li>
</ul>
<h2>四、退出登录</h2>
<p>点击右上角用户名,在下拉菜单中选择"退出登录"即可安全退出系统。</p>
<h2>五、系统首页(仪表盘)</h2>
<p>系统首页提供数据的整体概览,包括:</p>
<ul>
<li>设备总数及状态分布(正常、维修中、已报废)</li>
<li>人员总数及在职情况</li>
<li>未读通知数量</li>
</ul>
<p>点击卡片可快速跳转到对应的管理页面。</p>
<h2>六、设备管理</h2>
<h3>6.1 查看设备列表</h3>
<p>进入"设备管理"页面后,可以看到所有设备的列表,包含设备名称、类型、型号、序列号、状态、位置等信息。</p>
<h3>6.2 添加设备</h3>
<ol>
<li>点击页面右上角的"添加设备"按钮</li>
<li>在弹出的表单中填写设备信息(名称和类型为必填项)</li>
<li>点击"确定"保存</li>
</ol>
<h3>6.3 编辑设备</h3>
<ol>
<li>在设备列表中找到需要编辑的设备</li>
<li>点击操作列的"编辑"按钮</li>
<li>修改设备信息后点击"确定"保存</li>
</ol>
<h3>6.4 删除设备</h3>
<ol>
<li>在设备列表中找到需要删除的设备</li>
<li>点击操作列的"删除"按钮</li>
<li>在确认对话框中点击"确定"</li>
</ol>
<div className="warning">注意:删除操作不可恢复,请谨慎操作。</div>
<h3>6.5 搜索和筛选</h3>
<ul>
<li>在搜索框中输入关键词可按设备名称、序列号、位置搜索</li>
<li>通过“设备类型”下拉框按类型筛选</li>
<li>通过“设备状态”下拉框按状态筛选</li>
<li>点击“重置”按钮清除所有筛选条件</li>
</ul>
<h3>6.6 设备报修与维修状态修改</h3>
<ul>
<li><strong>设备报修(普通账户+管理员):</strong>点击操作列的“报修”按钮,确认后设备状态即变为“维修中”。</li>
<li><strong>修改维修状态(仅管理员):</strong>点击操作列的“维修状态”按钮,在弹窗中选择新状态(正常/维修中/已报废/闲置)后确定。例如维修完成后,可将状态从“维修中”改回“正常”。</li>
</ul>
<h2>七、人员管理</h2>
<h3>7.1 查看人员列表</h3>
<p>进入"人员管理"页面后,可以看到所有人员的信息,包含姓名、工号、部门、职位、联系方式等。</p>
<h3>7.2 添加人员</h3>
<ol>
<li>点击页面右上角的"添加人员"按钮</li>
<li>在弹出的表单中填写人员信息(姓名为必填项)</li>
<li>点击"确定"保存</li>
</ol>
<h3>7.3 编辑和删除人员</h3>
<p>操作方式与设备管理类似,点击对应操作列的按钮即可。</p>
<h3>7.4 搜索和筛选</h3>
<ul>
<li>搜索框支持按姓名、工号、手机号搜索</li>
<li>可按部门和在职状态进行筛选</li>
</ul>
<h3>7.5 创建登录账户(多用户管理)</h3>
<p>添加/编辑人员时,打开"创建/启用登录账户"开关,即可为该人员同步创建登录账户:</p>
<ol>
<li>填写登录用户名和密码</li>
<li>选择账户角色:普通账户 或 管理员账户</li>
<li>保存后,该人员即可用此账户登录系统</li>
</ol>
<div className="tip">人员与账户一一对应。删除人员时会同时删除其登录账户;关闭开关则停用账户。</div>
<h3>7.6 账户权限说明</h3>
<ul>
<li><strong>普通账户:</strong>添加设备、设备报修、查看人员、查看通知</li>
<li><strong>管理员账户:</strong>拥有全部权限(编辑/删除设备、管理人员与账户、发布/删除通知等)</li>
</ul>
<div className="warning">注意:普通账户登录后,界面上仅显示其有权限的操作按钮,后端也会做权限校验。</div>
<h2>八、通知管理</h2>
<h3>8.1 查看通知列表</h3>
<p>进入"通知管理"页面后,可以看到所有发布的通知,包含标题、类型、优先级、阅读状态等信息。</p>
<h3>8.2 发布通知</h3>
<ol>
<li>点击页面右上角的"发布通知"按钮</li>
<li>填写通知标题、内容</li>
<li>选择通知类型(通知、公告、提醒)</li>
<li>选择优先级(普通、高)</li>
<li>选择目标范围(全部、技术部、行政部等)</li>
<li>点击"确定"发布</li>
</ol>
<h3>8.3 标记已读</h3>
<ul>
<li>点击单条通知的"标记已读"按钮可将该通知标为已读</li>
<li>点击"全部已读"按钮可一键标记所有通知为已读</li>
</ul>
<h3>8.4 筛选通知</h3>
<ul>
<li>可按关键词搜索标题和内容</li>
<li>可按类型、优先级、阅读状态筛选</li>
</ul>
<h2>九、服务器部署指南</h2>
<h3>9.1 环境准备</h3>
<ol>
<li>安装 Node.js 18 或以上版本</li>
<li>将项目文件上传到服务器</li>
</ol>
<h3>9.2 安装依赖</h3>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
{`cd device-management-system
npm install
cd client
npm install
npm run build
cd ..`}
</pre>
<h3>9.3 启动服务</h3>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
{`# 启动服务(默认端口3001)
npm start
# 或指定端口
PORT=8080 npm start`}
</pre>
<h3>9.4 访问系统</h3>
<p>浏览器打开 http://服务器IP:3001 即可使用系统。</p>
<div className="tip">
提示:建议使用 PM2 等进程管理工具保持服务后台运行:
<pre style={{ marginTop: 8 }}>
{`npm install -g pm2
pm2 start server.js --name device-manager`}
</pre>
</div>
<h2>十、桌面应用打包</h2>
<h3>10.1 Windows EXE 打包</h3>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
{`# 安装依赖
npm install
cd client && npm install && npm run build && cd ..
# 打包 Windows 安装包
npm run electron:build:win`}
</pre>
<p>打包完成后,EXE安装包位于 <code>dist-electron</code> 目录中。</p>
<h2>十一、手机端使用</h2>
<h3>11.1 手机浏览器访问(响应式网页)</h3>
<ol>
<li>确保手机与服务器在同一局域网</li>
<li>手机浏览器打开 http://服务器IP:3001</li>
<li>页面会自动切换为移动端布局(底部导航 + 抽屉菜单)</li>
</ol>
<h3>11.2 安装为 PWA 应用(推荐)</h3>
<ol>
<li>用手机 Chrome / Edge 浏览器打开系统</li>
<li>点击浏览器菜单中的"添加到主屏幕" / "安装应用"</li>
<li>安装后从桌面图标打开,体验接近原生App</li>
</ol>
<div className="tip">PWA 方式无需应用商店,安装简单,支持离线缓存。</div>
<h3>11.3 打包原生 Android App(APK)</h3>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
{`# 前置: 安装 Android Studio 及 SDK
# 双击 build-app.bat 或手动执行:
cd client
npm install
npm run build
npx capacitor add android
npx capacitor sync android
npx capacitor open android
# 在 Android Studio 中 Build APK
# APK位置: android/app/build/outputs/apk/debug/app-debug.apk`}
</pre>
<h3>11.4 App 配置服务器地址</h3>
<ol>
<li>打开App进入登录页</li>
<li>点击"服务器设置"展开配置</li>
<li>输入服务器地址,如 http://192.168.1.100:3001</li>
<li>输入账号密码登录</li>
</ol>
<div className="warning">注意:App 必须能访问到服务器,请确保手机与服务器网络互通。</div>
</Card>
</div>
)
}
export default Guide
+90
View File
@@ -0,0 +1,90 @@
import React, { useState } from 'react'
import { Form, Input, Button, Card, message, Collapse } from 'antd'
import { UserOutlined, LockOutlined, LinkOutlined, SettingOutlined } from '@ant-design/icons'
import axios from 'axios'
import { setApiBase, getApiBase, isNative } from '../apiConfig'
function Login({ onLogin }) {
const [loading, setLoading] = useState(false)
const [serverUrl, setServerUrl] = useState(getApiBase())
const [form] = Form.useForm()
const handleSubmit = async (values) => {
setLoading(true)
try {
// 应用服务器地址
setApiBase(serverUrl)
const res = await axios.post('/api/auth/login', {
username: values.username,
password: values.password
})
if (res.data.success) {
const { token, ...user } = res.data.data
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(user))
message.success(res.data.message)
onLogin(user)
}
} catch (err) {
message.error(err.response?.data?.message || '登录失败,请检查服务器地址和网络')
} finally {
setLoading(false)
}
}
return (
<div className="login-page">
<div className="login-bg">
<div className="login-shape shape-1"></div>
<div className="login-shape shape-2"></div>
<div className="login-shape shape-3"></div>
</div>
<div className="login-container">
<div className="login-brand">
<img src="/assets/logo-white.png" alt="影视星河" className="login-logo" />
<p className="login-subtitle">设备管理系统</p>
</div>
<Card className="login-card" bordered={false}>
<h2 className="login-title">欢迎登录</h2>
<p className="login-desc">请输入您的账号和密码</p>
<Form form={form} name="login" onFinish={handleSubmit} size="large">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" autoComplete="username" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" autoComplete="current-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block className="login-btn">
登 录
</Button>
</Form.Item>
</Form>
<Collapse ghost items={[{
key: 'server',
label: <span style={{ color: '#6b7280' }}><SettingOutlined /> 服务器设置{isNative() ? '(App必填)' : ''}</span>,
children: (
<Input
prefix={<LinkOutlined />}
placeholder="http://服务器IP:3001"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
/>
)
}]} />
</Card>
<p className="login-copyright">© 2026 影视星河 设备管理系统</p>
</div>
</div>
)
}
export default Login
+307
View File
@@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react'
import { Table, Button, Input, Select, Space, Tag, Modal, Form, message, Popconfirm, Card, Badge, Tooltip } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, CheckOutlined, CheckCircleOutlined } from '@ant-design/icons'
import axios from 'axios'
import { can } from '../permissions'
const { Option } = Select
const { TextArea } = Input
const notifTypes = ['通知', '公告', '提醒']
const priorities = ['普通', '高']
const targets = ['全部', '技术部', '行政部', '财务部', '市场部', '人事部', '运营部']
function NotificationManagement({ user }) {
const manageAllowed = can(user, 'notification:manage')
const [notifications, setNotifications] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingNotif, setEditingNotif] = useState(null)
const [keyword, setKeyword] = useState('')
const [filterType, setFilterType] = useState('')
const [filterPriority, setFilterPriority] = useState('')
const [filterRead, setFilterRead] = useState('')
const [form] = Form.useForm()
useEffect(() => {
fetchNotifications()
}, [keyword, filterType, filterPriority, filterRead])
const fetchNotifications = async () => {
setLoading(true)
try {
const params = {}
if (keyword) params.keyword = keyword
if (filterType) params.type = filterType
if (filterPriority) params.priority = filterPriority
if (filterRead !== '') params.is_read = filterRead
const res = await axios.get('/api/notifications', { params })
if (res.data.success) {
setNotifications(res.data.data)
}
} catch (err) {
message.error('获取通知列表失败')
} finally {
setLoading(false)
}
}
const handleAdd = () => {
setEditingNotif(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditingNotif(record)
form.setFieldsValue(record)
setModalVisible(true)
}
const handleDelete = async (id) => {
try {
const res = await axios.delete(`/api/notifications/${id}`)
if (res.data.success) {
message.success('通知删除成功')
fetchNotifications()
}
} catch (err) {
message.error('删除失败')
}
}
const handleMarkRead = async (id) => {
try {
const res = await axios.patch(`/api/notifications/${id}/read`)
if (res.data.success) {
message.success('已标记为已读')
fetchNotifications()
}
} catch (err) {
message.error('操作失败')
}
}
const handleMarkAllRead = async () => {
try {
const res = await axios.patch('/api/notifications/read-all')
if (res.data.success) {
message.success('全部标记为已读')
fetchNotifications()
}
} catch (err) {
message.error('操作失败')
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
if (editingNotif) {
const res = await axios.put(`/api/notifications/${editingNotif.id}`, {
...values,
is_read: values.is_read || false
})
if (res.data.success) {
message.success('通知更新成功')
}
} else {
const res = await axios.post('/api/notifications', values)
if (res.data.success) {
message.success('通知发布成功')
}
}
setModalVisible(false)
fetchNotifications()
} catch (err) {
if (err.response) {
message.error(err.response.data.message || '操作失败')
}
}
}
const handleReset = () => {
setKeyword('')
setFilterType('')
setFilterPriority('')
setFilterRead('')
}
const getTypeTag = (type) => {
const colorMap = { '通知': 'blue', '公告': 'purple', '提醒': 'cyan' }
return <Tag color={colorMap[type] || 'default'}>{type}</Tag>
}
const getPriorityTag = (priority) => {
return <Tag color={priority === '高' ? 'red' : 'default'}>{priority}</Tag>
}
const unreadCount = notifications.filter(n => n.is_read === 0).length
const columns = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
width: 220,
ellipsis: true,
render: (text, record) => (
<span>
{record.is_read === 0 && <Badge status="processing" />}
{' '}{text}
</span>
)
},
{
title: '内容',
dataIndex: 'content',
key: 'content',
width: 300,
ellipsis: { showTitle: false },
render: (text) => <Tooltip placement="topLeft" title={text}>{text}</Tooltip>
},
{ title: '类型', dataIndex: 'type', key: 'type', width: 80, render: getTypeTag },
{ title: '优先级', dataIndex: 'priority', key: 'priority', width: 80, render: getPriorityTag },
{
title: '状态',
dataIndex: 'is_read',
key: 'is_read',
width: 80,
render: (val) => val ? <Tag color="green">已读</Tag> : <Tag color="orange">未读</Tag>
},
{ title: '目标', dataIndex: 'target', key: 'target', width: 90 },
{ title: '发布时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
render: (_, record) => (
<Space>
{record.is_read === 0 && (
<Button type="link" icon={<CheckOutlined />} size="small" onClick={() => handleMarkRead(record.id)}>已读</Button>
)}
{manageAllowed && (
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
)}
{manageAllowed && (
<Popconfirm title="确定要删除此通知吗?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
</Popconfirm>
)}
</Space>
)
}
]
return (
<div>
<Card style={{ marginBottom: 16 }}>
<div className="search-bar">
<Input
placeholder="搜索标题/内容"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
style={{ width: 200 }}
allowClear
/>
<Select
placeholder="类型"
value={filterType || undefined}
onChange={(val) => setFilterType(val || '')}
style={{ width: 110 }}
allowClear
>
{notifTypes.map(t => <Option key={t} value={t}>{t}</Option>)}
</Select>
<Select
placeholder="优先级"
value={filterPriority || undefined}
onChange={(val) => setFilterPriority(val || '')}
style={{ width: 110 }}
allowClear
>
{priorities.map(p => <Option key={p} value={p}>{p}</Option>)}
</Select>
<Select
placeholder="阅读状态"
value={filterRead}
onChange={(val) => setFilterRead(val)}
style={{ width: 120 }}
allowClear
>
<Option value="0">未读</Option>
<Option value="1">已读</Option>
</Select>
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
</div>
<div className="table-toolbar">
<Space>
<span>共 {notifications.length} 条记录</span>
{unreadCount > 0 && (
<Button icon={<CheckCircleOutlined />} onClick={handleMarkAllRead} size="small">
全部已读 ({unreadCount})
</Button>
)}
</Space>
{manageAllowed && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>发布通知</Button>}
</div>
</Card>
<Table
columns={columns}
dataSource={notifications}
rowKey="id"
loading={loading}
scroll={{ x: 1300 }}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`
}}
/>
<Modal
title={editingNotif ? '编辑通知' : '发布通知'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={640}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input placeholder="请输入通知标题" />
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
<TextArea rows={4} placeholder="请输入通知内容" />
</Form.Item>
<div className="form-grid-3">
<Form.Item name="type" label="类型" initialValue="通知">
<Select>
{notifTypes.map(t => <Option key={t} value={t}>{t}</Option>)}
</Select>
</Form.Item>
<Form.Item name="priority" label="优先级" initialValue="普通">
<Select>
{priorities.map(p => <Option key={p} value={p}>{p}</Option>)}
</Select>
</Form.Item>
<Form.Item name="target" label="发送范围" initialValue="全部">
<Select>
{targets.map(t => <Option key={t} value={t}>{t}</Option>)}
</Select>
</Form.Item>
</div>
</Form>
</Modal>
</div>
)
}
export default NotificationManagement
+303
View File
@@ -0,0 +1,303 @@
import React, { useState, useEffect } from 'react'
import { Table, Button, Input, Select, Space, Tag, Modal, Form, DatePicker, message, Popconfirm, Card, Switch } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, PhoneOutlined, MailOutlined } from '@ant-design/icons'
import axios from 'axios'
import dayjs from 'dayjs'
import { can, roleLabel } from '../permissions'
const { Option } = Select
const { TextArea } = Input
const departments = ['拍摄组', '后勤组', '监督组']
function PersonnelManagement({ user }) {
const manageAllowed = can(user, 'personnel:manage')
const [personnel, setPersonnel] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingPerson, setEditingPerson] = useState(null)
const [keyword, setKeyword] = useState('')
const [filterDept, setFilterDept] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [form] = Form.useForm()
useEffect(() => {
fetchPersonnel()
}, [keyword, filterDept, filterStatus])
const fetchPersonnel = async () => {
setLoading(true)
try {
const params = {}
if (keyword) params.keyword = keyword
if (filterDept) params.department = filterDept
if (filterStatus) params.status = filterStatus
const res = await axios.get('/api/personnel', { params })
if (res.data.success) {
setPersonnel(res.data.data)
}
} catch (err) {
message.error('获取人员列表失败')
} finally {
setLoading(false)
}
}
const handleAdd = () => {
setEditingPerson(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditingPerson(record)
form.setFieldsValue({
...record,
entry_date: record.entry_date ? dayjs(record.entry_date) : null,
createAccount: !!record.user_id,
account_username: record.account_username || '',
account_role: record.account_role || 'user',
account_password: ''
})
setModalVisible(true)
}
const handleDelete = async (id) => {
try {
const res = await axios.delete(`/api/personnel/${id}`)
if (res.data.success) {
message.success('人员删除成功')
fetchPersonnel()
}
} catch (err) {
message.error('删除失败')
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
const data = {
...values,
entry_date: values.entry_date ? values.entry_date.format('YYYY-MM-DD') : '',
account: {
createAccount: !!values.createAccount,
username: values.account_username,
password: values.account_password,
role: values.account_role || 'user'
}
}
delete data.createAccount
delete data.account_username
delete data.account_password
delete data.account_role
if (editingPerson) {
const res = await axios.put(`/api/personnel/${editingPerson.id}`, data)
if (res.data.success) {
message.success('人员更新成功')
}
} else {
const res = await axios.post('/api/personnel', data)
if (res.data.success) {
message.success('人员添加成功')
}
}
setModalVisible(false)
fetchPersonnel()
} catch (err) {
if (err.response) {
message.error(err.response.data.message || '操作失败')
}
}
}
const handleReset = () => {
setKeyword('')
setFilterDept('')
setFilterStatus('')
}
const getStatusTag = (status) => {
return <Tag color={status === '在职' ? 'green' : status === '离职' ? 'red' : 'default'}>{status}</Tag>
}
const columns = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '工号', dataIndex: 'employee_id', key: 'employee_id', width: 100 },
{ title: '部门', dataIndex: 'department', key: 'department', width: 100 },
{ title: '职位', dataIndex: 'position', key: 'position', width: 130 },
{
title: '电话',
dataIndex: 'phone',
key: 'phone',
width: 130,
render: (text) => text ? <span><PhoneOutlined style={{ marginRight: 4 }} />{text}</span> : '-'
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 200,
ellipsis: true,
render: (text) => text ? <span><MailOutlined style={{ marginRight: 4 }} />{text}</span> : '-'
},
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: getStatusTag },
{
title: '登录账户',
key: 'account',
width: 140,
render: (_, record) => record.account_username
? <Space direction="vertical" size={0}>
<span>{record.account_username}</span>
<Tag color={record.account_role === 'admin' ? 'gold' : 'blue'} style={{ marginRight: 0 }}>{roleLabel(record.account_role)}</Tag>
</Space>
: <Tag>无账户</Tag>
},
{ title: '入职日期', dataIndex: 'entry_date', key: 'entry_date', width: 110 },
{
title: '操作',
key: 'action',
width: 140,
fixed: 'right',
render: (_, record) => manageAllowed ? (
<Space>
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
<Popconfirm title="确定要删除此人员吗?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
</Popconfirm>
</Space>
) : <Tag color="default">仅查看</Tag>
}
]
return (
<div>
<Card style={{ marginBottom: 16 }}>
<div className="search-bar">
<Input
placeholder="搜索姓名/工号/手机号"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
style={{ width: 220 }}
allowClear
/>
<Select
placeholder="部门"
value={filterDept || undefined}
onChange={(val) => setFilterDept(val || '')}
style={{ width: 130 }}
allowClear
>
{departments.map(d => <Option key={d} value={d}>{d}</Option>)}
</Select>
<Select
placeholder="在职状态"
value={filterStatus || undefined}
onChange={(val) => setFilterStatus(val || '')}
style={{ width: 120 }}
allowClear
>
<Option value="在职">在职</Option>
<Option value="离职">离职</Option>
</Select>
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
</div>
<div className="table-toolbar">
<span>共 {personnel.length} 条记录{!manageAllowed && '(当前为普通账户,仅可查看)'}</span>
{manageAllowed && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加人员</Button>}
</div>
</Card>
<Table
columns={columns}
dataSource={personnel}
rowKey="id"
loading={loading}
scroll={{ x: 1100 }}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`
}}
/>
<Modal
title={editingPerson ? '编辑人员' : '添加人员'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={720}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div className="form-grid">
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
</Form.Item>
<Form.Item name="employee_id" label="工号">
<Input placeholder="请输入工号" />
</Form.Item>
<Form.Item name="department" label="部门">
<Select placeholder="请选择部门" allowClear>
{departments.map(d => <Option key={d} value={d}>{d}</Option>)}
</Select>
</Form.Item>
<Form.Item name="position" label="职位">
<Input placeholder="请输入职位" />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input placeholder="请输入电话" />
</Form.Item>
<Form.Item name="email" label="邮箱">
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="在职">
<Select>
<Option value="在职">在职</Option>
<Option value="离职">离职</Option>
</Select>
</Form.Item>
<Form.Item name="entry_date" label="入职日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" title="登录账户(可选)" style={{ marginBottom: 16, background: '#fafafa' }}>
<Form.Item name="createAccount" label="创建/启用登录账户" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.createAccount !== cur.createAccount}>
{({ getFieldValue }) => getFieldValue('createAccount') ? (
<div className="form-grid">
<Form.Item name="account_username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="登录用户名" />
</Form.Item>
<Form.Item name="account_password" label="密码" rules={[{ required: !editingPerson, message: '请输入密码' }]}>
<Input.Password placeholder={editingPerson ? '留空则不修改' : '登录密码'} />
</Form.Item>
<Form.Item name="account_role" label="账户角色" initialValue="user">
<Select>
<Option value="user">普通账户</Option>
<Option value="admin">管理员账户</Option>
</Select>
</Form.Item>
</div>
) : null}
</Form.Item>
</Card>
<Form.Item name="description" label="备注">
<TextArea rows={3} placeholder="请输入备注信息" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default PersonnelManagement
+28
View File
@@ -0,0 +1,28 @@
// 角色权限定义
// 普通账户(user):添加设备、设备报修、查看人员、查看通知
// 管理员账户(admin):全部权限
const USER_PERMISSIONS = [
'device:view',
'device:add',
'device:repair',
'personnel:view',
'notification:view',
'notification:read'
]
export function isAdmin(user) {
return !!user && user.role === 'admin'
}
// 判断用户是否有某权限
export function can(user, action) {
if (!user) return false
if (user.role === 'admin') return true
return USER_PERMISSIONS.includes(action)
}
// 角色显示名
export function roleLabel(role) {
return role === 'admin' ? '管理员' : '普通用户'
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets'
}
})
+192
View File
@@ -0,0 +1,192 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(__dirname, 'data', 'device_manager.db');
// 确保 data 目录存在
const fs = require('fs');
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(dbPath);
// 启用 WAL 模式提升性能
db.pragma('journal_mode = WAL');
// 初始化数据库表
function initDatabase() {
// 设备表
db.exec(`
CREATE TABLE IF NOT EXISTS devices (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
model TEXT,
serial_number TEXT,
status TEXT DEFAULT '正常',
location TEXT,
purchase_date TEXT,
warranty_date TEXT,
responsible_person TEXT,
description TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
`);
// 人员表
db.exec(`
CREATE TABLE IF NOT EXISTS personnel (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
employee_id TEXT UNIQUE,
department TEXT,
position TEXT,
phone TEXT,
email TEXT,
status TEXT DEFAULT '在职',
entry_date TEXT,
description TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
`);
// 通知表
db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
type TEXT DEFAULT '普通',
priority TEXT DEFAULT '普通',
is_read INTEGER DEFAULT 0,
target TEXT DEFAULT '全部',
created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime'))
)
`);
// 用户表
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
role TEXT DEFAULT 'user',
display_name TEXT,
created_at TEXT DEFAULT (datetime('now', 'localtime'))
)
`);
// 用户表增加 personnel_id 关联字段(人员与账户 1:1)
const userCols = db.prepare('PRAGMA table_info(users)').all();
if (!userCols.some(c => c.name === 'personnel_id')) {
db.exec('ALTER TABLE users ADD COLUMN personnel_id TEXT');
}
// 设备表增加报废相关字段
const deviceCols = db.prepare('PRAGMA table_info(devices)').all();
if (!deviceCols.some(c => c.name === 'scrap_reason')) {
db.exec("ALTER TABLE devices ADD COLUMN scrap_reason TEXT DEFAULT ''");
db.exec("ALTER TABLE devices ADD COLUMN scrap_date TEXT DEFAULT ''");
db.exec("ALTER TABLE devices ADD COLUMN scrap_operator TEXT DEFAULT ''");
}
// 维修记录表
db.exec(`
CREATE TABLE IF NOT EXISTS maintenance_records (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
device_name TEXT DEFAULT '',
type TEXT DEFAULT '报修',
remark TEXT DEFAULT '',
operator TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now', 'localtime'))
)
`);
// 设备操作日志表
db.exec(`
CREATE TABLE IF NOT EXISTS device_logs (
id TEXT PRIMARY KEY,
device_id TEXT NOT NULL,
device_name TEXT DEFAULT '',
action TEXT NOT NULL,
detail TEXT DEFAULT '',
operator TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now', 'localtime'))
)
`);
// 账户操作日志表
db.exec(`
CREATE TABLE IF NOT EXISTS user_logs (
id TEXT PRIMARY KEY,
username TEXT DEFAULT '',
action TEXT NOT NULL,
detail TEXT DEFAULT '',
operator TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now', 'localtime'))
)
`);
// 插入示例数据
const deviceCount = db.prepare('SELECT COUNT(*) as count FROM devices').get();
if (deviceCount.count === 0) {
const insertDevice = db.prepare(`
INSERT INTO devices (id, name, type, model, serial_number, status, location, purchase_date, warranty_date, responsible_person, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertDevice.run('d001', '联想ThinkPad X1 Carbon', '笔记本电脑', 'X1 Carbon Gen 11', 'SN2024001', '正常', '办公室A区', '2024-01-15', '2027-01-15', '张三', '高管使用笔记本电脑');
insertDevice.run('d002', '戴尔U2723QE显示器', '显示器', 'U2723QE', 'SN2024002', '正常', '办公室A区', '2024-02-20', '2027-02-20', '李四', '27寸4K显示器');
insertDevice.run('d003', 'HP LaserJet Pro打印机', '打印机', 'M404dn', 'SN2024003', '维修中', '打印室', '2023-06-10', '2026-06-10', '王五', '网络打印机');
insertDevice.run('d004', '华为交换机', '网络设备', 'S5735-L24T4S', 'SN2024004', '正常', '机房', '2023-03-01', '2028-03-01', '赵六', '24口千兆交换机');
insertDevice.run('d005', 'ThinkStation P360', '台式机', 'P360 Tower', 'SN2024005', '已报废', '仓库', '2022-01-01', '2025-01-01', '张三', '开发工作站');
}
const personnelCount = db.prepare('SELECT COUNT(*) as count FROM personnel').get();
if (personnelCount.count === 0) {
const insertPerson = db.prepare(`
INSERT INTO personnel (id, name, employee_id, department, position, phone, email, status, entry_date, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertPerson.run('p001', '张三', 'EMP001', '拍摄组', '拍摄设备主管', '13800138001', 'zhangsan@company.com', '在职', '2020-03-15', '负责拍摄设备与摄像器材管理');
insertPerson.run('p002', '李四', 'EMP002', '拍摄组', '摄影助理', '13800138002', 'lisi@company.com', '在职', '2021-06-01', '负责显示设备管理');
insertPerson.run('p003', '王五', 'EMP003', '后勤组', '后勤专员', '13800138003', 'wangwu@company.com', '在职', '2022-01-10', '负责办公设备与物资保障');
insertPerson.run('p004', '赵六', 'EMP004', '后勤组', '设备维护员', '13800138004', 'zhaoliu@company.com', '在职', '2019-09-01', '负责设备维护与保养');
insertPerson.run('p005', '钱七', 'EMP005', '监督组', '监督主管', '13800138005', 'qianqi@company.com', '在职', '2018-05-20', '负责设备使用监督与盘点核查');
}
const notifCount = db.prepare('SELECT COUNT(*) as count FROM notifications').get();
if (notifCount.count === 0) {
const insertNotif = db.prepare(`
INSERT INTO notifications (id, title, content, type, priority, is_read, target)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
insertNotif.run('n001', '设备盘点通知', '请各部门于本月底前完成设备盘点工作,并提交盘点报告。', '通知', '高', 0, '全部');
insertNotif.run('n002', '打印机维修通知', '打印室HP打印机正在进行维修,预计明天恢复使用。', '公告', '普通', 0, '全部');
insertNotif.run('n003', '新设备到货通知', '新采购的10台联想笔记本已到货,请相关人员前来领取。', '通知', '普通', 1, '拍摄组');
insertNotif.run('n004', '系统升级提醒', '设备管理系统将于本周六凌晨2点进行升级维护。', '提醒', '高', 0, '全部');
}
// 初始化默认账户(管理员 + 示例普通用户)
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get();
if (userCount.count === 0) {
const insertUser = db.prepare(`
INSERT INTO users (id, username, password, role, display_name, personnel_id)
VALUES (?, ?, ?, ?, ?, ?)
`);
insertUser.run('u001', 'admin', 'admin123', 'admin', '系统管理员', null);
insertUser.run('u002', 'zhangsan', '123456', 'user', '张三', 'p001');
}
}
initDatabase();
module.exports = db;
+45
View File
@@ -0,0 +1,45 @@
const { app, BrowserWindow } = require('electron');
const path = require('path');
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1024,
minHeight: 680,
title: '影视星河设备管理系统',
icon: path.join(__dirname, 'icon.ico'),
webPreferences: {
nodeIntegration: false,
contextIsolation: true
},
autoHideMenuBar: true
});
// 加载本地服务器
mainWindow.loadURL('http://localhost:3001');
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(() => {
// 启动后端服务器
require('../server.js');
// 等待服务器启动后创建窗口
setTimeout(createWindow, 1500);
});
app.on('window-all-closed', () => {
app.quit();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
+5972
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
{
"name": "device-management-system",
"version": "1.0.0",
"description": "影视星河设备管理系统 - Device Management System",
"main": "electron/main.js",
"scripts": {
"dev": "concurrently \"node server.js\" \"cd client && npm run dev\"",
"server": "node server.js",
"client": "cd client && npm run dev",
"build": "cd client && npm run build",
"start": "node server.js",
"electron:dev": "concurrently \"node server.js\" \"wait-on http://localhost:3001 && electron .\"",
"electron:build": "npm run build && electron-builder",
"electron:build:win": "npm run build && electron-builder --win",
"electron:build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"better-sqlite3": "^13.0.3",
"cors": "^2.8.5",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"concurrently": "^8.2.2",
"electron": "^28.1.0",
"electron-builder": "^24.9.1",
"vite": "^8.2.1",
"wait-on": "^7.2.0"
},
"build": {
"appId": "com.devicemanager.app",
"productName": "影视星河设备管理系统",
"directories": {
"output": "dist-electron"
},
"files": [
"server.js",
"database.js",
"electron/**/*",
"client/dist/**/*"
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"icon": "electron/icon.ico"
},
"nsis": {
"oneClick": false,
"perMachine": true,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"linux": {
"target": [
"AppImage",
"deb"
]
}
}
}
+809
View File
@@ -0,0 +1,809 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const db = require('./database');
const app = express();
const PORT = process.env.PORT || 3001;
// 内存会话存储
const sessions = new Map();
// 登录验证中间件(仅对 /api 路由生效)
function authMiddleware(req, res, next) {
const publicPaths = ['/auth/login', '/auth/logout', '/weather'];
if (publicPaths.some(path => req.path.startsWith(path))) {
return next();
}
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token || !sessions.has(token)) {
return res.status(401).json({ success: false, message: '未登录或登录已过期' });
}
req.user = sessions.get(token);
next();
}
// 管理员权限中间件
function requireAdmin(req, res, next) {
if (req.user && req.user.role === 'admin') {
return next();
}
return res.status(403).json({ success: false, message: '权限不足,该操作仅管理员可用' });
}
app.use(cors());
app.use(express.json());
// API 认证中间件
app.use('/api', authMiddleware);
// 生产环境提供前端静态文件
const clientBuildPath = path.join(__dirname, 'client', 'dist');
app.use(express.static(clientBuildPath));
// ==================== 设备管理 API ====================
// 记录设备操作日志
function logDeviceAction(deviceId, deviceName, action, detail, operator) {
db.prepare(`
INSERT INTO device_logs (id, device_id, device_name, action, detail, operator)
VALUES (?, ?, ?, ?, ?, ?)
`).run(uuidv4(), deviceId, deviceName || '', action, detail || '', operator || '');
}
// 写入维修记录
function addMaintenanceRecord(deviceId, deviceName, type, remark, operator) {
db.prepare(`
INSERT INTO maintenance_records (id, device_id, device_name, type, remark, operator)
VALUES (?, ?, ?, ?, ?, ?)
`).run(uuidv4(), deviceId, deviceName || '', type, remark || '', operator || '');
}
const getOperator = (req) => (req.user ? (req.user.display_name || req.user.username) : '');
// 获取所有设备
app.get('/api/devices', (req, res) => {
const { keyword, type, status } = req.query;
let sql = 'SELECT * FROM devices WHERE 1=1';
const params = [];
if (keyword) {
sql += ' AND (name LIKE ? OR serial_number LIKE ? OR location LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
if (type) {
sql += ' AND type = ?';
params.push(type);
}
if (status) {
sql += ' AND status = ?';
params.push(status);
}
sql += ' ORDER BY created_at DESC';
const devices = db.prepare(sql).all(...params);
res.json({ success: true, data: devices });
});
// 报废设备列表(须定义在 /api/devices/:id 之前,避免路由遮蔽)
app.get('/api/devices/scrapped', (req, res) => {
const scrapped = db.prepare(
"SELECT * FROM devices WHERE status = '已报废' ORDER BY scrap_date DESC, updated_at DESC"
).all();
res.json({ success: true, data: scrapped });
});
// 设备操作日志(管理员)
app.get('/api/devices/logs', requireAdmin, (req, res) => {
const { device_id, keyword } = req.query;
let sql = 'SELECT * FROM device_logs WHERE 1=1';
const params = [];
if (device_id) {
sql += ' AND device_id = ?';
params.push(device_id);
}
if (keyword) {
sql += ' AND (device_name LIKE ? OR action LIKE ? OR detail LIKE ? OR operator LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
sql += ' ORDER BY created_at DESC LIMIT 500';
res.json({ success: true, data: db.prepare(sql).all(...params) });
});
// 设备统计
app.get('/api/devices/stats/summary', (req, res) => {
const total = db.prepare('SELECT COUNT(*) as count FROM devices').get().count;
const normal = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='正常'").get().count;
const repairing = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='维修中'").get().count;
const scrapped = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='已报废'").get().count;
const idle = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='闲置'").get().count;
const scrappedThisMonth = db.prepare(
"SELECT COUNT(*) as count FROM devices WHERE status='已报废' AND substr(scrap_date,1,7) = substr(date('now','localtime'),1,7)"
).get().count;
const types = db.prepare('SELECT type, COUNT(*) as count FROM devices GROUP BY type').all();
res.json({ success: true, data: { total, normal, repairing, scrapped, idle, scrappedThisMonth, types } });
});
// 获取单个设备
app.get('/api/devices/:id', (req, res) => {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (device) {
res.json({ success: true, data: device });
} else {
res.status(404).json({ success: false, message: '设备不存在' });
}
});
// 获取设备维修/保养记录
app.get('/api/devices/:id/records', (req, res) => {
const records = db.prepare(
'SELECT * FROM maintenance_records WHERE device_id = ? ORDER BY created_at DESC'
).all(req.params.id);
res.json({ success: true, data: records });
});
// 添加设备
app.post('/api/devices', (req, res) => {
const { name, type, model, serial_number, status, location, purchase_date, warranty_date, responsible_person, description } = req.body;
const id = uuidv4();
db.prepare(`
INSERT INTO devices (id, name, type, model, serial_number, status, location, purchase_date, warranty_date, responsible_person, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, name, type, model || '', serial_number || '', status || '正常', location || '', purchase_date || '', warranty_date || '', responsible_person || '', description || '');
logDeviceAction(id, name, '添加', '新设备入库', getOperator(req));
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id);
res.json({ success: true, data: device, message: '设备添加成功' });
});
// 更新设备
app.put('/api/devices/:id', requireAdmin, (req, res) => {
const { name, type, model, serial_number, status, location, purchase_date, warranty_date, responsible_person, description } = req.body;
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
db.prepare(`
UPDATE devices SET name=?, type=?, model=?, serial_number=?, status=?, location=?, purchase_date=?, warranty_date=?, responsible_person=?, description=?, updated_at=datetime('now','localtime')
WHERE id=?
`).run(name, type, model || '', serial_number || '', status, location || '', purchase_date || '', warranty_date || '', responsible_person || '', description || '', req.params.id);
logDeviceAction(req.params.id, name, '编辑', '设备信息已更新', getOperator(req));
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: '设备更新成功' });
});
// 删除设备(管理员)
app.delete('/api/devices/:id', requireAdmin, (req, res) => {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
db.prepare('DELETE FROM devices WHERE id = ?').run(req.params.id);
db.prepare('DELETE FROM maintenance_records WHERE device_id = ?').run(req.params.id);
db.prepare('DELETE FROM device_logs WHERE device_id = ?').run(req.params.id);
res.json({ success: true, message: '设备删除成功' });
});
// 设备报修(普通用户+管理员):将状态置为维修中
app.patch('/api/devices/:id/repair', (req, res) => {
const { remark } = req.body || {};
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
if (device.status === '已报废') {
return res.status(400).json({ success: false, message: '已报废设备不可报修' });
}
db.prepare(`
UPDATE devices SET status='维修中', updated_at=datetime('now','localtime') WHERE id=?
`).run(req.params.id);
addMaintenanceRecord(req.params.id, device.name, '报修', remark || '', getOperator(req));
logDeviceAction(req.params.id, device.name, '报修', remark || '设备已标记为维修中', getOperator(req));
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: '报修成功,设备已标记为维修中' });
});
// 修改设备维修状态(管理员)
app.patch('/api/devices/:id/status', requireAdmin, (req, res) => {
const { status } = req.body || {};
const allowed = ['正常', '维修中', '已报废', '闲置'];
if (!status || !allowed.includes(status)) {
return res.status(400).json({ success: false, message: '无效的状态值' });
}
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
if (status === device.status) {
return res.json({ success: true, data: device, message: '状态未发生变化' });
}
if (status !== '已报废') {
// 离开报废状态时清空报废信息
db.prepare("UPDATE devices SET status=?, scrap_reason='', scrap_date='', scrap_operator='', updated_at=datetime('now','localtime') WHERE id=?").run(status, req.params.id);
} else {
db.prepare("UPDATE devices SET status=?, updated_at=datetime('now','localtime') WHERE id=?").run(status, req.params.id);
}
if (device.status === '维修中' && status === '正常') {
addMaintenanceRecord(req.params.id, device.name, '维修完成', '维修完成,状态恢复正常', getOperator(req));
}
logDeviceAction(req.params.id, device.name, '修改状态', `${device.status} → ${status}`, getOperator(req));
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: `维修状态已修改为:${status}` });
});
// 设备报废(管理员)
app.post('/api/devices/:id/scrap', requireAdmin, (req, res) => {
const { reason } = req.body || {};
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
if (device.status === '已报废') {
return res.status(400).json({ success: false, message: '该设备已处于报废状态' });
}
const operator = getOperator(req);
db.prepare(`
UPDATE devices SET status='已报废', scrap_reason=?, scrap_date=date('now','localtime'), scrap_operator=?, updated_at=datetime('now','localtime')
WHERE id=?
`).run(reason || '', operator, req.params.id);
addMaintenanceRecord(req.params.id, device.name, '报废', reason || '', operator);
logDeviceAction(req.params.id, device.name, '报废', reason || '设备已报废', operator);
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: '设备已报废' });
});
// 恢复报废设备(管理员):撤销报废,状态恢复为闲置
app.post('/api/devices/:id/restore', requireAdmin, (req, res) => {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
if (device.status !== '已报废') {
return res.status(400).json({ success: false, message: '仅报废状态的设备可恢复' });
}
db.prepare(`
UPDATE devices SET status='闲置', scrap_reason='', scrap_date='', scrap_operator='', updated_at=datetime('now','localtime')
WHERE id=?
`).run(req.params.id);
logDeviceAction(req.params.id, device.name, '恢复', '报废已撤销,状态恢复为闲置', getOperator(req));
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: '报废已撤销,设备状态恢复为闲置' });
});
// ==================== 人员管理 API ====================
// 获取所有人员(LEFT JOIN 账户信息)
app.get('/api/personnel', (req, res) => {
const { keyword, department, status } = req.query;
let sql = `
SELECT p.*, u.id AS user_id, u.username AS account_username, u.role AS account_role
FROM personnel p LEFT JOIN users u ON u.personnel_id = p.id
WHERE 1=1
`;
const params = [];
if (keyword) {
sql += ' AND (p.name LIKE ? OR p.employee_id LIKE ? OR p.phone LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
if (department) {
sql += ' AND p.department = ?';
params.push(department);
}
if (status) {
sql += ' AND p.status = ?';
params.push(status);
}
sql += ' ORDER BY p.created_at DESC';
const personnel = db.prepare(sql).all(...params);
res.json({ success: true, data: personnel });
});
// 获取单个人员
app.get('/api/personnel/:id', (req, res) => {
const person = db.prepare('SELECT * FROM personnel WHERE id = ?').get(req.params.id);
if (person) {
res.json({ success: true, data: person });
} else {
res.status(404).json({ success: false, message: '人员不存在' });
}
});
// 添加人员(管理员),可同时创建登录账户
app.post('/api/personnel', requireAdmin, (req, res) => {
const { name, employee_id, department, position, phone, email, status, entry_date, description, account } = req.body;
const id = uuidv4();
// 校验账户用户名唯一
if (account && account.createAccount) {
if (!account.username || !account.password) {
return res.status(400).json({ success: false, message: '创建账户需填写用户名和密码' });
}
const exist = db.prepare('SELECT id FROM users WHERE username = ?').get(account.username);
if (exist) {
return res.status(400).json({ success: false, message: `用户名 ${account.username} 已存在` });
}
}
db.prepare(`
INSERT INTO personnel (id, name, employee_id, department, position, phone, email, status, entry_date, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, name, employee_id || '', department || '', position || '', phone || '', email || '', status || '在职', entry_date || '', description || '');
// 同步创建账户
if (account && account.createAccount) {
db.prepare(`
INSERT INTO users (id, username, password, role, display_name, personnel_id)
VALUES (?, ?, ?, ?, ?, ?)
`).run(uuidv4(), account.username, account.password, account.role || 'user', name, id);
}
const person = db.prepare(`
SELECT p.*, u.id AS user_id, u.username AS account_username, u.role AS account_role
FROM personnel p LEFT JOIN users u ON u.personnel_id = p.id WHERE p.id = ?
`).get(id);
res.json({ success: true, data: person, message: account && account.createAccount ? '人员及账户创建成功' : '人员添加成功' });
});
// 更新人员(管理员),同步维护关联账户
app.put('/api/personnel/:id', requireAdmin, (req, res) => {
const { name, employee_id, department, position, phone, email, status, entry_date, description, account } = req.body;
db.prepare(`
UPDATE personnel SET name=?, employee_id=?, department=?, position=?, phone=?, email=?, status=?, entry_date=?, description=?, updated_at=datetime('now','localtime')
WHERE id=?
`).run(name, employee_id, department || '', position || '', phone || '', email || '', status, entry_date || '', description || '', req.params.id);
const existUser = db.prepare('SELECT * FROM users WHERE personnel_id = ?').get(req.params.id);
if (account && account.createAccount) {
// 校验用户名唯一(排除自身)
const dup = db.prepare('SELECT id FROM users WHERE username = ? AND personnel_id != ?').get(account.username, req.params.id);
if (dup) {
return res.status(400).json({ success: false, message: `用户名 ${account.username} 已存在` });
}
if (existUser) {
db.prepare('UPDATE users SET username=?, role=?, display_name=? WHERE personnel_id=?')
.run(account.username, account.role || 'user', name, req.params.id);
// 仅当提供新密码时更新密码
if (account.password) {
db.prepare('UPDATE users SET password=? WHERE personnel_id=?').run(account.password, req.params.id);
}
} else {
if (!account.username || !account.password) {
return res.status(400).json({ success: false, message: '创建账户需填写用户名和密码' });
}
db.prepare(`
INSERT INTO users (id, username, password, role, display_name, personnel_id)
VALUES (?, ?, ?, ?, ?, ?)
`).run(uuidv4(), account.username, account.password, account.role || 'user', name, req.params.id);
}
} else if (existUser) {
// 关闭账户:删除关联用户
db.prepare('DELETE FROM users WHERE personnel_id = ?').run(req.params.id);
}
const person = db.prepare(`
SELECT p.*, u.id AS user_id, u.username AS account_username, u.role AS account_role
FROM personnel p LEFT JOIN users u ON u.personnel_id = p.id WHERE p.id = ?
`).get(req.params.id);
res.json({ success: true, data: person, message: '人员更新成功' });
});
// 删除人员(管理员),同时删除关联账户
app.delete('/api/personnel/:id', requireAdmin, (req, res) => {
db.prepare('DELETE FROM users WHERE personnel_id = ?').run(req.params.id);
db.prepare('DELETE FROM personnel WHERE id = ?').run(req.params.id);
res.json({ success: true, message: '人员及关联账户删除成功' });
});
// 人员统计
app.get('/api/personnel/stats/summary', (req, res) => {
const total = db.prepare('SELECT COUNT(*) as count FROM personnel').get().count;
const active = db.prepare("SELECT COUNT(*) as count FROM personnel WHERE status='在职'").get().count;
const withAccount = db.prepare('SELECT COUNT(*) as count FROM personnel WHERE id IN (SELECT personnel_id FROM users WHERE personnel_id IS NOT NULL)').get().count;
const departments = db.prepare('SELECT department, COUNT(*) as count FROM personnel GROUP BY department').all();
res.json({ success: true, data: { total, active, withAccount, departments } });
});
// ==================== 通知管理 API ====================
// 获取所有通知
app.get('/api/notifications', (req, res) => {
const { keyword, type, priority, is_read } = req.query;
let sql = 'SELECT * FROM notifications WHERE 1=1';
const params = [];
if (keyword) {
sql += ' AND (title LIKE ? OR content LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`);
}
if (type) {
sql += ' AND type = ?';
params.push(type);
}
if (priority) {
sql += ' AND priority = ?';
params.push(priority);
}
if (is_read !== undefined && is_read !== '') {
sql += ' AND is_read = ?';
params.push(parseInt(is_read));
}
sql += ' ORDER BY created_at DESC';
const notifications = db.prepare(sql).all(...params);
res.json({ success: true, data: notifications });
});
// 获取单个通知
app.get('/api/notifications/:id', (req, res) => {
const notif = db.prepare('SELECT * FROM notifications WHERE id = ?').get(req.params.id);
if (notif) {
res.json({ success: true, data: notif });
} else {
res.status(404).json({ success: false, message: '通知不存在' });
}
});
// 添加通知(管理员)
app.post('/api/notifications', requireAdmin, (req, res) => {
const { title, content, type, priority, target } = req.body;
const id = uuidv4();
db.prepare(`
INSERT INTO notifications (id, title, content, type, priority, is_read, target)
VALUES (?, ?, ?, ?, ?, 0, ?)
`).run(id, title, content, type || '普通', priority || '普通', target || '全部');
const notif = db.prepare('SELECT * FROM notifications WHERE id = ?').get(id);
res.json({ success: true, data: notif, message: '通知发布成功' });
});
// 更新通知(管理员)
app.put('/api/notifications/:id', requireAdmin, (req, res) => {
const { title, content, type, priority, is_read, target } = req.body;
db.prepare(`
UPDATE notifications SET title=?, content=?, type=?, priority=?, is_read=?, target=?, updated_at=datetime('now','localtime')
WHERE id=?
`).run(title, content, type, priority, is_read ? 1 : 0, target, req.params.id);
const notif = db.prepare('SELECT * FROM notifications WHERE id = ?').get(req.params.id);
res.json({ success: true, data: notif, message: '通知更新成功' });
});
// 标记已读
app.patch('/api/notifications/:id/read', (req, res) => {
db.prepare("UPDATE notifications SET is_read=1, updated_at=datetime('now','localtime') WHERE id=?").run(req.params.id);
const notif = db.prepare('SELECT * FROM notifications WHERE id = ?').get(req.params.id);
res.json({ success: true, data: notif, message: '已标记为已读' });
});
// 全部标记已读
app.patch('/api/notifications/read-all', (req, res) => {
db.prepare("UPDATE notifications SET is_read=1, updated_at=datetime('now','localtime') WHERE is_read=0").run();
res.json({ success: true, message: '全部已标记为已读' });
});
// 删除通知(管理员)
app.delete('/api/notifications/:id', requireAdmin, (req, res) => {
db.prepare('DELETE FROM notifications WHERE id = ?').run(req.params.id);
res.json({ success: true, message: '通知删除成功' });
});
// 通知统计
app.get('/api/notifications/stats/summary', (req, res) => {
const total = db.prepare('SELECT COUNT(*) as count FROM notifications').get().count;
const unread = db.prepare('SELECT COUNT(*) as count FROM notifications WHERE is_read=0').get().count;
const high = db.prepare("SELECT COUNT(*) as count FROM notifications WHERE priority='高' AND is_read=0").get().count;
res.json({ success: true, data: { total, unread, high } });
});
// 清空已读通知(管理员)
app.delete('/api/notifications/read/clear', requireAdmin, (req, res) => {
const result = db.prepare('DELETE FROM notifications WHERE is_read = 1').run();
res.json({ success: true, message: `已清理 ${result.changes} 条已读通知` });
});
// ==================== 账户管理 API ====================
// 记录账户操作日志
function logUserAction(username, action, detail, operator) {
db.prepare(`
INSERT INTO user_logs (id, username, action, detail, operator)
VALUES (?, ?, ?, ?, ?)
`).run(uuidv4(), username || '', action, detail || '', operator || '');
}
// 获取所有账户(管理员,含关联人员)
app.get('/api/users', requireAdmin, (req, res) => {
const { keyword, role } = req.query;
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
FROM users u LEFT JOIN personnel p ON u.personnel_id = p.id
WHERE 1=1
`;
const params = [];
if (keyword) {
sql += ' AND (u.username LIKE ? OR u.display_name LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`);
}
if (role) {
sql += ' AND u.role = ?';
params.push(role);
}
sql += ' ORDER BY u.created_at ASC';
res.json({ success: true, data: db.prepare(sql).all(...params) });
});
// 账户操作日志(管理员)
app.get('/api/users/logs', requireAdmin, (req, res) => {
const logs = db.prepare('SELECT * FROM user_logs ORDER BY created_at DESC LIMIT 500').all();
res.json({ success: true, data: logs });
});
// 账户统计(管理员)
app.get('/api/users/stats/summary', requireAdmin, (req, res) => {
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 users = total - admins;
res.json({ success: true, data: { total, admins, users } });
});
// 创建独立账户(管理员)
app.post('/api/users', requireAdmin, (req, res) => {
const { username, password, role, display_name } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: '用户名和密码不能为空' });
}
const exist = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (exist) {
return res.status(400).json({ success: false, message: `用户名 ${username} 已存在` });
}
const id = uuidv4();
db.prepare(`
INSERT INTO users (id, username, password, role, display_name)
VALUES (?, ?, ?, ?, ?)
`).run(id, username, password, role === 'admin' ? 'admin' : 'user', display_name || username);
logUserAction(username, '创建', `创建账户,角色:${role === 'admin' ? '管理员' : '普通用户'}`, getOperator(req));
const user = db.prepare('SELECT id, username, role, display_name, created_at FROM users WHERE id = ?').get(id);
res.json({ success: true, data: user, message: '账户创建成功' });
});
// 更新账户信息(管理员:用户名/角色/显示名)
app.put('/api/users/:id', requireAdmin, (req, res) => {
const { username, role, display_name } = req.body;
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) {
return res.status(404).json({ success: false, message: '账户不存在' });
}
// 保护最后一个管理员
if (target.role === 'admin' && role === 'user') {
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role='admin'").get().count;
if (adminCount <= 1) {
return res.status(400).json({ success: false, message: '不能降级最后一个管理员账户' });
}
}
const dup = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id);
if (dup) {
return res.status(400).json({ success: false, message: `用户名 ${username} 已存在` });
}
db.prepare('UPDATE users SET username=?, role=?, display_name=? WHERE id=?')
.run(username, role === 'admin' ? 'admin' : 'user', display_name || username, req.params.id);
// 同步会话中的用户信息
for (const [token, sessionUser] of sessions.entries()) {
if (sessionUser.id === req.params.id) {
sessions.set(token, { id: req.params.id, username, role: role === 'admin' ? 'admin' : 'user', display_name: display_name || username });
}
}
logUserAction(username, '修改', '账户信息已更新', getOperator(req));
const user = db.prepare('SELECT id, username, role, display_name, created_at FROM users WHERE id = ?').get(req.params.id);
res.json({ success: true, data: user, message: '账户更新成功' });
});
// 重置账户密码(管理员)
app.patch('/api/users/:id/reset-password', requireAdmin, (req, res) => {
const { password } = req.body;
if (!password || password.length < 6) {
return res.status(400).json({ success: false, message: '新密码不能为空且不少于6位' });
}
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) {
return res.status(404).json({ success: false, message: '账户不存在' });
}
db.prepare('UPDATE users SET password=? WHERE id=?').run(password, req.params.id);
logUserAction(target.username, '重置密码', '密码已被管理员重置', getOperator(req));
res.json({ success: true, message: `已重置 ${target.username} 的密码` });
});
// 删除账户(管理员)
app.delete('/api/users/:id', requireAdmin, (req, res) => {
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) {
return res.status(404).json({ success: false, message: '账户不存在' });
}
if (req.user && req.user.id === target.id) {
return res.status(400).json({ success: false, message: '不能删除当前登录的账户' });
}
if (target.role === 'admin') {
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role='admin'").get().count;
if (adminCount <= 1) {
return res.status(400).json({ success: false, message: '不能删除最后一个管理员账户' });
}
}
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
// 使关联的会话失效
for (const [token, sessionUser] of sessions.entries()) {
if (sessionUser.id === target.id) sessions.delete(token);
}
logUserAction(target.username, '删除', '账户已删除', getOperator(req));
res.json({ success: true, message: '账户删除成功' });
});
// 账户操作日志清理(管理员)
app.delete('/api/users/logs/clear', requireAdmin, (req, res) => {
db.prepare('DELETE FROM user_logs').run();
res.json({ success: true, message: '账户日志已清空' });
});
// ==================== 认证 API ====================
// 登录
app.post('/api/auth/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: '用户名和密码不能为空' });
}
const user = db.prepare('SELECT id, username, role, display_name FROM users WHERE username = ? AND password = ?').get(username, password);
if (!user) {
return res.status(401).json({ success: false, message: '用户名或密码错误' });
}
const token = uuidv4();
sessions.set(token, user);
res.json({
success: true,
message: '登录成功',
data: { ...user, token }
});
});
// 获取当前登录用户
app.get('/api/auth/me', (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token || !sessions.has(token)) {
return res.status(401).json({ success: false, message: '未登录' });
}
res.json({ success: true, data: sessions.get(token) });
});
// 登出
app.post('/api/auth/logout', (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) sessions.delete(token);
res.json({ success: true, message: '登出成功' });
});
// 修改自己的密码(已登录用户)
app.patch('/api/auth/password', (req, res) => {
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword || newPassword.length < 6) {
return res.status(400).json({ success: false, message: '请输入原密码,且新密码不少于6位' });
}
const dbUser = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!dbUser || dbUser.password !== oldPassword) {
return res.status(400).json({ success: false, message: '原密码不正确' });
}
db.prepare('UPDATE users SET password=? WHERE id=?').run(newPassword, req.user.id);
logUserAction(dbUser.username, '修改密码', '用户自行修改密码', dbUser.display_name || dbUser.username);
res.json({ success: true, message: '密码修改成功' });
});
// ==================== 天气 API ====================
function getWeatherDesc(code) {
const map = {
0: '晴朗', 1: '多云', 2: '多云', 3: '阴天',
45: '雾', 48: '雾凇',
51: '毛毛雨', 53: '毛毛雨', 55: '毛毛雨',
56: '冻雨', 57: '冻雨',
61: '小雨', 63: '中雨', 65: '大雨',
66: '冻雨', 67: '冻雨',
71: '小雪', 73: '中雪', 75: '大雪', 77: '雪粒',
80: '阵雨', 81: '阵雨', 82: '强阵雨',
85: '阵雪', 86: '强阵雪',
95: '雷雨', 96: '雷雨伴冰雹', 99: '强雷雨伴冰雹'
};
return map[code] || '未知';
}
app.get('/api/weather/current', async (req, res) => {
try {
const city = req.query.city || 'beijing';
// 地理编码
const geoRes = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=zh&format=json`);
const geoData = await geoRes.json();
if (!geoData.results || geoData.results.length === 0) {
return res.status(404).json({ success: false, message: '未找到该城市' });
}
const { latitude, longitude, name, country } = geoData.results[0];
// 获取天气
const weatherRes = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min,weather_code&timezone=Asia/Shanghai&forecast_days=1`
);
const weatherData = await weatherRes.json();
const current = weatherData.current;
const daily = weatherData.daily;
res.json({
success: true,
data: {
city: name,
country,
current: {
temp: current.temperature_2m,
humidity: current.relative_humidity_2m,
windSpeed: current.wind_speed_10m,
weather: getWeatherDesc(current.weather_code),
code: current.weather_code
},
daily: {
maxTemp: daily.temperature_2m_max[0],
minTemp: daily.temperature_2m_min[0],
weather: getWeatherDesc(daily.weather_code[0]),
code: daily.weather_code[0]
}
}
});
} catch (err) {
console.error('获取天气失败:', err);
res.status(500).json({ success: false, message: '获取天气失败' });
}
});
// ==================== 全局统计 API ====================
app.get('/api/dashboard/stats', (req, res) => {
const deviceTotal = db.prepare('SELECT COUNT(*) as count FROM devices').get().count;
const deviceNormal = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='正常'").get().count;
const deviceRepairing = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='维修中'").get().count;
const deviceScrapped = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='已报废'").get().count;
const personnelTotal = db.prepare('SELECT COUNT(*) as count FROM personnel').get().count;
const personnelActive = db.prepare("SELECT COUNT(*) as count FROM personnel WHERE status='在职'").get().count;
const notifTotal = db.prepare('SELECT COUNT(*) as count FROM notifications').get().count;
const notifUnread = db.prepare('SELECT COUNT(*) as count FROM notifications WHERE is_read=0').get().count;
res.json({
success: true,
data: {
deviceTotal, deviceNormal, deviceRepairing, deviceScrapped,
personnelTotal, personnelActive,
notifTotal, notifUnread
}
});
});
// SPA 路由回退 - 所有未匹配的请求返回前端
app.get('*', (req, res) => {
res.sendFile(path.join(clientBuildPath, 'index.html'));
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`\n╔══════════════════════════════════════════╗`);
console.log(`║ 设备管理系统 已启动 ║`);
console.log(`║ 地址: http://localhost:${PORT} ║`);
console.log(`║ 按 Ctrl+C 停止服务 ║`);
console.log(`╚══════════════════════════════════════════╝\n`);
});