first commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist-electron/
|
||||
data/
|
||||
*.db
|
||||
client/dist/
|
||||
client/node_modules/
|
||||
client/android/
|
||||
client/ios/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
Generated
+4287
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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'))
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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' ? '管理员' : '普通用户'
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Generated
+5972
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}¤t=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`);
|
||||
});
|
||||
Reference in New Issue
Block a user