Files
Security_AI_integrated/frontend/src/pages/ROIEditor.tsx

516 lines
17 KiB
TypeScript
Raw Normal View History

2026-01-20 17:46:32 +08:00
import React, { useEffect, useState, useRef } from 'react';
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';
import { RangePickerProps } from 'antd/es/date-picker';
2026-01-20 17:46:32 +08:00
import axios from 'axios';
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;
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-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);
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 {
const workingHours = values.working_hours?.map((item: any) => ({
start: [item.start.hour(), item.start.minute()],
end: [item.end.hour(), item.end.minute()],
}));
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,
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);
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,
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,
working_hours: roi.working_hours?.map((wh: WorkingHours) => ({
start: dayjs().hour(wh.start[0]).minute(wh.start[1]),
end: dayjs().hour(wh.end[0]).minute(wh.end[1]),
})),
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,
working_hours: roi.working_hours?.map((wh: WorkingHours) => ({
start: dayjs().hour(wh.start[0]).minute(wh.start[1]),
end: dayjs().hour(wh.end[0]).minute(wh.end[1]),
})),
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-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>
<Divider></Divider>
<Form.List name="working_hours">
{(fields, { add, remove }) => (
<div>
{fields.map((field, index) => (
<Space key={field.key} align="baseline" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item
{...field}
label={index === 0 ? '时间段' : ''}
style={{ marginBottom: 0 }}
>
<TimePicker.RangePicker format="HH:mm" />
</Form.Item>
<Button
type="link"
danger
onClick={() => remove(field.name)}
>
</Button>
</Space>
))}
<Button type="dashed" onClick={() => add({ start: null, end: null })} block>
</Button>
</div>
)}
</Form.List>
<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);
}}>
</Button>
2026-01-20 17:46:32 +08:00
</Space>
</Form.Item>
</Form>
</Drawer>
</div>
);
};
export default ROIEditor;