first commit
This commit is contained in:
@@ -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'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user