update: v5.0
This commit is contained in:
+58
-7
@@ -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: <DashboardOutlined />, label: '系统首页' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'devices', icon: <DesktopOutlined />, label: '设备管理' },
|
||||
{ key: 'loans', icon: <SwapOutlined />, label: '设备借用' },
|
||||
{ key: 'personnel', icon: <TeamOutlined />, label: '人员管理' },
|
||||
...(user?.role === 'admin' ? [{ key: 'accounts', icon: <UserSwitchOutlined />, label: '账户管理' }] : []),
|
||||
{ key: 'notifications', icon: <BellOutlined />, label: '通知管理' },
|
||||
@@ -146,6 +179,7 @@ function App() {
|
||||
case 'about': return <About />
|
||||
case 'guide': return <Guide />
|
||||
case 'devices': return <DeviceManagement user={user} />
|
||||
case 'loans': return <DeviceLoan user={user} />
|
||||
case 'personnel': return <PersonnelManagement user={user} />
|
||||
case 'accounts': return user?.role === 'admin' ? <AccountManagement user={user} /> : <Dashboard onNavigate={setCurrentPage} />
|
||||
case 'notifications': return <NotificationManagement user={user} />
|
||||
@@ -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 <div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>加载中...</div>
|
||||
}
|
||||
|
||||
// antd 主题:深色/浅色算法切换;登录页固定浅色(白卡片渐变背景)
|
||||
const antdTheme = { algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm }
|
||||
|
||||
if (!user) {
|
||||
return <Login onLogin={handleLogin} />
|
||||
return (
|
||||
<ConfigProvider theme={{ algorithm: theme.defaultAlgorithm }}>
|
||||
<Login onLogin={handleLogin} />
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const menuContent = (
|
||||
@@ -262,13 +303,17 @@ function App() {
|
||||
// ============ 移动端布局 ============
|
||||
if (isMobile) {
|
||||
return (
|
||||
<ConfigProvider theme={antdTheme}>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header className="mobile-header">
|
||||
<Button type="text" icon={<MenuOutlined />} onClick={() => setDrawerVisible(true)} style={{ color: '#fff' }} />
|
||||
<span className="mobile-title">{getPageTitle()}</span>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" trigger={['click']}>
|
||||
<Avatar style={{ backgroundColor: '#7c3aed' }} icon={<UserOutlined />} />
|
||||
</Dropdown>
|
||||
<Space size={4}>
|
||||
<Button type="text" icon={isDark ? <SunOutlined /> : <MoonOutlined />} onClick={toggleTheme} style={{ color: '#fff' }} />
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" trigger={['click']}>
|
||||
<Avatar style={{ backgroundColor: '#7c3aed' }} icon={<UserOutlined />} />
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
<div className="mobile-info-bar">
|
||||
@@ -306,11 +351,13 @@ function App() {
|
||||
{pwdModal}
|
||||
<PushService user={user} />
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 桌面端布局 ============
|
||||
return (
|
||||
<ConfigProvider theme={antdTheme}>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider trigger={null} collapsible collapsed={collapsed} theme="dark">
|
||||
<div className="logo-area">
|
||||
@@ -349,6 +396,9 @@ function App() {
|
||||
<Space size="large" align="center">
|
||||
<Weather />
|
||||
<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>
|
||||
@@ -368,6 +418,7 @@ function App() {
|
||||
{pwdModal}
|
||||
<PushService user={user} />
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+158
-31
@@ -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 {
|
||||
|
||||
@@ -5,7 +5,7 @@ function About() {
|
||||
return (
|
||||
<div className="about-page">
|
||||
<Card>
|
||||
<h1>影视星河 · 设备管理系统 v2.2</h1>
|
||||
<h1>影视星河 · 设备管理系统 v5.0</h1>
|
||||
<Divider />
|
||||
|
||||
<h2>软件概述</h2>
|
||||
@@ -19,6 +19,7 @@ function About() {
|
||||
<li><strong>登录认证:</strong>支持账号密码登录,内置默认管理员账户,登录后根据时间智能问候</li>
|
||||
<li><strong>每日天气:</strong>顶部导航栏实时显示当前城市天气、温度、湿度等信息</li>
|
||||
<li><strong>设备管理:</strong>支持设备的添加、编辑、删除、查询等全生命周期管理,涵盖笔记本电脑、台式机、显示器、打印机、网络设备等各类IT资产</li>
|
||||
<li><strong>设备借用:</strong>支持设备借用登记与归还管理,可上传借用/归还照片,从已有设备与人员中选择,服务器自动记录借用与归还时间</li>
|
||||
<li><strong>人员管理:</strong>维护组织架构中的人员信息,包括部门、职位、联系方式等,便于设备责任分配</li>
|
||||
<li><strong>通知管理:</strong>支持发布各类通知、公告和提醒,可设置优先级和发送范围,确保重要信息及时传达</li>
|
||||
<li><strong>数据概览:</strong>提供直观的仪表盘,实时展示设备状态、人员情况和通知统计</li>
|
||||
@@ -62,12 +63,21 @@ function About() {
|
||||
|
||||
<h2>版本信息</h2>
|
||||
<ul>
|
||||
<li>当前版本:v2.2</li>
|
||||
<li>当前版本:v5.0</li>
|
||||
<li>发布日期:2026年8月</li>
|
||||
<li>许可证:MIT License</li>
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
<ol>
|
||||
<li>添加群聊删除人员的功能</li>
|
||||
|
||||
@@ -120,7 +120,7 @@ function Dashboard({ onNavigate }) {
|
||||
<h3>系统信息</h3>
|
||||
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||
<Col span={8}>
|
||||
<p><strong>系统版本:</strong>v2.2</p>
|
||||
<p><strong>系统版本:</strong>v5.0</p>
|
||||
<p><strong>系统名称:</strong>设备管理系统</p>
|
||||
</Col>
|
||||
<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(`
|
||||
CREATE TABLE IF NOT EXISTS user_logs (
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "device-management-system",
|
||||
"version": "2.2.0",
|
||||
"version": "5.0.0",
|
||||
"description": "影视星河设备管理系统 - Device Management System",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -48,6 +48,11 @@ function dropSession(token) {
|
||||
sessionActivity.delete(token);
|
||||
}
|
||||
|
||||
// 下线时间列(兼容旧库:已存在则忽略)
|
||||
try {
|
||||
db.prepare("ALTER TABLE users ADD COLUMN last_logout TEXT DEFAULT ''").run();
|
||||
} catch (e) { /* 列已存在 */ }
|
||||
|
||||
// 收集当前在线用户(用户ID -> 最近活跃时间戳,取最新)
|
||||
function getOnlineUserMap() {
|
||||
const now = Date.now();
|
||||
@@ -521,6 +526,85 @@ app.get('/api/personnel/stats/summary', (req, res) => {
|
||||
res.json({ success: true, data: { total, active, withAccount, departments } });
|
||||
});
|
||||
|
||||
// ==================== 设备借用/归还 API ====================
|
||||
|
||||
// 借用记录列表(可按状态/设备筛选,借用时间倒序)
|
||||
app.get('/api/loans', (req, res) => {
|
||||
const { status, device_id } = req.query;
|
||||
const conds = [];
|
||||
const params = [];
|
||||
if (status) { conds.push('status = ?'); params.push(status); }
|
||||
if (device_id) { conds.push('device_id = ?'); params.push(device_id); }
|
||||
const where = conds.length ? ' WHERE ' + conds.join(' AND ') : '';
|
||||
const rows = db.prepare(`SELECT * FROM device_loans${where} ORDER BY loan_time DESC, rowid DESC LIMIT 200`).all(...params);
|
||||
res.json({ success: true, data: rows });
|
||||
});
|
||||
|
||||
// 创建借用(服务器自动记录借用时间)
|
||||
app.post('/api/loans', (req, res) => {
|
||||
const { device_id, borrower, borrower_id, photo, remark } = req.body || {};
|
||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id || '');
|
||||
if (!device) {
|
||||
return res.status(400).json({ success: false, message: '请选择要借用的设备' });
|
||||
}
|
||||
if (!borrower || !String(borrower).trim()) {
|
||||
return res.status(400).json({ success: false, message: '请选择借用人' });
|
||||
}
|
||||
const id = uuidv4();
|
||||
db.prepare(`
|
||||
INSERT INTO device_loans (id, device_id, device_name, borrower_id, borrower, loan_photo, remark, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, device.id, device.name, borrower_id || '', String(borrower).trim(), photo || '', remark || '', getOperator(req));
|
||||
const row = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(id);
|
||||
logDeviceAction(device.id, device.name, '借用', `${row.borrower} 借走设备`, getOperator(req));
|
||||
pushToRole('admin', 'device_loan', '设备借用', `${row.borrower} 借用了设备「${device.name}」`, req.user.id);
|
||||
res.json({ success: true, data: row, message: '借用成功' });
|
||||
});
|
||||
|
||||
// 归还设备(服务器自动记录归还时间;设备/借用人可重新选择)
|
||||
app.post('/api/loans/:id/return', (req, res) => {
|
||||
const loan = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(req.params.id);
|
||||
if (!loan) {
|
||||
return res.status(404).json({ success: false, message: '借用记录不存在' });
|
||||
}
|
||||
if (loan.status === '已归还') {
|
||||
return res.status(400).json({ success: false, message: '该设备已归还,请勿重复操作' });
|
||||
}
|
||||
const { device_id, borrower, borrower_id, photo } = req.body || {};
|
||||
// 归还时可重新选择设备(从已有设备中选择)
|
||||
let deviceId = loan.device_id;
|
||||
let deviceName = loan.device_name;
|
||||
if (device_id && device_id !== loan.device_id) {
|
||||
const d = db.prepare('SELECT id, name FROM devices WHERE id = ?').get(device_id);
|
||||
if (d) { deviceId = d.id; deviceName = d.name; }
|
||||
}
|
||||
const borrowerName = (borrower && String(borrower).trim()) || loan.borrower;
|
||||
db.prepare(`
|
||||
UPDATE device_loans
|
||||
SET device_id = ?, device_name = ?, borrower = ?, borrower_id = ?,
|
||||
return_photo = ?, status = '已归还', return_time = datetime('now', 'localtime')
|
||||
WHERE id = ?
|
||||
`).run(deviceId, deviceName, borrowerName, borrower_id || loan.borrower_id, photo || '', loan.id);
|
||||
const row = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(loan.id);
|
||||
logDeviceAction(deviceId, deviceName, '归还', `${borrowerName} 归还设备`, getOperator(req));
|
||||
pushToRole('admin', 'device_return', '设备归还', `${borrowerName} 已归还设备「${deviceName}」`, req.user.id);
|
||||
res.json({ success: true, data: row, message: '归还成功' });
|
||||
});
|
||||
|
||||
// 查看借用/归还照片(所有登录用户可见)
|
||||
app.get('/api/loans/photo/:fileId', (req, res) => {
|
||||
const file = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(req.params.fileId);
|
||||
if (!file) {
|
||||
return res.status(404).json({ success: false, message: '照片不存在' });
|
||||
}
|
||||
const filePath = path.join(uploadsDir, file.stored_name);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ success: false, message: '照片文件已丢失' });
|
||||
}
|
||||
res.setHeader('Content-Type', file.file_mime || 'image/jpeg');
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
// ==================== 通知管理 API ====================
|
||||
|
||||
// 获取所有通知
|
||||
@@ -840,11 +924,15 @@ app.get('/api/chat/files/:id', (req, res) => {
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
// 全部用户简表(选择聊天对象/建群成员,含在线状态)
|
||||
// 全部用户简表(选择聊天对象/建群成员,含在线状态与登录/下线时间)
|
||||
app.get('/api/chat/users', (req, res) => {
|
||||
const rows = db.prepare('SELECT id, username, display_name, role FROM users ORDER BY created_at ASC').all();
|
||||
const rows = db.prepare('SELECT id, username, display_name, role, last_login, last_logout FROM users ORDER BY created_at ASC').all();
|
||||
const onlineMap = getOnlineUserMap();
|
||||
const data = rows.map(u => ({ ...u, is_online: onlineMap.has(u.id) }));
|
||||
const data = rows.map(u => ({
|
||||
...u,
|
||||
is_online: onlineMap.has(u.id),
|
||||
last_active: onlineMap.has(u.id) ? formatActive(onlineMap.get(u.id)) : null
|
||||
}));
|
||||
res.json({ success: true, data });
|
||||
});
|
||||
|
||||
@@ -1145,6 +1233,11 @@ app.get('/api/auth/me', (req, res) => {
|
||||
// 登出
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
const user = token ? sessions.get(token) : null;
|
||||
if (user) {
|
||||
// 记录下线时间
|
||||
db.prepare("UPDATE users SET last_logout=datetime('now','localtime') WHERE id=?").run(user.id);
|
||||
}
|
||||
if (token) dropSession(token);
|
||||
res.json({ success: true, message: '登出成功' });
|
||||
});
|
||||
@@ -1167,6 +1260,47 @@ app.patch('/api/auth/password', (req, res) => {
|
||||
res.json({ success: true, message: '密码修改成功,请使用新密码重新登录' });
|
||||
});
|
||||
|
||||
// ==================== App 版本检查与更新 ====================
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS app_versions (
|
||||
platform TEXT PRIMARY KEY,
|
||||
version TEXT NOT NULL,
|
||||
url TEXT DEFAULT '',
|
||||
size INTEGER DEFAULT 0,
|
||||
notes TEXT DEFAULT '',
|
||||
released_at TEXT
|
||||
)`);
|
||||
|
||||
// 首次启动播种当前四端 v4.0.0 版本信息(仅空表时写入)
|
||||
['windows', 'android', 'harmony', 'ios'].forEach(p => {
|
||||
db.prepare("INSERT OR IGNORE INTO app_versions(platform, version, url, size, notes, released_at) VALUES(?, '4.0.0', '', 0, '影视星河设备管理系统 v4.0.0', datetime('now','localtime'))").run(p);
|
||||
});
|
||||
|
||||
// 查询各端最新版本(客户端登录后检查更新用)
|
||||
app.get('/api/app/version/latest', (req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM app_versions').all();
|
||||
const map = {};
|
||||
rows.forEach(r => { map[r.platform] = r; });
|
||||
res.json({ success: true, data: map });
|
||||
});
|
||||
|
||||
// 管理员发布新版本:{ platform: windows|android|harmony|ios, version, url, size, notes }
|
||||
app.post('/api/app/version', requireAdmin, (req, res) => {
|
||||
const { platform, version, url, size, notes } = req.body || {};
|
||||
const platforms = ['windows', 'android', 'harmony', 'ios'];
|
||||
if (!platforms.includes(platform)) {
|
||||
return res.status(400).json({ success: false, message: '平台取值必须为 windows/android/harmony/ios' });
|
||||
}
|
||||
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
|
||||
return res.status(400).json({ success: false, message: '版本号格式必须为 x.y.z' });
|
||||
}
|
||||
db.prepare(`INSERT INTO app_versions(platform, version, url, size, notes, released_at)
|
||||
VALUES(?,?,?,?,?,datetime('now','localtime'))
|
||||
ON CONFLICT(platform) DO UPDATE SET version=excluded.version, url=excluded.url, size=excluded.size, notes=excluded.notes, released_at=excluded.released_at`)
|
||||
.run(platform, version, url || '', size || 0, notes || '');
|
||||
res.json({ success: true, message: platform + ' 端 v' + version + ' 已发布' });
|
||||
});
|
||||
|
||||
// ==================== 天气 API ====================
|
||||
|
||||
function getWeatherDesc(code) {
|
||||
|
||||
Reference in New Issue
Block a user