update
This commit is contained in:
Generated
+312
-310
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user