Files
Security_AI_integrated/frontend/src/pages/ROIEditor.tsx
16337 3af7a0f805
Some checks failed
Python Test / test (push) Has been cancelled
fix(roi-editor):修复 ROI 编辑器中时间段选择器(TimePicker.RangePicker)因连续调用两次状态更新导致的清空问题。
- 新增 `updateWorkingHoursRange` 批量更新函数,将 start/end 作为原子操作同步更新
- 在 onChange 回调中添加 `Array.isArray(dates) && dates.length >= 2` 类型校验
- 避免 React 异步 setState 冲突导致 workingHoursList 意外重置
2026-01-22 17:26:28 +08:00

549 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useRef } from 'react';
import { Card, Button, Space, Select, message, Drawer, Form, Input, InputNumber, Switch, TimePicker, Divider } from 'antd';
import { Stage, Layer, Rect, Line, Circle, Text as KonvaText } from 'react-konva';
import { RangePickerProps } from 'antd/es/date-picker';
import axios from 'axios';
import dayjs from 'dayjs';
interface WorkingHours {
start: number[];
end: number[];
}
interface ROI {
id: number;
name: string;
type: string;
points: number[][];
rule: string;
enabled: boolean;
threshold_sec: number;
confirm_sec: number;
return_sec: number;
working_hours: WorkingHours[] | null;
}
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();
const [workingHoursList, setWorkingHoursList] = useState<{start: dayjs.Dayjs | null, end: dayjs.Dayjs | null}[]>([]);
const [isDrawing, setIsDrawing] = useState(false);
const [tempPoints, setTempPoints] = useState<number[][]>([]);
const [backgroundImage, setBackgroundImage] = useState<HTMLImageElement | null>(null);
const stageRef = useRef<any>(null);
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;
});
};
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]);
useEffect(() => {
if (snapshot) {
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 });
setBackgroundImage(img);
};
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);
} 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 {
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()],
}));
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,
working_hours: workingHours,
enabled: values.enabled,
});
message.success('保存成功');
setDrawerVisible(false);
setWorkingHoursList([]);
fetchROIs();
} catch (err: any) {
message.error(`保存失败: ${err.response?.data?.detail || '未知错误'}`);
}
};
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;
}
const roi_id = `roi_${Date.now()}`;
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,
working_hours: [],
})
.then(() => {
message.success('ROI添加成功');
setIsDrawing(false);
setTempPoints([]);
fetchROIs();
})
.catch((err) => {
message.error(`添加失败: ${err.response?.data?.detail || '未知错误'}`);
setIsDrawing(false);
setTempPoints([]);
});
};
const handleCancelDrawing = () => {
setIsDrawing(false);
setTempPoints([]);
message.info('已取消绘制');
};
const handleDeleteROI = async (roiId: number) => {
if (!selectedCamera) return;
try {
await axios.delete(`/api/camera/${selectedCamera}/roi/${roiId}`);
message.success('删除成功');
if (selectedROI?.id === roiId) {
setSelectedROI(null);
setDrawerVisible(false);
}
fetchROIs();
} catch (err: any) {
message.error(`删除失败: ${err.response?.data?.detail || '未知错误'}`);
}
};
const getROIStrokeColor = (rule: string) => {
return rule === 'intrusion' ? '#ff4d4f' : '#faad14';
};
const renderROI = (roi: ROI) => {
const points = roi.points.flat();
const color = getROIStrokeColor(roi.rule);
const isSelected = selectedROI?.id === roi.id;
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,
});
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,
})) || []);
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';
}
}}
/>
);
};
return (
<div>
<Card>
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="选择摄像头"
value={selectedCamera}
onChange={(value) => {
setSelectedCamera(value);
setSelectedROI(null);
}}
style={{ width: 200 }}
options={cameras.map((c) => ({ label: c.name, value: c.id }))}
/>
<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>
)}
</Space>
<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>
)}
{snapshot ? (
<Stage
width={imageDim.width}
height={imageDim.height}
ref={stageRef}
onClick={handleStageClick}
onDblClick={handleStageDblClick}
style={{ cursor: isDrawing ? 'crosshair' : 'default' }}
>
<Layer>
{backgroundImage && (
<Rect
x={0}
y={0}
width={imageDim.width}
height={imageDim.height}
fillPatternImage={backgroundImage}
fillPatternOffset={{ x: 0, y: 0 }}
fillPatternScale={{ x: 1, y: 1 }}
/>
)}
{rois.map(renderROI)}
{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"
/>
))}
</>
)}
</Layer>
</Stage>
) : (
<div style={{ color: '#999' }}>...</div>
)}
</div>
<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,
});
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,
})) || []);
setDrawerVisible(true);
}}
>
<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>
))
)}
</Card>
</div>
</div>
</Card>
<Drawer
title={selectedROI ? `编辑ROI - ${selectedROI.name}` : '编辑ROI'}
open={drawerVisible}
onClose={() => {
setDrawerVisible(false);
setSelectedROI(null);
setWorkingHoursList([]);
}}
width={400}
>
<Form form={form} layout="vertical" onFinish={handleSaveROI}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:入口入侵区域" />
</Form.Item>
<Form.Item name="roi_type" label="类型" rules={[{ required: true }]}>
<Select
options={[
{ label: '多边形区域', value: 'polygon' },
{ label: '线段', value: 'line' },
]}
/>
</Form.Item>
<Form.Item name="rule_type" label="检测规则" rules={[{ required: true }]}>
<Select
options={[
{ label: '周界入侵检测', value: 'intrusion' },
{ label: '离岗检测', value: 'leave_post' },
]}
/>
</Form.Item>
{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>
<Divider></Divider>
<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)}
>
</Button>
</Space>
))}
<Button type="dashed" onClick={addWorkingHours} block>
</Button>
</div>
<Form.Item style={{ fontSize: 12, color: '#999' }}>
使
</Form.Item>
</>
)}
<Form.Item name="enabled" label="启用状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button onClick={() => {
setDrawerVisible(false);
setSelectedROI(null);
setWorkingHoursList([]);
}}>
</Button>
</Space>
</Form.Item>
</Form>
</Drawer>
</div>
);
};
export default ROIEditor;