ROI选区01

This commit is contained in:
2026-01-20 17:45:02 +08:00
parent 604ef82ffb
commit f39f59be94
8 changed files with 876 additions and 0 deletions

50
frontend/src/App.css Normal file
View 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
View 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;

View 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;

View 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
View 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;
}

View 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;

View 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;

View 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;