first commit

This commit is contained in:
mei
2026-08-10 22:56:38 +08:00
commit 2086bd79cc
32 changed files with 14110 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import React from 'react'
import { Card, Divider } from 'antd'
function About() {
return (
<div className="about-page">
<Card>
<h1>影视星河 · 设备管理系统 v1.0.0</h1>
<Divider />
<h2>软件概述</h2>
<p>
设备管理系统(Device Management System,简称DMS)是一款专为企事业单位设计的现代化设备资产管理软件。
系统集成了设备全生命周期管理、人员管理、通知推送等核心功能,旨在帮助用户高效管理各类IT设备和办公资产。
</p>
<h2>核心功能</h2>
<ul>
<li><strong>登录认证:</strong>支持账号密码登录,内置默认管理员账户,登录后根据时间智能问候</li>
<li><strong>每日天气:</strong>顶部导航栏实时显示当前城市天气、温度、湿度等信息</li>
<li><strong>设备管理:</strong>支持设备的添加、编辑、删除、查询等全生命周期管理,涵盖笔记本电脑、台式机、显示器、打印机、网络设备等各类IT资产</li>
<li><strong>人员管理:</strong>维护组织架构中的人员信息,包括部门、职位、联系方式等,便于设备责任分配</li>
<li><strong>通知管理:</strong>支持发布各类通知、公告和提醒,可设置优先级和发送范围,确保重要信息及时传达</li>
<li><strong>数据概览:</strong>提供直观的仪表盘,实时展示设备状态、人员情况和通知统计</li>
<li><strong>搜索过滤:</strong>支持按关键词、类型、状态等多维度快速检索</li>
<li><strong>品牌标识:</strong>登录页、侧边栏、顶部导航均展示影视星河品牌 Logo</li>
</ul>
<h2>技术架构</h2>
<ul>
<li><strong>前端:</strong>React 18 + Ant Design 5 + Vite 5</li>
<li><strong>后端:</strong>Node.js + Express.js</li>
<li><strong>数据库:</strong>SQLite(轻量级嵌入式数据库,无需额外安装)</li>
<li><strong>桌面端:</strong>Electron(支持打包为Windows/Mac/Linux桌面应用)</li>
</ul>
<h2>部署方式</h2>
<ul>
<li><strong>服务器部署:</strong>通过 Node.js 直接运行,支持任何服务器环境</li>
<li><strong>桌面应用:</strong>打包为 Windows EXE 安装包,支持一键安装使用</li>
<li><strong>手机网页:</strong>响应式设计,手机浏览器直接访问,自动适配移动端布局</li>
<li><strong>手机App:</strong>支持 PWA 安装到桌面,或通过 Capacitor 打包原生 Android APK</li>
<li><strong>局域网部署:</strong>支持在内网服务器上部署,供局域网内所有设备访问</li>
</ul>
<h2>默认管理员账户</h2>
<ul>
<li>用户名:<strong>admin</strong></li>
<li>密码:<strong>admin123</strong></li>
<li>角色:<strong>系统管理员</strong></li>
</ul>
<p className="warning">提示:首次登录后请及时修改默认密码,确保系统安全。</p>
<h2>系统要求</h2>
<ul>
<li>操作系统:Windows 10/11、macOS 10.15+、Linux</li>
<li>内存:最低 2GB,推荐 4GB 以上</li>
<li>存储空间:最低 200MB 可用空间</li>
<li>浏览器:Chrome 80+、Firefox 78+、Edge 80+、Safari 14+</li>
<li>网络:天气功能需要访问互联网(Open-Meteo 免费天气接口)</li>
</ul>
<h2>版本信息</h2>
<ul>
<li>当前版本:v1.0.0</li>
<li>发布日期:2026年8月</li>
<li>许可证:MIT License</li>
</ul>
</Card>
</div>
)
}
export default About
+140
View File
@@ -0,0 +1,140 @@
import React, { useState, useEffect } from 'react'
import { Card, Row, Col, Statistic, Spin } from 'antd'
import {
DesktopOutlined,
TeamOutlined,
BellOutlined,
CheckCircleOutlined,
ToolOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons'
import axios from 'axios'
function Dashboard({ onNavigate }) {
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchStats()
}, [])
const fetchStats = async () => {
try {
const res = await axios.get('/api/dashboard/stats')
if (res.data.success) {
setStats(res.data.data)
}
} catch (err) {
console.error('获取统计数据失败:', err)
} finally {
setLoading(false)
}
}
if (loading) {
return <div style={{ textAlign: 'center', padding: 60 }}><Spin size="large" tip="加载中..." /></div>
}
return (
<div>
<h2 style={{ marginBottom: 24 }}>数据概览</h2>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card className="dashboard-card" onClick={() => onNavigate('devices')}>
<Statistic
title="设备总数"
value={stats?.deviceTotal || 0}
prefix={<DesktopOutlined />}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="dashboard-card">
<Statistic
title="正常运行"
value={stats?.deviceNormal || 0}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="dashboard-card">
<Statistic
title="维修中"
value={stats?.deviceRepairing || 0}
prefix={<ToolOutlined />}
valueStyle={{ color: '#faad14' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card className="dashboard-card">
<Statistic
title="已报废"
value={stats?.deviceScrapped || 0}
prefix={<CloseCircleOutlined />}
valueStyle={{ color: '#f5222d' }}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} sm={12} lg={8}>
<Card className="dashboard-card" onClick={() => onNavigate('personnel')}>
<Statistic
title="人员总数"
value={stats?.personnelTotal || 0}
prefix={<TeamOutlined />}
valueStyle={{ color: '#722ed1' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card className="dashboard-card">
<Statistic
title="在职人员"
value={stats?.personnelActive || 0}
prefix={<CheckCircleOutlined />}
valueStyle={{ color: '#13c2c2' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card className="dashboard-card" onClick={() => onNavigate('notifications')}>
<Statistic
title="未读通知"
value={stats?.notifUnread || 0}
prefix={<ExclamationCircleOutlined />}
valueStyle={{ color: '#fa541c' }}
suffix={<span style={{ fontSize: 14, color: '#999' }}>/ {stats?.notifTotal || 0}</span>}
/>
</Card>
</Col>
</Row>
<Card style={{ marginTop: 24 }}>
<h3>系统信息</h3>
<Row gutter={16} style={{ marginTop: 12 }}>
<Col span={8}>
<p><strong>系统版本:</strong>v1.0.0</p>
<p><strong>系统名称:</strong>设备管理系统</p>
</Col>
<Col span={8}>
<p><strong>数据库:</strong>SQLite</p>
<p><strong>前端框架:</strong>React + Ant Design</p>
</Col>
<Col span={8}>
<p><strong>后端框架:</strong>Express.js</p>
<p><strong>部署方式:</strong>服务器 / 桌面应用</p>
</Col>
</Row>
</Card>
</div>
)
}
export default Dashboard
+317
View File
@@ -0,0 +1,317 @@
import React, { useState, useEffect } from 'react'
import { Table, Button, Input, Select, Space, Tag, Modal, Form, DatePicker, message, Popconfirm, Card } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, ToolOutlined, SyncOutlined } from '@ant-design/icons'
import axios from 'axios'
import dayjs from 'dayjs'
import { can } from '../permissions'
const { Option } = Select
const { TextArea } = Input
const deviceTypes = ['笔记本电脑', '台式机', '显示器', '打印机', '网络设备', '服务器', '投影仪', '其他']
const deviceStatuses = ['正常', '维修中', '已报废', '闲置']
function DeviceManagement({ user }) {
const isAdminUser = can(user, 'device:edit')
const canRepair = can(user, 'device:repair')
const canAdd = can(user, 'device:add')
const [devices, setDevices] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingDevice, setEditingDevice] = useState(null)
const [keyword, setKeyword] = useState('')
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [statusModalVisible, setStatusModalVisible] = useState(false)
const [statusDevice, setStatusDevice] = useState(null)
const [form] = Form.useForm()
const [statusForm] = Form.useForm()
useEffect(() => {
fetchDevices()
}, [keyword, filterType, filterStatus])
const fetchDevices = async () => {
setLoading(true)
try {
const params = {}
if (keyword) params.keyword = keyword
if (filterType) params.type = filterType
if (filterStatus) params.status = filterStatus
const res = await axios.get('/api/devices', { params })
if (res.data.success) {
setDevices(res.data.data)
}
} catch (err) {
message.error('获取设备列表失败')
} finally {
setLoading(false)
}
}
const handleAdd = () => {
setEditingDevice(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditingDevice(record)
form.setFieldsValue({
...record,
purchase_date: record.purchase_date ? dayjs(record.purchase_date) : null,
warranty_date: record.warranty_date ? dayjs(record.warranty_date) : null
})
setModalVisible(true)
}
const handleDelete = async (id) => {
try {
const res = await axios.delete(`/api/devices/${id}`)
if (res.data.success) {
message.success('设备删除成功')
fetchDevices()
}
} catch (err) {
message.error('删除失败')
}
}
const handleRepair = async (record) => {
Modal.confirm({
title: `设备报修:${record.name}`,
content: '确认将该设备标记为“维修中”吗?可填写报修说明。',
okText: '确认报修',
cancelText: '取消',
onOk: async () => {
try {
const res = await axios.patch(`/api/devices/${record.id}/repair`, { remark: '' })
if (res.data.success) {
message.success(res.data.message)
fetchDevices()
}
} catch (err) {
message.error(err.response?.data?.message || '报修失败')
}
}
})
}
// 管理员:打开修改维修状态弹窗
const handleOpenStatus = (record) => {
setStatusDevice(record)
statusForm.setFieldsValue({ status: record.status })
setStatusModalVisible(true)
}
// 管理员:提交维修状态修改
const handleStatusSubmit = async () => {
try {
const values = await statusForm.validateFields()
const res = await axios.patch(`/api/devices/${statusDevice.id}/status`, values)
if (res.data.success) {
message.success(res.data.message)
setStatusModalVisible(false)
fetchDevices()
}
} catch (err) {
if (err.response) message.error(err.response.data?.message || '修改失败')
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
const data = {
...values,
purchase_date: values.purchase_date ? values.purchase_date.format('YYYY-MM-DD') : '',
warranty_date: values.warranty_date ? values.warranty_date.format('YYYY-MM-DD') : ''
}
if (editingDevice) {
const res = await axios.put(`/api/devices/${editingDevice.id}`, data)
if (res.data.success) {
message.success('设备更新成功')
}
} else {
const res = await axios.post('/api/devices', data)
if (res.data.success) {
message.success('设备添加成功')
}
}
setModalVisible(false)
fetchDevices()
} catch (err) {
if (err.response) {
message.error(err.response.data.message || '操作失败')
}
}
}
const handleReset = () => {
setKeyword('')
setFilterType('')
setFilterStatus('')
}
const getStatusTag = (status) => {
const colorMap = {
'正常': 'green',
'维修中': 'orange',
'已报废': 'red',
'闲置': 'default'
}
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>
}
const columns = [
{ title: '设备名称', dataIndex: 'name', key: 'name', width: 180, ellipsis: true },
{ title: '设备类型', dataIndex: 'type', key: 'type', width: 110 },
{ title: '型号', dataIndex: 'model', key: 'model', width: 140, ellipsis: true },
{ title: '序列号', dataIndex: 'serial_number', key: 'serial_number', width: 130, ellipsis: true },
{ title: '状态', dataIndex: 'status', key: 'status', width: 90, render: getStatusTag },
{ title: '位置', dataIndex: 'location', key: 'location', width: 110, ellipsis: true },
{ title: '购买日期', dataIndex: 'purchase_date', key: 'purchase_date', width: 110 },
{ title: '负责人', dataIndex: 'responsible_person', key: 'responsible_person', width: 90 },
{
title: '操作',
key: 'action',
width: isAdminUser ? 280 : 100,
fixed: 'right',
render: (_, record) => (
<Space>
{canRepair && record.status !== '维修中' && record.status !== '已报废' && (
<Button type="link" icon={<ToolOutlined />} size="small" onClick={() => handleRepair(record)}>报修</Button>
)}
{isAdminUser && (
<Button type="link" icon={<SyncOutlined />} size="small" onClick={() => handleOpenStatus(record)}>维修状态</Button>
)}
{isAdminUser && (
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
)}
{isAdminUser && (
<Popconfirm title="确定要删除此设备吗?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
</Popconfirm>
)}
</Space>
)
}
]
return (
<div>
<Card style={{ marginBottom: 16 }}>
<div className="search-bar">
<Input
placeholder="搜索设备名称/序列号/位置"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
style={{ width: 240 }}
allowClear
/>
<Select
placeholder="设备类型"
value={filterType || undefined}
onChange={(val) => setFilterType(val || '')}
style={{ width: 140 }}
allowClear
>
{deviceTypes.map(t => <Option key={t} value={t}>{t}</Option>)}
</Select>
<Select
placeholder="设备状态"
value={filterStatus || undefined}
onChange={(val) => setFilterStatus(val || '')}
style={{ width: 120 }}
allowClear
>
{deviceStatuses.map(s => <Option key={s} value={s}>{s}</Option>)}
</Select>
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
</div>
<div className="table-toolbar">
<span>共 {devices.length} 条记录</span>
{canAdd && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加设备</Button>}
</div>
</Card>
<Table
columns={columns}
dataSource={devices}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`
}}
/>
<Modal
title={editingDevice ? '编辑设备' : '添加设备'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={720}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="设备名称" rules={[{ required: true, message: '请输入设备名称' }]}>
<Input placeholder="请输入设备名称" />
</Form.Item>
<Form.Item name="type" label="设备类型" rules={[{ required: true, message: '请选择设备类型' }]}>
<Select placeholder="请选择设备类型">
{deviceTypes.map(t => <Option key={t} value={t}>{t}</Option>)}
</Select>
</Form.Item>
<div className="form-grid">
<Form.Item name="model" label="型号"><Input placeholder="请输入型号" /></Form.Item>
<Form.Item name="serial_number" label="序列号"><Input placeholder="请输入序列号" /></Form.Item>
<Form.Item name="status" label="状态" initialValue="正常">
<Select>
{deviceStatuses.map(s => <Option key={s} value={s}>{s}</Option>)}
</Select>
</Form.Item>
<Form.Item name="location" label="位置"><Input placeholder="请输入位置" /></Form.Item>
<Form.Item name="purchase_date" label="购买日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
<Form.Item name="warranty_date" label="保修日期"><DatePicker style={{ width: '100%' }} /></Form.Item>
<Form.Item name="responsible_person" label="负责人"><Input placeholder="请输入负责人" /></Form.Item>
</div>
<Form.Item name="description" label="描述"><TextArea rows={3} placeholder="请输入描述信息" /></Form.Item>
</Form>
</Modal>
<Modal
title="修改维修状态"
open={statusModalVisible}
onOk={handleStatusSubmit}
onCancel={() => setStatusModalVisible(false)}
okText="确定"
cancelText="取消"
width={420}
>
{statusDevice && (
<>
<p style={{ color: '#666' }}>设备:<strong>{statusDevice.name}</strong> 当前状态:{getStatusTag(statusDevice.status)}</p>
<Form form={statusForm} layout="vertical">
<Form.Item name="status" label="新的维修状态" rules={[{ required: true, message: '请选择状态' }]}>
<Select>
{deviceStatuses.map(s => <Option key={s} value={s}>{s}</Option>)}
</Select>
</Form.Item>
</Form>
<div className="tip" style={{ fontSize: 12, color: '#999' }}>例如:维修完成后,将状态从“维修中”改回“正常”。</div>
</>
)}
</Modal>
</div>
)
}
export default DeviceManagement
+252
View File
@@ -0,0 +1,252 @@
import React from 'react'
import { Card, Divider } from 'antd'
function Guide() {
return (
<div className="guide-page">
<Card>
<h1>使用说明</h1>
<Divider />
<h2>一、登录系统</h2>
<ol>
<li>打开系统后进入登录页面</li>
<li>输入用户名和密码</li>
<li>点击"登录"按钮进入系统</li>
</ol>
<p>默认管理员账号:</p>
<ul>
<li>用户名:<strong>admin</strong></li>
<li>密码:<strong>admin123</strong></li>
</ul>
<div className="warning">注意:首次登录后建议立即修改默认密码。</div>
<h2>二、每日天气</h2>
<p>登录成功后,页面顶部导航栏右侧会显示当前城市天气信息,包括:</p>
<ul>
<li>当前温度</li>
<li>天气状况(晴、多云、雨、雪等)</li>
<li>今日最高/最低温度</li>
<li>湿度和风速(鼠标悬停查看详情)</li>
</ul>
<div className="tip">天气数据来自 Open-Meteo 免费天气接口,需要联网才能正常显示。</div>
<h2>三、问候语</h2>
<p>登录后,顶部导航栏会根据当前时间自动显示问候语:</p>
<ul>
<li>06:00 - 09:00:早上好</li>
<li>09:00 - 12:00:上午好</li>
<li>12:00 - 14:00:中午好</li>
<li>14:00 - 18:00:下午好</li>
<li>18:00 - 06:00:晚上好</li>
</ul>
<h2>四、退出登录</h2>
<p>点击右上角用户名,在下拉菜单中选择"退出登录"即可安全退出系统。</p>
<h2>五、系统首页(仪表盘)</h2>
<p>系统首页提供数据的整体概览,包括:</p>
<ul>
<li>设备总数及状态分布(正常、维修中、已报废)</li>
<li>人员总数及在职情况</li>
<li>未读通知数量</li>
</ul>
<p>点击卡片可快速跳转到对应的管理页面。</p>
<h2>六、设备管理</h2>
<h3>6.1 查看设备列表</h3>
<p>进入"设备管理"页面后,可以看到所有设备的列表,包含设备名称、类型、型号、序列号、状态、位置等信息。</p>
<h3>6.2 添加设备</h3>
<ol>
<li>点击页面右上角的"添加设备"按钮</li>
<li>在弹出的表单中填写设备信息(名称和类型为必填项)</li>
<li>点击"确定"保存</li>
</ol>
<h3>6.3 编辑设备</h3>
<ol>
<li>在设备列表中找到需要编辑的设备</li>
<li>点击操作列的"编辑"按钮</li>
<li>修改设备信息后点击"确定"保存</li>
</ol>
<h3>6.4 删除设备</h3>
<ol>
<li>在设备列表中找到需要删除的设备</li>
<li>点击操作列的"删除"按钮</li>
<li>在确认对话框中点击"确定"</li>
</ol>
<div className="warning">注意:删除操作不可恢复,请谨慎操作。</div>
<h3>6.5 搜索和筛选</h3>
<ul>
<li>在搜索框中输入关键词可按设备名称、序列号、位置搜索</li>
<li>通过“设备类型”下拉框按类型筛选</li>
<li>通过“设备状态”下拉框按状态筛选</li>
<li>点击“重置”按钮清除所有筛选条件</li>
</ul>
<h3>6.6 设备报修与维修状态修改</h3>
<ul>
<li><strong>设备报修(普通账户+管理员):</strong>点击操作列的“报修”按钮,确认后设备状态即变为“维修中”。</li>
<li><strong>修改维修状态(仅管理员):</strong>点击操作列的“维修状态”按钮,在弹窗中选择新状态(正常/维修中/已报废/闲置)后确定。例如维修完成后,可将状态从“维修中”改回“正常”。</li>
</ul>
<h2>七、人员管理</h2>
<h3>7.1 查看人员列表</h3>
<p>进入"人员管理"页面后,可以看到所有人员的信息,包含姓名、工号、部门、职位、联系方式等。</p>
<h3>7.2 添加人员</h3>
<ol>
<li>点击页面右上角的"添加人员"按钮</li>
<li>在弹出的表单中填写人员信息(姓名为必填项)</li>
<li>点击"确定"保存</li>
</ol>
<h3>7.3 编辑和删除人员</h3>
<p>操作方式与设备管理类似,点击对应操作列的按钮即可。</p>
<h3>7.4 搜索和筛选</h3>
<ul>
<li>搜索框支持按姓名、工号、手机号搜索</li>
<li>可按部门和在职状态进行筛选</li>
</ul>
<h3>7.5 创建登录账户(多用户管理)</h3>
<p>添加/编辑人员时,打开"创建/启用登录账户"开关,即可为该人员同步创建登录账户:</p>
<ol>
<li>填写登录用户名和密码</li>
<li>选择账户角色:普通账户 或 管理员账户</li>
<li>保存后,该人员即可用此账户登录系统</li>
</ol>
<div className="tip">人员与账户一一对应。删除人员时会同时删除其登录账户;关闭开关则停用账户。</div>
<h3>7.6 账户权限说明</h3>
<ul>
<li><strong>普通账户:</strong>添加设备、设备报修、查看人员、查看通知</li>
<li><strong>管理员账户:</strong>拥有全部权限(编辑/删除设备、管理人员与账户、发布/删除通知等)</li>
</ul>
<div className="warning">注意:普通账户登录后,界面上仅显示其有权限的操作按钮,后端也会做权限校验。</div>
<h2>八、通知管理</h2>
<h3>8.1 查看通知列表</h3>
<p>进入"通知管理"页面后,可以看到所有发布的通知,包含标题、类型、优先级、阅读状态等信息。</p>
<h3>8.2 发布通知</h3>
<ol>
<li>点击页面右上角的"发布通知"按钮</li>
<li>填写通知标题、内容</li>
<li>选择通知类型(通知、公告、提醒)</li>
<li>选择优先级(普通、高)</li>
<li>选择目标范围(全部、技术部、行政部等)</li>
<li>点击"确定"发布</li>
</ol>
<h3>8.3 标记已读</h3>
<ul>
<li>点击单条通知的"标记已读"按钮可将该通知标为已读</li>
<li>点击"全部已读"按钮可一键标记所有通知为已读</li>
</ul>
<h3>8.4 筛选通知</h3>
<ul>
<li>可按关键词搜索标题和内容</li>
<li>可按类型、优先级、阅读状态筛选</li>
</ul>
<h2>九、服务器部署指南</h2>
<h3>9.1 环境准备</h3>
<ol>
<li>安装 Node.js 18 或以上版本</li>
<li>将项目文件上传到服务器</li>
</ol>
<h3>9.2 安装依赖</h3>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
{`cd device-management-system
npm install
cd client
npm install
npm run build
cd ..`}
</pre>
<h3>9.3 启动服务</h3>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
{`# 启动服务(默认端口3001)
npm start
# 或指定端口
PORT=8080 npm start`}
</pre>
<h3>9.4 访问系统</h3>
<p>浏览器打开 http://服务器IP:3001 即可使用系统。</p>
<div className="tip">
提示:建议使用 PM2 等进程管理工具保持服务后台运行:
<pre style={{ marginTop: 8 }}>
{`npm install -g pm2
pm2 start server.js --name device-manager`}
</pre>
</div>
<h2>十、桌面应用打包</h2>
<h3>10.1 Windows EXE 打包</h3>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
{`# 安装依赖
npm install
cd client && npm install && npm run build && cd ..
# 打包 Windows 安装包
npm run electron:build:win`}
</pre>
<p>打包完成后,EXE安装包位于 <code>dist-electron</code> 目录中。</p>
<h2>十一、手机端使用</h2>
<h3>11.1 手机浏览器访问(响应式网页)</h3>
<ol>
<li>确保手机与服务器在同一局域网</li>
<li>手机浏览器打开 http://服务器IP:3001</li>
<li>页面会自动切换为移动端布局(底部导航 + 抽屉菜单)</li>
</ol>
<h3>11.2 安装为 PWA 应用(推荐)</h3>
<ol>
<li>用手机 Chrome / Edge 浏览器打开系统</li>
<li>点击浏览器菜单中的"添加到主屏幕" / "安装应用"</li>
<li>安装后从桌面图标打开,体验接近原生App</li>
</ol>
<div className="tip">PWA 方式无需应用商店,安装简单,支持离线缓存。</div>
<h3>11.3 打包原生 Android App(APK)</h3>
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
{`# 前置: 安装 Android Studio 及 SDK
# 双击 build-app.bat 或手动执行:
cd client
npm install
npm run build
npx capacitor add android
npx capacitor sync android
npx capacitor open android
# 在 Android Studio 中 Build APK
# APK位置: android/app/build/outputs/apk/debug/app-debug.apk`}
</pre>
<h3>11.4 App 配置服务器地址</h3>
<ol>
<li>打开App进入登录页</li>
<li>点击"服务器设置"展开配置</li>
<li>输入服务器地址,如 http://192.168.1.100:3001</li>
<li>输入账号密码登录</li>
</ol>
<div className="warning">注意:App 必须能访问到服务器,请确保手机与服务器网络互通。</div>
</Card>
</div>
)
}
export default Guide
+90
View File
@@ -0,0 +1,90 @@
import React, { useState } from 'react'
import { Form, Input, Button, Card, message, Collapse } from 'antd'
import { UserOutlined, LockOutlined, LinkOutlined, SettingOutlined } from '@ant-design/icons'
import axios from 'axios'
import { setApiBase, getApiBase, isNative } from '../apiConfig'
function Login({ onLogin }) {
const [loading, setLoading] = useState(false)
const [serverUrl, setServerUrl] = useState(getApiBase())
const [form] = Form.useForm()
const handleSubmit = async (values) => {
setLoading(true)
try {
// 应用服务器地址
setApiBase(serverUrl)
const res = await axios.post('/api/auth/login', {
username: values.username,
password: values.password
})
if (res.data.success) {
const { token, ...user } = res.data.data
localStorage.setItem('token', token)
localStorage.setItem('user', JSON.stringify(user))
message.success(res.data.message)
onLogin(user)
}
} catch (err) {
message.error(err.response?.data?.message || '登录失败,请检查服务器地址和网络')
} finally {
setLoading(false)
}
}
return (
<div className="login-page">
<div className="login-bg">
<div className="login-shape shape-1"></div>
<div className="login-shape shape-2"></div>
<div className="login-shape shape-3"></div>
</div>
<div className="login-container">
<div className="login-brand">
<img src="/assets/logo-white.png" alt="影视星河" className="login-logo" />
<p className="login-subtitle">设备管理系统</p>
</div>
<Card className="login-card" bordered={false}>
<h2 className="login-title">欢迎登录</h2>
<p className="login-desc">请输入您的账号和密码</p>
<Form form={form} name="login" onFinish={handleSubmit} size="large">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" autoComplete="username" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined />} placeholder="密码" autoComplete="current-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block className="login-btn">
登 录
</Button>
</Form.Item>
</Form>
<Collapse ghost items={[{
key: 'server',
label: <span style={{ color: '#6b7280' }}><SettingOutlined /> 服务器设置{isNative() ? '(App必填)' : ''}</span>,
children: (
<Input
prefix={<LinkOutlined />}
placeholder="http://服务器IP:3001"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
/>
)
}]} />
</Card>
<p className="login-copyright">© 2026 影视星河 设备管理系统</p>
</div>
</div>
)
}
export default Login
+307
View File
@@ -0,0 +1,307 @@
import React, { useState, useEffect } from 'react'
import { Table, Button, Input, Select, Space, Tag, Modal, Form, message, Popconfirm, Card, Badge, Tooltip } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, CheckOutlined, CheckCircleOutlined } from '@ant-design/icons'
import axios from 'axios'
import { can } from '../permissions'
const { Option } = Select
const { TextArea } = Input
const notifTypes = ['通知', '公告', '提醒']
const priorities = ['普通', '高']
const targets = ['全部', '技术部', '行政部', '财务部', '市场部', '人事部', '运营部']
function NotificationManagement({ user }) {
const manageAllowed = can(user, 'notification:manage')
const [notifications, setNotifications] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingNotif, setEditingNotif] = useState(null)
const [keyword, setKeyword] = useState('')
const [filterType, setFilterType] = useState('')
const [filterPriority, setFilterPriority] = useState('')
const [filterRead, setFilterRead] = useState('')
const [form] = Form.useForm()
useEffect(() => {
fetchNotifications()
}, [keyword, filterType, filterPriority, filterRead])
const fetchNotifications = async () => {
setLoading(true)
try {
const params = {}
if (keyword) params.keyword = keyword
if (filterType) params.type = filterType
if (filterPriority) params.priority = filterPriority
if (filterRead !== '') params.is_read = filterRead
const res = await axios.get('/api/notifications', { params })
if (res.data.success) {
setNotifications(res.data.data)
}
} catch (err) {
message.error('获取通知列表失败')
} finally {
setLoading(false)
}
}
const handleAdd = () => {
setEditingNotif(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditingNotif(record)
form.setFieldsValue(record)
setModalVisible(true)
}
const handleDelete = async (id) => {
try {
const res = await axios.delete(`/api/notifications/${id}`)
if (res.data.success) {
message.success('通知删除成功')
fetchNotifications()
}
} catch (err) {
message.error('删除失败')
}
}
const handleMarkRead = async (id) => {
try {
const res = await axios.patch(`/api/notifications/${id}/read`)
if (res.data.success) {
message.success('已标记为已读')
fetchNotifications()
}
} catch (err) {
message.error('操作失败')
}
}
const handleMarkAllRead = async () => {
try {
const res = await axios.patch('/api/notifications/read-all')
if (res.data.success) {
message.success('全部标记为已读')
fetchNotifications()
}
} catch (err) {
message.error('操作失败')
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
if (editingNotif) {
const res = await axios.put(`/api/notifications/${editingNotif.id}`, {
...values,
is_read: values.is_read || false
})
if (res.data.success) {
message.success('通知更新成功')
}
} else {
const res = await axios.post('/api/notifications', values)
if (res.data.success) {
message.success('通知发布成功')
}
}
setModalVisible(false)
fetchNotifications()
} catch (err) {
if (err.response) {
message.error(err.response.data.message || '操作失败')
}
}
}
const handleReset = () => {
setKeyword('')
setFilterType('')
setFilterPriority('')
setFilterRead('')
}
const getTypeTag = (type) => {
const colorMap = { '通知': 'blue', '公告': 'purple', '提醒': 'cyan' }
return <Tag color={colorMap[type] || 'default'}>{type}</Tag>
}
const getPriorityTag = (priority) => {
return <Tag color={priority === '高' ? 'red' : 'default'}>{priority}</Tag>
}
const unreadCount = notifications.filter(n => n.is_read === 0).length
const columns = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
width: 220,
ellipsis: true,
render: (text, record) => (
<span>
{record.is_read === 0 && <Badge status="processing" />}
{' '}{text}
</span>
)
},
{
title: '内容',
dataIndex: 'content',
key: 'content',
width: 300,
ellipsis: { showTitle: false },
render: (text) => <Tooltip placement="topLeft" title={text}>{text}</Tooltip>
},
{ title: '类型', dataIndex: 'type', key: 'type', width: 80, render: getTypeTag },
{ title: '优先级', dataIndex: 'priority', key: 'priority', width: 80, render: getPriorityTag },
{
title: '状态',
dataIndex: 'is_read',
key: 'is_read',
width: 80,
render: (val) => val ? <Tag color="green">已读</Tag> : <Tag color="orange">未读</Tag>
},
{ title: '目标', dataIndex: 'target', key: 'target', width: 90 },
{ title: '发布时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
render: (_, record) => (
<Space>
{record.is_read === 0 && (
<Button type="link" icon={<CheckOutlined />} size="small" onClick={() => handleMarkRead(record.id)}>已读</Button>
)}
{manageAllowed && (
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
)}
{manageAllowed && (
<Popconfirm title="确定要删除此通知吗?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
</Popconfirm>
)}
</Space>
)
}
]
return (
<div>
<Card style={{ marginBottom: 16 }}>
<div className="search-bar">
<Input
placeholder="搜索标题/内容"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
style={{ width: 200 }}
allowClear
/>
<Select
placeholder="类型"
value={filterType || undefined}
onChange={(val) => setFilterType(val || '')}
style={{ width: 110 }}
allowClear
>
{notifTypes.map(t => <Option key={t} value={t}>{t}</Option>)}
</Select>
<Select
placeholder="优先级"
value={filterPriority || undefined}
onChange={(val) => setFilterPriority(val || '')}
style={{ width: 110 }}
allowClear
>
{priorities.map(p => <Option key={p} value={p}>{p}</Option>)}
</Select>
<Select
placeholder="阅读状态"
value={filterRead}
onChange={(val) => setFilterRead(val)}
style={{ width: 120 }}
allowClear
>
<Option value="0">未读</Option>
<Option value="1">已读</Option>
</Select>
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
</div>
<div className="table-toolbar">
<Space>
<span>共 {notifications.length} 条记录</span>
{unreadCount > 0 && (
<Button icon={<CheckCircleOutlined />} onClick={handleMarkAllRead} size="small">
全部已读 ({unreadCount})
</Button>
)}
</Space>
{manageAllowed && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>发布通知</Button>}
</div>
</Card>
<Table
columns={columns}
dataSource={notifications}
rowKey="id"
loading={loading}
scroll={{ x: 1300 }}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`
}}
/>
<Modal
title={editingNotif ? '编辑通知' : '发布通知'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={640}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input placeholder="请输入通知标题" />
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
<TextArea rows={4} placeholder="请输入通知内容" />
</Form.Item>
<div className="form-grid-3">
<Form.Item name="type" label="类型" initialValue="通知">
<Select>
{notifTypes.map(t => <Option key={t} value={t}>{t}</Option>)}
</Select>
</Form.Item>
<Form.Item name="priority" label="优先级" initialValue="普通">
<Select>
{priorities.map(p => <Option key={p} value={p}>{p}</Option>)}
</Select>
</Form.Item>
<Form.Item name="target" label="发送范围" initialValue="全部">
<Select>
{targets.map(t => <Option key={t} value={t}>{t}</Option>)}
</Select>
</Form.Item>
</div>
</Form>
</Modal>
</div>
)
}
export default NotificationManagement
+303
View File
@@ -0,0 +1,303 @@
import React, { useState, useEffect } from 'react'
import { Table, Button, Input, Select, Space, Tag, Modal, Form, DatePicker, message, Popconfirm, Card, Switch } from 'antd'
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ReloadOutlined, PhoneOutlined, MailOutlined } from '@ant-design/icons'
import axios from 'axios'
import dayjs from 'dayjs'
import { can, roleLabel } from '../permissions'
const { Option } = Select
const { TextArea } = Input
const departments = ['拍摄组', '后勤组', '监督组']
function PersonnelManagement({ user }) {
const manageAllowed = can(user, 'personnel:manage')
const [personnel, setPersonnel] = useState([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingPerson, setEditingPerson] = useState(null)
const [keyword, setKeyword] = useState('')
const [filterDept, setFilterDept] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [form] = Form.useForm()
useEffect(() => {
fetchPersonnel()
}, [keyword, filterDept, filterStatus])
const fetchPersonnel = async () => {
setLoading(true)
try {
const params = {}
if (keyword) params.keyword = keyword
if (filterDept) params.department = filterDept
if (filterStatus) params.status = filterStatus
const res = await axios.get('/api/personnel', { params })
if (res.data.success) {
setPersonnel(res.data.data)
}
} catch (err) {
message.error('获取人员列表失败')
} finally {
setLoading(false)
}
}
const handleAdd = () => {
setEditingPerson(null)
form.resetFields()
setModalVisible(true)
}
const handleEdit = (record) => {
setEditingPerson(record)
form.setFieldsValue({
...record,
entry_date: record.entry_date ? dayjs(record.entry_date) : null,
createAccount: !!record.user_id,
account_username: record.account_username || '',
account_role: record.account_role || 'user',
account_password: ''
})
setModalVisible(true)
}
const handleDelete = async (id) => {
try {
const res = await axios.delete(`/api/personnel/${id}`)
if (res.data.success) {
message.success('人员删除成功')
fetchPersonnel()
}
} catch (err) {
message.error('删除失败')
}
}
const handleSubmit = async () => {
try {
const values = await form.validateFields()
const data = {
...values,
entry_date: values.entry_date ? values.entry_date.format('YYYY-MM-DD') : '',
account: {
createAccount: !!values.createAccount,
username: values.account_username,
password: values.account_password,
role: values.account_role || 'user'
}
}
delete data.createAccount
delete data.account_username
delete data.account_password
delete data.account_role
if (editingPerson) {
const res = await axios.put(`/api/personnel/${editingPerson.id}`, data)
if (res.data.success) {
message.success('人员更新成功')
}
} else {
const res = await axios.post('/api/personnel', data)
if (res.data.success) {
message.success('人员添加成功')
}
}
setModalVisible(false)
fetchPersonnel()
} catch (err) {
if (err.response) {
message.error(err.response.data.message || '操作失败')
}
}
}
const handleReset = () => {
setKeyword('')
setFilterDept('')
setFilterStatus('')
}
const getStatusTag = (status) => {
return <Tag color={status === '在职' ? 'green' : status === '离职' ? 'red' : 'default'}>{status}</Tag>
}
const columns = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
{ title: '工号', dataIndex: 'employee_id', key: 'employee_id', width: 100 },
{ title: '部门', dataIndex: 'department', key: 'department', width: 100 },
{ title: '职位', dataIndex: 'position', key: 'position', width: 130 },
{
title: '电话',
dataIndex: 'phone',
key: 'phone',
width: 130,
render: (text) => text ? <span><PhoneOutlined style={{ marginRight: 4 }} />{text}</span> : '-'
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 200,
ellipsis: true,
render: (text) => text ? <span><MailOutlined style={{ marginRight: 4 }} />{text}</span> : '-'
},
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: getStatusTag },
{
title: '登录账户',
key: 'account',
width: 140,
render: (_, record) => record.account_username
? <Space direction="vertical" size={0}>
<span>{record.account_username}</span>
<Tag color={record.account_role === 'admin' ? 'gold' : 'blue'} style={{ marginRight: 0 }}>{roleLabel(record.account_role)}</Tag>
</Space>
: <Tag>无账户</Tag>
},
{ title: '入职日期', dataIndex: 'entry_date', key: 'entry_date', width: 110 },
{
title: '操作',
key: 'action',
width: 140,
fixed: 'right',
render: (_, record) => manageAllowed ? (
<Space>
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => handleEdit(record)}>编辑</Button>
<Popconfirm title="确定要删除此人员吗?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
</Popconfirm>
</Space>
) : <Tag color="default">仅查看</Tag>
}
]
return (
<div>
<Card style={{ marginBottom: 16 }}>
<div className="search-bar">
<Input
placeholder="搜索姓名/工号/手机号"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
style={{ width: 220 }}
allowClear
/>
<Select
placeholder="部门"
value={filterDept || undefined}
onChange={(val) => setFilterDept(val || '')}
style={{ width: 130 }}
allowClear
>
{departments.map(d => <Option key={d} value={d}>{d}</Option>)}
</Select>
<Select
placeholder="在职状态"
value={filterStatus || undefined}
onChange={(val) => setFilterStatus(val || '')}
style={{ width: 120 }}
allowClear
>
<Option value="在职">在职</Option>
<Option value="离职">离职</Option>
</Select>
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
</div>
<div className="table-toolbar">
<span>共 {personnel.length} 条记录{!manageAllowed && '(当前为普通账户,仅可查看)'}</span>
{manageAllowed && <Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加人员</Button>}
</div>
</Card>
<Table
columns={columns}
dataSource={personnel}
rowKey="id"
loading={loading}
scroll={{ x: 1100 }}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`
}}
/>
<Modal
title={editingPerson ? '编辑人员' : '添加人员'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={720}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div className="form-grid">
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
</Form.Item>
<Form.Item name="employee_id" label="工号">
<Input placeholder="请输入工号" />
</Form.Item>
<Form.Item name="department" label="部门">
<Select placeholder="请选择部门" allowClear>
{departments.map(d => <Option key={d} value={d}>{d}</Option>)}
</Select>
</Form.Item>
<Form.Item name="position" label="职位">
<Input placeholder="请输入职位" />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input placeholder="请输入电话" />
</Form.Item>
<Form.Item name="email" label="邮箱">
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="在职">
<Select>
<Option value="在职">在职</Option>
<Option value="离职">离职</Option>
</Select>
</Form.Item>
<Form.Item name="entry_date" label="入职日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" title="登录账户(可选)" style={{ marginBottom: 16, background: '#fafafa' }}>
<Form.Item name="createAccount" label="创建/启用登录账户" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.createAccount !== cur.createAccount}>
{({ getFieldValue }) => getFieldValue('createAccount') ? (
<div className="form-grid">
<Form.Item name="account_username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="登录用户名" />
</Form.Item>
<Form.Item name="account_password" label="密码" rules={[{ required: !editingPerson, message: '请输入密码' }]}>
<Input.Password placeholder={editingPerson ? '留空则不修改' : '登录密码'} />
</Form.Item>
<Form.Item name="account_role" label="账户角色" initialValue="user">
<Select>
<Option value="user">普通账户</Option>
<Option value="admin">管理员账户</Option>
</Select>
</Form.Item>
</div>
) : null}
</Form.Item>
</Card>
<Form.Item name="description" label="备注">
<TextArea rows={3} placeholder="请输入备注信息" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
export default PersonnelManagement