初始化项目
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||||
|
# AI Studio automatically injects this at runtime from user secrets.
|
||||||
|
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||||
|
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||||
|
|
||||||
|
# APP_URL: The URL where this applet is hosted.
|
||||||
|
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||||
|
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||||
|
APP_URL="MY_APP_URL"
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/3f26c725-3837-42f2-964d-3838c11594a0
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Google AI Studio App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
6
metadata.json
Normal file
6
metadata.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "机器人智慧服务系统",
|
||||||
|
"description": "提供设备管理、视频监控、状态预警、警报处理及视频下载功能的一站式机器人智能管理平台。",
|
||||||
|
"requestFramePermissions": [],
|
||||||
|
"majorCapabilities": []
|
||||||
|
}
|
||||||
5999
package-lock.json
generated
Normal file
5999
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "react-example",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.1.1",
|
||||||
|
"@google/genai": "^1.29.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"antd": "^5.24.1",
|
||||||
|
"axios": "^1.15.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"lucide-react": "^0.546.0",
|
||||||
|
"motion": "^12.23.24",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.14.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.14.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/App.tsx
Normal file
7
src/App.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { router } from './router';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
}
|
||||||
150
src/components/Layout.tsx
Normal file
150
src/components/Layout.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Layout,
|
||||||
|
Menu,
|
||||||
|
Button,
|
||||||
|
Avatar,
|
||||||
|
Space,
|
||||||
|
ConfigProvider,
|
||||||
|
theme,
|
||||||
|
Breadcrumb,
|
||||||
|
Badge
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
ExperimentOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
BellOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
TeamOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { MOCK_USER } from '../constants';
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [time, setTime] = useState(new Date().toLocaleString());
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// 从 localStorage 获取真实用户信息
|
||||||
|
const userJson = localStorage.getItem('user');
|
||||||
|
const user = userJson ? JSON.parse(userJson) : MOCK_USER;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setTime(new Date().toLocaleString()), 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('isAuthenticated');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/', icon: <DashboardOutlined />, label: '概览控制台', roles: ['admin', 'operator', 'viewer'] },
|
||||||
|
{ key: '/devices', icon: <ExperimentOutlined />, label: '设备管理', roles: ['admin', 'operator'] },
|
||||||
|
{ key: '/video', icon: <VideoCameraOutlined />, label: '在线视频', roles: ['admin', 'operator', 'viewer'] },
|
||||||
|
{ key: '/alerts', icon: <BellOutlined />, label: '警报中心', roles: ['admin', 'operator'] },
|
||||||
|
{ key: '/downloads', icon: <DownloadOutlined />, label: '视频下载', roles: ['admin', 'operator'] },
|
||||||
|
{ key: '/users', icon: <TeamOutlined />, label: '用户管理', roles: ['admin'] },
|
||||||
|
{ key: '/roles', icon: <UserOutlined />, label: '角色权限', roles: ['admin'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredMenu = menuItems.filter(item => item.roles.includes(user.role));
|
||||||
|
|
||||||
|
const currentPathLabel = menuItems.find(i => i.key === location.pathname)?.label || '概览控制台';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
algorithm: theme.defaultAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#1890ff',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Layout: {
|
||||||
|
headerBg: '#fff',
|
||||||
|
siderBg: '#001529',
|
||||||
|
},
|
||||||
|
Menu: {
|
||||||
|
darkItemBg: '#001529',
|
||||||
|
darkItemSelectedBg: '#1890ff',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
<Sider trigger={null} collapsible collapsed={collapsed} width={200} theme="dark">
|
||||||
|
<div className="h-16 flex items-center justify-center border-b border-white/10 overflow-hidden">
|
||||||
|
<Space size="small" className="px-2">
|
||||||
|
<GlobalOutlined style={{ fontSize: 24, color: '#1890ff' }} />
|
||||||
|
{!collapsed && <span className="text-white font-bold text-lg whitespace-nowrap">RobotMaster AI</span>}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[location.pathname]}
|
||||||
|
items={filteredMenu}
|
||||||
|
onClick={({ key }) => navigate(key)}
|
||||||
|
/>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 p-2 bg-white/5 rounded text-[12px] text-gray-400">
|
||||||
|
当前权限: <span className="text-white font-bold">{user.role === 'admin' ? '系统管理员' : user.role === 'operator' ? '操作员' : '观察员'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Sider>
|
||||||
|
<Layout>
|
||||||
|
<Header className="px-6 flex items-center justify-between shadow-sm border-b border-gray-100 h-16 bg-white shrink-0 z-10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
style={{ fontSize: '16px', width: 40, height: 40 }}
|
||||||
|
/>
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ title: '系统首页' },
|
||||||
|
{ title: currentPathLabel }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-3 pr-6 border-r border-gray-100">
|
||||||
|
<span className="text-gray-400 font-mono text-[13px]">{time}</span>
|
||||||
|
<Badge status="success" />
|
||||||
|
<span className="text-gray-800 font-medium">{user.name}</span>
|
||||||
|
<Avatar src={user.avatar} size="small" icon={<UserOutlined />} />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<LogoutOutlined />}
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="font-bold text-[12px] uppercase"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
<Content className="p-4 overflow-y-auto bg-[#f4f7f9] min-h-0">
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/components/ProtectedRoute.tsx
Normal file
12
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function ProtectedRoute() {
|
||||||
|
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true';
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
34
src/constants.ts
Normal file
34
src/constants.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Device, Alert, VideoRecord, User } from './types';
|
||||||
|
|
||||||
|
export const MOCK_USER: User = {
|
||||||
|
id: 'user-1',
|
||||||
|
name: 'mabaoyu',
|
||||||
|
role: 'admin',
|
||||||
|
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEVICES: Device[] = [
|
||||||
|
{ id: 'R001', name: '巡检机器人 A1', type: '巡检型', status: 'online', battery: 85, cpu: 12, temp: 32.08, lastUpdate: '2024-03-20 10:00:00' },
|
||||||
|
{ id: 'R002', name: '安防辅助 B2', type: '安防型', status: 'alert', battery: 45, cpu: 45, temp: 42.5, lastUpdate: '2024-03-20 09:55:00' },
|
||||||
|
{ id: 'R003', name: '清洁终端 C1', type: '清洁型', status: 'offline', battery: 0, cpu: 0, temp: 20.0, lastUpdate: '2024-03-19 18:00:00' },
|
||||||
|
{ id: 'R004', name: '运输搬运 D5', type: '运输型', status: 'online', battery: 92, cpu: 8, temp: 28.5, lastUpdate: '2024-03-20 10:05:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ALERTS: Alert[] = [
|
||||||
|
{ id: 'A001', deviceId: 'R002', deviceName: '安防辅助 B2', type: 'critical', message: '芯片温度过高', time: '2024-03-20 10:02:15', handled: false },
|
||||||
|
{ id: 'A002', deviceId: 'R001', deviceName: '巡检机器人 A1', type: 'warning', message: '电池电量低于20%', time: '2024-03-20 09:30:00', handled: true },
|
||||||
|
{ id: 'A003', deviceId: 'R004', deviceName: '运输搬运 D5', type: 'info', message: '任务顺利完成', time: '2024-03-20 08:00:00', handled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VIDEOS: VideoRecord[] = [
|
||||||
|
{ id: 'V001', deviceId: 'R001', deviceName: '巡检机器人 A1', startTime: '2024-03-20 08:00', endTime: '2024-03-20 09:00', size: '450MB', url: '#' },
|
||||||
|
{ id: 'V002', deviceId: 'R001', deviceName: '巡检机器人 A1', startTime: '2024-03-20 09:00', endTime: '2024-03-20 10:00', size: '520MB', url: '#' },
|
||||||
|
{ id: 'V003', deviceId: 'R002', deviceName: '安防辅助 B2', startTime: '2024-03-20 07:30', endTime: '2024-03-20 08:30', size: '380MB', url: '#' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MANAGED_USERS: User[] = [
|
||||||
|
{ id: 'user-1', name: 'James Wilson', role: 'admin', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=James' },
|
||||||
|
{ id: 'user-2', name: 'Sarah Chen', role: 'operator', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah' },
|
||||||
|
{ id: 'user-3', name: 'Mike Ross', role: 'viewer', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Mike' },
|
||||||
|
{ id: 'user-4', name: 'Anna Lee', role: 'operator', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Anna' },
|
||||||
|
];
|
||||||
25
src/index.css
Normal file
25
src/index.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-bg: #f4f7f9;
|
||||||
|
--color-sidebar-bg: #001529;
|
||||||
|
--color-sidebar-text: #a6adb4;
|
||||||
|
--color-sidebar-active: #ffffff;
|
||||||
|
--color-sidebar-active-bg: #1890ff;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-border: #e8e8e8;
|
||||||
|
--color-primary: #1890ff;
|
||||||
|
--color-danger: #f5222d;
|
||||||
|
--color-warning: #faad14;
|
||||||
|
--color-success: #52c41a;
|
||||||
|
--color-text-main: #262626;
|
||||||
|
--color-text-sec: #8c8c8c;
|
||||||
|
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-bg text-text-main font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/lib/http.ts
Normal file
72
src/lib/http.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const http: AxiosInstance = axios.create({
|
||||||
|
baseURL: '/api', // 根据实际后端 API 路径调整
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
http.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
// 从 localStorage 获取 token
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token && config.headers) {
|
||||||
|
// 将 token 添加到请求头
|
||||||
|
config.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
http.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => {
|
||||||
|
// 处理成功响应
|
||||||
|
const { data } = response;
|
||||||
|
// 如果后端规范定义了 code,可以在这里统一处理
|
||||||
|
// if (data.code !== 200) {
|
||||||
|
// message.error(data.message || '请求失败');
|
||||||
|
// return Promise.reject(new Error(data.message || 'Error'));
|
||||||
|
// }
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
// 处理错误响应
|
||||||
|
if (error.response) {
|
||||||
|
switch (error.response.status) {
|
||||||
|
case 401:
|
||||||
|
// 未授权,清除本地信息并跳转到登录页
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('isAuthenticated');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
message.error('会话已过期,请重新登录');
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
message.error('拒绝访问:权限不足');
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
message.error('资源不存在');
|
||||||
|
break;
|
||||||
|
case 500:
|
||||||
|
message.error('服务器内部错误');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message.error(error.message || '网络错误');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error('连接服务器失败');
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default http;
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import {StrictMode} from 'react';
|
||||||
|
import {createRoot} from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
150
src/pages/Alerts.tsx
Normal file
150
src/pages/Alerts.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Card,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
Statistic
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
BellOutlined,
|
||||||
|
AlertOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
SyncOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { ALERTS } from '../constants';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
export default function Alerts() {
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '级别',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
render: (type: string) => {
|
||||||
|
const config = {
|
||||||
|
critical: { color: 'error', text: '紧急', icon: <AlertOutlined /> },
|
||||||
|
warning: { color: 'warning', text: '警告', icon: <BellOutlined /> },
|
||||||
|
info: { color: 'processing', text: '提示', icon: <InfoCircleOutlined /> },
|
||||||
|
}[type] || { color: 'default', text: '其他', icon: <InfoCircleOutlined /> };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag color={config.color} icon={config.icon} bordered={false} style={{ fontWeight: 'bold' }}>
|
||||||
|
{config.text}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '设备来源',
|
||||||
|
key: 'device',
|
||||||
|
render: (record: any) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text strong style={{ fontSize: 13 }}>{record.deviceName}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, fontFamily: 'monospace' }}>{record.deviceId}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '内容描述',
|
||||||
|
dataIndex: 'message',
|
||||||
|
key: 'message',
|
||||||
|
render: (text: string) => <Text ellipsis style={{ maxWidth: 300 }}>{text}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '发生时间',
|
||||||
|
dataIndex: 'time',
|
||||||
|
key: 'time',
|
||||||
|
render: (time: string) => <Text type="secondary" style={{ fontFamily: 'monospace', fontSize: 11 }}>{time}</Text>,
|
||||||
|
width: 160,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
render: (record: any) => (
|
||||||
|
record.handled ?
|
||||||
|
<Text type="success" italic style={{ fontSize: 11 }}><CheckCircleOutlined /> 已归档</Text> :
|
||||||
|
<Text type="warning" strong style={{ fontSize: 11 }}><SyncOutlined spin /> 待处理</Text>
|
||||||
|
),
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: () => (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" size="small" style={{ borderRadius: 2 }}>处理</Button>
|
||||||
|
<Button type="default" size="small" style={{ borderRadius: 2 }}>忽略</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
width: 140,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const summary = [
|
||||||
|
{ label: '严重故障', count: ALERTS.filter(a => a.type === 'critical').length, color: '#f5222d', icon: <AlertOutlined />, bgColor: '#fff1f0', borderColor: '#ffa39e' },
|
||||||
|
{ label: '异常警告', count: ALERTS.filter(a => a.type === 'warning').length, color: '#faad14', icon: <BellOutlined />, bgColor: '#fffbe6', borderColor: '#ffe58f' },
|
||||||
|
{ label: '状态提示', count: ALERTS.filter(a => a.type === 'info').length, color: '#1890ff', icon: <InfoCircleOutlined />, bgColor: '#e6f7ff', borderColor: '#91d5ff' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Title level={5} style={{ margin: 0 }}>警报中心: 实时检索</Title>
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
prefix={<SearchOutlined style={{ color: 'rgba(0,0,0,0.25)' }} />}
|
||||||
|
placeholder="搜索设备ID或警报内容..."
|
||||||
|
style={{ width: 250, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
<Button icon={<FilterOutlined />} style={{ borderRadius: 2 }}>
|
||||||
|
高级过滤
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
{summary.map((item) => (
|
||||||
|
<Col span={8} key={item.label}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.bgColor,
|
||||||
|
borderColor: item.borderColor,
|
||||||
|
borderRadius: 4
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={<Text strong type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', opacity: 0.7 }}>{item.label}</Text>}
|
||||||
|
value={item.count}
|
||||||
|
valueStyle={{ color: item.color, fontWeight: 900, fontSize: 24 }}
|
||||||
|
prefix={React.cloneElement(item.icon, { style: { opacity: 0.4 } })}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card styles={{ body: { padding: 0 } }} className="overflow-hidden">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={ALERTS.map(a => ({ ...a, key: a.id }))}
|
||||||
|
pagination={{ pageSize: 12, size: 'small' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
src/pages/Devices.tsx
Normal file
272
src/pages/Devices.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Tag,
|
||||||
|
Progress,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
Descriptions,
|
||||||
|
Tabs,
|
||||||
|
Card
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
PoweroffOutlined,
|
||||||
|
ClusterOutlined,
|
||||||
|
ArrowRightOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
HeatMapOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
|
ThunderboltOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { DEVICES } from '../constants';
|
||||||
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
export default function Devices() {
|
||||||
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
|
||||||
|
const selectedDevice = DEVICES.find(d => d.id === selectedDeviceId);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '节点身份',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (text: string, record: any) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text strong style={{ fontSize: 13 }}>{text}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, fontFamily: 'monospace' }}>{record.id} | {record.type}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '负载/状态',
|
||||||
|
key: 'status',
|
||||||
|
render: (record: any) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
italic
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: record.status === 'online' ? '#52c41a' : record.status === 'alert' ? '#f5222d' : '#bfbfbf'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{record.status.toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Space size="small">
|
||||||
|
<ClusterOutlined style={{ fontSize: 10, color: 'rgba(0,0,0,0.25)' }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, fontFamily: 'monospace' }}>{record.cpu}%</Text>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '能量效率',
|
||||||
|
dataIndex: 'battery',
|
||||||
|
key: 'battery',
|
||||||
|
render: (percent: number) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Progress
|
||||||
|
percent={percent}
|
||||||
|
size={[60, 4]}
|
||||||
|
showInfo={false}
|
||||||
|
status={percent < 20 ? 'exception' : 'active'}
|
||||||
|
strokeColor={percent < 20 ? '#f5222d' : percent < 50 ? '#faad14' : '#52c41a'}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: 11, fontFamily: 'monospace' }}>{percent}%</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '管理',
|
||||||
|
key: 'action',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: () => (
|
||||||
|
<Space>
|
||||||
|
<Button type="text" size="small" icon={<SettingOutlined />} />
|
||||||
|
<Button type="text" size="small" danger icon={<PoweroffOutlined />} />
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Title level={5} style={{ margin: 0 }}>核心资产管理系统</Title>
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
prefix={<SearchOutlined style={{ color: 'rgba(0,0,0,0.25)' }} />}
|
||||||
|
placeholder="搜索节点、ID或类型..."
|
||||||
|
style={{ width: 250, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} style={{ borderRadius: 2 }}>
|
||||||
|
接入新节点
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={16} className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||||
|
<Col flex="auto" className="h-full overflow-hidden">
|
||||||
|
<Card styles={{ body: { padding: 0 } }} className="h-full overflow-hidden flex flex-col">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={DEVICES.map(d => ({ ...d, key: d.id }))}
|
||||||
|
pagination={false}
|
||||||
|
sticky
|
||||||
|
scroll={{ y: 'calc(100vh - 250px)' }}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => setSelectedDeviceId(record.id),
|
||||||
|
className: selectedDeviceId === record.id ? 'bg-blue-50' : '',
|
||||||
|
style: { cursor: 'pointer' }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedDevice && (
|
||||||
|
<Col flex="0 0 340px" className="h-full">
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: 340, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: 340, opacity: 0 }}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className="h-full overflow-hidden"
|
||||||
|
styles={{ body: { padding: 0, height: '100%', display: 'flex', flexDirection: 'column' } }}
|
||||||
|
>
|
||||||
|
<div className="p-4 border-b border-gray-100 flex justify-between items-start bg-gray-50">
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, letterSpacing: 2, textTransform: 'uppercase', display: 'block' }}>
|
||||||
|
Asset Intelligence / v4.0
|
||||||
|
</Text>
|
||||||
|
<Title level={4} style={{ margin: '4px 0' }}>{selectedDevice.name}</Title>
|
||||||
|
<Space>
|
||||||
|
<Tag color="blue" bordered={false} style={{ fontSize: 10, fontWeight: 'bold' }}>{selectedDevice.type}</Tag>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, fontFamily: 'monospace' }}>ID: {selectedDevice.id}</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={() => setSelectedDeviceId(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="1"
|
||||||
|
className="px-4"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: '基本信息',
|
||||||
|
children: (
|
||||||
|
<div className="space-y-6 pt-4 pb-4">
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="设备名称">{selectedDevice.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="设备 ID">{selectedDevice.id}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="设备类型">{selectedDevice.type}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最后心跳">{selectedDevice.lastUpdate}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="固件版本">FW_2.5.4_REL</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="接入协议">MQTT_SSL</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<div className="p-3 bg-blue-50 border border-blue-100 rounded">
|
||||||
|
<Space size="small" align="start">
|
||||||
|
<InfoCircleOutlined style={{ color: '#1890ff', marginTop: 3 }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
该设备目前运行正常,已通过节点身份验证,正在进行全量数据透传。
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: '运行状态',
|
||||||
|
children: (
|
||||||
|
<div className="space-y-6 pt-4 pb-4">
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" bordered styles={{ body: { padding: 12 } }} style={{ backgroundColor: '#fafafa' }}>
|
||||||
|
<Statistic title={<Space><ClusterOutlined />负载率</Space>} value={selectedDevice.cpu} suffix="%" valueStyle={{ fontSize: 16, fontFamily: 'monospace' }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" bordered styles={{ body: { padding: 12 } }} style={{ backgroundColor: '#fafafa' }}>
|
||||||
|
<Statistic title={<Space><HeatMapOutlined />核心温度</Space>} value={selectedDevice.temp} suffix="°C" valueStyle={{ fontSize: 16, fontFamily: 'monospace', color: '#f5222d' }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" bordered styles={{ body: { padding: 12 } }} style={{ backgroundColor: '#fafafa' }}>
|
||||||
|
<Statistic title={<Space><ThunderboltOutlined />剩余能量</Space>} value={selectedDevice.battery} suffix="%" valueStyle={{ fontSize: 16, fontFamily: 'monospace', color: '#52c41a' }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card size="small" bordered styles={{ body: { padding: 12 } }} style={{ backgroundColor: '#fafafa' }}>
|
||||||
|
<Statistic title={<Space><DashboardOutlined />系统健康</Space>} value={98.2} suffix="%" valueStyle={{ fontSize: 16, fontFamily: 'monospace' }} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-gray-100">
|
||||||
|
<Text strong type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', display: 'block', marginBottom: 12 }}>
|
||||||
|
实时动态 (SIGNAL)
|
||||||
|
</Text>
|
||||||
|
<Space direction="vertical" className="w-full" size="small">
|
||||||
|
<div className="flex justify-between items-center bg-gray-50 p-2 rounded">
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>上行链路速率</Text>
|
||||||
|
<Text style={{ fontFamily: 'monospace', fontSize: 11 }}>1.2 Mbps</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center bg-gray-50 p-2 rounded">
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>下行链路速率</Text>
|
||||||
|
<Text style={{ fontFamily: 'monospace', fontSize: 11 }}>0.4 Mbps</Text>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center bg-gray-50 p-2 rounded">
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>当前信号强度</Text>
|
||||||
|
<Tag color="success" style={{ margin: 0, fontSize: 10 }}>EXCELLENT</Tag>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-100 bg-gray-50">
|
||||||
|
<Space direction="vertical" className="w-full" size="small">
|
||||||
|
<Button type="primary" block icon={<ArrowRightOutlined />} style={{ borderRadius: 2 }}>
|
||||||
|
发起远程诊断
|
||||||
|
</Button>
|
||||||
|
<Button block icon={<LineChartOutlined />} style={{ borderRadius: 2 }}>
|
||||||
|
同步设备策略
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/pages/Downloads.tsx
Normal file
120
src/pages/Downloads.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
DatePicker,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
Segmented,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
DownloadOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
SearchOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { VIDEOS } from '../constants';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
export default function Downloads() {
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '文件详情',
|
||||||
|
key: 'file',
|
||||||
|
render: (record: any) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<div className="w-8 h-8 bg-blue-50 text-blue-500 rounded flex items-center justify-center border border-blue-100">
|
||||||
|
<VideoCameraOutlined style={{ fontSize: 16 }} />
|
||||||
|
</div>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text strong style={{ fontSize: 13 }}>ROB_{record.id}_RAW.mp4</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, fontFamily: 'monospace', fontStyle: 'italic' }}>Cloud-Storage-01</Text>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '源设备',
|
||||||
|
dataIndex: 'deviceName',
|
||||||
|
key: 'deviceName',
|
||||||
|
render: (text: string) => <Text style={{ fontSize: 13 }}>{text}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '起止时间',
|
||||||
|
key: 'duration',
|
||||||
|
render: (record: any) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, fontFamily: 'monospace' }}>{record.startTime.split(' ')[1]}</Text>
|
||||||
|
<div className="h-[1px] w-4 bg-gray-200" />
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, fontFamily: 'monospace' }}>{record.endTime.split(' ')[1]}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '容量',
|
||||||
|
dataIndex: 'size',
|
||||||
|
key: 'size',
|
||||||
|
render: (text: string) => <Text type="secondary" style={{ fontSize: 12 }}>{text}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: () => (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
style={{ fontSize: 11, fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Title level={5} style={{ margin: 0 }}>视频回放中心</Title>
|
||||||
|
<Segmented
|
||||||
|
options={['全部录像', '今日录像']}
|
||||||
|
defaultValue="全部录像"
|
||||||
|
style={{ borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="small" style={{ backgroundColor: '#fafafa', borderRadius: 2 }}>
|
||||||
|
<Row gutter={16} align="middle">
|
||||||
|
<Col>
|
||||||
|
<Space>
|
||||||
|
<CalendarOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
|
||||||
|
<Text strong type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}>时间跨度</Text>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<RangePicker size="small" style={{ borderRadius: 2 }} />
|
||||||
|
</Col>
|
||||||
|
<Col flex="auto" className="text-right">
|
||||||
|
<Button type="primary" size="small" icon={<SearchOutlined />} style={{ borderRadius: 2 }}>
|
||||||
|
联机检索
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card styles={{ body: { padding: 0 } }} className="overflow-hidden">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={VIDEOS.map(v => ({ ...v, key: v.id }))}
|
||||||
|
pagination={{ pageSize: 12, size: 'small' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/pages/Home.tsx
Normal file
218
src/pages/Home.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Statistic,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Progress,
|
||||||
|
List,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ThunderboltOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
DotChartOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
BellOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { DEVICES, ALERTS } from '../constants';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
const AntdCard = Card as any;
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ title: '芯片温度', value: 32.08, icon: <DashboardOutlined />, color: '#ff4d4f', suffix: '℃' },
|
||||||
|
{ title: '设备电流', value: 0, icon: <ThunderboltOutlined />, color: '#1677ff', suffix: 'A' },
|
||||||
|
{ title: '设备电压', value: 0, icon: <DotChartOutlined />, color: '#fa8c16', suffix: 'V' },
|
||||||
|
{ title: '设备数量', value: DEVICES.length, icon: <RocketOutlined />, color: '#52c41a', suffix: '' },
|
||||||
|
{ title: '告警数量', value: ALERTS.filter(a => !a.handled).length, icon: <BellOutlined />, color: '#ff4d4f', suffix: '' },
|
||||||
|
{ title: '上报事件', value: 1, icon: <CloudUploadOutlined />, color: '#52c41a', suffix: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Node / 标识',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (text: string, record: any) => (
|
||||||
|
<Space direction="vertical" size={2}>
|
||||||
|
<Text strong style={{ fontSize: 13 }}>{text}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, fontFamily: 'monospace' }}>{record.id}</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status / 状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 110,
|
||||||
|
render: (status: string) => (
|
||||||
|
<Tag
|
||||||
|
color={status === 'online' ? 'success' : status === 'alert' ? 'error' : 'default'}
|
||||||
|
style={{
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '2px 8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status.toUpperCase()}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Batt / 电量',
|
||||||
|
dataIndex: 'battery',
|
||||||
|
key: 'battery',
|
||||||
|
width: 140,
|
||||||
|
render: (percent: number) => (
|
||||||
|
<Progress
|
||||||
|
percent={percent}
|
||||||
|
size="small"
|
||||||
|
status={percent < 20 ? 'exception' : 'active'}
|
||||||
|
strokeColor={percent < 20 ? '#ff4d4f' : '#52c41a'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Load / 负载',
|
||||||
|
dataIndex: 'cpu',
|
||||||
|
key: 'cpu',
|
||||||
|
width: 100,
|
||||||
|
render: (cpu: number) => (
|
||||||
|
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{cpu}%</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ping / 延迟',
|
||||||
|
key: 'ping',
|
||||||
|
width: 100,
|
||||||
|
align: 'right',
|
||||||
|
render: () => <Text style={{ fontFamily: 'monospace', fontSize: 12 }}>12ms</Text>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '16px' }}>
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{stats.map((stat, idx) => (
|
||||||
|
<Col xs={12} sm={12} md={8} lg={4} xl={4} key={idx}>
|
||||||
|
<AntdCard
|
||||||
|
size="small"
|
||||||
|
bordered={false}
|
||||||
|
hoverable
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||||
|
transition: 'all 0.3s',
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: '16px 14px' }}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={<span style={{ fontSize: 12, color: '#666', fontWeight: 500 }}>{stat.title}</span>}
|
||||||
|
value={stat.value}
|
||||||
|
precision={idx === 0 ? 2 : 0}
|
||||||
|
valueStyle={{
|
||||||
|
color: '#1f2329',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 22,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
prefix={React.cloneElement(stat.icon as React.ReactElement, {
|
||||||
|
style: { color: stat.color, fontSize: 16, marginRight: 6 },
|
||||||
|
})}
|
||||||
|
suffix={<span style={{ fontSize: 13, color: '#888' }}>{stat.suffix}</span>}
|
||||||
|
/>
|
||||||
|
</AntdCard>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 4 }}>
|
||||||
|
{/* 设备表格 */}
|
||||||
|
<Col xs={24} lg={16}>
|
||||||
|
<Card
|
||||||
|
title={<span style={{ fontSize: 14, fontWeight: 600 }}>核心节点实时清单</span>}
|
||||||
|
bordered={false}
|
||||||
|
style={{ borderRadius: 10, boxShadow: '0 2px 12px rgba(0,0,0,0.06)' }}
|
||||||
|
bodyStyle={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
size="middle"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={DEVICES.map(d => ({ ...d, key: d.id }))}
|
||||||
|
pagination={false}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => navigate('/devices'),
|
||||||
|
style: { cursor: 'pointer' },
|
||||||
|
})}
|
||||||
|
rowHoverable
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 告警列表 */}
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<Card
|
||||||
|
title={<span style={{ fontSize: 14, fontWeight: 600 }}>当日重点警报</span>}
|
||||||
|
extra={
|
||||||
|
<Button type="text" danger size="small" style={{ fontWeight: 500 }}>
|
||||||
|
一键清除
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
style={{ borderRadius: 10, boxShadow: '0 2px 12px rgba(0,0,0,0.06)' }}
|
||||||
|
bodyStyle={{ padding: 0, height: 460, overflowY: 'auto' }}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
dataSource={ALERTS.slice(0, 10)}
|
||||||
|
renderItem={(alert) => (
|
||||||
|
<List.Item
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: '1px solid #f5f5f5',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
hoverable
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<Tag
|
||||||
|
color={alert.type === 'critical' ? 'error' : alert.type === 'warning' ? 'warning' : 'processing'}
|
||||||
|
style={{
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: '0 6px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alert.type}
|
||||||
|
</Tag>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{alert.time.split(' ')[1]}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Text style={{ fontSize: 13, fontWeight: 500, lineHeight: 1.4 }}>
|
||||||
|
{alert.message}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, marginTop: 4, display: 'block' }}>
|
||||||
|
设备:{alert.deviceName}
|
||||||
|
</Text>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/pages/Login.tsx
Normal file
150
src/pages/Login.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
Card,
|
||||||
|
message
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
ArrowRightOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const onFinish = (values: any) => {
|
||||||
|
// 模拟登录逻辑
|
||||||
|
console.log('Login attempt:', values);
|
||||||
|
|
||||||
|
// 根据用户名模拟不同的角色用于演示
|
||||||
|
let role = 'viewer';
|
||||||
|
let name = '观察员';
|
||||||
|
|
||||||
|
if (values.username === 'admin') {
|
||||||
|
role = 'admin';
|
||||||
|
name = '系统管理员';
|
||||||
|
} else if (values.username === 'operator') {
|
||||||
|
role = 'operator';
|
||||||
|
name = '操作员';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储用户信息
|
||||||
|
const userInfo = {
|
||||||
|
username: values.username,
|
||||||
|
name: name,
|
||||||
|
role: role,
|
||||||
|
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${values.username}`
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('isAuthenticated', 'true');
|
||||||
|
localStorage.setItem('token', 'mock_token_' + Date.now()); // 模拟 Token
|
||||||
|
localStorage.setItem('user', JSON.stringify(userInfo));
|
||||||
|
|
||||||
|
message.success(`欢迎回来, ${name}`);
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#001529] flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
{/* Background Decor */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none opacity-20">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(circle_at_50%_50%,#1890ff_0%,transparent_50%)] opacity-20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
style={{ width: '100%', maxWidth: 400 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
bordered={false}
|
||||||
|
className="shadow-2xl overflow-hidden"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-[#001529] p-8 text-center border-b border-white/10">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 rounded bg-blue-500/10 text-[#1890ff] mb-4 border border-blue-500/20">
|
||||||
|
<RocketOutlined style={{ fontSize: 24 }} />
|
||||||
|
</div>
|
||||||
|
<Title level={4} style={{ color: '#fff', margin: 0, letterSpacing: 1 }}>RobotMaster AI</Title>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 10, marginTop: 4, display: 'block', letterSpacing: 2 }}>机器人智慧服务系统</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="p-8">
|
||||||
|
<Form
|
||||||
|
name="login"
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={onFinish}
|
||||||
|
autoComplete="off"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label={<Text strong type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}><UserOutlined style={{ color: '#1890ff', marginRight: 8 }} />账号名称</Text>}
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: '请输入管理员账号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入管理员账号" style={{ borderRadius: 2 }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={<Text strong type="secondary" style={{ fontSize: 11, textTransform: 'uppercase' }}><LockOutlined style={{ color: '#1890ff', marginRight: 8 }} />访问令牌 / 密码</Text>}
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="••••••••" style={{ borderRadius: 2 }} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||||
|
<Checkbox><Text type="secondary" style={{ fontSize: 12 }}>记住会话</Text></Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="link" size="small" style={{ fontSize: 12 }}>找回凭据?</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
block
|
||||||
|
icon={<SafetyCertificateOutlined />}
|
||||||
|
style={{ borderRadius: 2, height: 44, fontWeight: 'bold', fontSize: 14 }}
|
||||||
|
>
|
||||||
|
核验并进入系统 <ArrowRightOutlined />
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-8 py-4 bg-gray-50 border-t border-gray-100 flex justify-between items-center">
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, fontFamily: 'monospace' }}>System Version: v4.2.1-stable</Text>
|
||||||
|
<Space size={4}>
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<Text strong type="secondary" style={{ fontSize: 10, textTransform: 'uppercase' }}>Server Online</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Security Notice */}
|
||||||
|
<div className="absolute bottom-8 text-center w-full px-4">
|
||||||
|
<Text strong style={{ color: 'rgba(255,255,255,0.2)', fontSize: 10, letterSpacing: 4 }}>
|
||||||
|
AUTHORIZED PERSONNEL ONLY | ENCRYPTED LINK | TRACE ENABLED
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/pages/Roles.tsx
Normal file
103
src/pages/Roles.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Divider,
|
||||||
|
List
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
SafetyCertificateOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
TeamOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
export default function Roles() {
|
||||||
|
const roles = [
|
||||||
|
{ name: '超级管理员', key: 'admin', desc: '系统全权限,支持资源分配与权限审计', level: 'Level 1' },
|
||||||
|
{ name: '系统操作员', key: 'operator', desc: '设备策略下发,实时指令干预及警报处理', level: 'Level 2' },
|
||||||
|
{ name: '访客观察员', key: 'viewer', desc: '只读权限,支持实时视频预览与报表查阅', level: 'Level 3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const permissions = [
|
||||||
|
{ name: '设备控制权限', sectors: ['远程重启', '策略部署', '紧急停机'] },
|
||||||
|
{ name: '视频管理权限', sectors: ['实时监控', '云台控制', '录像下载'] },
|
||||||
|
{ name: '核心准入权限', sectors: ['审计日志', '成本报表', '用户配置'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Title level={5} style={{ margin: 0 }}>系统角色权限矩阵</Title>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} style={{ borderRadius: 2 }}>
|
||||||
|
新增角色组
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<Col span={8} key={role.key}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
style={{ borderRadius: 2 }}
|
||||||
|
actions={[
|
||||||
|
<Button type="link" size="small" icon={<TeamOutlined />}>管理成员</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card.Meta
|
||||||
|
avatar={
|
||||||
|
<div className="w-10 h-10 bg-blue-50 text-blue-500 rounded flex items-center justify-center border border-blue-100">
|
||||||
|
<SafetyCertificateOutlined style={{ fontSize: 20 }} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title={<span style={{ fontSize: 14 }}>{role.name}</span>}
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, fontFamily: 'monospace', textTransform: 'uppercase', fontStyle: 'italic' }}>{role.level}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12, lineHeight: '1.5', height: 40, display: 'block' }}>{role.desc}</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title={<Space><LockOutlined style={{ color: 'rgba(0,0,0,0.45)' }} /><Text strong style={{ fontSize: 14 }}>功能模块权限分配 (细分原子操作)</Text></Space>}
|
||||||
|
style={{ borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
<Row gutter={24} style={{ padding: '8px 0' }}>
|
||||||
|
{permissions.map((p) => (
|
||||||
|
<Col span={8} key={p.name}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Text strong style={{ fontSize: 11, borderLeft: '2px solid #1890ff', paddingLeft: 8, textTransform: 'uppercase', letterSpacing: 1 }}>{p.name}</Text>
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={p.sectors}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<List.Item style={{ padding: '8px 4px', border: 'none' }}>
|
||||||
|
<div className="flex items-center justify-between w-full p-2 bg-gray-50/50 rounded-sm hover:bg-gray-50 border border-transparent hover:border-gray-200 transition-all">
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{item}</Text>
|
||||||
|
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 12 }} />
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/pages/Users.tsx
Normal file
97
src/pages/Users.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
Avatar
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
UserOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { MANAGED_USERS } from '../constants';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '用户信息',
|
||||||
|
key: 'userinfo',
|
||||||
|
render: (record: any) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Avatar src={record.avatar} icon={<UserOutlined />} />
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text strong>{record.name}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{record.id}</Text>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '系统权限',
|
||||||
|
dataIndex: 'role',
|
||||||
|
key: 'role',
|
||||||
|
render: (role: string) => (
|
||||||
|
<Tag color={role === 'admin' ? 'blue' : 'gray'} style={{ borderRadius: 2, fontWeight: 'bold', textTransform: 'uppercase' }}>
|
||||||
|
{role}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账户状态',
|
||||||
|
key: 'status',
|
||||||
|
render: () => <Tag color="success">ACTIVE</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近登录',
|
||||||
|
key: 'lastLogin',
|
||||||
|
render: () => <Text type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>2024-03-24 10:22:45</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
align: 'right' as const,
|
||||||
|
render: () => (
|
||||||
|
<Space>
|
||||||
|
<Button icon={<EditOutlined />} size="small" type="link">编辑</Button>
|
||||||
|
<Button icon={<DeleteOutlined />} size="small" type="link" danger>删除</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Title level={5} style={{ margin: 0 }}>用户管理权限控制</Title>
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
prefix={<SearchOutlined style={{ color: 'rgba(0,0,0,0.25)' }} />}
|
||||||
|
placeholder="搜索用户名或工号..."
|
||||||
|
style={{ width: 250, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} style={{ borderRadius: 2 }}>
|
||||||
|
新增人员
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card styles={{ body: { padding: 0 } }} className="overflow-hidden">
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={MANAGED_USERS.map(u => ({ ...u, key: u.id }))}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/pages/Video.tsx
Normal file
208
src/pages/Video.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
Tooltip,
|
||||||
|
List,
|
||||||
|
Avatar,
|
||||||
|
Select,
|
||||||
|
Divider,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
CaretUpOutlined,
|
||||||
|
CaretDownOutlined,
|
||||||
|
CaretLeftOutlined,
|
||||||
|
CaretRightOutlined,
|
||||||
|
FullscreenOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
MonitorOutlined,
|
||||||
|
VideoCameraOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
CustomerServiceOutlined,
|
||||||
|
AlertOutlined,
|
||||||
|
EyeOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { DEVICES } from '../constants';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
function cn(...classes: string[]) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoControl() {
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState(DEVICES[0]);
|
||||||
|
const onlineDevices = DEVICES.filter(d => d.status === 'online' || d.status === 'alert');
|
||||||
|
|
||||||
|
const getVideoConfig = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case '安防型':
|
||||||
|
return {
|
||||||
|
count: 4,
|
||||||
|
labels: ['Front/主前', 'Back/后视', 'Left/左侧', 'Right/右侧'],
|
||||||
|
gridClass: 'grid-cols-2 grid-rows-2'
|
||||||
|
};
|
||||||
|
case '运输型':
|
||||||
|
return {
|
||||||
|
count: 5,
|
||||||
|
labels: ['Front/前驱', 'Back/后置', 'Left/左翼', 'Right/右翼', 'Cargo/货舱'],
|
||||||
|
gridClass: 'grid-cols-4 grid-rows-2'
|
||||||
|
};
|
||||||
|
case '巡检型':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
count: 3,
|
||||||
|
labels: ['Main/主探', 'Pan/全景', 'Detail/细节'],
|
||||||
|
gridClass: 'grid-cols-2 grid-rows-2'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getVideoConfig(selectedDevice.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={16} className="h-full overflow-hidden" style={{ minHeight: 0 }}>
|
||||||
|
{/* Video Content */}
|
||||||
|
<Col flex="auto" className="h-full flex flex-col gap-4 overflow-hidden">
|
||||||
|
{/* Top Control Bar */}
|
||||||
|
<div className="flex items-center justify-between bg-white p-3 rounded shadow-sm border border-gray-100">
|
||||||
|
<Space size="large">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 1 }}>当前执勤设备</Text>
|
||||||
|
<Select
|
||||||
|
value={selectedDevice.id}
|
||||||
|
onChange={(id) => setSelectedDevice(onlineDevices.find(d => d.id === id)!)}
|
||||||
|
style={{ width: 220, fontWeight: 'bold' }}
|
||||||
|
bordered={false}
|
||||||
|
options={onlineDevices.map(d => ({ label: d.name, value: d.id }))}
|
||||||
|
suffixIcon={<VideoCameraOutlined style={{ color: '#1890ff' }} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Divider type="vertical" style={{ height: 32 }} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Text type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: 1 }}>视频流路数</Text>
|
||||||
|
<Tag color="processing" style={{ marginTop: 2, fontWeight: 'bold', fontFamily: 'monospace' }}>
|
||||||
|
{config.count} CHANNELS ACTIVE
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<ReloadOutlined />}>刷新信号</Button>
|
||||||
|
<Button size="small" type="primary" ghost>全屏墙</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multi-Video Grid */}
|
||||||
|
<div className={cn(
|
||||||
|
"bg-black rounded border border-gray-800 p-1 gap-1 flex-1 overflow-hidden grid",
|
||||||
|
config.gridClass
|
||||||
|
)}>
|
||||||
|
{Array.from({ length: config.count }).map((_, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"relative bg-gray-900 overflow-hidden flex items-center justify-center group border border-white/5",
|
||||||
|
config.count === 3 && idx === 0 && "col-span-2",
|
||||||
|
config.count === 5 && idx === 0 && "col-span-2 row-span-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`https://picsum.photos/seed/${selectedDevice.id}_cam${idx}/640/360`}
|
||||||
|
alt={`Camera ${idx}`}
|
||||||
|
className="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity"
|
||||||
|
/>
|
||||||
|
<div className="absolute top-2 left-2 bg-black/60 text-white px-2 py-0.5 text-[10px] rounded-sm backdrop-blur-sm flex items-center gap-2 border border-white/10 uppercase font-mono">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse shadow-[0_0_8px_rgba(239,68,68,0.8)]" />
|
||||||
|
CAM-0{idx + 1} | {config.labels[idx]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 bg-blue-500/10 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
||||||
|
<Button shape="circle" icon={<FullscreenOutlined />} className="pointer-events-auto shadow-2xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Console Controls */}
|
||||||
|
<Card size="small" styles={{ body: { padding: 12 } }}>
|
||||||
|
<Row gutter={16} align="middle">
|
||||||
|
<Col flex="0 0 160px" style={{ borderRight: '1px solid #f0f0f0', paddingRight: 24 }}>
|
||||||
|
<Text strong type="secondary" style={{ fontSize: 10, textTransform: 'uppercase', display: 'block', textAlign: 'center', marginBottom: 8 }}>PTZ 舵机控制</Text>
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
<Button type="text" size="small" icon={<CaretUpOutlined />} />
|
||||||
|
<Space>
|
||||||
|
<Button type="text" size="small" icon={<CaretLeftOutlined />} />
|
||||||
|
<div className="w-4 h-4 rounded-full bg-blue-500 shadow-[0_0_8px_rgba(24,144,255,0.8)]" />
|
||||||
|
<Button type="text" size="small" icon={<CaretRightOutlined />} />
|
||||||
|
</Space>
|
||||||
|
<Button type="text" size="small" icon={<CaretDownOutlined />} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col flex="auto">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Text strong type="secondary" style={{ fontSize: 10, textTransform: 'uppercase' }}>系统集成作业指令 ({selectedDevice.type})</Text>
|
||||||
|
<Tag color="success" bordered={false} style={{ fontSize: 10, fontWeight: 'bold' }}>SIGNAL: 100% EXCELLENT</Tag>
|
||||||
|
</div>
|
||||||
|
<Space className="w-full" size="middle">
|
||||||
|
<Button icon={<CustomerServiceOutlined />} style={{ borderRadius: 2 }} block>双轴双向对讲</Button>
|
||||||
|
<Button danger icon={<AlertOutlined />} style={{ borderRadius: 2 }} block>触发远程驱逐</Button>
|
||||||
|
<Button type="primary" icon={<EyeOutlined />} style={{ borderRadius: 2 }} block>AI 视觉追踪锁定</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Sidebar Channels */}
|
||||||
|
<Col flex="0 0 260px" className="h-full">
|
||||||
|
<Card
|
||||||
|
title={<Space><MonitorOutlined /><Text strong style={{ fontSize: 13 }}>活跃视频流 ({onlineDevices.length})</Text></Space>}
|
||||||
|
extra={<Button type="text" size="small" icon={<ReloadOutlined />} />}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
className="h-full flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<List
|
||||||
|
dataSource={onlineDevices}
|
||||||
|
renderItem={(device) => (
|
||||||
|
<List.Item
|
||||||
|
onClick={() => setSelectedDevice(device)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-3 cursor-pointer transition-colors border-b last:border-0",
|
||||||
|
selectedDevice.id === device.id ? "bg-blue-50 border-l-4 border-l-blue-500" : "hover:bg-gray-50 border-l-4 border-l-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={
|
||||||
|
<div className={cn("w-12 h-12 rounded overflow-hidden border", selectedDevice.id === device.id ? "border-blue-300" : "border-gray-200")}>
|
||||||
|
<img src={`https://picsum.photos/seed/${device.id}/80/80`} className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<Text strong style={{ fontSize: 13, color: selectedDevice.id === device.id ? '#1890ff' : 'inherit' }}>
|
||||||
|
{device.name}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Space size={4} style={{ fontSize: 10 }}>
|
||||||
|
<Text type="secondary" style={{ fontFamily: 'monospace' }}>{device.id}</Text>
|
||||||
|
<div className={cn("w-2 h-2 rounded-full", device.status === 'alert' ? "bg-red-500" : "bg-green-500")} />
|
||||||
|
<Text type="secondary" italic>{device.type}</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/router/index.tsx
Normal file
62
src/router/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createBrowserRouter } from 'react-router-dom';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import ProtectedRoute from '../components/ProtectedRoute';
|
||||||
|
import Login from '../pages/Login';
|
||||||
|
import Home from '../pages/Home';
|
||||||
|
import Devices from '../pages/Devices';
|
||||||
|
import VideoControl from '../pages/Video';
|
||||||
|
import Alerts from '../pages/Alerts';
|
||||||
|
import Downloads from '../pages/Downloads';
|
||||||
|
import Roles from '../pages/Roles';
|
||||||
|
import Users from '../pages/Users';
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <Login />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <Layout />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: <Home />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'devices',
|
||||||
|
element: <Devices />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'video',
|
||||||
|
element: <VideoControl />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'alerts',
|
||||||
|
element: <Alerts />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'downloads',
|
||||||
|
element: <Downloads />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'roles',
|
||||||
|
element: <Roles />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'users',
|
||||||
|
element: <Users />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
element: <div className="p-10 text-center font-bold text-gray-400">模块开发中...</div>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
39
src/types.ts
Normal file
39
src/types.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export type Role = 'admin' | 'operator' | 'viewer';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: Role;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: 'online' | 'offline' | 'alert';
|
||||||
|
battery: number;
|
||||||
|
cpu: number;
|
||||||
|
temp: number;
|
||||||
|
lastUpdate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Alert {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
type: 'critical' | 'warning' | 'info';
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
handled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoRecord {
|
||||||
|
id: string;
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
size: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowJs": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import {defineConfig, loadEnv} from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig(({mode}) => {
|
||||||
|
const env = loadEnv(mode, '.', '');
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
define: {
|
||||||
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user