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 ( +
@@ -306,11 +351,13 @@ function App() { {pwdModal} + ) } // ============ 桌面端布局 ============ return ( +
@@ -349,6 +396,9 @@ function App() {
{user.display_name || user.username},{getGreeting()}!
+ + @@ -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月)

    +
      +
    1. 新增设备借用功能:支持上传借用照片,从已有设备中选择借用设备,从已有成员中选择借用人
    2. +
    3. 新增设备归还功能:支持上传归还照片,设备与人员可从已有数据中选择
    4. +
    5. 服务器自动存储借用时间与归还时间
    6. +
    7. 苹果端适配 Liquid Glass 液态玻璃设计(iOS 26/27)
    8. +
    9. 优化苹果端退出后台后需重新登录的问题
    10. +
    11. 修复苹果端底部导航栏触控困难的问题
    12. +

    v2.2(2026年8月)

    1. 添加群聊删除人员的功能
    2. 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 === '借用中' + ? + : 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) => ( + + + + + {preview && ( + + )} + + ) + + return ( +
      + + + } /> + + + } /> + + + } /> + + + + + + + + + + + +