From f39f59be94fe3537507e25f42181c4b420c91e81 Mon Sep 17 00:00:00 2001
From: 16337 <1633794139@qq.com>
Date: Tue, 20 Jan 2026 17:45:02 +0800
Subject: [PATCH] =?UTF-8?q?ROI=E9=80=89=E5=8C=BA01?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/App.css | 50 ++++++
frontend/src/App.tsx | 97 ++++++++++
frontend/src/AppRoutes.tsx | 27 +++
frontend/src/AppWrapper.tsx | 32 ++++
frontend/src/index.css | 87 +++++++++
frontend/src/pages/AlertCenter.tsx | 225 ++++++++++++++++++++++++
frontend/src/pages/CameraManagement.tsx | 179 +++++++++++++++++++
frontend/src/pages/Dashboard.tsx | 179 +++++++++++++++++++
8 files changed, 876 insertions(+)
create mode 100644 frontend/src/App.css
create mode 100644 frontend/src/App.tsx
create mode 100644 frontend/src/AppRoutes.tsx
create mode 100644 frontend/src/AppWrapper.tsx
create mode 100644 frontend/src/index.css
create mode 100644 frontend/src/pages/AlertCenter.tsx
create mode 100644 frontend/src/pages/CameraManagement.tsx
create mode 100644 frontend/src/pages/Dashboard.tsx
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000..c2077de
--- /dev/null
+++ b/frontend/src/App.css
@@ -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;
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..d0da010
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -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: ,
+ label: '监控面板',
+ },
+ {
+ key: '/cameras',
+ icon: ,
+ label: '摄像头管理',
+ },
+ {
+ key: '/rois',
+ icon: ,
+ label: 'ROI划定',
+ },
+ {
+ key: '/alerts',
+ icon: ,
+ label: '告警中心',
+ },
+ {
+ key: '/settings',
+ icon: ,
+ label: '系统设置',
+ },
+ ];
+
+ return (
+
+
+
+ 安保监控系统
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/frontend/src/AppRoutes.tsx b/frontend/src/AppRoutes.tsx
new file mode 100644
index 0000000..91f0e5d
--- /dev/null
+++ b/frontend/src/AppRoutes.tsx
@@ -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 = () => (
+
+);
+
+const AppRoutes: React.FC = () => {
+ return (
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ );
+};
+
+export default AppRoutes;
diff --git a/frontend/src/AppWrapper.tsx b/frontend/src/AppWrapper.tsx
new file mode 100644
index 0000000..fa96fb7
--- /dev/null
+++ b/frontend/src/AppWrapper.tsx
@@ -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 = () => (
+
+);
+
+const AppWrapper: React.FC = () => {
+ return (
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ );
+};
+
+export default AppWrapper;
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..445d52b
--- /dev/null
+++ b/frontend/src/index.css
@@ -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;
+}
diff --git a/frontend/src/pages/AlertCenter.tsx b/frontend/src/pages/AlertCenter.tsx
new file mode 100644
index 0000000..f6d1403
--- /dev/null
+++ b/frontend/src/pages/AlertCenter.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(false);
+ const [stats, setStats] = useState({ total: 0, unprocessed: 0 });
+ const [detailVisible, setDetailVisible] = useState(false);
+ const [selectedAlarm, setSelectedAlarm] = useState(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 = {
+ intrusion: 'red',
+ leave_post: 'orange',
+ };
+ return {type === 'intrusion' ? '周界入侵' : '离岗告警'};
+ };
+
+ 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) => (
+ {checked ? '已检查' : '待检查'}
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'processed',
+ key: 'processed',
+ render: (processed: boolean) => (
+ {processed ? '已处理' : '待处理'}
+ ),
+ },
+ {
+ title: '操作',
+ key: 'action',
+ render: (_: any, record: Alarm) => (
+
+ } onClick={() => handleViewDetail(record)}>
+ 查看
+
+ {!record.processed && (
+ } onClick={() => handleMarkProcessed(record.id)}>
+ 处理
+
+ )}
+ {!record.llm_checked && (
+ } onClick={() => handleLLMCheck(record.id)}>
+ AI检查
+
+ )}
+
+ ),
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setDetailVisible(false)}
+ footer={[
+ ,
+ selectedAlarm && !selectedAlarm.processed && (
+
+ ),
+ ]}
+ >
+ {selectedAlarm && (
+
+
时间: {dayjs(selectedAlarm.created_at).format('YYYY-MM-DD HH:mm:ss')}
+
摄像头: {selectedAlarm.camera_id}
+
类型: {getEventTypeTag(selectedAlarm.event_type)}
+
置信度: {selectedAlarm.confidence.toFixed(2)}
+
ROI: {selectedAlarm.roi_id}
+
状态: {selectedAlarm.processed ? '已处理' : '待处理'}
+ {selectedAlarm.llm_result && (
+
+
大模型分析结果:
+
+ {selectedAlarm.llm_result}
+
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default AlertCenter;
diff --git a/frontend/src/pages/CameraManagement.tsx b/frontend/src/pages/CameraManagement.tsx
new file mode 100644
index 0000000..c058ca6
--- /dev/null
+++ b/frontend/src/pages/CameraManagement.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(false);
+ const [modalVisible, setModalVisible] = useState(false);
+ const [editingCamera, setEditingCamera] = useState(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) => (
+
+ {enabled ? '启用' : '停用'}
+
+ ),
+ },
+ {
+ 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) => (
+
+ : }
+ onClick={() => handleToggle(record)}
+ />
+ } onClick={() => handleEdit(record)} />
+ } onClick={() => handleDelete(record.id)} />
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ } onClick={handleAdd}>
+ 添加摄像头
+
+
+
+
setModalVisible(false)}
+ onOk={() => form.submit()}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CameraManagement;
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
new file mode 100644
index 0000000..35a892b
--- /dev/null
+++ b/frontend/src/pages/Dashboard.tsx
@@ -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({ total: 0, unprocessed: 0, llm_pending: 0 });
+ const [recentAlerts, setRecentAlerts] = useState([]);
+ const [cameraStatus, setCameraStatus] = useState([]);
+
+ 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 (
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+ (
+
+
+
+ {alert.event_type === 'intrusion' ? '周界入侵' : '离岗告警'}
+
+ 摄像头 {alert.camera_id}
+
+ {alert.confidence.toFixed(2)}
+
+
+ }
+ description={formatTime(alert.created_at)}
+ />
+ {alert.snapshot_path && (
+
+ )}
+
+ )}
+ />
+
+
+
+
+ (
+
+
+
+ {cam.running ? '运行中' : '已停止'}
+
+ {cam.fps?.toFixed(1) || 0} FPS
+
+ }
+ />
+
+ )}
+ />
+
+
+
+
+ );
+};
+
+export default Dashboard;