Compare commits

9 Commits

111 changed files with 8264 additions and 466 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@
.turbopack
/dist
.DS_Store
/wdoc

View File

@@ -2,7 +2,9 @@ import { defineConfig } from '@umijs/max';
import {
alarmsRoute,
commonManagerRoutes,
forgotPasswordRoute,
loginRoute,
managerCameraRoute,
managerRouteBase,
notFoundRoute,
profileRoute,
@@ -15,17 +17,18 @@ export default defineConfig({
},
routes: [
loginRoute,
forgotPasswordRoute,
alarmsRoute,
profileRoute,
{
name: 'gms.monitor',
icon: 'icon-monitor',
path: '/monitor',
component: './Slave/GMS/Monitor',
component: './Slave/Spole/Monitor',
},
{
...managerRouteBase,
routes: [...commonManagerRoutes],
routes: [...commonManagerRoutes, managerCameraRoute],
},
{
path: '/',

View File

@@ -2,6 +2,7 @@ import { defineConfig } from '@umijs/max';
import {
alarmsRoute,
commonManagerRoutes,
forgotPasswordRoute,
loginRoute,
managerRouteBase,
notFoundRoute,
@@ -15,6 +16,7 @@ export default defineConfig({
},
routes: [
loginRoute,
forgotPasswordRoute,
alarmsRoute,
profileRoute,
{

View File

@@ -2,6 +2,7 @@ import { defineConfig } from '@umijs/max';
import {
alarmsRoute,
commonManagerRoutes,
forgotPasswordRoute,
loginRoute,
managerRouteBase,
notFoundRoute,
@@ -16,6 +17,7 @@ export default defineConfig({
routes: [
loginRoute,
alarmsRoute,
forgotPasswordRoute,
profileRoute,
{
name: 'spole.monitoring',

View File

@@ -4,6 +4,7 @@ import proxyProd from './config/proxy_prod';
import {
alarmsRoute,
commonManagerRoutes,
forgotPasswordRoute,
loginRoute,
managerRouteBase,
notFoundRoute,
@@ -16,6 +17,7 @@ const resolvedEnv = isProdBuild && !rawEnv ? 'prod' : rawEnv || 'dev';
const proxyConfig = isProdBuild ? proxyProd : proxyDev;
const routes = [
loginRoute,
forgotPasswordRoute,
alarmsRoute,
{
...managerRouteBase,

View File

@@ -24,6 +24,11 @@ const proxyDev: Record<string, any> = {
target: target,
changeOrigin: true,
},
'/mqtt': {
target: target,
changeOrigin: true,
ws: true,
},
},
test: {
'/test': {

View File

@@ -1,43 +1,48 @@
import { HTTPSTATUS } from '@/constants';
import { API_PATH_REFRESH_TOKEN } from '@/constants/api';
import { ROUTE_LOGIN } from '@/constants/routes';
import { getToken, removeToken } from '@/utils/storage';
import { history, RequestConfig } from '@umijs/max';
import { apiRefreshToken } from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt';
import {
clearAllData,
getAccessToken,
getRefreshToken,
setAccessToken,
} from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max';
import { message } from 'antd';
// Error handling scheme: Error types
// enum ErrorShowType {
// SILENT = 0,
// WARN_MESSAGE = 1,
// ERROR_MESSAGE = 2,
// NOTIFICATION = 3,
// REDIRECT = 9,
// }
// Response data structure agreed with the backend
// interface ResponseStructure<T = any> {
// success: boolean;
// data: T;
// errorCode?: number;
// errorMessage?: string;
// showType?: ErrorShowType;
// }
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
let refreshingTokenPromise: Promise<string | null> | null = null;
// const codeMessage = {
// 200: 'The server successfully returned the requested data。',
// 201: 'New or modified data succeeded。',
// 202: 'A request has been queued in the background (asynchronous task)。',
// 204: 'Data deleted successfully。',
// 400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
// 401: 'The user does not have permission (token, username, password is wrong) 。',
// 403: 'User is authorized, but access is prohibited。',
// 404: 'The request issued was for a non-existent record, the server did not operate。',
// 406: 'The requested format is not available。',
// 410: 'The requested resource is permanently deleted and will no longer be available。',
// 422: 'When creating an object, a validation error occurred。',
// 500: 'Server error, please check the server。',
// 502: 'Gateway error。',
// 503: 'Service unavailable, server temporarily overloaded or maintained。',
// 504: 'Gateway timeout。',
// };
async function getValidAccessToken(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken || checkRefreshTokenExpired(refreshToken)) {
return null;
}
if (!refreshingTokenPromise) {
refreshingTokenPromise = apiRefreshToken({ refresh_token: refreshToken })
.then((resp) => {
if (resp?.access_token) {
setAccessToken(resp.access_token);
return resp.access_token;
}
return null;
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error('Refresh token failed:', err);
return null;
})
.finally(() => {
refreshingTokenPromise = null;
});
}
return refreshingTokenPromise;
}
// Runtime configuration
export const handleRequestConfig: RequestConfig = {
@@ -45,7 +50,9 @@ export const handleRequestConfig: RequestConfig = {
timeout: 20000,
validateStatus: (status) => {
return (
(status >= 200 && status < 300) || status === HTTPSTATUS.HTTP_NOTFOUND
(status >= 200 && status < 300) ||
status === HTTPSTATUS.HTTP_NOTFOUND ||
status === HTTPSTATUS.HTTP_UNAUTHORIZED
);
},
headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -76,8 +83,8 @@ export const handleRequestConfig: RequestConfig = {
// 'Unknown error';
if (status === HTTPSTATUS.HTTP_UNAUTHORIZED) {
removeToken();
history.push(ROUTE_LOGIN);
// 401 đã được xử lý trong responseInterceptors (refresh token + redirect nếu cần)
return;
} else if (status === HTTPSTATUS.HTTP_SERVERERROR) {
// message.error('💥 Internal server error!');
} else {
@@ -93,7 +100,7 @@ export const handleRequestConfig: RequestConfig = {
// Request interceptors
requestInterceptors: [
(url: string, options: any) => {
const token = getToken();
const token = getAccessToken();
// console.log('Token: ', token);
return {
@@ -102,7 +109,9 @@ export const handleRequestConfig: RequestConfig = {
...options,
headers: {
...options.headers,
...(token ? { Authorization: `${token}` } : {}),
...(token && !options.headers.Authorization
? { Authorization: `${token}` }
: {}),
},
},
};
@@ -111,14 +120,67 @@ export const handleRequestConfig: RequestConfig = {
// Unwrap data from backend response
responseInterceptors: [
(response) => {
async (response: any, options: any) => {
const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN);
const alreadyRetried = options?.skipAuthRefresh === true;
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
!isRefreshRequest &&
!alreadyRetried
) {
const newToken = await getValidAccessToken();
console.log('Access Token hết hạn, đang refresh...');
// Không refresh được => xoá dữ liệu, điều hướng về trang login
if (!newToken) {
console.log('Access Token hết hạn và không thể refresh');
const { pathname } = history.location;
clearAllData();
if (pathname !== ROUTE_LOGIN) {
console.log(
'Request dev: Chuyển về trang login và có pathname ',
pathname,
);
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
} else {
console.log(
'Request dev: Chuyển về trang login và không có pathname',
);
history.push(ROUTE_LOGIN);
}
return Promise.reject(
new Error('Unauthorized and refresh token is invalid'),
);
}
const newOptions = {
...options,
headers: {
...(options.headers || {}),
Authorization: `${newToken}`,
},
skipAuthRefresh: true,
};
// Gọi lại request gốc với accessToken mới
return request(response.url, newOptions);
}
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
(isRefreshRequest || alreadyRetried)
) {
clearAllData();
history.push(ROUTE_LOGIN);
return Promise.reject(new Error('Unauthorized'));
}
// console.log('Response from server: ', response);
// const res = response.data as ResponseStructure<any>;
// if (res && res.success) {
// // ✅ Trả ra data luôn thay vì cả object
// return res.data;
// }
return {
status: response.status,
statusText: response.statusText,
@@ -127,5 +189,5 @@ export const handleRequestConfig: RequestConfig = {
data: response.data,
};
},
],
] as any,
};

View File

@@ -1,26 +1,48 @@
import { HTTPSTATUS } from '@/constants';
import { API_PATH_REFRESH_TOKEN } from '@/constants/api';
import { ROUTE_LOGIN } from '@/constants/routes';
import { getToken, removeToken } from '@/utils/storage';
import { history, RequestConfig } from '@umijs/max';
import { apiRefreshToken } from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt';
import {
clearAllData,
getAccessToken,
getRefreshToken,
setAccessToken,
} from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max';
import { message } from 'antd';
const codeMessage = {
200: 'The server successfully returned the requested data。',
201: 'New or modified data succeeded。',
202: 'A request has been queued in the background (asynchronous task)。',
204: 'Data deleted successfully。',
400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
401: 'The user does not have permission (token, username, password is wrong) 。',
403: 'User is authorized, but access is prohibited。',
404: 'The request issued was for a non-existent record, the server did not operate。',
406: 'The requested format is not available。',
410: 'The requested resource is permanently deleted and will no longer be available。',
422: 'When creating an object, a validation error occurred。',
500: 'Server error, please check the server。',
502: 'Gateway error。',
503: 'Service unavailable, server temporarily overloaded or maintained。',
504: 'Gateway timeout。',
};
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
let refreshingTokenPromise: Promise<string | null> | null = null;
async function getValidAccessToken(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken || checkRefreshTokenExpired(refreshToken)) {
return null;
}
if (!refreshingTokenPromise) {
refreshingTokenPromise = apiRefreshToken({ refresh_token: refreshToken })
.then((resp) => {
if (resp?.access_token) {
setAccessToken(resp.access_token);
return resp.access_token;
}
return null;
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error('Refresh token failed:', err);
return null;
})
.finally(() => {
refreshingTokenPromise = null;
});
}
return refreshingTokenPromise;
}
// Runtime configuration
export const handleRequestConfig: RequestConfig = {
@@ -28,7 +50,9 @@ export const handleRequestConfig: RequestConfig = {
timeout: 20000,
validateStatus: (status) => {
return (
(status >= 200 && status < 300) || status === HTTPSTATUS.HTTP_NOTFOUND
(status >= 200 && status < 300) ||
status === HTTPSTATUS.HTTP_NOTFOUND ||
status === HTTPSTATUS.HTTP_UNAUTHORIZED
);
},
headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -48,24 +72,25 @@ export const handleRequestConfig: RequestConfig = {
// Error catching and handling
errorHandler: (error: any) => {
if (error.response) {
const { status, statusText, data } = error.response;
const { status } = error.response;
// Ưu tiên: codeMessage → backend message → statusText
const errMsg =
codeMessage[status as keyof typeof codeMessage] ||
data?.message ||
statusText ||
'Unknown error';
// const errMsg =
// codeMessage[status as keyof typeof codeMessage] ||
// data?.message ||
// statusText ||
// 'Unknown error';
message.error(`${status}: ${errMsg}`);
if (status === 401) {
removeToken();
history.push(ROUTE_LOGIN);
if (status === HTTPSTATUS.HTTP_UNAUTHORIZED) {
// 401 đã được xử lý trong responseInterceptors (refresh token + redirect nếu cần)
return;
} else if (status === HTTPSTATUS.HTTP_SERVERERROR) {
// message.error('💥 Internal server error!');
} else {
message.error(`${status}: ${error.message || 'Error'}`);
}
} else if (error.request) {
message.error('🚨 No response from server!');
} else if (status) {
// message.error('💥 Internal server error!');
} else {
message.error(`⚠️ Request setup error: ${error.message}`);
}
@@ -79,14 +104,16 @@ export const handleRequestConfig: RequestConfig = {
// Chỉ cần thêm token, proxy sẽ xử lý việc redirect đến đúng port
// URL sẽ bắt đầu với /api và proxy sẽ chuyển đến hostname:81/api
const token = getToken();
const token = getAccessToken();
return {
url: url,
options: {
...options,
headers: {
...options.headers,
...(token ? { Authorization: `${token}` } : {}),
...(token && !options.headers.Authorization
? { Authorization: `${token}` }
: {}),
},
},
};
@@ -94,14 +121,65 @@ export const handleRequestConfig: RequestConfig = {
],
// Unwrap data from backend response
// responseInterceptors: [
// (response) => {
// const res = response.data as ResponseStructure<any>;
// if (res && res.success) {
// // ✅ Trả ra data luôn thay vì cả object
// return res.data;
// }
// return response.data;
// },
// ],
responseInterceptors: [
async (response: any, options: any) => {
const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN);
const alreadyRetried = options?.skipAuthRefresh === true;
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
!isRefreshRequest &&
!alreadyRetried
) {
const newToken = await getValidAccessToken();
// Không refresh được => xoá dữ liệu, điều hướng về trang login
if (!newToken) {
const { pathname } = history.location;
clearAllData();
if (pathname !== ROUTE_LOGIN) {
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
} else {
history.push(ROUTE_LOGIN);
}
return Promise.reject(
new Error('Unauthorized and refresh token is invalid'),
);
}
const newOptions = {
...options,
headers: {
...(options.headers || {}),
Authorization: `${newToken}`,
},
skipAuthRefresh: true,
};
// Gọi lại request gốc với accessToken mới
return request(response.url, newOptions);
}
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
(isRefreshRequest || alreadyRetried)
) {
clearAllData();
history.push(ROUTE_LOGIN);
return Promise.reject(new Error('Unauthorized'));
}
// console.log('Response from server: ', response);
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
config: response.config,
data: response.data,
};
},
] as any,
};

View File

@@ -5,6 +5,14 @@ export const loginRoute = {
layout: false,
};
export const forgotPasswordRoute = {
title: 'Forgot Password',
path: '/password/reset',
component: './Auth/ForgotPassword',
layout: false,
access: 'canAll',
};
export const profileRoute = {
name: 'profile',
icon: 'icon-user',
@@ -39,6 +47,10 @@ export const commonManagerRoutes = [
path: '/manager/devices',
component: './Manager/Device',
},
{
path: '/manager/devices/:thingId',
component: './Manager/Device/Detail',
},
],
},
{
@@ -80,6 +92,11 @@ export const commonManagerRoutes = [
},
];
export const managerCameraRoute = {
path: '/manager/devices/:thingId/camera',
component: './Manager/Device/Camera',
};
export const managerRouteBase = {
name: 'manager',
icon: 'icon-setting',

2
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "SMATEC-FRONTEND",
"name": "smatec-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {

BIN
public/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

View File

@@ -11,17 +11,20 @@ import LanguageSwitcher from './components/Lang/LanguageSwitcher';
import ThemeProvider from './components/Theme/ThemeProvider';
import ThemeSwitcher from './components/Theme/ThemeSwitcher';
import { THEME_KEY } from './constants';
import { ROUTE_LOGIN } from './constants/routes';
import { ROUTE_FORGOT_PASSWORD, ROUTE_LOGIN } from './constants/routes';
import NotFoundPage from './pages/Exception/NotFound';
import UnAccessPage from './pages/Exception/UnAccess';
import { apiQueryProfile } from './services/master/AuthController';
import { checkTokenExpired } from './utils/jwt';
import {
apiQueryProfile,
apiRefreshToken,
} from './services/master/AuthController';
import { checkRefreshTokenExpired } from './utils/jwt';
import { getLogoImage } from './utils/logo';
import {
clearAllData,
clearSessionData,
getToken,
removeToken,
getAccessToken,
getRefreshToken,
setAccessToken,
} from './utils/storage';
const isProdBuild = process.env.NODE_ENV === 'production';
export type InitialStateResponse = {
@@ -30,27 +33,62 @@ export type InitialStateResponse = {
theme?: 'light' | 'dark';
};
const publicRoutes = [ROUTE_LOGIN, ROUTE_FORGOT_PASSWORD];
const handleBackToLogin = () => {
const { pathname } = history.location;
clearAllData();
// Tránh reload liên tục nếu đã ở trang login hoặc public routes
if (publicRoutes.includes(pathname)) return;
window.location.href = `${ROUTE_LOGIN}?redirect=${pathname}`;
};
// 全局初始化数据配置,用于 Layout 用户信息和权限初始化
// 更多信息见文档https://umijs.org/docs/api/runtime-config#getinitialstate
export async function getInitialState(): Promise<InitialStateResponse> {
const userToken: string = getToken();
const refreshToken: string = getRefreshToken();
const { pathname } = history.location;
// Public routes that don't require authentication
if (publicRoutes.includes(pathname)) {
const currentTheme =
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
return {
theme: currentTheme,
};
}
dayjs.locale(getLocale() === 'en-US' ? 'en' : 'vi');
if (!userToken) {
if (pathname !== ROUTE_LOGIN) {
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
} else {
history.push(ROUTE_LOGIN);
}
if (!refreshToken) {
handleBackToLogin();
return {};
}
const isTokenExpried = checkTokenExpired(userToken);
const isTokenExpried = checkRefreshTokenExpired(refreshToken);
if (isTokenExpried) {
removeToken();
clearAllData();
clearSessionData();
window.location.href = ROUTE_LOGIN;
handleBackToLogin();
return {};
}
const ensureAccessToken = async () => {
const existing = getAccessToken();
if (existing) return existing;
try {
const resp = await apiRefreshToken({ refresh_token: refreshToken });
if (resp?.access_token) {
setAccessToken(resp.access_token);
return resp.access_token;
}
} catch (error) {
console.error('Cannot refresh access token: ', error);
}
return null;
};
const accessToken = await ensureAccessToken();
if (!accessToken) {
handleBackToLogin();
return {};
}
@@ -59,10 +97,7 @@ export async function getInitialState(): Promise<InitialStateResponse> {
const resp = await apiQueryProfile();
return resp;
} catch (error) {
removeToken();
clearAllData();
clearSessionData();
window.location.href = ROUTE_LOGIN;
console.error('Cannot get Profile: ', error);
}
};
const resp = await getUserProfile();
@@ -86,7 +121,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
contentWidth: 'Fluid',
navTheme: isDark ? 'realDark' : 'light',
splitMenus: true,
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_84vdbef39dp.js',
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_919jssa0os9.js',
contentStyle: {
padding: 0,
margin: 0,
@@ -144,6 +179,10 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
colorBgMenuItemSelected: '#EEF7FF', // background khi chọn
colorTextMenuSelected: isDark ? '#fff' : '#1A2130', // màu chữ khi chọn
},
pageContainer: {
paddingInlinePageContainerContent: 8,
paddingBlockPageContainerContent: 8,
},
},
unAccessible: <UnAccessPage />,
noFound: <NotFoundPage />,

View File

@@ -1,5 +1,9 @@
import { ROUTE_LOGIN, ROUTE_PROFILE } from '@/constants/routes';
import { clearAllData, clearSessionData, removeToken } from '@/utils/storage';
import {
clearAllData,
clearSessionData,
removeAccessToken,
} from '@/utils/storage';
import {
LogoutOutlined,
SettingOutlined,
@@ -36,7 +40,7 @@ export const AvatarDropdown = ({
icon: <LogoutOutlined />,
label: intl.formatMessage({ id: 'common.logout' }),
onClick: () => {
removeToken();
removeAccessToken();
clearAllData();
clearSessionData();
window.location.href = ROUTE_LOGIN;

View File

@@ -1,7 +1,7 @@
import { createFromIconfontCN } from '@ant-design/icons';
const IconFont = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_5096559_84vdbef39dp.js',
scriptUrl: '//at.alicdn.com/t/c/font_5096559_919jssa0os9.js',
});
export default IconFont;

View 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;

View File

@@ -0,0 +1,157 @@
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"
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;

View File

@@ -0,0 +1,298 @@
import { HTTPSTATUS } from '@/const';
import {
apiDeletePhoto,
apiGetPhoto,
apiGetTagsPhoto,
apiUploadPhoto,
} from '@/services/slave/sgw/PhotoController';
import { ModalForm, ProFormUploadButton } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Divider, message } from 'antd';
import { UploadFile } from 'antd/lib';
import { useState } from 'react';
type PhotoActionModalProps = {
isOpen?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
type: SgwModel.PhotoGetParams['type'];
id: string | number;
hasSubPhotos?: boolean;
};
const PhotoActionModal = ({
isOpen,
setIsOpen,
type,
id,
hasSubPhotos = true,
}: PhotoActionModalProps) => {
const [imageMain, setImageMain] = useState<UploadFile[]>([]);
const [imageSubs, setImageSubs] = useState<UploadFile[]>([]);
const [messageApi, contextHolder] = message.useMessage();
const intl = useIntl();
const fetchImageByTag = async (tag: string): Promise<UploadFile | null> => {
try {
const resp = await apiGetPhoto(type, id, tag);
if (resp.status !== HTTPSTATUS.HTTP_SUCCESS) {
return null;
}
const objectUrl = URL.createObjectURL(
new Blob([resp.data], { type: 'image/jpeg' }),
);
return {
uid: `-${tag}`,
name: `${tag}.jpg`,
status: 'done',
url: objectUrl,
};
} catch {
return null;
}
};
const handleUploadPhoto = async (
file: UploadFile,
tag: string,
): Promise<boolean> => {
try {
const resp = await apiUploadPhoto(
type,
String(id),
file.originFileObj as File,
tag,
);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
return true;
}
throw new Error('Upload photo failed');
} catch (error) {
console.error('Error when upload image: ', error);
return false;
}
};
const handleDeletePhoto = async (tag: string): Promise<boolean> => {
try {
const resp = await apiDeletePhoto(type, String(id), tag);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
return true;
}
throw new Error('Delete photo failed');
} catch (error) {
console.error('Error when delete image: ', error);
return false;
}
};
return (
<ModalForm
open={isOpen}
submitter={false}
onOpenChange={setIsOpen}
layout="vertical"
request={async () => {
// 1. Lấy ảnh chính (tag 'main')
const mainImage = await fetchImageByTag('main');
setImageMain(mainImage ? [mainImage] : []);
if (hasSubPhotos) {
// 2. Lấy danh sách tags
try {
const tagsResp = await apiGetTagsPhoto(type, id);
if (tagsResp?.tags && Array.isArray(tagsResp.tags)) {
// Lọc bỏ tag 'main' và lấy các tag còn lại
const subTags = tagsResp.tags.filter(
(tag: string) => tag !== 'main',
);
// 3. Lấy ảnh cho từng tag phụ
const subImages: UploadFile[] = [];
for (const tag of subTags) {
const img = await fetchImageByTag(tag);
if (img) {
subImages.push(img);
}
}
setImageSubs(subImages);
}
} catch {
// Không có tags hoặc lỗi
}
}
return {};
}}
>
{contextHolder}
<Divider>
{intl.formatMessage({ id: 'photo.main', defaultMessage: 'Ảnh chính' })}
</Divider>
<div
style={{
display: 'flex',
justifyContent: 'center',
marginBottom: 16,
}}
>
<ProFormUploadButton
name="main-picture"
label={null}
title={intl.formatMessage({
id: 'photo.upload',
defaultMessage: 'Chọn ảnh',
})}
accept="image/*"
max={1}
transform={(value) => ({ upload: value })}
fieldProps={{
onChange: async (info) => {
if (info.file.status !== 'removed') {
const isSuccess = await handleUploadPhoto(info.file, 'main');
if (!isSuccess) {
messageApi.error(
intl.formatMessage({
id: 'photo.update_fail',
defaultMessage: 'Cập nhật ảnh thất bại',
}),
);
setImageMain([]);
} else {
messageApi.success(
intl.formatMessage({
id: 'photo.update_success',
defaultMessage: 'Cập nhật ảnh thành công',
}),
);
// Dùng luôn file local để tạo URL, đỡ gọi API
const objectUrl = URL.createObjectURL(
info.file.originFileObj as File,
);
setImageMain([
{
uid: info.file.uid,
name: info.file.name,
status: 'done',
url: objectUrl,
},
]);
}
}
},
listType: 'picture-card',
fileList: imageMain,
maxCount: 1,
onRemove: async () => {
const isSuccess = await handleDeletePhoto('main');
if (!isSuccess) {
messageApi.error(
intl.formatMessage({
id: 'photo.delete_fail',
defaultMessage: 'Xóa ảnh thất bại',
}),
);
} else {
messageApi.success(
intl.formatMessage({
id: 'photo.delete_success',
defaultMessage: 'Xóa ảnh thành công',
}),
);
setImageMain([]);
}
},
}}
/>
</div>
{hasSubPhotos && (
<>
<Divider>
{intl.formatMessage({ id: 'photo.sub', defaultMessage: 'Ảnh phụ' })}
</Divider>
<div
style={{
display: 'flex',
justifyContent: imageSubs.length > 0 ? 'flex-start' : 'center',
marginBottom: 16,
}}
>
<ProFormUploadButton
name="sub-picture"
label={null}
title={intl.formatMessage({
id: 'photo.upload',
defaultMessage: 'Chọn ảnh',
})}
accept="image/*"
max={10}
transform={(value) => ({ upload: value })}
fieldProps={{
onChange: async (info) => {
// Tìm file mới được thêm (so sánh với imageSubs hiện tại)
const existingUids = new Set(imageSubs.map((f) => f.uid));
const newFiles = info.fileList.filter(
(f) => !existingUids.has(f.uid) && f.originFileObj,
);
for (const file of newFiles) {
// Tạo tag random
const randomTag = `sub_${Date.now()}_${Math.random()
.toString(36)
.substring(2, 9)}`;
const isSuccess = await handleUploadPhoto(file, randomTag);
if (!isSuccess) {
messageApi.error(
intl.formatMessage({
id: 'photo.update_fail',
defaultMessage: 'Cập nhật ảnh thất bại',
}),
);
setImageSubs(imageSubs); // Revert về state cũ
return;
}
messageApi.success(
intl.formatMessage({
id: 'photo.update_success',
defaultMessage: 'Thêm ảnh thành công',
}),
);
// Cập nhật file với tag
(file as any).tag = randomTag;
file.url = URL.createObjectURL(file.originFileObj as File);
file.status = 'done';
}
setImageSubs(info.fileList);
},
listType: 'picture-card',
fileList: imageSubs,
onRemove: async (file) => {
// Lấy tag từ file (được lưu khi fetch hoặc upload)
const tag = (file as any).tag || file.uid?.replace(/^-/, '');
const isSuccess = await handleDeletePhoto(tag);
if (!isSuccess) {
messageApi.error(
intl.formatMessage({
id: 'photo.delete_fail',
defaultMessage: 'Xóa ảnh thất bại',
}),
);
return false;
}
messageApi.success(
intl.formatMessage({
id: 'photo.delete_success',
defaultMessage: 'Xóa ảnh thành công',
}),
);
setImageSubs((prev) =>
prev.filter((f) => f.uid !== file.uid),
);
return true;
},
}}
/>
</div>
</>
)}
</ModalForm>
);
};
export default PhotoActionModal;

View File

@@ -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;
}
};

View File

@@ -0,0 +1,70 @@
import type { ButtonProps } from 'antd';
import { Button, theme, 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 { token } = theme.useToken();
const wrapperClassName = `tooltip-iconfont-wrapper-${color?.replace(
/[^a-zA-Z0-9]/g,
'-',
)}`;
const icon = (
<IconFont
type={iconFontName}
style={{ color: color || token.colorText }}
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;

View File

@@ -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;
}
}

2
src/const.ts Normal file
View File

@@ -0,0 +1,2 @@
// Re-export from constants for backward compatibility
export * from './constants';

View File

@@ -1,7 +1,9 @@
// Auth API Paths
export const API_PATH_LOGIN = '/api/tokens';
export const API_PATH_REFRESH_TOKEN = '/api/keys/refresh';
export const API_PATH_GET_PROFILE = '/api/users/profile';
export const API_CHANGE_PASSWORD = '/api/password';
export const API_FORGOT_PASSWORD = '/api/password/reset-request';
// Alarm API Constants
export const API_ALARMS = '/api/alarms';
export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
@@ -9,7 +11,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,8 +19,9 @@ 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';
export const API_USER_RESET_PASSWORD = '/api/password/reset';
export const API_USERS_BY_GROUP = '/api/users/groups';

View File

@@ -19,7 +19,8 @@ export const COLOR_WARNING = '#d48806';
export const COLOR_DANGEROUS = '#d9363e';
export const COLOR_SOS = '#ff0000';
export const TOKEN = 'token';
export const ACCESS_TOKEN = 'access_token';
export const REFRESH_TOKEN = 'refresh_token';
export const THEME_KEY = 'theme';
// Global Constants
export const LIMIT_TREE_LEVEL = 5;

View File

@@ -1,5 +1,7 @@
export const ROUTE_LOGIN = '/login';
export const ROUTE_FORGOT_PASSWORD = '/password/reset';
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';

View File

@@ -16,7 +16,7 @@ export const SGW_ROUTE_TRIPS_LAST = '/api/sgw/trips/last';
export const SGW_ROUTE_TRIPS_BY_ID = '/api/sgw/trips-by-id';
export const SGW_ROUTE_UPDATE_TRIP_STATUS = '/api/sgw/update-trip-status';
export const SGW_ROUTE_HAUL_HANDLE = '/api/sgw/haul-handle';
export const SGW_ROUTE_GET_FISH = '/api/sgw/fish-species';
export const SGW_ROUTE_GET_FISH = '/api/sgw/fishspecies-list';
export const SGW_ROUTE_UPDATE_FISHING_LOGS = '/api/sgw/update-fishing-logs';
// Crew API Routes
@@ -26,7 +26,12 @@ export const SGW_ROUTE_TRIPS_CREWS = '/api/sgw/trips/crews';
// Photo API Routes
export const SGW_ROUTE_PHOTO = '/api/sgw/photo';
export const SGW_ROUTE_PHOTO_TAGS = '/api/sgw/list-photo';
// Banzone API Routes
export const SGW_ROUTE_BANZONES = '/api/sgw/banzones';
export const SGW_ROUTE_BANZONES_LIST = '/api/sgw/banzones/list';
// Fish API Routes
export const SGW_ROUTE_CREATE_OR_UPDATE_FISH = '/api/sgw/fishspecies';
export const SGW_ROUTE_CREATE_OR_UPDATE_FISHING_LOGS = '/api/sgw/fishingLog';

View File

@@ -33,8 +33,11 @@ export default {
'common.theme.dark': 'Dark Theme',
'common.paginations.things': 'things',
'common.paginations.of': 'of',
'common.of': 'of',
'common.name': 'Name',
'common.name.required': 'Name is required',
'common.note': 'Note',
'common.image': 'Image',
'common.type': 'Type',
'common.type.placeholder': 'Select Type',
'common.status': 'Status',

View File

@@ -12,4 +12,17 @@ export default {
'master.auth.logout.title': 'Logout',
'master.auth.logout.confirm': 'Are you sure you want to logout?',
'master.auth.logout.success': 'Logout successful',
'master.auth.forgot.title': 'Forgot Password?',
'master.auth.backToLogin.title': 'Back to Login',
'master.auth.forgot.button.title': 'Send Reset Link',
'master.auth.forgot.message.success':
'Request sent successfully, please check your email!',
'master.auth.forgot.message.fail': 'Request failed, please try again later!',
'master.auth.reset.success': 'Password reset successful',
'master.auth.reset.error': 'An error occurred, please try again later!',
'master.auth.reset.invalid':
'The password reset link is invalid or has expired!',
'master.auth.reset.successTitle': 'Password reset successful!',
'master.auth.reset.successMessage': 'Redirecting to login page...',
'master.auth.reset.submit': 'Reset password',
};

View File

@@ -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,
};

View File

@@ -0,0 +1 @@
export default { 'master.thing.detail.alarmList.title': 'Alarm List' };

View File

@@ -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',
@@ -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

@@ -18,7 +18,10 @@ export default {
'master.users.full_name.placeholder': 'Enter full name',
'master.users.full_name.required': 'Please enter full name',
'master.users.password.placeholder': 'Password',
'master.users.confirmpassword.required': 'Confirm password is required',
'master.users.confirmPassword': 'Confirm Password',
'master.users.confirmPassword.placeholder': 'Enter Confirm Password',
'master.users.confirmPassword.required': 'Confirm password is required',
'master.users.confirmPassword.mismatch': 'Passwords do not match',
'master.users.email.placeholder': 'Email',
'master.users.phone_number': 'Phone number',
'master.users.phone_number.tip': 'The phone number is the unique key',
@@ -32,6 +35,7 @@ export default {
'master.users.role.sgw.end_user': 'Ship Owner',
'master.users.create.error': 'User creation failed',
'master.users.create.success': 'User created successfully',
'master.users.change_role.title': 'Set Permissions',
'master.users.change_role.confirm.title': 'Confirm role change',
'master.users.change_role.admin.content':
'Are you sure you want to change the role to Unit Manager?',
@@ -66,4 +70,8 @@ export default {
'master.users.things.sharing': 'Sharing devices...',
'master.users.thing.share.success': 'Device sharing successful',
'master.users.thing.share.fail': 'Device sharing failed',
'master.users.resetPassword.title': 'Reset Password',
'master.users.resetPassword.modal.title': 'Reset Password For User',
'master.users.resetPassword.success': 'Password reset successful',
'master.users.resetPassword.error': 'Password reset failed',
};

View File

@@ -1,6 +1,19 @@
import sgwFish from './sgw-fish-en';
import sgwMap from './sgw-map-en';
import sgwMenu from './sgw-menu-en';
import sgwPhoto from './sgw-photo-en';
import sgwShip from './sgw-ship-en';
import sgwTrip from './sgw-trip-en';
import sgwZone from './sgw-zone-en';
export default {
'sgw.title': 'Sea Gateway',
'sgw.ship': 'Ship',
...sgwMenu,
...sgwTrip,
...sgwMap,
...sgwShip,
...sgwFish,
...sgwPhoto,
...sgwZone,
};

View File

@@ -0,0 +1,53 @@
export default {
// Fish group
'fish.fish_group': 'Fish Group',
'fish.fish_group.tooltip': 'Enter fish group name',
'fish.fish_group.placeholder': 'Enter fish group',
// Rarity
'fish.rarity': 'Rarity Level',
'fish.rarity.placeholder': 'Select rarity level',
'fish.rarity.normal': 'Normal',
'fish.rarity.sensitive': 'Sensitive',
'fish.rarity.near_threatened': 'Near Threatened',
'fish.rarity.vulnerable': 'Vulnerable',
'fish.rarity.endangered': 'Endangered',
'fish.rarity.critically_endangered': 'Critically Endangered',
'fish.rarity.extinct_in_the_wild': 'Extinct in the Wild',
'fish.rarity.data_deficient': 'Data Deficient',
// Fish name
'fish.name': 'Fish Name',
'fish.name.tooltip': 'Enter fish name',
'fish.name.placeholder': 'Enter fish name',
'fish.name.required': 'Please enter fish name',
// Specific name
'fish.specific_name': 'Scientific Name',
'fish.specific_name.placeholder': 'Enter scientific name',
// Actions
'fish.create.title': 'Add New Fish Species',
'fish.edit.title': 'Edit Fish Species',
'fish.delete.title': 'Delete Fish Species',
'fish.delete.confirm': 'Are you sure you want to delete this fish species?',
'fish.delete_confirm': 'Are you sure you want to delete this fish species?',
'fish.delete.success': 'Fish species deleted successfully',
'fish.delete.fail': 'Failed to delete fish species',
'fish.create.success': 'Fish species created successfully',
'fish.create.fail': 'Failed to create fish species',
'fish.update.success': 'Fish species updated successfully',
'fish.update.fail': 'Failed to update fish species',
// Table columns
'fish.table.name': 'Fish Name',
'fish.table.specific_name': 'Scientific Name',
'fish.table.fish_group': 'Fish Group',
'fish.table.rarity': 'Rarity Level',
'fish.table.actions': 'Actions',
// Search & Filter
'fish.search.placeholder': 'Search fish species...',
'fish.filter.group': 'Filter by group',
'fish.filter.rarity': 'Filter by rarity',
};

View File

@@ -0,0 +1,37 @@
export default {
// Map
'home.mapError': 'Map Error',
'map.ship_detail.heading': 'Heading',
'map.ship_detail.speed': 'Speed',
'map.ship_detail.name': 'Ship Information',
// Thing status
'thing.name': 'Device Name',
'thing.status': 'Status',
'thing.status.normal': 'Normal',
'thing.status.warning': 'Warning',
'thing.status.critical': 'Critical',
'thing.status.sos': 'SOS',
// Map layers
'map.layer.list': 'Map Layers',
'map.layer.fishing_ban_zone': 'Fishing Ban Zone',
'map.layer.entry_ban_zone': 'Entry Ban Zone',
'map.layer.boundary_lines': 'Boundary Lines',
'map.layer.ports': 'Ports',
// Map filters
'map.filter.name': 'Filter',
'map.filter.ship_name': 'Ship Name',
'map.filter.ship_name_tooltip': 'Enter ship name to search',
'map.filter.ship_reg_number': 'Registration Number',
'map.filter.ship_reg_number_tooltip':
'Enter ship registration number to search',
'map.filter.ship_length': 'Ship Length (m)',
'map.filter.ship_power': 'Power (HP)',
'map.filter.ship_type': 'Ship Type',
'map.filter.ship_type_placeholder': 'Select ship type',
'map.filter.ship_warning': 'Warning',
'map.filter.ship_warning_placeholder': 'Select warning type',
'map.filter.area_type': 'Area Type',
};

View File

@@ -1,7 +1,11 @@
export default {
'menu.sgw.map': 'Maps',
'menu.sgw.trips': 'Trips',
'menu.sgw.ships': 'Ship',
'menu.manager.sgw.fishes': 'Fishes',
'menu.manager.sgw.zones': 'Zones',
'menu.sgw.ships': 'Ships',
'menu.sgw.fishes': 'Fishes',
'menu.sgw.zones': 'Prohibited Zones',
// Manager menu
'menu.manager.sgw.fishes': 'Fish Management',
'menu.manager.sgw.zones': 'Zone Management',
};

View File

@@ -0,0 +1,16 @@
export default {
'photo.main': 'Main Photo',
'photo.sub': 'Sub Photos',
'photo.upload': 'Upload Photo',
'photo.delete': 'Delete Photo',
'photo.delete.confirm': 'Are you sure you want to delete this photo?',
'photo.delete.success': 'Photo deleted successfully',
'photo.delete.fail': 'Failed to delete photo',
'photo.upload.success': 'Photo uploaded successfully',
'photo.upload.fail': 'Failed to upload photo',
'photo.upload.limit': 'Photo size must not exceed 5MB',
'photo.upload.format': 'Only JPG/PNG format supported',
'photo.manage': 'Manage Photos',
'photo.change': 'Change Photo',
'photo.add': 'Add Photo',
};

View File

@@ -0,0 +1,16 @@
export default {
// Pages - Ship List
'pages.ships.reg_number': 'Registration Number',
'pages.ships.name': 'Ship Name',
'pages.ships.type': 'Ship Type',
'pages.ships.home_port': 'Home Port',
'pages.ships.option': 'Options',
// Pages - Ship Create/Edit
'pages.ship.create.text': 'Create New Ship',
'pages.ships.create.title': 'Add New Ship',
'pages.ships.edit.title': 'Edit Ship',
// Pages - Things (Ship related)
'pages.things.fishing_license_expiry_date': 'Fishing License Expiry Date',
};

View File

@@ -0,0 +1,64 @@
export default {
// Pages - Trip List
'pages.trips.name': 'Trip Name',
'pages.trips.ship_id': 'Ship',
'pages.trips.departure_time': 'Departure Time',
'pages.trips.arrival_time': 'Arrival Time',
'pages.trips.status': 'Status',
'pages.trips.status.created': 'Created',
'pages.trips.status.pending_approval': 'Pending Approval',
'pages.trips.status.approved': 'Approved',
'pages.trips.status.active': 'Active',
'pages.trips.status.completed': 'Completed',
'pages.trips.status.cancelled': 'Cancelled',
// Pages - Date filters
'pages.date.yesterday': 'Yesterday',
'pages.date.lastweek': 'Last Week',
'pages.date.lastmonth': 'Last Month',
// Pages - Things/Ship
'pages.things.createTrip.text': 'Create Trip',
'pages.things.option': 'Options',
// Trip badges
'trip.badge.active': 'Active',
'trip.badge.approved': 'Approved',
'trip.badge.cancelled': 'Cancelled',
'trip.badge.completed': 'Completed',
'trip.badge.notApproved': 'Not Approved',
'trip.badge.unknown': 'Unknown',
'trip.badge.waitingApproval': 'Waiting Approval',
// Cancel trip
'trip.cancelTrip.button': 'Cancel Trip',
'trip.cancelTrip.placeholder': 'Enter reason for cancellation',
'trip.cancelTrip.reason': 'Reason',
'trip.cancelTrip.title': 'Cancel Trip',
'trip.cancelTrip.validation': 'Please enter a reason',
// Trip cost
'trip.cost.amount': 'Amount',
'trip.cost.crewSalary': 'Crew Salary',
'trip.cost.food': 'Food',
'trip.cost.fuel': 'Fuel',
'trip.cost.grandTotal': 'Grand Total',
'trip.cost.iceSalt': 'Ice & Salt',
'trip.cost.price': 'Price',
'trip.cost.total': 'Total',
'trip.cost.type': 'Type',
'trip.cost.unit': 'Unit',
// Trip gear
'trip.gear.name': 'Gear Name',
'trip.gear.quantity': 'Quantity',
// Haul fish list
'trip.haulFishList.fishCondition': 'Condition',
'trip.haulFishList.fishName': 'Fish Name',
'trip.haulFishList.fishRarity': 'Rarity',
'trip.haulFishList.fishSize': 'Size',
'trip.haulFishList.gearUsage': 'Gear Usage',
'trip.haulFishList.title': 'Haul Fish List',
'trip.haulFishList.weight': 'Weight',
};

View File

@@ -0,0 +1,32 @@
export default {
// Table columns
'banzones.name': 'Zone Name',
'banzones.area': 'Province/City',
'banzones.description': 'Description',
'banzones.type': 'Type',
'banzones.conditions': 'Conditions',
'banzones.state': 'Status',
'banzones.action': 'Actions',
'banzones.title': 'zones',
'banzones.create': 'Create Zone',
// Zone types
'banzone.area.fishing_ban': 'Fishing Ban',
'banzone.area.move_ban': 'Movement Ban',
'banzone.area.safe': 'Safe Area',
// Status
'banzone.is_enable': 'Enabled',
'banzone.is_unenabled': 'Disabled',
// Shape types
'banzone.polygon': 'Polygon',
'banzone.polyline': 'Polyline',
'banzone.circle': 'Circle',
// Notifications
'banzone.notify.delete_zone_success': 'Zone deleted successfully',
'banzone.notify.delete_zone_confirm':
'Are you sure you want to delete this zone',
'banzone.notify.fail': 'Operation failed!',
};

View File

@@ -32,6 +32,7 @@ export default {
'common.theme.dark': 'Tối',
'common.paginations.things': 'thiết bị',
'common.paginations.of': 'trên',
'common.of': 'trên',
'common.name': 'Tên',
'common.name.required': 'Tên không được để trống',
'common.note': 'Ghi chú',

View File

@@ -12,4 +12,21 @@ export default {
'master.auth.validation.email': 'Email không được để trống!',
'master.auth.password': 'Mật khẩu',
'master.auth.validation.password': 'Mật khẩu không được để trống!',
'master.auth.forgot.title': 'Quên mật khẩu?',
'master.auth.backToLogin.title': 'Quay lại đăng nhập',
'master.auth.forgot.button.title': 'Gửi yêu cầu đặt lại',
'master.auth.forgot.message.success':
'Gửi yêu cầu thành công, vui lòng kiểm tra email của bạn!',
'master.auth.forgot.message.fail':
'Gửi yêu cầu thất bại, vui lòng thử lại sau!',
'master.auth.reset.success': 'Đặt lại mật khẩu thành công',
'master.auth.reset.error': 'Có lỗi xảy ra, vui lòng thử lại sau!',
'master.auth.reset.invalid':
'Liên kết đặt lại mật khẩu không hợp lệ hoặc đã hết hạn!',
'master.auth.reset.successTitle': 'Đặt lại mật khẩu thành công!',
'master.auth.reset.successMessage':
'Đang chuyển hướng đến trang đăng nhập...',
'master.auth.reset.submit': 'Đặt lại mật khẩu',
'master.auth.reset.newPassword.placeholder': 'Nhập mật khẩu mới',
'master.auth.reset.confirmPassword.placeholder': 'Xác nhận mật khẩu mới',
};

View File

@@ -0,0 +1,3 @@
export default {
'master.thing.detail.alarmList.title': 'Danh sách cảnh báo',
};

View File

@@ -3,11 +3,12 @@ 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',
'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ị',
@@ -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

@@ -15,7 +15,10 @@ export default {
'master.users.password.minimum': 'Mật khẩu ít nhất 8 kí tự',
'master.users.password': 'Mật khẩu',
'master.users.password.placeholder': 'Nhập mật khẩu',
'master.users.confirmpassword.required': 'Vui lòng nhập lại mật khẩu',
'master.users.confirmPassword': 'Xác nhận mật khẩu',
'master.users.confirmPassword.placeholder': 'Nhập lại mật khẩu',
'master.users.confirmPassword.required': 'Vui lòng nhập lại mật khẩu',
'master.users.confirmPassword.mismatch': 'Mật khẩu không khớp',
'master.users.full_name': 'Tên đầy đủ',
'master.users.full_name.placeholder': 'Nhập tên đầy đủ',
'master.users.full_name.required': 'Vui lòng nhập tên đầy đủ',
@@ -32,6 +35,7 @@ export default {
'master.users.role.sgw.end_user': 'Chủ tàu',
'master.users.create.error': 'Tạo người dùng lỗi',
'master.users.create.success': 'Tạo người dùng thành công',
'master.users.change_role.title': 'Cài đặt quyền',
'master.users.change_role.confirm.title': 'Xác nhận thay đổi vai trò',
'master.users.change_role.admin.content':
'Bạn có chắc muốn thay đổi vai trò thành quản lý đơn vị không?',
@@ -65,4 +69,8 @@ export default {
'master.users.things.sharing': 'Đang chia sẻ thiết bị...',
'master.users.thing.share.success': 'Chia sẻ thiết bị thành công',
'master.users.thing.share.fail': 'Chia sẻ thiết bị thất bại',
'master.users.resetPassword.title': 'Đặt lại mật khẩu',
'master.users.resetPassword.modal.title': 'Đặt lại mật khẩu cho người dùng',
'master.users.resetPassword.success': 'Đặt lại mật khẩu thành công',
'master.users.resetPassword.error': 'Đặt lại mật khẩu thất bại',
};

View File

@@ -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,
};

View File

@@ -0,0 +1,53 @@
export default {
// Fish group
'fish.fish_group': 'Nhóm loài cá',
'fish.fish_group.tooltip': 'Nhập tên nhóm loài cá',
'fish.fish_group.placeholder': 'Nhập nhóm loài cá',
// Rarity
'fish.rarity': 'Mức độ quý hiếm',
'fish.rarity.placeholder': 'Chọn mức độ quý hiếm',
'fish.rarity.normal': 'Bình thường',
'fish.rarity.sensitive': 'Nhạy cảm',
'fish.rarity.near_threatened': 'Gần nguy cấp',
'fish.rarity.vulnerable': 'Sắp nguy cấp',
'fish.rarity.endangered': 'Nguy cấp',
'fish.rarity.critically_endangered': 'Cực kỳ nguy cấp',
'fish.rarity.extinct_in_the_wild': 'Tuyệt chủng trong tự nhiên',
'fish.rarity.data_deficient': 'Thiếu dữ liệu',
// Fish name
'fish.name': 'Tên loài cá',
'fish.name.tooltip': 'Nhập tên loài cá',
'fish.name.placeholder': 'Nhập tên loài cá',
'fish.name.required': 'Vui lòng nhập tên loài cá',
// Specific name
'fish.specific_name': 'Tên khoa học',
'fish.specific_name.placeholder': 'Nhập tên khoa học',
// Actions
'fish.create.title': 'Thêm loài cá mới',
'fish.edit.title': 'Chỉnh sửa loài cá',
'fish.delete.title': 'Xóa loài cá',
'fish.delete.confirm': 'Bạn có chắc chắn muốn xóa loài cá này không?',
'fish.delete_confirm': 'Bạn có chắc chắn muốn xóa loài cá này không?',
'fish.delete.success': 'Xóa loài cá thành công',
'fish.delete.fail': 'Xóa loài cá thất bại',
'fish.create.success': 'Thêm loài cá thành công',
'fish.create.fail': 'Thêm loài cá thất bại',
'fish.update.success': 'Cập nhật loài cá thành công',
'fish.update.fail': 'Cập nhật loài cá thất bại',
// Table columns
'fish.table.name': 'Tên loài cá',
'fish.table.specific_name': 'Tên khoa học',
'fish.table.fish_group': 'Nhóm loài',
'fish.table.rarity': 'Mức độ quý hiếm',
'fish.table.actions': 'Hành động',
// Search & Filter
'fish.search.placeholder': 'Tìm kiếm loài cá...',
'fish.filter.group': 'Lọc theo nhóm',
'fish.filter.rarity': 'Lọc theo mức độ quý hiếm',
};

View File

@@ -0,0 +1,36 @@
export default {
// Map
'home.mapError': 'Lỗi bản đồ',
'map.ship_detail.heading': 'Hướng di chuyển',
'map.ship_detail.speed': 'Tốc độ',
'map.ship_detail.name': 'Thông tin tàu',
// Thing status
'thing.name': 'Tên thiết bị',
'thing.status': 'Trạng thái',
'thing.status.normal': 'Bình thường',
'thing.status.warning': 'Cảnh báo',
'thing.status.critical': 'Nghiêm trọng',
'thing.status.sos': 'Khẩn cấp',
// Map layers
'map.layer.list': 'Danh sách lớp bản đồ',
'map.layer.fishing_ban_zone': 'Khu vực cấm đánh bắt',
'map.layer.entry_ban_zone': 'Khu vực cấm vào',
'map.layer.boundary_lines': 'Đường biên giới',
'map.layer.ports': 'Cảng',
// Map filters
'map.filter.name': 'Bộ lọc',
'map.filter.ship_name': 'Tên tàu',
'map.filter.ship_name_tooltip': 'Nhập tên tàu để tìm kiếm',
'map.filter.ship_reg_number': 'Số đăng ký',
'map.filter.ship_reg_number_tooltip': 'Nhập số đăng ký tàu để tìm kiếm',
'map.filter.ship_length': 'Chiều dài tàu (m)',
'map.filter.ship_power': 'Công suất (HP)',
'map.filter.ship_type': 'Loại tàu',
'map.filter.ship_type_placeholder': 'Chọn loại tàu',
'map.filter.ship_warning': 'Cảnh báo',
'map.filter.ship_warning_placeholder': 'Chọn loại cảnh báo',
'map.filter.area_type': 'Loại khu vực',
};

View File

@@ -2,6 +2,10 @@ export default {
'menu.sgw.map': 'Bản đồ',
'menu.sgw.trips': 'Chuyến đi',
'menu.sgw.ships': 'Quản lý tàu',
'menu.manager.sgw.fishes': 'Loài cá',
'menu.manager.sgw.zones': 'Khu vực',
'menu.sgw.fishes': 'Loài cá',
'menu.sgw.zones': 'Khu vực cấm',
// Manager menu
'menu.manager.sgw.fishes': 'Quản lý loài cá',
'menu.manager.sgw.zones': 'Quản lý khu vực cấm',
};

View File

@@ -0,0 +1,16 @@
export default {
'photo.main': 'Ảnh chính',
'photo.sub': 'Ảnh phụ',
'photo.upload': 'Tải ảnh lên',
'photo.delete': 'Xóa ảnh',
'photo.delete.confirm': 'Bạn có chắc chắn muốn xóa ảnh này không?',
'photo.delete.success': 'Xóa ảnh thành công',
'photo.delete.fail': 'Xóa ảnh thất bại',
'photo.upload.success': 'Tải ảnh lên thành công',
'photo.upload.fail': 'Tải ảnh lên thất bại',
'photo.upload.limit': 'Kích thước ảnh không được vượt quá 5MB',
'photo.upload.format': 'Chỉ hỗ trợ định dạng JPG/PNG',
'photo.manage': 'Quản lý ảnh',
'photo.change': 'Thay đổi ảnh',
'photo.add': 'Thêm ảnh',
};

View File

@@ -0,0 +1,16 @@
export default {
// Pages - Ship List
'pages.ships.reg_number': 'Số đăng ký',
'pages.ships.name': 'Tên tàu',
'pages.ships.type': 'Loại tàu',
'pages.ships.home_port': 'Cảng đăng ký',
'pages.ships.option': 'Tùy chọn',
// Pages - Ship Create/Edit
'pages.ship.create.text': 'Tạo tàu mới',
'pages.ships.create.title': 'Thêm tàu mới',
'pages.ships.edit.title': 'Chỉnh sửa tàu',
// Pages - Things (Ship related)
'pages.things.fishing_license_expiry_date': 'Ngày hết hạn giấy phép',
};

View File

@@ -0,0 +1,64 @@
export default {
// Pages - Trip List
'pages.trips.name': 'Tên chuyến đi',
'pages.trips.ship_id': 'Tàu',
'pages.trips.departure_time': 'Thời gian khởi hành',
'pages.trips.arrival_time': 'Thời gian về',
'pages.trips.status': 'Trạng thái',
'pages.trips.status.created': 'Đã tạo',
'pages.trips.status.pending_approval': 'Chờ phê duyệt',
'pages.trips.status.approved': 'Đã phê duyệt',
'pages.trips.status.active': 'Đang hoạt động',
'pages.trips.status.completed': 'Hoàn thành',
'pages.trips.status.cancelled': 'Đã hủy',
// Pages - Date filters
'pages.date.yesterday': 'Hôm qua',
'pages.date.lastweek': 'Tuần trước',
'pages.date.lastmonth': 'Tháng trước',
// Pages - Things/Ship
'pages.things.createTrip.text': 'Tạo chuyến đi',
'pages.things.option': 'Tùy chọn',
// Trip badges
'trip.badge.active': 'Đang hoạt động',
'trip.badge.approved': 'Đã duyệt',
'trip.badge.cancelled': 'Đã hủy',
'trip.badge.completed': 'Hoàn thành',
'trip.badge.notApproved': 'Chưa duyệt',
'trip.badge.unknown': 'Không xác định',
'trip.badge.waitingApproval': 'Chờ duyệt',
// Cancel trip
'trip.cancelTrip.button': 'Hủy chuyến',
'trip.cancelTrip.placeholder': 'Nhập lý do hủy chuyến',
'trip.cancelTrip.reason': 'Lý do',
'trip.cancelTrip.title': 'Hủy chuyến đi',
'trip.cancelTrip.validation': 'Vui lòng nhập lý do',
// Trip cost
'trip.cost.amount': 'Số lượng',
'trip.cost.crewSalary': 'Lương thuyền viên',
'trip.cost.food': 'Thực phẩm',
'trip.cost.fuel': 'Nhiên liệu',
'trip.cost.grandTotal': 'Tổng cộng',
'trip.cost.iceSalt': 'Đá & Muối',
'trip.cost.price': 'Giá',
'trip.cost.total': 'Tổng',
'trip.cost.type': 'Loại',
'trip.cost.unit': 'Đơn vị',
// Trip gear
'trip.gear.name': 'Tên ngư cụ',
'trip.gear.quantity': 'Số lượng',
// Haul fish list
'trip.haulFishList.fishCondition': 'Tình trạng',
'trip.haulFishList.fishName': 'Tên cá',
'trip.haulFishList.fishRarity': 'Độ hiếm',
'trip.haulFishList.fishSize': 'Kích thước',
'trip.haulFishList.gearUsage': 'Ngư cụ sử dụng',
'trip.haulFishList.title': 'Danh sách đánh bắt',
'trip.haulFishList.weight': 'Trọng lượng',
};

View File

@@ -1,6 +1,19 @@
import sgwFish from './sgw-fish-vi';
import sgwMap from './sgw-map-vi';
import sgwMenu from './sgw-menu-vi';
import sgwPhoto from './sgw-photo-vi';
import sgwShip from './sgw-ship-vi';
import sgwTrip from './sgw-trip-vi';
import sgwZone from './sgw-zone-vi';
export default {
'sgw.title': 'Hệ thống giám sát tàu cá',
'sgw.ship': 'Tàu',
...sgwMenu,
...sgwTrip,
...sgwMap,
...sgwShip,
...sgwFish,
...sgwPhoto,
...sgwZone,
};

View File

@@ -0,0 +1,31 @@
export default {
// Table columns
'banzones.name': 'Tên khu vực',
'banzones.area': 'Tỉnh/Thành phố',
'banzones.description': 'Mô tả',
'banzones.type': 'Loại',
'banzones.conditions': 'Điều kiện',
'banzones.state': 'Trạng thái',
'banzones.action': 'Hành động',
'banzones.title': 'khu vực',
'banzones.create': 'Tạo khu vực',
// Zone types
'banzone.area.fishing_ban': 'Cấm khai thác',
'banzone.area.move_ban': 'Cấm di chuyển',
'banzone.area.safe': 'Khu vực an toàn',
// Status
'banzone.is_enable': 'Kích hoạt',
'banzone.is_unenabled': 'Vô hiệu hóa',
// Shape types
'banzone.polygon': 'Đa giác',
'banzone.polyline': 'Đường kẻ',
'banzone.circle': 'Hình tròn',
// Notifications
'banzone.notify.delete_zone_success': 'Xóa khu vực thành công',
'banzone.notify.delete_zone_confirm': 'Bạn có chắc chắn muốn xóa khu vực',
'banzone.notify.fail': 'Thao tác thất bại!',
};

View File

@@ -1,5 +1,5 @@
import { SHIP_SOS_WS_URL } from '@/constants/slave/sgw/websocket';
import { wsClient } from '@/utils/slave/sgw/wsClient';
import { wsClient } from '@/utils/wsClient';
import { useCallback, useState } from 'react';
export default function useGetShipSos() {

View File

@@ -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 }}>

View File

@@ -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={
<Button
size="small"
variant="solid"
color="green"
icon={<CheckOutlined />}
onClick={() => setModalVisit(true)}
>
{intl.formatMessage({
id: 'common.confirm',
})}
</Button>
React.isValidElement(trigger) ? (
trigger
) : (
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={() => setIsOpen(true)}
>
{intl.formatMessage({
id: 'common.confirm',
})}
</Button>
)
}
>
<ProForm.Group>

View File

@@ -1,17 +1,15 @@
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,
FilterOutlined,
} from '@ant-design/icons';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd';
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
import { Button, Flex, message, Progress, Tooltip } from 'antd';
import moment from 'moment';
import { useRef, useState } from 'react';
import AlarmDescription from './components/AlarmDescription';
@@ -24,6 +22,9 @@ const AlarmPage = () => {
const [thingFilterDatas, setThingFilterDatas] = useState<string[]>([]);
const intl = useIntl();
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({
@@ -199,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>
}
onFinish={(isReload) => {
if (isReload) tableRef.current?.reload();
}}
>
<Button danger icon={<DeleteOutlined />} size="small">
<FormattedMessage id="master.alarms.unconfirm.title" />
</Button>
</Popconfirm>
/>
) : (
<AlarmFormConfirm
isOpen={isConfirmModalOpen}
setIsOpen={setIsConfirmModalOpen}
alarm={alarm}
message={messageApi}
onFinish={(isReload) => {

View File

@@ -0,0 +1,421 @@
import Footer from '@/components/Footer';
import LangSwitches from '@/components/Lang/LanguageSwitcherAuth';
import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth';
import { THEME_KEY } from '@/constants';
import { ROUTE_LOGIN } from '@/constants/routes';
import { apiUserResetPassword } from '@/services/master/UserController';
import { parseAccessToken } from '@/utils/jwt';
import { getDomainTitle, getLogoImage } from '@/utils/logo';
import { ProForm, ProFormText } from '@ant-design/pro-components';
import {
FormattedMessage,
history,
useIntl,
useSearchParams,
} from '@umijs/max';
import { Button, ConfigProvider, Image, message, Result, theme } from 'antd';
import { useEffect, useState } from 'react';
import backgroundImg from '../../../../public/background.png';
import mobifontLogo from '../../../../public/mobifont-logo.png';
type ResetPasswordFormValues = {
password: string;
confirmPassword: string;
};
const ResetPassword = () => {
const [searchParams] = useSearchParams();
const resetPasswordToken = searchParams.get('token');
const [tokenValid, setTokenValid] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [isDark, setIsDark] = useState(
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') === 'dark',
);
const { token } = theme.useToken();
const [messageApi, contextHolder] = message.useMessage();
const intl = useIntl();
// Listen for theme changes from ThemeSwitcherAuth
useEffect(() => {
const handleThemeChange = (e: Event) => {
const customEvent = e as CustomEvent<{ theme: 'light' | 'dark' }>;
setIsDark(customEvent.detail.theme === 'dark');
};
window.addEventListener('theme-change', handleThemeChange as EventListener);
return () => {
window.removeEventListener(
'theme-change',
handleThemeChange as EventListener,
);
};
}, []);
useEffect(() => {
const tokenParsed = resetPasswordToken
? parseAccessToken(resetPasswordToken)
: null;
setTokenValid(
tokenParsed?.expriresAt
? tokenParsed.expriresAt * 1000 > Date.now()
: true,
);
}, [resetPasswordToken]);
const handleSubmit = async (values: ResetPasswordFormValues) => {
setLoading(true);
try {
const resp = await apiUserResetPassword({
token: resetPasswordToken!,
password: values.password,
confirm_password: values.confirmPassword,
});
messageApi.success(
intl.formatMessage({
id: 'master.auth.reset.success',
defaultMessage: 'Đặt lại mật khẩu thành công!',
}),
);
setSuccess(true);
setTimeout(() => {
history.push(ROUTE_LOGIN);
}, 2000);
} catch (error) {
console.error('Reset password error:', error);
messageApi.error(
intl.formatMessage({
id: 'master.auth.reset.error',
defaultMessage: 'Có lỗi xảy ra, vui lòng thử lại!',
}),
);
} finally {
setLoading(false);
}
};
// Token invalid state
if (!tokenValid) {
return (
<ConfigProvider
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<div
style={{
backgroundColor: isDark ? '#000' : token.colorBgContainer,
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
backgroundColor: token.colorBgSolidActive,
backdropFilter: 'blur(4px)',
padding: '48px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '500px',
width: '100%',
}}
>
<Result
icon={<Image src={getLogoImage()} preview={false} width={80} />}
title={
<span style={{ color: token.colorBgContainer }}>
{intl.formatMessage({
id: getDomainTitle(),
defaultMessage: 'Smatec',
})}
</span>
}
subTitle={
<span style={{ color: token.colorBgContainer }}>
{intl.formatMessage({
id: 'master.auth.reset.invalid',
defaultMessage:
'Liên kết đặt lại mật khẩu không hợp lệ hoặc đã hết hạn.',
})}
</span>
}
extra={
<Button
type="primary"
onClick={() => history.push(ROUTE_LOGIN)}
>
{intl.formatMessage({
id: 'master.auth.backToLogin.title',
defaultMessage: 'Quay về trang Đăng nhập',
})}
</Button>
}
/>
</div>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
</div>
</ConfigProvider>
);
}
// Success state
if (success) {
return (
<ConfigProvider
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<div
style={{
backgroundImage: `url(${backgroundImg})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
backgroundColor: token.colorBgLayout,
padding: '48px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '500px',
width: '100%',
}}
>
<Result
status="success"
title={
<span style={{ color: token.colorText }}>
{intl.formatMessage({
id: 'master.auth.reset.successTitle',
defaultMessage: 'Mật khẩu đã được đặt lại!',
})}
</span>
}
subTitle={
<span style={{ color: token.colorTextDescription }}>
{intl.formatMessage({
id: 'master.auth.reset.successMessage',
defaultMessage: 'Đang chuyển hướng đến trang đăng nhập...',
})}
</span>
}
/>
</div>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
</div>
</ConfigProvider>
);
}
// Reset password form
return (
<ConfigProvider
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<div
style={{
backgroundColor: 'transparent',
height: '100vh',
backgroundImage: `url(${backgroundImg})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{contextHolder}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
position: 'relative',
}}
>
<div
style={{
backgroundColor: 'rgba(0, 0, 0, 0.65)',
backdropFilter: 'blur(4px)',
padding: '40px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
width: '100%',
maxWidth: '400px',
}}
>
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
<Image
src={getLogoImage()}
preview={false}
width={60}
style={{ marginBottom: '16px' }}
/>
<h2
style={{
color: token.colorBgContainer,
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '8px',
}}
>
{intl.formatMessage({
id: getDomainTitle(),
defaultMessage: 'Smatec',
})}
</h2>
<Image preview={false} src={mobifontLogo} />
</div>
<ProForm
submitter={{
searchConfig: {
submitText: intl.formatMessage({
id: 'master.auth.reset.submit',
defaultMessage: 'Đặt lại mật khẩu',
}),
},
submitButtonProps: {
loading,
size: 'large',
block: true,
},
resetButtonProps: {
style: { display: 'none' },
},
}}
onFinish={handleSubmit}
>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
placeholder: intl.formatMessage({
id: 'master.auth.reset.newPassword.placeholder',
defaultMessage: 'Nhập mật khẩu mới',
}),
autoComplete: 'password',
}}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.password.required"
defaultMessage="Password is required"
/>
),
},
{
pattern:
/^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*.]{6,16}$/g,
message: intl.formatMessage({
id: 'master.profile.change-password.password.strong',
defaultMessage:
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
}),
},
{
validator: async (rule, value) => {
if (value && value.length < 8) {
return Promise.reject(
intl.formatMessage({
id: 'master.users.password.minimum',
defaultMessage: 'Minimum password length is 8',
}),
);
}
return Promise.resolve();
},
},
]}
/>
<ProFormText.Password
name="confirmPassword"
fieldProps={{
size: 'large',
placeholder: intl.formatMessage({
id: 'master.users.confirmPassword',
defaultMessage: 'Xác nhận mật khẩu',
}),
autoComplete: 'confirmPassword',
}}
dependencies={['password']}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.confirmPassword.required',
defaultMessage: 'Vui lòng xác nhận mật khẩu!',
}),
},
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(
new Error(
intl.formatMessage({
id: 'master.users.confirmPassword.mismatch',
defaultMessage: 'Mật khẩu xác nhận không khớp!',
}),
),
);
},
}),
]}
/>
</ProForm>
<div style={{ textAlign: 'center', marginTop: '16px' }}>
<Button
type="link"
// style={{ color: token.colorBgContainer }}
onClick={() => history.push(ROUTE_LOGIN)}
>
{intl.formatMessage({
id: 'master.auth.backToLogin.title',
defaultMessage: 'Quay về trang Đăng nhập',
})}
</Button>
</div>
</div>
</div>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
<div
style={{
backgroundColor: 'transparent',
position: 'absolute',
bottom: 0,
zIndex: 99,
width: '100%',
}}
>
<Footer />
</div>
</div>
</ConfigProvider>
);
};
export default ResetPassword;

View File

@@ -1,48 +1,100 @@
import Footer from '@/components/Footer';
import LangSwitches from '@/components/Lang/LanguageSwitcherAuth';
import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth';
import { THEME_KEY } from '@/constants';
import { ROUTER_HOME } from '@/constants/routes';
import { apiLogin, apiQueryProfile } from '@/services/master/AuthController';
import { parseJwt } from '@/utils/jwt';
import { getLogoImage } from '@/utils/logo';
import { getBrowserId, getToken, removeToken, setToken } from '@/utils/storage';
import {
apiForgotPassword,
apiLogin,
apiQueryProfile,
} from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt';
import { getDomainTitle, getLogoImage } from '@/utils/logo';
import {
getBrowserId,
getRefreshToken,
removeAccessToken,
removeRefreshToken,
setAccessToken,
setRefreshToken,
} from '@/utils/storage';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
import { history, useIntl, useModel } from '@umijs/max';
import { Image, theme } from 'antd';
import { useEffect } from 'react';
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
import { Button, ConfigProvider, Flex, Image, message, theme } from 'antd';
import { CSSProperties, useEffect, useState } from 'react';
import { flushSync } from 'react-dom';
import mobifontLogo from '../../../public/mobifont-logo.png';
type LoginType = 'login' | 'forgot';
// Form wrapper with animation
const FormWrapper = ({
children,
key,
}: {
children: React.ReactNode;
key: string;
}) => {
const style: CSSProperties = {
animation: 'fadeInSlide 0.4s ease-out forwards',
};
return (
<div key={key} style={style}>
<style>{`
@keyframes fadeInSlide {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
`}</style>
{children}
</div>
);
};
const LoginPage = () => {
const [isDark, setIsDark] = useState(
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') === 'dark',
);
const { token } = theme.useToken();
const [messageApi, contextHolder] = message.useMessage();
const urlParams = new URL(window.location.href).searchParams;
const redirect = urlParams.get('redirect');
const intl = useIntl();
const { setInitialState } = useModel('@@initialState');
const getDomainTitle = () => {
switch (process.env.DOMAIN_ENV) {
case 'gms':
return 'gms.title';
case 'sgw':
return 'sgw.title';
case 'spole':
return 'spole.title';
default:
return 'Smatec Master';
}
};
const [loginType, setLoginType] = useState<LoginType>('login');
// Listen for theme changes from ThemeSwitcherAuth
useEffect(() => {
const handleThemeChange = (e: Event) => {
const customEvent = e as CustomEvent<{ theme: 'light' | 'dark' }>;
setIsDark(customEvent.detail.theme === 'dark');
};
window.addEventListener('theme-change', handleThemeChange as EventListener);
return () => {
window.removeEventListener(
'theme-change',
handleThemeChange as EventListener,
);
};
}, []);
const checkLogin = async () => {
const token = getToken();
if (!token) {
const refreshToken = getRefreshToken();
if (!refreshToken) {
return;
}
const parsed = parseJwt(token);
const { exp } = parsed;
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
if (exp - now < oneHour) {
removeToken();
const isRefreshTokenExpired = checkRefreshTokenExpired(refreshToken);
if (isRefreshTokenExpired) {
removeAccessToken();
removeRefreshToken();
return;
} else {
const userInfo = await apiQueryProfile();
if (userInfo) {
@@ -66,147 +118,263 @@ const LoginPage = () => {
}, []);
const handleLogin = async (values: MasterModel.LoginRequestBody) => {
try {
const { email, password } = values;
const resp = await apiLogin({
guid: getBrowserId(),
email,
password,
});
if (resp?.token) {
setToken(resp.token);
const userInfo = await apiQueryProfile();
if (userInfo) {
flushSync(() => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
}));
});
}
if (redirect) {
history.push(redirect);
} else {
history.push(ROUTER_HOME);
const { email, password } = values;
if (loginType === 'login') {
try {
const resp = await apiLogin({
guid: getBrowserId(),
email,
password,
});
if (resp?.token) {
setAccessToken(resp.token);
setRefreshToken(resp.refresh_token);
const userInfo = await apiQueryProfile();
if (userInfo) {
flushSync(() => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
}));
});
}
if (redirect) {
history.push(redirect);
} else {
history.push(ROUTER_HOME);
}
}
} catch (error) {
console.error('Login error:', error);
}
} else {
try {
const host = window.location.origin;
const body: MasterModel.ForgotPasswordRequestBody = {
email: email,
host: host,
};
const resp = await apiForgotPassword(body);
if (!resp.error) {
messageApi.success(
intl.formatMessage({
id: 'master.auth.forgot.message.success',
defaultMessage:
'Request sent successfully, please check your email!',
}),
);
}
} catch (error) {
console.error('Error when send reset password: ', error);
messageApi.error(
intl.formatMessage({
id: 'master.auth.forgot.message.fail',
defaultMessage: 'Request failed, please try again later!',
}),
);
}
} catch (error) {
console.error('Login error:', error);
}
};
return (
<div
style={{
backgroundColor: 'white',
height: '100vh',
<ConfigProvider
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<LoginFormPage
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
logo={getLogoImage()}
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
title={
<span style={{ color: token.colorBgContainer }}>
{intl.formatMessage({
id: getDomainTitle(),
defaultMessage: 'Smatec',
})}
</span>
}
containerStyle={{
backgroundColor: 'rgba(0, 0, 0,0.65)',
backdropFilter: 'blur(4px)',
}}
subTitle={<Image preview={false} src={mobifontLogo} />}
submitter={{
searchConfig: {
submitText: intl.formatMessage({
id: 'master.auth.login.title',
defaultMessage: 'Đăng nhập',
}),
},
}}
onFinish={async (values: MasterModel.LoginRequestBody) =>
handleLogin(values)
}
>
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: (
<UserOutlined
style={{
color: token.colorText,
}}
className={'prefixIcon'}
/>
),
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.auth.validation.email',
defaultMessage: 'Email không được để trống!',
}),
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
autoComplete: 'current-password',
prefix: (
<LockOutlined
style={{
color: token.colorText,
}}
className={'prefixIcon'}
/>
),
}}
placeholder={intl.formatMessage({
id: 'master.auth.password',
defaultMessage: 'Mật khẩu',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.auth.validation.password',
defaultMessage: 'Mật khẩu không được để trống!',
}),
},
]}
/>
</>
</LoginFormPage>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
<div
style={{
backgroundColor: 'transparent',
position: 'absolute',
bottom: 0,
zIndex: 99,
width: '100%',
backgroundColor: isDark ? '#000' : 'white',
height: '100vh',
}}
>
<Footer />
{contextHolder}
<LoginFormPage
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
logo={getLogoImage()}
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
title={
<span style={{ color: token.colorBgContainer }}>
{intl.formatMessage({
id: getDomainTitle(),
defaultMessage: 'Smatec',
})}
</span>
}
containerStyle={{
backgroundColor: 'rgba(0, 0, 0,0.65)',
backdropFilter: 'blur(4px)',
}}
subTitle={<Image preview={false} src={mobifontLogo} />}
submitter={{
searchConfig: {
submitText:
loginType === 'login'
? intl.formatMessage({
id: 'master.auth.login.title',
defaultMessage: 'Đăng nhập',
})
: intl.formatMessage({
id: 'master.auth.forgot.button.title',
defaultMessage: 'Đăng nhập',
}),
},
}}
onFinish={async (values: MasterModel.LoginRequestBody) =>
handleLogin(values)
}
>
<FormWrapper key={loginType}>
{loginType === 'login' && (
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: (
<UserOutlined
// style={{
// color: token.colorText,
// }}
className={'prefixIcon'}
/>
),
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.email.required"
defaultMessage="The email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
autoComplete: 'current-password',
prefix: <LockOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.password',
defaultMessage: 'Mật khẩu',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.auth.validation.password',
defaultMessage: 'Mật khẩu không được để trống!',
}),
},
]}
/>
</>
)}
{loginType === 'forgot' && (
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: <UserOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.email.required"
defaultMessage="The email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
]}
/>
</>
)}
</FormWrapper>
<Flex
justify="flex-end"
align="flex-start"
style={{ marginBlockEnd: 16 }}
>
<Button
type="link"
size="small"
onClick={() => {
if (loginType === 'login') {
setLoginType('forgot');
} else {
setLoginType('login');
}
}}
>
{loginType === 'login' ? (
<FormattedMessage
id="master.auth.forgot.title"
defaultMessage="Quên mật khẩu?"
/>
) : (
<FormattedMessage
id="master.auth.backToLogin.title"
defaultMessage="Back to Login"
/>
)}
</Button>
</Flex>
</LoginFormPage>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
<div
style={{
backgroundColor: 'transparent',
position: 'absolute',
bottom: 0,
zIndex: 99,
width: '100%',
}}
>
<Footer />
</div>
</div>
</div>
</ConfigProvider>
);
};
export default LoginPage;

View 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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -0,0 +1,169 @@
import { apiQueryCamera } from '@/services/master/MessageController';
import { apiGetThingDetail } from '@/services/master/ThingController';
import { wsClient } from '@/utils/wsClient';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useModel, useParams } from '@umijs/max';
import { Button, Col, Row, Space, Spin } from 'antd';
import { useEffect, useState } from 'react';
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 [isModalVisible, setIsModalVisible] = useState(false);
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('/mqtt', false);
const unsubscribe = wsClient.subscribe((data: any) => {
console.log('Received WS data:', data);
});
return () => {
unsubscribe();
};
}, []);
// Fetch thing info on mount
useEffect(() => {
const fetchThingInfo = async () => {
if (!thingId) return;
try {
setLoading(true);
const thingData = await apiGetThingDetail(thingId);
setThing(thingData);
} catch (error) {
console.error('Failed to fetch thing info:', error);
} finally {
setLoading(false);
}
};
fetchThingInfo();
}, [thingId]);
// 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 = () => {
setIsModalVisible(true);
};
const handleCloseModal = () => {
setIsModalVisible(false);
};
const handleSubmitCamera = (values: any) => {
console.log('Camera values:', values);
// TODO: Call API to create camera
handleCloseModal();
};
// Helper function to determine which camera component to render
const renderCameraRecordingComponent = () => {
const thingType = thing?.metadata?.type;
if (thingType === 'gmsv5') {
return <ConfigCameraV5 thing={thing} />;
}
if (thingType === 'spole' || thingType === 'gmsv6') {
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
}
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
};
return (
<Spin spinning={loading}>
<PageContainer
header={{
title: (
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => history.push('/manager/devices')}
/>
<span>{thing?.name || 'Loading...'}</span>
</Space>
),
}}
>
<Row gutter={24}>
{/* Left Column - Camera Table */}
<Col xs={24} md={10} lg={8}>
<CameraTable
cameraData={cameras}
onCreateCamera={handleOpenModal}
onReload={fetchCameraConfig}
loading={cameraLoading}
/>
</Col>
{/* Right Column - Camera Recording Configuration */}
<Col xs={24} md={14} lg={16}>
{renderCameraRecordingComponent()}
</Col>
</Row>
{/* Create Camera Modal */}
<CameraFormModal
open={isModalVisible}
onCancel={handleCloseModal}
onSubmit={handleSubmitCamera}
/>
</PageContainer>
</Spin>
);
};
export default CameraConfigPage;

View File

@@ -0,0 +1,166 @@
import IconFont from '@/components/IconFont';
import { StatisticCard } from '@ant-design/pro-components';
import {
Divider,
Flex,
GlobalToken,
Grid,
theme,
Tooltip,
Typography,
} from 'antd';
const { Text } = Typography;
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;
name?: string;
}
const getIconTypeByEntity = (
entity: MasterModel.Entity,
token: GlobalToken,
): IconTypeResult => {
if (!entity.config || entity.config.length === 0) {
return {
iconType: 'icon-not-found',
color: token.colorPrimary,
};
}
switch (entity.config[0].subType) {
case 'smoke':
return {
iconType: 'icon-smoke1',
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
name: entity.value === 0 ? 'Bình thường' : 'Phát hiện',
};
case 'heat':
return {
iconType: 'icon-fire',
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
name: entity.value === 0 ? 'Bình thường' : 'Phát hiện',
};
case 'motion':
return {
iconType: 'icon-motion',
color: entity.value === 0 ? token.colorTextBase : token.colorInfoActive,
name: entity.value === 0 ? 'Không' : 'Phát hiện',
};
case 'flood':
return {
iconType: 'icon-water-ingress',
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
name: entity.value === 0 ? 'Không' : 'Phát hiện',
};
case 'door':
return {
iconType: entity.value === 0 ? 'icon-door' : 'icon-door-open1',
color: entity.value === 0 ? token.colorText : token.colorWarning,
name: entity.value === 0 ? 'Đóng' : 'Mở',
};
case 'button':
return {
iconType: 'icon-alarm-button',
color: entity.value === 0 ? token.colorText : token.colorSuccess,
name: entity.value === 0 ? 'Tắt' : 'Bật',
};
default:
return {
iconType: 'icon-not-found',
color: token.colorPrimary,
};
}
};
const StatisticCardItem = (entity: MasterModel.Entity, token: GlobalToken) => {
const { iconType, color, name } = getIconTypeByEntity(entity, token);
return (
<StatisticCard
bordered={false}
key={entity.entityId}
style={{
borderRadius: 8,
background: token.colorBgContainer,
border: `1px solid ${token.colorBorder}`,
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.boxShadow = `0 4px 12px ${token.colorPrimary}20`;
el.style.transform = 'translateY(-2px)';
}}
onMouseLeave={(e) => {
const el = e.currentTarget as HTMLElement;
el.style.boxShadow = 'none';
el.style.transform = 'translateY(0)';
}}
statistic={{
icon: (
<Tooltip title={entity.name}>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 56,
marginBottom: 8,
}}
>
<IconFont type={iconType} style={{ color, fontSize: 32 }} />
</div>
</Tooltip>
),
title: (
<Text
ellipsis={{ tooltip: entity.name }}
style={{ fontSize: 13, fontWeight: 500 }}
>
{entity.name}
</Text>
),
value: name,
valueStyle: { fontSize: 12, color, fontWeight: 600, marginTop: 8 },
}}
/>
);
};
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" gap="middle">
<Divider orientation="left">Cảm biến</Divider>
{binarySensors.map((entity) => (
<div
key={entity.entityId}
style={{
width: 'fit-content',
}}
>
{StatisticCardItem(entity, token)}
</div>
))}
</Flex>
);
};
export default BinarySensors;

View 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;

View File

@@ -0,0 +1,121 @@
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 { Divider, Flex, 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
bodyStyle={{
paddingInline: 12,
paddingBlock: 8,
}}
title={intl.formatMessage({
id: 'master.thing.detail.alarmList.title',
})}
colSpan={{ xs: 24, sm: 24, md: 24, lg: 6, xl: 6 }}
bordered
>
<DeviceAlarmList key="thing-alarms-key" thingId={thingId || ''} />
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 24, lg: 18, xl: 18 }}>
<Flex wrap gap="small">
<BinarySensors nodeConfigs={nodeConfigs} />
<Divider orientation="left">Trạng thái</Divider>
</Flex>
</ProCard>
</ProCard>
</PageContainer>
);
};
export default DetailDevicePage;

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

@@ -0,0 +1,168 @@
import { apiResetUserPassword } from '@/services/master/UserController';
import { LockOutlined } from '@ant-design/icons';
import {
ModalForm,
ProFormInstance,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { MessageInstance } from 'antd/es/message/interface';
import { useRef } from 'react';
type ResetPasswordProps = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
user: MasterModel.UserResponse;
message: MessageInstance;
onSuccess?: (isSuccess: boolean) => void;
};
type ResetPasswordFormValues = {
password: string;
confirmPassword: string;
};
const ResetPassword = ({
isOpen,
setIsOpen,
user,
message,
onSuccess,
}: ResetPasswordProps) => {
const formRef = useRef<ProFormInstance<ResetPasswordFormValues>>();
const intl = useIntl();
return (
<ModalForm<ResetPasswordFormValues>
title={`${intl.formatMessage({
id: 'master.users.resetPassword.modal.title',
defaultMessage: 'Reset Password',
})}: ${user.metadata?.full_name || user.email}`}
open={isOpen}
onOpenChange={setIsOpen}
formRef={formRef}
autoFocusFirstInput
modalProps={{
destroyOnHidden: true,
}}
onFinish={async (values: ResetPasswordFormValues) => {
try {
const resp = await apiResetUserPassword(
user.id || '',
values.password,
);
message.success(
intl.formatMessage({
id: 'master.users.resetPassword.success',
defaultMessage: 'Reset password successfully',
}),
);
formRef.current?.resetFields();
onSuccess?.(true);
return true;
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.users.resetPassword.error',
defaultMessage: 'Failed to reset password',
}),
);
onSuccess?.(false);
return false;
}
}}
>
<ProFormText.Password
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.password.required"
defaultMessage="Password is required"
/>
),
},
{
pattern: /^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*.]{6,16}$/g,
message: intl.formatMessage({
id: 'master.profile.change-password.password.strong',
defaultMessage:
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
}),
},
{
validator: async (rule, value) => {
if (value && value.length < 8) {
return Promise.reject(
intl.formatMessage({
id: 'master.users.password.minimum',
defaultMessage: 'Minimum password length is 8',
}),
);
}
return Promise.resolve();
},
},
]}
name="password"
label={intl.formatMessage({
id: 'master.users.password',
defaultMessage: 'Password',
})}
fieldProps={{
prefix: <LockOutlined />,
autoComplete: 'new-password',
}}
placeholder={intl.formatMessage({
id: 'master.users.password.placeholder',
defaultMessage: 'Password',
})}
/>
<ProFormText.Password
dependencies={['password']}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.confirmPassword.required"
defaultMessage="Please confirm your password"
/>
),
},
({ getFieldValue }) => ({
validator(_: unknown, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(
new Error(
intl.formatMessage({
id: 'master.users.confirmPassword.mismatch',
defaultMessage: 'The two passwords do not match',
}),
),
);
},
}),
]}
name="confirmPassword"
label={intl.formatMessage({
id: 'master.users.confirmPassword',
defaultMessage: 'Confirm Password',
})}
fieldProps={{
prefix: <LockOutlined />,
autoComplete: 'new-password',
}}
placeholder={intl.formatMessage({
id: 'master.users.confirmPassword.placeholder',
defaultMessage: 'Confirm Password',
})}
/>
</ModalForm>
);
};
export default ResetPassword;

View File

@@ -1,4 +1,4 @@
import IconFont from '@/components/IconFont';
import TooltipIconFontButton from '@/components/shared/TooltipIconFontButton';
import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants';
import {
@@ -19,12 +19,16 @@ import {
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Button, Grid, Popconfirm, theme } from 'antd';
import { Button, Grid, Popconfirm, Space, theme } from 'antd';
import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react';
import CreateUser from './components/CreateUser';
import ResetPassword from './components/ResetPassword';
type ResetUserPaswordProps = {
user: MasterModel.UserResponse | null;
isOpen: boolean;
};
const ManagerUserPage = () => {
const { useBreakpoint } = Grid;
const intl = useIntl();
@@ -41,11 +45,21 @@ const ManagerUserPage = () => {
string | string[] | null
>(null);
const [resetPasswordUser, setResetPasswordUser] =
useState<ResetUserPaswordProps>({
user: null,
isOpen: false,
});
const handleClickAssign = (user: MasterModel.UserResponse) => {
const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`;
history.push(path);
};
const handleClickResetPassword = (user: MasterModel.UserResponse) => {
setResetPasswordUser({ user: user, isOpen: true });
};
const columns: ProColumns<MasterModel.UserResponse>[] = [
{
key: 'email',
@@ -123,14 +137,28 @@ const ManagerUserPage = () => {
hideInSearch: true,
render: (_, user) => {
return (
<>
<Button
<Space>
<TooltipIconFontButton
shape="default"
size="small"
icon={<IconFont type="icon-assign" />}
iconFontName="icon-assign"
tooltip={intl.formatMessage({
id: 'master.users.change_role.title',
defaultMessage: 'Set Permissions',
})}
onClick={() => handleClickAssign(user)}
/>
</>
<TooltipIconFontButton
shape="default"
size="small"
iconFontName="icon-reset-password"
tooltip={intl.formatMessage({
id: 'master.users.resetPassword.title',
defaultMessage: 'Reset Password',
})}
onClick={() => handleClickResetPassword(user)}
/>
</Space>
);
},
},
@@ -181,6 +209,19 @@ const ManagerUserPage = () => {
return (
<>
{contextHolder}
{resetPasswordUser.user && (
<ResetPassword
message={messageApi}
isOpen={resetPasswordUser.isOpen}
user={resetPasswordUser.user}
setIsOpen={(isOpen) =>
setResetPasswordUser((prev) => ({ ...prev, isOpen }))
}
onSuccess={(isSuccess) => {
if (isSuccess) actionRef.current?.reload();
}}
/>
)}
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>
<TreeGroup

View File

@@ -0,0 +1,317 @@
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { ModalForm } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, DatePicker, Flex, Form, Input, Select, Space } from 'antd';
import { FormListFieldData } from 'antd/lib';
import dayjs from 'dayjs';
import { useEffect } from 'react';
import { AreaCondition, ZoneFormField } from '../type';
const { RangePicker } = DatePicker;
// Transform form values to match AreaCondition type
const transformToAreaCondition = (values: any[]): AreaCondition[] => {
return values.map((item) => {
const { type } = item;
switch (type) {
case 'month_range': {
// RangePicker for month returns [Dayjs, Dayjs]
const [from, to] = item.from ?? [];
return {
type: 'month_range',
from: from?.month() ?? 0, // 0-11
to: to?.month() ?? 0,
};
}
case 'date_range': {
// RangePicker for date returns [Dayjs, Dayjs]
const [from, to] = item.from ?? [];
return {
type: 'date_range',
from: from?.format('YYYY-MM-DD') ?? '',
to: to?.format('YYYY-MM-DD') ?? '',
};
}
case 'length_limit': {
return {
type: 'length_limit',
min: Number(item.min) ?? 0,
max: Number(item.max) ?? 0,
};
}
default:
return item;
}
});
};
// Transform AreaCondition to form values
const transformToFormValues = (conditions?: AreaCondition[]): any[] => {
if (!conditions || conditions.length === 0) return [{}];
return conditions.map((condition) => {
switch (condition.type) {
case 'month_range': {
return {
type: 'month_range',
from: [dayjs().month(condition.from), dayjs().month(condition.to)],
};
}
case 'date_range': {
return {
type: 'date_range',
from: [dayjs(condition.from), dayjs(condition.to)],
};
}
case 'length_limit': {
return condition;
}
default:
return {};
}
});
};
const ConditionRow = ({
field,
remove,
}: {
field: FormListFieldData;
remove: (name: number) => void;
}) => {
const selectedType = Form.useWatch([
ZoneFormField.AreaConditions,
field.name,
'type',
]);
const intl = useIntl();
return (
<Flex gap="middle" style={{ marginBottom: 16 }}>
<Space align="baseline">
<Form.Item
name={[field.name, 'type']}
label={intl.formatMessage({
id: 'banzone.category.name',
defaultMessage: 'Danh mục',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.category.not_empty',
defaultMessage: 'Category cannot be empty!',
}),
},
]}
>
<Select
placeholder={intl.formatMessage({
id: 'banzone.category.add',
defaultMessage: 'Add category',
})}
>
<Select.Option value="month_range">
<FormattedMessage
id="banzone.condition.yearly_select"
defaultMessage="Yearly"
/>
</Select.Option>
<Select.Option value="date_range">
<FormattedMessage
id="banzone.condition.specific_time_select"
defaultMessage="Specific date"
/>
</Select.Option>
<Select.Option value="length_limit">
<FormattedMessage
id="banzone.condition.length_limit"
defaultMessage="Length limit"
/>
</Select.Option>
</Select>
</Form.Item>
{selectedType !== undefined &&
(selectedType === 'length_limit' ? (
<Form.Item
label={intl.formatMessage({
id: 'banzone.condition.length_limit',
defaultMessage: 'Length limit',
})}
required
>
<Space>
<Form.Item
name={[field.name, 'min']}
noStyle
rules={[
{
required: true,
message: intl.formatMessage({
id: 'common.not_empty',
defaultMessage: 'Cannot be empty!',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'banzone.condition.length_limit_min',
defaultMessage: 'Minimum',
})}
type="number"
/>
</Form.Item>
<Form.Item
name={[field.name, 'max']}
noStyle
rules={[
{
required: true,
message: intl.formatMessage({
id: 'common.not_empty',
defaultMessage: 'Cannot be empty!',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'banzone.condition.length_limit_max',
defaultMessage: 'Maximum',
})}
type="number"
/>
</Form.Item>
</Space>
</Form.Item>
) : (
<Form.Item
name={[field.name, 'from']}
label={intl.formatMessage({
id: 'banzone.condition.ban_time',
defaultMessage: 'Time',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'common.not_empty',
defaultMessage: 'Cannot be empty!',
}),
},
]}
>
<RangePicker
picker={selectedType === 'month_range' ? 'month' : 'date'}
format={
selectedType === 'month_range' ? 'MM/YYYY' : 'DD/MM/YYYY'
}
/>
</Form.Item>
))}
<MinusCircleOutlined onClick={() => remove(field.name)} />
</Space>
</Flex>
);
};
interface AddConditionFormProps {
initialData?: AreaCondition[];
isVisible: boolean;
setVisible: (visible: boolean) => void;
onFinish?: (values: AreaCondition[]) => void;
}
const AddConditionForm = ({
initialData,
isVisible,
setVisible,
onFinish,
}: AddConditionFormProps) => {
const [form] = Form.useForm();
useEffect(() => {
if (isVisible) {
form.resetFields();
if (initialData && initialData.length > 0) {
form.setFieldsValue({ conditions: transformToFormValues(initialData) });
} else {
form.setFieldsValue({ conditions: [{}] });
}
}
}, [isVisible, initialData]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const transformedConditions = transformToAreaCondition(values.conditions);
onFinish?.(transformedConditions);
setVisible(false);
form.resetFields();
} catch (err) {
console.error('Validation failed', err);
}
};
return (
<ModalForm
form={form}
open={isVisible}
onOpenChange={(open) => {
if (!open) form.resetFields();
setVisible(open);
}}
title={
<FormattedMessage
id="banzone.category.add"
defaultMessage="Add category"
/>
}
width="600px"
submitter={{
searchConfig: { submitText: 'Lưu' },
render: (_, doms) => (
<Flex justify="center" gap={16}>
{doms}
</Flex>
),
}}
onFinish={handleSubmit}
>
<Form.List name={ZoneFormField.AreaConditions}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<ConditionRow
key={field.key}
field={field}
remove={remove}
// isFirst={index === 0}
/>
))}
<Form.Item>
<Flex justify="center">
<Button
type="dashed"
onClick={() => add()}
icon={<PlusOutlined />}
style={{ width: 300 }}
>
<FormattedMessage
id="banzone.category.add"
defaultMessage="Add category"
/>
</Button>
</Flex>
</Form.Item>
</>
)}
</Form.List>
</ModalForm>
);
};
export default AddConditionForm;

View File

@@ -0,0 +1,334 @@
import { getCircleRadius } from '@/utils/slave/sgw/geomUtils';
import { PlusOutlined } from '@ant-design/icons';
import {
ProFormDigit,
ProFormInstance,
ProFormItem,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Col, Flex, Form, Input, Row, Tag, Tooltip } from 'antd';
import { MutableRefObject, useMemo, useState } from 'react';
import {
CircleGeometry,
GeometryType,
PolygonGeometry,
tagPlusStyle,
validateGeometry,
ZoneFormData,
ZoneFormField,
} from '../type';
import PolygonModal from './PolygonModal';
type GeometryFormProps = {
shape?: number;
form: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
zoneData?: SgwModel.Geom;
};
const GeometryForm = ({ shape, form }: GeometryFormProps) => {
const [isPolygonModalOpen, setIsPolygonModalOpen] = useState<boolean>(false);
const [indexTag, setIndexTag] = useState<number>(-1);
const intl = useIntl();
const polygonGeometry =
(Form.useWatch(
ZoneFormField.PolygonGeometry,
form.current || undefined,
) as PolygonGeometry[]) || [];
// Circle area calculation (subscribe to Radius so it updates)
const area = Form.useWatch(
[ZoneFormField.CircleData, 'area'],
form.current || undefined,
) as number | undefined;
const radiusArea = useMemo(() => {
if (shape === GeometryType.CIRCLE) {
if (area && area > 0) {
return getCircleRadius(area);
}
}
return 0;
}, [shape, area]);
const handleRemovePolygon = (index: number) => {
const newPolygons = polygonGeometry.filter(
(_, i) => i !== index,
) as PolygonGeometry[];
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: newPolygons,
});
};
const handleEditPolygon = (index: number) => {
setIsPolygonModalOpen(true);
setIndexTag(index);
};
// Format coordinates for display
const formatCoords = (coords: number[][]) => {
return coords.map((c) => `[${c[0]}, ${c[1]}]`).join(', ');
};
switch (shape) {
case GeometryType.POLYGON:
return (
<>
<ProFormItem
name={ZoneFormField.PolygonGeometry}
label={intl.formatMessage({
id: 'banzone.geometry.coordinates',
defaultMessage: 'Tọa độ',
})}
required
>
<Flex gap="10px 4px" wrap>
{polygonGeometry.map((polygon, index) => (
<Tooltip
key={index}
title={formatCoords(polygon.geometry) || ''}
>
<Tag
closable
onClose={(e) => {
e.preventDefault();
handleRemovePolygon(index);
}}
onClick={() => handleEditPolygon(index)}
color="blue"
style={{ cursor: 'pointer' }}
>
{intl.formatMessage({
id: 'banzones.title',
defaultMessage: 'Khu vực',
})}{' '}
{index + 1}
</Tag>
</Tooltip>
))}
<Button
style={tagPlusStyle}
icon={<PlusOutlined />}
onClick={() => {
setIndexTag(-1);
setIsPolygonModalOpen(true);
}}
>
<FormattedMessage
id="banzone.geometry.add_zone"
defaultMessage="Thêm vùng"
/>
</Button>
</Flex>
</ProFormItem>
<PolygonModal
isVisible={isPolygonModalOpen}
setVisible={setIsPolygonModalOpen}
initialData={polygonGeometry}
index={indexTag}
handleSubmit={async (values) => {
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: values,
});
setIndexTag(-1);
setIsPolygonModalOpen(false);
}}
/>
</>
);
case GeometryType.LINESTRING:
return (
<ProFormItem
required
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.coordinates.not_empty',
defaultMessage: 'Tọa độ không được để trống!',
}),
},
{
validator: (_: any, value: string) => {
return validateGeometry(value);
},
},
]}
name={ZoneFormField.PolylineData}
label={intl.formatMessage({
id: 'banzone.geometry.coordinates',
defaultMessage: 'Tọa độ',
})}
tooltip={intl.formatMessage({
id: 'banzone.geometry.coordinates.tooltip',
defaultMessage:
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
})}
>
<Input.TextArea
autoSize={{ minRows: 5, maxRows: 10 }}
placeholder={intl.formatMessage({
id: 'banzone.geometry.coordinates.placeholder',
defaultMessage:
'Ví dụ: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
})}
/>
</ProFormItem>
);
case GeometryType.CIRCLE:
return (
<>
<Row gutter={16}>
<Col span={12}>
<ProFormText
name={[ZoneFormField.CircleData, 'center', 0]}
label={intl.formatMessage({
id: 'banzone.geometry.longitude',
defaultMessage: 'Kinh độ',
})}
placeholder={intl.formatMessage({
id: 'banzone.geometry.longitude.placeholder',
defaultMessage: 'Nhập kinh độ',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.longitude.required',
defaultMessage: 'Kinh độ không được để trống!',
}),
},
]}
fieldProps={{
type: 'number',
step: '0.000001',
onChange: (e) => {
const lng = parseFloat(e.target.value);
const currentCircle = (form.current?.getFieldValue(
ZoneFormField.CircleData,
) as CircleGeometry) || { center: [0, 0], area: 0 };
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
...currentCircle,
center: [lng, currentCircle.center?.[1] || 0],
},
});
},
}}
/>
</Col>
<Col span={12}>
<ProFormText
name={[ZoneFormField.CircleData, 'center', 1]}
label={intl.formatMessage({
id: 'banzone.geometry.latitude',
defaultMessage: 'Vĩ độ',
})}
placeholder={intl.formatMessage({
id: 'banzone.geometry.latitude.placeholder',
defaultMessage: 'Nhập vĩ độ',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.latitude.required',
defaultMessage: 'Vĩ độ không được để trống!',
}),
},
]}
fieldProps={{
type: 'number',
step: '0.000001',
onChange: (e) => {
const lat = parseFloat(e.target.value);
const currentCircle = (form.current?.getFieldValue(
ZoneFormField.CircleData,
) as CircleGeometry) || { center: [0, 0], radius: 0 };
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
...currentCircle,
center: [currentCircle.center?.[0] || 0, lat],
},
});
},
}}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<ProFormDigit
name={[ZoneFormField.CircleData, 'area']}
label={intl.formatMessage({
id: 'banzone.geometry.area',
defaultMessage: 'Diện tích (Hecta)',
})}
placeholder={intl.formatMessage({
id: 'banzone.geometry.area.placeholder',
defaultMessage: 'Nhập diện tích',
})}
min={1}
fieldProps={{
precision: 0,
onChange: (value) => {
const currentCircle = (form.current?.getFieldValue(
ZoneFormField.CircleData,
) as CircleGeometry) || { center: [0, 0], radius: 0 };
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
center: currentCircle.center || [0, 0],
area: value || 0,
},
});
},
}}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.area.required',
defaultMessage: 'Diện tích không được để trống!',
}),
},
]}
/>
</Col>
<Col span={12}>
<ProFormItem
name={[ZoneFormField.CircleData, 'radius']}
label={intl.formatMessage({
id: 'banzone.geometry.radius',
defaultMessage: 'Bán kính (m)',
})}
>
<Input
disabled
readOnly
value={
radiusArea > 0
? `${radiusArea} ${intl.formatMessage({
id: 'banzone.geometry.metrics',
defaultMessage: 'mét',
})}`
: ''
}
addonAfter={intl.formatMessage({
id: 'banzone.geometry.auto_calculate',
defaultMessage: 'Tự động tính',
})}
/>
</ProFormItem>
</Col>
</Row>
</>
);
default:
return <div>Vui lòng chọn loại hình học đ nhập toạ đ.</div>;
}
};
export default GeometryForm;

View File

@@ -0,0 +1,175 @@
import {
ModalForm,
ProFormItem,
ProFormList,
} from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Flex, Input, theme } from 'antd';
import { useEffect, useMemo, useRef } from 'react';
import { PolygonGeometry, validateGeometry } from '../type';
interface PolygonModalProps {
initialData?: PolygonGeometry[];
index?: number;
isVisible: boolean;
setVisible: (visible: boolean) => void;
handleSubmit: (values: PolygonGeometry[]) => Promise<void>;
}
const PolygonModal = ({
isVisible,
setVisible,
handleSubmit,
initialData,
index,
}: PolygonModalProps) => {
const formRef = useRef<any>();
const { token } = theme.useToken();
const intl = useIntl();
// Counter to track item index during render
let itemIndex = 0;
// Convert initialData to form format
const initialValues = useMemo(() => {
if (!initialData || initialData.length === 0) {
return { conditions: [] };
}
return {
conditions: initialData.map((item) => ({
geometry: JSON.stringify(item.geometry),
id: item.id || undefined,
})),
};
}, [initialData]);
useEffect(() => {
if (isVisible) {
formRef.current?.setFieldsValue(initialValues);
}
}, [isVisible, initialValues]);
// Handle form submit - convert back to AreaGeometry format
const handleFinish = async (values: any) => {
const conditions = values.conditions || [];
const result: PolygonGeometry[] = conditions.map((item: any) => ({
geometry: JSON.parse(item.geometry),
id: item.id,
}));
await handleSubmit(result);
};
return (
<>
{/* Global style for highlighting TextArea border */}
<style>{`
.highlighted-item .ant-input {
border-color: ${token.colorWarningActive} !important;
}
`}</style>
<ModalForm
formRef={formRef}
open={isVisible}
onOpenChange={(open) => {
if (!open) formRef.current?.resetFields();
setVisible(open);
}}
title={intl.formatMessage({
id: 'banzone.polygon_modal.title',
defaultMessage: 'Thêm toạ độ',
})}
width="40%"
initialValues={initialValues}
submitter={{
searchConfig: {
submitText: intl.formatMessage({
id: 'common.save',
defaultMessage: 'Lưu',
}),
},
render: (_, doms) => (
<Flex justify="center" gap={16}>
{doms}
</Flex>
),
}}
onFinish={handleFinish}
>
<ProFormList
required
name="conditions"
creatorButtonProps={{
position: 'bottom',
creatorButtonText: intl.formatMessage({
id: 'banzone.geometry.add_zone',
defaultMessage: 'Thêm toạ độ',
}),
}}
copyIconProps={false}
deleteIconProps={{
tooltipText: intl.formatMessage({
id: 'common.delete',
defaultMessage: 'Xoá',
}),
}}
itemRender={({ listDom, action }) => {
const currentIndex = itemIndex++;
const isHighlighted = index !== undefined && currentIndex === index;
return (
<div
className={isHighlighted ? 'highlighted-item' : ''}
data-item-index={currentIndex}
>
{listDom}
<Flex gap={8} justify="flex-end">
{action}
</Flex>
</div>
);
}}
>
{/* Hidden field for id - needed to preserve id when editing */}
<ProFormItem name="id" hidden>
<input type="hidden" />
</ProFormItem>
<ProFormItem
name="geometry"
required
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.coordinates.not_empty',
defaultMessage: 'Coordinates cannot be empty!',
}),
},
{
validator: (_rule: any, value: any) => {
return validateGeometry(value);
},
},
]}
label={intl.formatMessage({
id: 'banzone.geometry.coordinates',
defaultMessage: 'Coordinates',
})}
tooltip={intl.formatMessage({
id: 'banzone.geometry.coordinates.tooltip',
defaultMessage:
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
})}
>
<Input.TextArea
autoSize={{ minRows: 5, maxRows: 10 }}
placeholder={intl.formatMessage({
id: 'banzone.geometry.coordinates.placeholder',
defaultMessage:
'Example: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
})}
/>
</ProFormItem>
</ProFormList>
</ModalForm>
</>
);
};
export default PolygonModal;

View File

@@ -0,0 +1,273 @@
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
import { PlusOutlined } from '@ant-design/icons';
import {
ProForm,
ProFormInstance,
ProFormSelect,
ProFormSwitch,
ProFormText,
ProFormTextArea,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Col, Flex, Form, Row, Tag, Tooltip } from 'antd';
import dayjs from 'dayjs';
import { MutableRefObject, useState } from 'react';
import { tagPlusStyle, ZoneFormData, ZoneFormField } from '../type';
import AddConditionForm from './AddConditionForm';
import GeometryForm from './GeometryForm';
interface ZoneFormProps {
shape?: number;
formRef: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
}
const ZoneForm = ({ formRef, shape }: ZoneFormProps) => {
const intl = useIntl();
const [isConditionModalOpen, setIsConditionModalOpen] = useState(false);
const handleGroupSelect = (groupId: string | string[] | null) => {
formRef.current?.setFieldsValue({ [ZoneFormField.AreaId]: groupId });
};
const selectedGroupIds = Form.useWatch(
ZoneFormField.AreaId,
formRef.current || undefined,
) as string | string[] | null | undefined;
const conditionData = Form.useWatch(
ZoneFormField.AreaConditions,
formRef.current || undefined,
);
const handleConditionsClose = (indexToRemove: number) => {
formRef.current?.setFieldValue(
ZoneFormField.AreaConditions,
conditionData?.filter((_, index) => index !== indexToRemove),
);
};
return (
<>
<Row gutter={16}>
{/* Tên */}
<Col xs={24} md={12}>
<ProFormText
name={ZoneFormField.AreaName}
label={intl.formatMessage({
id: 'common.name',
defaultMessage: 'Tên',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.form.name.required.message',
defaultMessage: 'Tên khu vực không được để trống!',
}),
},
]}
required
/>
</Col>
{/* Loại */}
<Col xs={24} md={12}>
<ProFormSelect
name={ZoneFormField.AreaType}
label={intl.formatMessage({
id: 'common.type',
defaultMessage: 'Loại',
})}
required
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.form.area_type.required.message',
defaultMessage: 'Loại khu vực không được để trống',
}),
},
]}
placeholder={intl.formatMessage({
id: 'banzone.form.area_type.placeholder',
defaultMessage: 'Chọn loại khu vực',
})}
options={[
{
label: intl.formatMessage({
id: 'banzone.area.fishing_ban',
defaultMessage: 'Cấm đánh bắt',
}),
value: 1,
},
{
label: intl.formatMessage({
id: 'banzone.area.move_ban',
defaultMessage: 'Cấm di chuyển',
}),
value: 2,
},
{
label: intl.formatMessage({
id: 'banzone.area.safe',
defaultMessage: 'Vùng an toàn',
}),
value: 3,
},
]}
/>
</Col>
</Row>
<Row gutter={16}>
{/* Tỉnh */}
<Col xs={24} md={12}>
<ProForm.Item
name={ZoneFormField.AreaId}
label={intl.formatMessage({
id: 'common.province',
defaultMessage: 'Tỉnh',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.form.province.required.message',
defaultMessage: 'Tỉnh quản lý không được để trống!',
}),
},
]}
>
<TreeSelectedGroup
groupIds={selectedGroupIds ?? ''}
onSelected={handleGroupSelect}
/>
</ProForm.Item>
</Col>
{/* Có hiệu lực */}
<Col xs={24} md={12}>
<ProFormSwitch
name={ZoneFormField.AreaEnabled}
label={intl.formatMessage({
id: 'banzone.is_enable',
defaultMessage: 'Có hiệu lực',
})}
valuePropName="checked"
/>
</Col>
</Row>
<ProFormTextArea
name={ZoneFormField.AreaDescription}
label={intl.formatMessage({
id: 'common.description',
defaultMessage: 'Mô tả',
})}
placeholder={intl.formatMessage({
id: 'banzone.form.description.placeholder',
defaultMessage: 'Nhập mô tả khu vực',
})}
autoSize={{ minRows: 3, maxRows: 5 }}
/>
<Form.Item
name={ZoneFormField.AreaConditions}
label={intl.formatMessage({
id: 'banzone.condition',
defaultMessage: 'Điều kiện',
})}
>
<Flex gap="10px 4px" wrap>
{(conditionData || []).map((condition, index) => {
// console.log("Condition: ", condition);
let tootip = '';
let label = '';
const { type } = condition;
switch (type) {
case 'month_range': {
label = intl.formatMessage({
id: 'banzone.condition.yearly',
defaultMessage: 'Hàng năm',
});
const fromMonth = condition.from + 1;
const toMonth = condition.to + 1;
tootip = `Tháng từ ${fromMonth} đến tháng ${toMonth}`;
break;
}
case 'date_range': {
label = intl.formatMessage({
id: 'banzone.condition.specific_time',
defaultMessage: 'Thời gian cụ thể',
});
const fromDate = dayjs(condition.from).format('DD/MM/YYYY');
const toDate = dayjs(condition.to).format('DD/MM/YYYY');
tootip = `Từ ${fromDate} đến ${toDate}`;
break;
}
case 'length_limit':
label = intl.formatMessage({
id: 'banzone.condition.length_limit',
defaultMessage: 'Chiều dài cho phép',
});
tootip = `Chiều dài tàu từ ${condition.min} đến ${condition.max} mét`;
break;
default:
label = intl.formatMessage({
id: 'common.undefined',
defaultMessage: 'Không xác định',
});
}
return (
<Tooltip title={tootip} key={index}>
<Tag
closable
onClick={() => setIsConditionModalOpen(true)}
onClose={(e) => {
e.preventDefault();
handleConditionsClose(index);
}}
color={
type === 'month_range'
? 'blue'
: type === 'date_range'
? 'green'
: 'volcano'
}
>
{label}
</Tag>
</Tooltip>
);
})}
<Button
style={tagPlusStyle}
icon={<PlusOutlined />}
onClick={() => setIsConditionModalOpen(true)}
>
<FormattedMessage
id="banzone.condition.add"
defaultMessage="Thêm điều kiện"
/>
</Button>
</Flex>
</Form.Item>
<GeometryForm shape={shape} form={formRef} />
<AddConditionForm
isVisible={isConditionModalOpen}
setVisible={setIsConditionModalOpen}
initialData={conditionData}
onFinish={(newConditions) => {
try {
formRef.current?.setFieldValue(
ZoneFormField.AreaConditions,
newConditions,
);
} catch (e) {
console.error('Error setting form value:', e);
}
}}
/>
</>
);
};
export default ZoneForm;

View File

@@ -0,0 +1 @@
export { useMapGeometrySync } from './useMapGeometrySync';

View File

@@ -0,0 +1,304 @@
import { BaseMap, ZoneData } from '@/pages/Slave/SGW/Map/type';
import {
getAreaFromRadius,
getCircleRadius,
} from '@/utils/slave/sgw/geomUtils';
import { type ProFormInstance } from '@ant-design/pro-components';
import { Feature } from 'ol';
import { MutableRefObject, useEffect, useRef } from 'react';
import type { ZoneFormData } from '../type';
import { GeometryType, ZoneFormField } from '../type';
// Default zone data for drawing features
const DEFAULT_ZONE_DATA: ZoneData = { type: 'default' };
interface DrawnFeatureData {
type: string;
coordinates: any;
feature: any;
}
interface MapGeometrySyncOptions {
baseMap: MutableRefObject<BaseMap | null>;
dataLayerId: string;
form: MutableRefObject<ProFormInstance<ZoneFormData> | undefined>;
shape?: number;
enabled?: boolean;
}
/**
* Custom hook to sync geometry between Map and Form
* - Draw on Map -> Update Form
* - Modify on Map -> Update Form
* - Form changes -> Update Map (optional)
*/
export const useMapGeometrySync = (options: MapGeometrySyncOptions) => {
const { baseMap, dataLayerId, form, enabled = true } = options;
const isHandlingUpdateRef = useRef(false);
useEffect(() => {
if (!baseMap || !enabled) return;
// Handle feature drawn event
const handleFeatureDrawn = (data: DrawnFeatureData) => {
if (isHandlingUpdateRef.current) return;
isHandlingUpdateRef.current = true;
switch (data.type) {
case 'Polygon': {
const currentGeometry =
form.current?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
const polygonId = `polygon_${Date.now()}`; // Tạo unique ID
data.feature.set('polygonId', polygonId); // Sửa: set trực tiếp key-value
const polygonCoords = data.coordinates;
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: [
...currentGeometry,
{
geometry: polygonCoords[0],
id: polygonId,
},
],
});
break;
}
case 'LineString': {
baseMap.current?.clearFeatures(dataLayerId);
const lineCoords = data.coordinates;
form.current?.setFieldsValue({
[ZoneFormField.PolylineData]: JSON.stringify(lineCoords),
});
break;
}
case 'Circle': {
baseMap.current?.clearFeatures(dataLayerId);
const circleData = data.coordinates;
console.log('Circle Data: ', circleData);
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
center: circleData.center,
area: getAreaFromRadius(circleData.radius),
},
});
break;
}
case 'Point':
// Point is stored as circle with small radius
// form?.setFieldsValue({
// [ZoneFormField.CircleData]: {
// center: data.coordinates,
// radius: 100, // Default radius for point
// },
// [ZoneFormField.Radius]: 100,
// });
break;
}
setTimeout(() => {
isHandlingUpdateRef.current = false;
}, 100);
};
// Handle feature modified event
const handleFeatureModified = (data: {
feature: any;
coordinates: any;
}) => {
if (isHandlingUpdateRef.current) return;
isHandlingUpdateRef.current = true;
const geometry = data.feature.getGeometry();
if (!geometry) {
isHandlingUpdateRef.current = false;
return;
}
const geomType = geometry.getType();
switch (geomType) {
case 'Polygon': {
const polygonId = data.feature.get('polygonId');
if (polygonId) {
const currentGeometry =
form.current?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
const modifiedCoords = data.coordinates[0];
const updatedGeometry = currentGeometry.map((item: any) =>
item.id === polygonId
? { ...item, geometry: modifiedCoords }
: item,
);
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: updatedGeometry,
});
}
break;
}
case 'LineString': {
form.current?.setFieldsValue({
[ZoneFormField.PolylineData]: JSON.stringify(data.coordinates),
});
break;
}
case 'Circle': {
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
center: data.coordinates.center,
// radius: data.coordinates.radius,
area: getAreaFromRadius(data.coordinates.radius),
},
});
break;
}
}
setTimeout(() => {
isHandlingUpdateRef.current = false;
}, 100);
};
// Register event listeners
baseMap.current?.onFeatureDrawn(handleFeatureDrawn);
baseMap.current?.onFeatureModified(handleFeatureModified);
// Cleanup
return () => {
// Note: BaseMap doesn't have off method, so events will remain
// This is acceptable as the component unmounts
};
}, [baseMap, enabled]);
/**
* Update map display based on current form data
*/
const updateMapFromForm = (type: GeometryType) => {
if (!baseMap || !form) return;
const formCurrent = form.current;
let geometryData;
if (type === GeometryType.POLYGON) {
geometryData =
formCurrent?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
} else if (type === GeometryType.LINESTRING) {
const polylineString =
formCurrent?.getFieldValue(ZoneFormField.PolylineData) || [];
geometryData = JSON.parse(polylineString);
} else if (type === GeometryType.CIRCLE) {
geometryData = formCurrent?.getFieldValue(ZoneFormField.CircleData)
? [formCurrent?.getFieldValue(ZoneFormField.CircleData)]
: [];
}
if (!geometryData || geometryData.length === 0) {
baseMap.current?.clearFeatures(dataLayerId);
return;
}
// Nếu đang xử lý event từ Map (vẽ/sửa), KHÔNG clear và vẽ lại
// vì Draw interaction đã add feature vào map sẵn rồi
if (isHandlingUpdateRef.current) {
return;
}
// Clear existing features trước khi vẽ lại (chỉ khi không đang handle map event)
baseMap.current?.clearFeatures(dataLayerId);
switch (type) {
case GeometryType.POLYGON:
{
const features: Feature[] = [];
geometryData.forEach((geom: any) => {
const feature = baseMap.current?.addPolygon(
dataLayerId,
[geom.geometry],
DEFAULT_ZONE_DATA,
geom.id,
);
features.push(feature!);
});
baseMap.current?.zoomToFeatures(features);
}
break;
case GeometryType.LINESTRING:
{
const feature = baseMap.current?.addPolyline(
dataLayerId,
geometryData,
DEFAULT_ZONE_DATA,
);
baseMap.current?.zoomToFeatures([feature!]);
}
break;
case GeometryType.CIRCLE:
{
const feature = baseMap.current?.addCircle(
dataLayerId,
geometryData[0].center,
getCircleRadius(geometryData[0].area),
DEFAULT_ZONE_DATA,
);
baseMap.current?.zoomToFeatures([feature!]);
}
break;
}
};
/**
* Clear all drawn features from map
*/
const clearMapFeatures = () => {
if (!baseMap) return;
baseMap.current?.clearFeatures(dataLayerId);
};
/**
* Draw existing geometry on map (for update mode)
*/
const drawExistingGeometry = (geometry: any) => {
if (!baseMap || !geometry) return;
clearMapFeatures();
if (geometry.geom_type === GeometryType.POLYGON && geometry.polygons) {
geometry.polygons.forEach((polygonCoords: number[][]) => {
baseMap.current?.addPolygon(
dataLayerId,
[polygonCoords],
DEFAULT_ZONE_DATA,
);
});
} else if (
geometry.geom_type === GeometryType.LINESTRING &&
geometry.coordinates
) {
baseMap.current?.addPolyline(
dataLayerId,
geometry.coordinates,
DEFAULT_ZONE_DATA,
);
} else if (
geometry.geom_type === GeometryType.CIRCLE &&
geometry.center &&
geometry.radius
) {
baseMap.current?.addCircle(
dataLayerId,
geometry.center,
geometry.radius,
DEFAULT_ZONE_DATA,
);
}
};
return {
updateMapFromForm,
clearMapFeatures,
drawExistingGeometry,
};
};
export default useMapGeometrySync;

View File

@@ -0,0 +1,520 @@
import { SGW_ROUTE_BANZONES_LIST } from '@/constants/slave/sgw/routes';
import VietNamMap, {
VietNamMapRef,
} from '@/pages/Slave/SGW/Map/components/VietNamMap';
import { BaseMap, DATA_LAYER } from '@/pages/Slave/SGW/Map/type';
import {
apiCreateBanzone,
apiGetZoneById,
apiUpdateBanzone,
} from '@/services/slave/sgw/ZoneController';
import {
getAreaFromRadius,
getCircleRadius,
} from '@/utils/slave/sgw/geomUtils';
import { DeleteOutlined, EditFilled, GatewayOutlined } from '@ant-design/icons';
import {
PageContainer,
ProCard,
ProForm,
ProFormInstance,
} from '@ant-design/pro-components';
import {
FormattedMessage,
history,
useIntl,
useLocation,
useModel,
useParams,
} from '@umijs/max';
import { Button, Flex, Form, Grid, message } from 'antd';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { useCallback, useEffect, useRef, useState } from 'react';
import ZoneForm from './components/ZoneForm';
import { useMapGeometrySync } from './hooks';
import {
AreaCondition,
checkValidateGeometry,
DrawActionType,
formatLineStringWKT,
formatPolygonGeometryToWKT,
GeometryType,
parseLineStringWKT,
parseMultiPolygonWKT,
parsePointWKT,
PolygonGeometry,
ZoneFormData,
ZoneFormField,
ZoneLocationState,
} from './type';
const CreateOrUpdateBanzone = () => {
const location = useLocation() as { state: ZoneLocationState };
const shape = location.state?.shape;
const type = location.state?.type;
const { id: zoneId } = useParams();
const intl = useIntl();
const { useBreakpoint } = Grid;
const screens = useBreakpoint();
const formRef = useRef<ProFormInstance<ZoneFormData>>();
const vietNamMapRef = useRef<VietNamMapRef>(null);
const baseMap = useRef<BaseMap | null>(null);
const drawController = useRef<any>(null);
const [loading, setLoading] = useState(false);
const [formReady, setFormReady] = useState(false);
const { groupMap } = useModel('master.useGroups');
const [mapActions, setMapActions] = useState<DrawActionType>({
isDrawing: false,
isModifying: false,
});
const { clearMapFeatures, updateMapFromForm } = useMapGeometrySync({
baseMap: baseMap,
dataLayerId: DATA_LAYER,
form: formRef,
shape,
enabled: !!baseMap.current,
});
// Handler for back navigation
const handleBack = () => {
history.push(SGW_ROUTE_BANZONES_LIST);
formRef.current?.resetFields();
};
const handleMapReady = useCallback((baseMapInstance: BaseMap) => {
baseMap.current = baseMapInstance;
// Create a vector layer for dynamic features
const vectorDataLayer = new VectorLayer({
source: new VectorSource(),
});
vectorDataLayer.set('id', DATA_LAYER);
baseMapInstance.addLayer(vectorDataLayer);
baseMapInstance.setView([116.152685, 15.70581], 5);
// Initialize draw controller
drawController.current = baseMapInstance.DrawAndModifyFeature(DATA_LAYER);
}, []);
const handleEnableModify = () => {
if (drawController.current) {
setMapActions({
isDrawing: false,
isModifying: true,
});
drawController.current.removeInteractions();
drawController.current.enableModify();
}
};
const handleSubmit = async (values?: ZoneFormData) => {
if (!values) return;
// Validate required fields
if (!values[ZoneFormField.AreaName]) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.error.required' }),
);
return;
}
if (!values[ZoneFormField.AreaType]) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.area_type.required' }),
);
return;
}
if (!values[ZoneFormField.AreaId]) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.province.required' }),
);
return;
}
const lineStringData = values[ZoneFormField.PolylineData];
const polygonData = values[ZoneFormField.PolygonGeometry];
const circleData = values[ZoneFormField.CircleData];
if (!lineStringData && !polygonData && !circleData) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.geometry.required' }),
);
return;
}
let geometryJson = '';
// // Convert geometry to API format
switch (shape) {
case GeometryType.POLYGON: {
const polygonWKT: string = formatPolygonGeometryToWKT(
polygonData || [],
);
geometryJson = JSON.stringify({
geom_type: GeometryType.POLYGON,
geom_poly: polygonWKT,
geom_lines: '',
geom_point: '',
geom_radius: 0,
});
break;
}
case GeometryType.LINESTRING: {
const polylineData = JSON.parse(lineStringData || '[]');
const polylineWKT: string = formatLineStringWKT(polylineData);
geometryJson = JSON.stringify({
geom_type: GeometryType.LINESTRING,
geom_poly: '',
geom_lines: polylineWKT,
geom_point: '',
geom_radius: 0,
});
break;
}
case GeometryType.CIRCLE: {
// For circle, API expects radius in hecta, convert from meters
const radiusInMetter = getCircleRadius(circleData?.area || 0);
const pointWKT = `POINT(${circleData?.center[0] || 0} ${
circleData?.center[1] || 0
})`;
geometryJson = JSON.stringify({
geom_type: GeometryType.CIRCLE,
geom_poly: '',
geom_lines: '',
geom_point: pointWKT,
geom_radius: radiusInMetter,
});
break;
}
default:
message.error('Loại hình học không hợp lệ!');
return;
}
const groupId = values[ZoneFormField.AreaId];
const provinceCode = Array.isArray(groupId)
? groupId.map((id) => groupMap[id]?.code || '').join(',')
: groupMap[groupId as string]?.code || '';
// // Prepare request body
const requestBody: SgwModel.ZoneBodyRequest = {
name: values[ZoneFormField.AreaName],
type: values[ZoneFormField.AreaType],
group_id: groupId as string,
province_code: provinceCode,
enabled: values[ZoneFormField.AreaEnabled] ?? true,
description: values[ZoneFormField.AreaDescription],
conditions: values[ZoneFormField.AreaConditions] as SgwModel.Condition[],
geom: geometryJson,
};
console.log('Submit body:', requestBody);
try {
setLoading(true);
if (type === 'create') {
const key = 'create';
message.open({
key,
type: 'loading',
content: intl.formatMessage({ id: 'banzone.creating' }),
});
await apiCreateBanzone(requestBody);
message.open({
key,
type: 'success',
content: intl.formatMessage({ id: 'banzone.creating_success' }),
});
} else if (type === 'update' && zoneId) {
const key = 'update';
message.open({
key,
type: 'loading',
content: intl.formatMessage({ id: 'banzone.updating' }),
});
await apiUpdateBanzone(zoneId, requestBody);
message.open({
key,
type: 'success',
content: intl.formatMessage({ id: 'banzone.updating_success' }),
});
} else {
message.error(
type === 'update'
? intl.formatMessage({ id: 'banzone.updating_fail' })
: intl.formatMessage({ id: 'banzone.creating_fail' }),
);
return;
}
setTimeout(() => {
handleBack();
}, 1000);
} catch (error) {
console.error('Failed to save zone:', error);
message.error(intl.formatMessage({ id: 'banzone.fail.save' }));
} finally {
setLoading(false);
}
};
const handleOnClickDraw = () => {
drawController.current = baseMap.current?.DrawAndModifyFeature(DATA_LAYER);
setMapActions({
isDrawing: true,
isModifying: false,
});
drawController.current.removeInteractions();
switch (shape) {
case 1:
drawController.current.drawPolygon();
break;
case 2:
drawController.current.drawLineString();
break;
case 3:
drawController.current.drawCircle();
break;
case 4:
drawController.current.drawPoint();
break;
default:
break;
}
};
const polygonGeometry =
(Form.useWatch(
ZoneFormField.PolygonGeometry,
formReady && formRef.current ? formRef.current : undefined,
) as PolygonGeometry[]) || [];
const polylineData = Form.useWatch(
ZoneFormField.PolylineData,
formReady && formRef.current ? formRef.current : undefined,
);
const circleData = Form.useWatch(
ZoneFormField.CircleData,
formReady && formRef.current ? formRef.current : undefined,
);
useEffect(() => {
if (polygonGeometry && polygonGeometry.length > 0) {
updateMapFromForm(GeometryType.POLYGON);
}
}, [polygonGeometry, formRef]);
useEffect(() => {
if (polylineData && checkValidateGeometry(polylineData)) {
updateMapFromForm(GeometryType.LINESTRING);
}
}, [polylineData, formRef]);
useEffect(() => {
if (circleData) {
updateMapFromForm(GeometryType.CIRCLE);
}
}, [circleData, formRef]);
return (
<PageContainer
onBack={handleBack}
title={
type === 'create' ? (
<FormattedMessage id="banzones.create" />
) : (
<FormattedMessage id="banzones.update" />
)
}
style={{
padding: 10,
}}
>
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 10, lg: 10, xl: 10 }}>
<ProForm<ZoneFormData>
formRef={formRef}
layout="vertical"
onFinish={handleSubmit}
onInit={(_, form) => {
formRef.current = form;
setFormReady(true);
}}
initialValues={{
[ZoneFormField.AreaEnabled]: true,
}}
request={async () => {
if (type === 'update' && zoneId) {
try {
const zone: SgwModel.Banzone = await apiGetZoneById(zoneId);
if (!zone) return {} as ZoneFormData;
// Parse geometry (API may use `geom` or `geometry` field)
let parsedGeometry: SgwModel.Geom | undefined;
const geomRaw = (zone as any).geom ?? (zone as any).geometry;
if (geomRaw) {
try {
parsedGeometry =
typeof geomRaw === 'string'
? JSON.parse(geomRaw)
: geomRaw;
} catch (e) {
console.warn('Failed to parse geometry', e);
}
}
const groupId = Object.entries(groupMap).find(
([, value]) => value.code === zone.province_code,
)?.[0] as string | undefined;
// Build base form data
const formData: Partial<ZoneFormData> = {
[ZoneFormField.AreaName]: zone.name || '',
[ZoneFormField.AreaType]: zone.type ?? 1,
[ZoneFormField.AreaEnabled]: zone.enabled ?? true,
[ZoneFormField.AreaDescription]: zone.description || '',
[ZoneFormField.AreaId]: groupId || '',
[ZoneFormField.AreaConditions]:
zone.conditions as AreaCondition[],
};
// Map geometry to form fields depending on geometry type
if (parsedGeometry) {
switch (parsedGeometry.geom_type) {
case GeometryType.POLYGON: {
const polygons = parseMultiPolygonWKT(
parsedGeometry.geom_poly || '',
);
formData[ZoneFormField.PolygonGeometry] = polygons.map(
(polygon) => ({
geometry: polygon,
id: Math.random().toString(36).substring(2, 9),
}),
);
break;
}
case GeometryType.LINESTRING: {
const polyline = parseLineStringWKT(
parsedGeometry.geom_lines || '',
);
formData[ZoneFormField.PolylineData] =
JSON.stringify(polyline);
break;
}
case GeometryType.CIRCLE: {
const center = parsePointWKT(
parsedGeometry.geom_point || '',
);
formData[ZoneFormField.CircleData] = {
center: center || [0, 0],
radius: parsedGeometry.geom_radius || 0,
area: getAreaFromRadius(
parsedGeometry.geom_radius || 0,
),
};
break;
}
default:
break;
}
}
return formData as ZoneFormData;
} catch (error) {
console.error('Failed to load zone for request:', error);
return {} as ZoneFormData;
}
}
return {} as ZoneFormData;
}}
submitter={{
searchConfig: {
submitText:
type === 'create'
? intl.formatMessage({ id: 'banzone.create.button.title' })
: intl.formatMessage({ id: 'banzone.update.button.title' }),
},
submitButtonProps: {
loading: loading,
},
render: (_, dom) => {
return (
<Flex
gap={8}
justify="center"
style={{ marginTop: 24, marginBottom: 24 }}
>
{dom}
</Flex>
);
},
}}
>
<ZoneForm formRef={formRef} shape={shape} />
<Flex align="center" gap={8} justify="end">
<Button
onClick={handleOnClickDraw}
variant={`${mapActions.isDrawing ? 'solid' : 'dashed'}`}
color={`${mapActions.isDrawing ? 'green' : 'default'}`}
icon={<GatewayOutlined />}
>
<FormattedMessage
id="banzone.map_action.draw"
defaultMessage="Draw"
/>
</Button>
<Button
onClick={handleEnableModify}
variant={`${mapActions.isModifying ? 'solid' : 'dashed'}`}
color={`${mapActions.isModifying ? 'green' : 'default'}`}
icon={<EditFilled />}
>
<FormattedMessage
id="banzone.map_action.modify"
defaultMessage="Modify"
/>
</Button>
<Button
danger
type="primary"
icon={<DeleteOutlined />}
onClick={() => {
clearMapFeatures();
setMapActions({
isDrawing: false,
isModifying: false,
});
switch (shape) {
case GeometryType.POLYGON:
formRef.current?.setFieldValue(
ZoneFormField.PolygonGeometry,
[],
);
break;
case GeometryType.LINESTRING:
formRef.current?.setFieldValue(
ZoneFormField.PolylineData,
'',
);
break;
case GeometryType.CIRCLE:
formRef.current?.setFieldValue(
ZoneFormField.CircleData,
undefined,
);
break;
default:
break;
}
}}
></Button>
</Flex>
</ProForm>
</ProCard>
<ProCard ghost colSpan={{ xs: 24, sm: 24, md: 14, lg: 14, xl: 14 }}>
<VietNamMap
ref={vietNamMapRef}
style={{
maxWidth: '100%',
height: '100%',
width: '100%',
}}
onMapReady={handleMapReady}
/>
</ProCard>
</ProCard>
</PageContainer>
);
};
export default CreateOrUpdateBanzone;

View File

@@ -0,0 +1,216 @@
export enum ZoneFormField {
AreaName = 'name',
AreaType = 'type',
AreaId = 'id',
AreaEnabled = 'enabled',
AreaDescription = 'description',
AreaConditions = 'conditions',
AreaProvinceCode = 'province_code',
PolygonGeometry = 'geometry',
PolylineData = 'polyline_data',
CircleData = 'circle_data',
}
export interface ZoneFormData {
[ZoneFormField.AreaName]: string;
[ZoneFormField.AreaType]: number; // API returns number
[ZoneFormField.AreaId]: string | string[] | null;
[ZoneFormField.AreaEnabled]: boolean;
[ZoneFormField.AreaDescription]?: string;
[ZoneFormField.AreaConditions]?: AreaCondition[];
[ZoneFormField.AreaProvinceCode]: string | number; // API returns string
[ZoneFormField.PolygonGeometry]: PolygonGeometry[];
[ZoneFormField.PolylineData]?: string;
[ZoneFormField.CircleData]?: CircleGeometry;
}
type MonthRangeCondition = {
type: 'month_range';
from: number;
to: number;
};
type DateRangeCondition = {
type: 'date_range';
from: string;
to: string;
};
type LengthLimitCondition = {
type: 'length_limit';
min: number;
max: number;
};
export type AreaCondition =
| MonthRangeCondition
| DateRangeCondition
| LengthLimitCondition;
export interface ZoneLocationState {
shape?: number;
type: 'create' | 'update';
}
export const tagPlusStyle = {
height: 22,
// background: "blue",
borderStyle: 'dashed',
};
export type PolygonGeometry = {
geometry: number[][];
id?: string;
};
export type CircleGeometry = {
center: [number, number];
radius: number;
area?: number;
};
// Geometry type mapping with API
export enum GeometryType {
POLYGON = 1,
LINESTRING = 2,
CIRCLE = 3,
}
export interface DrawActionType {
isDrawing: boolean;
isModifying: boolean;
}
// Parse MULTIPOINT/WKT for polygon
export const parseMultiPolygonWKT = (wktString: string): number[][][] => {
if (
!wktString ||
typeof wktString !== 'string' ||
!wktString.startsWith('MULTIPOLYGON')
) {
return [];
}
const matched = wktString.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
if (!matched) return [];
const polygons = matched[1]
.split(')),((') // chia các polygon
.map((polygonStr) =>
polygonStr
.trim()
.split(',')
.map((coordStr) => {
const [x, y] = coordStr.trim().split(' ').map(Number);
return [x, y];
}),
);
return polygons;
};
// // Parse LINESTRING WKT
export const parseLineStringWKT = (wkt: string): number[][] => {
if (!wkt || !wkt.startsWith('LINESTRING')) return [];
const match = wkt.match(/LINESTRING\s*\((.*)\)/);
if (!match) return [];
return match[1].split(',').map((coordStr) => {
const [x, y] = coordStr.trim().split(' ').map(Number);
return [x, y]; // [lng, lat]
});
};
// // Parse POINT WKT
export const parsePointWKT = (wkt: string): [number, number] | null => {
if (!wkt || !wkt.startsWith('POINT')) return null;
const match = wkt.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
if (!match) return null;
return [parseFloat(match[1]), parseFloat(match[2])]; // [lng, lat]
};
// Format coordinates array to WKT LINESTRING
export const formatLineStringWKT = (coordinates: number[][]): string => {
const coordStr = coordinates.map((c) => `${c[0]} ${c[1]}`).join(',');
return `LINESTRING(${coordStr})`;
};
// Format AreaGeometry[] to WKT MULTIPOLYGON string
export const formatPolygonGeometryToWKT = (
geometries: PolygonGeometry[],
): string => {
const polygons = geometries.map((g) => g.geometry as number[][]);
if (polygons.length === 0) return '';
const polygonStrs = polygons.map((polygon) => {
const coordStr = polygon.map((c) => `${c[0]} ${c[1]}`).join(',');
return `(${coordStr})`;
});
return `MULTIPOLYGON((${polygonStrs.join('),(')}))`;
};
export const validateGeometry = (value: any) => {
if (!value || typeof value !== 'string') {
return Promise.reject('Dữ liệu không hợp lệ!');
}
try {
const text = value.trim();
const formattedText = text.startsWith('[[') ? text : `[${text}]`;
const data = JSON.parse(formattedText);
if (!Array.isArray(data)) {
return Promise.reject('Dữ liệu không phải mảng!');
}
for (const item of data) {
if (
!Array.isArray(item) ||
item.length !== 2 ||
typeof item[0] !== 'number' ||
typeof item[1] !== 'number'
) {
return Promise.reject(
'Mỗi dòng phải là [longitude, latitude] với số thực!',
);
}
}
return Promise.resolve();
} catch (e) {
return Promise.reject('Định dạng JSON không hợp lệ!');
}
};
export const checkValidateGeometry = (value: string) => {
if (!value || typeof value !== 'string') {
return false;
}
try {
const text = value.trim();
const formattedText = text.startsWith('[[') ? text : `[${text}]`;
const data = JSON.parse(formattedText);
if (!Array.isArray(data)) {
return false;
}
for (const item of data) {
if (
!Array.isArray(item) ||
item.length !== 2 ||
typeof item[0] !== 'number' ||
typeof item[1] !== 'number'
) {
return false;
}
}
return true;
} catch (e) {
return false;
}
};

View File

@@ -1,11 +1,644 @@
import React from 'react';
import IconFont from '@/components/IconFont';
import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/const';
import {
SGW_ROUTE_BANZONES,
SGW_ROUTE_BANZONES_LIST,
} from '@/constants/slave/sgw/routes';
import {
apiGetAllBanzones,
apiRemoveBanzone,
} from '@/services/slave/sgw/ZoneController';
import { flattenGroupNodes } from '@/utils/slave/sgw/groupUtils';
import { formatDate } from '@/utils/slave/sgw/timeUtils';
import { DeleteOutlined, DownOutlined, EditOutlined } from '@ant-design/icons';
import {
ActionType,
ProCard,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
import {
Button,
Dropdown,
Flex,
Grid,
message,
Popconfirm,
Space,
Tag,
Tooltip,
Typography,
} from 'antd';
import { MenuProps } from 'antd/lib';
import { useEffect, useRef, useState } from 'react';
const { Paragraph, Text } = Typography;
const BanZoneList = () => {
const { useBreakpoint } = Grid;
const intl = useIntl();
const screens = useBreakpoint();
const tableRef = useRef<ActionType>();
const [groupCheckedKeys, setGroupCheckedKeys] = useState<string>('');
const { groups, getGroups } = useModel('master.useGroups');
const groupFlattened = flattenGroupNodes(groups || []);
const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRows] = useState<SgwModel.Banzone[]>([]);
useEffect(() => {
if (groups === null) {
getGroups();
}
}, [groups]);
// Reload table khi groups được load
useEffect(() => {
if (groups && groups.length > 0 && tableRef.current) {
tableRef.current.reload();
}
}, [groups]);
const handleEdit = (record: SgwModel.Banzone) => {
console.log('record: ', record);
let geomType = 1; // Default: Polygon
try {
if (record.geometry) {
const geometry: SgwModel.Geom = JSON.parse(record.geometry);
geomType = geometry.geom_type || 1;
}
} catch (e) {
console.error('Failed to parse geometry:', e);
}
history.push(`${SGW_ROUTE_BANZONES_LIST}/${record.id}`, {
type: 'update',
shape: geomType,
});
};
const handleDelete = async (record: SgwModel.Banzone) => {
try {
const groupID = groupFlattened.find(
(m) => m.metadata.code === record.province_code,
)?.id;
await apiRemoveBanzone(record.id || '', groupID || '');
messageApi.success(
intl.formatMessage({
id: 'banzone.notify.delete_zone_success',
defaultMessage: 'Zone deleted successfully',
}),
);
// Reload lại bảng
if (tableRef.current) {
tableRef.current.reload();
}
} catch (error) {
console.error('Error deleting area:', error);
messageApi.error(
intl.formatMessage({
id: 'banzone.notify.fail',
defaultMessage: 'Delete zone failed!',
}),
);
}
};
const columns: ProColumns<SgwModel.Banzone>[] = [
{
key: 'name',
title: <FormattedMessage id="banzones.name" defaultMessage="Name" />,
dataIndex: 'name',
render: (_, record) => (
<div
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
}}
>
<Paragraph
copyable
style={{ margin: 0 }}
ellipsis={{ rows: 999, tooltip: record?.name }}
>
{record?.name}
</Paragraph>
</div>
),
width: '15%',
},
{
key: 'group',
title: <FormattedMessage id="banzones.area" defaultMessage="Province" />,
dataIndex: 'province_code',
hideInSearch: true,
responsive: ['lg', 'md'],
ellipsis: true,
render: (_, record) => {
const matchedMember =
groupFlattened.find(
(group) => group.metadata.code === record.province_code,
) ?? null;
return (
<Text ellipsis={{ tooltip: matchedMember?.name || '-' }}>
{matchedMember?.name || '-'}
</Text>
);
},
width: '15%',
},
{
key: 'description',
title: (
<FormattedMessage id="banzones.description" defaultMessage="Mô tả" />
),
dataIndex: 'description',
hideInSearch: true,
render: (_, record) => (
<Paragraph
ellipsis={{ rows: 2, tooltip: record?.description }}
style={{
margin: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{record?.description || '-'}
</Paragraph>
),
width: '15%',
},
{
key: 'type',
title: <FormattedMessage id="banzones.type" defaultMessage="Loại" />,
dataIndex: 'type',
valueType: 'select',
fieldProps: {
options: [
{
label: intl.formatMessage({
id: 'banzone.area.fishing_ban',
defaultMessage: 'Fishing Ban',
}),
value: 1,
},
{
label: intl.formatMessage({
id: 'banzone.area.move_ban',
defaultMessage: 'Movement Ban',
}),
value: 2,
},
{
label: intl.formatMessage({
id: 'banzone.area.safe',
defaultMessage: 'Safe Area',
}),
value: 3,
},
],
},
render: (_, record) => (
<Tag color={record.type === 1 ? '#f50' : 'orange'}>
{record.type === 1
? intl.formatMessage({
id: 'banzone.area.fishing_ban',
defaultMessage: 'Fishing Ban',
})
: intl.formatMessage({
id: 'banzone.area.move_ban',
defaultMessage: 'Movement Ban',
})}
</Tag>
),
width: 120,
},
{
key: 'conditions',
title: (
<FormattedMessage id="banzones.conditions" defaultMessage="Điều kiện" />
),
dataIndex: 'conditions',
hideInSearch: true,
render: (conditions) => {
if (!Array.isArray(conditions)) return null;
return (
<Space direction="vertical" size={4}>
{conditions.map((cond, index) => {
switch (cond.type) {
case 'month_range':
return (
<Tooltip
key={index}
title={`Áp dụng từ tháng ${cond.from} đến tháng ${cond.to} hàng năm`}
>
<Tag
color="geekblue"
style={{ borderRadius: 8, margin: 0 }}
>
Th.{cond.from} - Th.{cond.to}
</Tag>
</Tooltip>
);
case 'date_range':
return (
<Tooltip
key={index}
title={`Áp dụng từ ${formatDate(
cond.from,
)} đến ${formatDate(cond.to)}`}
>
<Tag color="green" style={{ borderRadius: 8, margin: 0 }}>
{formatDate(cond.from)} {formatDate(cond.to)}
</Tag>
</Tooltip>
);
case 'length_limit':
return (
<Tooltip
key={index}
title={`Tàu từ ${cond.min} đến ${cond.max} mét`}
>
<Tag color="cyan" style={{ borderRadius: 8, margin: 0 }}>
{cond.min}-{cond.max}m
</Tag>
</Tooltip>
);
default:
return null;
}
})}
</Space>
);
},
width: 180,
},
{
key: 'enabled',
title: (
<FormattedMessage id="banzones.state" defaultMessage="Trạng thái" />
),
dataIndex: 'enabled',
valueType: 'select',
fieldProps: {
options: [
{
label: intl.formatMessage({
id: 'banzone.is_enable',
defaultMessage: 'Enabled',
}),
value: true,
},
{
label: intl.formatMessage({
id: 'banzone.is_unenabled',
defaultMessage: 'Disabled',
}),
value: false,
},
],
},
hideInSearch: false,
responsive: ['lg', 'md'],
render: (_, record) => {
return (
<Tag color={record.enabled === true ? '#08CB00' : '#DCDCDC'}>
{record.enabled === true
? intl.formatMessage({
id: 'banzone.is_enable',
defaultMessage: 'Enabled',
})
: intl.formatMessage({
id: 'banzone.is_unenabled',
defaultMessage: 'Disabled',
})}
</Tag>
);
},
width: 120,
},
{
title: <FormattedMessage id="banzones.action" defaultMessage="Action" />,
hideInSearch: true,
width: 120,
fixed: 'right',
render: (_, record) => [
<Space key="actions">
<Button
key="edit"
type="primary"
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record)}
></Button>
<Popconfirm
key="delete"
title={intl.formatMessage({
id: 'common.delete_confirm',
defaultMessage: 'Confirm delete?',
})}
description={`${intl.formatMessage({
id: 'banzone.notify.delete_zone_confirm',
defaultMessage: 'Are you sure you want to delete this zone',
})} "${record.name}"?`}
onConfirm={() => handleDelete(record)}
okText={intl.formatMessage({
id: 'common.delete',
defaultMessage: 'Delete',
})}
cancelText={intl.formatMessage({
id: 'common.cancel',
defaultMessage: 'Cancel',
})}
okType="danger"
>
<Button
type="primary"
danger
icon={<DeleteOutlined />}
size="small"
></Button>
</Popconfirm>
</Space>,
],
},
];
const items: Required<MenuProps>['items'] = [
{
label: intl.formatMessage({
id: 'banzone.polygon',
defaultMessage: 'Polygon',
}),
onClick: () => {
history.push(SGW_ROUTE_BANZONES, {
shape: 1,
type: 'create',
});
},
key: '0',
icon: <IconFont type="icon-polygon" />,
},
{
label: intl.formatMessage({
id: 'banzone.polyline',
defaultMessage: 'Polyline',
}),
key: '1',
onClick: () => {
history.push(SGW_ROUTE_BANZONES, {
shape: 2,
type: 'create',
});
},
icon: <IconFont type="icon-polyline" />,
},
{
label: intl.formatMessage({
id: 'banzone.circle',
defaultMessage: 'Circle',
}),
key: '3',
onClick: () => {
history.push(SGW_ROUTE_BANZONES, {
shape: 3,
type: 'create',
});
},
icon: <IconFont type="icon-circle" />,
},
];
const deleteMultipleBanzones = async (records: SgwModel.Banzone[]) => {
const key = 'deleteMultiple';
messageApi.open({
key,
type: 'loading',
content: intl.formatMessage({
id: 'common.deleting',
defaultMessage: 'Deleting...',
}),
duration: 0,
});
try {
for (const record of records) {
const groupID = groupFlattened.find(
(m) => m.metadata.code === record.province_code,
)?.id;
await apiRemoveBanzone(record.id || '', groupID || '');
}
messageApi.open({
key,
type: 'success',
content: `Đã xoá thành công ${records.length} khu vực`,
duration: 2,
});
tableRef.current?.reload();
} catch (error) {
console.error('Error deleting area:', error);
messageApi.open({
key,
type: 'error',
content: intl.formatMessage({
id: 'banzone.notify.fail',
defaultMessage: 'Delete zone failed!',
}),
duration: 2,
});
}
};
const SGWArea: React.FC = () => {
return (
<div>
<h1>Khu vực (SGW Manager)</h1>
</div>
<>
{contextHolder}
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 4, lg: 4, xl: 4 }}>
<TreeGroup
multiple
onSelected={(value) => {
// Convert group IDs to province codes string
const selectedIds = Array.isArray(value)
? value
: value
? [value]
: [];
const provinceCodes =
selectedIds.length > 0
? selectedIds
.reduce((codes: string[], id) => {
const group = groupFlattened.find((g) => g.id === id);
if (group?.metadata?.code) {
codes.push(group.metadata.code);
}
return codes;
}, [])
.join(',')
: '';
setGroupCheckedKeys(provinceCodes);
tableRef.current?.reload();
}}
/>
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 20, lg: 20, xl: 20 }}>
<ProTable<SgwModel.Banzone>
tableLayout="fixed"
scroll={{ x: 1000 }}
actionRef={tableRef}
columns={columns}
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE,
showSizeChanger: true,
pageSizeOptions: ['5', '10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'banzones.title',
defaultMessage: 'zones',
})}`,
}}
request={async (params) => {
const { current, pageSize, name, type, enabled } = params;
// Nếu chưa có groups, đợi
if (!groups || groups.length === 0) {
return {
success: true,
data: [],
total: 0,
};
}
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
const groupFalttened = flattenGroupNodes(groups || []);
const groupId =
groupCheckedKeys ||
groupFalttened
.map((group) => group.metadata.code)
.filter(Boolean)
.join(',') + ',';
if (!groupId || groupId === ',') {
return {
success: true,
data: [],
total: 0,
};
}
const body: SgwModel.SearchZonePaginationBody = {
name: name || '',
order: 'name',
dir: 'asc',
limit: pageSize,
offset: offset,
metadata: {
province_code: groupId,
...(type ? { type: Number(type) } : {}), // nếu có type thì thêm vào
...(enabled !== undefined ? { enabled } : {}),
},
};
try {
const resp = await apiGetAllBanzones(body);
return {
success: true,
data: resp?.banzones || [],
total: resp?.total || 0,
};
} catch (error) {
console.error('Query banzones failed:', error);
return {
success: true,
data: [],
total: 0,
};
}
}}
rowKey="id"
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
search={{
layout: 'vertical',
defaultCollapsed: false,
}}
dateFormatter="string"
rowSelection={{
selectedRowKeys: selectedRowsState?.map((row) => row?.id ?? ''),
onChange: (_, selectedRows) => {
setSelectedRows(selectedRows);
},
}}
tableAlertRender={({ selectedRowKeys }) => (
<div>Đã chọn {selectedRowKeys.length} mục</div>
)}
tableAlertOptionRender={({ selectedRows, onCleanSelected }) => {
return (
<Flex gap={5}>
<Popconfirm
title={intl.formatMessage({
id: 'common.notification',
defaultMessage: 'Thông báo',
})}
description={`Bạn muốn xoá hết ${selectedRows.length} khu vực này?`}
onConfirm={() => {
deleteMultipleBanzones(selectedRows);
}}
okText={intl.formatMessage({
id: 'common.sure',
defaultMessage: 'Chắc chắn',
})}
cancelText={intl.formatMessage({
id: 'common.no',
defaultMessage: 'Không',
})}
>
<Button type="primary" danger>
{intl.formatMessage({
id: 'common.delete',
defaultMessage: 'Xóa',
})}
</Button>
</Popconfirm>
<Button color="cyan" variant="text" onClick={onCleanSelected}>
{intl.formatMessage({
id: 'common.cancel',
defaultMessage: 'Bỏ chọn',
})}
</Button>
</Flex>
);
}}
toolBarRender={() => [
<Dropdown
menu={{ items }}
trigger={['click']}
key="toolbar-dropdown"
>
<Button type="primary">
<Space>
{intl.formatMessage({
id: 'banzones.create',
defaultMessage: 'Tạo khu vực',
})}
<DownOutlined />
</Space>
</Button>
</Dropdown>,
]}
/>
</ProCard>
</ProCard>
</>
);
};
export default SGWArea;
export default BanZoneList;

View File

@@ -0,0 +1,399 @@
import { HTTPSTATUS } from '@/constants';
import {
apiCreateFishSpecies,
apiUpdateFishSpecies,
} from '@/services/slave/sgw/FishController';
import {
apiDeletePhoto,
apiUploadPhoto,
} from '@/services/slave/sgw/PhotoController';
import {
ModalForm,
ProForm,
ProFormInstance,
ProFormSelect,
ProFormText,
ProFormTextArea,
ProFormUploadButton,
} from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { MessageInstance } from 'antd/es/message/interface';
import type { UploadFile } from 'antd/es/upload/interface';
import { useRef, useState } from 'react';
export type AddOrUpdateFishProps = {
type: 'create' | 'update';
fish?: SgwModel.Fish;
isOpen?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
message: MessageInstance;
onReload?: (isSuccess: boolean) => void;
};
const AddOrUpdateFish = ({
type,
fish,
isOpen,
setIsOpen,
message,
onReload,
}: AddOrUpdateFishProps) => {
const formRef = useRef<ProFormInstance<SgwModel.Fish>>();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [originalFileList, setOriginalFileList] = useState<UploadFile[]>([]);
const intl = useIntl();
// Check ảnh có thay đổi so với ban đầu không
const hasImageChanged = () => {
const currentHasImage = fileList.length > 0;
const originalHasImage = originalFileList.length > 0;
// Nếu số lượng ảnh khác nhau → có thay đổi
if (currentHasImage !== originalHasImage) {
return true;
}
// Nếu cả 2 đều rỗng → không thay đổi
if (!currentHasImage) {
return false;
}
// Nếu có ảnh, check xem file có phải là file mới upload không
// (file gốc có uid = '-1', file mới upload có uid khác)
const currentFile = fileList[0];
const isOriginalImage =
currentFile.uid === '-1' && currentFile.status === 'done';
return !isOriginalImage;
};
return (
<ModalForm<SgwModel.Fish>
key={fish?.id || 'new'}
open={isOpen}
formRef={formRef}
title={
type === 'create'
? intl.formatMessage({
id: 'fish.create.title',
defaultMessage: 'Thêm cá mới',
})
: intl.formatMessage({
id: 'fish.update.title',
defaultMessage: 'Cập nhật cá',
})
}
onOpenChange={setIsOpen}
layout="vertical"
modalProps={{
destroyOnHidden: true,
}}
request={async () => {
if (type === 'update' && fish) {
return fish;
}
setFileList([]);
setOriginalFileList([]);
return {};
}}
onFinish={async (values) => {
// 1. Cập nhật thông tin cá
if (type === 'create') {
// TODO: Gọi API tạo cá mới
// const result = await apiCreateFish(values);
console.log('Create fish:', values);
try {
const resp = await apiCreateFishSpecies(values);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.create.success',
defaultMessage: 'Tạo cá thành công',
}),
);
onReload?.(true);
const id = resp.data.name_ids![0];
if (fileList.length > 0 && fileList[0].originFileObj && id) {
// TODO: Sau khi có result.id từ API create
// await apiUploadPhoto('fish', result.id, fileList[0].originFileObj);
console.log('Upload photo for new fish');
try {
const resp = await apiUploadPhoto(
'fish',
id.toString(),
fileList[0].originFileObj,
);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.create.image.success',
defaultMessage: 'Thêm ảnh cá thành công',
}),
);
onReload?.(true);
} else {
throw new Error('Thêm ảnh thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.create.image.fail',
defaultMessage: 'Thêm ảnh cá thất bại',
}),
);
}
}
} else {
throw new Error('Tạo cá thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.create.fail',
defaultMessage: 'Tạo cá thất bại',
}),
);
}
// 2. Upload ảnh (nếu có chọn ảnh)
onReload?.(true);
} else {
// TODO: Gọi API cập nhật cá
// await apiUpdateFish(fish!.id!, values);
console.log('Update fish:', fish?.id, values);
// Check nếu dữ liệu có thay đổi so với ban đầu
const hasDataChanged =
fish!.name !== values.name ||
fish!.scientific_name !== values.scientific_name ||
fish!.group_name !== values.group_name ||
fish!.rarity_level !== values.rarity_level ||
fish!.note !== values.note;
if (hasDataChanged) {
try {
const body = { ...values, id: fish!.id! };
const resp = await apiUpdateFishSpecies(body);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.update.success',
defaultMessage: 'Cập nhật cá thành công',
}),
);
onReload?.(true);
} else {
throw new Error('Cập nhật cá thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.update.fail',
defaultMessage: 'Cập nhật cá thất bại',
}),
);
return true;
}
} else {
console.log('Dữ liệu không thay đổi, bỏ qua API update');
}
// 2. Upload ảnh (chỉ khi ảnh có thay đổi)
if (hasImageChanged()) {
if (fileList.length > 0 && fileList[0].originFileObj) {
// TODO: Upload ảnh mới
// await apiUploadPhoto('fish', fish!.id!, fileList[0].originFileObj);
try {
const resp = await apiUploadPhoto(
'fish',
fish!.id!.toString(),
fileList[0].originFileObj,
);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.update.image.success',
defaultMessage: 'Cập nhật ảnh cá thành công',
}),
);
onReload?.(true);
} else {
throw new Error('Cập nhật ảnh thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.update.image.fail',
defaultMessage: 'Cập nhật ảnh cá thất bại',
}),
);
return true;
}
} else {
// TODO: Xóa ảnh (nếu có API delete)
console.log('Remove photo');
const resp = await apiDeletePhoto('fish', fish!.id!);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.delete.image.success',
defaultMessage: 'Xóa ảnh cá thành công',
}),
);
onReload?.(true);
} else {
message.error(
intl.formatMessage({
id: 'fish.delete.image.fail',
defaultMessage: 'Xóa ảnh cá thất bại',
}),
);
return true;
}
}
}
}
return true;
}}
>
{type === 'create' && (
<div
style={{
display: 'flex',
justifyContent: 'center',
marginBottom: 16,
}}
>
<ProFormUploadButton
name="upload"
label={null}
title="Chọn ảnh"
accept="image/*"
max={1}
transform={(value) => ({ upload: value })}
fieldProps={{
onChange(info) {
setFileList(info.fileList);
},
listType: 'picture-card',
fileList: fileList,
onRemove: () => {
setFileList([]);
},
}}
/>
</div>
)}
<ProForm.Group>
<ProFormText
name="name"
width="md"
label={intl.formatMessage({
id: 'common.name',
defaultMessage: 'Tên',
})}
tooltip={intl.formatMessage({
id: 'fish.name.tooltip',
defaultMessage: 'Tên loài cá',
})}
placeholder={intl.formatMessage({
id: 'fish.name.placeholder',
defaultMessage: 'Nhập tên cá',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'fish.name.required',
defaultMessage: 'Tên cá không được để trống',
}),
},
]}
/>
<ProFormText
name="scientific_name"
width="md"
label={intl.formatMessage({
id: 'fish.specific_name',
defaultMessage: 'Tên khoa học',
})}
placeholder={intl.formatMessage({
id: 'fish.specific_name.placeholder',
defaultMessage: 'Nhập tên khoa học',
})}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormText
name="group_name"
label={intl.formatMessage({
id: 'fish.fish_group',
defaultMessage: 'Nhóm',
})}
width="md"
tooltip={intl.formatMessage({
id: 'fish.fish_group.tooltip',
defaultMessage: 'Nhóm cá',
})}
placeholder={intl.formatMessage({
id: 'fish.fish_group.placeholder',
defaultMessage: 'Nhập nhóm cá',
})}
/>
<ProFormSelect
name="rarity_level"
label={intl.formatMessage({
id: 'fish.rarity',
defaultMessage: 'Độ hiếm',
})}
width="md"
placeholder={intl.formatMessage({
id: 'fish.rarity.placeholder',
defaultMessage: 'Chọn độ hiếm',
})}
options={[
{
label: intl.formatMessage({
id: 'fish.rarity.normal',
defaultMessage: 'Phổ biến',
}),
value: 1,
},
{
label: intl.formatMessage({
id: 'fish.rarity.sensitive',
defaultMessage: 'Dễ bị tổn thương',
}),
value: 2,
},
{
label: intl.formatMessage({
id: 'fish.rarity.near_threatened',
defaultMessage: 'Gần bị đe dọa',
}),
value: 3,
},
{
label: intl.formatMessage({
id: 'fish.rarity.endangered',
defaultMessage: 'Nguy cấp',
}),
value: 4,
},
]}
/>
</ProForm.Group>
<ProFormTextArea
name="note"
label={intl.formatMessage({
id: 'common.description',
defaultMessage: 'Ghi chú',
})}
placeholder={intl.formatMessage({
id: 'common.description.placeholder',
defaultMessage: 'Nhập ghi chú',
})}
/>
</ModalForm>
);
};
export default AddOrUpdateFish;

View File

@@ -0,0 +1,66 @@
import { HTTPSTATUS } from '@/constants';
import { apiGetPhoto } from '@/services/slave/sgw/PhotoController';
import { Image, Spin } from 'antd';
import { useEffect, useRef, useState } from 'react';
export const FishImage = ({
fishId,
alt,
isReload,
}: {
fishId: string;
alt: string;
isReload: boolean;
}) => {
const [url, setUrl] = useState<string>('');
const [loading, setLoading] = useState(true);
const objectUrlRef = useRef<string>('');
useEffect(() => {
let isMounted = true;
const fetchImage = async () => {
try {
const resp = await apiGetPhoto('fish', fishId);
let objectUrl = '';
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
const blob = new Blob([resp.data], { type: 'image/jpeg' });
objectUrl = URL.createObjectURL(blob);
objectUrlRef.current = objectUrl;
} else {
throw new Error('Failed to fetch image');
}
if (isMounted) {
setUrl(objectUrl);
setLoading(false);
}
} catch (error) {
// console.log('Error: ', error);
setUrl('');
if (isMounted) {
setLoading(false);
}
}
};
fetchImage();
return () => {
isMounted = false;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
}
};
}, [fishId, isReload]);
if (loading) {
return <Spin size="small" />;
}
if (!url) {
return <span>-</span>;
}
return <Image height={50} width={50} src={url} alt={alt} />;
};

View File

@@ -1,11 +1,350 @@
import React from 'react';
import PhotoActionModal from '@/components/shared/PhotoActionModal';
import { DEFAULT_PAGE_SIZE, HTTPSTATUS } from '@/constants';
import {
apiDeleteFishSpecies,
apiGetFishSpecies,
} from '@/services/slave/sgw/FishController';
import { getRarityById } from '@/utils/slave/sgw/fishRarity';
import {
DeleteOutlined,
EditOutlined,
PictureOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Flex, message, Popconfirm, Tag, theme, Tooltip } from 'antd';
import { useRef, useState } from 'react';
import AddOrUpdateFish from './component/AddOrUpdateFish';
import { FishImage } from './component/FishImage';
const FishList = () => {
const tableRef = useRef<ActionType>();
const intl = useIntl();
const [messageApi, contextHolder] = message.useMessage();
const [showAddOrUpdateModal, setShowAddOrUpdateModal] =
useState<boolean>(false);
const [fishSelected, setFishSelected] = useState<SgwModel.Fish | undefined>(
undefined,
);
const [fishPhotoModalOpen, setFishPhotoModalOpen] = useState<boolean>(false);
const [fishID, setFishID] = useState<number | undefined>(undefined);
const [isReloadImage, setIsReloadImage] = useState<boolean>(false);
const token = theme.useToken();
const getColorByRarityLevel = (level: number) => {
switch (level) {
case 2:
return token.token.yellow;
case 3:
return token.token.orange;
case 4:
return token.token.colorError;
case 5:
return '#FF6347';
case 6:
return '#FF4500';
case 7:
return '#FF0000';
case 8:
return '#8B0000';
default:
return token.token.green;
}
};
const handleDeleteFish = async (id: string) => {
try {
const resp = await apiDeleteFishSpecies(id);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
messageApi.success(
intl.formatMessage({
id: 'fish.delete.success',
defaultMessage: 'Successfully deleted fish',
}),
);
tableRef.current?.reload();
} else {
throw new Error('Xóa cá thất bại');
}
} catch (error) {
messageApi.error(
intl.formatMessage({
id: 'fish.delete.fail',
defaultMessage: 'Failed to delete fish',
}),
);
}
};
const columns: ProColumns<SgwModel.Fish>[] = [
{
title: intl.formatMessage({
id: 'common.name',
defaultMessage: 'Name',
}),
dataIndex: 'name',
key: 'name',
copyable: true,
render: (dom, entity) => {
return (
<Tooltip
title={
intl.formatMessage({
id: 'fish.specific_name',
defaultMessage: 'Scientific Name',
}) +
': ' +
entity.scientific_name
}
>
{dom}
</Tooltip>
);
},
},
{
title: intl.formatMessage({
id: 'common.image',
defaultMessage: 'Image',
}),
dataIndex: 'id',
key: 'id',
hideInSearch: true,
// valueType: 'image',
render: (_, entity) => {
return (
<FishImage
fishId={String(entity.id || '')}
alt={entity.name || ''}
isReload={isReloadImage}
/>
);
},
},
{
title: intl.formatMessage({
id: 'fish.fish_group',
defaultMessage: 'Group',
}),
dataIndex: 'group_name',
key: 'group_name',
},
{
title: intl.formatMessage({
id: 'fish.rarity',
defaultMessage: 'Rarity',
}),
dataIndex: 'rarity_level',
key: 'rarity_level',
valueType: 'select',
valueEnum: {
1: {
text: intl.formatMessage({
id: 'fish.rarity.normal',
defaultMessage: 'Common',
}),
},
2: {
text: intl.formatMessage({
id: 'fish.rarity.sensitive',
defaultMessage: 'Sensitive',
}),
},
3: {
text: intl.formatMessage({
id: 'fish.rarity.near_threatened',
defaultMessage: 'Near Threatened',
}),
},
4: {
text: intl.formatMessage({
id: 'fish.rarity.endangered',
defaultMessage: 'Endangered',
}),
},
},
render: (_, entity) => {
const rarity = getRarityById(entity.rarity_level || 1);
return (
<Tag color={getColorByRarityLevel(entity.rarity_level || 1)}>
{rarity ||
intl.formatMessage({
id: 'common.undefined',
defaultMessage: 'Undefined',
})}
</Tag>
);
},
},
{
title: intl.formatMessage({
id: 'common.note',
defaultMessage: 'Note',
}),
dataIndex: 'note',
hideInSearch: true,
key: 'note',
ellipsis: true,
},
{
title: intl.formatMessage({
id: 'common.actions',
defaultMessage: 'Actions',
}),
dataIndex: 'actions',
key: 'actions',
hideInSearch: true,
align: 'center',
render: (_, entity) => {
return (
<Flex align="center" justify="center" gap={8}>
<Button
type="text"
onClick={() => {
setFishSelected(entity);
setShowAddOrUpdateModal(true);
}}
icon={<EditOutlined />}
></Button>
<Button
type="text"
onClick={async () => {
setFishID(entity.id);
setFishPhotoModalOpen(true);
}}
icon={<PictureOutlined />}
></Button>
<Popconfirm
title={intl.formatMessage({
id: 'fish.delete_confirm',
defaultMessage:
'Are you sure you want to delete this fish species?',
})}
onConfirm={() => handleDeleteFish(entity.id?.toString() || '')}
okText={intl.formatMessage({
id: 'common.yes',
defaultMessage: 'Yes',
})}
cancelText={intl.formatMessage({
id: 'common.no',
defaultMessage: 'No',
})}
>
<Button type="text" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Flex>
);
},
},
];
const SGWFish: React.FC = () => {
return (
<div>
<h1> (SGW Manager)</h1>
{contextHolder}
<AddOrUpdateFish
type={fishSelected ? 'update' : 'create'}
isOpen={showAddOrUpdateModal}
setIsOpen={setShowAddOrUpdateModal}
fish={fishSelected}
message={messageApi}
onReload={(isSuccess) => {
if (isSuccess) {
tableRef.current?.reload();
setIsReloadImage((prev) => !prev);
}
}}
/>
<PhotoActionModal
key={fishID ?? 'none'}
isOpen={fishPhotoModalOpen}
setIsOpen={setFishPhotoModalOpen}
type={'fish'}
id={fishID!}
hasSubPhotos={true}
/>
<ProTable<SgwModel.Fish>
actionRef={tableRef}
rowKey="id"
size="large"
columns={columns}
search={{
defaultCollapsed: false,
}}
columnEmptyText="-"
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
showSizeChanger: true,
pageSizeOptions: ['5', '10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'fish.name',
defaultMessage: 'fishes',
})}`,
}}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
toolBarRender={() => [
<Button
color="cyan"
variant="outlined"
key="add-fish"
icon={<PlusOutlined />}
size="middle"
onClick={() => {
setFishSelected(undefined);
setShowAddOrUpdateModal(true);
}}
>
<FormattedMessage
id="fish.create.title"
defaultMessage="Add Fish Species"
/>
</Button>,
]}
request={async (params) => {
const { current, pageSize, name, group_name, rarity_level } = params;
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
const body: SgwModel.SearchFishPaginationBody = {
name: name,
order: 'name',
limit: pageSize,
offset: offset,
dir: 'desc',
};
if (group_name || rarity_level) body.metadata = {};
if (group_name && body.metadata) {
body.metadata.group_name = group_name;
}
if (rarity_level && body.metadata) {
body.metadata.rarity_level = Number(rarity_level);
}
try {
const res = await apiGetFishSpecies(body);
return {
data: res.fishes,
total: res.total,
success: true,
};
} catch (error) {
return {
data: [],
total: 0,
success: false,
};
}
}}
/>
</div>
);
};
export default SGWFish;
export default FishList;

View File

@@ -72,8 +72,8 @@ const ShipDetail = ({
}
try {
const photoData = await apiGetPhoto('ship', resp.id || '');
const blob = new Blob([photoData], { type: 'image/jpeg' });
const photoResponse = await apiGetPhoto('ship', resp.id || '');
const blob = new Blob([photoResponse.data], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
setShipImage(url);
} catch (e) {

View File

@@ -49,7 +49,7 @@ const uploadPhoto = async (
type: 'people',
id: string,
file: File,
): Promise<void> => {
): Promise<{ status: number }> => {
return apiUploadPhoto(type, id, file);
};
@@ -71,11 +71,11 @@ const CrewPhoto: React.FC<{ record: SgwModel.TripCrews }> = ({ record }) => {
}
try {
const photoData = await apiGetPhoto(
const photoResponse = await apiGetPhoto(
'people',
record.Person.personal_id,
);
const blob = new Blob([photoData], { type: 'image/jpeg' });
const blob = new Blob([photoResponse.data], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
setPhotoSrc(url);
} catch (error) {

View File

@@ -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',

View File

@@ -1,7 +1,9 @@
import {
API_CHANGE_PASSWORD,
API_FORGOT_PASSWORD,
API_PATH_GET_PROFILE,
API_PATH_LOGIN,
API_PATH_REFRESH_TOKEN,
API_USERS,
} from '@/constants/api';
import { request } from '@umijs/max';
@@ -15,6 +17,15 @@ export async function apiLogin(body: MasterModel.LoginRequestBody) {
});
}
export async function apiRefreshToken(
body: MasterModel.RefreshTokenRequestBody,
) {
return request<MasterModel.RefreshTokenReponse>(API_PATH_REFRESH_TOKEN, {
method: 'POST',
data: body,
});
}
export async function apiQueryProfile() {
return request<MasterModel.UserResponse>(API_PATH_GET_PROFILE);
}
@@ -40,3 +51,11 @@ export async function apiChangePassword(
getResponse: true,
});
}
export async function apiForgotPassword(
body: MasterModel.ForgotPasswordRequestBody,
) {
return request<MasterModel.ForgotPasswordResponse>(API_FORGOT_PASSWORD, {
method: 'POST',
data: body,
});
}

View File

@@ -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,
});
}

View 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;
}

View File

@@ -1,5 +1,5 @@
import {
API_SHARE_THING,
API_THING,
API_THING_POLICY,
API_THINGS_SEARCH,
} from '@/constants/api';
@@ -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_THING}/${value.id}`, {
method: 'PUT',
data: value,
});
}
export async function apiGetThingPolicyByUser(
params: Partial<MasterModel.SearchPaginationBody>,
userId: string,
@@ -69,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'],
@@ -83,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,
@@ -92,3 +100,7 @@ export async function apiShareThingToUser(
getResponse: true,
});
}
export async function apiGetThingDetail(thing_id: string) {
return request<MasterModel.Thing>(`${API_THING}/${thing_id}`);
}

View File

@@ -1,4 +1,9 @@
import { API_GROUPS, API_USERS, API_USERS_BY_GROUP } from '@/constants/api';
import {
API_GROUPS,
API_USER_RESET_PASSWORD,
API_USERS,
API_USERS_BY_GROUP,
} from '@/constants/api';
import { request } from '@umijs/max';
export async function apiQueryUsers(
@@ -44,3 +49,23 @@ export async function apiDeleteUser(userId: string) {
getResponse: true,
});
}
export async function apiResetUserPassword(
userId: string,
newPassword: string,
) {
return request(`${API_USERS}/${userId}/password`, {
method: 'PUT',
data: { new_password: newPassword },
getResponse: true,
});
}
export async function apiUserResetPassword(
body: MasterModel.UserResetPasswordRequest,
) {
return request(API_USER_RESET_PASSWORD, {
method: 'PUT',
data: body,
});
}

View File

@@ -17,3 +17,4 @@ declare namespace MasterModel {
direction?: string;
}
}
7;

View File

@@ -7,5 +7,51 @@ declare namespace MasterModel {
interface LoginResponse {
token?: string;
refresh_token: string;
enabled_2fa: boolean;
}
interface RefreshTokenRequestBody {
refresh_token: string;
}
interface RefreshTokenReponse {
access_token: string;
}
interface TokenParsed {
exp: number;
iat: number;
iss: string;
sub: string;
issuer_id: string;
type: number;
purpose: 'access' | 'refresh';
}
interface RefreshTokenParsed extends TokenParsed {
jti: string;
}
interface TokenParsedTransformed {
expriresAt: number;
issuedAt: number;
issuer: string;
/** User Email */
subject: string;
issuerId: string;
type: number;
purpose: 'access' | 'refresh';
}
interface RefreshTokenParsedTransformed extends TokenParsedTransformed {
jwtID: string;
}
interface ForgotPasswordRequestBody {
email: string;
host: string;
}
interface ForgotPasswordResponse {
message: string;
error?: string;
}
}

View File

@@ -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
View 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;
}
}

View File

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

View File

@@ -41,4 +41,9 @@ declare namespace MasterModel {
limit?: number;
users: UserResponse[];
}
interface UserResetPasswordRequest {
token: string;
password: string;
confirm_password?: string;
}
}

View File

@@ -0,0 +1,36 @@
import {
SGW_ROUTE_CREATE_OR_UPDATE_FISH,
SGW_ROUTE_GET_FISH,
} from '@/constants/slave/sgw/routes';
import { request } from '@umijs/max';
export async function apiGetFishSpecies(
body?: SgwModel.SearchFishPaginationBody,
): Promise<SgwModel.FishSpeciesResponse> {
return request<SgwModel.FishSpeciesResponse>(SGW_ROUTE_GET_FISH, {
method: 'POST',
data: body,
});
}
export async function apiCreateFishSpecies(body?: SgwModel.Fish) {
return request<SgwModel.CreateFishResponse>(SGW_ROUTE_CREATE_OR_UPDATE_FISH, {
method: 'POST',
data: [body],
getResponse: true,
});
}
export async function apiUpdateFishSpecies(body?: SgwModel.Fish) {
return request(SGW_ROUTE_CREATE_OR_UPDATE_FISH, {
method: 'PUT',
data: body,
getResponse: true,
});
}
export async function apiDeleteFishSpecies(id?: string) {
return request(`${SGW_ROUTE_CREATE_OR_UPDATE_FISH}/${id}`, {
method: 'DELETE',
getResponse: true,
});
}

View File

@@ -1,38 +1,80 @@
import { SGW_ROUTE_PHOTO } from '@/constants/slave/sgw/routes';
import {
SGW_ROUTE_PHOTO,
SGW_ROUTE_PHOTO_TAGS,
} from '@/constants/slave/sgw/routes';
import { request } from '@umijs/max';
/**
* Get photo from server
* @param type Type of photo ('ship' or 'people')
* @param type Type of photo ('ship' or 'people' or 'fish')
* @param id ID of the entity
* @returns Photo as ArrayBuffer
* @param tag Photo tag (default: 'main')
* @returns Photo response with ArrayBuffer data
*/
export async function apiGetPhoto(
type: SgwModel.PhotoGetParams['type'],
id: string,
): Promise<ArrayBuffer> {
return request<ArrayBuffer>(`${SGW_ROUTE_PHOTO}/${type}/${id}/main`, {
method: 'GET',
responseType: 'arraybuffer',
});
id: string | number,
tag: string = 'main',
): Promise<{ status: number; data: ArrayBuffer }> {
const response = await request<ArrayBuffer>(
`${SGW_ROUTE_PHOTO}/${type}/${id}/${tag}`,
{
method: 'GET',
responseType: 'arraybuffer',
getResponse: true,
},
);
return {
status: 200,
data: response.data,
};
}
export async function apiGetTagsPhoto(
type: SgwModel.PhotoGetParams['type'],
id: string | number,
) {
return request<SgwModel.GetTagsResponse>(
`${SGW_ROUTE_PHOTO_TAGS}/${type}/${id}`,
);
}
/**
* Upload photo to server
* @param type Type of photo ('ship' or 'people')
* @param type Type of photo ('ship' or 'people' or 'fish')
* @param id ID of the entity
* @param file File to upload
* @param tag Photo tag (default: 'main')
*/
export async function apiUploadPhoto(
type: SgwModel.PhotoUploadParams['type'],
id: string,
file: File,
): Promise<void> {
tag: string = 'main',
): Promise<{ status: number }> {
const formData = new FormData();
formData.append('file', file);
return request<void>(`${SGW_ROUTE_PHOTO}/${type}/${id}/main`, {
await request<void>(`${SGW_ROUTE_PHOTO}/${type}/${id}/${tag}`, {
method: 'POST',
data: formData,
});
return { status: 200 };
}
/**
* Delete photo from server
*/
export async function apiDeletePhoto(
type: SgwModel.PhotoGetParams['type'],
id: string | number,
tag: string = 'main',
): Promise<{ status: number }> {
await request(`${SGW_ROUTE_PHOTO}/${type}/${id}/${tag}`, {
method: 'DELETE',
});
return { status: 200 };
}

Some files were not shown because too many files have changed in this diff Show More