This commit is contained in:
mei
2026-08-11 10:03:40 +08:00
parent 2086bd79cc
commit fb326279fa
25 changed files with 2590 additions and 2379 deletions
+312 -310
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "device-management-client",
"private": true,
"version": "1.0.0",
"version": "2.1.0",
"type": "module",
"scripts": {
"dev": "vite",
+124 -7
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Layout, Menu, theme, Button, Space, Dropdown, message, Drawer, Avatar } from 'antd'
import { Layout, Menu, theme, Button, Space, Dropdown, message, Drawer, Avatar, Modal, Form, Input, Badge, Tooltip } from 'antd'
import {
DashboardOutlined,
DesktopOutlined,
@@ -10,7 +10,10 @@ import {
MenuOutlined,
LogoutOutlined,
UserOutlined,
DownOutlined
DownOutlined,
UserSwitchOutlined,
KeyOutlined,
MessageOutlined
} from '@ant-design/icons'
import axios from 'axios'
import Login from './pages/Login'
@@ -19,6 +22,8 @@ import Dashboard from './pages/Dashboard'
import DeviceManagement from './pages/DeviceManagement'
import PersonnelManagement from './pages/PersonnelManagement'
import NotificationManagement from './pages/NotificationManagement'
import AccountManagement from './pages/AccountManagement'
import Messages from './pages/Messages'
import About from './pages/About'
import Guide from './pages/Guide'
@@ -42,6 +47,9 @@ function App() {
const [currentPage, setCurrentPage] = useState('dashboard')
const [user, setUser] = useState(null)
const [checkingAuth, setCheckingAuth] = useState(true)
const [pwdModalVisible, setPwdModalVisible] = useState(false)
const [pwdForm] = Form.useForm()
const [unreadTotal, setUnreadTotal] = useState(0)
const {
token: { colorBgContainer, borderRadiusLG }
@@ -69,6 +77,23 @@ function App() {
return () => window.removeEventListener('auth-expired', onExpired)
}, [])
// 轮询消息未读总数(侧边栏徽标)
useEffect(() => {
if (!user) return
const fetchUnread = () => {
axios.get('/api/chat/conversations')
.then(res => {
if (res.data.success) {
setUnreadTotal(res.data.data.reduce((s, c) => s + (c.unread || 0), 0))
}
})
.catch(() => {})
}
fetchUnread()
const timer = setInterval(fetchUnread, 10000)
return () => clearInterval(timer)
}, [user?.id])
const handleLogin = (userData) => {
setUser(userData)
setCurrentPage('dashboard')
@@ -96,20 +121,20 @@ function App() {
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: '人员管理' },
...(user?.role === 'admin' ? [{ key: 'accounts', icon: <UserSwitchOutlined />, label: '账户管理' }] : []),
{ key: 'notifications', icon: <BellOutlined />, label: '通知管理' }
]
// 移动端底部导航(4个核心入口)
// 移动端底部导航(5个核心入口)
const bottomNavItems = [
{ key: 'dashboard', icon: <DashboardOutlined />, label: '首页' },
{ key: 'devices', icon: <DesktopOutlined />, label: '设备' },
{ key: 'personnel', icon: <TeamOutlined />, label: '人员' },
{ key: 'notifications', icon: <BellOutlined />, label: '通知' }
{ key: 'notifications', icon: <BellOutlined />, label: '通知' },
{ key: 'messages', icon: <MessageOutlined />, label: '消息' }
]
const renderPage = () => {
@@ -119,7 +144,9 @@ function App() {
case 'guide': return <Guide />
case 'devices': return <DeviceManagement user={user} />
case 'personnel': return <PersonnelManagement user={user} />
case 'accounts': return user?.role === 'admin' ? <AccountManagement user={user} /> : <Dashboard onNavigate={setCurrentPage} />
case 'notifications': return <NotificationManagement user={user} />
case 'messages': return <Messages user={user} />
default: return <Dashboard onNavigate={setCurrentPage} />
}
}
@@ -127,14 +154,82 @@ function App() {
const getPageTitle = () => {
const titles = {
dashboard: '系统首页', about: '软件简介', guide: '使用说明',
devices: '设备管理', personnel: '人员管理', notifications: '通知管理'
devices: '设备管理', personnel: '人员管理', accounts: '账户管理',
notifications: '通知管理', messages: '消息对话'
}
return titles[currentPage] || '系统首页'
}
// 修改自己的密码(所有用户可用,成功后需用新密码重新登录)
const handleChangePassword = async () => {
try {
const values = await pwdForm.validateFields()
const res = await axios.patch('/api/auth/password', {
oldPassword: values.oldPassword,
newPassword: values.newPassword
})
if (res.data.success) {
setPwdModalVisible(false)
pwdForm.resetFields()
// 后端已使当前会话失效,清除本地登录态回到登录页
localStorage.removeItem('token')
localStorage.removeItem('user')
setUser(null)
message.success(res.data.message)
}
} catch (err) {
if (err.response) message.error(err.response.data?.message || '修改失败')
}
}
const pwdModal = (
<Modal
title="修改密码"
open={pwdModalVisible}
onOk={handleChangePassword}
onCancel={() => setPwdModalVisible(false)}
okText="确定"
cancelText="取消"
width={420}
>
<Form form={pwdForm} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
<Input.Password placeholder="请输入原密码" />
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码不少于6位' }
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认新密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) return Promise.resolve()
return Promise.reject(new Error('两次输入的密码不一致'))
}
})
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
</Form>
</Modal>
)
const userMenuItems = [
{ key: 'role', label: `角色:${user?.role === 'admin' ? '管理员' : '普通用户'}`, icon: <UserOutlined /> },
{ type: 'divider' },
{ key: 'changePwd', label: '修改密码', icon: <KeyOutlined />, onClick: () => setPwdModalVisible(true) },
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, danger: true, onClick: () => handleLogout() }
]
@@ -204,6 +299,8 @@ function App() {
</button>
))}
</nav>
{pwdModal}
</Layout>
)
}
@@ -226,6 +323,21 @@ function App() {
items={menuItems}
onClick={({ key }) => setCurrentPage(key)}
/>
<div className="sider-footer">
<Tooltip title={collapsed ? '消息对话' : ''} placement="right">
<Button
type="text"
block
className={currentPage === 'messages' ? 'sider-footer-active' : ''}
onClick={() => setCurrentPage('messages')}
>
<Badge count={unreadTotal} size="small" offset={collapsed ? [12, -8] : [8, 0]}>
<MessageOutlined style={{ fontSize: 16 }} />
</Badge>
{!collapsed && <span style={{ marginLeft: 10 }}>消息对话</span>}
</Button>
</Tooltip>
</div>
</Sider>
<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)' }}>
@@ -233,6 +345,9 @@ function App() {
<Space size="large" align="center">
<Weather />
<div className="greeting-text">{user.display_name || user.username},{getGreeting()}!</div>
<Button type="text" icon={<KeyOutlined />} onClick={() => setPwdModalVisible(true)}>
修改密码
</Button>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{user.display_name || user.username}
@@ -245,6 +360,8 @@ function App() {
{renderPage()}
</Content>
</Layout>
{pwdModal}
</Layout>
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { Tag, Spin, Tooltip } from 'antd'
import { CloudOutlined, SunOutlined, CloudRain, ThunderboltOutlined } from '@ant-design/icons'
import { CloudOutlined, SunOutlined, ThunderboltOutlined } from '@ant-design/icons'
import axios from 'axios'
function Weather() {
@@ -27,7 +27,7 @@ function Weather() {
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' }} />
if ([61, 63, 65, 80, 81, 82].includes(code)) return <CloudOutlined style={{ color: '#1890ff' }} />
return <CloudOutlined style={{ color: '#8c8c8c' }} />
}
+144
View File
@@ -490,3 +490,147 @@ body {
grid-template-columns: 1fr;
}
}
/* ==================== 侧边栏底部固定按钮 ==================== */
.ant-layout-sider-children {
display: flex;
flex-direction: column;
}
.sider-footer {
margin-top: auto;
padding: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.sider-footer .ant-btn {
color: rgba(255, 255, 255, 0.75);
justify-content: flex-start;
}
.sider-footer .ant-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.08);
}
.sider-footer .sider-footer-active,
.sider-footer .sider-footer-active:hover {
color: #fff;
background: #7c3aed;
}
/* ==================== 消息对话页面 ==================== */
.messages-layout {
display: flex;
min-height: 560px;
}
.messages-list {
width: 320px;
border-right: 1px solid #f0f0f0;
padding: 12px;
overflow-y: auto;
}
.messages-chat {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.conv-item:hover {
background: #f5f5f5;
border-radius: 6px;
}
.conv-item-active,
.conv-item-active:hover {
background: #f0ebff;
border-radius: 6px;
}
.chat-window {
display: flex;
flex-direction: column;
height: 560px;
}
.chat-header {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
background: #fafafa;
}
.chat-msg {
margin-bottom: 14px;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.chat-msg-mine {
align-items: flex-end;
}
.chat-msg-sender {
font-size: 12px;
color: #999;
margin-bottom: 3px;
}
.chat-bubble {
max-width: 70%;
padding: 8px 12px;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
}
.chat-bubble-mine {
background: #7c3aed;
border-color: #7c3aed;
color: #fff;
}
.chat-msg-time {
font-size: 11px;
color: #bbb;
margin-top: 3px;
}
.chat-input {
display: flex;
gap: 8px;
align-items: flex-end;
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
}
.chat-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 560px;
}
@media (max-width: 768px) {
.chat-window {
height: calc(100vh - 220px);
}
.chat-bubble {
max-width: 82%;
}
}
+15 -2
View File
@@ -5,7 +5,7 @@ function About() {
return (
<div className="about-page">
<Card>
<h1>影视星河 · 设备管理系统 v1.0.0</h1>
<h1>影视星河 · 设备管理系统 v2.1</h1>
<Divider />
<h2>软件概述</h2>
@@ -62,10 +62,23 @@ function About() {
<h2>版本信息</h2>
<ul>
<li>当前版本:v1.0.0</li>
<li>当前版本:v2.1</li>
<li>发布日期:2026年8月</li>
<li>许可证:MIT License</li>
</ul>
<h2>更新日志</h2>
<h3>v2.1(2026年8月)</h3>
<ol>
<li>添加用户修改密码功能</li>
<li>添加用户聊天建群和在线状态</li>
<li>优化页面布局</li>
<li>修复已知问题</li>
</ol>
<h3>v1.0.0(2026年8月)</h3>
<ul>
<li>首个正式版本:设备管理、人员管理、通知管理、账户管理、数据概览、天气服务等核心功能上线</li>
</ul>
</Card>
</div>
)
+330
View File
@@ -0,0 +1,330 @@
import React, { useState, useEffect, useRef } from 'react'
import { Table, Button, Input, Select, Space, Tag, Modal, Form, message, Popconfirm, Card, Badge } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, KeyOutlined } from '@ant-design/icons'
import axios from 'axios'
import { roleLabel } from '../permissions'
const { Option } = Select
function AccountManagement({ user }) {
const [users, setUsers] = useState([])
const [logs, setLogs] = useState([])
const [loading, setLoading] = useState(false)
const [logsLoading, setLogsLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const [keyword, setKeyword] = useState('')
const [filterRole, setFilterRole] = useState('')
const [form] = Form.useForm()
const [resetForm] = Form.useForm()
useEffect(() => {
fetchUsers()
fetchLogs()
}, [keyword, filterRole])
const fetchUsers = async () => {
setLoading(true)
try {
const params = {}
if (keyword) params.keyword = keyword
if (filterRole) params.role = filterRole
const res = await axios.get('/api/users', { params })
if (res.data.success) setUsers(res.data.data)
} catch (err) {
message.error(err.response?.data?.message || '获取账户列表失败')
} finally {
setLoading(false)
}
}
// 每 30 秒自动刷新在线状态
const fetchUsersRef = useRef(fetchUsers)
fetchUsersRef.current = fetchUsers
useEffect(() => {
const timer = setInterval(() => fetchUsersRef.current(), 30000)
return () => clearInterval(timer)
}, [])
const fetchLogs = async () => {
setLogsLoading(true)
try {
const res = await axios.get('/api/users/logs')
if (res.data.success) setLogs(res.data.data)
} catch (err) {
// 静默失败
} finally {
setLogsLoading(false)
}
}
const handleAdd = () => {
setEditingUser(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditingUser(record)
form.setFieldsValue({
username: record.username,
role: record.role,
display_name: record.display_name
})
setModalVisible(true)
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
if (editingUser) {
const res = await axios.put(`/api/users/${editingUser.id}`, values)
if (res.data.success) {
message.success(res.data.message)
setModalVisible(false)
fetchUsers()
fetchLogs()
}
} else {
const res = await axios.post('/api/users', values)
if (res.data.success) {
message.success(res.data.message)
setModalVisible(false)
fetchUsers()
fetchLogs()
}
}
} catch (err) {
if (err.response) message.error(err.response.data?.message || '操作失败')
}
}
const handleDelete = async (id) => {
try {
const res = await axios.delete(`/api/users/${id}`)
if (res.data.success) {
message.success(res.data.message)
fetchUsers()
fetchLogs()
}
} catch (err) {
message.error(err.response?.data?.message || '删除失败')
}
}
const handleOpenReset = (record) => {
resetForm.resetFields()
Modal.confirm({
title: `重置密码:${record.username}`,
icon: null,
content: (
<Form form={resetForm} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item
name="password"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码不少于6位' }
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
</Form>
),
okText: '确认重置',
cancelText: '取消',
onOk: async () => {
try {
const values = await resetForm.validateFields()
const res = await axios.patch(`/api/users/${record.id}/reset-password`, values)
if (res.data.success) {
message.success(res.data.message)
fetchLogs()
}
} catch (err) {
if (err.response) message.error(err.response.data?.message || '重置失败')
return Promise.reject()
}
}
})
}
const handleClearLogs = async () => {
try {
const res = await axios.delete('/api/users/logs/clear')
if (res.data.success) {
message.success(res.data.message)
fetchLogs()
}
} catch (err) {
message.error('清空失败')
}
}
const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username', width: 140 },
{ title: '显示名', dataIndex: 'display_name', key: 'display_name', width: 120, render: (t) => t || '-' },
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 100,
render: (role) => <Tag color={role === 'admin' ? 'gold' : 'blue'}>{roleLabel(role)}</Tag>
},
{
title: '在线状态',
key: 'online',
width: 150,
render: (_, record) => (
<div>
<Badge
status={record.is_online ? 'success' : 'default'}
text={record.is_online ? '在线' : '离线'}
/>
{record.is_online && record.last_active && (
<div style={{ fontSize: 12, color: '#999' }}>最近活跃:{record.last_active}</div>
)}
{!record.is_online && record.last_login && (
<div style={{ fontSize: 12, color: '#999' }}>上次登录:{record.last_login}</div>
)}
</div>
)
},
{
title: '关联人员',
key: 'personnel',
width: 160,
render: (_, record) => record.personnel_name
? <span>{record.personnel_name}{record.department ? `(${record.department})` : ''}</span>
: <Tag>独立账户</Tag>
},
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
{
title: '操作',
key: 'action',
width: 220,
fixed: 'right',
render: (_, record) => (
<Space>
<Button type="link" icon={<KeyOutlined />} size="small" onClick={() => handleOpenReset(record)}>重置密码</Button>
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
<Popconfirm
title="确定要删除此账户吗?"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
disabled={record.id === user?.id}
>
<Button type="link" danger icon={<DeleteOutlined />} size="small" disabled={record.id === user?.id}>删除</Button>
</Popconfirm>
</Space>
)
}
]
const logColumns = [
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 160 },
{ title: '账户', dataIndex: 'username', key: 'username', width: 120 },
{
title: '操作',
dataIndex: 'action',
key: 'action',
width: 100,
render: (a) => <Tag color={a === '删除' ? 'red' : a === '创建' ? 'green' : 'blue'}>{a}</Tag>
},
{ title: '详情', dataIndex: 'detail', key: 'detail', render: (t) => t || '-' },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 120, render: (t) => t || '-' }
]
return (
<div className="page-content">
<Card title="账户管理" style={{ marginBottom: 16 }}>
<div className="search-bar">
<Input
placeholder="搜索用户名/显示名"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
allowClear
style={{ width: 220 }}
onPressEnter={fetchUsers}
/>
<Select placeholder="角色" value={filterRole || undefined} onChange={(v) => setFilterRole(v || '')} allowClear style={{ width: 140 }}>
<Option value="admin">管理员</Option>
<Option value="user">普通用户</Option>
</Select>
<Button icon={<ReloadOutlined />} onClick={() => { fetchUsers(); fetchLogs() }}>刷新</Button>
<Space style={{ marginLeft: 'auto' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>创建账户</Button>
</Space>
</div>
<div className="table-toolbar">
<span>共 {users.length} 个账户,当前在线 {users.filter(u => u.is_online).length} 个</span>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
scroll={{ x: 900 }}
pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 条` }}
/>
</Card>
<Card title="账户操作日志" extra={
<Popconfirm title="确定要清空所有账户日志吗?" onConfirm={handleClearLogs} okText="确定" cancelText="取消">
<Button size="small" danger>清空日志</Button>
</Popconfirm>
}>
<Table
columns={logColumns}
dataSource={logs}
rowKey="id"
loading={logsLoading}
size="small"
pagination={{ pageSize: 8, showTotal: (total) => `共 ${total} 条` }}
/>
</Card>
<Modal
title={editingUser ? '编辑账户' : '创建账户'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
okText="确定"
cancelText="取消"
width={480}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="登录用户名" />
</Form.Item>
{!editingUser && (
<Form.Item
name="password"
label="密码"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码不少于6位' }
]}
>
<Input.Password placeholder="登录密码" />
</Form.Item>
)}
<Form.Item name="display_name" label="显示名">
<Input placeholder="界面显示的名称(默认同用户名)" />
</Form.Item>
<Form.Item name="role" label="角色" initialValue="user">
<Select>
<Option value="user">普通账户</Option>
<Option value="admin">管理员账户</Option>
</Select>
</Form.Item>
{editingUser && <div style={{ fontSize: 12, color: '#999' }}>如需修改密码,请使用列表中的"重置密码"功能。</div>}
</Form>
</Modal>
</div>
)
}
export default AccountManagement
+1 -1
View File
@@ -120,7 +120,7 @@ function Dashboard({ onNavigate }) {
<h3>系统信息</h3>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col span={8}>
<p><strong>系统版本:</strong>v1.0.0</p>
<p><strong>系统版本:</strong>v2.1</p>
<p><strong>系统名称:</strong>设备管理系统</p>
</Col>
<Col span={8}>
+172 -20
View File
@@ -1,6 +1,6 @@
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 { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ToolOutlined, SyncOutlined, HistoryOutlined, StopOutlined, RollbackOutlined } from '@ant-design/icons'
import axios from 'axios'
import dayjs from 'dayjs'
import { can } from '../permissions'
@@ -24,8 +24,18 @@ function DeviceManagement({ user }) {
const [filterStatus, setFilterStatus] = useState('')
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [statusDevice, setStatusDevice] = useState(null)
const [scrapModalVisible, setScrapModalVisible] = useState(false)
const [scrapDevice, setScrapDevice] = useState(null)
const [repairModalVisible, setRepairModalVisible] = useState(false)
const [repairDevice, setRepairDevice] = useState(null)
const [recordsModalVisible, setRecordsModalVisible] = useState(false)
const [recordsDevice, setRecordsDevice] = useState(null)
const [records, setRecords] = useState([])
const [recordsLoading, setRecordsLoading] = useState(false)
const [form] = Form.useForm()
const [statusForm] = Form.useForm()
const [scrapForm] = Form.useForm()
const [repairForm] = Form.useForm()
useEffect(() => {
fetchDevices()
@@ -78,24 +88,24 @@ function DeviceManagement({ user }) {
}
}
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 handleRepair = (record) => {
setRepairDevice(record)
repairForm.resetFields()
setRepairModalVisible(true)
}
const handleRepairSubmit = async () => {
try {
const values = await repairForm.validateFields()
const res = await axios.patch(`/api/devices/${repairDevice.id}/repair`, { remark: values.remark })
if (res.data.success) {
message.success(res.data.message)
setRepairModalVisible(false)
fetchDevices()
}
})
} catch (err) {
if (err.response) message.error(err.response.data?.message || '报修失败')
}
}
// 管理员:打开修改维修状态弹窗
@@ -120,6 +130,65 @@ function DeviceManagement({ user }) {
}
}
// 管理员:打开报废弹窗
const handleOpenScrap = (record) => {
setScrapDevice(record)
scrapForm.resetFields()
setScrapModalVisible(true)
}
// 管理员:提交报废
const handleScrapSubmit = async () => {
try {
const values = await scrapForm.validateFields()
const res = await axios.post(`/api/devices/${scrapDevice.id}/scrap`, { reason: values.reason })
if (res.data.success) {
message.success(res.data.message)
setScrapModalVisible(false)
fetchDevices()
}
} catch (err) {
if (err.response) message.error(err.response.data?.message || '报废失败')
}
}
// 管理员:恢复报废设备
const handleRestore = (record) => {
Modal.confirm({
title: `恢复设备:${record.name}`,
content: '确认撤销报废,将状态恢复为“闲置”吗?',
okText: '确认恢复',
cancelText: '取消',
onOk: async () => {
try {
const res = await axios.post(`/api/devices/${record.id}/restore`)
if (res.data.success) {
message.success(res.data.message)
fetchDevices()
}
} catch (err) {
message.error(err.response?.data?.message || '恢复失败')
}
}
})
}
// 查看维修/保养记录
const handleShowRecords = async (record) => {
setRecordsDevice(record)
setRecords([])
setRecordsModalVisible(true)
setRecordsLoading(true)
try {
const res = await axios.get(`/api/devices/${record.id}/records`)
if (res.data.success) setRecords(res.data.data)
} catch (err) {
message.error('获取记录失败')
} finally {
setRecordsLoading(false)
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
@@ -177,16 +246,23 @@ function DeviceManagement({ user }) {
{
title: '操作',
key: 'action',
width: isAdminUser ? 280 : 100,
width: isAdminUser ? 360 : 150,
fixed: 'right',
render: (_, record) => (
<Space>
<Space wrap>
{canRepair && record.status !== '维修中' && record.status !== '已报废' && (
<Button type="link" icon={<ToolOutlined />} size="small" onClick={() => handleRepair(record)}>报修</Button>
)}
<Button type="link" icon={<HistoryOutlined />} size="small" onClick={() => handleShowRecords(record)}>记录</Button>
{isAdminUser && (
<Button type="link" icon={<SyncOutlined />} size="small" onClick={() => handleOpenStatus(record)}>维修状态</Button>
)}
{isAdminUser && record.status !== '已报废' && (
<Button type="link" danger icon={<StopOutlined />} size="small" onClick={() => handleOpenScrap(record)}>报废</Button>
)}
{isAdminUser && record.status === '已报废' && (
<Button type="link" icon={<RollbackOutlined />} size="small" onClick={() => handleRestore(record)}>恢复</Button>
)}
{isAdminUser && (
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
)}
@@ -287,6 +363,28 @@ function DeviceManagement({ user }) {
</Form>
</Modal>
<Modal
title="设备报修"
open={repairModalVisible}
onOk={handleRepairSubmit}
onCancel={() => setRepairModalVisible(false)}
okText="确认报修"
cancelText="取消"
width={420}
>
{repairDevice && (
<>
<p style={{ color: '#666' }}>设备:<strong>{repairDevice.name}</strong>({repairDevice.type})</p>
<Form form={repairForm} layout="vertical">
<Form.Item name="remark" label="故障说明">
<TextArea rows={3} placeholder="如:开机无显示、打印卡纸等(选填)" />
</Form.Item>
</Form>
<div style={{ fontSize: 12, color: '#999' }}>确认后设备状态将变为“维修中”,并记入维修记录。</div>
</>
)}
</Modal>
<Modal
title="修改维修状态"
open={statusModalVisible}
@@ -310,6 +408,60 @@ function DeviceManagement({ user }) {
</>
)}
</Modal>
<Modal
title="设备报废"
open={scrapModalVisible}
onOk={handleScrapSubmit}
onCancel={() => setScrapModalVisible(false)}
okText="确认报废"
cancelText="取消"
okButtonProps={{ danger: true }}
width={420}
>
{scrapDevice && (
<>
<p style={{ color: '#666' }}>设备:<strong>{scrapDevice.name}</strong>({scrapDevice.type})</p>
<Form form={scrapForm} layout="vertical">
<Form.Item name="reason" label="报废原因" rules={[{ required: true, message: '请填写报废原因' }]}>
<TextArea rows={3} placeholder="如:达到使用年限、无法修复等" />
</Form.Item>
</Form>
<div style={{ fontSize: 12, color: '#999' }}>报废后设备状态变为“已报废”,如需撤销可点击“恢复”。</div>
</>
)}
</Modal>
<Modal
title={recordsDevice ? `维修记录:${recordsDevice.name}` : '维修记录'}
open={recordsModalVisible}
onCancel={() => setRecordsModalVisible(false)}
footer={null}
width={560}
>
{recordsDevice && recordsDevice.status === '已报废' && (
<p style={{ color: '#cf1322', fontSize: 13 }}>
报废信息:{recordsDevice.scrap_reason || '未填写原因'} 报废日期:{recordsDevice.scrap_date || '-'} 操作人:{recordsDevice.scrap_operator || '-'}
</p>
)}
{records.length === 0 && !recordsLoading ? (
<p style={{ color: '#999', textAlign: 'center', padding: 24 }}>暂无维修记录</p>
) : (
<Table
size="small"
rowKey="id"
dataSource={records}
loading={recordsLoading}
pagination={false}
columns={[
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 150 },
{ title: '类型', dataIndex: 'type', key: 'type', width: 90, render: (t) => <Tag color={t === '报修' ? 'orange' : t === '报废' ? 'red' : 'green'}>{t}</Tag> },
{ title: '说明', dataIndex: 'remark', key: 'remark', render: (t) => t || '-' },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100, render: (t) => t || '-' }
]}
/>
)}
</Modal>
</div>
)
}
+22 -3
View File
@@ -89,8 +89,16 @@ function Guide() {
<h3>6.6 设备报修与维修状态修改</h3>
<ul>
<li><strong>设备报修(普通账户+管理员):</strong>点击操作列的“报修”按钮,确认后设备状态即变为“维修中”。</li>
<li><strong>修改维修状态(仅管理员):</strong>点击操作列的“维修状态”按钮,在弹窗中选择新状态(正常/维修中/已报废/闲置)后确定。例如维修完成后,可将状态从“维修中”改回“正常”。</li>
<li><strong>设备报修(普通账户+管理员):</strong>点击操作列的“报修”按钮,填写故障说明后确认,设备状态即变为“维修中”,并记入维修记录。</li>
<li><strong>修改维修状态(仅管理员):</strong>点击操作列的“维修状态”按钮,在弹窗中选择新状态(正常/维修中/已报废/闲置)后确定。维修中改回正常时会自动写入一条“维修完成”记录。</li>
<li><strong>查看记录:</strong>点击“记录”按钮可查看该设备的报修、维修完成、报废等历史记录。</li>
</ul>
<h3>6.7 报废管理(仅管理员)</h3>
<ul>
<li><strong>报废:</strong>点击操作列的“报废”按钮,填写报废原因后确认。设备状态变为“已报废”,并记录报废日期与操作人。</li>
<li><strong>恢复:</strong>已报废设备可点击“恢复”撤销报废,状态回到“闲置”。</li>
<li><strong>筛选查看:</strong>可通过顶部“设备状态”筛选器选择“已报废”查看所有报废设备。</li>
</ul>
<h2>七、人员管理</h2>
@@ -129,6 +137,17 @@ function Guide() {
</ul>
<div className="warning">注意:普通账户登录后,界面上仅显示其有权限的操作按钮,后端也会做权限校验。</div>
<h3>7.7 账户管理模块(仅管理员)</h3>
<p>管理员在左侧菜单可进入“账户管理”页面,集中管理所有登录账户:</p>
<ul>
<li><strong>创建账户:</strong>可创建不关联人员的独立账户,指定用户名、密码和角色</li>
<li><strong>编辑:</strong>修改账户的用户名、显示名和角色(不能降级最后一个管理员)</li>
<li><strong>重置密码:</strong>忘记密码时由管理员直接重置</li>
<li><strong>删除:</strong>删除账户(不能删除当前登录账户和最后一个管理员),被删除账户的在线会话会立即失效</li>
<li><strong>操作日志:</strong>页面下方展示账户的创建、修改、删除、重置密码等审计记录</li>
</ul>
<div className="tip">任何用户都可以点击右上角头像菜单中的“修改密码”来自行修改自己的登录密码。</div>
<h2>八、通知管理</h2>
<h3>8.1 查看通知列表</h3>
<p>进入"通知管理"页面后,可以看到所有发布的通知,包含标题、类型、优先级、阅读状态等信息。</p>
@@ -139,7 +158,7 @@ function Guide() {
<li>填写通知标题、内容</li>
<li>选择通知类型(通知、公告、提醒)</li>
<li>选择优先级(普通、高)</li>
<li>选择目标范围(全部、技术部、行政部等)</li>
<li>选择目标范围(全部、拍摄组、后勤组、监督组)</li>
<li>点击"确定"发布</li>
</ol>
+349
View File
@@ -0,0 +1,349 @@
import React, { useState, useEffect, useRef } from 'react'
import { Card, Button, Input, List, Badge, Modal, Form, Select, Tabs, Avatar, Empty, message, Popconfirm, Space, Tag, Tooltip } from 'antd'
import { PlusOutlined, SendOutlined, TeamOutlined, UserOutlined, ArrowLeftOutlined, DeleteOutlined } from '@ant-design/icons'
import axios from 'axios'
const { TextArea } = Input
// 移动端检测
function useIsMobile() {
const [isMobile, setIsMobile] = useState(window.innerWidth < 768)
useEffect(() => {
const onResize = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
return isMobile
}
function Messages({ user }) {
const isMobile = useIsMobile()
const [conversations, setConversations] = useState([])
const [allUsers, setAllUsers] = useState([])
const [activeConv, setActiveConv] = useState(null)
const [messages, setMessages] = useState([])
const [inputValue, setInputValue] = useState('')
const [sending, setSending] = useState(false)
const [createVisible, setCreateVisible] = useState(false)
const [createForm] = Form.useForm()
const [creating, setCreating] = useState(false)
const bottomRef = useRef(null)
const activeConvRef = useRef(null)
const lastMsgIdRef = useRef(0)
// 打开会话时同步到 ref(供轮询闭包使用)
const openConversation = (conv) => {
setActiveConv(conv)
activeConvRef.current = conv
lastMsgIdRef.current = 0
setMessages([])
}
const closeConversation = () => {
setActiveConv(null)
activeConvRef.current = null
setMessages([])
}
const fetchConversations = async () => {
try {
const res = await axios.get('/api/chat/conversations')
if (res.data.success) setConversations(res.data.data)
} catch (err) { /* 静默 */ }
}
const fetchUsers = async () => {
try {
const res = await axios.get('/api/chat/users')
if (res.data.success) setAllUsers(res.data.data)
} catch (err) { /* 静默 */ }
}
// 拉取当前打开会话的消息(首次全量,之后增量)
const fetchMessages = async (conv) => {
if (!conv) return
try {
const after = lastMsgIdRef.current
const res = await axios.get(`/api/chat/conversations/${conv.id}/messages`, { params: after ? { after } : {} })
if (res.data.success) {
const rows = res.data.data
if (rows.length > 0) {
lastMsgIdRef.current = rows[rows.length - 1].id
setMessages(prev => after > 0 ? [...prev, ...rows] : rows)
}
}
} catch (err) { /* 静默 */ }
}
// 会话列表 5 秒轮询;打开的会话 3 秒轮询消息
useEffect(() => {
fetchConversations()
fetchUsers()
const convTimer = setInterval(fetchConversations, 5000)
const msgTimer = setInterval(() => fetchMessages(activeConvRef.current), 3000)
return () => { clearInterval(convTimer); clearInterval(msgTimer) }
}, [])
// 新消息自动滚动到底部
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages.length])
const handleSend = async () => {
const content = inputValue.trim()
if (!content || !activeConv || sending) return
setSending(true)
try {
const res = await axios.post(`/api/chat/conversations/${activeConv.id}/messages`, { content })
if (res.data.success) {
setInputValue('')
await fetchMessages(activeConvRef.current)
fetchConversations()
}
} catch (err) {
message.error(err.response?.data?.message || '发送失败')
} finally {
setSending(false)
}
}
const handleCreate = async (values) => {
setCreating(true)
try {
const payload = values.tab === 'group'
? { type: 'group', name: values.name, memberIds: values.memberIds }
: { type: 'direct', userId: values.userId }
const res = await axios.post('/api/chat/conversations', payload)
if (res.data.success) {
message.success(res.data.message)
setCreateVisible(false)
createForm.resetFields()
await fetchConversations()
// 自动打开新会话
const list = (await axios.get('/api/chat/conversations')).data
if (list.success) {
const conv = list.data.find(c => c.id === res.data.data.id)
if (conv) openConversation(conv)
}
}
} catch (err) {
message.error(err.response?.data?.message || '创建失败')
} finally {
setCreating(false)
}
}
const handleDeleteConv = async (conv) => {
try {
const res = await axios.delete(`/api/chat/conversations/${conv.id}`)
if (res.data.success) {
message.success(res.data.message)
if (activeConv?.id === conv.id) closeConversation()
fetchConversations()
}
} catch (err) {
message.error(err.response?.data?.message || '删除失败')
}
}
const canDelete = (conv) => conv.type === 'direct' || conv.creator_id === user?.id
const otherOptions = allUsers.filter(u => u.id !== user?.id).map(u => ({
value: u.id,
label: (
<span>
<Badge status={u.is_online ? 'success' : 'default'} />
{u.display_name || u.username}
<span style={{ color: '#999', fontSize: 12 }}>({u.username})</span>
</span>
)
}))
// ============ 会话列表 ============
const conversationList = (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontWeight: 500 }}>我的会话</span>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); setCreateVisible(true) }}>
发起对话 / 建群
</Button>
</div>
{conversations.length === 0 ? (
<Empty description="暂无会话,点击右上角发起对话" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
dataSource={conversations}
renderItem={(conv) => (
<List.Item
className={`conv-item ${activeConv?.id === conv.id ? 'conv-item-active' : ''}`}
onClick={() => openConversation(conv)}
style={{ cursor: 'pointer', padding: '10px 8px' }}
extra={
<Space size={4}>
{conv.unread > 0 && <Badge count={conv.unread} />}
{canDelete(conv) && (
<Popconfirm
title={conv.type === 'group' ? '确定解散该群组吗?' : '确定删除该会话吗?'}
onConfirm={(e) => { e?.stopPropagation(); handleDeleteConv(conv) }}
onCancel={(e) => e?.stopPropagation()}
okText="确定"
cancelText="取消"
>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
)}
</Space>
}
>
<List.Item.Meta
avatar={
<Avatar
style={{ backgroundColor: conv.type === 'group' ? '#faad14' : '#7c3aed' }}
icon={conv.type === 'group' ? <TeamOutlined /> : <UserOutlined />}
/>
}
title={
<span>
{conv.name || '未命名'}
{conv.type === 'group' && <Tag style={{ marginLeft: 8 }}>{conv.member_count}人</Tag>}
</span>
}
description={
conv.last_message
? `${conv.last_message.sender_name}:${conv.last_message.content.slice(0, 20)}`
: '暂无消息'
}
/>
</List.Item>
)}
/>
)}
</>
)
// ============ 聊天窗口 ============
const chatWindow = activeConv ? (
<div className="chat-window">
<div className="chat-header">
{isMobile && (
<Button type="text" icon={<ArrowLeftOutlined />} onClick={closeConversation} style={{ marginRight: 4 }} />
)}
<span style={{ fontWeight: 500 }}>{activeConv.name || '未命名'}</span>
{activeConv.type === 'group' && (
<Tooltip title={activeConv.members.map(m => m.display_name || m.username).join('、')}>
<Tag style={{ marginLeft: 8 }}>{activeConv.member_count}人</Tag>
</Tooltip>
)}
</div>
<div className="chat-messages">
{messages.length === 0 && <Empty description="还没有消息,发送第一条吧" image={Empty.PRESENTED_IMAGE_SIMPLE} style={{ marginTop: 40 }} />}
{messages.map(m => {
const mine = m.sender_id === user?.id
return (
<div key={m.id} className={`chat-msg ${mine ? 'chat-msg-mine' : ''}`}>
{!mine && <div className="chat-msg-sender">{m.sender_name}</div>}
<div className={`chat-bubble ${mine ? 'chat-bubble-mine' : ''}`}>{m.content}</div>
<div className="chat-msg-time">{m.created_at?.slice(5, 16)}</div>
</div>
)
})}
<div ref={bottomRef} />
</div>
<div className="chat-input">
<TextArea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={(e) => { if (!e.shiftKey) { e.preventDefault(); handleSend() } }}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
autoSize={{ minRows: 1, maxRows: 4 }}
style={{ flex: 1 }}
/>
<Button type="primary" icon={<SendOutlined />} loading={sending} onClick={handleSend}>发送</Button>
</div>
</div>
) : (
<div className="chat-placeholder">
<Empty description="选择一个会话开始聊天" />
</div>
)
// ============ 布局 ============
if (isMobile) {
return (
<div className="page-content">
<Card bodyStyle={{ padding: 12 }}>
{activeConv ? chatWindow : conversationList}
</Card>
<CreateConvModal />
</div>
)
}
return (
<div className="page-content">
<Card bodyStyle={{ padding: 0 }}>
<div className="messages-layout">
<div className="messages-list">{conversationList}</div>
<div className="messages-chat">{chatWindow}</div>
</div>
</Card>
<CreateConvModal />
</div>
)
// ============ 发起对话 / 建群弹窗 ============
function CreateConvModal() {
return (
<Modal
title="发起对话"
open={createVisible}
onCancel={() => setCreateVisible(false)}
footer={null}
width={480}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ tab: 'direct' }}>
<Form.Item name="tab" noStyle>
<Tabs
centered
items={[
{
key: 'direct',
label: '单聊',
children: (
<Form.Item name="userId" label="选择对话对象" rules={[{ required: true, message: '请选择对话对象' }]}>
<Select placeholder="选择用户(绿点为在线)" options={otherOptions} showSearch optionFilterProp="label" />
</Form.Item>
)
},
{
key: 'group',
label: '创建群聊',
children: (
<>
<Form.Item name="name" label="群名称" rules={[{ required: true, message: '请输入群名称' }]}>
<Input placeholder="例如:拍摄组工作群" />
</Form.Item>
<Form.Item name="memberIds" label="群成员(除自己外至少选1人)" rules={[{ required: true, message: '请选择群成员' }]}>
<Select mode="multiple" placeholder="选择群成员" options={otherOptions} showSearch optionFilterProp="label" />
</Form.Item>
</>
)
}
]}
/>
</Form.Item>
<div style={{ textAlign: 'right' }}>
<Space>
<Button onClick={() => setCreateVisible(false)}>取消</Button>
<Button type="primary" htmlType="submit" loading={creating}>确定</Button>
</Space>
</div>
</Form>
</Modal>
)
}
}
export default Messages
+18 -1
View File
@@ -9,7 +9,7 @@ const { TextArea } = Input
const notifTypes = ['通知', '公告', '提醒']
const priorities = ['普通', '高']
const targets = ['全部', '技术部', '行政部', '财务部', '市场部', '人事部', '运营部']
const targets = ['全部', '拍摄组', '后勤组', '监督组']
function NotificationManagement({ user }) {
const manageAllowed = can(user, 'notification:manage')
@@ -95,6 +95,18 @@ function NotificationManagement({ user }) {
}
}
const handleClearRead = async () => {
try {
const res = await axios.delete('/api/notifications/read/clear')
if (res.data.success) {
message.success(res.data.message)
fetchNotifications()
}
} catch (err) {
message.error(err.response?.data?.message || '清空失败')
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
@@ -246,6 +258,11 @@ function NotificationManagement({ user }) {
全部已读 ({unreadCount})
</Button>
)}
{manageAllowed && (
<Popconfirm title="确定要清空所有已读通知吗?" onConfirm={handleClearRead} okText="确定" cancelText="取消">
<Button danger size="small">清空已读</Button>
</Popconfirm>
)}
</Space>
{manageAllowed && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>发布通知</Button>}
</div>