diff --git a/client/src/App.jsx b/client/src/App.jsx
index 19bc46a..5c3a733 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
-import { Layout, Menu, theme, Button, Space, Dropdown, message, Drawer, Avatar, Modal, Form, Input, Badge, Tooltip } from 'antd'
+import { Layout, Menu, theme, ConfigProvider, Button, Space, Dropdown, message, Drawer, Avatar, Modal, Form, Input, Badge, Tooltip } from 'antd'
import {
DashboardOutlined,
DesktopOutlined,
@@ -13,13 +13,17 @@ import {
DownOutlined,
UserSwitchOutlined,
KeyOutlined,
- MessageOutlined
+ MessageOutlined,
+ MoonOutlined,
+ SunOutlined,
+ SwapOutlined
} from '@ant-design/icons'
import axios from 'axios'
import Login from './pages/Login'
import Weather from './components/Weather'
import Dashboard from './pages/Dashboard'
import DeviceManagement from './pages/DeviceManagement'
+import DeviceLoan from './pages/DeviceLoan'
import PersonnelManagement from './pages/PersonnelManagement'
import NotificationManagement from './pages/NotificationManagement'
import AccountManagement from './pages/AccountManagement'
@@ -41,8 +45,36 @@ function useIsMobile() {
return isMobile
}
+// 深色模式 Hook:优先用户手动选择(localStorage),否则跟随系统 prefers-color-scheme
+function useThemeMode() {
+ const [mode, setMode] = useState(() => localStorage.getItem('themeMode') || 'auto')
+ const [sysDark, setSysDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
+ useEffect(() => {
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
+ const onChange = (e) => setSysDark(e.matches)
+ if (mq.addEventListener) mq.addEventListener('change', onChange)
+ else mq.addListener(onChange)
+ return () => {
+ if (mq.removeEventListener) mq.removeEventListener('change', onChange)
+ else mq.removeListener(onChange)
+ }
+ }, [])
+ const isDark = mode === 'dark' || (mode === 'auto' && sysDark)
+ useEffect(() => {
+ // 供 index.css 中 [data-theme='dark'] 变量覆盖使用
+ document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light')
+ }, [isDark])
+ const toggle = () => {
+ const next = isDark ? 'light' : 'dark'
+ setMode(next)
+ localStorage.setItem('themeMode', next)
+ }
+ return { isDark, toggle }
+}
+
function App() {
const isMobile = useIsMobile()
+ const { isDark, toggle: toggleTheme } = useThemeMode()
const [collapsed, setCollapsed] = useState(false)
const [drawerVisible, setDrawerVisible] = useState(false)
const [currentPage, setCurrentPage] = useState('dashboard')
@@ -124,6 +156,7 @@ function App() {
{ key: 'dashboard', icon: , label: '系统首页' },
{ type: 'divider' },
{ key: 'devices', icon: , label: '设备管理' },
+ { key: 'loans', icon: , label: '设备借用' },
{ key: 'personnel', icon: , label: '人员管理' },
...(user?.role === 'admin' ? [{ key: 'accounts', icon: , label: '账户管理' }] : []),
{ key: 'notifications', icon: , label: '通知管理' },
@@ -146,6 +179,7 @@ function App() {
case 'about': return
case 'guide': return
case 'devices': return
+ case 'loans': return
case 'personnel': return
case 'accounts': return user?.role === 'admin' ? :
case 'notifications': return
@@ -157,7 +191,7 @@ function App() {
const getPageTitle = () => {
const titles = {
dashboard: '系统首页', about: '软件简介', guide: '使用说明',
- devices: '设备管理', personnel: '人员管理', accounts: '账户管理',
+ devices: '设备管理', loans: '设备借用', personnel: '人员管理', accounts: '账户管理',
notifications: '通知管理', messages: '消息对话'
}
return titles[currentPage] || '系统首页'
@@ -240,8 +274,15 @@ function App() {
return
加载中...
}
+ // antd 主题:深色/浅色算法切换;登录页固定浅色(白卡片渐变背景)
+ const antdTheme = { algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm }
+
if (!user) {
- return
+ return (
+
+
+
+ )
}
const menuContent = (
@@ -262,13 +303,17 @@ function App() {
// ============ 移动端布局 ============
if (isMobile) {
return (
+
} onClick={() => setDrawerVisible(true)} style={{ color: '#fff' }} />
{getPageTitle()}
-
- } />
-
+
+ : } onClick={toggleTheme} style={{ color: '#fff' }} />
+
+ } />
+
+
@@ -306,11 +351,13 @@ function App() {
{pwdModal}
+
)
}
// ============ 桌面端布局 ============
return (
+
@@ -349,6 +396,9 @@ function App() {
{user.display_name || user.username},{getGreeting()}!
+
+ : } onClick={toggleTheme} />
+
} onClick={() => setPwdModalVisible(true)}>
修改密码
@@ -368,6 +418,7 @@ function App() {
{pwdModal}
+
)
}
diff --git a/client/src/index.css b/client/src/index.css
index dfd6676..8fb79cf 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -4,8 +4,47 @@
box-sizing: border-box;
}
+/* ==================== 主题变量(深色模式适配) ==================== */
+:root {
+ --page-bg: #f5f7fa;
+ --card-bg: #ffffff;
+ --text-main: #374151;
+ --text-sub: #666666;
+ --text-hint: #999999;
+ --text-faint: #bbbbbb;
+ --border: #e8e8e8;
+ --border-light: #f0f0f0;
+ --hover-bg: #f5f5f5;
+ --active-bg: #f0ebff;
+ --chat-bg: #fafafa;
+ --tip-bg: #e6f7ff;
+ --tip-border: #91d5ff;
+ --warn-bg: #fffbe6;
+ --warn-border: #ffe58f;
+}
+
+[data-theme='dark'] {
+ --page-bg: #141414;
+ --card-bg: #1f1f1f;
+ --text-main: rgba(255, 255, 255, 0.85);
+ --text-sub: rgba(255, 255, 255, 0.65);
+ --text-hint: rgba(255, 255, 255, 0.45);
+ --text-faint: rgba(255, 255, 255, 0.3);
+ --border: #303030;
+ --border-light: #303030;
+ --hover-bg: #2a2a2a;
+ --active-bg: #2a2140;
+ --chat-bg: #181818;
+ --tip-bg: #111a2c;
+ --tip-border: #153450;
+ --warn-bg: #2b2111;
+ --warn-border: #594214;
+}
+
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ background: var(--page-bg);
+ color: var(--text-main);
}
.logo {
@@ -79,12 +118,12 @@ body {
.about-page h2 {
font-size: 20px;
margin: 24px 0 12px;
- color: #333;
+ color: var(--text-main);
}
.about-page p {
line-height: 1.8;
- color: #666;
+ color: var(--text-sub);
margin-bottom: 12px;
}
@@ -95,7 +134,7 @@ body {
.about-page li {
line-height: 2;
- color: #666;
+ color: var(--text-sub);
}
.guide-page {
@@ -113,20 +152,20 @@ body {
.guide-page h2 {
font-size: 20px;
margin: 24px 0 12px;
- color: #333;
+ color: var(--text-main);
padding-bottom: 8px;
- border-bottom: 2px solid #e8e8e8;
+ border-bottom: 2px solid var(--border);
}
.guide-page h3 {
font-size: 16px;
margin: 16px 0 8px;
- color: #444;
+ color: var(--text-main);
}
.guide-page p {
line-height: 1.8;
- color: #666;
+ color: var(--text-sub);
margin-bottom: 12px;
}
@@ -138,20 +177,20 @@ body {
.guide-page li {
line-height: 2;
- color: #666;
+ color: var(--text-sub);
}
.guide-page .tip {
- background: #e6f7ff;
- border: 1px solid #91d5ff;
+ background: var(--tip-bg);
+ border: 1px solid var(--tip-border);
border-radius: 4px;
padding: 12px 16px;
margin: 12px 0;
}
.guide-page .warning {
- background: #fffbe6;
- border: 1px solid #ffe58f;
+ background: var(--warn-bg);
+ border: 1px solid var(--warn-border);
border-radius: 4px;
padding: 12px 16px;
margin: 12px 0;
@@ -315,7 +354,7 @@ body {
/* ==================== 顶部问候语 ==================== */
.greeting-text {
font-size: 15px;
- color: #374151;
+ color: var(--text-main);
font-weight: 500;
}
@@ -351,21 +390,21 @@ body {
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
- background: #f5f7fa;
- border-bottom: 1px solid #e8e8e8;
+ background: var(--page-bg);
+ border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.mobile-greeting {
font-size: 14px;
- color: #374151;
+ color: var(--text-main);
font-weight: 500;
}
.mobile-content {
padding: 12px;
padding-bottom: 76px; /* 为底部导航留空间 */
- background: #f5f7fa;
+ background: var(--page-bg);
}
/* 底部导航 */
@@ -375,8 +414,8 @@ body {
left: 0;
right: 0;
display: flex;
- background: #fff;
- border-top: 1px solid #e8e8e8;
+ background: var(--card-bg);
+ border-top: 1px solid var(--border);
z-index: 100;
padding-bottom: env(safe-area-inset-bottom);
}
@@ -391,7 +430,7 @@ body {
padding: 8px 0;
border: none;
background: none;
- color: #8c8c8c;
+ color: var(--text-hint);
font-size: 12px;
cursor: pointer;
transition: color 0.2s;
@@ -527,7 +566,7 @@ body {
.messages-list {
width: 320px;
- border-right: 1px solid #f0f0f0;
+ border-right: 1px solid var(--border-light);
padding: 12px;
overflow-y: auto;
}
@@ -540,13 +579,13 @@ body {
}
.conv-item:hover {
- background: #f5f5f5;
+ background: var(--hover-bg);
border-radius: 6px;
}
.conv-item-active,
.conv-item-active:hover {
- background: #f0ebff;
+ background: var(--active-bg);
border-radius: 6px;
}
@@ -560,14 +599,14 @@ body {
display: flex;
align-items: center;
padding: 12px 16px;
- border-bottom: 1px solid #f0f0f0;
+ border-bottom: 1px solid var(--border-light);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
- background: #fafafa;
+ background: var(--chat-bg);
}
.chat-msg {
@@ -583,15 +622,15 @@ body {
.chat-msg-sender {
font-size: 12px;
- color: #999;
+ color: var(--text-hint);
margin-bottom: 3px;
}
.chat-bubble {
max-width: 70%;
padding: 8px 12px;
- background: #fff;
- border: 1px solid #eee;
+ background: var(--card-bg);
+ border: 1px solid var(--border-light);
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
@@ -605,16 +644,104 @@ body {
.chat-msg-time {
font-size: 11px;
- color: #bbb;
+ color: var(--text-faint);
margin-top: 3px;
}
+.chat-msg-status {
+ margin-left: 6px;
+ font-size: 11px;
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ cursor: default;
+}
+
+.chat-msg-read {
+ color: #52c41a;
+}
+
+.chat-msg-unread {
+ color: var(--text-hint);
+}
+
.chat-input {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px 16px 12px;
+ border-top: 1px solid var(--border-light);
+}
+
+.chat-toolbar {
+ display: flex;
+ gap: 2px;
+}
+
+.chat-input-row {
display: flex;
gap: 8px;
align-items: flex-end;
- padding: 12px 16px;
- border-top: 1px solid #f0f0f0;
+}
+
+.emoji-panel {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ gap: 2px;
+ max-width: 320px;
+}
+
+.emoji-item {
+ font-size: 20px;
+ line-height: 1;
+ padding: 5px;
+ border-radius: 6px;
+ cursor: pointer;
+ text-align: center;
+}
+
+.emoji-item:hover {
+ background: var(--hover-bg);
+}
+
+.chat-bubble-media {
+ background: var(--card-bg);
+ border: 1px solid var(--border-light);
+ color: inherit;
+ padding: 4px;
+}
+
+.chat-file-card {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 200px;
+ max-width: 280px;
+ padding: 10px 12px;
+}
+
+.chat-file-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.chat-file-name {
+ font-size: 13px;
+ color: var(--text-main);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chat-file-size {
+ font-size: 12px;
+ color: var(--text-hint);
+}
+
+.chat-file-download {
+ color: #7c3aed;
+ font-size: 16px;
+ padding: 4px;
}
.chat-placeholder {
diff --git a/client/src/pages/About.jsx b/client/src/pages/About.jsx
index 5803afd..7fe2877 100644
--- a/client/src/pages/About.jsx
+++ b/client/src/pages/About.jsx
@@ -5,7 +5,7 @@ function About() {
return (
- 影视星河 · 设备管理系统 v2.2
+ 影视星河 · 设备管理系统 v5.0
软件概述
@@ -19,6 +19,7 @@ function About() {
登录认证:支持账号密码登录,内置默认管理员账户,登录后根据时间智能问候
每日天气:顶部导航栏实时显示当前城市天气、温度、湿度等信息
设备管理:支持设备的添加、编辑、删除、查询等全生命周期管理,涵盖笔记本电脑、台式机、显示器、打印机、网络设备等各类IT资产
+ 设备借用:支持设备借用登记与归还管理,可上传借用/归还照片,从已有设备与人员中选择,服务器自动记录借用与归还时间
人员管理:维护组织架构中的人员信息,包括部门、职位、联系方式等,便于设备责任分配
通知管理:支持发布各类通知、公告和提醒,可设置优先级和发送范围,确保重要信息及时传达
数据概览:提供直观的仪表盘,实时展示设备状态、人员情况和通知统计
@@ -62,12 +63,21 @@ function About() {
版本信息
- - 当前版本:v2.2
+ - 当前版本:v5.0
- 发布日期:2026年8月
- 许可证:MIT License
更新日志
+ v5.0(2026年8月)
+
+ - 新增设备借用功能:支持上传借用照片,从已有设备中选择借用设备,从已有成员中选择借用人
+ - 新增设备归还功能:支持上传归还照片,设备与人员可从已有数据中选择
+ - 服务器自动存储借用时间与归还时间
+ - 苹果端适配 Liquid Glass 液态玻璃设计(iOS 26/27)
+ - 优化苹果端退出后台后需重新登录的问题
+ - 修复苹果端底部导航栏触控困难的问题
+
v2.2(2026年8月)
- 添加群聊删除人员的功能
diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx
index 39da754..858b106 100644
--- a/client/src/pages/Dashboard.jsx
+++ b/client/src/pages/Dashboard.jsx
@@ -120,7 +120,7 @@ function Dashboard({ onNavigate }) {
系统信息
- 系统版本:v2.2
+ 系统版本:v5.0
系统名称:设备管理系统
diff --git a/client/src/pages/DeviceLoan.jsx b/client/src/pages/DeviceLoan.jsx
new file mode 100644
index 0000000..9a4cb24
--- /dev/null
+++ b/client/src/pages/DeviceLoan.jsx
@@ -0,0 +1,346 @@
+import React, { useState, useEffect } from 'react'
+import {
+ Card, Table, Tag, Button, Modal, Form, Select, Upload, Input, Space,
+ Image, message, Empty, Row, Col, Statistic
+} from 'antd'
+import {
+ PlusOutlined, SwapOutlined, CameraOutlined, CheckCircleOutlined, ClockCircleOutlined
+} from '@ant-design/icons'
+import axios from 'axios'
+import { getApiBase } from '../apiConfig'
+
+// 借用/归还照片地址(图片无法携带 Authorization 头,走查询参数 token)
+const photoUrl = (fileId) => {
+ if (!fileId) return ''
+ const token = localStorage.getItem('token') || ''
+ return `${getApiBase()}/api/loans/photo/${fileId}?token=${encodeURIComponent(token)}`
+}
+
+function DeviceLoan({ user }) {
+ const [loans, setLoans] = useState([])
+ const [devices, setDevices] = useState([])
+ const [personnel, setPersonnel] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [statusFilter, setStatusFilter] = useState('')
+ const [keyword, setKeyword] = useState('')
+
+ // 借用弹窗
+ const [loanOpen, setLoanOpen] = useState(false)
+ const [loanForm] = Form.useForm()
+ const [loanPhoto, setLoanPhoto] = useState('')
+ const [loanPhotoPreview, setLoanPhotoPreview] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+
+ // 归还弹窗
+ const [returnOpen, setReturnOpen] = useState(false)
+ const [returnTarget, setReturnTarget] = useState(null)
+ const [returnForm] = Form.useForm()
+ const [returnPhoto, setReturnPhoto] = useState('')
+ const [returnPhotoPreview, setReturnPhotoPreview] = useState('')
+
+ const loadLoans = async () => {
+ setLoading(true)
+ try {
+ const res = await axios.get('/api/loans')
+ if (res.data.success) setLoans(res.data.data || [])
+ } catch (err) {
+ message.error(err.response?.data?.message || '加载借用记录失败')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ loadLoans()
+ axios.get('/api/devices').then(res => {
+ if (res.data.success) setDevices(res.data.data || [])
+ }).catch(() => {})
+ axios.get('/api/personnel').then(res => {
+ if (res.data.success) setPersonnel(res.data.data || [])
+ }).catch(() => {})
+ }, [])
+
+ // 上传照片(复用通用文件上传接口,返回 fileId)
+ const uploadPhoto = async (file) => {
+ const fd = new FormData()
+ fd.append('file', file)
+ const res = await axios.post('/api/chat/upload', fd, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ })
+ if (!res.data.success) throw new Error(res.data.message || '上传失败')
+ return res.data.data.fileId
+ }
+
+ // ============ 借用 ============
+ const openLoanModal = () => {
+ loanForm.resetFields()
+ setLoanPhoto('')
+ setLoanPhotoPreview('')
+ setLoanOpen(true)
+ }
+
+ const handleLoanPhoto = async (file) => {
+ try {
+ const fileId = await uploadPhoto(file)
+ setLoanPhoto(fileId)
+ setLoanPhotoPreview(photoUrl(fileId))
+ message.success('借用照片上传成功')
+ } catch (err) {
+ message.error(err.response?.data?.message || err.message || '照片上传失败')
+ }
+ return false
+ }
+
+ const submitLoan = async () => {
+ try {
+ const values = await loanForm.validateFields()
+ setSubmitting(true)
+ const res = await axios.post('/api/loans', {
+ device_id: values.device_id,
+ borrower: values.borrower,
+ borrower_id: personnel.find(p => p.name === values.borrower)?.id || '',
+ photo: loanPhoto,
+ remark: values.remark || ''
+ })
+ if (res.data.success) {
+ message.success(res.data.message || '借用成功')
+ setLoanOpen(false)
+ loadLoans()
+ }
+ } catch (err) {
+ if (err.response) message.error(err.response.data?.message || '借用失败')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ // ============ 归还 ============
+ const openReturnModal = (loan) => {
+ setReturnTarget(loan)
+ setReturnPhoto('')
+ setReturnPhotoPreview('')
+ returnForm.setFieldsValue({
+ device_id: loan.device_id,
+ borrower: loan.borrower
+ })
+ setReturnOpen(true)
+ }
+
+ const handleReturnPhoto = async (file) => {
+ try {
+ const fileId = await uploadPhoto(file)
+ setReturnPhoto(fileId)
+ setReturnPhotoPreview(photoUrl(fileId))
+ message.success('归还照片上传成功')
+ } catch (err) {
+ message.error(err.response?.data?.message || err.message || '照片上传失败')
+ }
+ return false
+ }
+
+ const submitReturn = async () => {
+ try {
+ const values = await returnForm.validateFields()
+ setSubmitting(true)
+ const res = await axios.post(`/api/loans/${returnTarget.id}/return`, {
+ device_id: values.device_id,
+ borrower: values.borrower,
+ borrower_id: personnel.find(p => p.name === values.borrower)?.id || '',
+ photo: returnPhoto
+ })
+ if (res.data.success) {
+ message.success(res.data.message || '归还成功')
+ setReturnOpen(false)
+ loadLoans()
+ }
+ } catch (err) {
+ if (err.response) message.error(err.response.data?.message || '归还失败')
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ // ============ 列表 ============
+ const filtered = loans.filter(l => {
+ if (statusFilter && l.status !== statusFilter) return false
+ if (keyword) {
+ const k = keyword.toLowerCase()
+ const hit = [l.device_name, l.borrower, l.created_by]
+ .some(v => (v || '').toLowerCase().includes(k))
+ if (!hit) return false
+ }
+ return true
+ })
+
+ const loaning = loans.filter(l => l.status === '借用中').length
+ const returned = loans.filter(l => l.status === '已归还').length
+
+ const columns = [
+ {
+ title: '设备', dataIndex: 'device_name', key: 'device',
+ render: (v) => {v || '-'}
+ },
+ {
+ title: '借用人', dataIndex: 'borrower', key: 'borrower',
+ render: (v, r) => {
+ const p = personnel.find(x => x.name === r.borrower)
+ return p ? `${v}(${p.department || '-'})` : v
+ }
+ },
+ {
+ title: '状态', dataIndex: 'status', key: 'status', width: 100,
+ render: (v) => v === '已归还'
+ ? 已归还
+ : 借用中
+ },
+ { title: '借用时间', dataIndex: 'loan_time', key: 'loan_time', width: 170 },
+ { title: '归还时间', dataIndex: 'return_time', key: 'return_time', width: 170, render: (v) => v || '-' },
+ {
+ title: '借用照片', dataIndex: 'loan_photo', key: 'loan_photo', width: 90,
+ render: (v) => v
+ ?
+ : -
+ },
+ {
+ title: '归还照片', dataIndex: 'return_photo', key: 'return_photo', width: 90,
+ render: (v) => v
+ ?
+ : -
+ },
+ { title: '经办人', dataIndex: 'created_by', key: 'created_by', width: 110, render: (v) => v || '-' },
+ {
+ title: '操作', key: 'action', width: 100,
+ render: (_, r) => r.status === '借用中'
+ ? } onClick={() => openReturnModal(r)}>归还
+ : null
+ }
+ ]
+
+ const deviceOptions = devices
+ .filter(d => d.status !== '已报废')
+ .map(d => ({ value: d.id, label: `${d.name}(${d.type || '未分类'} / ${d.status})` }))
+
+ const personnelOptions = personnel.map(p => ({
+ value: p.name,
+ label: `${p.name}${p.department ? ' · ' + p.department : ''}${p.position ? ' · ' + p.position : ''}`
+ }))
+
+ // 照片上传按钮(借用/归还共用样式)
+ const photoUploadBtn = (preview, beforeUpload, text) => (
+
+
+ }>{text}
+
+ {preview && (
+
+ )}
+
+ )
+
+ return (
+
+
+
+ } />
+
+
+ } />
+
+
+ } />
+
+
+
+
+
+
+
+ } onClick={openLoanModal}>借用设备
+
+
+ }}
+ pagination={{ pageSize: 10, showSizeChanger: false }}
+ scroll={{ x: 900 }}
+ />
+
+
+ {/* 借用弹窗 */}
+
setLoanOpen(false)}
+ okText="确认借用"
+ cancelText="取消"
+ confirmLoading={submitting}
+ width={520}
+ >
+
+
+
+
+
+
+
+ {photoUploadBtn(loanPhotoPreview, handleLoanPhoto, loanPhoto ? '重新上传照片' : '上传借用照片')}
+
+
+
+
+
+ 确认借用后,服务器将自动记录借用时间。
+
+
+ {/* 归还弹窗 */}
+
setReturnOpen(false)}
+ okText="确认归还"
+ cancelText="取消"
+ confirmLoading={submitting}
+ width={520}
+ >
+
+
+
+
+
+
+
+ {photoUploadBtn(returnPhotoPreview, handleReturnPhoto, returnPhoto ? '重新上传照片' : '上传归还照片')}
+
+
+ 确认归还后,服务器将自动记录归还时间。
+
+
+ )
+}
+
+export default DeviceLoan
diff --git a/database.js b/database.js
index 6ddd6c2..5c6cd1d 100644
--- a/database.js
+++ b/database.js
@@ -125,6 +125,25 @@ function initDatabase() {
)
`);
+ // 设备借用记录表(借用/归还时间由服务器自动记录)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS device_loans (
+ id TEXT PRIMARY KEY,
+ device_id TEXT NOT NULL,
+ device_name TEXT DEFAULT '',
+ borrower_id TEXT DEFAULT '',
+ borrower TEXT DEFAULT '',
+ loan_photo TEXT DEFAULT '',
+ return_photo TEXT DEFAULT '',
+ status TEXT DEFAULT '借用中',
+ loan_time TEXT DEFAULT (datetime('now', 'localtime')),
+ return_time TEXT DEFAULT '',
+ remark TEXT DEFAULT '',
+ created_by TEXT DEFAULT '',
+ created_at TEXT DEFAULT (datetime('now', 'localtime'))
+ )
+ `);
+
// 账户操作日志表
db.exec(`
CREATE TABLE IF NOT EXISTS user_logs (
diff --git a/package.json b/package.json
index 9b9e5b5..ba336bd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "device-management-system",
- "version": "2.2.0",
+ "version": "5.0.0",
"description": "影视星河设备管理系统 - Device Management System",
"main": "electron/main.js",
"scripts": {
diff --git a/server.js b/server.js
index b86ed38..bac3609 100644
--- a/server.js
+++ b/server.js
@@ -48,6 +48,11 @@ function dropSession(token) {
sessionActivity.delete(token);
}
+// 下线时间列(兼容旧库:已存在则忽略)
+try {
+ db.prepare("ALTER TABLE users ADD COLUMN last_logout TEXT DEFAULT ''").run();
+} catch (e) { /* 列已存在 */ }
+
// 收集当前在线用户(用户ID -> 最近活跃时间戳,取最新)
function getOnlineUserMap() {
const now = Date.now();
@@ -521,6 +526,85 @@ app.get('/api/personnel/stats/summary', (req, res) => {
res.json({ success: true, data: { total, active, withAccount, departments } });
});
+// ==================== 设备借用/归还 API ====================
+
+// 借用记录列表(可按状态/设备筛选,借用时间倒序)
+app.get('/api/loans', (req, res) => {
+ const { status, device_id } = req.query;
+ const conds = [];
+ const params = [];
+ if (status) { conds.push('status = ?'); params.push(status); }
+ if (device_id) { conds.push('device_id = ?'); params.push(device_id); }
+ const where = conds.length ? ' WHERE ' + conds.join(' AND ') : '';
+ const rows = db.prepare(`SELECT * FROM device_loans${where} ORDER BY loan_time DESC, rowid DESC LIMIT 200`).all(...params);
+ res.json({ success: true, data: rows });
+});
+
+// 创建借用(服务器自动记录借用时间)
+app.post('/api/loans', (req, res) => {
+ const { device_id, borrower, borrower_id, photo, remark } = req.body || {};
+ const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id || '');
+ if (!device) {
+ return res.status(400).json({ success: false, message: '请选择要借用的设备' });
+ }
+ if (!borrower || !String(borrower).trim()) {
+ return res.status(400).json({ success: false, message: '请选择借用人' });
+ }
+ const id = uuidv4();
+ db.prepare(`
+ INSERT INTO device_loans (id, device_id, device_name, borrower_id, borrower, loan_photo, remark, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(id, device.id, device.name, borrower_id || '', String(borrower).trim(), photo || '', remark || '', getOperator(req));
+ const row = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(id);
+ logDeviceAction(device.id, device.name, '借用', `${row.borrower} 借走设备`, getOperator(req));
+ pushToRole('admin', 'device_loan', '设备借用', `${row.borrower} 借用了设备「${device.name}」`, req.user.id);
+ res.json({ success: true, data: row, message: '借用成功' });
+});
+
+// 归还设备(服务器自动记录归还时间;设备/借用人可重新选择)
+app.post('/api/loans/:id/return', (req, res) => {
+ const loan = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(req.params.id);
+ if (!loan) {
+ return res.status(404).json({ success: false, message: '借用记录不存在' });
+ }
+ if (loan.status === '已归还') {
+ return res.status(400).json({ success: false, message: '该设备已归还,请勿重复操作' });
+ }
+ const { device_id, borrower, borrower_id, photo } = req.body || {};
+ // 归还时可重新选择设备(从已有设备中选择)
+ let deviceId = loan.device_id;
+ let deviceName = loan.device_name;
+ if (device_id && device_id !== loan.device_id) {
+ const d = db.prepare('SELECT id, name FROM devices WHERE id = ?').get(device_id);
+ if (d) { deviceId = d.id; deviceName = d.name; }
+ }
+ const borrowerName = (borrower && String(borrower).trim()) || loan.borrower;
+ db.prepare(`
+ UPDATE device_loans
+ SET device_id = ?, device_name = ?, borrower = ?, borrower_id = ?,
+ return_photo = ?, status = '已归还', return_time = datetime('now', 'localtime')
+ WHERE id = ?
+ `).run(deviceId, deviceName, borrowerName, borrower_id || loan.borrower_id, photo || '', loan.id);
+ const row = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(loan.id);
+ logDeviceAction(deviceId, deviceName, '归还', `${borrowerName} 归还设备`, getOperator(req));
+ pushToRole('admin', 'device_return', '设备归还', `${borrowerName} 已归还设备「${deviceName}」`, req.user.id);
+ res.json({ success: true, data: row, message: '归还成功' });
+});
+
+// 查看借用/归还照片(所有登录用户可见)
+app.get('/api/loans/photo/:fileId', (req, res) => {
+ const file = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(req.params.fileId);
+ if (!file) {
+ return res.status(404).json({ success: false, message: '照片不存在' });
+ }
+ const filePath = path.join(uploadsDir, file.stored_name);
+ if (!fs.existsSync(filePath)) {
+ return res.status(404).json({ success: false, message: '照片文件已丢失' });
+ }
+ res.setHeader('Content-Type', file.file_mime || 'image/jpeg');
+ res.sendFile(filePath);
+});
+
// ==================== 通知管理 API ====================
// 获取所有通知
@@ -840,11 +924,15 @@ app.get('/api/chat/files/:id', (req, res) => {
fs.createReadStream(filePath).pipe(res);
});
-// 全部用户简表(选择聊天对象/建群成员,含在线状态)
+// 全部用户简表(选择聊天对象/建群成员,含在线状态与登录/下线时间)
app.get('/api/chat/users', (req, res) => {
- const rows = db.prepare('SELECT id, username, display_name, role FROM users ORDER BY created_at ASC').all();
+ const rows = db.prepare('SELECT id, username, display_name, role, last_login, last_logout FROM users ORDER BY created_at ASC').all();
const onlineMap = getOnlineUserMap();
- const data = rows.map(u => ({ ...u, is_online: onlineMap.has(u.id) }));
+ const data = rows.map(u => ({
+ ...u,
+ is_online: onlineMap.has(u.id),
+ last_active: onlineMap.has(u.id) ? formatActive(onlineMap.get(u.id)) : null
+ }));
res.json({ success: true, data });
});
@@ -1145,6 +1233,11 @@ app.get('/api/auth/me', (req, res) => {
// 登出
app.post('/api/auth/logout', (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
+ const user = token ? sessions.get(token) : null;
+ if (user) {
+ // 记录下线时间
+ db.prepare("UPDATE users SET last_logout=datetime('now','localtime') WHERE id=?").run(user.id);
+ }
if (token) dropSession(token);
res.json({ success: true, message: '登出成功' });
});
@@ -1167,6 +1260,47 @@ app.patch('/api/auth/password', (req, res) => {
res.json({ success: true, message: '密码修改成功,请使用新密码重新登录' });
});
+// ==================== App 版本检查与更新 ====================
+
+db.exec(`CREATE TABLE IF NOT EXISTS app_versions (
+ platform TEXT PRIMARY KEY,
+ version TEXT NOT NULL,
+ url TEXT DEFAULT '',
+ size INTEGER DEFAULT 0,
+ notes TEXT DEFAULT '',
+ released_at TEXT
+)`);
+
+// 首次启动播种当前四端 v4.0.0 版本信息(仅空表时写入)
+['windows', 'android', 'harmony', 'ios'].forEach(p => {
+ db.prepare("INSERT OR IGNORE INTO app_versions(platform, version, url, size, notes, released_at) VALUES(?, '4.0.0', '', 0, '影视星河设备管理系统 v4.0.0', datetime('now','localtime'))").run(p);
+});
+
+// 查询各端最新版本(客户端登录后检查更新用)
+app.get('/api/app/version/latest', (req, res) => {
+ const rows = db.prepare('SELECT * FROM app_versions').all();
+ const map = {};
+ rows.forEach(r => { map[r.platform] = r; });
+ res.json({ success: true, data: map });
+});
+
+// 管理员发布新版本:{ platform: windows|android|harmony|ios, version, url, size, notes }
+app.post('/api/app/version', requireAdmin, (req, res) => {
+ const { platform, version, url, size, notes } = req.body || {};
+ const platforms = ['windows', 'android', 'harmony', 'ios'];
+ if (!platforms.includes(platform)) {
+ return res.status(400).json({ success: false, message: '平台取值必须为 windows/android/harmony/ios' });
+ }
+ if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
+ return res.status(400).json({ success: false, message: '版本号格式必须为 x.y.z' });
+ }
+ db.prepare(`INSERT INTO app_versions(platform, version, url, size, notes, released_at)
+ VALUES(?,?,?,?,?,datetime('now','localtime'))
+ ON CONFLICT(platform) DO UPDATE SET version=excluded.version, url=excluded.url, size=excluded.size, notes=excluded.notes, released_at=excluded.released_at`)
+ .run(platform, version, url || '', size || 0, notes || '');
+ res.json({ success: true, message: platform + ' 端 v' + version + ' 已发布' });
+});
+
// ==================== 天气 API ====================
function getWeatherDesc(code) {