Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bc15192ec | |||
|
|
ea07d0c99e | ||
|
|
ed5751002b | ||
|
|
6d1c085ff7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,3 +14,4 @@
|
||||
.turbopack
|
||||
/dist
|
||||
.DS_Store
|
||||
/wdoc
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
name: 'gms.monitor',
|
||||
icon: 'icon-monitor',
|
||||
path: '/monitor',
|
||||
component: './Slave/GMS/Monitor',
|
||||
component: './Slave/Spole/Monitor',
|
||||
},
|
||||
{
|
||||
...managerRouteBase,
|
||||
|
||||
@@ -24,6 +24,11 @@ const proxyDev: Record<string, any> = {
|
||||
target: target,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/mqtt': {
|
||||
target: target,
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
'/test': {
|
||||
|
||||
@@ -102,7 +102,9 @@ export const handleRequestConfig: RequestConfig = {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
...(token ? { Authorization: `${token}` } : {}),
|
||||
...(token && !options.headers.Authorization
|
||||
? { Authorization: `${token}` }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -86,7 +86,9 @@ export const handleRequestConfig: RequestConfig = {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
...(token ? { Authorization: `${token}` } : {}),
|
||||
...(token && !options.headers.Authorization
|
||||
? { Authorization: `${token}` }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,6 +39,10 @@ export const commonManagerRoutes = [
|
||||
path: '/manager/devices',
|
||||
component: './Manager/Device',
|
||||
},
|
||||
{
|
||||
path: '/manager/devices/:thingId',
|
||||
component: './Manager/Device/Detail',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -86,7 +86,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
|
||||
contentWidth: 'Fluid',
|
||||
navTheme: isDark ? 'realDark' : 'light',
|
||||
splitMenus: true,
|
||||
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_pwy498d2aw.js',
|
||||
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_qeg2471go4g.js',
|
||||
contentStyle: {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFromIconfontCN } from '@ant-design/icons';
|
||||
|
||||
const IconFont = createFromIconfontCN({
|
||||
scriptUrl: '//at.alicdn.com/t/c/font_5096559_pwy498d2aw.js',
|
||||
scriptUrl: '//at.alicdn.com/t/c/font_5096559_qeg2471go4g.js',
|
||||
});
|
||||
|
||||
export default IconFont;
|
||||
|
||||
82
src/components/shared/Alarm/AlarmUnConfirmButton.tsx
Normal file
82
src/components/shared/Alarm/AlarmUnConfirmButton.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { HTTPSTATUS } from '@/constants';
|
||||
import { apiUnconfirmAlarm } from '@/services/master/AlarmController';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Popconfirm } from 'antd';
|
||||
import { MessageInstance } from 'antd/es/message/interface';
|
||||
|
||||
type AlarmUnConfirmButtonProps = {
|
||||
alarm: MasterModel.Alarm;
|
||||
onFinish?: (isReload: boolean) => void;
|
||||
message: MessageInstance;
|
||||
button?: React.ReactNode;
|
||||
};
|
||||
const AlarmUnConfirmButton = ({
|
||||
alarm,
|
||||
message,
|
||||
button,
|
||||
onFinish,
|
||||
}: AlarmUnConfirmButtonProps) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Popconfirm
|
||||
title={intl.formatMessage({
|
||||
id: 'master.alarms.unconfirm.body',
|
||||
defaultMessage: 'Are you sure you want to unconfirm this alarm?',
|
||||
})}
|
||||
okText={intl.formatMessage({
|
||||
id: 'common.yes',
|
||||
defaultMessage: 'Yes',
|
||||
})}
|
||||
cancelText={intl.formatMessage({
|
||||
id: 'common.no',
|
||||
defaultMessage: 'No',
|
||||
})}
|
||||
onConfirm={async () => {
|
||||
const body: MasterModel.ConfirmAlarmRequest = {
|
||||
id: alarm.id,
|
||||
thing_id: alarm.thing_id,
|
||||
time: alarm.time,
|
||||
};
|
||||
try {
|
||||
const resp = await apiUnconfirmAlarm(body);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
message.success({
|
||||
content: intl.formatMessage({
|
||||
id: 'master.alarms.unconfirm.success',
|
||||
defaultMessage: 'Confirm alarm successfully',
|
||||
}),
|
||||
});
|
||||
onFinish?.(true);
|
||||
} else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) {
|
||||
message.warning({
|
||||
content: intl.formatMessage({
|
||||
id: 'master.alarms.not_found',
|
||||
defaultMessage: 'Alarm has expired or does not exist',
|
||||
}),
|
||||
});
|
||||
onFinish?.(true);
|
||||
} else {
|
||||
throw new Error('Failed to confirm alarm');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error when unconfirm alarm: ', error);
|
||||
message.error({
|
||||
content: intl.formatMessage({
|
||||
id: 'master.alarms.unconfirm.fail',
|
||||
defaultMessage: 'Unconfirm alarm failed',
|
||||
}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{button ? (
|
||||
button
|
||||
) : (
|
||||
<Button danger icon={<CloseOutlined />} size="small" />
|
||||
)}
|
||||
</Popconfirm>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlarmUnConfirmButton;
|
||||
158
src/components/shared/DeviceAlarmList.tsx
Normal file
158
src/components/shared/DeviceAlarmList.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE, DURATION_POLLING } from '@/const';
|
||||
import AlarmDescription from '@/pages/Alarm/components/AlarmDescription';
|
||||
import AlarmFormConfirm from '@/pages/Alarm/components/AlarmFormConfirm';
|
||||
import { apiGetAlarms } from '@/services/master/AlarmController';
|
||||
import { CheckOutlined } from '@ant-design/icons';
|
||||
import { ActionType, ProList, ProListMetas } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, message, theme, Typography } from 'antd';
|
||||
import moment from 'moment';
|
||||
import { useRef, useState } from 'react';
|
||||
import AlarmUnConfirmButton from './Alarm/AlarmUnConfirmButton';
|
||||
import { getBadgeStatus, getTitleColorByDeviceStateLevel } from './ThingShared';
|
||||
|
||||
type DeviceAlarmListProps = {
|
||||
thingId: string;
|
||||
};
|
||||
|
||||
const DeviceAlarmList = ({ thingId }: DeviceAlarmListProps) => {
|
||||
const { token } = theme.useToken();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const intl = useIntl();
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [loading, setIsLoading] = useState<boolean>(false);
|
||||
const [alarmConfirmed, setAlarmConfirmed] = useState<
|
||||
MasterModel.Alarm | undefined
|
||||
>(undefined);
|
||||
const columns: ProListMetas<MasterModel.Alarm> = {
|
||||
title: {
|
||||
dataIndex: 'name',
|
||||
render(_, item) {
|
||||
return (
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: getTitleColorByDeviceStateLevel(item.level || 0, token),
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
avatar: {
|
||||
render: (_, item) => getBadgeStatus(item.level || 0),
|
||||
},
|
||||
description: {
|
||||
dataIndex: 'time',
|
||||
render: (_, item) => {
|
||||
return (
|
||||
<>
|
||||
{item.confirmed ? (
|
||||
<AlarmDescription alarm={item} size="small" />
|
||||
) : (
|
||||
<div>{moment.unix(item?.time || 0).format(DATE_TIME_FORMAT)}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
render: (_, entity) => [
|
||||
entity.confirmed ? (
|
||||
<AlarmUnConfirmButton
|
||||
key="unconfirm"
|
||||
alarm={entity}
|
||||
message={messageApi}
|
||||
onFinish={(isReload) => {
|
||||
if (isReload) actionRef.current?.reload();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
key="confirm"
|
||||
icon={<CheckOutlined />}
|
||||
type="dashed"
|
||||
className="green-btn"
|
||||
style={{ color: 'green', borderColor: 'green' }}
|
||||
size="small"
|
||||
onClick={() => setAlarmConfirmed(entity)}
|
||||
></Button>
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<AlarmFormConfirm
|
||||
isOpen={!!alarmConfirmed}
|
||||
setIsOpen={(v) => {
|
||||
if (!v) setAlarmConfirmed(undefined);
|
||||
}}
|
||||
alarm={alarmConfirmed || ({} as MasterModel.Alarm)}
|
||||
trigger={<></>}
|
||||
message={messageApi}
|
||||
onFinish={(isReload) => {
|
||||
if (isReload) actionRef.current?.reload();
|
||||
}}
|
||||
/>
|
||||
<ProList<MasterModel.Alarm>
|
||||
rowKey={(record) => `${record.thing_id}_${record.id}`}
|
||||
bordered
|
||||
actionRef={actionRef}
|
||||
metas={columns}
|
||||
polling={DURATION_POLLING}
|
||||
loading={loading}
|
||||
search={false}
|
||||
dateFormatter="string"
|
||||
cardProps={{
|
||||
bodyStyle: { paddingInline: 16, paddingBlock: 8 },
|
||||
}}
|
||||
pagination={{
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
|
||||
showSizeChanger: false,
|
||||
pageSizeOptions: ['5', '10', '15', '20'],
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]}
|
||||
${intl.formatMessage({
|
||||
id: 'common.paginations.of',
|
||||
defaultMessage: 'of',
|
||||
})}
|
||||
${total} ${intl.formatMessage({
|
||||
id: 'master.alarms.table.pagination',
|
||||
defaultMessage: 'alarms',
|
||||
})}`,
|
||||
}}
|
||||
request={async (params) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { current, pageSize } = params;
|
||||
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
|
||||
const body: MasterModel.SearchAlarmPaginationBody = {
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
thing_id: thingId,
|
||||
dir: 'desc',
|
||||
};
|
||||
const res = await apiGetAlarms(body);
|
||||
return {
|
||||
data: res.alarms,
|
||||
total: res.total,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
data: [],
|
||||
total: 0,
|
||||
success: false,
|
||||
};
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeviceAlarmList;
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
STATUS_SOS,
|
||||
STATUS_WARNING,
|
||||
} from '@/constants';
|
||||
import { Badge } from 'antd';
|
||||
import { Badge, GlobalToken } from 'antd';
|
||||
import IconFont from '../IconFont';
|
||||
|
||||
export const getBadgeStatus = (status: number) => {
|
||||
@@ -30,3 +30,21 @@ export const getBadgeConnection = (online: boolean) => {
|
||||
return <IconFont type="icon-cloud-disconnect" />;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTitleColorByDeviceStateLevel = (
|
||||
level: number,
|
||||
token: GlobalToken,
|
||||
) => {
|
||||
switch (level) {
|
||||
case STATUS_NORMAL:
|
||||
return token.colorSuccess;
|
||||
case STATUS_WARNING:
|
||||
return token.colorWarning;
|
||||
case STATUS_DANGEROUS:
|
||||
return token.colorError;
|
||||
case STATUS_SOS:
|
||||
return token.colorError;
|
||||
default:
|
||||
return token.colorText;
|
||||
}
|
||||
};
|
||||
|
||||
69
src/components/shared/TooltipIconFontButton.tsx
Normal file
69
src/components/shared/TooltipIconFontButton.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ButtonProps } from 'antd';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import React from 'react';
|
||||
import IconFont from '../IconFont';
|
||||
|
||||
type TooltipIconFontButtonProps = {
|
||||
tooltip?: string;
|
||||
iconFontName: string;
|
||||
color?: string;
|
||||
onClick?: () => void;
|
||||
} & Omit<ButtonProps, 'icon' | 'color'>;
|
||||
|
||||
const TooltipIconFontButton: React.FC<TooltipIconFontButtonProps> = ({
|
||||
tooltip,
|
||||
iconFontName,
|
||||
color,
|
||||
onClick,
|
||||
...buttonProps
|
||||
}) => {
|
||||
const wrapperClassName = `tooltip-iconfont-wrapper-${color?.replace(
|
||||
/[^a-zA-Z0-9]/g,
|
||||
'-',
|
||||
)}`;
|
||||
const icon = (
|
||||
<IconFont
|
||||
type={iconFontName}
|
||||
style={{ color: color || 'black' }}
|
||||
className={wrapperClassName}
|
||||
/>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<Button
|
||||
onClick={onClick}
|
||||
icon={icon}
|
||||
{...buttonProps}
|
||||
style={
|
||||
color
|
||||
? {
|
||||
...buttonProps.style,
|
||||
['--icon-button-color' as string]: color,
|
||||
}
|
||||
: buttonProps.style
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
icon={icon}
|
||||
{...buttonProps}
|
||||
style={
|
||||
color
|
||||
? {
|
||||
...buttonProps.style,
|
||||
['--icon-button-color' as string]: color,
|
||||
}
|
||||
: buttonProps.style
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TooltipIconFontButton;
|
||||
@@ -76,3 +76,29 @@
|
||||
.table-row-select tbody tr:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Target icon inside the color wrapper - higher specificity
|
||||
:local(.tooltip-iconfont-wrapper) .iconfont,
|
||||
.tooltip-iconfont-wrapper .iconfont,
|
||||
.tooltip-iconfont-wrapper svg {
|
||||
color: currentcolor !important;
|
||||
}
|
||||
|
||||
// Even more specific - target within Button
|
||||
.ant-btn .tooltip-iconfont-wrapper .iconfont {
|
||||
color: currentcolor !important;
|
||||
}
|
||||
|
||||
// Most aggressive - global selector
|
||||
:global {
|
||||
.ant-btn .tooltip-iconfont-wrapper .iconfont,
|
||||
.ant-btn .tooltip-iconfont-wrapper svg {
|
||||
color: currentcolor !important;
|
||||
}
|
||||
|
||||
// Use CSS variable approach
|
||||
.ant-btn[style*='--icon-button-color'] .iconfont,
|
||||
.ant-btn[style*='--icon-button-color'] svg {
|
||||
color: var(--icon-button-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
|
||||
// Thing API Constants
|
||||
export const API_THINGS_SEARCH = '/api/things/search';
|
||||
export const API_THING_POLICY = '/api/things/policy2';
|
||||
export const API_SHARE_THING = '/api/things';
|
||||
export const API_THING = '/api/things';
|
||||
|
||||
// Group API Constants
|
||||
export const API_GROUPS = '/api/groups';
|
||||
@@ -17,7 +17,7 @@ export const API_GROUP_MEMBERS = '/api/members';
|
||||
export const API_GROUP_CHILDREN = '/api/groups';
|
||||
|
||||
// Log API Constants
|
||||
export const API_LOGS = '/api/reader/channels';
|
||||
export const API_READER = '/api/reader/channels';
|
||||
|
||||
// User API Constants
|
||||
export const API_USERS = '/api/users';
|
||||
|
||||
@@ -2,4 +2,5 @@ export const ROUTE_LOGIN = '/login';
|
||||
export const ROUTER_HOME = '/';
|
||||
export const ROUTE_PROFILE = '/profile';
|
||||
export const ROUTE_MANAGER_USERS = '/manager/users';
|
||||
export const ROUTE_MANAGER_DEVICES = '/manager/devices';
|
||||
export const ROUTE_MANAGER_USERS_PERMISSIONS = 'permissions';
|
||||
|
||||
@@ -4,6 +4,7 @@ import masterGroupEn from './master-group-en';
|
||||
import masterSysLogEn from './master-log-en';
|
||||
import masterMenuEn from './master-menu-en';
|
||||
import masterMenuProfileEn from './master-profile-en';
|
||||
import masterThingDetailEn from './master-thing-detail-en';
|
||||
import masterThingEn from './master-thing-en';
|
||||
import masterUserEn from './master-user-en';
|
||||
export default {
|
||||
@@ -16,4 +17,5 @@ export default {
|
||||
...masterSysLogEn,
|
||||
...masterUserEn,
|
||||
...masterGroupEn,
|
||||
...masterThingDetailEn,
|
||||
};
|
||||
|
||||
1
src/locales/en-US/master/master-thing-detail-en.ts
Normal file
1
src/locales/en-US/master/master-thing-detail-en.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default { 'master.thing.detail.alarmList.title': 'Alarm List' };
|
||||
@@ -8,8 +8,8 @@ export default {
|
||||
'master.devices.title': 'Devices',
|
||||
'master.devices.name': 'Name',
|
||||
'master.devices.name.tip': 'The device name',
|
||||
'master.devices.external_id': 'External ID',
|
||||
'master.devices.external_id.tip': 'The external identifier',
|
||||
'master.devices.external_id': 'Hardware ID',
|
||||
'master.devices.external_id.tip': 'The hardware identifier',
|
||||
'master.devices.type': 'Type',
|
||||
'master.devices.type.tip': 'The device type',
|
||||
'master.devices.online': 'Online',
|
||||
|
||||
3
src/locales/vi-VN/master/master-thing-detail-vi.ts
Normal file
3
src/locales/vi-VN/master/master-thing-detail-vi.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
'master.thing.detail.alarmList.title': 'Danh sách cảnh báo',
|
||||
};
|
||||
@@ -8,7 +8,7 @@ export default {
|
||||
'master.devices.title': 'Quản lý thiết bị',
|
||||
'master.devices.name': 'Tên',
|
||||
'master.devices.name.tip': 'Tên thiết bị',
|
||||
'master.devices.external_id': 'External ID',
|
||||
'master.devices.external_id': 'Hardware ID',
|
||||
'master.devices.external_id.tip': 'Mã định danh bên ngoài',
|
||||
'master.devices.type': 'Loại',
|
||||
'master.devices.type.tip': 'Loại thiết bị',
|
||||
|
||||
@@ -4,6 +4,7 @@ import masterGroupVi from './master-group-vi';
|
||||
import masterSysLogVi from './master-log-vi';
|
||||
import masterMenuVi from './master-menu-vi';
|
||||
import masterProfileVi from './master-profile-vi';
|
||||
import masterThingDetailVi from './master-thing-detail-vi';
|
||||
import masterThingVi from './master-thing-vi';
|
||||
import masterUserVi from './master-user-vi';
|
||||
export default {
|
||||
@@ -16,4 +17,5 @@ export default {
|
||||
...masterSysLogVi,
|
||||
...masterUserVi,
|
||||
...masterGroupVi,
|
||||
...masterThingDetailVi,
|
||||
};
|
||||
|
||||
@@ -4,21 +4,23 @@ import {
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Space, Typography } from 'antd';
|
||||
import { SpaceSize } from 'antd/es/space';
|
||||
import moment from 'moment';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface AlarmDescriptionProps {
|
||||
alarm: MasterModel.Alarm;
|
||||
size?: SpaceSize;
|
||||
}
|
||||
|
||||
const AlarmDescription = ({ alarm }: AlarmDescriptionProps) => {
|
||||
const AlarmDescription = ({ alarm, size = 'large' }: AlarmDescriptionProps) => {
|
||||
if (!alarm?.confirmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space size="large" wrap>
|
||||
<Space size={size} wrap>
|
||||
<Space align="baseline" size={10}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
<Text type="secondary" style={{ fontSize: 15 }}>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { MessageInstance } from 'antd/es/message/interface';
|
||||
import moment from 'moment';
|
||||
import { useRef, useState } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
type AlarmForm = {
|
||||
name: string;
|
||||
@@ -19,22 +19,26 @@ type AlarmForm = {
|
||||
description: string;
|
||||
};
|
||||
type AlarmFormConfirmProps = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
trigger?: React.ReactNode;
|
||||
alarm: MasterModel.Alarm;
|
||||
message: MessageInstance;
|
||||
onFinish?: (reload: boolean) => void;
|
||||
};
|
||||
const AlarmFormConfirm = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
trigger,
|
||||
alarm,
|
||||
message,
|
||||
onFinish,
|
||||
}: AlarmFormConfirmProps) => {
|
||||
const [modalVisit, setModalVisit] = useState(false);
|
||||
const formRef = useRef<ProFormInstance<AlarmForm>>();
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<ModalForm<AlarmForm>
|
||||
open={modalVisit}
|
||||
open={isOpen}
|
||||
formRef={formRef}
|
||||
title={
|
||||
<Flex align="center" justify="center">
|
||||
@@ -45,8 +49,9 @@ const AlarmFormConfirm = ({
|
||||
layout="horizontal"
|
||||
modalProps={{
|
||||
destroyOnHidden: true,
|
||||
// maskStyle: { backgroundColor: 'rgba(0,0,0,0.1)' },
|
||||
}}
|
||||
onOpenChange={setModalVisit}
|
||||
onOpenChange={setIsOpen}
|
||||
request={async () => {
|
||||
return {
|
||||
name: alarm.name ?? '',
|
||||
@@ -95,17 +100,20 @@ const AlarmFormConfirm = ({
|
||||
return true;
|
||||
}}
|
||||
trigger={
|
||||
React.isValidElement(trigger) ? (
|
||||
trigger
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="green"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => setModalVisit(true)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'common.confirm',
|
||||
})}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<ProForm.Group>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import AlarmUnConfirmButton from '@/components/shared/Alarm/AlarmUnConfirmButton';
|
||||
import ThingsFilter from '@/components/shared/ThingFilterModal';
|
||||
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE, HTTPSTATUS } from '@/constants';
|
||||
import {
|
||||
apiGetAlarms,
|
||||
apiUnconfirmAlarm,
|
||||
} from '@/services/master/AlarmController';
|
||||
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE } from '@/constants';
|
||||
import { apiGetAlarms } from '@/services/master/AlarmController';
|
||||
import {
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
@@ -11,7 +9,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||
import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd';
|
||||
import { Button, Flex, message, Progress, Tooltip } from 'antd';
|
||||
import moment from 'moment';
|
||||
import { useRef, useState } from 'react';
|
||||
import AlarmDescription from './components/AlarmDescription';
|
||||
@@ -26,7 +24,7 @@ const AlarmPage = () => {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const { currentUserProfile } = initialState || {};
|
||||
|
||||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState<boolean>(false);
|
||||
const columns: ProColumns<MasterModel.Alarm>[] = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
@@ -202,65 +200,22 @@ const AlarmPage = () => {
|
||||
return (
|
||||
<Flex gap={10}>
|
||||
{alarm?.confirmed || false ? (
|
||||
<Popconfirm
|
||||
title={intl.formatMessage({
|
||||
id: 'master.alarms.unconfirm.body',
|
||||
defaultMessage:
|
||||
'Are you sure you want to unconfirm this alarm?',
|
||||
})}
|
||||
okText={intl.formatMessage({
|
||||
id: 'common.yes',
|
||||
defaultMessage: 'Yes',
|
||||
})}
|
||||
cancelText={intl.formatMessage({
|
||||
id: 'common.no',
|
||||
defaultMessage: 'No',
|
||||
})}
|
||||
onConfirm={async () => {
|
||||
const body: MasterModel.ConfirmAlarmRequest = {
|
||||
id: alarm.id,
|
||||
thing_id: alarm.thing_id,
|
||||
time: alarm.time,
|
||||
};
|
||||
try {
|
||||
const resp = await apiUnconfirmAlarm(body);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
message.success({
|
||||
content: intl.formatMessage({
|
||||
id: 'master.alarms.unconfirm.success',
|
||||
defaultMessage: 'Confirm alarm successfully',
|
||||
}),
|
||||
});
|
||||
tableRef.current?.reload();
|
||||
} else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) {
|
||||
message.warning({
|
||||
content: intl.formatMessage({
|
||||
id: 'master.alarms.not_found',
|
||||
defaultMessage:
|
||||
'Alarm has expired or does not exist',
|
||||
}),
|
||||
});
|
||||
tableRef.current?.reload();
|
||||
} else {
|
||||
throw new Error('Failed to confirm alarm');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error when unconfirm alarm: ', error);
|
||||
message.error({
|
||||
content: intl.formatMessage({
|
||||
id: 'master.alarms.unconfirm.fail',
|
||||
defaultMessage: 'Unconfirm alarm failed',
|
||||
}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlarmUnConfirmButton
|
||||
alarm={alarm}
|
||||
message={messageApi}
|
||||
button={
|
||||
<Button danger icon={<DeleteOutlined />} size="small">
|
||||
<FormattedMessage id="master.alarms.unconfirm.title" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
onFinish={(isReload) => {
|
||||
if (isReload) tableRef.current?.reload();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AlarmFormConfirm
|
||||
isOpen={isConfirmModalOpen}
|
||||
setIsOpen={setIsConfirmModalOpen}
|
||||
alarm={alarm}
|
||||
message={messageApi}
|
||||
onFinish={(isReload) => {
|
||||
|
||||
174
src/pages/Manager/Device/Camera/components/CameraFormModal.tsx
Normal file
174
src/pages/Manager/Device/Camera/components/CameraFormModal.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
} from 'antd';
|
||||
|
||||
// Camera types
|
||||
const CAMERA_TYPES = [
|
||||
{ label: 'HIKVISION', value: 'HIKVISION' },
|
||||
{ label: 'DAHUA', value: 'DAHUA' },
|
||||
{ label: 'GENERIC', value: 'GENERIC' },
|
||||
];
|
||||
|
||||
interface CameraFormValues {
|
||||
name: string;
|
||||
type: string;
|
||||
account: string;
|
||||
password: string;
|
||||
ipAddress: string;
|
||||
rtspPort: number;
|
||||
httpPort: number;
|
||||
stream: number;
|
||||
channel: number;
|
||||
}
|
||||
|
||||
interface CameraFormModalProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: CameraFormValues) => void;
|
||||
}
|
||||
|
||||
const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
||||
open,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [form] = Form.useForm<CameraFormValues>();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
onSubmit(values);
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Tạo mới camera"
|
||||
open={open}
|
||||
onCancel={handleCancel}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleCancel}>
|
||||
Hủy
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmit}>
|
||||
Đồ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" autoComplete="off" />
|
||||
</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"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraFormModal;
|
||||
106
src/pages/Manager/Device/Camera/components/CameraTable.tsx
Normal file
106
src/pages/Manager/Device/Camera/components/CameraTable.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Card, Checkbox, Space, Table, theme } from 'antd';
|
||||
|
||||
interface CameraTableProps {
|
||||
cameraData: MasterModel.Camera[] | null;
|
||||
onCreateCamera: () => void;
|
||||
onReload?: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const CameraTable: React.FC<CameraTableProps> = ({
|
||||
cameraData,
|
||||
onCreateCamera,
|
||||
onReload,
|
||||
loading = false,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const handleReload = () => {
|
||||
console.log('Reload cameras');
|
||||
onReload?.();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
console.log('Delete selected cameras');
|
||||
// TODO: Implement delete functionality
|
||||
};
|
||||
|
||||
const handleEdit = (camera: MasterModel.Camera) => {
|
||||
console.log('Edit camera:', camera);
|
||||
// TODO: Implement edit functionality
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'checkbox',
|
||||
width: 50,
|
||||
render: () => <Checkbox />,
|
||||
},
|
||||
{
|
||||
title: 'Tên',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text: string) => (
|
||||
<a style={{ color: token.colorPrimary }}>{text || '-'}</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Loại',
|
||||
dataIndex: 'cate_id',
|
||||
key: 'cate_id',
|
||||
render: (text: string) => text || '-',
|
||||
},
|
||||
{
|
||||
title: 'Địa chỉ IP',
|
||||
dataIndex: 'ip',
|
||||
key: 'ip',
|
||||
render: (text: string) => text || '-',
|
||||
},
|
||||
{
|
||||
title: 'Thao tác',
|
||||
key: 'action',
|
||||
render: (_: any, record: MasterModel.Camera) => (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card bodyStyle={{ padding: 16 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateCamera}>
|
||||
Tạo mới camera
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReload} />
|
||||
<Button icon={<DeleteOutlined />} onClick={handleDelete} />
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
dataSource={cameraData || []}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
size: 'small',
|
||||
showTotal: (total: number, range: [number, number]) =>
|
||||
`Hiển thị ${range[0]}-${range[1]} của ${total} camera`,
|
||||
pageSize: 10,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraTable;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Button, Card, Select, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Recording modes for V5 - chỉ có không ghi và ghi 24/24
|
||||
const RECORDING_MODES = [
|
||||
{ label: 'Không ghi', value: 'none' },
|
||||
{ label: 'Ghi 24/24', value: '24/7' },
|
||||
];
|
||||
|
||||
interface CameraV5Props {
|
||||
thing: MasterModel.Thing | null;
|
||||
initialRecordingMode?: string;
|
||||
}
|
||||
|
||||
const CameraV5: React.FC<CameraV5Props> = ({
|
||||
thing,
|
||||
initialRecordingMode = 'none',
|
||||
}) => {
|
||||
const [recordingMode, setRecordingMode] = useState(initialRecordingMode);
|
||||
|
||||
console.log('ConfigCameraV5 - thing:', thing);
|
||||
|
||||
const handleSubmit = () => {
|
||||
console.log('Submit recording mode:', recordingMode);
|
||||
// TODO: Call API to save recording configuration
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
Gửi đi
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraV5;
|
||||
192
src/pages/Manager/Device/Camera/components/ConfigCameraV6.tsx
Normal file
192
src/pages/Manager/Device/Camera/components/ConfigCameraV6.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { apiQueryConfigAlarm } from '@/services/master/MessageController';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { Button, Card, Col, Row, Select, theme, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Recording modes for V6
|
||||
const RECORDING_MODES = [
|
||||
{ label: 'Không ghi', value: 'none' },
|
||||
{ label: 'Theo cảnh báo', value: 'alarm' },
|
||||
{ label: '24/24', value: 'all' },
|
||||
];
|
||||
|
||||
interface CameraV6Props {
|
||||
thing: MasterModel.Thing | null;
|
||||
cameraConfig?: MasterModel.CameraV6 | null;
|
||||
}
|
||||
|
||||
const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
||||
const { token } = theme.useToken();
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
|
||||
const [recordingMode, setRecordingMode] = useState<'none' | 'alarm' | 'all'>(
|
||||
'none',
|
||||
);
|
||||
const [alarmConfig, setAlarmConfig] = useState<MasterModel.Alarm[] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Initialize states from cameraConfig when it's available
|
||||
useEffect(() => {
|
||||
if (cameraConfig) {
|
||||
// Set recording mode from config
|
||||
if (cameraConfig.record_type) {
|
||||
setRecordingMode(cameraConfig.record_type);
|
||||
}
|
||||
|
||||
// Set selected alerts from config
|
||||
if (
|
||||
cameraConfig.record_alarm_list &&
|
||||
Array.isArray(cameraConfig.record_alarm_list)
|
||||
) {
|
||||
setSelectedAlerts(cameraConfig.record_alarm_list);
|
||||
}
|
||||
}
|
||||
}, [cameraConfig]);
|
||||
|
||||
// Fetch alarm config when thing data is available and recording mode is 'alarm'
|
||||
useEffect(() => {
|
||||
const fetchAlarmConfig = async () => {
|
||||
if (
|
||||
!thing ||
|
||||
!initialState?.currentUserProfile?.metadata?.frontend_thing_key ||
|
||||
recordingMode !== 'alarm'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await apiQueryConfigAlarm(
|
||||
thing.metadata?.data_channel_id || '',
|
||||
initialState.currentUserProfile.metadata.frontend_thing_key,
|
||||
{
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
subtopic: `config.${thing.metadata?.type}.alarms`,
|
||||
},
|
||||
);
|
||||
if (resp.messages && resp.messages.length > 0) {
|
||||
const parsed = resp.messages[0].string_value_parsed;
|
||||
if (Array.isArray(parsed)) {
|
||||
setAlarmConfig(parsed as MasterModel.Alarm[]);
|
||||
} else {
|
||||
setAlarmConfig([]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch alarm config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlarmConfig();
|
||||
}, [thing, initialState, recordingMode]);
|
||||
|
||||
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:', {
|
||||
recordingMode,
|
||||
selectedAlerts,
|
||||
});
|
||||
// TODO: Call API to save alert configuration
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
{/* Recording Mode */}
|
||||
<div className="mb-6">
|
||||
<Text strong className="block mb-2">
|
||||
Ghi dữ liệu camera
|
||||
</Text>
|
||||
<div className="flex gap-8 items-center">
|
||||
<Select
|
||||
value={recordingMode}
|
||||
onChange={setRecordingMode}
|
||||
options={RECORDING_MODES}
|
||||
className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4"
|
||||
/>
|
||||
<Button type="primary" onClick={handleSubmitAlerts}>
|
||||
Gửi đi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert List - Only show when mode is 'alarm' */}
|
||||
{recordingMode === 'alarm' && (
|
||||
<div>
|
||||
<Text strong className="block mb-2">
|
||||
Danh sách cảnh báo
|
||||
</Text>
|
||||
|
||||
<div
|
||||
className="flex justify-between items-center mb-4 px-3 py-2 rounded border"
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
}}
|
||||
>
|
||||
<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]}>
|
||||
{alarmConfig?.map((alarm) => {
|
||||
const alarmId = alarm.id ?? '';
|
||||
const isSelected =
|
||||
alarmId !== '' && selectedAlerts.includes(alarmId);
|
||||
return (
|
||||
<Col xs={12} sm={8} md={6} lg={4} xl={4} key={alarmId}>
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => handleAlertToggle(alarmId)}
|
||||
className="cursor-pointer h-20 flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: isSelected
|
||||
? token.colorPrimary
|
||||
: token.colorBorder,
|
||||
borderWidth: isSelected ? 2 : 1,
|
||||
background: isSelected
|
||||
? token.colorPrimaryBg
|
||||
: token.colorBgContainer,
|
||||
}}
|
||||
>
|
||||
<div className="p-2 text-center w-full">
|
||||
<Text
|
||||
className="text-xs break-words"
|
||||
style={{
|
||||
color: isSelected
|
||||
? token.colorPrimary
|
||||
: token.colorText,
|
||||
}}
|
||||
>
|
||||
{alarm.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraV6;
|
||||
@@ -1,120 +1,34 @@
|
||||
import { apiSearchThings } from '@/services/master/ThingController';
|
||||
import { apiQueryCamera } from '@/services/master/MessageController';
|
||||
import { apiGetThingDetail } from '@/services/master/ThingController';
|
||||
import { wsClient } from '@/utils/wsClient';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ArrowLeftOutlined } 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,
|
||||
theme,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { history, useModel, useParams } from '@umijs/max';
|
||||
import { Button, Col, Row, Space, Spin } 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;
|
||||
}
|
||||
import CameraFormModal from './components/CameraFormModal';
|
||||
import CameraTable from './components/CameraTable';
|
||||
import ConfigCameraV5 from './components/ConfigCameraV5';
|
||||
import ConfigCameraV6 from './components/ConfigCameraV6';
|
||||
|
||||
const CameraConfigPage = () => {
|
||||
const { thingId } = useParams<{ thingId: string }>();
|
||||
const { token } = theme.useToken();
|
||||
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);
|
||||
const [cameraLoading, setCameraLoading] = useState(false);
|
||||
const [thing, setThing] = useState<MasterModel.Thing | null>(null);
|
||||
const [cameras, setCameras] = useState<MasterModel.Camera[] | null>([]);
|
||||
const [cameraConfig, setCameraConfig] = useState<MasterModel.CameraV6 | null>(
|
||||
null,
|
||||
);
|
||||
const { initialState } = useModel('@@initialState');
|
||||
|
||||
useEffect(() => {
|
||||
wsClient.connect('wss://gms.smatec.com.vn/mqtt', false);
|
||||
wsClient.connect('/mqtt', false);
|
||||
const unsubscribe = wsClient.subscribe((data: any) => {
|
||||
console.log('Received WS data:', data);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
@@ -124,118 +38,90 @@ const CameraConfigPage = () => {
|
||||
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);
|
||||
}
|
||||
const thingData = await apiGetThingDetail(thingId);
|
||||
setThing(thingData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch thing info:', error);
|
||||
setThingName(thingId);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchThingInfo();
|
||||
}, [thingId]);
|
||||
|
||||
const handleBack = () => {
|
||||
history.push('/manager/devices');
|
||||
// Fetch camera config when thing data is available
|
||||
const fetchCameraConfig = async () => {
|
||||
if (
|
||||
!thing ||
|
||||
!initialState?.currentUserProfile?.metadata?.frontend_thing_key
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setCameraLoading(true);
|
||||
const resp = await apiQueryCamera(
|
||||
thing.metadata?.data_channel_id || '',
|
||||
initialState.currentUserProfile.metadata.frontend_thing_key,
|
||||
{
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
subtopic: `config.${thing.metadata?.type}.cameras`,
|
||||
},
|
||||
);
|
||||
|
||||
if (resp.messages!.length > 0) {
|
||||
setCameras(
|
||||
resp.messages![0].string_value_parsed?.cams as MasterModel.Camera[],
|
||||
);
|
||||
setCameraConfig(
|
||||
resp.messages![0].string_value_parsed as MasterModel.CameraV6,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch camera config:', error);
|
||||
} finally {
|
||||
setCameraLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCameraConfig();
|
||||
}, [thing, initialState]);
|
||||
|
||||
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();
|
||||
const handleSubmitCamera = (values: any) => {
|
||||
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);
|
||||
};
|
||||
|
||||
// Helper function to determine which camera component to render
|
||||
const renderCameraRecordingComponent = () => {
|
||||
const thingType = thing?.metadata?.type;
|
||||
|
||||
if (thingType === 'gmsv5') {
|
||||
return <ConfigCameraV5 thing={thing} />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlertToggle = (alertId: string) => {
|
||||
if (selectedAlerts.includes(alertId)) {
|
||||
setSelectedAlerts(selectedAlerts.filter((id) => id !== alertId));
|
||||
} else {
|
||||
setSelectedAlerts([...selectedAlerts, alertId]);
|
||||
if (thingType === 'spole' || thingType === 'gmsv6') {
|
||||
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAlerts = () => {
|
||||
setSelectedAlerts([]);
|
||||
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
|
||||
};
|
||||
|
||||
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: token.colorPrimary }}>{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
|
||||
@@ -245,9 +131,9 @@ const CameraConfigPage = () => {
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={handleBack}
|
||||
onClick={() => history.push('/manager/devices')}
|
||||
/>
|
||||
<span>{thingName || 'Loading...'}</span>
|
||||
<span>{thing?.name || 'Loading...'}</span>
|
||||
</Space>
|
||||
),
|
||||
}}
|
||||
@@ -255,248 +141,26 @@ const CameraConfigPage = () => {
|
||||
<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,
|
||||
}}
|
||||
<CameraTable
|
||||
cameraData={cameras}
|
||||
onCreateCamera={handleOpenModal}
|
||||
onReload={fetchCameraConfig}
|
||||
loading={cameraLoading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Right Column - Alert Configuration */}
|
||||
{/* Right Column - Camera Recording 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: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
}}
|
||||
>
|
||||
<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
|
||||
? token.colorPrimary
|
||||
: token.colorBorder,
|
||||
borderWidth: isSelected ? 2 : 1,
|
||||
background: isSelected
|
||||
? token.colorPrimaryBg
|
||||
: token.colorBgContainer,
|
||||
height: 80,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
bodyStyle={{
|
||||
padding: 8,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isSelected
|
||||
? token.colorPrimary
|
||||
: token.colorText,
|
||||
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>
|
||||
{renderCameraRecordingComponent()}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Create Camera Modal */}
|
||||
<Modal
|
||||
title="Tạo mới"
|
||||
<CameraFormModal
|
||||
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>
|
||||
onSubmit={handleSubmitCamera}
|
||||
/>
|
||||
</PageContainer>
|
||||
</Spin>
|
||||
);
|
||||
|
||||
105
src/pages/Manager/Device/Detail/components/BinarySensors.tsx
Normal file
105
src/pages/Manager/Device/Detail/components/BinarySensors.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import IconFont from '@/components/IconFont';
|
||||
import { StatisticCard } from '@ant-design/pro-components';
|
||||
import { Flex, GlobalToken, Grid, theme } from 'antd';
|
||||
|
||||
type BinarySensorsProps = {
|
||||
nodeConfigs: MasterModel.NodeConfig[];
|
||||
};
|
||||
|
||||
export const getBinaryEntities = (
|
||||
nodeConfigs: MasterModel.NodeConfig[] = [],
|
||||
): MasterModel.Entity[] =>
|
||||
nodeConfigs.flatMap((nodeConfig) =>
|
||||
nodeConfig.type === 'din'
|
||||
? nodeConfig.entities.filter(
|
||||
(entity) => entity.type === 'bin' && entity.active === 1,
|
||||
)
|
||||
: [],
|
||||
);
|
||||
interface IconTypeResult {
|
||||
iconType: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const getIconTypeByEntity = (
|
||||
entity: MasterModel.Entity,
|
||||
token: GlobalToken,
|
||||
): IconTypeResult => {
|
||||
if (!entity.config || entity.config.length === 0) {
|
||||
return {
|
||||
iconType: 'icon-device-setting',
|
||||
color: token.colorPrimary,
|
||||
};
|
||||
}
|
||||
switch (entity.config[0].subType) {
|
||||
case 'smoke':
|
||||
return {
|
||||
iconType: 'icon-fire',
|
||||
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
|
||||
};
|
||||
case 'heat':
|
||||
return {
|
||||
iconType: 'icon-fire',
|
||||
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
|
||||
};
|
||||
case 'motion':
|
||||
return {
|
||||
iconType: 'icon-motion',
|
||||
color: entity.value === 0 ? token.colorTextBase : token.colorInfoActive,
|
||||
};
|
||||
case 'flood':
|
||||
return {
|
||||
iconType: 'icon-water-ingress',
|
||||
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
|
||||
};
|
||||
case 'door':
|
||||
return {
|
||||
iconType: entity.value === 0 ? 'icon-door' : 'icon-door-open',
|
||||
color: entity.value === 0 ? token.colorText : token.colorWarning,
|
||||
};
|
||||
case 'button':
|
||||
return {
|
||||
iconType: 'icon-alarm-button',
|
||||
color: entity.value === 0 ? token.colorText : token.colorSuccess,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
iconType: 'icon-door',
|
||||
color: token.colorPrimary,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const StatisticCardItem = (entity: MasterModel.Entity, token: GlobalToken) => {
|
||||
const { iconType, color } = getIconTypeByEntity(entity, token);
|
||||
return (
|
||||
<StatisticCard
|
||||
key={entity.entityId}
|
||||
statistic={{
|
||||
title: entity.name,
|
||||
icon: (
|
||||
<IconFont type={iconType} style={{ color: color, fontSize: 24 }} />
|
||||
),
|
||||
value: entity.active === 1 ? 'Active' : 'Inactive',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const BinarySensors = ({ nodeConfigs }: BinarySensorsProps) => {
|
||||
const binarySensors = getBinaryEntities(nodeConfigs);
|
||||
console.log('BinarySensor: ', binarySensors);
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const { useBreakpoint } = Grid;
|
||||
const screens = useBreakpoint();
|
||||
return (
|
||||
<Flex wrap="wrap">
|
||||
<StatisticCard.Group direction={screens.sm ? 'row' : 'column'}>
|
||||
{binarySensors.map((entity) => StatisticCardItem(entity, token))}
|
||||
</StatisticCard.Group>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BinarySensors;
|
||||
34
src/pages/Manager/Device/Detail/components/ThingTitle.tsx
Normal file
34
src/pages/Manager/Device/Detail/components/ThingTitle.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getBadgeConnection } from '@/components/shared/ThingShared';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Flex, Typography } from 'antd';
|
||||
import moment from 'moment';
|
||||
const { Text, Title } = Typography;
|
||||
const ThingTitle = ({ thing }: { thing: MasterModel.Thing | null }) => {
|
||||
const intl = useIntl();
|
||||
if (thing === null) {
|
||||
return <Text>{intl.formatMessage({ id: 'common.undefined' })}</Text>;
|
||||
}
|
||||
|
||||
const connectionDuration = thing.metadata!.connected
|
||||
? thing.metadata!.uptime! * 1000
|
||||
: (Math.round(new Date().getTime() / 1000) -
|
||||
thing.metadata!.updated_time!) *
|
||||
1000;
|
||||
return (
|
||||
<Flex gap={10}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{thing.name || intl.formatMessage({ id: 'common.undefined' })}
|
||||
</Title>
|
||||
<Flex gap={5} align="center" justify="center">
|
||||
{getBadgeConnection(thing.metadata!.connected || false)}
|
||||
<Text type={thing.metadata?.connected ? undefined : 'secondary'}>
|
||||
{connectionDuration > 0
|
||||
? moment.duration(connectionDuration).humanize()
|
||||
: ''}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThingTitle;
|
||||
115
src/pages/Manager/Device/Detail/index.tsx
Normal file
115
src/pages/Manager/Device/Detail/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import DeviceAlarmList from '@/components/shared/DeviceAlarmList';
|
||||
import TooltipIconFontButton from '@/components/shared/TooltipIconFontButton';
|
||||
import { ROUTER_HOME } from '@/constants/routes';
|
||||
import { apiQueryNodeConfigMessage } from '@/services/master/MessageController';
|
||||
import { apiGetThingDetail } from '@/services/master/ThingController';
|
||||
import { PageContainer, ProCard } from '@ant-design/pro-components';
|
||||
import { history, useIntl, useModel, useParams } from '@umijs/max';
|
||||
import { Grid } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import BinarySensors from './components/BinarySensors';
|
||||
import ThingTitle from './components/ThingTitle';
|
||||
|
||||
const DetailDevicePage = () => {
|
||||
const { thingId } = useParams();
|
||||
const [thing, setThing] = useState<MasterModel.Thing | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const { useBreakpoint } = Grid;
|
||||
const screens = useBreakpoint();
|
||||
const intl = useIntl();
|
||||
const [nodeConfigs, setNodeConfigs] = useState<MasterModel.NodeConfig[]>([]);
|
||||
const getThingDetail = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const thing = await apiGetThingDetail(thingId || '');
|
||||
setThing(thing);
|
||||
} catch (error) {
|
||||
console.error('Error when get Thing: ', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
const getDeviceConfig = async () => {
|
||||
try {
|
||||
const resp = await apiQueryNodeConfigMessage(
|
||||
thing?.metadata?.data_channel_id || '',
|
||||
initialState?.currentUserProfile?.metadata?.frontend_thing_key || '',
|
||||
{
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
subtopic: `config.${thing?.metadata?.type}.node`,
|
||||
},
|
||||
);
|
||||
if (resp.messages && resp.messages.length > 0) {
|
||||
console.log('Node Configs: ', resp.messages[0].string_value_parsed);
|
||||
|
||||
setNodeConfigs(resp.messages[0].string_value_parsed ?? []);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (thingId) {
|
||||
getThingDetail();
|
||||
}
|
||||
}, [thingId]);
|
||||
useEffect(() => {
|
||||
if (thing) {
|
||||
getDeviceConfig();
|
||||
}
|
||||
}, [thing]);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title={isLoading ? 'Loading...' : <ThingTitle thing={thing} />}
|
||||
header={{
|
||||
onBack: () => history.push(ROUTER_HOME),
|
||||
breadcrumb: undefined,
|
||||
}}
|
||||
extra={[
|
||||
<TooltipIconFontButton
|
||||
key="logs"
|
||||
tooltip="Nhật ký"
|
||||
iconFontName="icon-system-diary"
|
||||
shape="circle"
|
||||
size="middle"
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
<TooltipIconFontButton
|
||||
key="notifications"
|
||||
tooltip="Thông báo"
|
||||
iconFontName="icon-bell"
|
||||
shape="circle"
|
||||
size="middle"
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
<TooltipIconFontButton
|
||||
key="settings"
|
||||
tooltip="Cài đặt"
|
||||
iconFontName="icon-setting-device"
|
||||
shape="circle"
|
||||
size="middle"
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||
<ProCard
|
||||
title={intl.formatMessage({
|
||||
id: 'master.thing.detail.alarmList.title',
|
||||
})}
|
||||
colSpan={{ xs: 24, sm: 24, md: 24, lg: 6, xl: 6 }}
|
||||
bodyStyle={{ paddingInline: 0, paddingBlock: 0 }}
|
||||
bordered
|
||||
>
|
||||
<DeviceAlarmList key="thing-alarms-key" thingId={thingId || ''} />
|
||||
</ProCard>
|
||||
<ProCard>
|
||||
<BinarySensors nodeConfigs={nodeConfigs} />
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailDevicePage;
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@/components/shared/ThingShared';
|
||||
import TreeGroup from '@/components/shared/TreeGroup';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants';
|
||||
import { ROUTE_MANAGER_DEVICES } from '@/constants/routes';
|
||||
import { apiSearchThings } from '@/services/master/ThingController';
|
||||
import {
|
||||
ActionType,
|
||||
@@ -12,12 +13,12 @@ import {
|
||||
ProColumns,
|
||||
ProTable,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { FormattedMessage, history, useIntl } from '@umijs/max';
|
||||
import { Flex, Grid, theme, Typography } from 'antd';
|
||||
import moment from 'moment';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { TagStateCallbackPayload } from '../../SGW/Map/type';
|
||||
const { Text } = Typography;
|
||||
const { Text, Link } = Typography;
|
||||
const SpoleHome: React.FC = () => {
|
||||
const { useBreakpoint } = Grid;
|
||||
const intl = useIntl();
|
||||
@@ -39,9 +40,6 @@ const SpoleHome: React.FC = () => {
|
||||
{
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
title: (
|
||||
<FormattedMessage id="master.devices.name" defaultMessage="Name" />
|
||||
),
|
||||
tip: intl.formatMessage({
|
||||
id: 'master.devices.name.tip',
|
||||
defaultMessage: 'The device name',
|
||||
@@ -49,6 +47,20 @@ const SpoleHome: React.FC = () => {
|
||||
dataIndex: 'name',
|
||||
hideInSearch: true,
|
||||
copyable: true,
|
||||
render: (_, row) => {
|
||||
return (
|
||||
<Link
|
||||
copyable
|
||||
onClick={() => history.push(`${ROUTE_MANAGER_DEVICES}/${row.id}`)}
|
||||
>
|
||||
{row.name ||
|
||||
intl.formatMessage({
|
||||
id: 'common.undefined',
|
||||
defaultMessage: 'Undefined',
|
||||
})}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'connected',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { API_LOGS } from '@/constants/api';
|
||||
import { API_READER } from '@/constants/api';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
export async function apiQueryLogs(
|
||||
params: MasterModel.SearchLogPaginationBody,
|
||||
type: MasterModel.LogTypeRequest,
|
||||
) {
|
||||
return request<MasterModel.LogResponse>(`${API_LOGS}/${type}/messages`, {
|
||||
return request<MasterModel.LogResponse>(`${API_READER}/${type}/messages`, {
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
213
src/services/master/MessageController.ts
Normal file
213
src/services/master/MessageController.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { API_READER } from '@/constants/api';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
// Transform functions
|
||||
function transformEntityConfigChildDetail(
|
||||
raw: MasterModel.PurpleC,
|
||||
): MasterModel.EntityConfigChildDetail {
|
||||
return {
|
||||
entityId: raw.eid || '',
|
||||
value: raw.v,
|
||||
operation: raw.op,
|
||||
duration: raw.for,
|
||||
};
|
||||
}
|
||||
|
||||
function transformEntityConfigChild(
|
||||
raw: MasterModel.CElement,
|
||||
): MasterModel.EntityConfigChild {
|
||||
return {
|
||||
type: raw.t || '',
|
||||
children: raw.c ? [transformEntityConfigChildDetail(raw.c)] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function transformEntityConfig(raw: MasterModel.EC): MasterModel.EntityConfig {
|
||||
return {
|
||||
level: raw.l as 0 | 1 | 2 | undefined,
|
||||
normalCondition: raw.nc,
|
||||
subType: raw.st,
|
||||
children: raw.c?.map(transformEntityConfigChild),
|
||||
};
|
||||
}
|
||||
|
||||
function transformEntity(raw: MasterModel.E): MasterModel.Entity {
|
||||
return {
|
||||
entityId: raw.eid || '',
|
||||
type: raw.t || '',
|
||||
name: raw.n || '',
|
||||
active: raw.a,
|
||||
value: raw.v,
|
||||
config: raw.c ? [transformEntityConfig(raw.c)] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformNodeConfig(
|
||||
raw: MasterModel.RawNodeConfig,
|
||||
): MasterModel.NodeConfig {
|
||||
return {
|
||||
nodeId: raw.nid || '',
|
||||
type: raw.t || '',
|
||||
name: raw.n || '',
|
||||
entities: raw.e?.map(transformEntity) || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Reverse transform functions
|
||||
function reverseTransformEntityConfigChildDetail(
|
||||
detail: MasterModel.EntityConfigChildDetail,
|
||||
): MasterModel.PurpleC {
|
||||
return {
|
||||
eid: detail.entityId,
|
||||
v: detail.value,
|
||||
op: detail.operation,
|
||||
for: detail.duration,
|
||||
};
|
||||
}
|
||||
|
||||
function reverseTransformEntityConfigChild(
|
||||
child: MasterModel.EntityConfigChild,
|
||||
): MasterModel.CElement {
|
||||
return {
|
||||
t: child.type,
|
||||
c: child.children?.[0]
|
||||
? reverseTransformEntityConfigChildDetail(child.children[0])
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function reverseTransformEntityConfig(
|
||||
raw: MasterModel.EntityConfig,
|
||||
): MasterModel.EC {
|
||||
return {
|
||||
l: raw.level,
|
||||
nc: raw.normalCondition,
|
||||
st: raw.subType,
|
||||
c: raw.children?.map(reverseTransformEntityConfigChild),
|
||||
};
|
||||
}
|
||||
|
||||
function reverseTransformEntity(entity: MasterModel.Entity): MasterModel.E {
|
||||
return {
|
||||
eid: entity.entityId,
|
||||
t: entity.type,
|
||||
n: entity.name,
|
||||
a: entity.active,
|
||||
v: entity.value,
|
||||
c: entity.config?.[0]
|
||||
? reverseTransformEntityConfig(entity.config[0])
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformRawNodeConfig(
|
||||
node: MasterModel.NodeConfig,
|
||||
): MasterModel.RawNodeConfig {
|
||||
return {
|
||||
nid: node.nodeId,
|
||||
t: node.type,
|
||||
n: node.name,
|
||||
e: node.entities.map(reverseTransformEntity),
|
||||
};
|
||||
}
|
||||
|
||||
export async function apiQueryNodeConfigMessage(
|
||||
dataChanelId: string,
|
||||
authorization: string,
|
||||
params: MasterModel.SearchMessagePaginationBody,
|
||||
) {
|
||||
const resp = await request<
|
||||
MasterModel.MesageReaderResponse<MasterModel.NodeConfig[]>
|
||||
>(`${API_READER}/${dataChanelId}/messages`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
},
|
||||
params: params,
|
||||
});
|
||||
|
||||
// Process messages to add string_value_parsed
|
||||
if (resp.messages) {
|
||||
resp.messages = resp.messages.map((message) => {
|
||||
if (message.string_value) {
|
||||
try {
|
||||
const rawNodeConfigs: MasterModel.RawNodeConfig[] = JSON.parse(
|
||||
message.string_value,
|
||||
);
|
||||
message.string_value_parsed = rawNodeConfigs.map(transformNodeConfig);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse string_value:', error);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function apiQueryCamera(
|
||||
dataChanelId: string,
|
||||
authorization: string,
|
||||
params: MasterModel.SearchMessagePaginationBody,
|
||||
) {
|
||||
const resp = await request<MasterModel.CameraV6MessageResponse>(
|
||||
`${API_READER}/${dataChanelId}/messages`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
},
|
||||
params: params,
|
||||
},
|
||||
);
|
||||
|
||||
// Process messages to add string_value_parsed
|
||||
if (resp.messages) {
|
||||
resp.messages = resp.messages.map((message) => {
|
||||
if (message.string_value) {
|
||||
try {
|
||||
message.string_value_parsed = JSON.parse(message.string_value);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse string_value:', error);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
export async function apiQueryConfigAlarm(
|
||||
dataChanelId: string,
|
||||
authorization: string,
|
||||
params: MasterModel.SearchMessagePaginationBody,
|
||||
) {
|
||||
const resp = await request<MasterModel.AlarmMessageResponse>(
|
||||
`${API_READER}/${dataChanelId}/messages`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
},
|
||||
params: params,
|
||||
},
|
||||
);
|
||||
|
||||
// Process messages to add string_value_parsed
|
||||
if (resp.messages) {
|
||||
resp.messages = resp.messages.map((message) => {
|
||||
if (message.string_value) {
|
||||
try {
|
||||
message.string_value_parsed = JSON.parse(message.string_value);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse string_value:', error);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
API_SHARE_THING,
|
||||
API_THING,
|
||||
API_THING_POLICY,
|
||||
API_THINGS_SEARCH,
|
||||
} from '@/constants/api';
|
||||
@@ -55,7 +55,7 @@ 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}`, {
|
||||
return request<MasterModel.Thing>(`${API_THING}/${value.id}`, {
|
||||
method: 'PUT',
|
||||
data: value,
|
||||
});
|
||||
@@ -77,7 +77,7 @@ export async function apiDeleteUserThingPolicy(
|
||||
thing_id: string,
|
||||
user_id: string,
|
||||
) {
|
||||
return request(`${API_SHARE_THING}/${thing_id}/share`, {
|
||||
return request(`${API_THING}/${thing_id}/share`, {
|
||||
method: 'DELETE',
|
||||
data: {
|
||||
policies: ['read', 'write', 'delete'],
|
||||
@@ -91,7 +91,7 @@ export async function apiShareThingToUser(
|
||||
user_id: string,
|
||||
policies: string[],
|
||||
) {
|
||||
return request(`${API_SHARE_THING}/${thing_id}/share`, {
|
||||
return request(`${API_THING}/${thing_id}/share`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
policies: policies,
|
||||
@@ -100,3 +100,7 @@ export async function apiShareThingToUser(
|
||||
getResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiGetThingDetail(thing_id: string) {
|
||||
return request<MasterModel.Thing>(`${API_THING}/${thing_id}`);
|
||||
}
|
||||
|
||||
47
src/services/master/typings/log.d.ts
vendored
47
src/services/master/typings/log.d.ts
vendored
@@ -8,7 +8,7 @@ declare namespace MasterModel {
|
||||
|
||||
type LogTypeRequest = 'user_logs' | undefined;
|
||||
|
||||
interface LogResponse {
|
||||
interface MesageReaderResponse<T = MessageDataType> {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
publisher?: string;
|
||||
@@ -16,10 +16,18 @@ declare namespace MasterModel {
|
||||
to?: number;
|
||||
format?: string;
|
||||
total?: number;
|
||||
messages?: Message[];
|
||||
messages?: Message<T>[];
|
||||
}
|
||||
|
||||
interface Message {
|
||||
// Response types cho từng domain
|
||||
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
|
||||
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
|
||||
type AlarmMessageResponse = MesageReaderResponse<Alarm>;
|
||||
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
|
||||
|
||||
type MessageDataType = NodeConfig[] | CameraV5 | CameraV6;
|
||||
|
||||
interface Message<T = MessageDataType> {
|
||||
channel?: string;
|
||||
subtopic?: string;
|
||||
publisher?: string;
|
||||
@@ -27,5 +35,38 @@ declare namespace MasterModel {
|
||||
name?: string;
|
||||
time?: number;
|
||||
string_value?: string;
|
||||
string_value_parsed?: T;
|
||||
}
|
||||
|
||||
// Message types cho từng domain
|
||||
type CameraMessage = Message<CameraV5>;
|
||||
type CameraV6Message = Message<CameraV6>;
|
||||
type NodeConfigMessage = Message<NodeConfig[]>;
|
||||
|
||||
interface CameraV5 {
|
||||
cams?: Camera[];
|
||||
}
|
||||
interface CameraV6 extends CameraV5 {
|
||||
record_type?: 'none' | 'alarm' | 'all';
|
||||
record_alarm_list?: string[];
|
||||
}
|
||||
|
||||
interface Camera {
|
||||
id?: string;
|
||||
name?: string;
|
||||
cate_id?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
rtsp_port?: number;
|
||||
http_port?: number;
|
||||
channel?: number;
|
||||
ip?: string;
|
||||
stream?: number;
|
||||
}
|
||||
|
||||
interface Alarm {
|
||||
id: string;
|
||||
type: Type;
|
||||
name: string;
|
||||
}
|
||||
}
|
||||
|
||||
101
src/services/master/typings/message.d.ts
vendored
Normal file
101
src/services/master/typings/message.d.ts
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
declare namespace MasterModel {
|
||||
interface SearchMessagePaginationBody extends SearchPaginationBody {
|
||||
subtopic?: string;
|
||||
}
|
||||
|
||||
interface RawNodeConfig {
|
||||
nid?: string;
|
||||
t?: string;
|
||||
n?: string;
|
||||
e?: E[];
|
||||
}
|
||||
interface E {
|
||||
eid?: string;
|
||||
t?: string;
|
||||
n?: string;
|
||||
a?: number;
|
||||
v?: number;
|
||||
c?: EC;
|
||||
vs?: string;
|
||||
}
|
||||
|
||||
interface EC {
|
||||
l?: number;
|
||||
nc?: number;
|
||||
st?: string;
|
||||
c?: CElement[];
|
||||
}
|
||||
|
||||
interface CElement {
|
||||
t?: string;
|
||||
c?: PurpleC;
|
||||
}
|
||||
|
||||
interface PurpleC {
|
||||
eid?: string;
|
||||
v?: number;
|
||||
op?: number;
|
||||
for?: number;
|
||||
}
|
||||
|
||||
// Interface Tranformed RawNodeConfig
|
||||
|
||||
interface NodeConfig {
|
||||
/** Node ID - Định danh duy nhất */
|
||||
nodeId: string;
|
||||
/** Type - Loại thiết bị */
|
||||
type: string;
|
||||
/** Name - Tên hiển thị */
|
||||
name: string;
|
||||
/** Entities - Danh sách cảm biến */
|
||||
entities: Entity[];
|
||||
}
|
||||
interface Entity {
|
||||
/** Entity ID - Định danh duy nhất của cảm biến */
|
||||
entityId: string;
|
||||
/** Type - Loại cảm biến (vd: 'bin' - nhị phân 0/1, 'bin_t' - nhị phân có trigger) */
|
||||
type: string;
|
||||
/** Name - Tên hiển thị */
|
||||
name: string;
|
||||
/** Active - Đã kích hoạt cảm biến này hay chưa (1=đã kích hoạt, 0=chưa kích hoạt) */
|
||||
active?: number;
|
||||
/** Value - Giá trị hiện tại (1=có, 0=không) */
|
||||
value?: number;
|
||||
/** EntityConfig - Cấu hình bổ sung */
|
||||
config?: EntityConfig[];
|
||||
}
|
||||
interface EntityConfig {
|
||||
/** Level - Mức độ cảnh báo
|
||||
0 = info,
|
||||
1 = warning,
|
||||
2 = critical */
|
||||
level?: 0 | 1 | 2;
|
||||
/** Normal Condition - Điều kiện bình thường */
|
||||
normalCondition?: number;
|
||||
/** SubType - Phân loại chi tiết */
|
||||
subType?: string;
|
||||
/** Children - Các cấu hình con */
|
||||
children?: EntityConfigChild[];
|
||||
}
|
||||
interface EntityConfigChild {
|
||||
/** Type - Loại điều kiện */
|
||||
type: string;
|
||||
children?: EntityConfigChildDetail[];
|
||||
}
|
||||
interface EntityConfigChildDetail {
|
||||
/** entity ID - Cảm biến được theo dõi */
|
||||
entityId: string;
|
||||
/** Value - Ngưỡng giá trị */
|
||||
value?: number;
|
||||
/** Operation - Toán tử so sánh:
|
||||
* 0 = == (bằng)
|
||||
* 1 = != (khác)
|
||||
* 2 = > (lớn hơn)
|
||||
* 3 = >= (lớn hơn hoặc bằng)
|
||||
* 4 = < (nhỏ hơn)
|
||||
* 5 = <= (nhỏ hơn hoặc bằng) */
|
||||
operation?: number;
|
||||
/** Duration - Thời gian duy trì (giây) */
|
||||
duration?: number;
|
||||
}
|
||||
}
|
||||
1
src/services/master/typings/thing.d.ts
vendored
1
src/services/master/typings/thing.d.ts
vendored
@@ -30,6 +30,7 @@ declare namespace MasterModel {
|
||||
state_updated_time?: number;
|
||||
type?: string;
|
||||
updated_time?: number;
|
||||
uptime?: number;
|
||||
lat?: string;
|
||||
lng?: string;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class WSClient {
|
||||
|
||||
/**
|
||||
* Kết nối tới WebSocket server.
|
||||
* @param url Địa chỉ WebSocket server
|
||||
* @param url Địa chỉ WebSocket server (có thể là relative path như /mqtt)
|
||||
* @param isAuthenticated Có sử dụng token xác thực hay không
|
||||
*/
|
||||
connect(url: string, isAuthenticated: boolean) {
|
||||
@@ -18,7 +18,13 @@ class WSClient {
|
||||
if (isAuthenticated) {
|
||||
token = getToken();
|
||||
}
|
||||
const wsUrl = isAuthenticated ? `${url}?token=${token}` : url;
|
||||
let wsUrl = url;
|
||||
if (url.startsWith('/')) {
|
||||
// Relative path, prepend base WebSocket URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}${url}`;
|
||||
}
|
||||
wsUrl = isAuthenticated ? `${wsUrl}?token=${token}` : wsUrl;
|
||||
this.ws = new ReconnectingWebSocket(wsUrl, [], {
|
||||
maxRetries: 10,
|
||||
maxReconnectionDelay: 10000,
|
||||
|
||||
Reference in New Issue
Block a user