2026-01-20 17:46:32 +08:00
|
|
|
|
import React, { useEffect, useState, useRef } from 'react';
|
2026-01-22 16:44:26 +08:00
|
|
|
|
import { Card, Button, Space, Select, message, Drawer, Form, Input, InputNumber, Switch, TimePicker, Divider } from 'antd';
|
2026-01-20 17:46:32 +08:00
|
|
|
|
import { Stage, Layer, Rect, Line, Circle, Text as KonvaText } from 'react-konva';
|
2026-01-22 16:44:26 +08:00
|
|
|
|
import { RangePickerProps } from 'antd/es/date-picker';
|
2026-01-20 17:46:32 +08:00
|
|
|
|
import axios from 'axios';
|
2026-01-22 16:44:26 +08:00
|
|
|
|
import dayjs from 'dayjs';
|
|
|
|
|
|
|
|
|
|
|
|
interface WorkingHours {
|
|
|
|
|
|
start: number[];
|
|
|
|
|
|
end: number[];
|
|
|
|
|
|
}
|
2026-01-20 17:46:32 +08:00
|
|
|
|
|
|
|
|
|
|
interface ROI {
|
|
|
|
|
|
id: number;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
type: string;
|
|
|
|
|
|
points: number[][];
|
|
|
|
|
|
rule: string;
|
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
|
threshold_sec: number;
|
|
|
|
|
|
confirm_sec: number;
|
|
|
|
|
|
return_sec: number;
|
2026-01-22 16:44:26 +08:00
|
|
|
|
working_hours: WorkingHours[] | null;
|
2026-01-20 17:46:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 [imageDim, setImageDim] = useState({ width: 800, height: 600 });
|
|
|
|
|
|
const [selectedROI, setSelectedROI] = useState<ROI | null>(null);
|
|
|
|
|
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
|
|
|
|
|
const [form] = Form.useForm();
|
2026-01-22 17:26:28 +08:00
|
|
|
|
const [workingHoursList, setWorkingHoursList] = useState<{start: dayjs.Dayjs | null, end: dayjs.Dayjs | null}[]>([]);
|
2026-01-20 17:46:32 +08:00
|
|
|
|
|
2026-01-21 13:29:39 +08:00
|
|
|
|
const [isDrawing, setIsDrawing] = useState(false);
|
|
|
|
|
|
const [tempPoints, setTempPoints] = useState<number[][]>([]);
|
|
|
|
|
|
const [backgroundImage, setBackgroundImage] = useState<HTMLImageElement | null>(null);
|
2026-01-20 17:46:32 +08:00
|
|
|
|
const stageRef = useRef<any>(null);
|
|
|
|
|
|
|
2026-01-22 17:26:28 +08:00
|
|
|
|
const addWorkingHours = () => {
|
|
|
|
|
|
setWorkingHoursList([...workingHoursList, { start: null, end: null }]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const removeWorkingHours = (index: number) => {
|
|
|
|
|
|
const newList = workingHoursList.filter((_, i) => i !== index);
|
|
|
|
|
|
setWorkingHoursList(newList);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const updateWorkingHours = (index: number, field: 'start' | 'end', value: dayjs.Dayjs | null) => {
|
|
|
|
|
|
const newList = [...workingHoursList];
|
|
|
|
|
|
newList[index] = { ...newList[index], [field]: value };
|
|
|
|
|
|
setWorkingHoursList(newList);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const updateWorkingHoursRange = (index: number, start: dayjs.Dayjs | null, end: dayjs.Dayjs | null) => {
|
|
|
|
|
|
setWorkingHoursList(prev => {
|
|
|
|
|
|
const newList = [...prev];
|
|
|
|
|
|
if (newList[index]) {
|
|
|
|
|
|
newList[index] = { start, end };
|
|
|
|
|
|
}
|
|
|
|
|
|
return newList;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-20 17:46:32 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-21 13:29:39 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (snapshot) {
|
2026-01-20 17:46:32 +08:00
|
|
|
|
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 });
|
2026-01-21 13:29:39 +08:00
|
|
|
|
setBackgroundImage(img);
|
2026-01-20 17:46:32 +08:00
|
|
|
|
};
|
2026-01-21 13:29:39 +08:00
|
|
|
|
img.src = `data:image/jpeg;base64,${snapshot}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [snapshot]);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchSnapshot = async () => {
|
|
|
|
|
|
if (!selectedCamera) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await axios.get(`/api/camera/${selectedCamera}/snapshot/base64`);
|
|
|
|
|
|
setSnapshot(res.data.image);
|
2026-01-20 17:46:32 +08:00
|
|
|
|
} 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 {
|
2026-01-22 17:26:28 +08:00
|
|
|
|
const workingHours = workingHoursList
|
|
|
|
|
|
.filter(item => item.start && item.end)
|
|
|
|
|
|
.map(item => ({
|
|
|
|
|
|
start: [item.start!.hour(), item.start!.minute()],
|
|
|
|
|
|
end: [item.end!.hour(), item.end!.minute()],
|
|
|
|
|
|
}));
|
2026-01-22 16:44:26 +08:00
|
|
|
|
|
2026-01-21 13:29:39 +08:00
|
|
|
|
await axios.put(`/api/camera/${selectedCamera}/roi/${selectedROI.id}`, {
|
|
|
|
|
|
name: values.name,
|
|
|
|
|
|
roi_type: values.roi_type,
|
|
|
|
|
|
rule_type: values.rule_type,
|
|
|
|
|
|
threshold_sec: values.threshold_sec,
|
|
|
|
|
|
confirm_sec: values.confirm_sec,
|
2026-01-22 16:44:26 +08:00
|
|
|
|
working_hours: workingHours,
|
2026-01-21 13:29:39 +08:00
|
|
|
|
enabled: values.enabled,
|
|
|
|
|
|
});
|
2026-01-20 17:46:32 +08:00
|
|
|
|
message.success('保存成功');
|
|
|
|
|
|
setDrawerVisible(false);
|
2026-01-22 17:26:28 +08:00
|
|
|
|
setWorkingHoursList([]);
|
2026-01-20 17:46:32 +08:00
|
|
|
|
fetchROIs();
|
2026-01-21 13:29:39 +08:00
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
message.error(`保存失败: ${err.response?.data?.detail || '未知错误'}`);
|
2026-01-20 17:46:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-21 13:29:39 +08:00
|
|
|
|
const handleAddROI = () => {
|
|
|
|
|
|
if (!selectedCamera) {
|
|
|
|
|
|
message.warning('请先选择摄像头');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setIsDrawing(true);
|
|
|
|
|
|
setTempPoints([]);
|
|
|
|
|
|
setSelectedROI(null);
|
|
|
|
|
|
message.info('点击画布绘制ROI区域,双击完成绘制');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleStageClick = (e: any) => {
|
|
|
|
|
|
if (!isDrawing) return;
|
|
|
|
|
|
|
|
|
|
|
|
const stage = e.target.getStage();
|
|
|
|
|
|
const pos = stage.getPointerPosition();
|
|
|
|
|
|
if (pos) {
|
|
|
|
|
|
setTempPoints(prev => [...prev, [pos.x, pos.y]]);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleStageDblClick = () => {
|
|
|
|
|
|
if (!isDrawing || tempPoints.length < 3) {
|
|
|
|
|
|
if (tempPoints.length > 0 && tempPoints.length < 3) {
|
|
|
|
|
|
message.warning('至少需要3个点才能形成多边形');
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-20 17:46:32 +08:00
|
|
|
|
const roi_id = `roi_${Date.now()}`;
|
2026-01-21 13:29:39 +08:00
|
|
|
|
axios.post(`/api/camera/${selectedCamera}/roi`, {
|
|
|
|
|
|
roi_id,
|
|
|
|
|
|
name: `区域${rois.length + 1}`,
|
|
|
|
|
|
roi_type: 'polygon',
|
|
|
|
|
|
points: tempPoints,
|
|
|
|
|
|
rule_type: 'intrusion',
|
|
|
|
|
|
threshold_sec: 60,
|
|
|
|
|
|
confirm_sec: 5,
|
|
|
|
|
|
return_sec: 5,
|
2026-01-22 16:44:26 +08:00
|
|
|
|
working_hours: [],
|
2026-01-21 13:29:39 +08:00
|
|
|
|
})
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
message.success('ROI添加成功');
|
|
|
|
|
|
setIsDrawing(false);
|
|
|
|
|
|
setTempPoints([]);
|
2026-01-20 17:46:32 +08:00
|
|
|
|
fetchROIs();
|
2026-01-21 13:29:39 +08:00
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
|
message.error(`添加失败: ${err.response?.data?.detail || '未知错误'}`);
|
|
|
|
|
|
setIsDrawing(false);
|
|
|
|
|
|
setTempPoints([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleCancelDrawing = () => {
|
|
|
|
|
|
setIsDrawing(false);
|
|
|
|
|
|
setTempPoints([]);
|
|
|
|
|
|
message.info('已取消绘制');
|
2026-01-20 17:46:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDeleteROI = async (roiId: number) => {
|
|
|
|
|
|
if (!selectedCamera) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await axios.delete(`/api/camera/${selectedCamera}/roi/${roiId}`);
|
|
|
|
|
|
message.success('删除成功');
|
2026-01-21 13:29:39 +08:00
|
|
|
|
if (selectedROI?.id === roiId) {
|
|
|
|
|
|
setSelectedROI(null);
|
|
|
|
|
|
setDrawerVisible(false);
|
|
|
|
|
|
}
|
2026-01-20 17:46:32 +08:00
|
|
|
|
fetchROIs();
|
2026-01-21 13:29:39 +08:00
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
message.error(`删除失败: ${err.response?.data?.detail || '未知错误'}`);
|
2026-01-20 17:46:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getROIStrokeColor = (rule: string) => {
|
|
|
|
|
|
return rule === 'intrusion' ? '#ff4d4f' : '#faad14';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderROI = (roi: ROI) => {
|
|
|
|
|
|
const points = roi.points.flat();
|
|
|
|
|
|
const color = getROIStrokeColor(roi.rule);
|
2026-01-21 13:29:39 +08:00
|
|
|
|
const isSelected = selectedROI?.id === roi.id;
|
2026-01-20 17:46:32 +08:00
|
|
|
|
|
2026-01-21 13:29:39 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<Line
|
|
|
|
|
|
key={roi.id}
|
|
|
|
|
|
points={points}
|
|
|
|
|
|
closed={roi.type === 'polygon'}
|
|
|
|
|
|
stroke={isSelected ? '#1890ff' : color}
|
|
|
|
|
|
strokeWidth={isSelected ? 3 : 2}
|
|
|
|
|
|
fill={`${color}33`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSelectedROI(roi);
|
|
|
|
|
|
form.setFieldsValue({
|
|
|
|
|
|
name: roi.name,
|
|
|
|
|
|
roi_type: roi.type,
|
|
|
|
|
|
rule_type: roi.rule,
|
|
|
|
|
|
threshold_sec: roi.threshold_sec,
|
|
|
|
|
|
confirm_sec: roi.confirm_sec,
|
|
|
|
|
|
enabled: roi.enabled,
|
|
|
|
|
|
});
|
2026-01-22 17:26:28 +08:00
|
|
|
|
setWorkingHoursList(roi.working_hours?.map((wh: WorkingHours) => ({
|
|
|
|
|
|
start: wh.start ? dayjs().hour(wh.start[0]).minute(wh.start[1]) : null,
|
|
|
|
|
|
end: wh.end ? dayjs().hour(wh.end[0]).minute(wh.end[1]) : null,
|
|
|
|
|
|
})) || []);
|
2026-01-21 13:29:39 +08:00
|
|
|
|
setDrawerVisible(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMouseEnter={(e) => {
|
|
|
|
|
|
const container = e.target.getStage()?.container();
|
|
|
|
|
|
if (container) {
|
|
|
|
|
|
container.style.cursor = 'pointer';
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMouseLeave={(e) => {
|
|
|
|
|
|
const container = e.target.getStage()?.container();
|
|
|
|
|
|
if (container) {
|
|
|
|
|
|
container.style.cursor = 'default';
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-01-20 17:46:32 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Card>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<Space style={{ marginBottom: 16 }} wrap>
|
2026-01-20 17:46:32 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
placeholder="选择摄像头"
|
|
|
|
|
|
value={selectedCamera}
|
2026-01-21 13:29:39 +08:00
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
setSelectedCamera(value);
|
|
|
|
|
|
setSelectedROI(null);
|
|
|
|
|
|
}}
|
2026-01-20 17:46:32 +08:00
|
|
|
|
style={{ width: 200 }}
|
|
|
|
|
|
options={cameras.map((c) => ({ label: c.name, value: c.id }))}
|
|
|
|
|
|
/>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<Button onClick={fetchSnapshot}>刷新截图</Button>
|
|
|
|
|
|
{isDrawing ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button danger onClick={handleCancelDrawing}>取消绘制</Button>
|
|
|
|
|
|
<Button type="primary" disabled={tempPoints.length < 3} onClick={handleStageDblClick}>
|
|
|
|
|
|
完成绘制 ({tempPoints.length} 点)
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Button type="primary" onClick={handleAddROI}>添加ROI</Button>
|
|
|
|
|
|
)}
|
2026-01-20 17:46:32 +08:00
|
|
|
|
</Space>
|
|
|
|
|
|
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<div className="roi-editor-container" style={{ display: 'flex', gap: 16, flexDirection: 'row' }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
background: '#f0f0f0',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
minHeight: 500,
|
|
|
|
|
|
border: isDrawing ? '2px solid #1890ff' : '1px solid #d9d9d9',
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
position: 'relative'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{isDrawing && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 10,
|
|
|
|
|
|
left: 10,
|
|
|
|
|
|
zIndex: 10,
|
|
|
|
|
|
background: 'rgba(24, 144, 255, 0.9)',
|
|
|
|
|
|
color: 'white',
|
|
|
|
|
|
padding: '8px 16px',
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
fontSize: 14
|
|
|
|
|
|
}}>
|
|
|
|
|
|
绘制模式 - 点击添加点,双击完成
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-01-20 17:46:32 +08:00
|
|
|
|
{snapshot ? (
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<Stage
|
|
|
|
|
|
width={imageDim.width}
|
|
|
|
|
|
height={imageDim.height}
|
|
|
|
|
|
ref={stageRef}
|
|
|
|
|
|
onClick={handleStageClick}
|
|
|
|
|
|
onDblClick={handleStageDblClick}
|
|
|
|
|
|
style={{ cursor: isDrawing ? 'crosshair' : 'default' }}
|
|
|
|
|
|
>
|
2026-01-20 17:46:32 +08:00
|
|
|
|
<Layer>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
{backgroundImage && (
|
|
|
|
|
|
<Rect
|
|
|
|
|
|
x={0}
|
|
|
|
|
|
y={0}
|
|
|
|
|
|
width={imageDim.width}
|
|
|
|
|
|
height={imageDim.height}
|
|
|
|
|
|
fillPatternImage={backgroundImage}
|
|
|
|
|
|
fillPatternOffset={{ x: 0, y: 0 }}
|
|
|
|
|
|
fillPatternScale={{ x: 1, y: 1 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-01-20 17:46:32 +08:00
|
|
|
|
{rois.map(renderROI)}
|
2026-01-21 13:29:39 +08:00
|
|
|
|
{isDrawing && tempPoints.length > 0 && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Line
|
|
|
|
|
|
points={tempPoints.flat()}
|
|
|
|
|
|
stroke="#1890ff"
|
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
|
dash={[5, 5]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{tempPoints.map((point, idx) => (
|
|
|
|
|
|
<Circle
|
|
|
|
|
|
key={idx}
|
|
|
|
|
|
x={point[0]}
|
|
|
|
|
|
y={point[1]}
|
|
|
|
|
|
radius={5}
|
|
|
|
|
|
fill="#1890ff"
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{tempPoints.map((point, idx) => (
|
|
|
|
|
|
<KonvaText
|
|
|
|
|
|
key={`label-${idx}`}
|
|
|
|
|
|
x={point[0] + 10}
|
|
|
|
|
|
y={point[1] - 10}
|
|
|
|
|
|
text={`${idx + 1}`}
|
|
|
|
|
|
fontSize={14}
|
|
|
|
|
|
fill="#1890ff"
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-01-20 17:46:32 +08:00
|
|
|
|
</Layer>
|
|
|
|
|
|
</Stage>
|
|
|
|
|
|
) : (
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<div style={{ color: '#999' }}>加载中...</div>
|
2026-01-20 17:46:32 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<div style={{ width: 280, flexShrink: 0 }}>
|
|
|
|
|
|
<Card title="ROI列表" size="small" bodyStyle={{ maxHeight: 500, overflow: 'auto' }}>
|
|
|
|
|
|
{rois.length === 0 ? (
|
|
|
|
|
|
<div style={{ color: '#999', textAlign: 'center', padding: 20 }}>
|
|
|
|
|
|
暂无ROI区域,点击"添加ROI"开始绘制
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
rois.map((roi) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={roi.id}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: 8,
|
|
|
|
|
|
marginBottom: 8,
|
|
|
|
|
|
background: selectedROI?.id === roi.id ? '#e6f7ff' : '#fafafa',
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
border: selectedROI?.id === roi.id ? '2px solid #1890ff' : '1px solid #d9d9d9',
|
|
|
|
|
|
}}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSelectedROI(roi);
|
|
|
|
|
|
form.setFieldsValue({
|
|
|
|
|
|
name: roi.name,
|
|
|
|
|
|
roi_type: roi.type,
|
|
|
|
|
|
rule_type: roi.rule,
|
|
|
|
|
|
threshold_sec: roi.threshold_sec,
|
|
|
|
|
|
confirm_sec: roi.confirm_sec,
|
|
|
|
|
|
enabled: roi.enabled,
|
|
|
|
|
|
});
|
2026-01-22 17:26:28 +08:00
|
|
|
|
setWorkingHoursList(roi.working_hours?.map((wh: WorkingHours) => ({
|
|
|
|
|
|
start: wh.start ? dayjs().hour(wh.start[0]).minute(wh.start[1]) : null,
|
|
|
|
|
|
end: wh.end ? dayjs().hour(wh.end[0]).minute(wh.end[1]) : null,
|
|
|
|
|
|
})) || []);
|
2026-01-21 13:29:39 +08:00
|
|
|
|
setDrawerVisible(true);
|
2026-01-20 17:46:32 +08:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>{roi.name}</div>
|
|
|
|
|
|
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
|
|
|
|
|
|
类型: {roi.type === 'polygon' ? '多边形' : '线段'} | 规则: {roi.rule === 'intrusion' ? '入侵检测' : '离岗检测'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Space size={4}>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="link"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
danger
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleDeleteROI(roi.id);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
删除
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
2026-01-20 17:46:32 +08:00
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Drawer
|
2026-01-21 13:29:39 +08:00
|
|
|
|
title={selectedROI ? `编辑ROI - ${selectedROI.name}` : '编辑ROI'}
|
2026-01-20 17:46:32 +08:00
|
|
|
|
open={drawerVisible}
|
2026-01-21 13:29:39 +08:00
|
|
|
|
onClose={() => {
|
|
|
|
|
|
setDrawerVisible(false);
|
|
|
|
|
|
setSelectedROI(null);
|
2026-01-22 17:26:28 +08:00
|
|
|
|
setWorkingHoursList([]);
|
2026-01-21 13:29:39 +08:00
|
|
|
|
}}
|
2026-01-20 17:46:32 +08:00
|
|
|
|
width={400}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Form form={form} layout="vertical" onFinish={handleSaveROI}>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
|
|
|
|
|
<Input placeholder="例如:入口入侵区域" />
|
2026-01-20 17:46:32 +08:00
|
|
|
|
</Form.Item>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<Form.Item name="roi_type" label="类型" rules={[{ required: true }]}>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ label: '多边形区域', value: 'polygon' },
|
|
|
|
|
|
{ label: '线段', value: 'line' },
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
2026-01-20 17:46:32 +08:00
|
|
|
|
</Form.Item>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<Form.Item name="rule_type" label="检测规则" rules={[{ required: true }]}>
|
2026-01-20 17:46:32 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
options={[
|
2026-01-21 13:29:39 +08:00
|
|
|
|
{ label: '周界入侵检测', value: 'intrusion' },
|
2026-01-20 17:46:32 +08:00
|
|
|
|
{ label: '离岗检测', value: 'leave_post' },
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Form.Item>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
{selectedROI?.rule === 'leave_post' && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Form.Item name="threshold_sec" label="超时时间(秒)" rules={[{ required: true }]}>
|
|
|
|
|
|
<InputNumber min={60} style={{ width: '100%' }} />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item name="confirm_sec" label="确认时间(秒)" rules={[{ required: true }]}>
|
|
|
|
|
|
<InputNumber min={5} style={{ width: '100%' }} />
|
|
|
|
|
|
</Form.Item>
|
2026-01-22 16:44:26 +08:00
|
|
|
|
<Divider>工作时间配置(可选)</Divider>
|
2026-01-22 17:26:28 +08:00
|
|
|
|
<div>
|
|
|
|
|
|
{workingHoursList.map((item, index) => (
|
|
|
|
|
|
<Space key={index} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
|
|
|
|
|
|
<Form.Item label={index === 0 ? '时间段' : ''} style={{ marginBottom: 0 }}>
|
|
|
|
|
|
<TimePicker.RangePicker
|
|
|
|
|
|
format="HH:mm"
|
|
|
|
|
|
value={item.start && item.end ? [item.start, item.end] : null}
|
|
|
|
|
|
onChange={(dates) => {
|
|
|
|
|
|
if (dates && Array.isArray(dates) && dates.length >= 2 && dates[0] && dates[1]) {
|
|
|
|
|
|
updateWorkingHoursRange(index, dates[0], dates[1]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
updateWorkingHoursRange(index, null, null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="link"
|
|
|
|
|
|
danger
|
|
|
|
|
|
onClick={() => removeWorkingHours(index)}
|
|
|
|
|
|
>
|
|
|
|
|
|
删除
|
2026-01-22 16:44:26 +08:00
|
|
|
|
</Button>
|
2026-01-22 17:26:28 +08:00
|
|
|
|
</Space>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<Button type="dashed" onClick={addWorkingHours} block>
|
|
|
|
|
|
添加时间段
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-01-22 16:44:26 +08:00
|
|
|
|
<Form.Item style={{ fontSize: 12, color: '#999' }}>
|
|
|
|
|
|
不配置工作时间则使用系统全局设置
|
|
|
|
|
|
</Form.Item>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Form.Item name="enabled" label="启用状态" valuePropName="checked">
|
|
|
|
|
|
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
2026-01-20 17:46:32 +08:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item>
|
|
|
|
|
|
<Space>
|
|
|
|
|
|
<Button type="primary" htmlType="submit">
|
|
|
|
|
|
保存
|
|
|
|
|
|
</Button>
|
2026-01-21 13:29:39 +08:00
|
|
|
|
<Button onClick={() => {
|
|
|
|
|
|
setDrawerVisible(false);
|
|
|
|
|
|
setSelectedROI(null);
|
2026-01-22 17:26:28 +08:00
|
|
|
|
setWorkingHoursList([]);
|
2026-01-21 13:29:39 +08:00
|
|
|
|
}}>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
2026-01-20 17:46:32 +08:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default ROIEditor;
|