diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..aa6ed2f --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import AppWrapper from './AppWrapper' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/ROIEditor.tsx b/frontend/src/pages/ROIEditor.tsx new file mode 100644 index 0000000..7c129c0 --- /dev/null +++ b/frontend/src/pages/ROIEditor.tsx @@ -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([]); + const [selectedCamera, setSelectedCamera] = useState(null); + const [rois, setRois] = useState([]); + const [snapshot, setSnapshot] = useState(''); + const [loading, setLoading] = useState(false); + const [imageDim, setImageDim] = useState({ width: 800, height: 600 }); + const [selectedROI, setSelectedROI] = useState(null); + const [drawerVisible, setDrawerVisible] = useState(false); + const [form] = Form.useForm(); + + const stageRef = useRef(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 ( + { + setSelectedROI(roi); + form.setFieldsValue(roi); + setDrawerVisible(true); + }} + /> + ); + } else if (roi.type === 'line') { + return ( + { + setSelectedROI(roi); + form.setFieldsValue(roi); + setDrawerVisible(true); + }} + /> + ); + } + return null; + }; + + return ( + + + + ({ label: c.name, value: c.id }))} + /> + + 刷新截图 + + 添加ROI + + + + + {snapshot ? ( + + + { + 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)} + + + ) : ( + 加载中... + )} + + + + {rois.map((roi) => ( + { + setSelectedROI(roi); + form.setFieldsValue(roi); + setDrawerVisible(true); + }} + > + {roi.name} + + 类型: {roi.type} | 规则: {roi.rule} + + { + e.stopPropagation(); + handleDeleteROI(roi.id); + }} + > + 删除 + + + ))} + + + + + + setDrawerVisible(false)} + width={400} + > + + + + + + + + + + + + + + + + + + + + + + + 保存 + + setDrawerVisible(false)}>取消 + + + + + + ); +}; + +export default ROIEditor;