update: v5.0

This commit is contained in:
mei
2026-08-13 14:29:10 +08:00
parent 8ee98719ef
commit 1271627ef9
8 changed files with 732 additions and 45 deletions
+58 -7
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react' 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 { import {
DashboardOutlined, DashboardOutlined,
DesktopOutlined, DesktopOutlined,
@@ -13,13 +13,17 @@ import {
DownOutlined, DownOutlined,
UserSwitchOutlined, UserSwitchOutlined,
KeyOutlined, KeyOutlined,
MessageOutlined MessageOutlined,
MoonOutlined,
SunOutlined,
SwapOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import axios from 'axios' import axios from 'axios'
import Login from './pages/Login' import Login from './pages/Login'
import Weather from './components/Weather' import Weather from './components/Weather'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import DeviceManagement from './pages/DeviceManagement' import DeviceManagement from './pages/DeviceManagement'
import DeviceLoan from './pages/DeviceLoan'
import PersonnelManagement from './pages/PersonnelManagement' import PersonnelManagement from './pages/PersonnelManagement'
import NotificationManagement from './pages/NotificationManagement' import NotificationManagement from './pages/NotificationManagement'
import AccountManagement from './pages/AccountManagement' import AccountManagement from './pages/AccountManagement'
@@ -41,8 +45,36 @@ function useIsMobile() {
return isMobile 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() { function App() {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const { isDark, toggle: toggleTheme } = useThemeMode()
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [drawerVisible, setDrawerVisible] = useState(false) const [drawerVisible, setDrawerVisible] = useState(false)
const [currentPage, setCurrentPage] = useState('dashboard') const [currentPage, setCurrentPage] = useState('dashboard')
@@ -124,6 +156,7 @@ function App() {
{ key: 'dashboard', icon: <DashboardOutlined />, label: '系统首页' }, { key: 'dashboard', icon: <DashboardOutlined />, label: '系统首页' },
{ type: 'divider' }, { type: 'divider' },
{ key: 'devices', icon: <DesktopOutlined />, label: '设备管理' }, { key: 'devices', icon: <DesktopOutlined />, label: '设备管理' },
{ key: 'loans', icon: <SwapOutlined />, label: '设备借用' },
{ key: 'personnel', icon: <TeamOutlined />, label: '人员管理' }, { key: 'personnel', icon: <TeamOutlined />, label: '人员管理' },
...(user?.role === 'admin' ? [{ key: 'accounts', icon: <UserSwitchOutlined />, label: '账户管理' }] : []), ...(user?.role === 'admin' ? [{ key: 'accounts', icon: <UserSwitchOutlined />, label: '账户管理' }] : []),
{ key: 'notifications', icon: <BellOutlined />, label: '通知管理' }, { key: 'notifications', icon: <BellOutlined />, label: '通知管理' },
@@ -146,6 +179,7 @@ function App() {
case 'about': return <About /> case 'about': return <About />
case 'guide': return <Guide /> case 'guide': return <Guide />
case 'devices': return <DeviceManagement user={user} /> case 'devices': return <DeviceManagement user={user} />
case 'loans': return <DeviceLoan user={user} />
case 'personnel': return <PersonnelManagement user={user} /> case 'personnel': return <PersonnelManagement user={user} />
case 'accounts': return user?.role === 'admin' ? <AccountManagement user={user} /> : <Dashboard onNavigate={setCurrentPage} /> case 'accounts': return user?.role === 'admin' ? <AccountManagement user={user} /> : <Dashboard onNavigate={setCurrentPage} />
case 'notifications': return <NotificationManagement user={user} /> case 'notifications': return <NotificationManagement user={user} />
@@ -157,7 +191,7 @@ function App() {
const getPageTitle = () => { const getPageTitle = () => {
const titles = { const titles = {
dashboard: '系统首页', about: '软件简介', guide: '使用说明', dashboard: '系统首页', about: '软件简介', guide: '使用说明',
devices: '设备管理', personnel: '人员管理', accounts: '账户管理', devices: '设备管理', loans: '设备借用', personnel: '人员管理', accounts: '账户管理',
notifications: '通知管理', messages: '消息对话' notifications: '通知管理', messages: '消息对话'
} }
return titles[currentPage] || '系统首页' return titles[currentPage] || '系统首页'
@@ -240,8 +274,15 @@ function App() {
return <div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>加载中...</div> return <div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>加载中...</div>
} }
// antd 主题:深色/浅色算法切换;登录页固定浅色(白卡片渐变背景)
const antdTheme = { algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm }
if (!user) { if (!user) {
return <Login onLogin={handleLogin} /> return (
<ConfigProvider theme={{ algorithm: theme.defaultAlgorithm }}>
<Login onLogin={handleLogin} />
</ConfigProvider>
)
} }
const menuContent = ( const menuContent = (
@@ -262,13 +303,17 @@ function App() {
// ============ 移动端布局 ============ // ============ 移动端布局 ============
if (isMobile) { if (isMobile) {
return ( return (
<ConfigProvider theme={antdTheme}>
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<Header className="mobile-header"> <Header className="mobile-header">
<Button type="text" icon={<MenuOutlined />} onClick={() => setDrawerVisible(true)} style={{ color: '#fff' }} /> <Button type="text" icon={<MenuOutlined />} onClick={() => setDrawerVisible(true)} style={{ color: '#fff' }} />
<span className="mobile-title">{getPageTitle()}</span> <span className="mobile-title">{getPageTitle()}</span>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" trigger={['click']}> <Space size={4}>
<Avatar style={{ backgroundColor: '#7c3aed' }} icon={<UserOutlined />} /> <Button type="text" icon={isDark ? <SunOutlined /> : <MoonOutlined />} onClick={toggleTheme} style={{ color: '#fff' }} />
</Dropdown> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight" trigger={['click']}>
<Avatar style={{ backgroundColor: '#7c3aed' }} icon={<UserOutlined />} />
</Dropdown>
</Space>
</Header> </Header>
<div className="mobile-info-bar"> <div className="mobile-info-bar">
@@ -306,11 +351,13 @@ function App() {
{pwdModal} {pwdModal}
<PushService user={user} /> <PushService user={user} />
</Layout> </Layout>
</ConfigProvider>
) )
} }
// ============ 桌面端布局 ============ // ============ 桌面端布局 ============
return ( return (
<ConfigProvider theme={antdTheme}>
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible collapsed={collapsed} theme="dark"> <Sider trigger={null} collapsible collapsed={collapsed} theme="dark">
<div className="logo-area"> <div className="logo-area">
@@ -349,6 +396,9 @@ function App() {
<Space size="large" align="center"> <Space size="large" align="center">
<Weather /> <Weather />
<div className="greeting-text">{user.display_name || user.username},{getGreeting()}!</div> <div className="greeting-text">{user.display_name || user.username},{getGreeting()}!</div>
<Tooltip title={isDark ? '切换浅色模式' : '切换深色模式'}>
<Button type="text" icon={isDark ? <SunOutlined /> : <MoonOutlined />} onClick={toggleTheme} />
</Tooltip>
<Button type="text" icon={<KeyOutlined />} onClick={() => setPwdModalVisible(true)}> <Button type="text" icon={<KeyOutlined />} onClick={() => setPwdModalVisible(true)}>
修改密码 修改密码
</Button> </Button>
@@ -368,6 +418,7 @@ function App() {
{pwdModal} {pwdModal}
<PushService user={user} /> <PushService user={user} />
</Layout> </Layout>
</ConfigProvider>
) )
} }
+158 -31
View File
@@ -4,8 +4,47 @@
box-sizing: border-box; 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 { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--page-bg);
color: var(--text-main);
} }
.logo { .logo {
@@ -79,12 +118,12 @@ body {
.about-page h2 { .about-page h2 {
font-size: 20px; font-size: 20px;
margin: 24px 0 12px; margin: 24px 0 12px;
color: #333; color: var(--text-main);
} }
.about-page p { .about-page p {
line-height: 1.8; line-height: 1.8;
color: #666; color: var(--text-sub);
margin-bottom: 12px; margin-bottom: 12px;
} }
@@ -95,7 +134,7 @@ body {
.about-page li { .about-page li {
line-height: 2; line-height: 2;
color: #666; color: var(--text-sub);
} }
.guide-page { .guide-page {
@@ -113,20 +152,20 @@ body {
.guide-page h2 { .guide-page h2 {
font-size: 20px; font-size: 20px;
margin: 24px 0 12px; margin: 24px 0 12px;
color: #333; color: var(--text-main);
padding-bottom: 8px; padding-bottom: 8px;
border-bottom: 2px solid #e8e8e8; border-bottom: 2px solid var(--border);
} }
.guide-page h3 { .guide-page h3 {
font-size: 16px; font-size: 16px;
margin: 16px 0 8px; margin: 16px 0 8px;
color: #444; color: var(--text-main);
} }
.guide-page p { .guide-page p {
line-height: 1.8; line-height: 1.8;
color: #666; color: var(--text-sub);
margin-bottom: 12px; margin-bottom: 12px;
} }
@@ -138,20 +177,20 @@ body {
.guide-page li { .guide-page li {
line-height: 2; line-height: 2;
color: #666; color: var(--text-sub);
} }
.guide-page .tip { .guide-page .tip {
background: #e6f7ff; background: var(--tip-bg);
border: 1px solid #91d5ff; border: 1px solid var(--tip-border);
border-radius: 4px; border-radius: 4px;
padding: 12px 16px; padding: 12px 16px;
margin: 12px 0; margin: 12px 0;
} }
.guide-page .warning { .guide-page .warning {
background: #fffbe6; background: var(--warn-bg);
border: 1px solid #ffe58f; border: 1px solid var(--warn-border);
border-radius: 4px; border-radius: 4px;
padding: 12px 16px; padding: 12px 16px;
margin: 12px 0; margin: 12px 0;
@@ -315,7 +354,7 @@ body {
/* ==================== 顶部问候语 ==================== */ /* ==================== 顶部问候语 ==================== */
.greeting-text { .greeting-text {
font-size: 15px; font-size: 15px;
color: #374151; color: var(--text-main);
font-weight: 500; font-weight: 500;
} }
@@ -351,21 +390,21 @@ body {
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 10px 12px; padding: 10px 12px;
background: #f5f7fa; background: var(--page-bg);
border-bottom: 1px solid #e8e8e8; border-bottom: 1px solid var(--border);
flex-wrap: wrap; flex-wrap: wrap;
} }
.mobile-greeting { .mobile-greeting {
font-size: 14px; font-size: 14px;
color: #374151; color: var(--text-main);
font-weight: 500; font-weight: 500;
} }
.mobile-content { .mobile-content {
padding: 12px; padding: 12px;
padding-bottom: 76px; /* 为底部导航留空间 */ padding-bottom: 76px; /* 为底部导航留空间 */
background: #f5f7fa; background: var(--page-bg);
} }
/* 底部导航 */ /* 底部导航 */
@@ -375,8 +414,8 @@ body {
left: 0; left: 0;
right: 0; right: 0;
display: flex; display: flex;
background: #fff; background: var(--card-bg);
border-top: 1px solid #e8e8e8; border-top: 1px solid var(--border);
z-index: 100; z-index: 100;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
@@ -391,7 +430,7 @@ body {
padding: 8px 0; padding: 8px 0;
border: none; border: none;
background: none; background: none;
color: #8c8c8c; color: var(--text-hint);
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
transition: color 0.2s; transition: color 0.2s;
@@ -527,7 +566,7 @@ body {
.messages-list { .messages-list {
width: 320px; width: 320px;
border-right: 1px solid #f0f0f0; border-right: 1px solid var(--border-light);
padding: 12px; padding: 12px;
overflow-y: auto; overflow-y: auto;
} }
@@ -540,13 +579,13 @@ body {
} }
.conv-item:hover { .conv-item:hover {
background: #f5f5f5; background: var(--hover-bg);
border-radius: 6px; border-radius: 6px;
} }
.conv-item-active, .conv-item-active,
.conv-item-active:hover { .conv-item-active:hover {
background: #f0ebff; background: var(--active-bg);
border-radius: 6px; border-radius: 6px;
} }
@@ -560,14 +599,14 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 16px; padding: 12px 16px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid var(--border-light);
} }
.chat-messages { .chat-messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 16px; padding: 16px;
background: #fafafa; background: var(--chat-bg);
} }
.chat-msg { .chat-msg {
@@ -583,15 +622,15 @@ body {
.chat-msg-sender { .chat-msg-sender {
font-size: 12px; font-size: 12px;
color: #999; color: var(--text-hint);
margin-bottom: 3px; margin-bottom: 3px;
} }
.chat-bubble { .chat-bubble {
max-width: 70%; max-width: 70%;
padding: 8px 12px; padding: 8px 12px;
background: #fff; background: var(--card-bg);
border: 1px solid #eee; border: 1px solid var(--border-light);
border-radius: 8px; border-radius: 8px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
@@ -605,16 +644,104 @@ body {
.chat-msg-time { .chat-msg-time {
font-size: 11px; font-size: 11px;
color: #bbb; color: var(--text-faint);
margin-top: 3px; 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 { .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; display: flex;
gap: 8px; gap: 8px;
align-items: flex-end; 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 { .chat-placeholder {
+12 -2
View File
@@ -5,7 +5,7 @@ function About() {
return ( return (
<div className="about-page"> <div className="about-page">
<Card> <Card>
<h1>影视星河 · 设备管理系统 v2.2</h1> <h1>影视星河 · 设备管理系统 v5.0</h1>
<Divider /> <Divider />
<h2>软件概述</h2> <h2>软件概述</h2>
@@ -19,6 +19,7 @@ function About() {
<li><strong>登录认证:</strong>支持账号密码登录,内置默认管理员账户,登录后根据时间智能问候</li> <li><strong>登录认证:</strong>支持账号密码登录,内置默认管理员账户,登录后根据时间智能问候</li>
<li><strong>每日天气:</strong>顶部导航栏实时显示当前城市天气、温度、湿度等信息</li> <li><strong>每日天气:</strong>顶部导航栏实时显示当前城市天气、温度、湿度等信息</li>
<li><strong>设备管理:</strong>支持设备的添加、编辑、删除、查询等全生命周期管理,涵盖笔记本电脑、台式机、显示器、打印机、网络设备等各类IT资产</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>支持发布各类通知、公告和提醒,可设置优先级和发送范围,确保重要信息及时传达</li>
<li><strong>数据概览:</strong>提供直观的仪表盘,实时展示设备状态、人员情况和通知统计</li> <li><strong>数据概览:</strong>提供直观的仪表盘,实时展示设备状态、人员情况和通知统计</li>
@@ -62,12 +63,21 @@ function About() {
<h2>版本信息</h2> <h2>版本信息</h2>
<ul> <ul>
<li>当前版本:v2.2</li> <li>当前版本:v5.0</li>
<li>发布日期:2026年8月</li> <li>发布日期:2026年8月</li>
<li>许可证:MIT License</li> <li>许可证:MIT License</li>
</ul> </ul>
<h2>更新日志</h2> <h2>更新日志</h2>
<h3>v5.0(2026年8月)</h3>
<ol>
<li>新增设备借用功能:支持上传借用照片,从已有设备中选择借用设备,从已有成员中选择借用人</li>
<li>新增设备归还功能:支持上传归还照片,设备与人员可从已有数据中选择</li>
<li>服务器自动存储借用时间与归还时间</li>
<li>苹果端适配 Liquid Glass 液态玻璃设计(iOS 26/27)</li>
<li>优化苹果端退出后台后需重新登录的问题</li>
<li>修复苹果端底部导航栏触控困难的问题</li>
</ol>
<h3>v2.2(2026年8月)</h3> <h3>v2.2(2026年8月)</h3>
<ol> <ol>
<li>添加群聊删除人员的功能</li> <li>添加群聊删除人员的功能</li>
+1 -1
View File
@@ -120,7 +120,7 @@ function Dashboard({ onNavigate }) {
<h3>系统信息</h3> <h3>系统信息</h3>
<Row gutter={16} style={{ marginTop: 12 }}> <Row gutter={16} style={{ marginTop: 12 }}>
<Col span={8}> <Col span={8}>
<p><strong>系统版本:</strong>v2.2</p> <p><strong>系统版本:</strong>v5.0</p>
<p><strong>系统名称:</strong>设备管理系统</p> <p><strong>系统名称:</strong>设备管理系统</p>
</Col> </Col>
<Col span={8}> <Col span={8}>
+346
View File
@@ -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) => <strong>{v || '-'}</strong>
},
{
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 === '已归还'
? <Tag color="green">已归还</Tag>
: <Tag color="orange">借用中</Tag>
},
{ 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
? <Image src={photoUrl(v)} width={56} height={56} style={{ objectFit: 'cover', borderRadius: 6 }} />
: <span style={{ color: '#999' }}>-</span>
},
{
title: '归还照片', dataIndex: 'return_photo', key: 'return_photo', width: 90,
render: (v) => v
? <Image src={photoUrl(v)} width={56} height={56} style={{ objectFit: 'cover', borderRadius: 6 }} />
: <span style={{ color: '#999' }}>-</span>
},
{ title: '经办人', dataIndex: 'created_by', key: 'created_by', width: 110, render: (v) => v || '-' },
{
title: '操作', key: 'action', width: 100,
render: (_, r) => r.status === '借用中'
? <Button type="primary" size="small" icon={<SwapOutlined />} onClick={() => openReturnModal(r)}>归还</Button>
: 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) => (
<Space align="start">
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={beforeUpload}
>
<Button icon={<CameraOutlined />}>{text}</Button>
</Upload>
{preview && (
<Image src={preview} width={64} height={64} style={{ objectFit: 'cover', borderRadius: 6 }} />
)}
</Space>
)
return (
<div>
<Row gutter={16}>
<Col xs={24} sm={8}>
<Card><Statistic title="借用记录总数" value={loans.length} prefix={<ClockCircleOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="借用中" value={loaning} valueStyle={{ color: '#fa8c16' }} prefix={<SwapOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="已归还" value={returned} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} /></Card>
</Col>
</Row>
<Card style={{ marginTop: 16 }}>
<Space wrap style={{ marginBottom: 16 }}>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 130 }}
options={[
{ value: '', label: '全部状态' },
{ value: '借用中', label: '借用中' },
{ value: '已归还', label: '已归还' }
]}
/>
<Input.Search
placeholder="搜索设备 / 借用人"
allowClear
style={{ width: 220 }}
onSearch={setKeyword}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={openLoanModal}>借用设备</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={filtered}
loading={loading}
locale={{ emptyText: <Empty description="暂无借用记录" /> }}
pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ x: 900 }}
/>
</Card>
{/* 借用弹窗 */}
<Modal
title="借用设备"
open={loanOpen}
onOk={submitLoan}
onCancel={() => setLoanOpen(false)}
okText="确认借用"
cancelText="取消"
confirmLoading={submitting}
width={520}
>
<Form form={loanForm} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="device_id" label="借用设备" rules={[{ required: true, message: '请选择设备' }]}>
<Select showSearch optionFilterProp="label" placeholder="从已有设备中选择" options={deviceOptions} />
</Form.Item>
<Form.Item name="borrower" label="借用人" rules={[{ required: true, message: '请选择借用人' }]}>
<Select showSearch optionFilterProp="label" placeholder="从已有成员中选择" options={personnelOptions} />
</Form.Item>
<Form.Item label="借用照片">
{photoUploadBtn(loanPhotoPreview, handleLoanPhoto, loanPhoto ? '重新上传照片' : '上传借用照片')}
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="选填,如借用用途等" />
</Form.Item>
</Form>
<p style={{ color: '#999', fontSize: 12 }}>确认借用后,服务器将自动记录借用时间。</p>
</Modal>
{/* 归还弹窗 */}
<Modal
title="归还设备"
open={returnOpen}
onOk={submitReturn}
onCancel={() => setReturnOpen(false)}
okText="确认归还"
cancelText="取消"
confirmLoading={submitting}
width={520}
>
<Form form={returnForm} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="device_id" label="归还设备" rules={[{ required: true, message: '请选择设备' }]}>
<Select showSearch optionFilterProp="label" placeholder="从已有设备中选择" options={deviceOptions} />
</Form.Item>
<Form.Item name="borrower" label="归还人" rules={[{ required: true, message: '请选择归还人' }]}>
<Select showSearch optionFilterProp="label" placeholder="从已有成员中选择" options={personnelOptions} />
</Form.Item>
<Form.Item label="归还照片">
{photoUploadBtn(returnPhotoPreview, handleReturnPhoto, returnPhoto ? '重新上传照片' : '上传归还照片')}
</Form.Item>
</Form>
<p style={{ color: '#999', fontSize: 12 }}>确认归还后,服务器将自动记录归还时间。</p>
</Modal>
</div>
)
}
export default DeviceLoan
+19
View File
@@ -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(` db.exec(`
CREATE TABLE IF NOT EXISTS user_logs ( CREATE TABLE IF NOT EXISTS user_logs (
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "device-management-system", "name": "device-management-system",
"version": "2.2.0", "version": "5.0.0",
"description": "影视星河设备管理系统 - Device Management System", "description": "影视星河设备管理系统 - Device Management System",
"main": "electron/main.js", "main": "electron/main.js",
"scripts": { "scripts": {
+137 -3
View File
@@ -48,6 +48,11 @@ function dropSession(token) {
sessionActivity.delete(token); sessionActivity.delete(token);
} }
// 下线时间列(兼容旧库:已存在则忽略)
try {
db.prepare("ALTER TABLE users ADD COLUMN last_logout TEXT DEFAULT ''").run();
} catch (e) { /* 列已存在 */ }
// 收集当前在线用户(用户ID -> 最近活跃时间戳,取最新) // 收集当前在线用户(用户ID -> 最近活跃时间戳,取最新)
function getOnlineUserMap() { function getOnlineUserMap() {
const now = Date.now(); 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 } }); 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 ==================== // ==================== 通知管理 API ====================
// 获取所有通知 // 获取所有通知
@@ -840,11 +924,15 @@ app.get('/api/chat/files/:id', (req, res) => {
fs.createReadStream(filePath).pipe(res); fs.createReadStream(filePath).pipe(res);
}); });
// 全部用户简表(选择聊天对象/建群成员,含在线状态) // 全部用户简表(选择聊天对象/建群成员,含在线状态与登录/下线时间)
app.get('/api/chat/users', (req, 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 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 }); res.json({ success: true, data });
}); });
@@ -1145,6 +1233,11 @@ app.get('/api/auth/me', (req, res) => {
// 登出 // 登出
app.post('/api/auth/logout', (req, res) => { app.post('/api/auth/logout', (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', ''); 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); if (token) dropSession(token);
res.json({ success: true, message: '登出成功' }); res.json({ success: true, message: '登出成功' });
}); });
@@ -1167,6 +1260,47 @@ app.patch('/api/auth/password', (req, res) => {
res.json({ success: true, message: '密码修改成功,请使用新密码重新登录' }); 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 ==================== // ==================== 天气 API ====================
function getWeatherDesc(code) { function getWeatherDesc(code) {