2026-08-10 22:56:38 +08:00
|
|
|
|
import React, { useState, useEffect } from 'react'
|
2026-08-11 10:03:40 +08:00
|
|
|
|
import { Layout, Menu, theme, Button, Space, Dropdown, message, Drawer, Avatar, Modal, Form, Input, Badge, Tooltip } from 'antd'
|
2026-08-10 22:56:38 +08:00
|
|
|
|
import {
|
|
|
|
|
|
DashboardOutlined,
|
|
|
|
|
|
DesktopOutlined,
|
|
|
|
|
|
TeamOutlined,
|
|
|
|
|
|
BellOutlined,
|
|
|
|
|
|
InfoCircleOutlined,
|
|
|
|
|
|
BookOutlined,
|
|
|
|
|
|
MenuOutlined,
|
|
|
|
|
|
LogoutOutlined,
|
|
|
|
|
|
UserOutlined,
|
2026-08-11 10:03:40 +08:00
|
|
|
|
DownOutlined,
|
|
|
|
|
|
UserSwitchOutlined,
|
|
|
|
|
|
KeyOutlined,
|
|
|
|
|
|
MessageOutlined
|
2026-08-10 22:56:38 +08:00
|
|
|
|
} 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'
|
2026-08-11 10:03:40 +08:00
|
|
|
|
import AccountManagement from './pages/AccountManagement'
|
|
|
|
|
|
import Messages from './pages/Messages'
|
2026-08-10 22:56:38 +08:00
|
|
|
|
import About from './pages/About'
|
|
|
|
|
|
import Guide from './pages/Guide'
|
2026-08-11 10:22:12 +08:00
|
|
|
|
import Download from './pages/Download'
|
2026-08-10 22:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-11 10:03:40 +08:00
|
|
|
|
const [pwdModalVisible, setPwdModalVisible] = useState(false)
|
|
|
|
|
|
const [pwdForm] = Form.useForm()
|
|
|
|
|
|
const [unreadTotal, setUnreadTotal] = useState(0)
|
2026-08-10 22:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-08-11 10:03:40 +08:00
|
|
|
|
// 轮询消息未读总数(侧边栏徽标)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!user) return
|
|
|
|
|
|
const fetchUnread = () => {
|
|
|
|
|
|
axios.get('/api/chat/conversations')
|
|
|
|
|
|
.then(res => {
|
|
|
|
|
|
if (res.data.success) {
|
|
|
|
|
|
setUnreadTotal(res.data.data.reduce((s, c) => s + (c.unread || 0), 0))
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
}
|
|
|
|
|
|
fetchUnread()
|
|
|
|
|
|
const timer = setInterval(fetchUnread, 10000)
|
|
|
|
|
|
return () => clearInterval(timer)
|
|
|
|
|
|
}, [user?.id])
|
|
|
|
|
|
|
2026-08-10 22:56:38 +08:00
|
|
|
|
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: '系统首页' },
|
|
|
|
|
|
{ type: 'divider' },
|
|
|
|
|
|
{ key: 'devices', icon: <DesktopOutlined />, label: '设备管理' },
|
|
|
|
|
|
{ key: 'personnel', icon: <TeamOutlined />, label: '人员管理' },
|
2026-08-11 10:03:40 +08:00
|
|
|
|
...(user?.role === 'admin' ? [{ key: 'accounts', icon: <UserSwitchOutlined />, label: '账户管理' }] : []),
|
2026-08-11 10:22:12 +08:00
|
|
|
|
{ key: 'notifications', icon: <BellOutlined />, label: '通知管理' },
|
|
|
|
|
|
{ key: 'download', icon: <InfoCircleOutlined />, label: '软件下载' },
|
2026-08-10 22:56:38 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-08-11 10:03:40 +08:00
|
|
|
|
// 移动端底部导航(5个核心入口)
|
2026-08-10 22:56:38 +08:00
|
|
|
|
const bottomNavItems = [
|
|
|
|
|
|
{ key: 'dashboard', icon: <DashboardOutlined />, label: '首页' },
|
|
|
|
|
|
{ key: 'devices', icon: <DesktopOutlined />, label: '设备' },
|
|
|
|
|
|
{ key: 'personnel', icon: <TeamOutlined />, label: '人员' },
|
2026-08-11 10:03:40 +08:00
|
|
|
|
{ key: 'notifications', icon: <BellOutlined />, label: '通知' },
|
|
|
|
|
|
{ key: 'messages', icon: <MessageOutlined />, label: '消息' }
|
2026-08-10 22:56:38 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
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} />
|
2026-08-11 10:03:40 +08:00
|
|
|
|
case 'accounts': return user?.role === 'admin' ? <AccountManagement user={user} /> : <Dashboard onNavigate={setCurrentPage} />
|
2026-08-10 22:56:38 +08:00
|
|
|
|
case 'notifications': return <NotificationManagement user={user} />
|
2026-08-11 10:03:40 +08:00
|
|
|
|
case 'messages': return <Messages user={user} />
|
2026-08-11 10:22:12 +08:00
|
|
|
|
case 'download': return <Download />
|
2026-08-10 22:56:38 +08:00
|
|
|
|
default: return <Dashboard onNavigate={setCurrentPage} />
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const getPageTitle = () => {
|
|
|
|
|
|
const titles = {
|
2026-08-11 10:22:12 +08:00
|
|
|
|
dashboard: '系统首页', about: '软件简介', guide: '使用说明',download: '软件下载',
|
2026-08-11 10:03:40 +08:00
|
|
|
|
devices: '设备管理', personnel: '人员管理', accounts: '账户管理',
|
|
|
|
|
|
notifications: '通知管理', messages: '消息对话'
|
2026-08-10 22:56:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
return titles[currentPage] || '系统首页'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 10:03:40 +08:00
|
|
|
|
// 修改自己的密码(所有用户可用,成功后需用新密码重新登录)
|
|
|
|
|
|
const handleChangePassword = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const values = await pwdForm.validateFields()
|
|
|
|
|
|
const res = await axios.patch('/api/auth/password', {
|
|
|
|
|
|
oldPassword: values.oldPassword,
|
|
|
|
|
|
newPassword: values.newPassword
|
|
|
|
|
|
})
|
|
|
|
|
|
if (res.data.success) {
|
|
|
|
|
|
setPwdModalVisible(false)
|
|
|
|
|
|
pwdForm.resetFields()
|
|
|
|
|
|
// 后端已使当前会话失效,清除本地登录态回到登录页
|
|
|
|
|
|
localStorage.removeItem('token')
|
|
|
|
|
|
localStorage.removeItem('user')
|
|
|
|
|
|
setUser(null)
|
|
|
|
|
|
message.success(res.data.message)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (err.response) message.error(err.response.data?.message || '修改失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pwdModal = (
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title="修改密码"
|
|
|
|
|
|
open={pwdModalVisible}
|
|
|
|
|
|
onOk={handleChangePassword}
|
|
|
|
|
|
onCancel={() => setPwdModalVisible(false)}
|
|
|
|
|
|
okText="确定"
|
|
|
|
|
|
cancelText="取消"
|
|
|
|
|
|
width={420}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Form form={pwdForm} layout="vertical" style={{ marginTop: 12 }}>
|
|
|
|
|
|
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
|
|
|
|
|
|
<Input.Password placeholder="请输入原密码" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="newPassword"
|
|
|
|
|
|
label="新密码"
|
|
|
|
|
|
rules={[
|
|
|
|
|
|
{ required: true, message: '请输入新密码' },
|
|
|
|
|
|
{ min: 6, message: '密码不少于6位' }
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input.Password placeholder="请输入新密码" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="confirmPassword"
|
|
|
|
|
|
label="确认新密码"
|
|
|
|
|
|
dependencies={['newPassword']}
|
|
|
|
|
|
rules={[
|
|
|
|
|
|
{ required: true, message: '请再次输入新密码' },
|
|
|
|
|
|
({ getFieldValue }) => ({
|
|
|
|
|
|
validator(_, value) {
|
|
|
|
|
|
if (!value || getFieldValue('newPassword') === value) return Promise.resolve()
|
|
|
|
|
|
return Promise.reject(new Error('两次输入的密码不一致'))
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Input.Password placeholder="请再次输入新密码" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-10 22:56:38 +08:00
|
|
|
|
const userMenuItems = [
|
|
|
|
|
|
{ key: 'role', label: `角色:${user?.role === 'admin' ? '管理员' : '普通用户'}`, icon: <UserOutlined /> },
|
|
|
|
|
|
{ type: 'divider' },
|
2026-08-11 10:03:40 +08:00
|
|
|
|
{ key: 'changePwd', label: '修改密码', icon: <KeyOutlined />, onClick: () => setPwdModalVisible(true) },
|
2026-08-10 22:56:38 +08:00
|
|
|
|
{ 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>
|
2026-08-11 10:03:40 +08:00
|
|
|
|
|
|
|
|
|
|
{pwdModal}
|
2026-08-10 22:56:38 +08:00
|
|
|
|
</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)}
|
|
|
|
|
|
/>
|
2026-08-11 10:03:40 +08:00
|
|
|
|
<div className="sider-footer">
|
|
|
|
|
|
<Tooltip title={collapsed ? '消息对话' : ''} placement="right">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
block
|
|
|
|
|
|
className={currentPage === 'messages' ? 'sider-footer-active' : ''}
|
|
|
|
|
|
onClick={() => setCurrentPage('messages')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Badge count={unreadTotal} size="small" offset={collapsed ? [12, -8] : [8, 0]}>
|
|
|
|
|
|
<MessageOutlined style={{ fontSize: 16 }} />
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
{!collapsed && <span style={{ marginLeft: 10 }}>消息对话</span>}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</div>
|
2026-08-10 22:56:38 +08:00
|
|
|
|
</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>
|
2026-08-11 10:03:40 +08:00
|
|
|
|
<Button type="text" icon={<KeyOutlined />} onClick={() => setPwdModalVisible(true)}>
|
|
|
|
|
|
修改密码
|
|
|
|
|
|
</Button>
|
2026-08-10 22:56:38 +08:00
|
|
|
|
<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>
|
2026-08-11 10:03:40 +08:00
|
|
|
|
|
|
|
|
|
|
{pwdModal}
|
2026-08-10 22:56:38 +08:00
|
|
|
|
</Layout>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default App
|