Compare commits

15 Commits

Author SHA1 Message Date
Tran Anh Tuan
dca363275e feat(users): add reset password functionality for users and implement forgot password page 2026-02-03 17:33:47 +07:00
9bc15192ec feat(camera): Refactor camera management with new components, update localization keys, and enhance API integration 2026-01-28 17:07:09 +07:00
Tran Anh Tuan
ea07d0c99e feat(master/device-detail && alarm): Enhance device detail page with alarm list and binary sensors integration, update iconfont URLs, and improve alarm confirmation handling 2026-01-27 20:56:54 +07:00
Tran Anh Tuan
ed5751002b feat(wsClient): Update WebSocket connection to support relative paths and enhance MQTT handling 2026-01-27 12:55:01 +07:00
Tran Anh Tuan
6d1c085ff7 feat(master/manager/device): Enhance device management with new detail view, API updates, and improved request handling 2026-01-27 12:18:28 +07:00
Lê Tuấn Anh
a11e2c2991 feat(sgw): Implement Create or Update Banzone functionality with map integration 2026-01-27 12:17:11 +07:00
c9aeca0ed9 feat(wsClient): implement WebSocket client with reconnecting functionality 2026-01-27 12:16:09 +07:00
Tran Anh Tuan
fea9cca865 chore(iconfont): update iconfont url 2026-01-27 10:48:31 +07:00
1f35516e44 feat: Implement camera management page and device location updates, including API, typings, and routing. 2026-01-26 11:18:36 +07:00
Tran Anh Tuan
17d246d5ef feat(api): Extend apiSearchThings to support dynamic domain types 2026-01-24 18:22:10 +07:00
Tran Anh Tuan
b0b09a86b7 refactor(typings): Refactor user and profile models; update API response types 2026-01-24 18:18:16 +07:00
Lê Tuấn Anh
1a06328c77 feat(sgw): Add new services and utilities for ship, trip, and photo management 2026-01-23 15:18:02 +07:00
Tran Anh Tuan
e5b388505a chore(iconfont): update iconfont url 2026-01-23 12:47:55 +07:00
Tran Anh Tuan
6691122c8f feat(manager/users/share): Enhance device sharing functionality and update related APIs and UI components 2026-01-23 11:29:35 +07:00
8b95a620c2 feat(manager/devices): Add device management features including creation and editing 2026-01-22 17:07:02 +07:00
191 changed files with 19902 additions and 1049 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { defineConfig } from '@umijs/max';
import { import {
alarmsRoute, alarmsRoute,
commonManagerRoutes, commonManagerRoutes,
forgotPasswordRoute,
loginRoute, loginRoute,
managerRouteBase, managerRouteBase,
notFoundRoute, notFoundRoute,
@@ -15,6 +16,7 @@ export default defineConfig({
}, },
routes: [ routes: [
loginRoute, loginRoute,
forgotPasswordRoute,
alarmsRoute, alarmsRoute,
profileRoute, profileRoute,
{ {
@@ -23,6 +25,13 @@ export default defineConfig({
path: '/map', path: '/map',
component: './Slave/SGW/Map', component: './Slave/SGW/Map',
}, },
{
name: 'sgw.ships',
icon: 'icon-ship',
path: '/ships',
component: './Slave/SGW/Ship',
access: 'canEndUser_User',
},
{ {
name: 'sgw.trips', name: 'sgw.trips',
icon: 'icon-trip', icon: 'icon-trip',

View File

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

View File

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

View File

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

View File

@@ -1,43 +1,48 @@
import { HTTPSTATUS } from '@/constants'; import { HTTPSTATUS } from '@/constants';
import { API_PATH_REFRESH_TOKEN } from '@/constants/api';
import { ROUTE_LOGIN } from '@/constants/routes'; import { ROUTE_LOGIN } from '@/constants/routes';
import { getToken, removeToken } from '@/utils/storage'; import { apiRefreshToken } from '@/services/master/AuthController';
import { history, RequestConfig } from '@umijs/max'; import { checkRefreshTokenExpired } from '@/utils/jwt';
import {
clearAllData,
getAccessToken,
getRefreshToken,
setAccessToken,
} from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max';
import { message } from 'antd'; import { message } from 'antd';
// Error handling scheme: Error types // Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
// enum ErrorShowType { let refreshingTokenPromise: Promise<string | null> | null = null;
// 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;
// }
// const codeMessage = { async function getValidAccessToken(): Promise<string | null> {
// 200: 'The server successfully returned the requested data。', const refreshToken = getRefreshToken();
// 201: 'New or modified data succeeded。',
// 202: 'A request has been queued in the background (asynchronous task)。', if (!refreshToken || checkRefreshTokenExpired(refreshToken)) {
// 204: 'Data deleted successfully。', return null;
// 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。', if (!refreshingTokenPromise) {
// 404: 'The request issued was for a non-existent record, the server did not operate。', refreshingTokenPromise = apiRefreshToken({ refresh_token: refreshToken })
// 406: 'The requested format is not available。', .then((resp) => {
// 410: 'The requested resource is permanently deleted and will no longer be available。', if (resp?.access_token) {
// 422: 'When creating an object, a validation error occurred。', setAccessToken(resp.access_token);
// 500: 'Server error, please check the server。', return resp.access_token;
// 502: 'Gateway error。', }
// 503: 'Service unavailable, server temporarily overloaded or maintained。', return null;
// 504: 'Gateway timeout。', })
// }; .catch((err) => {
// eslint-disable-next-line no-console
console.error('Refresh token failed:', err);
return null;
})
.finally(() => {
refreshingTokenPromise = null;
});
}
return refreshingTokenPromise;
}
// Runtime configuration // Runtime configuration
export const handleRequestConfig: RequestConfig = { export const handleRequestConfig: RequestConfig = {
@@ -45,7 +50,9 @@ export const handleRequestConfig: RequestConfig = {
timeout: 20000, timeout: 20000,
validateStatus: (status) => { validateStatus: (status) => {
return ( 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' }, headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -76,8 +83,8 @@ export const handleRequestConfig: RequestConfig = {
// 'Unknown error'; // 'Unknown error';
if (status === HTTPSTATUS.HTTP_UNAUTHORIZED) { if (status === HTTPSTATUS.HTTP_UNAUTHORIZED) {
removeToken(); // 401 đã được xử lý trong responseInterceptors (refresh token + redirect nếu cần)
history.push(ROUTE_LOGIN); return;
} else if (status === HTTPSTATUS.HTTP_SERVERERROR) { } else if (status === HTTPSTATUS.HTTP_SERVERERROR) {
// message.error('💥 Internal server error!'); // message.error('💥 Internal server error!');
} else { } else {
@@ -93,7 +100,7 @@ export const handleRequestConfig: RequestConfig = {
// Request interceptors // Request interceptors
requestInterceptors: [ requestInterceptors: [
(url: string, options: any) => { (url: string, options: any) => {
const token = getToken(); const token = getAccessToken();
// console.log('Token: ', token); // console.log('Token: ', token);
return { return {
@@ -102,7 +109,9 @@ export const handleRequestConfig: RequestConfig = {
...options, ...options,
headers: { headers: {
...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 // Unwrap data from backend response
responseInterceptors: [ 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); // 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 { return {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
@@ -127,5 +189,5 @@ export const handleRequestConfig: RequestConfig = {
data: response.data, data: response.data,
}; };
}, },
], ] as any,
}; };

View File

@@ -1,26 +1,48 @@
import { HTTPSTATUS } from '@/constants'; import { HTTPSTATUS } from '@/constants';
import { API_PATH_REFRESH_TOKEN } from '@/constants/api';
import { ROUTE_LOGIN } from '@/constants/routes'; import { ROUTE_LOGIN } from '@/constants/routes';
import { getToken, removeToken } from '@/utils/storage'; import { apiRefreshToken } from '@/services/master/AuthController';
import { history, RequestConfig } from '@umijs/max'; import { checkRefreshTokenExpired } from '@/utils/jwt';
import {
clearAllData,
getAccessToken,
getRefreshToken,
setAccessToken,
} from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max';
import { message } from 'antd'; import { message } from 'antd';
const codeMessage = { // Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
200: 'The server successfully returned the requested data。', let refreshingTokenPromise: Promise<string | null> | null = null;
201: 'New or modified data succeeded。',
202: 'A request has been queued in the background (asynchronous task)。', async function getValidAccessToken(): Promise<string | null> {
204: 'Data deleted successfully。', const refreshToken = getRefreshToken();
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) 。', if (!refreshToken || checkRefreshTokenExpired(refreshToken)) {
403: 'User is authorized, but access is prohibited。', return null;
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。', if (!refreshingTokenPromise) {
422: 'When creating an object, a validation error occurred。', refreshingTokenPromise = apiRefreshToken({ refresh_token: refreshToken })
500: 'Server error, please check the server。', .then((resp) => {
502: 'Gateway error。', if (resp?.access_token) {
503: 'Service unavailable, server temporarily overloaded or maintained。', setAccessToken(resp.access_token);
504: 'Gateway timeout。', 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 // Runtime configuration
export const handleRequestConfig: RequestConfig = { export const handleRequestConfig: RequestConfig = {
@@ -28,7 +50,9 @@ export const handleRequestConfig: RequestConfig = {
timeout: 20000, timeout: 20000,
validateStatus: (status) => { validateStatus: (status) => {
return ( 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' }, headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -48,24 +72,25 @@ export const handleRequestConfig: RequestConfig = {
// Error catching and handling // Error catching and handling
errorHandler: (error: any) => { errorHandler: (error: any) => {
if (error.response) { if (error.response) {
const { status, statusText, data } = error.response; const { status } = error.response;
// Ưu tiên: codeMessage → backend message → statusText // Ưu tiên: codeMessage → backend message → statusText
const errMsg = // const errMsg =
codeMessage[status as keyof typeof codeMessage] || // codeMessage[status as keyof typeof codeMessage] ||
data?.message || // data?.message ||
statusText || // statusText ||
'Unknown error'; // 'Unknown error';
message.error(`${status}: ${errMsg}`); if (status === HTTPSTATUS.HTTP_UNAUTHORIZED) {
if (status === 401) { // 401 đã được xử lý trong responseInterceptors (refresh token + redirect nếu cần)
removeToken(); return;
history.push(ROUTE_LOGIN); } else if (status === HTTPSTATUS.HTTP_SERVERERROR) {
// message.error('💥 Internal server error!');
} else {
message.error(`${status}: ${error.message || 'Error'}`);
} }
} else if (error.request) { } else if (error.request) {
message.error('🚨 No response from server!'); message.error('🚨 No response from server!');
} else if (status) {
// message.error('💥 Internal server error!');
} else { } else {
message.error(`⚠️ Request setup error: ${error.message}`); 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 // 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 // URL sẽ bắt đầu với /api và proxy sẽ chuyển đến hostname:81/api
const token = getToken(); const token = getAccessToken();
return { return {
url: url, url: url,
options: { options: {
...options, ...options,
headers: { headers: {
...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 // Unwrap data from backend response
// responseInterceptors: [ responseInterceptors: [
// (response) => { async (response: any, options: any) => {
// const res = response.data as ResponseStructure<any>; const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN);
// if (res && res.success) { const alreadyRetried = options?.skipAuthRefresh === true;
// // ✅ Trả ra data luôn thay vì cả object
// return res.data; // Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
// } if (
// return response.data; 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, layout: false,
}; };
export const forgotPasswordRoute = {
title: 'Forgot Password',
path: '/password/reset',
component: './Auth/ForgotPassword',
layout: false,
access: 'canAll',
};
export const profileRoute = { export const profileRoute = {
name: 'profile', name: 'profile',
icon: 'icon-user', icon: 'icon-user',
@@ -39,6 +47,10 @@ export const commonManagerRoutes = [
path: '/manager/devices', path: '/manager/devices',
component: './Manager/Device', 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 = { export const managerRouteBase = {
name: 'manager', name: 'manager',
icon: 'icon-setting', icon: 'icon-setting',

123
pnpm-lock.yaml generated
View File

@@ -26,6 +26,15 @@ importers:
dayjs: dayjs:
specifier: ^1.11.19 specifier: ^1.11.19
version: 1.11.19 version: 1.11.19
moment:
specifier: ^2.30.1
version: 2.30.1
ol:
specifier: ^10.6.1
version: 10.7.0
reconnecting-websocket:
specifier: ^4.4.0
version: 4.4.0
devDependencies: devDependencies:
'@types/react': '@types/react':
specifier: ^18.0.33 specifier: ^18.0.33
@@ -1091,6 +1100,9 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, tarball: https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, tarball: https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz}
engines: {node: '>= 8'} engines: {node: '>= 8'}
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==, tarball: https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, tarball: https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1356,6 +1368,9 @@ packages:
'@types/prop-types@15.7.15': '@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==, tarball: https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz} resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==, tarball: https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz}
'@types/rbush@4.0.0':
resolution: {integrity: sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==, tarball: https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz}
'@types/react-dom@18.3.7': '@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==, tarball: https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz} resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==, tarball: https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz}
peerDependencies: peerDependencies:
@@ -2746,6 +2761,9 @@ packages:
react: 15.x || ^16.0.0-0 react: 15.x || ^16.0.0-0
react-dom: 15.x || ^16.0.0-0 react-dom: 15.x || ^16.0.0-0
earcut@3.0.2:
resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==, tarball: https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz}
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, tarball: https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, tarball: https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz}
@@ -3179,6 +3197,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, tarball: https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, tarball: https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
geotiff@2.1.3:
resolution: {integrity: sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==, tarball: https://registry.npmjs.org/geotiff/-/geotiff-2.1.3.tgz}
engines: {node: '>=10.19'}
get-caller-file@2.0.5: get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, tarball: https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz} resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, tarball: https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz}
engines: {node: 6.* || 8.* || >= 10.*} engines: {node: 6.* || 8.* || >= 10.*}
@@ -3840,6 +3862,9 @@ packages:
kolorist@1.8.0: kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==, tarball: https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz} resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==, tarball: https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz}
lerc@3.0.0:
resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==, tarball: https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz}
less-loader@12.3.0: less-loader@12.3.0:
resolution: {integrity: sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==, tarball: https://registry.npmjs.org/less-loader/-/less-loader-12.3.0.tgz} resolution: {integrity: sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==, tarball: https://registry.npmjs.org/less-loader/-/less-loader-12.3.0.tgz}
engines: {node: '>= 18.12.0'} engines: {node: '>= 18.12.0'}
@@ -4281,6 +4306,9 @@ packages:
obuf@1.1.2: obuf@1.1.2:
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==, tarball: https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz} resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==, tarball: https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz}
ol@10.7.0:
resolution: {integrity: sha512-122U5gamPqNgLpLOkogFJhgpywvd/5en2kETIDW+Ubfi9lPnZ0G9HWRdG+CX0oP8od2d6u6ky3eewIYYlrVczw==, tarball: https://registry.npmjs.org/ol/-/ol-10.7.0.tgz}
on-exit-leak-free@0.2.0: on-exit-leak-free@0.2.0:
resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==, tarball: https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-0.2.0.tgz} resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==, tarball: https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-0.2.0.tgz}
@@ -4352,6 +4380,9 @@ packages:
pako@1.0.11: pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==, tarball: https://registry.npmjs.org/pako/-/pako-1.0.11.tgz} resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==, tarball: https://registry.npmjs.org/pako/-/pako-1.0.11.tgz}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==, tarball: https://registry.npmjs.org/pako/-/pako-2.1.0.tgz}
param-case@3.0.4: param-case@3.0.4:
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==, tarball: https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz} resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==, tarball: https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz}
@@ -4363,6 +4394,9 @@ packages:
resolution: {integrity: sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==, tarball: https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz} resolution: {integrity: sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==, tarball: https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.9.tgz}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
parse-headers@2.0.6:
resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==, tarball: https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz}
parse-json@5.2.0: parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, tarball: https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz} resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, tarball: https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4421,6 +4455,10 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==, tarball: https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz} resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==, tarball: https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz}
engines: {node: '>=8'} engines: {node: '>=8'}
pbf@4.0.1:
resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==, tarball: https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz}
hasBin: true
pbkdf2@3.1.5: pbkdf2@3.1.5:
resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==, tarball: https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz} resolution: {integrity: sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ==, tarball: https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -4835,6 +4873,9 @@ packages:
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, tarball: https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, tarball: https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz}
protocol-buffers-schema@3.6.0:
resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==, tarball: https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz}
proxy-addr@2.0.7: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==, tarball: https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==, tarball: https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -4883,6 +4924,13 @@ packages:
resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==, tarball: https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz} resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==, tarball: https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz}
engines: {node: '>=8'} engines: {node: '>=8'}
quick-lru@6.1.2:
resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==, tarball: https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz}
engines: {node: '>=12'}
quickselect@3.0.0:
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==, tarball: https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz}
randombytes@2.1.0: randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==, tarball: https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz} resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==, tarball: https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz}
@@ -4897,6 +4945,9 @@ packages:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==, tarball: https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz} resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==, tarball: https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
rbush@4.0.1:
resolution: {integrity: sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==, tarball: https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz}
rc-align@4.0.15: rc-align@4.0.15:
resolution: {integrity: sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==, tarball: https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz} resolution: {integrity: sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==, tarball: https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz}
peerDependencies: peerDependencies:
@@ -5462,6 +5513,9 @@ packages:
resolution: {integrity: sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==, tarball: https://registry.npmjs.org/real-require/-/real-require-0.1.0.tgz} resolution: {integrity: sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==, tarball: https://registry.npmjs.org/real-require/-/real-require-0.1.0.tgz}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
reconnecting-websocket@4.4.0:
resolution: {integrity: sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==, tarball: https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz}
redent@3.0.0: redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==, tarball: https://registry.npmjs.org/redent/-/redent-3.0.0.tgz} resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==, tarball: https://registry.npmjs.org/redent/-/redent-3.0.0.tgz}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -5538,6 +5592,9 @@ packages:
resolve-pkg-maps@1.0.0: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, tarball: https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, tarball: https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz}
resolve-protobuf-schema@2.1.0:
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==, tarball: https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz}
resolve-url-loader@5.0.0: resolve-url-loader@5.0.0:
resolution: {integrity: sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==, tarball: https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz} resolution: {integrity: sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==, tarball: https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -6377,6 +6434,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==, tarball: https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz} resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==, tarball: https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz}
engines: {node: '>= 8'} engines: {node: '>= 8'}
web-worker@1.5.0:
resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==, tarball: https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz}
webpack-5-chain@8.0.1: webpack-5-chain@8.0.1:
resolution: {integrity: sha512-Tu1w80WA2Z+X6e7KzGy+cc0A0z+npVJA/fh55q2azMJ030gqz343Kx+yNAstDCeugsepmtDWY2J2IBRW/O+DEA==, tarball: https://registry.npmjs.org/webpack-5-chain/-/webpack-5-chain-8.0.1.tgz} resolution: {integrity: sha512-Tu1w80WA2Z+X6e7KzGy+cc0A0z+npVJA/fh55q2azMJ030gqz343Kx+yNAstDCeugsepmtDWY2J2IBRW/O+DEA==, tarball: https://registry.npmjs.org/webpack-5-chain/-/webpack-5-chain-8.0.1.tgz}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -6454,6 +6514,9 @@ packages:
utf-8-validate: utf-8-validate:
optional: true optional: true
xml-utils@1.10.2:
resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==, tarball: https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz}
xtend@4.0.2: xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, tarball: https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, tarball: https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
@@ -6501,6 +6564,9 @@ packages:
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz}
zstddec@0.1.0:
resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==, tarball: https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz}
snapshots: snapshots:
'@ahooksjs/use-request@2.8.15(react@18.3.1)': '@ahooksjs/use-request@2.8.15(react@18.3.1)':
@@ -7810,6 +7876,8 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1 fastq: 1.20.1
'@petamoriken/float16@3.9.3': {}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -8095,6 +8163,8 @@ snapshots:
'@types/prop-types@15.7.15': {} '@types/prop-types@15.7.15': {}
'@types/rbush@4.0.0': {}
'@types/react-dom@18.3.7(@types/react@18.3.27)': '@types/react-dom@18.3.7(@types/react@18.3.27)':
dependencies: dependencies:
'@types/react': 18.3.27 '@types/react': 18.3.27
@@ -10061,6 +10131,8 @@ snapshots:
react-router-redux: 5.0.0-alpha.9(react@18.3.1) react-router-redux: 5.0.0-alpha.9(react@18.3.1)
redux: 3.7.2 redux: 3.7.2
earcut@3.0.2: {}
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
ee-first@1.1.1: {} ee-first@1.1.1: {}
@@ -10704,6 +10776,17 @@ snapshots:
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
geotiff@2.1.3:
dependencies:
'@petamoriken/float16': 3.9.3
lerc: 3.0.0
pako: 2.1.0
parse-headers: 2.0.6
quick-lru: 6.1.2
web-worker: 1.5.0
xml-utils: 1.10.2
zstddec: 0.1.0
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
@@ -11398,6 +11481,8 @@ snapshots:
kolorist@1.8.0: {} kolorist@1.8.0: {}
lerc@3.0.0: {}
less-loader@12.3.0(less@4.5.1)(webpack@5.104.1): less-loader@12.3.0(less@4.5.1)(webpack@5.104.1):
dependencies: dependencies:
less: 4.5.1 less: 4.5.1
@@ -11877,6 +11962,14 @@ snapshots:
obuf@1.1.2: {} obuf@1.1.2: {}
ol@10.7.0:
dependencies:
'@types/rbush': 4.0.0
earcut: 3.0.2
geotiff: 2.1.3
pbf: 4.0.1
rbush: 4.0.1
on-exit-leak-free@0.2.0: {} on-exit-leak-free@0.2.0: {}
on-finished@2.3.0: on-finished@2.3.0:
@@ -11953,6 +12046,8 @@ snapshots:
pako@1.0.11: {} pako@1.0.11: {}
pako@2.1.0: {}
param-case@3.0.4: param-case@3.0.4:
dependencies: dependencies:
dot-case: 3.0.4 dot-case: 3.0.4
@@ -11970,6 +12065,8 @@ snapshots:
pbkdf2: 3.1.5 pbkdf2: 3.1.5
safe-buffer: 5.2.1 safe-buffer: 5.2.1
parse-headers@2.0.6: {}
parse-json@5.2.0: parse-json@5.2.0:
dependencies: dependencies:
'@babel/code-frame': 7.28.6 '@babel/code-frame': 7.28.6
@@ -12017,6 +12114,10 @@ snapshots:
path-type@4.0.0: {} path-type@4.0.0: {}
pbf@4.0.1:
dependencies:
resolve-protobuf-schema: 2.1.0
pbkdf2@3.1.5: pbkdf2@3.1.5:
dependencies: dependencies:
create-hash: 1.2.0 create-hash: 1.2.0
@@ -12396,6 +12497,8 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
react-is: 16.13.1 react-is: 16.13.1
protocol-buffers-schema@3.6.0: {}
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@@ -12447,6 +12550,10 @@ snapshots:
quick-lru@4.0.1: {} quick-lru@4.0.1: {}
quick-lru@6.1.2: {}
quickselect@3.0.0: {}
randombytes@2.1.0: randombytes@2.1.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@@ -12465,6 +12572,10 @@ snapshots:
iconv-lite: 0.4.24 iconv-lite: 0.4.24
unpipe: 1.0.0 unpipe: 1.0.0
rbush@4.0.1:
dependencies:
quickselect: 3.0.0
rc-align@4.0.15(react-dom@18.3.1(react@18.3.1))(react@18.3.1): rc-align@4.0.15(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.28.6
@@ -13262,6 +13373,8 @@ snapshots:
real-require@0.1.0: {} real-require@0.1.0: {}
reconnecting-websocket@4.4.0: {}
redent@3.0.0: redent@3.0.0:
dependencies: dependencies:
indent-string: 4.0.0 indent-string: 4.0.0
@@ -13340,6 +13453,10 @@ snapshots:
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
resolve-protobuf-schema@2.1.0:
dependencies:
protocol-buffers-schema: 3.6.0
resolve-url-loader@5.0.0: resolve-url-loader@5.0.0:
dependencies: dependencies:
adjust-sourcemap-loader: 4.0.0 adjust-sourcemap-loader: 4.0.0
@@ -14334,6 +14451,8 @@ snapshots:
web-streams-polyfill@3.3.3: {} web-streams-polyfill@3.3.3: {}
web-worker@1.5.0: {}
webpack-5-chain@8.0.1: webpack-5-chain@8.0.1:
dependencies: dependencies:
deepmerge: 1.5.2 deepmerge: 1.5.2
@@ -14447,6 +14566,8 @@ snapshots:
ws@8.19.0: {} ws@8.19.0: {}
xml-utils@1.10.2: {}
xtend@4.0.2: {} xtend@4.0.2: {}
y18n@5.0.8: {} y18n@5.0.8: {}
@@ -14480,3 +14601,5 @@ snapshots:
zod: 3.25.76 zod: 3.25.76
zod@3.25.76: {} zod@3.25.76: {}
zstddec@0.1.0: {}

BIN
public/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

View File

@@ -11,46 +11,84 @@ import LanguageSwitcher from './components/Lang/LanguageSwitcher';
import ThemeProvider from './components/Theme/ThemeProvider'; import ThemeProvider from './components/Theme/ThemeProvider';
import ThemeSwitcher from './components/Theme/ThemeSwitcher'; import ThemeSwitcher from './components/Theme/ThemeSwitcher';
import { THEME_KEY } from './constants'; 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 NotFoundPage from './pages/Exception/NotFound';
import UnAccessPage from './pages/Exception/UnAccess'; import UnAccessPage from './pages/Exception/UnAccess';
import { apiQueryProfile } from './services/master/AuthController'; import {
import { checkTokenExpired } from './utils/jwt'; apiQueryProfile,
apiRefreshToken,
} from './services/master/AuthController';
import { checkRefreshTokenExpired } from './utils/jwt';
import { getLogoImage } from './utils/logo'; import { getLogoImage } from './utils/logo';
import { import {
clearAllData, clearAllData,
clearSessionData, getAccessToken,
getToken, getRefreshToken,
removeToken, setAccessToken,
} from './utils/storage'; } from './utils/storage';
const isProdBuild = process.env.NODE_ENV === 'production'; const isProdBuild = process.env.NODE_ENV === 'production';
export type InitialStateResponse = { export type InitialStateResponse = {
getUserProfile?: () => Promise<MasterModel.ProfileResponse | undefined>; getUserProfile?: () => Promise<MasterModel.UserResponse | undefined>;
currentUserProfile?: MasterModel.ProfileResponse; currentUserProfile?: MasterModel.UserResponse;
theme?: 'light' | 'dark'; 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 用户信息和权限初始化 // 全局初始化数据配置,用于 Layout 用户信息和权限初始化
// 更多信息见文档https://umijs.org/docs/api/runtime-config#getinitialstate // 更多信息见文档https://umijs.org/docs/api/runtime-config#getinitialstate
export async function getInitialState(): Promise<InitialStateResponse> { export async function getInitialState(): Promise<InitialStateResponse> {
const userToken: string = getToken(); const refreshToken: string = getRefreshToken();
const { pathname } = history.location; 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'); dayjs.locale(getLocale() === 'en-US' ? 'en' : 'vi');
if (!userToken) { if (!refreshToken) {
if (pathname !== ROUTE_LOGIN) { handleBackToLogin();
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
} else {
history.push(ROUTE_LOGIN);
}
return {}; return {};
} }
const isTokenExpried = checkTokenExpired(userToken); const isTokenExpried = checkRefreshTokenExpired(refreshToken);
if (isTokenExpried) { if (isTokenExpried) {
removeToken(); handleBackToLogin();
clearAllData(); return {};
clearSessionData(); }
window.location.href = ROUTE_LOGIN;
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 {}; return {};
} }
@@ -59,10 +97,7 @@ export async function getInitialState(): Promise<InitialStateResponse> {
const resp = await apiQueryProfile(); const resp = await apiQueryProfile();
return resp; return resp;
} catch (error) { } catch (error) {
removeToken(); console.error('Cannot get Profile: ', error);
clearAllData();
clearSessionData();
window.location.href = ROUTE_LOGIN;
} }
}; };
const resp = await getUserProfile(); const resp = await getUserProfile();
@@ -86,7 +121,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
contentWidth: 'Fluid', contentWidth: 'Fluid',
navTheme: isDark ? 'realDark' : 'light', navTheme: isDark ? 'realDark' : 'light',
splitMenus: true, splitMenus: true,
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_fvnh1x2eqer.js', iconfontUrl: '//at.alicdn.com/t/c/font_5096559_919jssa0os9.js',
contentStyle: { contentStyle: {
padding: 0, padding: 0,
margin: 0, margin: 0,
@@ -144,6 +179,10 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
colorBgMenuItemSelected: '#EEF7FF', // background khi chọn colorBgMenuItemSelected: '#EEF7FF', // background khi chọn
colorTextMenuSelected: isDark ? '#fff' : '#1A2130', // màu chữ khi chọn colorTextMenuSelected: isDark ? '#fff' : '#1A2130', // màu chữ khi chọn
}, },
pageContainer: {
paddingInlinePageContainerContent: 8,
paddingBlockPageContainerContent: 8,
},
}, },
unAccessible: <UnAccessPage />, unAccessible: <UnAccessPage />,
noFound: <NotFoundPage />, noFound: <NotFoundPage />,

BIN
src/assets/alarm_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
src/assets/exclamation.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
src/assets/marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src/assets/ship_alarm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
src/assets/ship_alarm_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
src/assets/ship_online.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
src/assets/ship_warning.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
src/assets/sos_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
src/assets/warning_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

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

View File

@@ -1,7 +1,7 @@
import { createFromIconfontCN } from '@ant-design/icons'; import { createFromIconfontCN } from '@ant-design/icons';
const IconFont = createFromIconfontCN({ const IconFont = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_5096559_fvnh1x2eqer.js', scriptUrl: '//at.alicdn.com/t/c/font_5096559_919jssa0os9.js',
}); });
export default IconFont; 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,62 @@
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
import { Button, Popconfirm, Tooltip } from 'antd';
import { useState } from 'react';
/* =======================
DeleteButton
======================= */
interface DeleteButtonProps {
title: string;
text: string;
onOk: () => void | Promise<void>;
}
export const DeleteButton: React.FC<DeleteButtonProps> = ({
title,
text,
onOk,
}) => {
const [visible, setVisible] = useState<boolean>(false);
const handleConfirm = async () => {
await onOk();
setVisible(false);
};
return (
<Popconfirm
title={title}
open={visible}
onConfirm={handleConfirm}
onCancel={() => setVisible(false)}
>
<Tooltip title={text}>
<Button
size="small"
danger
type="primary"
icon={<DeleteOutlined />}
onClick={() => setVisible(true)}
/>
</Tooltip>
</Popconfirm>
);
};
/* =======================
EditButton
======================= */
interface EditButtonProps {
text: string;
onClick: () => void;
}
export const EditButton: React.FC<EditButtonProps> = ({ text, onClick }) => {
return (
<Tooltip title={text}>
<Button size="small" icon={<EditOutlined />} onClick={onClick} />
</Tooltip>
);
};

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

@@ -0,0 +1,205 @@
import {
AlertOutlined,
CheckOutlined,
DisconnectOutlined,
ExclamationOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Flex, Tag, theme, Tooltip } from 'antd';
import { useState } from 'react';
import { TagStateCallbackPayload } from '../../pages/Slave/SGW/Map/type';
type TagStateProps = {
normalCount?: number;
warningCount?: number;
criticalCount?: number;
sosCount?: number;
disconnectedCount?: number;
onTagPress?: (selection: TagStateCallbackPayload) => void;
};
const TagState = ({
normalCount = 0,
warningCount = 0,
criticalCount = 0,
sosCount,
disconnectedCount = 0,
onTagPress,
}: TagStateProps) => {
const [activeStates, setActiveStates] = useState({
normal: false,
warning: false,
critical: false,
sos: false,
disconnected: false,
});
const { token } = theme.useToken();
const intl = useIntl();
// Style variants using antd theme tokens for dark mode support
const getTagStyle = (
type: 'normal' | 'warning' | 'critical' | 'offline',
isActive: boolean,
) => {
const baseStyle = {
borderRadius: token.borderRadiusSM,
borderWidth: 1,
borderStyle: 'solid' as const,
};
if (type === 'normal') {
return {
...baseStyle,
color: isActive ? token.colorSuccess : token.colorSuccess,
backgroundColor: isActive
? token.colorSuccessBg
: token.colorBgContainer,
borderColor: token.colorSuccessBorder,
};
}
if (type === 'warning') {
return {
...baseStyle,
color: isActive ? token.colorWarning : token.colorWarning,
backgroundColor: isActive
? token.colorWarningBg
: token.colorBgContainer,
borderColor: token.colorWarningBorder,
};
}
if (type === 'critical') {
return {
...baseStyle,
color: isActive ? token.colorError : token.colorError,
backgroundColor: isActive ? token.colorErrorBg : token.colorBgContainer,
borderColor: token.colorErrorBorder,
};
}
// offline
return {
...baseStyle,
color: token.colorTextSecondary,
backgroundColor: isActive
? token.colorFillSecondary
: token.colorBgContainer,
borderColor: token.colorBorder,
};
};
const handleTagClick = (key: keyof typeof activeStates) => {
const newStates = { ...activeStates, [key]: !activeStates[key] };
setActiveStates(newStates);
if (onTagPress) {
onTagPress({
isNormal: newStates.normal,
isWarning: newStates.warning,
isCritical: newStates.critical,
isSos: newStates.sos,
isDisconnected: newStates.disconnected,
});
}
};
return (
<Flex
gap={1}
style={{
overflowX: 'auto',
overflowY: 'hidden',
whiteSpace: 'nowrap',
minWidth: 0,
zIndex: 20,
flexWrap: 'nowrap',
}}
>
{/* Only show SOS tag if sosCount is provided (SGW environment) */}
{sosCount !== undefined && (
<Tooltip
title={intl.formatMessage({
id: 'common.level.sos',
defaultMessage: 'SOS',
})}
>
<Tag.CheckableTag
style={getTagStyle('critical', activeStates.sos)}
icon={<AlertOutlined />}
checked={activeStates.sos}
onChange={() => handleTagClick('sos')}
>
{`${sosCount}`}
</Tag.CheckableTag>
</Tooltip>
)}
{/* {normalCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'common.level.normal',
defaultMessage: 'Normal',
})}
>
<Tag.CheckableTag
style={getTagStyle('normal', activeStates.normal)}
icon={<CheckOutlined />}
checked={activeStates.normal}
onChange={() => handleTagClick('normal')}
>
{`${normalCount}`}
</Tag.CheckableTag>
</Tooltip>
{/* {warningCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'common.level.warning',
defaultMessage: 'Warning',
})}
>
<Tag.CheckableTag
style={getTagStyle('warning', activeStates.warning)}
icon={<WarningOutlined />}
checked={activeStates.warning}
onChange={() => handleTagClick('warning')}
>
{`${warningCount}`}
</Tag.CheckableTag>
</Tooltip>
{/* {criticalCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'common.level.critical',
defaultMessage: 'Critical',
})}
>
<Tag.CheckableTag
style={getTagStyle('critical', activeStates.critical)}
icon={<ExclamationOutlined />}
checked={activeStates.critical}
onChange={() => handleTagClick('critical')}
>
{`${criticalCount}`}
</Tag.CheckableTag>
</Tooltip>
{/* {disconnectedCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'common.level.disconnected',
defaultMessage: 'Disconnected',
})}
>
<Tag.CheckableTag
style={getTagStyle('offline', activeStates.disconnected)}
icon={<DisconnectOutlined />}
checked={activeStates.disconnected}
onChange={() => handleTagClick('disconnected')}
>
{`${disconnectedCount}`}
</Tag.CheckableTag>
</Tooltip>
</Flex>
);
};
export default TagState;

View File

@@ -13,17 +13,23 @@ import TreeGroup from './TreeGroup';
const { Paragraph } = Typography; const { Paragraph } = Typography;
type ThingsFilterProps = { type ThingsFilterProps = {
title?: string;
isOpen?: boolean; isOpen?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
thingIds?: string | string[] | null; thingIds?: string | string[] | null;
extra?: React.ReactNode;
onSubmit?: (thingIds: string[]) => void; onSubmit?: (thingIds: string[]) => void;
disabled?: boolean;
}; };
const ThingsFilter = ({ const ThingsFilter = ({
title,
isOpen, isOpen,
setIsOpen, setIsOpen,
thingIds, thingIds,
extra,
onSubmit, onSubmit,
disabled = false,
}: ThingsFilterProps) => { }: ThingsFilterProps) => {
const intl = useIntl(); const intl = useIntl();
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -93,6 +99,7 @@ const ThingsFilter = ({
]; ];
return ( return (
<Modal <Modal
title={title ? title : null}
open={isOpen} open={isOpen}
centered centered
width="80%" width="80%"
@@ -104,7 +111,10 @@ const ThingsFilter = ({
<FormattedMessage id="common.cancel" defaultMessage="Cancel" /> <FormattedMessage id="common.cancel" defaultMessage="Cancel" />
} }
> >
<ProCard split={screens.md ? 'vertical' : 'horizontal'}> <ProCard
split={screens.md ? 'vertical' : 'horizontal'}
extra={extra ? extra : null}
>
<ProCard colSpan={{ xs: 24, sm: 8, md: 8, lg: 6, xl: 6 }}> <ProCard colSpan={{ xs: 24, sm: 8, md: 8, lg: 6, xl: 6 }}>
<TreeGroup <TreeGroup
disable={isLoading} disable={isLoading}
@@ -128,6 +138,15 @@ const ThingsFilter = ({
alwaysShowAlert: true, alwaysShowAlert: true,
selectedRowKeys, selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys), onChange: (keys) => setSelectedRowKeys(keys),
getCheckboxProps: (record) => ({
disabled:
disabled && thingIds && record.id
? (Array.isArray(thingIds)
? thingIds
: [thingIds]
).includes(record.id)
: false,
}),
}} }}
pagination={{ pagination={{
size: 'small', size: 'small',

View File

@@ -0,0 +1,50 @@
import {
STATUS_DANGEROUS,
STATUS_NORMAL,
STATUS_SOS,
STATUS_WARNING,
} from '@/constants';
import { Badge, GlobalToken } from 'antd';
import IconFont from '../IconFont';
export const getBadgeStatus = (status: number) => {
switch (status) {
case STATUS_NORMAL:
return <Badge size="default" status="success" />;
case STATUS_WARNING:
return <Badge size="default" status="warning" />;
case STATUS_DANGEROUS:
return <Badge size="default" status="error" />;
case STATUS_SOS:
return <Badge size="default" status="error" />;
default:
return <Badge size="default" status="default" />;
}
};
export const getBadgeConnection = (online: boolean) => {
switch (online) {
case true:
return <Badge status="processing" />;
default:
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

@@ -0,0 +1,104 @@
.italic {
//font-style: italic;
margin-left: 4px;
}
.disconnected {
color: rgb(146, 143, 143);
//background-color: rgb(219, 220, 222);
cursor: pointer;
}
:global(.cursor-pointer-row .ant-table-tbody > tr) {
cursor: pointer;
}
.normalActive {
color: #52c41a;
background: #e0fec3;
border-color: #b7eb8f;
}
.warningActive {
color: #faad14;
background: #f8ebaa;
border-color: #ffe58f;
}
.criticalActive {
color: #ff4d4f;
background: #f9b9b0;
border-color: #ffccc7;
}
.normal {
color: #52c41a;
background: #fff;
border-color: #b7eb8f;
}
.warning {
color: #faad14;
background: #fff;
border-color: #ffe58f;
}
.critical {
color: #ff4d4f;
background: #fff;
border-color: #ffccc7;
}
.offline {
background: #fff;
color: rgba(0, 0, 0, 88%);
border-color: #d9d9d9;
}
.offlineActive {
background: rgb(190, 190, 190);
color: rgba(0, 0, 0, 88%);
border-color: #d9d9d9;
}
.online {
background: #fff;
color: #1677ff;
border-color: #91caff;
}
.onlineActive {
background: #c6e1f5;
color: #1677ff;
border-color: #91caff;
}
.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,14 +1,17 @@
// Auth API Paths // Auth API Paths
export const API_PATH_LOGIN = '/api/tokens'; 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_PATH_GET_PROFILE = '/api/users/profile';
export const API_CHANGE_PASSWORD = '/api/password'; export const API_CHANGE_PASSWORD = '/api/password';
export const API_FORGOT_PASSWORD = '/api/password/reset-request';
// Alarm API Constants // Alarm API Constants
export const API_ALARMS = '/api/alarms'; export const API_ALARMS = '/api/alarms';
export const API_ALARMS_CONFIRM = '/api/alarms/confirm'; export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
// Thing API Constants // Thing API Constants
export const API_THINGS_SEARCH = '/api/things/search'; export const API_THINGS_SEARCH = '/api/things/search';
export const API_THINGS_POLICY = '/api/things/policy'; export const API_THING_POLICY = '/api/things/policy2';
export const API_THING = '/api/things';
// Group API Constants // Group API Constants
export const API_GROUPS = '/api/groups'; export const API_GROUPS = '/api/groups';
@@ -16,8 +19,9 @@ export const API_GROUP_MEMBERS = '/api/members';
export const API_GROUP_CHILDREN = '/api/groups'; export const API_GROUP_CHILDREN = '/api/groups';
// Log API Constants // Log API Constants
export const API_LOGS = '/api/reader/channels'; export const API_READER = '/api/reader/channels';
// User API Constants // User API Constants
export const API_USERS = '/api/users'; export const API_USERS = '/api/users';
export const API_USER_RESET_PASSWORD = '/api/password/reset';
export const API_USERS_BY_GROUP = '/api/users/groups'; export const API_USERS_BY_GROUP = '/api/users/groups';

View File

@@ -5,6 +5,9 @@ export const DATE_TIME_FORMAT = 'DD/MM/YYYY HH:mm:ss';
export const TIME_FORMAT = 'HH:mm:ss'; export const TIME_FORMAT = 'HH:mm:ss';
export const DATE_FORMAT = 'DD/MM/YYYY'; export const DATE_FORMAT = 'DD/MM/YYYY';
export const DURATION_DISCONNECTED = 300; //seconds
export const DURATION_POLLING_PRESENTATIONS = 120000; //milliseconds
export const STATUS_NORMAL = 0; export const STATUS_NORMAL = 0;
export const STATUS_WARNING = 1; export const STATUS_WARNING = 1;
export const STATUS_DANGEROUS = 2; export const STATUS_DANGEROUS = 2;
@@ -16,7 +19,8 @@ export const COLOR_WARNING = '#d48806';
export const COLOR_DANGEROUS = '#d9363e'; export const COLOR_DANGEROUS = '#d9363e';
export const COLOR_SOS = '#ff0000'; 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'; export const THEME_KEY = 'theme';
// Global Constants // Global Constants
export const LIMIT_TREE_LEVEL = 5; export const LIMIT_TREE_LEVEL = 5;

View File

@@ -1,5 +1,7 @@
export const ROUTE_LOGIN = '/login'; export const ROUTE_LOGIN = '/login';
export const ROUTE_FORGOT_PASSWORD = '/password/reset';
export const ROUTER_HOME = '/'; export const ROUTER_HOME = '/';
export const ROUTE_PROFILE = '/profile'; export const ROUTE_PROFILE = '/profile';
export const ROUTE_MANAGER_USERS = '/manager/users'; export const ROUTE_MANAGER_USERS = '/manager/users';
export const ROUTE_MANAGER_DEVICES = '/manager/devices';
export const ROUTE_MANAGER_USERS_PERMISSIONS = 'permissions'; export const ROUTE_MANAGER_USERS_PERMISSIONS = 'permissions';

View File

@@ -4,3 +4,12 @@ export enum SGW_ROLE {
USERS = 'users', USERS = 'users',
ENDUSER = 'enduser', ENDUSER = 'enduser',
} }
export enum SGW_STATUS {
CREATE_FISHING_LOG_SUCCESS = 'CREATE_FISHING_LOG_SUCCESS',
CREATE_FISHING_LOG_FAIL = 'CREATE_FISHING_LOG_FAIL',
START_TRIP_SUCCESS = 'START_TRIP_SUCCESS',
START_TRIP_FAIL = 'START_TRIP_FAIL',
UPDATE_FISHING_LOG_SUCCESS = 'UPDATE_FISHING_LOG_SUCCESS',
UPDATE_FISHING_LOG_FAIL = 'UPDATE_FISHING_LOG_FAIL',
}

View File

@@ -2,3 +2,36 @@ export const SGW_ROUTE_HOME = '/maps';
export const SGW_ROUTE_TRIP = '/trip'; export const SGW_ROUTE_TRIP = '/trip';
export const SGW_ROUTE_CREATE_BANZONE = '/manager/banzones/create'; export const SGW_ROUTE_CREATE_BANZONE = '/manager/banzones/create';
export const SGW_ROUTE_MANAGER_BANZONE = '/manager/banzones'; export const SGW_ROUTE_MANAGER_BANZONE = '/manager/banzones';
// API Routes
export const SGW_ROUTE_PORTS = '/api/sgw/ports';
export const SGW_ROUTE_SHIPS = '/api/sgw/ships';
export const SGW_ROUTE_SHIP_TYPES = '/api/sgw/ships/types';
export const SGW_ROUTE_SHIP_GROUPS = '/api/sgw/shipsgroup';
// Trip API Routes
export const SGW_ROUTE_TRIPS = '/api/sgw/trips';
export const SGW_ROUTE_TRIPS_LIST = '/api/sgw/tripslist';
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/fishspecies-list';
export const SGW_ROUTE_UPDATE_FISHING_LOGS = '/api/sgw/update-fishing-logs';
// Crew API Routes
export const SGW_ROUTE_CREW = '/api/sgw/crew';
export const SGW_ROUTE_TRIP_CREW = '/api/sgw/tripcrew';
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

@@ -0,0 +1 @@
export const SHIP_SOS_WS_URL = 'wss://sgw.gms.vn/thingscache';

View File

@@ -33,11 +33,15 @@ export default {
'common.theme.dark': 'Dark Theme', 'common.theme.dark': 'Dark Theme',
'common.paginations.things': 'things', 'common.paginations.things': 'things',
'common.paginations.of': 'of', 'common.paginations.of': 'of',
'common.of': 'of',
'common.name': 'Name', 'common.name': 'Name',
'common.name.required': 'Name is required', 'common.name.required': 'Name is required',
'common.note': 'Note',
'common.image': 'Image',
'common.type': 'Type', 'common.type': 'Type',
'common.type.placeholder': 'Select Type', 'common.type.placeholder': 'Select Type',
'common.status': 'Status', 'common.status': 'Status',
'common.connect': 'Connection',
'common.province': 'Province', 'common.province': 'Province',
'common.description': 'Description', 'common.description': 'Description',
'common.description.required': 'Description is required', 'common.description.required': 'Description is required',
@@ -48,6 +52,7 @@ export default {
'common.updated_at': 'Updated At', 'common.updated_at': 'Updated At',
'common.undefined': 'Undefined', 'common.undefined': 'Undefined',
'common.not_empty': 'Cannot be empty!', 'common.not_empty': 'Cannot be empty!',
'common.level.disconnected': 'Disconnected',
'common.level.normal': 'Normal', 'common.level.normal': 'Normal',
'common.level.warning': 'Warning', 'common.level.warning': 'Warning',
'common.level.critical': 'Critical', 'common.level.critical': 'Critical',

View File

@@ -12,4 +12,17 @@ export default {
'master.auth.logout.title': 'Logout', 'master.auth.logout.title': 'Logout',
'master.auth.logout.confirm': 'Are you sure you want to logout?', 'master.auth.logout.confirm': 'Are you sure you want to logout?',
'master.auth.logout.success': 'Logout successful', '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 masterSysLogEn from './master-log-en';
import masterMenuEn from './master-menu-en'; import masterMenuEn from './master-menu-en';
import masterMenuProfileEn from './master-profile-en'; import masterMenuProfileEn from './master-profile-en';
import masterThingDetailEn from './master-thing-detail-en';
import masterThingEn from './master-thing-en'; import masterThingEn from './master-thing-en';
import masterUserEn from './master-user-en'; import masterUserEn from './master-user-en';
export default { export default {
@@ -16,4 +17,5 @@ export default {
...masterSysLogEn, ...masterSysLogEn,
...masterUserEn, ...masterUserEn,
...masterGroupEn, ...masterGroupEn,
...masterThingDetailEn,
}; };

View File

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

View File

@@ -3,4 +3,45 @@ export default {
'master.thing.external_id': 'External ID', 'master.thing.external_id': 'External ID',
'master.thing.group': 'Group', 'master.thing.group': 'Group',
'master.thing.address': 'Address', 'master.thing.address': 'Address',
// Device translations
'master.devices.title': 'Devices',
'master.devices.name': 'Name',
'master.devices.name.tip': 'The device name',
'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',
'master.devices.offline': 'Offline',
'master.devices.table.pagination': 'devices',
'master.devices.register': 'Add Device',
'master.devices.register.title': 'Add new device',
'master.devices.create.success': 'Device created successfully',
'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',
'master.devices.cancel': 'Cancel',
'master.devices.name.placeholder': 'Enter device name',
'master.devices.name.required': 'Please enter device name',
'master.devices.external_id.placeholder': 'Enter external ID',
'master.devices.external_id.required': 'Please enter external ID',
'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.placeholder': 'Enter full name',
'master.users.full_name.required': 'Please enter full name', 'master.users.full_name.required': 'Please enter full name',
'master.users.password.placeholder': 'Password', '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.email.placeholder': 'Email',
'master.users.phone_number': 'Phone number', 'master.users.phone_number': 'Phone number',
'master.users.phone_number.tip': 'The phone number is the unique key', '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.role.sgw.end_user': 'Ship Owner',
'master.users.create.error': 'User creation failed', 'master.users.create.error': 'User creation failed',
'master.users.create.success': 'User created successfully', '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.confirm.title': 'Confirm role change',
'master.users.change_role.admin.content': 'master.users.change_role.admin.content':
'Are you sure you want to change the role to Unit Manager?', 'Are you sure you want to change the role to Unit Manager?',
@@ -48,7 +52,26 @@ export default {
'master.users.unassign.fail': 'Unassign failed', 'master.users.unassign.fail': 'Unassign failed',
'master.users.assign.success': 'Assign group successful', 'master.users.assign.success': 'Assign group successful',
'master.users.assign.fail': 'Assign group failed', 'master.users.assign.fail': 'Assign group failed',
'master.users.deletion.title': 'Are you sure to delete this selected items?', 'master.users.delete.title': 'Are you sure to delete this selected items?',
'master.users.delete.success': 'User deleted successfully', 'master.users.delete.success': 'User deleted successfully',
'master.users.delete.fail': 'User deletion failed', 'master.users.delete.fail': 'User deletion failed',
'master.users.things.list': 'List of devices',
'master.users.things.relations.text': 'Relations',
'master.users.things.relation.write': 'Control',
'master.users.things.relation.read': 'View',
'master.users.things.relation.delete': 'Config',
'master.users.thing.unshare.confirm':
'Are you sure you want to stop sharing these devices?',
'master.users.thing.unshare.title': 'Stop Sharing',
'master.users.things.unsharing': 'Unsharing devices...',
'master.users.thing.unshare.success': 'Stop sharing successful',
'master.users.thing.unshare.fail': 'Stop sharing failed',
'master.users.thing.share.title': 'Share things',
'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 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 { export default {
'sgw.title': 'Sea Gateway', 'sgw.title': 'Sea Gateway',
'sgw.ship': 'Ship', 'sgw.ship': 'Ship',
...sgwMenu, ...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,6 +1,11 @@
export default { export default {
'menu.sgw.map': 'Maps', 'menu.sgw.map': 'Maps',
'menu.sgw.trips': 'Trips', 'menu.sgw.trips': 'Trips',
'menu.manager.sgw.fishes': 'Fishes', 'menu.sgw.ships': 'Ships',
'menu.manager.sgw.zones': 'Zones', '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.theme.dark': 'Tối',
'common.paginations.things': 'thiết bị', 'common.paginations.things': 'thiết bị',
'common.paginations.of': 'trên', 'common.paginations.of': 'trên',
'common.of': 'trên',
'common.name': 'Tên', 'common.name': 'Tên',
'common.name.required': 'Tên không được để trống', 'common.name.required': 'Tên không được để trống',
'common.note': 'Ghi chú', 'common.note': 'Ghi chú',
@@ -39,6 +40,7 @@ export default {
'common.type': 'Loại', 'common.type': 'Loại',
'common.type.placeholder': 'Chọn loại', 'common.type.placeholder': 'Chọn loại',
'common.status': 'Trạng thái', 'common.status': 'Trạng thái',
'common.connect': 'Kết nối',
'common.province': 'Tỉnh', 'common.province': 'Tỉnh',
'common.description': 'Mô tả', 'common.description': 'Mô tả',
'common.description.required': 'Mô tả không được để trống', 'common.description.required': 'Mô tả không được để trống',
@@ -49,6 +51,7 @@ export default {
'common.updated_at': 'Ngày cập nhật', 'common.updated_at': 'Ngày cập nhật',
'common.undefined': 'Chưa xác định', 'common.undefined': 'Chưa xác định',
'common.not_empty': 'Không được để trống!', 'common.not_empty': 'Không được để trống!',
'common.level.disconnected': 'Mất kết nối',
'common.level.normal': 'Bình thường', 'common.level.normal': 'Bình thường',
'common.level.warning': 'Cảnh báo', 'common.level.warning': 'Cảnh báo',
'common.level.critical': 'Nguy hiểm', 'common.level.critical': 'Nguy hiểm',

View File

@@ -12,4 +12,21 @@ export default {
'master.auth.validation.email': 'Email không được để trống!', 'master.auth.validation.email': 'Email không được để trống!',
'master.auth.password': 'Mật khẩu', 'master.auth.password': 'Mật khẩu',
'master.auth.validation.password': 'Mật khẩu không được để trống!', '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

@@ -4,7 +4,7 @@ export default {
'Không thể tạo đơn vị con khi gốc đã có thiết bị', 'Không thể tạo đơn vị con khi gốc đã có thiết bị',
'master.groups.add': 'Tạo đơn vị cấp dưới', 'master.groups.add': 'Tạo đơn vị cấp dưới',
'master.groups.delete.confirm': 'Bạn có chắc muốn xóa nhóm này không?', 'master.groups.delete.confirm': 'Bạn có chắc muốn xóa nhóm này không?',
'master.groups.code': 'Mã đ vị', 'master.groups.code': 'Mã đơn vị',
'master.groups.code.exists': 'Mã đã tồn tại', 'master.groups.code.exists': 'Mã đã tồn tại',
'master.groups.short_name': 'Tên viết tắt', 'master.groups.short_name': 'Tên viết tắt',
'master.groups.short_name.exists': 'Tên viết tắt đã tồn tại', 'master.groups.short_name.exists': 'Tên viết tắt đã tồn tại',

View File

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

View File

@@ -3,4 +3,45 @@ export default {
'master.thing.external_id': 'External ID', 'master.thing.external_id': 'External ID',
'master.thing.group': 'Nhóm', 'master.thing.group': 'Nhóm',
'master.thing.address': 'Địa chỉ', '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': '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ị',
'master.devices.online': 'Trực tuyến',
'master.devices.offline': 'Ngoại tuyến',
'master.devices.table.pagination': 'thiết bị',
'master.devices.register': 'Thêm thiết bị',
'master.devices.register.title': 'Thêm thiết bị mới',
'master.devices.create.success': 'Tạo thiết bị thành công',
'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 ý',
'master.devices.cancel': 'Hủy',
'master.devices.name.placeholder': 'Nhập tên thiết bị',
'master.devices.name.required': 'Vui lòng nhập tên thiết bị',
'master.devices.external_id.placeholder': 'Nhập mã định danh bên ngoài',
'master.devices.external_id.required': 'Vui lòng nhập mã định danh bên ngoài',
'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.minimum': 'Mật khẩu ít nhất 8 kí tự',
'master.users.password': 'Mật khẩu', 'master.users.password': 'Mật khẩu',
'master.users.password.placeholder': 'Nhập 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': 'Tên đầy đủ',
'master.users.full_name.placeholder': 'Nhập 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 đủ', '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.role.sgw.end_user': 'Chủ tàu',
'master.users.create.error': 'Tạo người dùng lỗi', '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.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.confirm.title': 'Xác nhận thay đổi vai trò',
'master.users.change_role.admin.content': '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?', 'Bạn có chắc muốn thay đổi vai trò thành quản lý đơn vị không?',
@@ -47,7 +51,26 @@ export default {
'master.users.unassign.fail': 'Ngừng phân quyền thất bại', 'master.users.unassign.fail': 'Ngừng phân quyền thất bại',
'master.users.assign.success': 'Phân quyền đơn vị thành công', 'master.users.assign.success': 'Phân quyền đơn vị thành công',
'master.users.assign.fail': 'Phân quyền đơn vị thất bại', 'master.users.assign.fail': 'Phân quyền đơn vị thất bại',
'master.users.deletion.title': 'Chắc chắn xoá các tài khoản đã chọn?', 'master.users.delete.title': 'Chắc chắn xoá các tài khoản đã chọn?',
'master.users.delete.success': 'Xoá người dùng thành công', 'master.users.delete.success': 'Xoá người dùng thành công',
'master.users.delete.fail': 'Xoá người dùng thất bại', 'master.users.delete.fail': 'Xoá người dùng thất bại',
'master.users.things.list': 'Danh sách thiết bị',
'master.users.things.relations.text': 'Hành động',
'master.users.things.relation.write': 'Điều khiển',
'master.users.things.relation.read': 'Giám sát',
'master.users.things.relation.delete': 'Quản lí',
'master.users.thing.unshare.confirm':
'Chắc chắn muốn ngừng chia sẻ các thiết bị này?',
'master.users.thing.unshare.title': 'Ngừng chia sẻ',
'master.users.thing.unshare.success': 'Ngừng chia sẻ thành công',
'master.users.thing.unshare.fail': 'Ngừng chia sẻ thất bại',
'master.users.things.unsharing': 'Đang ngừng chia sẻ thiết bị...',
'master.users.thing.share.title': 'Chia sẻ thiết bị',
'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 masterSysLogVi from './master-log-vi';
import masterMenuVi from './master-menu-vi'; import masterMenuVi from './master-menu-vi';
import masterProfileVi from './master-profile-vi'; import masterProfileVi from './master-profile-vi';
import masterThingDetailVi from './master-thing-detail-vi';
import masterThingVi from './master-thing-vi'; import masterThingVi from './master-thing-vi';
import masterUserVi from './master-user-vi'; import masterUserVi from './master-user-vi';
export default { export default {
@@ -16,4 +17,5 @@ export default {
...masterSysLogVi, ...masterSysLogVi,
...masterUserVi, ...masterUserVi,
...masterGroupVi, ...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

@@ -1,6 +1,11 @@
export default { export default {
'menu.sgw.map': 'Bản đồ', 'menu.sgw.map': 'Bản đồ',
'menu.sgw.trips': 'Chuyến đi', 'menu.sgw.trips': 'Chuyến đi',
'menu.manager.sgw.fishes': 'Loài cá', 'menu.sgw.ships': 'Quản lý tàu',
'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 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 { export default {
'sgw.title': 'Hệ thống giám sát tàu cá', 'sgw.title': 'Hệ thống giám sát tàu cá',
'sgw.ship': 'Tàu', 'sgw.ship': 'Tàu',
...sgwMenu, ...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

@@ -0,0 +1,36 @@
import { apiQueryPorts } from '@/services/slave/sgw/ShipController';
import { useCallback, useState } from 'react';
export default function useHomeport() {
const [homeports, setHomeports] = useState<SgwModel.Port[]>([]);
const [loading, setLoading] = useState(false);
const getHomeportsByProvinceCode = useCallback(async () => {
setLoading(true);
try {
const params: SgwModel.PortQueryParams = {
name: '',
order: 'name',
dir: 'asc',
limit: 100,
offset: 0,
};
console.log('Calling apiQueryPorts with params:', params);
const res = await apiQueryPorts(params);
console.log('apiQueryPorts response:', res);
setHomeports(res?.ports || []);
} catch (err) {
console.error('Fetch Homeports failed:', err);
setHomeports([]);
} finally {
setLoading(false);
}
}, []);
return {
homeports,
loading,
getHomeportsByProvinceCode,
};
}

View File

@@ -0,0 +1,30 @@
import { SHIP_SOS_WS_URL } from '@/constants/slave/sgw/websocket';
import { wsClient } from '@/utils/wsClient';
import { useCallback, useState } from 'react';
export default function useGetShipSos() {
const [shipSos, setShipSos] = useState<WsTypes.WsThingResponse | null>(null);
const [loading, setLoading] = useState(false);
const getShipSosWs = useCallback(async () => {
setLoading(true);
try {
wsClient.connect(SHIP_SOS_WS_URL, true);
const unsubscribe = wsClient.subscribe(
(data: WsTypes.WsThingResponse) => {
setShipSos((pre) => {
if (pre?.time && data.time && pre.time > data.time) {
return pre;
}
return data;
});
setLoading(false);
},
);
return unsubscribe;
} catch (error) {
console.error('Error when get Ship SOS: ', error);
}
}, []);
return { shipSos, getShipSosWs, loading };
}

View File

@@ -0,0 +1,25 @@
import { apiGetShipTypes } from '@/services/slave/sgw/ShipController';
import { useCallback, useState } from 'react';
export default function useShipTypes() {
const [shipTypes, setShipTypes] = useState<SgwModel.ShipType[] | null>(null);
const [loading, setLoading] = useState(false);
const getShipTypes = useCallback(async () => {
setLoading(true);
try {
const res = await apiGetShipTypes(); // đổi URL cho phù hợp
setShipTypes(res || null);
} catch (err) {
console.error('Fetch ShipTypes failed', err);
} finally {
setLoading(false);
}
}, []);
return {
shipTypes,
loading,
getShipTypes,
};
}

View File

@@ -4,21 +4,23 @@ import {
UserOutlined, UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Space, Typography } from 'antd'; import { Space, Typography } from 'antd';
import { SpaceSize } from 'antd/es/space';
import moment from 'moment'; import moment from 'moment';
const { Text } = Typography; const { Text } = Typography;
interface AlarmDescriptionProps { interface AlarmDescriptionProps {
alarm: MasterModel.Alarm; alarm: MasterModel.Alarm;
size?: SpaceSize;
} }
const AlarmDescription = ({ alarm }: AlarmDescriptionProps) => { const AlarmDescription = ({ alarm, size = 'large' }: AlarmDescriptionProps) => {
if (!alarm?.confirmed) { if (!alarm?.confirmed) {
return null; return null;
} }
return ( return (
<Space size="large" wrap> <Space size={size} wrap>
<Space align="baseline" size={10}> <Space align="baseline" size={10}>
<CheckCircleOutlined style={{ color: '#52c41a' }} /> <CheckCircleOutlined style={{ color: '#52c41a' }} />
<Text type="secondary" style={{ fontSize: 15 }}> <Text type="secondary" style={{ fontSize: 15 }}>

View File

@@ -11,7 +11,7 @@ import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Flex } from 'antd'; import { Button, Flex } from 'antd';
import { MessageInstance } from 'antd/es/message/interface'; import { MessageInstance } from 'antd/es/message/interface';
import moment from 'moment'; import moment from 'moment';
import { useRef, useState } from 'react'; import React, { useRef } from 'react';
type AlarmForm = { type AlarmForm = {
name: string; name: string;
@@ -19,22 +19,26 @@ type AlarmForm = {
description: string; description: string;
}; };
type AlarmFormConfirmProps = { type AlarmFormConfirmProps = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
trigger?: React.ReactNode;
alarm: MasterModel.Alarm; alarm: MasterModel.Alarm;
message: MessageInstance; message: MessageInstance;
onFinish?: (reload: boolean) => void; onFinish?: (reload: boolean) => void;
}; };
const AlarmFormConfirm = ({ const AlarmFormConfirm = ({
isOpen,
setIsOpen,
trigger,
alarm, alarm,
message, message,
onFinish, onFinish,
}: AlarmFormConfirmProps) => { }: AlarmFormConfirmProps) => {
const [modalVisit, setModalVisit] = useState(false);
const formRef = useRef<ProFormInstance<AlarmForm>>(); const formRef = useRef<ProFormInstance<AlarmForm>>();
const intl = useIntl(); const intl = useIntl();
return ( return (
<ModalForm<AlarmForm> <ModalForm<AlarmForm>
open={modalVisit} open={isOpen}
formRef={formRef} formRef={formRef}
title={ title={
<Flex align="center" justify="center"> <Flex align="center" justify="center">
@@ -45,8 +49,9 @@ const AlarmFormConfirm = ({
layout="horizontal" layout="horizontal"
modalProps={{ modalProps={{
destroyOnHidden: true, destroyOnHidden: true,
// maskStyle: { backgroundColor: 'rgba(0,0,0,0.1)' },
}} }}
onOpenChange={setModalVisit} onOpenChange={setIsOpen}
request={async () => { request={async () => {
return { return {
name: alarm.name ?? '', name: alarm.name ?? '',
@@ -95,17 +100,20 @@ const AlarmFormConfirm = ({
return true; return true;
}} }}
trigger={ trigger={
<Button React.isValidElement(trigger) ? (
size="small" trigger
variant="solid" ) : (
color="green" <Button
icon={<CheckOutlined />} size="small"
onClick={() => setModalVisit(true)} type="primary"
> icon={<CheckOutlined />}
{intl.formatMessage({ onClick={() => setIsOpen(true)}
id: 'common.confirm', >
})} {intl.formatMessage({
</Button> id: 'common.confirm',
})}
</Button>
)
} }
> >
<ProForm.Group> <ProForm.Group>

View File

@@ -1,17 +1,15 @@
import AlarmUnConfirmButton from '@/components/shared/Alarm/AlarmUnConfirmButton';
import ThingsFilter from '@/components/shared/ThingFilterModal'; import ThingsFilter from '@/components/shared/ThingFilterModal';
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE, HTTPSTATUS } from '@/constants'; import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE } from '@/constants';
import { import { apiGetAlarms } from '@/services/master/AlarmController';
apiGetAlarms,
apiUnconfirmAlarm,
} from '@/services/master/AlarmController';
import { import {
CloseOutlined, CloseOutlined,
DeleteOutlined, DeleteOutlined,
FilterOutlined, FilterOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components'; import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max'; import { FormattedMessage, useIntl, useModel } from '@umijs/max';
import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd'; import { Button, Flex, message, Progress, Tooltip } from 'antd';
import moment from 'moment'; import moment from 'moment';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import AlarmDescription from './components/AlarmDescription'; import AlarmDescription from './components/AlarmDescription';
@@ -24,6 +22,9 @@ const AlarmPage = () => {
const [thingFilterDatas, setThingFilterDatas] = useState<string[]>([]); const [thingFilterDatas, setThingFilterDatas] = useState<string[]>([]);
const intl = useIntl(); const intl = useIntl();
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const { initialState } = useModel('@@initialState');
const { currentUserProfile } = initialState || {};
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState<boolean>(false);
const columns: ProColumns<MasterModel.Alarm>[] = [ const columns: ProColumns<MasterModel.Alarm>[] = [
{ {
title: intl.formatMessage({ title: intl.formatMessage({
@@ -199,65 +200,22 @@ const AlarmPage = () => {
return ( return (
<Flex gap={10}> <Flex gap={10}>
{alarm?.confirmed || false ? ( {alarm?.confirmed || false ? (
<Popconfirm <AlarmUnConfirmButton
title={intl.formatMessage({ alarm={alarm}
id: 'master.alarms.unconfirm.body', message={messageApi}
defaultMessage: button={
'Are you sure you want to unconfirm this alarm?', <Button danger icon={<DeleteOutlined />} size="small">
})} <FormattedMessage id="master.alarms.unconfirm.title" />
okText={intl.formatMessage({ </Button>
id: 'common.yes', }
defaultMessage: 'Yes', onFinish={(isReload) => {
})} if (isReload) tableRef.current?.reload();
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',
}),
});
}
}} }}
> />
<Button danger icon={<DeleteOutlined />} size="small">
<FormattedMessage id="master.alarms.unconfirm.title" />
</Button>
</Popconfirm>
) : ( ) : (
<AlarmFormConfirm <AlarmFormConfirm
isOpen={isConfirmModalOpen}
setIsOpen={setIsConfirmModalOpen}
alarm={alarm} alarm={alarm}
message={messageApi} message={messageApi}
onFinish={(isReload) => { 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 Footer from '@/components/Footer';
import LangSwitches from '@/components/Lang/LanguageSwitcherAuth'; import LangSwitches from '@/components/Lang/LanguageSwitcherAuth';
import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth'; import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth';
import { THEME_KEY } from '@/constants';
import { ROUTER_HOME } from '@/constants/routes'; import { ROUTER_HOME } from '@/constants/routes';
import { apiLogin, apiQueryProfile } from '@/services/master/AuthController'; import {
import { parseJwt } from '@/utils/jwt'; apiForgotPassword,
import { getLogoImage } from '@/utils/logo'; apiLogin,
import { getBrowserId, getToken, removeToken, setToken } from '@/utils/storage'; 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 { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginFormPage, ProFormText } from '@ant-design/pro-components'; import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
import { history, useIntl, useModel } from '@umijs/max'; import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
import { Image, theme } from 'antd'; import { Button, ConfigProvider, Flex, Image, message, theme } from 'antd';
import { useEffect } from 'react'; import { CSSProperties, useEffect, useState } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import mobifontLogo from '../../../public/mobifont-logo.png'; 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 LoginPage = () => {
const [isDark, setIsDark] = useState(
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') === 'dark',
);
const { token } = theme.useToken(); const { token } = theme.useToken();
const [messageApi, contextHolder] = message.useMessage();
const urlParams = new URL(window.location.href).searchParams; const urlParams = new URL(window.location.href).searchParams;
const redirect = urlParams.get('redirect'); const redirect = urlParams.get('redirect');
const intl = useIntl(); const intl = useIntl();
const { setInitialState } = useModel('@@initialState'); const { setInitialState } = useModel('@@initialState');
const getDomainTitle = () => { const [loginType, setLoginType] = useState<LoginType>('login');
switch (process.env.DOMAIN_ENV) {
case 'gms':
return 'gms.title';
case 'sgw':
return 'sgw.title';
case 'spole':
return 'spole.title';
default:
return 'Smatec Master';
}
};
// 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 checkLogin = async () => {
const token = getToken(); const refreshToken = getRefreshToken();
if (!token) { if (!refreshToken) {
return; return;
} }
const parsed = parseJwt(token); const isRefreshTokenExpired = checkRefreshTokenExpired(refreshToken);
const { exp } = parsed; if (isRefreshTokenExpired) {
const now = Math.floor(Date.now() / 1000); removeAccessToken();
const oneHour = 60 * 60; removeRefreshToken();
if (exp - now < oneHour) { return;
removeToken();
} else { } else {
const userInfo = await apiQueryProfile(); const userInfo = await apiQueryProfile();
if (userInfo) { if (userInfo) {
@@ -66,147 +118,263 @@ const LoginPage = () => {
}, []); }, []);
const handleLogin = async (values: MasterModel.LoginRequestBody) => { const handleLogin = async (values: MasterModel.LoginRequestBody) => {
try { const { email, password } = values;
const { email, password } = values; if (loginType === 'login') {
const resp = await apiLogin({ try {
guid: getBrowserId(), const resp = await apiLogin({
email, guid: getBrowserId(),
password, email,
}); password,
if (resp?.token) { });
setToken(resp.token); if (resp?.token) {
const userInfo = await apiQueryProfile(); setAccessToken(resp.token);
if (userInfo) { setRefreshToken(resp.refresh_token);
flushSync(() => { const userInfo = await apiQueryProfile();
setInitialState((s: any) => ({ if (userInfo) {
...s, flushSync(() => {
currentUserProfile: userInfo, setInitialState((s: any) => ({
})); ...s,
}); currentUserProfile: userInfo,
} }));
if (redirect) { });
history.push(redirect); }
} else { if (redirect) {
history.push(ROUTER_HOME); 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 ( return (
<div <ConfigProvider
style={{ theme={{
backgroundColor: 'white', algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
height: '100vh',
}} }}
> >
<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 <div
style={{ style={{
backgroundColor: 'transparent', backgroundColor: isDark ? '#000' : 'white',
position: 'absolute', height: '100vh',
bottom: 0,
zIndex: 99,
width: '100%',
}} }}
> >
<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>
</div> </ConfigProvider>
); );
}; };
export default LoginPage; 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

@@ -0,0 +1,210 @@
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
import { PlusOutlined } from '@ant-design/icons';
import {
ModalForm,
ProForm,
ProFormInstance,
ProFormSelect,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button } from 'antd';
import { MessageInstance } from 'antd/es/message/interface';
import { useRef, useState } from 'react';
type CreateDeviceProps = {
message: MessageInstance;
onSuccess?: (isSuccess: boolean) => void;
};
type CreateDeviceFormValues = {
name: string;
external_id: string;
type: string;
address?: string;
group_id?: string;
};
const CreateDevice = ({ message, onSuccess }: CreateDeviceProps) => {
const formRef = useRef<ProFormInstance<CreateDeviceFormValues>>();
const intl = useIntl();
const [group_id, setGroupId] = useState<string | string[] | null>(null);
const handleGroupSelect = (group: string | string[] | null) => {
setGroupId(group);
formRef.current?.setFieldsValue({
group_id: Array.isArray(group) ? group.join(',') : group || undefined,
});
};
return (
<ModalForm<CreateDeviceFormValues>
title={intl.formatMessage({
id: 'master.devices.register.title',
defaultMessage: 'Register New Device',
})}
formRef={formRef}
trigger={
<Button type="primary" key="primary" icon={<PlusOutlined />}>
<FormattedMessage
id="master.devices.register"
defaultMessage="Register"
/>
</Button>
}
autoFocusFirstInput
onFinish={async (values: CreateDeviceFormValues) => {
// TODO: Implement API call to create device
console.log('Create device with values:', values);
try {
// Placeholder for API call
// const body = {
// name: values.name,
// metadata: {
// external_id: values.external_id,
// type: values.type,
// address: values.address,
// group_id: values.group_id,
// },
// };
// const resp = await apiCreateDevice(body);
message.success(
intl.formatMessage({
id: 'master.devices.create.success',
defaultMessage: 'Create device successfully',
}),
);
formRef.current?.resetFields();
onSuccess?.(true);
return true;
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.devices.create.error',
defaultMessage: 'Failed to create device',
}),
);
onSuccess?.(false);
return false;
}
}}
>
<ProFormText
name={'name'}
label={intl.formatMessage({
id: 'master.devices.name',
defaultMessage: 'Name',
})}
placeholder={intl.formatMessage({
id: 'master.devices.name.placeholder',
defaultMessage: 'Enter device name',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.name.required',
defaultMessage: 'The device name is required',
}),
},
]}
/>
<ProFormText
name={'external_id'}
label={intl.formatMessage({
id: 'master.devices.external_id',
defaultMessage: 'External ID',
})}
placeholder={intl.formatMessage({
id: 'master.devices.external_id.placeholder',
defaultMessage: 'Enter external ID',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.external_id.required',
defaultMessage: 'The external ID is required',
}),
},
]}
/>
<ProFormSelect
name="type"
label={intl.formatMessage({
id: 'master.devices.type',
defaultMessage: 'Type',
})}
options={[
{
label: intl.formatMessage({
id: 'master.devices.type.gms',
defaultMessage: 'GMS',
}),
value: 'gms',
},
{
label: intl.formatMessage({
id: 'master.devices.type.sgw',
defaultMessage: 'SGW',
}),
value: 'sgw',
},
{
label: intl.formatMessage({
id: 'master.devices.type.spole',
defaultMessage: 'SPOLE',
}),
value: 'spole',
},
]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.type.required',
defaultMessage: 'Please select a device type',
}),
},
]}
/>
<ProFormText
name={'address'}
label={intl.formatMessage({
id: 'master.devices.address',
defaultMessage: 'Address',
})}
placeholder={intl.formatMessage({
id: 'master.devices.address.placeholder',
defaultMessage: 'Enter device address',
})}
/>
<ProForm.Item
name="group_id"
label={intl.formatMessage({
id: 'master.devices.groups',
defaultMessage: 'Groups',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.groups.required',
defaultMessage: 'Please select groups!',
}),
},
]}
>
<TreeSelectedGroup groupIds={group_id} onSelected={handleGroupSelect} />
</ProForm.Item>
</ModalForm>
);
};
export default CreateDevice;

View File

@@ -0,0 +1,152 @@
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 EditDeviceModal: React.FC<Props> = ({
visible,
device,
onCancel,
onSubmit,
}) => {
const [form] = Form.useForm();
const intl = useIntl();
useEffect(() => {
if (device) {
form.setFieldsValue({
name: device.name,
external_id: device?.metadata?.external_id,
address: device?.metadata?.address,
});
} else {
form.resetFields();
}
}, [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({
id: 'master.devices.update.title',
defaultMessage: 'Update device',
})}
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={handleFinish}
preserve={false}
>
<Form.Item
name="name"
label={intl.formatMessage({
id: 'master.devices.name',
defaultMessage: 'Name',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.name.required',
defaultMessage: 'Please enter device name',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.name.placeholder',
defaultMessage: 'Enter device name',
})}
/>
</Form.Item>
<Form.Item
name="external_id"
label={intl.formatMessage({
id: 'master.devices.external_id',
defaultMessage: 'External ID',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.external_id.required',
defaultMessage: 'Please enter external ID',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.external_id.placeholder',
defaultMessage: 'Enter external ID',
})}
/>
</Form.Item>
<Form.Item
name="address"
label={intl.formatMessage({
id: 'master.devices.address',
defaultMessage: 'Address',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.address.required',
defaultMessage: 'Please enter address',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.address.placeholder',
defaultMessage: 'Enter address',
})}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default EditDeviceModal;

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,5 +1,375 @@
import IconFont from '@/components/IconFont';
import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants';
import {
apiSearchThings,
apiUpdateThing,
} from '@/services/master/ThingController';
import { EditOutlined, EnvironmentOutlined } from '@ant-design/icons';
import {
ActionType,
ProCard,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
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 ManagerDevicePage = () => {
return <div>ManagerDevicePage</div>; const { useBreakpoint } = Grid;
const intl = useIntl();
const screens = useBreakpoint();
const { token } = theme.useToken();
const actionRef = useRef<ActionType | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRowsState] = useState<
MasterModel.Thing[]
>([]);
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
string | string[] | null
>(null);
const [isEditModalVisible, setIsEditModalVisible] = useState<boolean>(false);
const [editingDevice, setEditingDevice] = useState<MasterModel.Thing | null>(
null,
);
const [isLocationModalVisible, setIsLocationModalVisible] =
useState<boolean>(false);
const [locationDevice, setLocationDevice] =
useState<MasterModel.Thing | null>(null);
const handleLocation = (device: MasterModel.Thing) => {
setLocationDevice(device);
setIsLocationModalVisible(true);
};
const handleEdit = (device: MasterModel.Thing) => {
setEditingDevice(device);
setIsEditModalVisible(true);
};
const handleEditCancel = () => {
setIsEditModalVisible(false);
setEditingDevice(null);
};
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>[] = [
{
key: 'name',
title: (
<FormattedMessage id="master.devices.name" defaultMessage="Name" />
),
tip: intl.formatMessage({
id: 'master.devices.name.tip',
defaultMessage: 'The device name',
}),
dataIndex: 'name',
render: (_, record) => (
<Paragraph
style={{
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
color: token.colorText,
}}
copyable
>
{record?.name}
</Paragraph>
),
},
{
key: 'external_id',
title: (
<FormattedMessage
id="master.devices.external_id"
defaultMessage="External ID"
/>
),
tip: intl.formatMessage({
id: 'master.devices.external_id.tip',
defaultMessage: 'The external identifier',
}),
responsive: ['lg', 'md'],
dataIndex: ['metadata', 'external_id'],
render: (_, record) =>
record?.metadata?.external_id ? (
<Paragraph
style={{
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
color: token.colorText,
}}
copyable
>
{record?.metadata?.external_id}
</Paragraph>
) : (
'-'
),
},
{
key: 'type',
hideInSearch: true,
title: (
<FormattedMessage id="master.devices.type" defaultMessage="Type" />
),
tip: intl.formatMessage({
id: 'master.devices.type.tip',
defaultMessage: 'The device type',
}),
dataIndex: ['metadata', 'type'],
render: (_, record) => record?.metadata?.type || '...',
},
{
key: 'connected',
hideInSearch: true,
title: <FormattedMessage id="common.status" defaultMessage="Status" />,
dataIndex: ['metadata', 'connected'],
render: (_, record) => (
<Tag color={record?.metadata?.connected ? 'green' : 'red'}>
{record?.metadata?.connected
? intl.formatMessage({
id: 'master.devices.online',
defaultMessage: 'Online',
})
: intl.formatMessage({
id: 'master.devices.offline',
defaultMessage: 'Offline',
})}
</Tag>
),
},
{
title: (
<FormattedMessage id="common.actions" defaultMessage="Operating" />
),
hideInSearch: true,
render: (_, device) => {
return (
<Space
size={5}
split={<Divider type="vertical" style={{ margin: '0 4px' }} />}
>
<Button
shape="default"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(device)}
/>
<Button
shape="default"
size="small"
icon={<EnvironmentOutlined />}
onClick={() => handleLocation(device)}
/>
<Button
shape="default"
size="small"
icon={<IconFont type="icon-camera" />}
onClick={() => {
history.push(`/manager/devices/${device.id}/camera`);
}}
/>
{device?.metadata?.type === 'gmsv6' && (
<Button
shape="default"
size="small"
icon={<IconFont type="icon-terminal" />}
/>
)}
</Space>
);
},
},
];
return (
<>
<EditDeviceModal
visible={isEditModalVisible}
device={editingDevice}
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 }}>
<TreeGroup
disable={isLoading}
multiple={true}
groupIds={groupCheckedKeys}
onSelected={(value: string | string[] | null) => {
setGroupCheckedKeys(value);
if (actionRef.current) {
actionRef.current.reload();
}
}}
/>
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
<ProTable<MasterModel.Thing>
columns={columns}
tableLayout="auto"
actionRef={actionRef}
rowKey="id"
search={{
layout: 'vertical',
defaultCollapsed: false,
}}
dateFormatter="string"
rowSelection={{
selectedRowKeys: selectedRowsState.map((row) => row.id!),
onChange: (_: React.Key[], selectedRows: MasterModel.Thing[]) => {
setSelectedRowsState(selectedRows);
},
}}
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
showSizeChanger: true,
pageSizeOptions: ['10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.paginations.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'master.devices.table.pagination',
defaultMessage: 'devices',
})}`,
}}
request={async (params = {}) => {
const { current = 1, pageSize, name, external_id } = params;
const size = pageSize || DEFAULT_PAGE_SIZE * 2;
const offset = current === 1 ? 0 : (current - 1) * size;
setIsLoading(true);
const metadata: Partial<MasterModel.SearchThingMetadata> = {};
if (external_id) metadata.external_id = external_id;
// Add group filter if groups are selected
if (groupCheckedKeys && groupCheckedKeys.length > 0) {
const groupId = Array.isArray(groupCheckedKeys)
? groupCheckedKeys.join(',')
: groupCheckedKeys;
metadata.group_id = groupId;
}
const query: MasterModel.SearchThingPaginationBody = {
offset: offset,
limit: size,
order: 'name',
dir: 'asc',
};
if (name) query.name = name;
if (Object.keys(metadata).length > 0) query.metadata = metadata;
try {
const response = await apiSearchThings(query);
setIsLoading(false);
return {
data: response.things || [],
success: true,
total: response.total || 0,
};
} catch (error) {
setIsLoading(false);
return {
data: [],
success: false,
total: 0,
};
}
}}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
toolBarRender={() => [
<CreateDevice
message={messageApi}
onSuccess={(isSuccess) => {
if (isSuccess) {
actionRef.current?.reload();
}
}}
key="create-device"
/>,
]}
/>
</ProCard>
</ProCard>
</>
);
}; };
export default ManagerDevicePage; export default ManagerDevicePage;

View File

@@ -0,0 +1,303 @@
const LogActions = (intl: any) => {
return [
//Alarm
{
title: intl.formatMessage({
id: 'master.logs.things.alarm.confirm',
defaultMessage: 'Alarm confirm',
}),
value: '0-0',
selectable: false,
children: [
{
value: 'things.alarm_confirm',
title: intl.formatMessage({
id: 'master.logs.things.confirm',
defaultMessage: 'Confirm',
}),
},
{
value: 'things.alarm_unconfirm',
title: intl.formatMessage({
id: 'master.logs.things.unconfirm',
defaultMessage: 'Unconfirm',
}),
},
],
},
//Things
{
title: intl.formatMessage({
id: 'master.logs.things',
defaultMessage: 'Things',
}),
value: '0-1',
selectable: false,
children: [
{
value: 'things.create',
title: intl.formatMessage({
id: 'master.logs.things.create',
defaultMessage: 'Create new thing',
}),
},
{
value: 'things.update',
title: intl.formatMessage({
id: 'master.logs.things.update',
defaultMessage: 'Update thing',
}),
},
{
value: 'things.remove',
title: intl.formatMessage({
id: 'master.logs.things.remove',
defaultMessage: 'Remove thing',
}),
},
{
value: 'things.share',
title: intl.formatMessage({
id: 'master.logs.things.share',
defaultMessage: 'Share thing',
}),
},
{
value: 'things.unshare',
title: intl.formatMessage({
id: 'master.logs.things.unshare',
defaultMessage: 'Unshare thing',
}),
},
{
value: 'things.update_key',
title: intl.formatMessage({
id: 'master.logs.things.update_key',
defaultMessage: 'Update key thing',
}),
},
],
},
// Users
{
title: intl.formatMessage({
id: 'master.logs.users',
defaultMessage: 'Users',
}),
value: '0-2',
selectable: false,
children: [
{
value: 'users.create',
title: intl.formatMessage({
id: 'master.logs.users.create',
defaultMessage: 'Register user',
}),
},
{
value: 'users.update',
title: intl.formatMessage({
id: 'master.logs.users.update',
defaultMessage: 'Update user',
}),
},
{
value: 'users.remove',
title: intl.formatMessage({
id: 'master.logs.users.remove',
defaultMessage: 'Remove user',
}),
},
{
value: 'users.login',
title: intl.formatMessage({
id: 'master.logs.users.login',
defaultMessage: 'User login',
}),
},
],
},
// Groups
{
title: intl.formatMessage({
id: 'master.logs.groups',
defaultMessage: 'Groups',
}),
value: '0-3',
selectable: false,
children: [
{
value: 'group.create',
title: intl.formatMessage({
id: 'master.logs.groups.create',
defaultMessage: 'Create new group',
}),
},
{
value: 'group.update',
title: intl.formatMessage({
id: 'master.logs.groups.update',
defaultMessage: 'Update group',
}),
},
{
value: 'group.remove',
title: intl.formatMessage({
id: 'master.logs.groups.remove',
defaultMessage: 'Remove group',
}),
},
{
value: 'group.assign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.assign_thing',
defaultMessage: 'Assign thing to group',
}),
},
{
value: 'group.assign_user',
title: intl.formatMessage({
id: 'master.logs.groups.assign_user',
defaultMessage: 'Assign user to group',
}),
},
{
value: 'group.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_thing',
defaultMessage: 'Remove thing from group',
}),
},
{
value: 'group.unassign_user',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_user',
defaultMessage: 'Remove user from group',
}),
},
],
},
// Ships
{
title: intl.formatMessage({
id: 'master.logs.ships',
defaultMessage: 'Ships',
}),
value: '0-4',
selectable: false,
children: [
{
value: 'ships.create',
title: intl.formatMessage({
id: 'master.logs.ships.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'ships.update',
title: intl.formatMessage({
id: 'master.logs.ships.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'ships.remove',
title: intl.formatMessage({
id: 'master.logs.ships.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'ships.assign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.assign_thing',
defaultMessage: 'Assign thing to ship',
}),
},
{
value: 'ships.assign_user',
title: intl.formatMessage({
id: 'master.logs.ships.assign_user',
defaultMessage: 'Assign user to ship',
}),
},
{
value: 'ships.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'ships.unassign_user',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
// Trips
{
title: intl.formatMessage({
id: 'master.logs.trips',
defaultMessage: 'Trips',
}),
value: '0-5',
selectable: false,
children: [
{
value: 'trips.create',
title: intl.formatMessage({
id: 'master.logs.trips.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'trips.update',
title: intl.formatMessage({
id: 'master.logs.trips.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'trips.remove',
title: intl.formatMessage({
id: 'master.logs.trips.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'trips.approve',
title: intl.formatMessage({
id: 'master.logs.trips.approve',
defaultMessage: 'Approve trip',
}),
},
{
value: 'trips.request_approve',
title: intl.formatMessage({
id: 'master.logs.trips.request_approve',
defaultMessage: 'Request approval for trip',
}),
},
{
value: 'trips.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'trips.unassign_user',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
];
};
export default LogActions;

View File

@@ -3,314 +3,18 @@ import { apiQueryLogs } from '@/services/master/LogController';
import { apiQueryUsers } from '@/services/master/UserController'; import { apiQueryUsers } from '@/services/master/UserController';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components'; import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { theme } from 'antd';
import { DatePicker } from 'antd/lib'; import { DatePicker } from 'antd/lib';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useRef } from 'react'; import { useRef } from 'react';
import LogActions from './components/LogActions';
const SystemLogs = () => { const SystemLogs = () => {
const intl = useIntl(); const intl = useIntl();
const tableRef = useRef<ActionType>(); const tableRef = useRef<ActionType>();
const actions = [ const { token } = theme.useToken();
//Alarm
{
title: intl.formatMessage({
id: 'master.logs.things.alarm.confirm',
defaultMessage: 'Alarm confirm',
}),
value: '0-0',
selectable: false,
children: [
{
value: 'things.alarm_confirm',
title: intl.formatMessage({
id: 'master.logs.things.confirm',
defaultMessage: 'Confirm',
}),
},
{
value: 'things.alarm_unconfirm',
title: intl.formatMessage({
id: 'master.logs.things.unconfirm',
defaultMessage: 'Unconfirm',
}),
},
],
},
//Things
{
title: intl.formatMessage({
id: 'master.logs.things',
defaultMessage: 'Things',
}),
value: '0-1',
selectable: false,
children: [
{
value: 'things.create',
title: intl.formatMessage({
id: 'master.logs.things.create',
defaultMessage: 'Create new thing',
}),
},
{
value: 'things.update',
title: intl.formatMessage({
id: 'master.logs.things.update',
defaultMessage: 'Update thing',
}),
},
{
value: 'things.remove',
title: intl.formatMessage({
id: 'master.logs.things.remove',
defaultMessage: 'Remove thing',
}),
},
{
value: 'things.share',
title: intl.formatMessage({
id: 'master.logs.things.share',
defaultMessage: 'Share thing',
}),
},
{
value: 'things.unshare',
title: intl.formatMessage({
id: 'master.logs.things.unshare',
defaultMessage: 'Unshare thing',
}),
},
{
value: 'things.update_key',
title: intl.formatMessage({
id: 'master.logs.things.update_key',
defaultMessage: 'Update key thing',
}),
},
],
},
// Users
{
title: intl.formatMessage({
id: 'master.logs.users',
defaultMessage: 'Users',
}),
value: '0-2',
selectable: false,
children: [
{
value: 'users.create',
title: intl.formatMessage({
id: 'master.logs.users.create',
defaultMessage: 'Register user',
}),
},
{
value: 'users.update',
title: intl.formatMessage({
id: 'master.logs.users.update',
defaultMessage: 'Update user',
}),
},
{
value: 'users.remove',
title: intl.formatMessage({
id: 'master.logs.users.remove',
defaultMessage: 'Remove user',
}),
},
{
value: 'users.login',
title: intl.formatMessage({
id: 'master.logs.users.login',
defaultMessage: 'User login',
}),
},
],
},
// Groups
{
title: intl.formatMessage({
id: 'master.logs.groups',
defaultMessage: 'Groups',
}),
value: '0-3',
selectable: false,
children: [
{
value: 'group.create',
title: intl.formatMessage({
id: 'master.logs.groups.create',
defaultMessage: 'Create new group',
}),
},
{
value: 'group.update',
title: intl.formatMessage({
id: 'master.logs.groups.update',
defaultMessage: 'Update group',
}),
},
{
value: 'group.remove',
title: intl.formatMessage({
id: 'master.logs.groups.remove',
defaultMessage: 'Remove group',
}),
},
{
value: 'group.assign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.assign_thing',
defaultMessage: 'Assign thing to group',
}),
},
{
value: 'group.assign_user',
title: intl.formatMessage({
id: 'master.logs.groups.assign_user',
defaultMessage: 'Assign user to group',
}),
},
{
value: 'group.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_thing',
defaultMessage: 'Remove thing from group',
}),
},
{
value: 'group.unassign_user',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_user',
defaultMessage: 'Remove user from group',
}),
},
],
},
// Ships
{
title: intl.formatMessage({
id: 'master.logs.ships',
defaultMessage: 'Ships',
}),
value: '0-4',
selectable: false,
children: [
{
value: 'ships.create',
title: intl.formatMessage({
id: 'master.logs.ships.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'ships.update',
title: intl.formatMessage({
id: 'master.logs.ships.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'ships.remove',
title: intl.formatMessage({
id: 'master.logs.ships.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'ships.assign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.assign_thing',
defaultMessage: 'Assign thing to ship',
}),
},
{
value: 'ships.assign_user',
title: intl.formatMessage({
id: 'master.logs.ships.assign_user',
defaultMessage: 'Assign user to ship',
}),
},
{
value: 'ships.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'ships.unassign_user',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
// Trips
{
title: intl.formatMessage({
id: 'master.logs.trips',
defaultMessage: 'Trips',
}),
value: '0-5',
selectable: false,
children: [
{
value: 'trips.create',
title: intl.formatMessage({
id: 'master.logs.trips.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'trips.update',
title: intl.formatMessage({
id: 'master.logs.trips.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'trips.remove',
title: intl.formatMessage({
id: 'master.logs.trips.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'trips.approve',
title: intl.formatMessage({
id: 'master.logs.trips.approve',
defaultMessage: 'Approve trip',
}),
},
{
value: 'trips.request_approve',
title: intl.formatMessage({
id: 'master.logs.trips.request_approve',
defaultMessage: 'Request approval for trip',
}),
},
{
value: 'trips.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'trips.unassign_user',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
];
const queryUserSource = async (): Promise<MasterModel.ProfileResponse[]> => { const queryUserSource = async (): Promise<MasterModel.UserResponse[]> => {
try { try {
const body: MasterModel.SearchUserPaginationBody = { const body: MasterModel.SearchUserPaginationBody = {
offset: 0, offset: 0,
@@ -345,6 +49,10 @@ const SystemLogs = () => {
return ( return (
<DatePicker.RangePicker <DatePicker.RangePicker
width="50%" width="50%"
style={{
backgroundColor: token.colorBgContainer,
color: token.colorText,
}}
presets={[ presets={[
{ {
label: intl.formatMessage({ label: intl.formatMessage({
@@ -404,9 +112,9 @@ const SystemLogs = () => {
treeCheckable: true, treeCheckable: true,
multiple: true, multiple: true,
}, },
request: async () => actions, request: async () => LogActions(intl),
render: (_, item) => { render: (_, item) => {
const childs = actions.flatMap((a) => a.children || []); const childs = LogActions(intl).flatMap((a) => a.children || []);
const action = childs.find((a) => a?.value === item.subtopic); const action = childs.find((a) => a?.value === item.subtopic);
return action?.title ?? '...'; return action?.title ?? '...';
}, },

View File

@@ -14,7 +14,7 @@ enum AssignTabsKey {
const AssignUserPage = () => { const AssignUserPage = () => {
const { userId } = useParams<{ userId: string }>(); const { userId } = useParams<{ userId: string }>();
const [userProfile, setUserProfile] = const [userProfile, setUserProfile] =
useState<MasterModel.ProfileResponse | null>(null); useState<MasterModel.UserResponse | null>(null);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [tabSelected, setTabSelected] = useState<AssignTabsKey>( const [tabSelected, setTabSelected] = useState<AssignTabsKey>(
AssignTabsKey.group, AssignTabsKey.group,

View File

@@ -24,7 +24,7 @@ import {
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
const { Text } = Typography; const { Text } = Typography;
type AssignGroupProps = { type AssignGroupProps = {
user: MasterModel.ProfileResponse | null; user: MasterModel.UserResponse | null;
}; };
const AssignGroup = ({ user }: AssignGroupProps) => { const AssignGroup = ({ user }: AssignGroupProps) => {
const groupActionRef = useRef<ActionType>(); const groupActionRef = useRef<ActionType>();

View File

@@ -1,64 +1,323 @@
import { ActionType, ProList } from '@ant-design/pro-components'; import ThingsFilter from '@/components/shared/ThingFilterModal';
import { useIntl } from '@umijs/max'; import { DEFAULT_PAGE_SIZE } from '@/constants';
import { message } from 'antd'; import {
import { useRef } from 'react'; apiDeleteUserThingPolicy,
apiGetThingPolicyByUser,
apiShareThingToUser,
} from '@/services/master/ThingController';
import { DeleteOutlined, ShareAltOutlined } from '@ant-design/icons';
import {
ActionType,
FooterToolbar,
ProList,
ProListMetas,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import {
Button,
Checkbox,
GetProp,
message,
Popconfirm,
Tag,
Typography,
} from 'antd';
import { useRef, useState } from 'react';
const { Paragraph } = Typography;
type PolicyShareDefault = {
read: boolean;
write: boolean;
delete: boolean;
};
type ShareThingProps = { type ShareThingProps = {
user: MasterModel.ProfileResponse | null; user: MasterModel.UserResponse | null;
}; };
const ShareThing = ({ user }: ShareThingProps) => { const ShareThing = ({ user }: ShareThingProps) => {
const listActionRef = useRef<ActionType>(); const listActionRef = useRef<ActionType>();
const intl = useIntl(); const intl = useIntl();
const [messageAPI, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRows] = useState<
MasterModel.ThingPolicy[]
>([]);
const [thingPolicy, setThingPolicy] = useState<MasterModel.ThingPolicy[]>([]);
const [shareThingModalVisible, setShareThingModalVisible] =
useState<boolean>(false);
const [policyShare, setPolicyShare] = useState<PolicyShareDefault>({
read: true,
write: true,
delete: true,
});
const getPolicyInfo = (
policy: MasterModel.Policy,
): { color: string; text: string } => {
switch (policy) {
case 'read':
return {
color: 'blue',
text: intl.formatMessage({
id: 'master.users.things.relation.read',
defaultMessage: 'Read',
}),
};
case 'write':
return {
color: 'gold',
text: intl.formatMessage({
id: 'master.users.things.relation.write',
defaultMessage: 'Write',
}),
};
case 'delete':
return {
color: 'red',
text: intl.formatMessage({
id: 'master.users.things.relation.delete',
defaultMessage: 'Delete',
}),
};
default:
return { color: 'default', text: policy };
}
};
const columns: ProListMetas<MasterModel.ThingPolicy> = {
title: {
dataIndex: 'name',
render: (_, record: MasterModel.ThingPolicy) => (
<Paragraph copyable>{record?.thing_name}</Paragraph>
),
},
subTitle: {
dataIndex: 'metadata.external_id',
render: (_, record: MasterModel.ThingPolicy) => (
<Paragraph copyable>{record?.external_id}</Paragraph>
),
},
description: {
dataIndex: 'policies',
render: (_, record: MasterModel.ThingPolicy) => {
return record?.policies?.map((policy) => {
const info = getPolicyInfo(policy);
return (
<Tag key={policy} color={info.color}>
{info.text}
</Tag>
);
});
},
},
};
const handleShareThings = async (thingIds: string[]) => {
const thingsFiltered = thingIds.filter((thingId) => {
return !thingPolicy.find((thing) => thing.thing_id === thingId);
});
if (thingsFiltered.length === 0) return;
const key = 'share';
try {
messageApi.open({
type: 'loading',
content: intl.formatMessage({
id: 'master.users.things.sharing',
defaultMessage: 'Sharing devices...',
}),
key,
});
const allShare = thingsFiltered.map(async (thingId) => {
const resp = await apiShareThingToUser(
thingId,
user?.id || '',
Object.keys(policyShare).filter(
(key) => policyShare[key as keyof PolicyShareDefault],
),
);
});
await Promise.all(allShare);
messageApi.open({
type: 'success',
content: intl.formatMessage({
id: 'master.users.thing.share.success',
defaultMessage: 'Share successfully and will refresh soon',
}),
key,
});
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
if (listActionRef.current) {
listActionRef.current.reload();
}
} catch (error) {
console.error('Error when share thing: ', error);
messageApi.open({
type: 'error',
content: intl.formatMessage({
id: 'master.users.thing.share.fail',
defaultMessage: 'Share failed, please try again!',
}),
key,
});
}
};
const onChange: GetProp<typeof Checkbox.Group, 'onChange'> = (
checkedValues,
) => {
setPolicyShare({
read: checkedValues.includes('read'),
write: checkedValues.includes('write'),
delete: checkedValues.includes('delete'),
});
};
const handleUnshare = async (selectedRows: MasterModel.ThingPolicy[]) => {
if (!selectedRows) return true;
const key = 'unshare';
try {
messageApi.open({
type: 'loading',
content: intl.formatMessage({
id: 'master.users.things.unsharing',
defaultMessage: 'Unsharing devices...',
}),
key,
});
const allUnshare = selectedRows.map(async (row) => {
const resp = await apiDeleteUserThingPolicy(
row?.thing_id || '',
user?.id || '',
);
});
await Promise.all(allUnshare);
messageApi.open({
type: 'success',
content: intl.formatMessage({
id: 'master.users.thing.unshare.success',
defaultMessage: 'Unshare successfully and will refresh soon',
}),
key,
});
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
return true;
} catch (error) {
console.error('Error when unshare thing: ', error);
messageApi.open({
type: 'error',
content: intl.formatMessage({
id: 'master.users.thing.unshare.fail',
defaultMessage: 'Unshare failed, please try again!',
}),
key,
});
return false;
}
};
return ( return (
<> <>
{contextHolder} {contextHolder}
<ProList <ThingsFilter
isOpen={shareThingModalVisible}
setIsOpen={setShareThingModalVisible}
thingIds={thingPolicy.map((thing) => thing.thing_id!)}
disabled
extra={
<Checkbox.Group
options={[
{
label: intl.formatMessage({
id: 'master.users.things.relation.read',
defaultMessage: 'Read',
}),
value: 'read',
disabled: true,
},
{
label: intl.formatMessage({
id: 'master.users.things.relation.write',
defaultMessage: 'Write',
}),
value: 'write',
},
{
label: intl.formatMessage({
id: 'master.users.things.relation.delete',
defaultMessage: 'Delete',
}),
value: 'delete',
},
]}
value={Object.keys(policyShare).filter(
(key) => policyShare[key as keyof PolicyShareDefault],
)}
onChange={onChange}
/>
}
onSubmit={handleShareThings}
/>
<ProList<MasterModel.ThingPolicy>
headerTitle={intl.formatMessage({ headerTitle={intl.formatMessage({
id: 'pages.users.things.list', id: 'master.users.things.list',
defaultMessage: 'List things', defaultMessage: 'List things',
})} })}
actionRef={listActionRef} actionRef={listActionRef}
toolBarRender={() => [ toolBarRender={() => [
// <Button <Button
// type="primary" type="primary"
// key="primary" key="primary"
// onClick={() => { icon={<ShareAltOutlined />}
// handleShareModalVisible(true); onClick={() => {
// }} setShareThingModalVisible(true);
// > }}
// <PlusOutlined />{" "} >
// <FormattedMessage <FormattedMessage
// id="pages.things.share.text" id="master.users.thing.share.title"
// defaultMessage="Share" defaultMessage="Share"
// /> />
// </Button>, </Button>,
]} ]}
pagination={{
pageSize: DEFAULT_PAGE_SIZE,
}}
metas={columns} metas={columns}
request={async () => { request={async (params) => {
const { current, pageSize } = params;
const query = { const query = {
type: 'sub', type: 'sub',
id: user?.id || '', id: user?.id || '',
}; };
if (user?.id) { if (user?.id) {
const resp = (await apiQueryThingsByPolicy( const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
query, const policyBody: Partial<MasterModel.SearchPaginationBody> = {
)) as PolicyResponse; offset: offset,
const { relations } = resp; limit: pageSize,
if (relations) { };
const queries = relations.map(async (rel: PolicyRelation) => { const resp = await apiGetThingPolicyByUser(policyBody, user.id);
const thg = await apiQueryThing(rel.id);
return { if (resp.things) {
...thg, setThingPolicy(resp.things);
relations: rel?.actions,
};
});
const policies = await Promise.all(queries);
return Promise.resolve({ return Promise.resolve({
success: true, success: true,
data: policies, data: resp.things,
total: policies.length, total: resp.total,
}); });
} else {
return {
success: false,
data: [],
total: 0,
};
} }
} }
return Promise.resolve({ return Promise.resolve({
@@ -67,32 +326,67 @@ const ShareThing = ({ user }: ShareThingProps) => {
total: 0, total: 0,
}); });
}} }}
rowKey="id" rowKey="external_id"
search={false} search={false}
// rowSelection={{ rowSelection={{
// selectedRowKeys: selectedRowsState.map((row) => row.id).filter((id): id is string => id !== undefined), selectedRowKeys: selectedRowsState
// onChange: (_: React.Key[], selectedRows: API.Thing[]) => { .map((row) => row.external_id)
// setSelectedRows(selectedRows); .filter((id): id is string => id !== undefined),
// }, onChange: (_, selectedRows: MasterModel.ThingPolicy[]) => {
// }} setSelectedRows(selectedRows);
/> },
{/* <FormShareVms
visible={shareModalVisibale}
onVisibleChange={handleShareModalVisible}
user={user}
onSubmit={async (values: ShareFormValues) => {
console.log(values);
const success = await handleShare(values);
if (success) {
handleShareModalVisible(false);
onReload();
if (actionRef.current) {
//await delay(1000);
actionRef.current.reload();
}
}
}} }}
/> */} />
{selectedRowsState?.length > 0 && (
<FooterToolbar
extra={
<div>
<FormattedMessage
id="master.footer.chosen"
defaultMessage="Chosen"
/>{' '}
<a
style={{
fontWeight: 600,
}}
>
{selectedRowsState.length}
</a>{' '}
<FormattedMessage
id="common.paginations.things"
defaultMessage="item"
/>
</div>
}
>
<Popconfirm
title={intl.formatMessage({
id: 'master.users.thing.unshare.confirm',
defaultMessage: 'Are you sure to stop sharing these devices?',
})}
okText={intl.formatMessage({
id: 'common.sure',
defaultMessage: 'Sure',
})}
onConfirm={async () => {
const success = await handleUnshare(selectedRowsState);
if (success) {
setSelectedRows([]);
if (listActionRef.current) {
listActionRef.current.reload();
}
}
}}
>
<Button type="primary" danger icon={<DeleteOutlined />}>
<FormattedMessage
id="master.users.thing.unshare.title"
defaultMessage="Sure"
/>
</Button>
</Popconfirm>
</FooterToolbar>
)}
</> </>
); );
}; };

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 TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants'; import { DEFAULT_PAGE_SIZE } from '@/constants';
import { import {
@@ -19,12 +19,16 @@ import {
ProTable, ProTable,
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl } from '@umijs/max'; 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 message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph'; import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import CreateUser from './components/CreateUser'; import CreateUser from './components/CreateUser';
import ResetPassword from './components/ResetPassword';
type ResetUserPaswordProps = {
user: MasterModel.UserResponse | null;
isOpen: boolean;
};
const ManagerUserPage = () => { const ManagerUserPage = () => {
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
const intl = useIntl(); const intl = useIntl();
@@ -34,19 +38,29 @@ const ManagerUserPage = () => {
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRowsState] = useState< const [selectedRowsState, setSelectedRowsState] = useState<
MasterModel.ProfileResponse[] MasterModel.UserResponse[]
>([]); >([]);
const [groupCheckedKeys, setGroupCheckedKeys] = useState< const [groupCheckedKeys, setGroupCheckedKeys] = useState<
string | string[] | null string | string[] | null
>(null); >(null);
const handleClickAssign = (user: MasterModel.ProfileResponse) => { 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}`; const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`;
history.push(path); history.push(path);
}; };
const columns: ProColumns<MasterModel.ProfileResponse>[] = [ const handleClickResetPassword = (user: MasterModel.UserResponse) => {
setResetPasswordUser({ user: user, isOpen: true });
};
const columns: ProColumns<MasterModel.UserResponse>[] = [
{ {
key: 'email', key: 'email',
title: ( title: (
@@ -123,20 +137,34 @@ const ManagerUserPage = () => {
hideInSearch: true, hideInSearch: true,
render: (_, user) => { render: (_, user) => {
return ( return (
<> <Space>
<Button <TooltipIconFontButton
shape="default" shape="default"
size="small" 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)} 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>
); );
}, },
}, },
]; ];
const handleRemove = async (selectedRows: MasterModel.ProfileResponse[]) => { const handleRemove = async (selectedRows: MasterModel.UserResponse[]) => {
const key = 'remove_user'; const key = 'remove_user';
if (!selectedRows) return true; if (!selectedRows) return true;
@@ -151,7 +179,7 @@ const ManagerUserPage = () => {
key, key,
}); });
const allDelete = selectedRows.map( const allDelete = selectedRows.map(
async (row: MasterModel.ProfileResponse) => { async (row: MasterModel.UserResponse) => {
await apiDeleteUser(row?.id || ''); await apiDeleteUser(row?.id || '');
}, },
); );
@@ -181,6 +209,19 @@ const ManagerUserPage = () => {
return ( return (
<> <>
{contextHolder} {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 split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}> <ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>
<TreeGroup <TreeGroup
@@ -196,7 +237,7 @@ const ManagerUserPage = () => {
/> />
</ProCard> </ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}> <ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
<ProTable<MasterModel.ProfileResponse> <ProTable<MasterModel.UserResponse>
columns={columns} columns={columns}
tableLayout="auto" tableLayout="auto"
actionRef={actionRef} actionRef={actionRef}
@@ -210,7 +251,7 @@ const ManagerUserPage = () => {
selectedRowKeys: selectedRowsState.map((row) => row.id!), selectedRowKeys: selectedRowsState.map((row) => row.id!),
onChange: ( onChange: (
_: React.Key[], _: React.Key[],
selectedRows: MasterModel.ProfileResponse[], selectedRows: MasterModel.UserResponse[],
) => { ) => {
setSelectedRowsState(selectedRows); setSelectedRowsState(selectedRows);
}, },
@@ -249,12 +290,12 @@ const ManagerUserPage = () => {
let users = userByGroupResponses.users || []; let users = userByGroupResponses.users || [];
// Apply filters // Apply filters
if (email) { if (email) {
users = users.filter((user: MasterModel.ProfileResponse) => users = users.filter((user: MasterModel.UserResponse) =>
user.email?.includes(email), user.email?.includes(email),
); );
} }
if (phone_number) { if (phone_number) {
users = users.filter((user: MasterModel.ProfileResponse) => users = users.filter((user: MasterModel.UserResponse) =>
user.metadata?.phone_number?.includes(phone_number), user.metadata?.phone_number?.includes(phone_number),
); );
} }
@@ -269,7 +310,7 @@ const ManagerUserPage = () => {
}; };
} else { } else {
// Use regular queryUsers API // Use regular queryUsers API
const metadata: Partial<MasterModel.ProfileMetadata> = {}; const metadata: Partial<MasterModel.UserMetadata> = {};
if (phone_number) metadata.phone_number = phone_number; if (phone_number) metadata.phone_number = phone_number;
const query: MasterModel.SearchUserPaginationBody = { const query: MasterModel.SearchUserPaginationBody = {
@@ -343,7 +384,7 @@ const ManagerUserPage = () => {
> >
<Popconfirm <Popconfirm
title={intl.formatMessage({ title={intl.formatMessage({
id: 'master.users.deletion.title', id: 'master.users.delete.title',
defaultMessage: 'Are you sure to delete this selected items', defaultMessage: 'Are you sure to delete this selected items',
})} })}
okText={intl.formatMessage({ okText={intl.formatMessage({

View File

@@ -32,7 +32,7 @@ const ChangeProfile = () => {
}} }}
onFinish={async (values) => { onFinish={async (values) => {
try { try {
const body: Partial<MasterModel.ProfileMetadata> = { const body: Partial<MasterModel.UserMetadata> = {
full_name: values.full_name, full_name: values.full_name,
phone_number: values.phone_number, phone_number: values.phone_number,
}; };

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