305 lines
9.1 KiB
TypeScript
305 lines
9.1 KiB
TypeScript
|
|
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;
|