ROI选区01
This commit is contained in:
50
frontend/src/App.css
Normal file
50
frontend/src/App.css
Normal file
@@ -0,0 +1,50 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 32px;
|
||||
margin: 16px;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.site-layout .site-layout-background {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ant-layout-sider {
|
||||
overflow: auto;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
margin-left: 200px;
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
line-height: 64px;
|
||||
padding: 0 24px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
97
frontend/src/App.tsx
Normal file
97
frontend/src/App.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { Layout, Menu, theme } from 'antd';
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
AlertOutlined,
|
||||
SettingOutlined,
|
||||
DashboardOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import './App.css';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
const App: React.FC = () => {
|
||||
const {
|
||||
token: { colorBgContainer },
|
||||
} = theme.useToken();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '监控面板',
|
||||
},
|
||||
{
|
||||
key: '/cameras',
|
||||
icon: <VideoCameraOutlined />,
|
||||
label: '摄像头管理',
|
||||
},
|
||||
{
|
||||
key: '/rois',
|
||||
icon: <EditOutlined />,
|
||||
label: 'ROI划定',
|
||||
},
|
||||
{
|
||||
key: '/alerts',
|
||||
icon: <AlertOutlined />,
|
||||
label: '告警中心',
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统设置',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider collapsible theme="dark">
|
||||
<div style={{
|
||||
height: 32,
|
||||
margin: 16,
|
||||
background: 'rgba(255, 255, 255, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
}}>
|
||||
安保监控系统
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
defaultSelectedKeys={['/']}
|
||||
selectedKeys={[location.pathname]}
|
||||
mode="inline"
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header style={{ padding: 0, background: colorBgContainer }}>
|
||||
<div style={{ paddingLeft: 24, fontSize: 18, fontWeight: 'bold' }}>
|
||||
安保异常行为识别系统 v1.0
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{ margin: '16px' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 24,
|
||||
minHeight: 360,
|
||||
background: colorBgContainer,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
27
frontend/src/AppRoutes.tsx
Normal file
27
frontend/src/AppRoutes.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import CameraManagement from './pages/CameraManagement';
|
||||
import ROIEditor from './pages/ROIEditor';
|
||||
import AlertCenter from './pages/AlertCenter';
|
||||
|
||||
const Settings: React.FC = () => (
|
||||
<div>
|
||||
<h2>系统设置</h2>
|
||||
<p>配置管理页面开发中...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppRoutes: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/cameras" element={<CameraManagement />} />
|
||||
<Route path="/rois" element={<ROIEditor />} />
|
||||
<Route path="/alerts" element={<AlertCenter />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
32
frontend/src/AppWrapper.tsx
Normal file
32
frontend/src/AppWrapper.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import CameraManagement from './pages/CameraManagement';
|
||||
import ROIEditor from './pages/ROIEditor';
|
||||
import AlertCenter from './pages/AlertCenter';
|
||||
|
||||
const Settings: React.FC = () => (
|
||||
<div>
|
||||
<h2>系统设置</h2>
|
||||
<p>配置管理页面开发中...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppWrapper: React.FC = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<App>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/cameras" element={<CameraManagement />} />
|
||||
<Route path="/rois" element={<ROIEditor />} />
|
||||
<Route path="/alerts" element={<AlertCenter />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</App>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppWrapper;
|
||||
87
frontend/src/index.css
Normal file
87
frontend/src/index.css
Normal file
@@ -0,0 +1,87 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.camera-card {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.camera-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.camera-info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.roi-editor-container {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alert-list .alert-item {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.alert-item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.alert-item.unprocessed {
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-card.alert {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.stat-card.camera {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
opacity: 0.8;
|
||||
margin-top: 8px;
|
||||
}
|
||||
225
frontend/src/pages/AlertCenter.tsx
Normal file
225
frontend/src/pages/AlertCenter.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Tag, Button, Space, Card, Row, Col, Statistic, Modal, message } from 'antd';
|
||||
import { EyeOutlined, CheckOutlined, SendOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Alarm {
|
||||
id: number;
|
||||
camera_id: number;
|
||||
roi_id: string;
|
||||
event_type: string;
|
||||
confidence: number;
|
||||
snapshot_path: string;
|
||||
llm_checked: boolean;
|
||||
llm_result: string | null;
|
||||
processed: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const AlertCenter: React.FC = () => {
|
||||
const [alarms, setAlarms] = useState<Alarm[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState({ total: 0, unprocessed: 0 });
|
||||
const [detailVisible, setDetailVisible] = useState(false);
|
||||
const [selectedAlarm, setSelectedAlarm] = useState<Alarm | null>(null);
|
||||
|
||||
const fetchAlarms = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/alarms?limit=100');
|
||||
setAlarms(res.data);
|
||||
} catch (err) {
|
||||
message.error('获取告警列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/alarms/stats');
|
||||
setStats(res.data);
|
||||
} catch (err) {
|
||||
console.error('获取统计失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlarms();
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const handleViewDetail = (alarm: Alarm) => {
|
||||
setSelectedAlarm(alarm);
|
||||
setDetailVisible(true);
|
||||
};
|
||||
|
||||
const handleMarkProcessed = async (id: number) => {
|
||||
try {
|
||||
await axios.put(`/api/alarms/${id}`, { processed: true });
|
||||
message.success('已标记为已处理');
|
||||
fetchAlarms();
|
||||
fetchStats();
|
||||
} catch (err) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLLMCheck = async (id: number) => {
|
||||
try {
|
||||
await axios.post(`/api/alarms/${id}/llm-check`);
|
||||
message.success('大模型分析完成');
|
||||
fetchAlarms();
|
||||
fetchStats();
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail || '大模型分析失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getEventTypeTag = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
intrusion: 'red',
|
||||
leave_post: 'orange',
|
||||
};
|
||||
return <Tag color={colors[type] || 'blue'}>{type === 'intrusion' ? '周界入侵' : '离岗告警'}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '摄像头',
|
||||
dataIndex: 'camera_id',
|
||||
key: 'camera_id',
|
||||
render: (id: number) => `摄像头 ${id}`,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'event_type',
|
||||
key: 'event_type',
|
||||
render: getEventTypeTag,
|
||||
},
|
||||
{
|
||||
title: '置信度',
|
||||
dataIndex: 'confidence',
|
||||
key: 'confidence',
|
||||
render: (v: number) => v.toFixed(2),
|
||||
},
|
||||
{
|
||||
title: 'ROI',
|
||||
dataIndex: 'roi_id',
|
||||
key: 'roi_id',
|
||||
},
|
||||
{
|
||||
title: '大模型',
|
||||
dataIndex: 'llm_checked',
|
||||
key: 'llm_checked',
|
||||
render: (checked: boolean) => (
|
||||
<Tag color={checked ? 'green' : 'default'}>{checked ? '已检查' : '待检查'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'processed',
|
||||
key: 'processed',
|
||||
render: (processed: boolean) => (
|
||||
<Tag color={processed ? 'blue' : 'gold'}>{processed ? '已处理' : '待处理'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: Alarm) => (
|
||||
<Space>
|
||||
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>
|
||||
查看
|
||||
</Button>
|
||||
{!record.processed && (
|
||||
<Button type="link" icon={<CheckOutlined />} onClick={() => handleMarkProcessed(record.id)}>
|
||||
处理
|
||||
</Button>
|
||||
)}
|
||||
{!record.llm_checked && (
|
||||
<Button type="link" icon={<SendOutlined />} onClick={() => handleLLMCheck(record.id)}>
|
||||
AI检查
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={12}>
|
||||
<Card>
|
||||
<Statistic title="总告警数" value={stats.total} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card>
|
||||
<Statistic title="待处理告警" value={stats.unprocessed} valueStyle={{ color: '#faad14' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={alarms}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="告警详情"
|
||||
open={detailVisible}
|
||||
onCancel={() => setDetailVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setDetailVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
selectedAlarm && !selectedAlarm.processed && (
|
||||
<Button
|
||||
key="process"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
handleMarkProcessed(selectedAlarm.id);
|
||||
setDetailVisible(false);
|
||||
}}
|
||||
>
|
||||
标记为已处理
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
>
|
||||
{selectedAlarm && (
|
||||
<div>
|
||||
<p><strong>时间:</strong> {dayjs(selectedAlarm.created_at).format('YYYY-MM-DD HH:mm:ss')}</p>
|
||||
<p><strong>摄像头:</strong> {selectedAlarm.camera_id}</p>
|
||||
<p><strong>类型:</strong> {getEventTypeTag(selectedAlarm.event_type)}</p>
|
||||
<p><strong>置信度:</strong> {selectedAlarm.confidence.toFixed(2)}</p>
|
||||
<p><strong>ROI:</strong> {selectedAlarm.roi_id}</p>
|
||||
<p><strong>状态:</strong> {selectedAlarm.processed ? '已处理' : '待处理'}</p>
|
||||
{selectedAlarm.llm_result && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<strong>大模型分析结果:</strong>
|
||||
<p style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, marginTop: 8 }}>
|
||||
{selectedAlarm.llm_result}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertCenter;
|
||||
179
frontend/src/pages/CameraManagement.tsx
Normal file
179
frontend/src/pages/CameraManagement.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, Input, InputNumber, Switch, Space, Tag, message } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, PlayCircleOutlined, PauseCircleOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Camera {
|
||||
id: number;
|
||||
name: string;
|
||||
rtsp_url: string;
|
||||
enabled: boolean;
|
||||
fps_limit: number;
|
||||
process_every_n_frames: number;
|
||||
}
|
||||
|
||||
const CameraManagement: React.FC = () => {
|
||||
const [cameras, setCameras] = useState<Camera[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingCamera, setEditingCamera] = useState<Camera | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchCameras = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/cameras?enabled_only=false');
|
||||
setCameras(res.data);
|
||||
} catch (err) {
|
||||
message.error('获取摄像头列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCameras();
|
||||
}, []);
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingCamera(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (camera: Camera) => {
|
||||
setEditingCamera(camera);
|
||||
form.setFieldsValue(camera);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await axios.delete(`/api/cameras/${id}`);
|
||||
message.success('删除成功');
|
||||
fetchCameras();
|
||||
} catch (err) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (camera: Camera) => {
|
||||
try {
|
||||
await axios.put(`/api/cameras/${camera.id}`, { enabled: !camera.enabled });
|
||||
message.success(camera.enabled ? '已停用' : '已启用');
|
||||
fetchCameras();
|
||||
} catch (err) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
try {
|
||||
if (editingCamera) {
|
||||
await axios.put(`/api/cameras/${editingCamera.id}`, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await axios.post('/api/cameras', values);
|
||||
message.success('添加成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchCameras();
|
||||
} catch (err) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'RTSP地址',
|
||||
dataIndex: 'rtsp_url',
|
||||
key: 'rtsp_url',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (enabled: boolean) => (
|
||||
<Tag color={enabled ? 'green' : 'red'}>
|
||||
{enabled ? '启用' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'FPS限制',
|
||||
dataIndex: 'fps_limit',
|
||||
key: 'fps_limit',
|
||||
},
|
||||
{
|
||||
title: '处理间隔',
|
||||
dataIndex: 'process_every_n_frames',
|
||||
key: 'process_every_n_frames',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: Camera) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={record.enabled ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={() => handleToggle(record)}
|
||||
/>
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => handleEdit(record)} />
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
添加摄像头
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={cameras}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
/>
|
||||
<Modal
|
||||
title={editingCamera ? '编辑摄像头' : '添加摄像头'}
|
||||
open={modalVisible}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
onOk={() => form.submit()}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="rtsp_url" label="RTSP地址" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="fps_limit" label="FPS限制" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={60} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="process_every_n_frames" label="处理间隔帧数" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={30} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraManagement;
|
||||
179
frontend/src/pages/Dashboard.tsx
Normal file
179
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, Statistic, List, Tag, Button, Space, Timeline } from 'antd';
|
||||
import { AlertOutlined, VideoCameraOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Alert {
|
||||
id: number;
|
||||
camera_id: number;
|
||||
event_type: string;
|
||||
confidence: number;
|
||||
snapshot_path: string;
|
||||
created_at: string;
|
||||
processed: boolean;
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
total: number;
|
||||
unprocessed: number;
|
||||
llm_pending: number;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const [stats, setStats] = useState<DashboardStats>({ total: 0, unprocessed: 0, llm_pending: 0 });
|
||||
const [recentAlerts, setRecentAlerts] = useState<Alert[]>([]);
|
||||
const [cameraStatus, setCameraStatus] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
fetchAlerts();
|
||||
fetchCameraStatus();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchStats();
|
||||
fetchAlerts();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/alarms/stats');
|
||||
setStats(res.data);
|
||||
} catch (err) {
|
||||
console.error('获取统计数据失败', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/alarms?limit=5');
|
||||
setRecentAlerts(res.data);
|
||||
} catch (err) {
|
||||
console.error('获取告警失败', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCameraStatus = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/pipeline/status');
|
||||
const cameras = Object.entries(res.data.cameras || {}).map(([id, info]) => ({
|
||||
id,
|
||||
...info as any,
|
||||
}));
|
||||
setCameraStatus(cameras);
|
||||
} catch (err) {
|
||||
console.error('获取摄像头状态失败', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getAlertColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'intrusion':
|
||||
return 'red';
|
||||
case 'leave_post':
|
||||
return 'orange';
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timeStr: string) => {
|
||||
const date = new Date(timeStr);
|
||||
return date.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总告警数"
|
||||
value={stats.total}
|
||||
prefix={<AlertOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="待处理告警"
|
||||
value={stats.unprocessed}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
prefix={<ClockCircleOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="大模型待检"
|
||||
value={stats.llm_pending}
|
||||
prefix={<VideoCameraOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={16}>
|
||||
<Card title="最新告警" className="alert-list">
|
||||
<List
|
||||
dataSource={recentAlerts}
|
||||
renderItem={(alert) => (
|
||||
<List.Item className={alert.processed ? 'alert-item' : 'alert-item unprocessed'}>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
<Tag color={getAlertColor(alert.event_type)}>
|
||||
{alert.event_type === 'intrusion' ? '周界入侵' : '离岗告警'}
|
||||
</Tag>
|
||||
<span>摄像头 {alert.camera_id}</span>
|
||||
<span style={{ color: '#999' }}>
|
||||
{alert.confidence.toFixed(2)}
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
description={formatTime(alert.created_at)}
|
||||
/>
|
||||
{alert.snapshot_path && (
|
||||
<Button type="link" size="small">
|
||||
查看截图
|
||||
</Button>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card title="摄像头状态">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={cameraStatus}
|
||||
renderItem={(cam) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={`摄像头 ${cam.id}`}
|
||||
description={
|
||||
<Space>
|
||||
<Tag color={cam.running ? 'green' : 'red'}>
|
||||
{cam.running ? '运行中' : '已停止'}
|
||||
</Tag>
|
||||
<span>{cam.fps?.toFixed(1) || 0} FPS</span>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
Reference in New Issue
Block a user