update: v5.0
This commit is contained in:
+58
-7
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user