ROI选区01

This commit is contained in:
2026-01-20 17:46:32 +08:00
parent f39f59be94
commit 7552cf86c3
10 changed files with 709 additions and 145 deletions

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Layout, Menu, theme } from 'antd';
import {
VideoCameraOutlined,
@@ -18,6 +18,7 @@ const App: React.FC = () => {
} = theme.useToken();
const navigate = useNavigate();
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const menuItems = [
{
@@ -49,18 +50,36 @@ const App: React.FC = () => {
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider collapsible theme="dark">
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
theme="dark"
style={{
zIndex: 100,
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
overflow: 'auto',
height: '100vh',
}}
width={200}
collapsedWidth={80}
>
<div style={{
height: 32,
margin: 16,
background: 'rgba(255, 255, 255, 0.2)',
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 'bold',
fontSize: 16,
background: 'rgba(255, 255, 255, 0.1)',
whiteSpace: 'nowrap',
overflow: 'hidden',
}}>
{collapsed ? '' : '安保监控系统'}
</div>
<Menu
theme="dark"
@@ -71,23 +90,31 @@ const App: React.FC = () => {
onClick={({ key }) => navigate(key)}
/>
</Sider>
<Layout>
<Header style={{ padding: 0, background: colorBgContainer }}>
<div style={{ paddingLeft: 24, fontSize: 18, fontWeight: 'bold' }}>
<Layout style={{ marginLeft: collapsed ? 80 : 200, transition: 'margin-left 0.2s' }}>
<Header style={{
padding: '0 24px',
background: colorBgContainer,
marginLeft: 0,
display: 'flex',
alignItems: 'center',
position: 'sticky',
top: 0,
zIndex: 99,
boxShadow: '0 1px 4px rgba(0,0,0,0.08)',
}}>
<div style={{ fontSize: 18, fontWeight: 'bold', color: '#333' }}>
v1.0
</div>
</Header>
<Content style={{ margin: '16px' }}>
<div
style={{
padding: 24,
minHeight: 360,
background: colorBgContainer,
borderRadius: 8,
}}
>
<Outlet />
</div>
<Content style={{
margin: 24,
padding: 24,
minHeight: 360,
background: colorBgContainer,
borderRadius: 8,
overflow: 'auto',
}}>
<Outlet />
</Content>
</Layout>
</Layout>

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import App from './App';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Dashboard from './pages/Dashboard';
import CameraManagement from './pages/CameraManagement';
import ROIEditor from './pages/ROIEditor';
import AlertCenter from './pages/AlertCenter';
import App from './App';
const Settings: React.FC = () => (
<div>
@@ -16,15 +16,16 @@ const Settings: React.FC = () => (
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>
<Routes>
<Route path="/" element={<App />}>
<Route index element={<Dashboard />} />
<Route path="cameras" element={<CameraManagement />} />
<Route path="rois" element={<ROIEditor />} />
<Route path="alerts" element={<AlertCenter />} />
<Route path="settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
);
};

View File

@@ -6,6 +6,34 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f5f5;
}
#root {
min-height: 100vh;
}
.ant-layout-sider {
z-index: 100;
}
.ant-layout {
z-index: 1;
}
.ant-dropdown,
.ant-select-dropdown,
.ant-picker-dropdown,
.ant-popover {
z-index: 1050 !important;
}
.ant-message {
z-index: 1060;
}
.ant-notification {
z-index: 1070;
}
.camera-grid {

14
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { ConfigProvider } from 'antd'
import AppWrapper from './AppWrapper'
import './index.css'
import zhCN from 'antd/locale/zh_CN'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<AppWrapper />
</ConfigProvider>
</React.StrictMode>,
)

View File

@@ -1,6 +1,12 @@
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 React, { useEffect, useState, useMemo } from 'react';
import {
Table, Button, Modal, Form, Input, InputNumber, Switch, Space, Tag, message,
Popconfirm, Tooltip, Badge
} from 'antd';
import {
PlusOutlined, DeleteOutlined, EditOutlined, PlayCircleOutlined,
PauseCircleOutlined, SearchOutlined, SyncOutlined, CameraOutlined
} from '@ant-design/icons';
import axios from 'axios';
interface Camera {
@@ -10,20 +16,34 @@ interface Camera {
enabled: boolean;
fps_limit: number;
process_every_n_frames: number;
created_at: string;
}
interface CameraStatus {
is_running: boolean;
fps: number;
error_message: string | null;
last_check_time: string;
}
const CameraManagement: React.FC = () => {
const [cameras, setCameras] = useState<Camera[]>([]);
const [cameraStatus, setCameraStatus] = useState<Record<number, CameraStatus>>({});
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingCamera, setEditingCamera] = useState<Camera | null>(null);
const [searchText, setSearchText] = useState('');
const [form] = Form.useForm();
const fetchCameras = async () => {
setLoading(true);
try {
const res = await axios.get('/api/cameras?enabled_only=false');
setCameras(res.data);
const [camerasRes, statusRes] = await Promise.all([
axios.get('/api/cameras?enabled_only=false'),
axios.get('/api/pipeline/status')
]);
setCameras(camerasRes.data);
setCameraStatus(statusRes.data.cameras || {});
} catch (err) {
message.error('获取摄像头列表失败');
} finally {
@@ -33,11 +53,50 @@ const CameraManagement: React.FC = () => {
useEffect(() => {
fetchCameras();
const interval = setInterval(fetchCameras, 5000);
return () => clearInterval(interval);
}, []);
const extractIP = (url: string): string => {
try {
const match = url.match(/:\/\/([^:]+):?(\d+)?\//);
return match ? match[1] : '未知';
} catch {
return '未知';
}
};
const getStatusInfo = (camera: Camera) => {
const status = cameraStatus[camera.id];
if (!camera.enabled) {
return { color: '#8c8c8f', text: '已停用', status: 'default' };
}
if (!status) {
return { color: '#fa8c16', text: '加载中...', status: 'processing' };
}
if (status.is_running && status.fps > 0) {
return { color: '#52c41a', text: `运行中 (${status.fps.toFixed(1)} FPS)`, status: 'success' };
}
if (status.error_message) {
return { color: '#f5222d', text: `连接失败`, status: 'error' };
}
return { color: '#fa8c16', text: '初始化中...', status: 'processing' };
};
const filteredCameras = useMemo(() => {
if (!searchText) return cameras;
const lowerSearch = searchText.toLowerCase();
return cameras.filter(cam =>
cam.name.toLowerCase().includes(lowerSearch) ||
cam.rtsp_url.toLowerCase().includes(lowerSearch) ||
extractIP(cam.rtsp_url).toLowerCase().includes(lowerSearch)
);
}, [cameras, searchText]);
const handleAdd = () => {
setEditingCamera(null);
form.resetFields();
form.setFieldsValue({ fps_limit: 30, process_every_n_frames: 3, enabled: true });
setModalVisible(true);
};
@@ -88,51 +147,114 @@ const CameraManagement: React.FC = () => {
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 60,
width: 70,
sorter: (a: Camera, b: Camera) => a.id - b.id,
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
sorter: (a: Camera, b: Camera) => a.name.localeCompare(b.name),
render: (name: string, record: Camera) => (
<Space direction="vertical" size={0}>
<span style={{ fontWeight: 500 }}>{name}</span>
<Tag icon={<CameraOutlined />} style={{ fontSize: 12 }}>
#{record.id}
</Tag>
</Space>
),
},
{
title: 'RTSP地址',
dataIndex: 'rtsp_url',
key: 'rtsp_url',
ellipsis: true,
title: 'IP地址',
key: 'ip',
width: 150,
render: (_: any, record: Camera) => {
const ip = extractIP(record.rtsp_url);
return (
<Tooltip title={record.rtsp_url}>
<code style={{ fontSize: 12, color: '#666' }}>{ip}</code>
</Tooltip>
);
},
},
{
title: '状态',
key: 'status',
width: 160,
sorter: (a: Camera, b: Camera) => {
const statusA = getStatusInfo(a).status;
const statusB = getStatusInfo(b).status;
return statusA.localeCompare(statusB);
},
render: (_: any, record: Camera) => {
const { color, text, status } = getStatusInfo(record);
return (
<Badge status={status as any} text={<span style={{ color }}>{text}</span>} />
);
},
},
{
title: '启用',
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean) => (
<Tag color={enabled ? 'green' : 'red'}>
{enabled ? '启用' : '停用'}
</Tag>
width: 80,
render: (enabled: boolean, record: Camera) => (
<Switch
size="small"
checked={enabled}
onChange={() => handleToggle(record)}
checkedChildren="启用"
unCheckedChildren="停用"
/>
),
},
{
title: 'FPS限制',
dataIndex: 'fps_limit',
key: 'fps_limit',
width: 100,
sorter: (a: Camera, b: Camera) => a.fps_limit - b.fps_limit,
},
{
title: '处理间隔',
dataIndex: 'process_every_n_frames',
key: 'process_every_n_frames',
width: 100,
render: (n: number) => `${n}`,
},
{
title: '操作',
key: 'action',
width: 150,
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 size="small">
<Tooltip title={record.enabled ? '停用' : '启用'}>
<Button
type="text"
size="small"
icon={record.enabled ? <PauseCircleOutlined style={{ color: '#faad14' }} /> : <PlayCircleOutlined style={{ color: '#52c41a' }} />}
onClick={() => handleToggle(record)}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Popconfirm
title="确定删除此摄像头?"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Tooltip title="删除">
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
</Space>
),
},
@@ -140,35 +262,102 @@ const CameraManagement: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, gap: 16, flexWrap: 'wrap' }}>
<Space>
<Input
placeholder="搜索摄像头名称、IP或RTSP地址"
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
allowClear
/>
<Tooltip title="刷新列表">
<Button icon={<SyncOutlined spin={loading} />} onClick={fetchCameras} loading={loading} />
</Tooltip>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
</Button>
</div>
<Table
columns={columns}
dataSource={cameras}
dataSource={filteredCameras}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条,共 ${total}`,
}}
scroll={{ x: 1200 }}
/>
<Modal
title={editingCamera ? '编辑摄像头' : '添加摄像头'}
title={
<Space>
{editingCamera ? <EditOutlined /> : <PlusOutlined />}
{editingCamera ? '编辑摄像头' : '新增摄像头'}
</Space>
}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={() => form.submit()}
width={500}
okText="保存"
cancelText="取消"
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
<Form.Item
name="name"
label="摄像头名称"
rules={[{ required: true, message: '请输入摄像头名称' }]}
tooltip="给摄像头起一个易于识别的名称"
>
<Input placeholder="例如:办公楼入口摄像头" />
</Form.Item>
<Form.Item name="rtsp_url" label="RTSP地址" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
<Form.Item
name="rtsp_url"
label="RTSP地址"
rules={[
{ required: true, message: '请输入RTSP地址' },
{ type: 'url', message: '请输入有效的URL地址' }
]}
tooltip="RTSP流地址格式如rtsp://admin:password@192.168.1.100:554/stream"
>
<Input.TextArea rows={3} placeholder="rtsp://admin:password@IP地址:554/通道号" />
</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%' }} />
<Space style={{ width: '100%' }} size="large">
<Form.Item
name="fps_limit"
label="FPS限制"
tooltip="摄像头最大处理帧率"
style={{ flex: 1 }}
>
<InputNumber min={1} max={60} style={{ width: '100%' }} addonAfter="帧/秒" />
</Form.Item>
<Form.Item
name="process_every_n_frames"
label="处理间隔"
tooltip="每N帧处理一次用于降低CPU负载"
style={{ flex: 1 }}
>
<InputNumber min={1} max={30} style={{ width: '100%' }} addonAfter="帧" />
</Form.Item>
</Space>
<Form.Item
name="enabled"
label="启用状态"
valuePropName="checked"
tooltip="关闭后将停止该摄像头的检测任务"
>
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
</Form>
</Modal>

View File

@@ -0,0 +1,304 @@
import React, { useEffect, useState, useRef } from 'react';
import { Card, Button, Space, Select, message, Modal, Form, Input, InputNumber, Drawer } from 'antd';
import { Stage, Layer, Rect, Line, Circle, Text as KonvaText } from 'react-konva';
import axios from 'axios';
interface ROI {
id: number;
roi_id: string;
name: string;
type: string;
points: number[][];
rule: string;
direction: string | null;
enabled: boolean;
threshold_sec: number;
confirm_sec: number;
return_sec: number;
}
interface Camera {
id: number;
name: string;
}
const ROIEditor: React.FC = () => {
const [cameras, setCameras] = useState<Camera[]>([]);
const [selectedCamera, setSelectedCamera] = useState<number | null>(null);
const [rois, setRois] = useState<ROI[]>([]);
const [snapshot, setSnapshot] = useState<string>('');
const [loading, setLoading] = useState(false);
const [imageDim, setImageDim] = useState({ width: 800, height: 600 });
const [selectedROI, setSelectedROI] = useState<ROI | null>(null);
const [drawerVisible, setDrawerVisible] = useState(false);
const [form] = Form.useForm();
const stageRef = useRef<any>(null);
const fetchCameras = async () => {
try {
const res = await axios.get('/api/cameras?enabled_only=true');
setCameras(res.data);
if (res.data.length > 0 && !selectedCamera) {
setSelectedCamera(res.data[0].id);
}
} catch (err) {
message.error('获取摄像头列表失败');
}
};
useEffect(() => {
fetchCameras();
}, []);
useEffect(() => {
if (selectedCamera) {
fetchSnapshot();
fetchROIs();
}
}, [selectedCamera]);
const fetchSnapshot = async () => {
if (!selectedCamera) return;
try {
const res = await axios.get(`/api/camera/${selectedCamera}/snapshot/base64`);
setSnapshot(res.data.image);
const img = new Image();
img.onload = () => {
const maxWidth = 800;
const maxHeight = 600;
const scale = Math.min(maxWidth / img.width, maxHeight / img.height);
setImageDim({ width: img.width * scale, height: img.height * scale });
};
img.src = `data:image/jpeg;base64,${res.data.image}`;
} catch (err) {
message.error('获取截图失败');
}
};
const fetchROIs = async () => {
if (!selectedCamera) return;
try {
const res = await axios.get(`/api/camera/${selectedCamera}/rois`);
setRois(res.data);
} catch (err) {
message.error('获取ROI列表失败');
}
};
const handleSaveROI = async (values: any) => {
if (!selectedCamera || !selectedROI) return;
try {
await axios.put(`/api/camera/${selectedCamera}/roi/${selectedROI.id}`, values);
message.success('保存成功');
setDrawerVisible(false);
fetchROIs();
} catch (err) {
message.error('保存失败');
}
};
const handleAddROI = async () => {
if (!selectedCamera) return;
const roi_id = `roi_${Date.now()}`;
try {
await axios.post(`/api/camera/${selectedCamera}/roi`, {
roi_id,
name: '新区域',
roi_type: 'polygon',
points: [[100, 100], [300, 100], [300, 300], [100, 300]],
rule_type: 'leave_post',
threshold_sec: 360,
confirm_sec: 30,
return_sec: 5,
});
message.success('添加成功');
fetchROIs();
} catch (err) {
message.error('添加失败');
}
};
const handleDeleteROI = async (roiId: number) => {
if (!selectedCamera) return;
try {
await axios.delete(`/api/camera/${selectedCamera}/roi/${roiId}`);
message.success('删除成功');
fetchROIs();
} catch (err) {
message.error('删除失败');
}
};
const getROIStrokeColor = (rule: string) => {
return rule === 'intrusion' ? '#ff4d4f' : '#faad14';
};
const renderROI = (roi: ROI) => {
const points = roi.points.flat();
const color = getROIStrokeColor(roi.rule);
if (roi.type === 'polygon') {
return (
<Line
key={roi.id}
points={points}
closed
stroke={color}
strokeWidth={2}
fill={`${color}33`}
onClick={() => {
setSelectedROI(roi);
form.setFieldsValue(roi);
setDrawerVisible(true);
}}
/>
);
} else if (roi.type === 'line') {
return (
<Line
key={roi.id}
points={points}
stroke={color}
strokeWidth={3}
onClick={() => {
setSelectedROI(roi);
form.setFieldsValue(roi);
setDrawerVisible(true);
}}
/>
);
}
return null;
};
return (
<div>
<Card>
<Space style={{ marginBottom: 16 }}>
<Select
placeholder="选择摄像头"
value={selectedCamera}
onChange={setSelectedCamera}
style={{ width: 200 }}
options={cameras.map((c) => ({ label: c.name, value: c.id }))}
/>
<Button type="primary" onClick={fetchSnapshot}>
</Button>
<Button onClick={handleAddROI}>ROI</Button>
</Space>
<div className="roi-editor-container" style={{ display: 'flex', gap: 16 }}>
<div style={{ flex: 1, background: '#f0f0f0', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{snapshot ? (
<Stage width={imageDim.width} height={imageDim.height} ref={stageRef}>
<Layer>
<Rect
x={0}
y={0}
width={imageDim.width}
height={imageDim.height}
fillPatternImage={
(() => {
const img = new Image();
img.src = `data:image/jpeg;base64,${snapshot}`;
return img;
})()
}
fillPatternOffset={{ x: 0, y: 0 }}
fillPatternScale={{ x: 1, y: 1 }}
/>
{rois.map(renderROI)}
</Layer>
</Stage>
) : (
<div>...</div>
)}
</div>
<div style={{ width: 300 }}>
<Card title="ROI列表" size="small">
{rois.map((roi) => (
<div
key={roi.id}
style={{
padding: 8,
marginBottom: 8,
background: '#fafafa',
borderRadius: 4,
cursor: 'pointer',
border: selectedROI?.id === roi.id ? '2px solid #1890ff' : '1px solid #d9d9d9',
}}
onClick={() => {
setSelectedROI(roi);
form.setFieldsValue(roi);
setDrawerVisible(true);
}}
>
<div style={{ fontWeight: 'bold' }}>{roi.name}</div>
<div style={{ fontSize: 12, color: '#666' }}>
: {roi.type} | : {roi.rule}
</div>
<Button
type="text"
danger
size="small"
onClick={(e) => {
e.stopPropagation();
handleDeleteROI(roi.id);
}}
>
</Button>
</div>
))}
</Card>
</div>
</div>
</Card>
<Drawer
title="编辑ROI"
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
width={400}
>
<Form form={form} layout="vertical" onFinish={handleSaveROI}>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="roi_type" label="类型">
<Select options={[{ label: '多边形', value: 'polygon' }, { label: '线段', value: 'line' }]} />
</Form.Item>
<Form.Item name="rule_type" label="规则">
<Select
options={[
{ label: '离岗检测', value: 'leave_post' },
{ label: '周界入侵', value: 'intrusion' },
]}
/>
</Form.Item>
<Form.Item name="threshold_sec" label="超时时间(秒)">
<InputNumber min={60} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="confirm_sec" label="确认时间(秒)">
<InputNumber min={5} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<input type="checkbox" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={() => setDrawerVisible(false)}></Button>
</Space>
</Form.Item>
</Form>
</Drawer>
</div>
);
};
export default ROIEditor;