feat: Implement camera management page and device location updates, including API, typings, and routing.

This commit is contained in:
2026-01-26 11:18:36 +07:00
parent 17d246d5ef
commit 1f35516e44
10 changed files with 741 additions and 21 deletions

View File

@@ -21,6 +21,9 @@ export default {
'master.devices.create.error': 'Device creation failed',
'master.devices.groups': 'Groups',
'master.devices.groups.required': 'Please select groups',
// Update info device
'master.devices.update.success': 'Updated successfully',
'master.devices.update.error': 'Update failed',
// Edit device modal
'master.devices.update.title': 'Update device',
'master.devices.ok': 'OK',
@@ -32,4 +35,13 @@ export default {
'master.devices.address': 'Address',
'master.devices.address.placeholder': 'Enter address',
'master.devices.address.required': 'Please enter address',
// Location modal
'master.devices.location.title': 'Update location',
'master.devices.location.latitude': 'Latitude',
'master.devices.location.latitude.required': 'Please enter latitude',
'master.devices.location.longitude': 'Longitude',
'master.devices.location.longitude.required': 'Please enter longitude',
'master.devices.location.placeholder': 'Enter data',
'master.devices.location.update.success': 'Location updated successfully',
'master.devices.location.update.error': 'Location update failed',
};

View File

@@ -3,6 +3,7 @@ export default {
'master.thing.external_id': 'External ID',
'master.thing.group': 'Nhóm',
'master.thing.address': 'Địa chỉ',
// Device translations
'master.devices.title': 'Quản lý thiết bị',
'master.devices.name': 'Tên',
@@ -20,6 +21,9 @@ export default {
'master.devices.create.error': 'Tạo thiết bị lỗi',
'master.devices.groups': 'Đơn vị',
'master.devices.groups.required': 'Vui lòng chọn đơn vị',
// Update info device
'master.devices.update.success': 'Cập nhật thành công',
'master.devices.update.error': 'Cập nhật thất bại',
// Edit device modal
'master.devices.update.title': 'Cập nhật thiết bị',
'master.devices.ok': 'Đồng ý',
@@ -31,4 +35,13 @@ export default {
'master.devices.address': 'Địa chỉ',
'master.devices.address.placeholder': 'Nhập địa chỉ',
'master.devices.address.required': 'Vui lòng nhập địa chỉ',
// Location modal
'master.devices.location.title': 'Cập nhật vị trí',
'master.devices.location.latitude': 'Vị độ',
'master.devices.location.latitude.required': 'Vui lòng nhập vị độ',
'master.devices.location.longitude': 'Kinh độ',
'master.devices.location.longitude.required': 'Vui lòng nhập kinh độ',
'master.devices.location.placeholder': 'Nhập dữ liệu',
'master.devices.location.update.success': 'Cập nhật vị trí thành công',
'master.devices.location.update.error': 'Cập nhật vị trí thất bại',
};

View File

@@ -0,0 +1,483 @@
import { apiSearchThings } from '@/services/master/ThingController';
import {
ArrowLeftOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useParams } from '@umijs/max';
import {
Button,
Card,
Checkbox,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Spin,
Table,
Typography,
} from 'antd';
import { useEffect, useState } from 'react';
const { Text } = Typography;
// Camera types
const CAMERA_TYPES = [
{ label: 'HIKVISION', value: 'HIKVISION' },
{ label: 'DAHUA', value: 'DAHUA' },
{ label: 'GENERIC', value: 'GENERIC' },
];
// Recording modes
const RECORDING_MODES = [
{ label: 'Theo cảnh báo', value: 'alarm' },
{ label: 'Liên tục', value: 'continuous' },
{ label: 'Thủ công', value: 'manual' },
];
// Alert types for configuration
const ALERT_TYPES = [
{ id: 'motion', name: 'Chuyển Động có cảnh báo' },
{ id: 'smoke', name: 'Khói có cảnh báo' },
{ id: 'door', name: 'Cửa có cảnh báo' },
{ id: 'ac1_high', name: 'Điện AC 1 cao' },
{ id: 'ac1_low', name: 'Điện AC 1 thấp' },
{ id: 'ac1_lost', name: 'Điện AC 1 mất' },
{ id: 'load_high', name: 'Điện tải cao' },
{ id: 'load_low', name: 'Điện tải thấp' },
{ id: 'load_lost', name: 'Điện tải mất' },
{ id: 'grid_high', name: 'Điện lưới cao' },
{ id: 'grid_low', name: 'Điện lưới thấp' },
{ id: 'grid_lost', name: 'Điện lưới mất' },
{ id: 'ac1_on_error', name: 'Điều hòa 1 bật lỗi' },
{ id: 'ac1_off_error', name: 'Điều hòa 1 tắt lỗi' },
{ id: 'ac1_has_error', name: 'Điều hòa 1 có thể lỗi' },
{ id: 'ac2_on_error', name: 'Điều hòa 2 bật lỗi' },
{ id: 'ac2_off_error', name: 'Điều hòa 2 tắt lỗi' },
{ id: 'ac2_has_error', name: 'Điều hòa 2 điều hòa có thể lỗi' },
{ id: 'room_temp_high', name: 'Nhiệt độ phòng máy nhiệt độ phòng máy cao' },
{ id: 'rectifier_error', name: 'Rectifier bật lỗi' },
{ id: 'meter_volt_high', name: 'Công tơ điện điện áp cao' },
{ id: 'meter_volt_low', name: 'Công tơ điện điện áp thấp' },
{ id: 'meter_lost', name: 'Công tơ điện mất điện áp' },
{ id: 'lithium_volt_low', name: 'Pin lithium điện áp thấp' },
{ id: 'lithium_temp_high', name: 'Pin lithium nhiệt độ cao' },
{ id: 'lithium_capacity_low', name: 'Pin lithium dung lượng thấp' },
];
// Camera interface
interface Camera {
id: string;
name: string;
type: string;
ipAddress: string;
}
interface CameraFormValues {
name: string;
type: string;
account: string;
password: string;
ipAddress: string;
rtspPort: number;
httpPort: number;
stream: number;
channel: number;
}
const CameraConfigPage = () => {
const { thingId } = useParams<{ thingId: string }>();
const [form] = Form.useForm<CameraFormValues>();
const [isModalVisible, setIsModalVisible] = useState(false);
const [cameras, setCameras] = useState<Camera[]>([]);
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([
'motion',
'smoke',
'door',
]);
const [recordingMode, setRecordingMode] = useState('alarm');
const [thingName, setThingName] = useState<string>('');
const [loading, setLoading] = useState(true);
// Fetch thing info on mount
useEffect(() => {
const fetchThingInfo = async () => {
if (!thingId) return;
try {
setLoading(true);
const response = await apiSearchThings({
offset: 0,
limit: 1,
id: thingId,
});
if (response?.things && response.things.length > 0) {
setThingName(response.things[0].name || thingId);
} else {
setThingName(thingId);
}
} catch (error) {
console.error('Failed to fetch thing info:', error);
setThingName(thingId);
} finally {
setLoading(false);
}
};
fetchThingInfo();
}, [thingId]);
const handleBack = () => {
history.push('/manager/devices');
};
const handleOpenModal = () => {
form.resetFields();
form.setFieldsValue({
type: 'HIKVISION',
rtspPort: 554,
httpPort: 80,
stream: 0,
channel: 0,
});
setIsModalVisible(true);
};
const handleCloseModal = () => {
setIsModalVisible(false);
form.resetFields();
};
const handleSubmitCamera = async () => {
try {
const values = await form.validateFields();
console.log('Camera values:', values);
// TODO: Call API to create camera
setCameras([
...cameras,
{
id: String(cameras.length + 1),
name: values.name,
type: values.type,
ipAddress: values.ipAddress,
},
]);
handleCloseModal();
} catch (error) {
console.error('Validation failed:', error);
}
};
const handleAlertToggle = (alertId: string) => {
if (selectedAlerts.includes(alertId)) {
setSelectedAlerts(selectedAlerts.filter((id) => id !== alertId));
} else {
setSelectedAlerts([...selectedAlerts, alertId]);
}
};
const handleClearAlerts = () => {
setSelectedAlerts([]);
};
const handleSubmitAlerts = () => {
console.log('Submit alerts:', selectedAlerts);
// TODO: Call API to save alert configuration
};
const columns = [
{
title: '',
dataIndex: 'checkbox',
width: 50,
render: () => <Checkbox />,
},
{
title: 'Tên',
dataIndex: 'name',
key: 'name',
render: (text: string) => <a style={{ color: '#1890ff' }}>{text}</a>,
},
{
title: 'Loại',
dataIndex: 'type',
key: 'type',
},
{
title: 'Địa chỉ IP',
dataIndex: 'ipAddress',
key: 'ipAddress',
},
{
title: 'Thao tác',
key: 'action',
render: () => <Button size="small" icon={<EditOutlined />} />,
},
];
return (
<Spin spinning={loading}>
<PageContainer
header={{
title: (
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={handleBack}
/>
<span>{thingName || 'Loading...'}</span>
</Space>
),
}}
>
<Row gutter={24}>
{/* Left Column - Camera Table */}
<Col xs={24} md={10} lg={8}>
<Card bodyStyle={{ padding: 16 }}>
<Space style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleOpenModal}
>
Tạo mới camera
</Button>
<Button icon={<ReloadOutlined />} />
<Button icon={<SettingOutlined />} />
<Button icon={<DeleteOutlined />} />
</Space>
<Table
dataSource={cameras}
columns={columns}
rowKey="id"
size="small"
pagination={{
size: 'small',
showTotal: (total, range) =>
`${range[0]}-${range[1]} trên ${total} mặt hàng`,
pageSize: 10,
}}
/>
</Card>
</Col>
{/* Right Column - Alert Configuration */}
<Col xs={24} md={14} lg={16}>
<Card bodyStyle={{ padding: 16 }}>
{/* Recording Mode */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
Ghi dữ liệu camera
</Text>
<Select
value={recordingMode}
onChange={setRecordingMode}
options={RECORDING_MODES}
style={{ width: 200 }}
/>
</div>
{/* Alert List */}
<div>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
Danh sách cảnh báo
</Text>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
padding: '8px 12px',
background: '#f5f5f5',
borderRadius: 4,
}}
>
<Text type="secondary">
đã chọn {selectedAlerts.length} mục
</Text>
<Button type="link" onClick={handleClearAlerts}>
Xóa
</Button>
</div>
{/* Alert Cards Grid */}
<Row gutter={[12, 12]}>
{ALERT_TYPES.map((alert) => {
const isSelected = selectedAlerts.includes(alert.id);
return (
<Col xs={12} sm={8} md={6} lg={4} xl={4} key={alert.id}>
<Card
size="small"
hoverable
onClick={() => handleAlertToggle(alert.id)}
style={{
cursor: 'pointer',
borderColor: isSelected ? '#1890ff' : '#d9d9d9',
borderWidth: isSelected ? 2 : 1,
background: isSelected ? '#e6f7ff' : '#fff',
height: 80,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
bodyStyle={{
padding: 8,
textAlign: 'center',
width: '100%',
}}
>
<Text
style={{
fontSize: 12,
color: isSelected ? '#1890ff' : 'inherit',
wordBreak: 'break-word',
}}
>
{alert.name}
</Text>
</Card>
</Col>
);
})}
</Row>
{/* Submit Button */}
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Button type="primary" onClick={handleSubmitAlerts}>
Gửi đi
</Button>
</div>
</div>
</Card>
</Col>
</Row>
{/* Create Camera Modal */}
<Modal
title="Tạo mới"
open={isModalVisible}
onCancel={handleCloseModal}
footer={[
<Button key="cancel" onClick={handleCloseModal}>
Hủy
</Button>,
<Button key="submit" type="primary" onClick={handleSubmitCamera}>
Đng ý
</Button>,
]}
width={500}
>
<Form
form={form}
layout="vertical"
initialValues={{
type: 'HIKVISION',
rtspPort: 554,
httpPort: 80,
stream: 0,
channel: 0,
}}
>
<Form.Item
label="Tên"
name="name"
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
>
<Input placeholder="nhập dữ liệu" />
</Form.Item>
<Form.Item
label="Loại"
name="type"
rules={[{ required: true, message: 'Vui lòng chọn loại' }]}
>
<Select options={CAMERA_TYPES} />
</Form.Item>
<Form.Item
label="Tài khoản"
name="account"
rules={[{ required: true, message: 'Vui lòng nhập tài khoản' }]}
>
<Input placeholder="nhập tài khoản" />
</Form.Item>
<Form.Item
label="Mật khẩu"
name="password"
rules={[{ required: true, message: 'Vui lòng nhập mật khẩu' }]}
>
<Input.Password placeholder="nhập mật khẩu" />
</Form.Item>
<Form.Item
label="Địa chỉ IP"
name="ipAddress"
rules={[{ required: true, message: 'Vui lòng nhập địa chỉ IP' }]}
>
<Input placeholder="192.168.1.10" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="Cổng RTSP"
name="rtspPort"
rules={[
{ required: true, message: 'Vui lòng nhập cổng RTSP' },
]}
>
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Cổng HTTP"
name="httpPort"
rules={[
{ required: true, message: 'Vui lòng nhập cổng HTTP' },
]}
>
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="Luồng"
name="stream"
rules={[{ required: true, message: 'Vui lòng nhập luồng' }]}
>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Kênh"
name="channel"
rules={[{ required: true, message: 'Vui lòng nhập kênh' }]}
>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</PageContainer>
</Spin>
);
};
export default CameraConfigPage;

View File

@@ -6,11 +6,7 @@ interface Props {
visible: boolean;
device: MasterModel.Thing | null;
onCancel: () => void;
onSubmit: (values: {
name: string;
external_id: string;
address?: string;
}) => void;
onSubmit: (values: MasterModel.Thing) => void;
}
const EditDeviceModal: React.FC<Props> = ({
@@ -34,6 +30,24 @@ const EditDeviceModal: React.FC<Props> = ({
}
}, [device, form]);
const handleFinish = (values: {
name: string;
external_id: string;
address?: string;
}) => {
const payload: MasterModel.Thing = {
...device,
name: values.name,
metadata: {
...(device?.metadata || {}),
external_id: values.external_id,
address: values.address,
},
};
onSubmit(payload);
};
return (
<Modal
title={intl.formatMessage({
@@ -53,7 +67,12 @@ const EditDeviceModal: React.FC<Props> = ({
})}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={onSubmit} preserve={false}>
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
preserve={false}
>
<Form.Item
name="name"
label={intl.formatMessage({

View File

@@ -0,0 +1,121 @@
import { useIntl } from '@umijs/max';
import { Form, Input, Modal } from 'antd';
import React, { useEffect } from 'react';
interface Props {
visible: boolean;
device: MasterModel.Thing | null;
onCancel: () => void;
onSubmit: (values: MasterModel.Thing) => void;
}
const LocationModal: React.FC<Props> = ({
visible,
device,
onCancel,
onSubmit,
}) => {
const [form] = Form.useForm();
const intl = useIntl();
useEffect(() => {
if (device) {
form.setFieldsValue({
lat: device?.metadata?.lat || '',
lng: device?.metadata?.lng || '',
});
} else {
form.resetFields();
}
}, [device, form]);
return (
<Modal
title={intl.formatMessage({
id: 'master.devices.location.title',
defaultMessage: 'Update location',
})}
open={visible}
onCancel={onCancel}
onOk={() => form.submit()}
okText={intl.formatMessage({
id: 'master.devices.ok',
defaultMessage: 'OK',
})}
cancelText={intl.formatMessage({
id: 'master.devices.cancel',
defaultMessage: 'Cancel',
})}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={(values) => {
const payload: MasterModel.Thing = {
id: device?.id,
name: device?.name,
key: device?.key,
metadata: {
...device?.metadata,
lat: values.lat,
lng: values.lng,
},
};
onSubmit(payload);
}}
preserve={false}
>
<Form.Item
name="lat"
label={intl.formatMessage({
id: 'master.devices.location.latitude',
defaultMessage: 'Latitude',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.location.latitude.required',
defaultMessage: 'Please enter latitude',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.location.placeholder',
defaultMessage: 'Enter data',
})}
/>
</Form.Item>
<Form.Item
name="lng"
label={intl.formatMessage({
id: 'master.devices.location.longitude',
defaultMessage: 'Longitude',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.location.longitude.required',
defaultMessage: 'Please enter longitude',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.location.placeholder',
defaultMessage: 'Enter data',
})}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default LocationModal;

View File

@@ -1,7 +1,10 @@
import IconFont from '@/components/IconFont';
import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants';
import { apiSearchThings } from '@/services/master/ThingController';
import {
apiSearchThings,
apiUpdateThing,
} from '@/services/master/ThingController';
import { EditOutlined, EnvironmentOutlined } from '@ant-design/icons';
import {
ActionType,
@@ -9,13 +12,14 @@ import {
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Button, Divider, Grid, Space, Tag, theme } from 'antd';
import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react';
import CreateDevice from './components/CreateDevice';
import EditDeviceModal from './components/EditDeviceModal';
import LocationModal from './components/LocationModal';
const ManagerDevicePage = () => {
const { useBreakpoint } = Grid;
@@ -35,9 +39,14 @@ const ManagerDevicePage = () => {
const [editingDevice, setEditingDevice] = useState<MasterModel.Thing | null>(
null,
);
const [isLocationModalVisible, setIsLocationModalVisible] =
useState<boolean>(false);
const [locationDevice, setLocationDevice] =
useState<MasterModel.Thing | null>(null);
const handleClickAssign = (device: MasterModel.Thing) => {
console.log('Device ', device);
const handleLocation = (device: MasterModel.Thing) => {
setLocationDevice(device);
setIsLocationModalVisible(true);
};
const handleEdit = (device: MasterModel.Thing) => {
@@ -50,13 +59,53 @@ const ManagerDevicePage = () => {
setEditingDevice(null);
};
const handleEditSubmit = async (values: any) => {
// TODO: call update API here if available. For now just simulate success.
console.log('Update values for', editingDevice?.id, values);
messageApi.success('Cập nhật thành công');
setIsEditModalVisible(false);
setEditingDevice(null);
actionRef.current?.reload();
const handleLocationCancel = () => {
setIsLocationModalVisible(false);
setLocationDevice(null);
};
const handleLocationSubmit = async (values: MasterModel.Thing) => {
try {
await apiUpdateThing(values);
messageApi.success(
intl.formatMessage({
id: 'master.devices.location.update.success',
defaultMessage: 'Location updated successfully',
}),
);
setIsLocationModalVisible(false);
setLocationDevice(null);
actionRef.current?.reload();
} catch (error) {
messageApi.error(
intl.formatMessage({
id: 'master.devices.location.update.error',
defaultMessage: 'Location update failed',
}),
);
}
};
const handleEditSubmit = async (values: MasterModel.Thing) => {
try {
await apiUpdateThing(values);
messageApi.success(
intl.formatMessage({
id: 'master.devices.update.success',
defaultMessage: 'Updated successfully',
}),
);
setIsEditModalVisible(false);
setEditingDevice(null);
actionRef.current?.reload();
} catch (error) {
messageApi.error(
intl.formatMessage({
id: 'master.devices.update.error',
defaultMessage: 'Update failed',
}),
);
}
};
const columns: ProColumns<MasterModel.Thing>[] = [
@@ -172,20 +221,21 @@ const ManagerDevicePage = () => {
shape="default"
size="small"
icon={<EnvironmentOutlined />}
// onClick={() => handleClickAssign(device)}
onClick={() => handleLocation(device)}
/>
<Button
shape="default"
size="small"
icon={<IconFont type="icon-camera" />}
// onClick={() => handleClickAssign(device)}
onClick={() => {
history.push(`/manager/devices/${device.id}/camera`);
}}
/>
{device?.metadata?.type === 'gmsv6' && (
<Button
shape="default"
size="small"
icon={<IconFont type="icon-terminal" />}
// onClick={() => handleClickAssign(device)}
/>
)}
</Space>
@@ -202,6 +252,12 @@ const ManagerDevicePage = () => {
onCancel={handleEditCancel}
onSubmit={handleEditSubmit}
/>
<LocationModal
visible={isLocationModalVisible}
device={locationDevice}
onCancel={handleLocationCancel}
onSubmit={handleLocationSubmit}
/>
{contextHolder}
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>

View File

@@ -53,6 +53,14 @@ export async function apiSearchThings(
}
}
export async function apiUpdateThing(value: MasterModel.Thing) {
if (!value.id) throw new Error('Thing id is required');
return request<MasterModel.Thing>(`${API_SHARE_THING}/${value.id}`, {
method: 'PUT',
data: value,
});
}
export async function apiGetThingPolicyByUser(
params: Partial<MasterModel.SearchPaginationBody>,
userId: string,

View File

@@ -30,6 +30,8 @@ declare namespace MasterModel {
state_updated_time?: number;
type?: string;
updated_time?: number;
lat?: string;
lng?: string;
}
interface ThingsResponse<