初始化项目

This commit is contained in:
mmc
2026-04-17 17:20:41 +08:00
commit 6122e884fe
27 changed files with 7881 additions and 0 deletions

9
.env.example Normal file
View 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
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

20
README.md Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
{
"name": "机器人智慧服务系统",
"description": "提供设备管理、视频监控、状态预警、警报处理及视频下载功能的一站式机器人智能管理平台。",
"requestFramePermissions": [],
"majorCapabilities": []
}

5999
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View 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
View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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',
},
};
});