Compare commits
12 Commits
e5b388505a
...
tuanta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca363275e | ||
| 9bc15192ec | |||
|
|
ea07d0c99e | ||
|
|
ed5751002b | ||
|
|
6d1c085ff7 | ||
|
|
a11e2c2991 | ||
| c9aeca0ed9 | |||
|
|
fea9cca865 | ||
| 1f35516e44 | |||
|
|
17d246d5ef | ||
|
|
b0b09a86b7 | ||
|
|
1a06328c77 |
3
.gitignore
vendored
@@ -13,4 +13,5 @@
|
|||||||
.swc
|
.swc
|
||||||
.turbopack
|
.turbopack
|
||||||
/dist
|
/dist
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/wdoc
|
||||||
@@ -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: '/',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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': {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
2
package-lock.json
generated
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "SMATEC-FRONTEND",
|
"name": "smatec-frontend",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|||||||
BIN
public/background.png
Normal file
|
After Width: | Height: | Size: 518 KiB |
89
src/app.tsx
@@ -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_84vdbef39dp.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
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src/assets/exclamation.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
src/assets/marker.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/ship_alarm.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src/assets/ship_alarm_2.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/ship_alarm_fishing.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
src/assets/ship_online.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src/assets/ship_online_fishing.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src/assets/ship_undefine.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src/assets/ship_warning.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src/assets/ship_warning_fishing.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src/assets/sos_icon.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
src/assets/warning_icon.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
@@ -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;
|
||||||
|
|||||||
@@ -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_84vdbef39dp.js',
|
scriptUrl: '//at.alicdn.com/t/c/font_5096559_919jssa0os9.js',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default IconFont;
|
export default IconFont;
|
||||||
|
|||||||
82
src/components/shared/Alarm/AlarmUnConfirmButton.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { HTTPSTATUS } from '@/constants';
|
||||||
|
import { apiUnconfirmAlarm } from '@/services/master/AlarmController';
|
||||||
|
import { CloseOutlined } from '@ant-design/icons';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button, Popconfirm } from 'antd';
|
||||||
|
import { MessageInstance } from 'antd/es/message/interface';
|
||||||
|
|
||||||
|
type AlarmUnConfirmButtonProps = {
|
||||||
|
alarm: MasterModel.Alarm;
|
||||||
|
onFinish?: (isReload: boolean) => void;
|
||||||
|
message: MessageInstance;
|
||||||
|
button?: React.ReactNode;
|
||||||
|
};
|
||||||
|
const AlarmUnConfirmButton = ({
|
||||||
|
alarm,
|
||||||
|
message,
|
||||||
|
button,
|
||||||
|
onFinish,
|
||||||
|
}: AlarmUnConfirmButtonProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
return (
|
||||||
|
<Popconfirm
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'master.alarms.unconfirm.body',
|
||||||
|
defaultMessage: 'Are you sure you want to unconfirm this alarm?',
|
||||||
|
})}
|
||||||
|
okText={intl.formatMessage({
|
||||||
|
id: 'common.yes',
|
||||||
|
defaultMessage: 'Yes',
|
||||||
|
})}
|
||||||
|
cancelText={intl.formatMessage({
|
||||||
|
id: 'common.no',
|
||||||
|
defaultMessage: 'No',
|
||||||
|
})}
|
||||||
|
onConfirm={async () => {
|
||||||
|
const body: MasterModel.ConfirmAlarmRequest = {
|
||||||
|
id: alarm.id,
|
||||||
|
thing_id: alarm.thing_id,
|
||||||
|
time: alarm.time,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const resp = await apiUnconfirmAlarm(body);
|
||||||
|
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
message.success({
|
||||||
|
content: intl.formatMessage({
|
||||||
|
id: 'master.alarms.unconfirm.success',
|
||||||
|
defaultMessage: 'Confirm alarm successfully',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
onFinish?.(true);
|
||||||
|
} else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) {
|
||||||
|
message.warning({
|
||||||
|
content: intl.formatMessage({
|
||||||
|
id: 'master.alarms.not_found',
|
||||||
|
defaultMessage: 'Alarm has expired or does not exist',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
onFinish?.(true);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to confirm alarm');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error when unconfirm alarm: ', error);
|
||||||
|
message.error({
|
||||||
|
content: intl.formatMessage({
|
||||||
|
id: 'master.alarms.unconfirm.fail',
|
||||||
|
defaultMessage: 'Unconfirm alarm failed',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{button ? (
|
||||||
|
button
|
||||||
|
) : (
|
||||||
|
<Button danger icon={<CloseOutlined />} size="small" />
|
||||||
|
)}
|
||||||
|
</Popconfirm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlarmUnConfirmButton;
|
||||||
62
src/components/shared/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
157
src/components/shared/DeviceAlarmList.tsx
Normal 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;
|
||||||
298
src/components/shared/PhotoActionModal.tsx
Normal 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;
|
||||||
205
src/components/shared/TagState.tsx
Normal 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;
|
||||||
50
src/components/shared/ThingShared.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
70
src/components/shared/TooltipIconFontButton.tsx
Normal 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;
|
||||||
104
src/components/shared/index.less
Normal 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
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export from constants for backward compatibility
|
||||||
|
export * from './constants';
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
// 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';
|
||||||
@@ -9,7 +11,7 @@ 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_THING_POLICY = '/api/things/policy2';
|
export const API_THING_POLICY = '/api/things/policy2';
|
||||||
export const API_SHARE_THING = '/api/things';
|
export const API_THING = '/api/things';
|
||||||
|
|
||||||
// Group API Constants
|
// Group API Constants
|
||||||
export const API_GROUPS = '/api/groups';
|
export const API_GROUPS = '/api/groups';
|
||||||
@@ -17,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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
1
src/constants/slave/sgw/websocket.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const SHIP_SOS_WS_URL = 'wss://sgw.gms.vn/thingscache';
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
1
src/locales/en-US/master/master-thing-detail-en.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default { 'master.thing.detail.alarmList.title': 'Alarm List' };
|
||||||
@@ -8,8 +8,8 @@ export default {
|
|||||||
'master.devices.title': 'Devices',
|
'master.devices.title': 'Devices',
|
||||||
'master.devices.name': 'Name',
|
'master.devices.name': 'Name',
|
||||||
'master.devices.name.tip': 'The device name',
|
'master.devices.name.tip': 'The device name',
|
||||||
'master.devices.external_id': 'External ID',
|
'master.devices.external_id': 'Hardware ID',
|
||||||
'master.devices.external_id.tip': 'The external identifier',
|
'master.devices.external_id.tip': 'The hardware identifier',
|
||||||
'master.devices.type': 'Type',
|
'master.devices.type': 'Type',
|
||||||
'master.devices.type.tip': 'The device type',
|
'master.devices.type.tip': 'The device type',
|
||||||
'master.devices.online': 'Online',
|
'master.devices.online': 'Online',
|
||||||
@@ -21,6 +21,9 @@ export default {
|
|||||||
'master.devices.create.error': 'Device creation failed',
|
'master.devices.create.error': 'Device creation failed',
|
||||||
'master.devices.groups': 'Groups',
|
'master.devices.groups': 'Groups',
|
||||||
'master.devices.groups.required': 'Please select 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
|
// Edit device modal
|
||||||
'master.devices.update.title': 'Update device',
|
'master.devices.update.title': 'Update device',
|
||||||
'master.devices.ok': 'OK',
|
'master.devices.ok': 'OK',
|
||||||
@@ -32,4 +35,13 @@ export default {
|
|||||||
'master.devices.address': 'Address',
|
'master.devices.address': 'Address',
|
||||||
'master.devices.address.placeholder': 'Enter address',
|
'master.devices.address.placeholder': 'Enter address',
|
||||||
'master.devices.address.required': 'Please 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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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?',
|
||||||
@@ -66,4 +70,8 @@ export default {
|
|||||||
'master.users.things.sharing': 'Sharing devices...',
|
'master.users.things.sharing': 'Sharing devices...',
|
||||||
'master.users.thing.share.success': 'Device sharing successful',
|
'master.users.thing.share.success': 'Device sharing successful',
|
||||||
'master.users.thing.share.fail': 'Device sharing failed',
|
'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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
53
src/locales/en-US/slave/sgw/sgw-fish-en.ts
Normal 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',
|
||||||
|
};
|
||||||
37
src/locales/en-US/slave/sgw/sgw-map-en.ts
Normal 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',
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
16
src/locales/en-US/slave/sgw/sgw-photo-en.ts
Normal 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',
|
||||||
|
};
|
||||||
16
src/locales/en-US/slave/sgw/sgw-ship-en.ts
Normal 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',
|
||||||
|
};
|
||||||
64
src/locales/en-US/slave/sgw/sgw-trip-en.ts
Normal 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',
|
||||||
|
};
|
||||||
32
src/locales/en-US/slave/sgw/sgw-zone-en.ts
Normal 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!',
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
3
src/locales/vi-VN/master/master-thing-detail-vi.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
'master.thing.detail.alarmList.title': 'Danh sách cảnh báo',
|
||||||
|
};
|
||||||
@@ -3,11 +3,12 @@ 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
|
// Device translations
|
||||||
'master.devices.title': 'Quản lý thiết bị',
|
'master.devices.title': 'Quản lý thiết bị',
|
||||||
'master.devices.name': 'Tên',
|
'master.devices.name': 'Tên',
|
||||||
'master.devices.name.tip': 'Tên thiết bị',
|
'master.devices.name.tip': 'Tên thiết bị',
|
||||||
'master.devices.external_id': 'External ID',
|
'master.devices.external_id': 'Hardware ID',
|
||||||
'master.devices.external_id.tip': 'Mã định danh bên ngoài',
|
'master.devices.external_id.tip': 'Mã định danh bên ngoài',
|
||||||
'master.devices.type': 'Loại',
|
'master.devices.type': 'Loại',
|
||||||
'master.devices.type.tip': 'Loại thiết bị',
|
'master.devices.type.tip': 'Loại thiết bị',
|
||||||
@@ -20,6 +21,9 @@ export default {
|
|||||||
'master.devices.create.error': 'Tạo thiết bị lỗi',
|
'master.devices.create.error': 'Tạo thiết bị lỗi',
|
||||||
'master.devices.groups': 'Đơn vị',
|
'master.devices.groups': 'Đơn vị',
|
||||||
'master.devices.groups.required': 'Vui lòng chọn đơ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
|
// Edit device modal
|
||||||
'master.devices.update.title': 'Cập nhật thiết bị',
|
'master.devices.update.title': 'Cập nhật thiết bị',
|
||||||
'master.devices.ok': 'Đồng ý',
|
'master.devices.ok': 'Đồng ý',
|
||||||
@@ -31,4 +35,13 @@ export default {
|
|||||||
'master.devices.address': 'Địa chỉ',
|
'master.devices.address': 'Địa chỉ',
|
||||||
'master.devices.address.placeholder': 'Nhập địa chỉ',
|
'master.devices.address.placeholder': 'Nhập địa chỉ',
|
||||||
'master.devices.address.required': 'Vui lòng 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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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?',
|
||||||
@@ -65,4 +69,8 @@ export default {
|
|||||||
'master.users.things.sharing': 'Đang 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.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.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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
53
src/locales/vi-VN/slave/sgw/sgw-fish-vi.ts
Normal 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',
|
||||||
|
};
|
||||||
36
src/locales/vi-VN/slave/sgw/sgw-map-vi.ts
Normal 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',
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
16
src/locales/vi-VN/slave/sgw/sgw-photo-vi.ts
Normal 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',
|
||||||
|
};
|
||||||
16
src/locales/vi-VN/slave/sgw/sgw-ship-vi.ts
Normal 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',
|
||||||
|
};
|
||||||
64
src/locales/vi-VN/slave/sgw/sgw-trip-vi.ts
Normal 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',
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
31
src/locales/vi-VN/slave/sgw/sgw-zone-vi.ts
Normal 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!',
|
||||||
|
};
|
||||||
36
src/models/slave/sgw/useHomePorts.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
30
src/models/slave/sgw/useShipSos.ts
Normal 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 };
|
||||||
|
}
|
||||||
25
src/models/slave/sgw/useShipTypes.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
421
src/pages/Auth/ForgotPassword/index.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
174
src/pages/Manager/Device/Camera/components/CameraFormModal.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
} from 'antd';
|
||||||
|
|
||||||
|
// Camera types
|
||||||
|
const CAMERA_TYPES = [
|
||||||
|
{ label: 'HIKVISION', value: 'HIKVISION' },
|
||||||
|
{ label: 'DAHUA', value: 'DAHUA' },
|
||||||
|
{ label: 'GENERIC', value: 'GENERIC' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CameraFormValues {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
account: string;
|
||||||
|
password: string;
|
||||||
|
ipAddress: string;
|
||||||
|
rtspPort: number;
|
||||||
|
httpPort: number;
|
||||||
|
stream: number;
|
||||||
|
channel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CameraFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (values: CameraFormValues) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
||||||
|
open,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm<CameraFormValues>();
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
onSubmit(values);
|
||||||
|
form.resetFields();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Validation failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
form.resetFields();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Tạo mới camera"
|
||||||
|
open={open}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={handleCancel}>
|
||||||
|
Hủy
|
||||||
|
</Button>,
|
||||||
|
<Button key="submit" type="primary" onClick={handleSubmit}>
|
||||||
|
Đồng ý
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
type: 'HIKVISION',
|
||||||
|
rtspPort: 554,
|
||||||
|
httpPort: 80,
|
||||||
|
stream: 0,
|
||||||
|
channel: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="Tên"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="nhập dữ liệu" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Loại"
|
||||||
|
name="type"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng chọn loại' }]}
|
||||||
|
>
|
||||||
|
<Select options={CAMERA_TYPES} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Tài khoản"
|
||||||
|
name="account"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập tài khoản' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="nhập tài khoản" autoComplete="off" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Mật khẩu"
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập mật khẩu' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="nhập mật khẩu"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Địa chỉ IP"
|
||||||
|
name="ipAddress"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập địa chỉ IP' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="192.168.1.10" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Cổng RTSP"
|
||||||
|
name="rtspPort"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập cổng RTSP' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Cổng HTTP"
|
||||||
|
name="httpPort"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập cổng HTTP' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Luồng"
|
||||||
|
name="stream"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập luồng' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Kênh"
|
||||||
|
name="channel"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập kênh' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CameraFormModal;
|
||||||
106
src/pages/Manager/Device/Camera/components/CameraTable.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Card, Checkbox, Space, Table, theme } from 'antd';
|
||||||
|
|
||||||
|
interface CameraTableProps {
|
||||||
|
cameraData: MasterModel.Camera[] | null;
|
||||||
|
onCreateCamera: () => void;
|
||||||
|
onReload?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CameraTable: React.FC<CameraTableProps> = ({
|
||||||
|
cameraData,
|
||||||
|
onCreateCamera,
|
||||||
|
onReload,
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const handleReload = () => {
|
||||||
|
console.log('Reload cameras');
|
||||||
|
onReload?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
console.log('Delete selected cameras');
|
||||||
|
// TODO: Implement delete functionality
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (camera: MasterModel.Camera) => {
|
||||||
|
console.log('Edit camera:', camera);
|
||||||
|
// TODO: Implement edit functionality
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'checkbox',
|
||||||
|
width: 50,
|
||||||
|
render: () => <Checkbox />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tên',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (text: string) => (
|
||||||
|
<a style={{ color: token.colorPrimary }}>{text || '-'}</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Loại',
|
||||||
|
dataIndex: 'cate_id',
|
||||||
|
key: 'cate_id',
|
||||||
|
render: (text: string) => text || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Địa chỉ IP',
|
||||||
|
dataIndex: 'ip',
|
||||||
|
key: 'ip',
|
||||||
|
render: (text: string) => text || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Thao tác',
|
||||||
|
key: 'action',
|
||||||
|
render: (_: any, record: MasterModel.Camera) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card bodyStyle={{ padding: 16 }}>
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateCamera}>
|
||||||
|
Tạo mới camera
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleReload} />
|
||||||
|
<Button icon={<DeleteOutlined />} onClick={handleDelete} />
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={cameraData || []}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
size: 'small',
|
||||||
|
showTotal: (total: number, range: [number, number]) =>
|
||||||
|
`Hiển thị ${range[0]}-${range[1]} của ${total} camera`,
|
||||||
|
pageSize: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CameraTable;
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Button, Card, Select, Typography } from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// Recording modes for V5 - chỉ có không ghi và ghi 24/24
|
||||||
|
const RECORDING_MODES = [
|
||||||
|
{ label: 'Không ghi', value: 'none' },
|
||||||
|
{ label: 'Ghi 24/24', value: '24/7' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CameraV5Props {
|
||||||
|
thing: MasterModel.Thing | null;
|
||||||
|
initialRecordingMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CameraV5: React.FC<CameraV5Props> = ({
|
||||||
|
thing,
|
||||||
|
initialRecordingMode = 'none',
|
||||||
|
}) => {
|
||||||
|
const [recordingMode, setRecordingMode] = useState(initialRecordingMode);
|
||||||
|
|
||||||
|
console.log('ConfigCameraV5 - thing:', thing);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
console.log('Submit recording mode:', recordingMode);
|
||||||
|
// TODO: Call API to save recording configuration
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card bodyStyle={{ padding: 16 }}>
|
||||||
|
{/* Recording Mode */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
Ghi dữ liệu camera
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={recordingMode}
|
||||||
|
onChange={setRecordingMode}
|
||||||
|
options={RECORDING_MODES}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Button type="primary" onClick={handleSubmit}>
|
||||||
|
Gửi đi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CameraV5;
|
||||||
192
src/pages/Manager/Device/Camera/components/ConfigCameraV6.tsx
Normal file
@@ -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;
|
||||||
169
src/pages/Manager/Device/Camera/index.tsx
Normal 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;
|
||||||
166
src/pages/Manager/Device/Detail/components/BinarySensors.tsx
Normal 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;
|
||||||
34
src/pages/Manager/Device/Detail/components/ThingTitle.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { getBadgeConnection } from '@/components/shared/ThingShared';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Flex, Typography } from 'antd';
|
||||||
|
import moment from 'moment';
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
const ThingTitle = ({ thing }: { thing: MasterModel.Thing | null }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
if (thing === null) {
|
||||||
|
return <Text>{intl.formatMessage({ id: 'common.undefined' })}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionDuration = thing.metadata!.connected
|
||||||
|
? thing.metadata!.uptime! * 1000
|
||||||
|
: (Math.round(new Date().getTime() / 1000) -
|
||||||
|
thing.metadata!.updated_time!) *
|
||||||
|
1000;
|
||||||
|
return (
|
||||||
|
<Flex gap={10}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
|
{thing.name || intl.formatMessage({ id: 'common.undefined' })}
|
||||||
|
</Title>
|
||||||
|
<Flex gap={5} align="center" justify="center">
|
||||||
|
{getBadgeConnection(thing.metadata!.connected || false)}
|
||||||
|
<Text type={thing.metadata?.connected ? undefined : 'secondary'}>
|
||||||
|
{connectionDuration > 0
|
||||||
|
? moment.duration(connectionDuration).humanize()
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThingTitle;
|
||||||
121
src/pages/Manager/Device/Detail/index.tsx
Normal 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;
|
||||||
@@ -6,11 +6,7 @@ interface Props {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
device: MasterModel.Thing | null;
|
device: MasterModel.Thing | null;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (values: {
|
onSubmit: (values: MasterModel.Thing) => void;
|
||||||
name: string;
|
|
||||||
external_id: string;
|
|
||||||
address?: string;
|
|
||||||
}) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditDeviceModal: React.FC<Props> = ({
|
const EditDeviceModal: React.FC<Props> = ({
|
||||||
@@ -34,6 +30,24 @@ const EditDeviceModal: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [device, form]);
|
}, [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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
@@ -53,7 +67,12 @@ const EditDeviceModal: React.FC<Props> = ({
|
|||||||
})}
|
})}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={onSubmit} preserve={false}>
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleFinish}
|
||||||
|
preserve={false}
|
||||||
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
|
|||||||
121
src/pages/Manager/Device/components/LocationModal.tsx
Normal 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;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import IconFont from '@/components/IconFont';
|
import IconFont from '@/components/IconFont';
|
||||||
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 { apiSearchThings } from '@/services/master/ThingController';
|
import {
|
||||||
|
apiSearchThings,
|
||||||
|
apiUpdateThing,
|
||||||
|
} from '@/services/master/ThingController';
|
||||||
import { EditOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
import { EditOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
ActionType,
|
ActionType,
|
||||||
@@ -9,13 +12,14 @@ import {
|
|||||||
ProColumns,
|
ProColumns,
|
||||||
ProTable,
|
ProTable,
|
||||||
} from '@ant-design/pro-components';
|
} from '@ant-design/pro-components';
|
||||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
import { FormattedMessage, history, useIntl } from '@umijs/max';
|
||||||
import { Button, Divider, Grid, Space, Tag, theme } from 'antd';
|
import { Button, Divider, Grid, Space, Tag, 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 CreateDevice from './components/CreateDevice';
|
import CreateDevice from './components/CreateDevice';
|
||||||
import EditDeviceModal from './components/EditDeviceModal';
|
import EditDeviceModal from './components/EditDeviceModal';
|
||||||
|
import LocationModal from './components/LocationModal';
|
||||||
|
|
||||||
const ManagerDevicePage = () => {
|
const ManagerDevicePage = () => {
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
@@ -35,9 +39,14 @@ const ManagerDevicePage = () => {
|
|||||||
const [editingDevice, setEditingDevice] = useState<MasterModel.Thing | null>(
|
const [editingDevice, setEditingDevice] = useState<MasterModel.Thing | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [isLocationModalVisible, setIsLocationModalVisible] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
const [locationDevice, setLocationDevice] =
|
||||||
|
useState<MasterModel.Thing | null>(null);
|
||||||
|
|
||||||
const handleClickAssign = (device: MasterModel.Thing) => {
|
const handleLocation = (device: MasterModel.Thing) => {
|
||||||
console.log('Device ', device);
|
setLocationDevice(device);
|
||||||
|
setIsLocationModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (device: MasterModel.Thing) => {
|
const handleEdit = (device: MasterModel.Thing) => {
|
||||||
@@ -50,13 +59,53 @@ const ManagerDevicePage = () => {
|
|||||||
setEditingDevice(null);
|
setEditingDevice(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditSubmit = async (values: any) => {
|
const handleLocationCancel = () => {
|
||||||
// TODO: call update API here if available. For now just simulate success.
|
setIsLocationModalVisible(false);
|
||||||
console.log('Update values for', editingDevice?.id, values);
|
setLocationDevice(null);
|
||||||
messageApi.success('Cập nhật thành công');
|
};
|
||||||
setIsEditModalVisible(false);
|
|
||||||
setEditingDevice(null);
|
const handleLocationSubmit = async (values: MasterModel.Thing) => {
|
||||||
actionRef.current?.reload();
|
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>[] = [
|
const columns: ProColumns<MasterModel.Thing>[] = [
|
||||||
@@ -172,20 +221,21 @@ const ManagerDevicePage = () => {
|
|||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EnvironmentOutlined />}
|
icon={<EnvironmentOutlined />}
|
||||||
// onClick={() => handleClickAssign(device)}
|
onClick={() => handleLocation(device)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconFont type="icon-camera" />}
|
icon={<IconFont type="icon-camera" />}
|
||||||
// onClick={() => handleClickAssign(device)}
|
onClick={() => {
|
||||||
|
history.push(`/manager/devices/${device.id}/camera`);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{device?.metadata?.type === 'gmsv6' && (
|
{device?.metadata?.type === 'gmsv6' && (
|
||||||
<Button
|
<Button
|
||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconFont type="icon-terminal" />}
|
icon={<IconFont type="icon-terminal" />}
|
||||||
// onClick={() => handleClickAssign(device)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -202,6 +252,12 @@ const ManagerDevicePage = () => {
|
|||||||
onCancel={handleEditCancel}
|
onCancel={handleEditCancel}
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
/>
|
/>
|
||||||
|
<LocationModal
|
||||||
|
visible={isLocationModalVisible}
|
||||||
|
device={locationDevice}
|
||||||
|
onCancel={handleLocationCancel}
|
||||||
|
onSubmit={handleLocationSubmit}
|
||||||
|
/>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<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 }}>
|
||||||
@@ -255,7 +311,7 @@ const ManagerDevicePage = () => {
|
|||||||
const offset = current === 1 ? 0 : (current - 1) * size;
|
const offset = current === 1 ? 0 : (current - 1) * size;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const metadata: Partial<MasterModel.ThingMetadata> = {};
|
const metadata: Partial<MasterModel.SearchThingMetadata> = {};
|
||||||
if (external_id) metadata.external_id = external_id;
|
if (external_id) metadata.external_id = external_id;
|
||||||
|
|
||||||
// Add group filter if groups are selected
|
// Add group filter if groups are selected
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const SystemLogs = () => {
|
|||||||
const tableRef = useRef<ActionType>();
|
const tableRef = useRef<ActionType>();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type PolicyShareDefault = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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>();
|
||||||
|
|||||||
168
src/pages/Manager/User/components/ResetPassword.tsx
Normal 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;
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { ModalForm } from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
import { Button, DatePicker, Flex, Form, Input, Select, Space } from 'antd';
|
||||||
|
import { FormListFieldData } from 'antd/lib';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { AreaCondition, ZoneFormField } from '../type';
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
// Transform form values to match AreaCondition type
|
||||||
|
const transformToAreaCondition = (values: any[]): AreaCondition[] => {
|
||||||
|
return values.map((item) => {
|
||||||
|
const { type } = item;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'month_range': {
|
||||||
|
// RangePicker for month returns [Dayjs, Dayjs]
|
||||||
|
const [from, to] = item.from ?? [];
|
||||||
|
return {
|
||||||
|
type: 'month_range',
|
||||||
|
from: from?.month() ?? 0, // 0-11
|
||||||
|
to: to?.month() ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'date_range': {
|
||||||
|
// RangePicker for date returns [Dayjs, Dayjs]
|
||||||
|
const [from, to] = item.from ?? [];
|
||||||
|
return {
|
||||||
|
type: 'date_range',
|
||||||
|
from: from?.format('YYYY-MM-DD') ?? '',
|
||||||
|
to: to?.format('YYYY-MM-DD') ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'length_limit': {
|
||||||
|
return {
|
||||||
|
type: 'length_limit',
|
||||||
|
min: Number(item.min) ?? 0,
|
||||||
|
max: Number(item.max) ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transform AreaCondition to form values
|
||||||
|
const transformToFormValues = (conditions?: AreaCondition[]): any[] => {
|
||||||
|
if (!conditions || conditions.length === 0) return [{}];
|
||||||
|
|
||||||
|
return conditions.map((condition) => {
|
||||||
|
switch (condition.type) {
|
||||||
|
case 'month_range': {
|
||||||
|
return {
|
||||||
|
type: 'month_range',
|
||||||
|
from: [dayjs().month(condition.from), dayjs().month(condition.to)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'date_range': {
|
||||||
|
return {
|
||||||
|
type: 'date_range',
|
||||||
|
from: [dayjs(condition.from), dayjs(condition.to)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'length_limit': {
|
||||||
|
return condition;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConditionRow = ({
|
||||||
|
field,
|
||||||
|
remove,
|
||||||
|
}: {
|
||||||
|
field: FormListFieldData;
|
||||||
|
remove: (name: number) => void;
|
||||||
|
}) => {
|
||||||
|
const selectedType = Form.useWatch([
|
||||||
|
ZoneFormField.AreaConditions,
|
||||||
|
field.name,
|
||||||
|
'type',
|
||||||
|
]);
|
||||||
|
const intl = useIntl();
|
||||||
|
return (
|
||||||
|
<Flex gap="middle" style={{ marginBottom: 16 }}>
|
||||||
|
<Space align="baseline">
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'type']}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.category.name',
|
||||||
|
defaultMessage: 'Danh mục',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.category.not_empty',
|
||||||
|
defaultMessage: 'Category cannot be empty!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.category.add',
|
||||||
|
defaultMessage: 'Add category',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Select.Option value="month_range">
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.condition.yearly_select"
|
||||||
|
defaultMessage="Yearly"
|
||||||
|
/>
|
||||||
|
</Select.Option>
|
||||||
|
<Select.Option value="date_range">
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.condition.specific_time_select"
|
||||||
|
defaultMessage="Specific date"
|
||||||
|
/>
|
||||||
|
</Select.Option>
|
||||||
|
<Select.Option value="length_limit">
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.condition.length_limit"
|
||||||
|
defaultMessage="Length limit"
|
||||||
|
/>
|
||||||
|
</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{selectedType !== undefined &&
|
||||||
|
(selectedType === 'length_limit' ? (
|
||||||
|
<Form.Item
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.condition.length_limit',
|
||||||
|
defaultMessage: 'Length limit',
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'min']}
|
||||||
|
noStyle
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'common.not_empty',
|
||||||
|
defaultMessage: 'Cannot be empty!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.condition.length_limit_min',
|
||||||
|
defaultMessage: 'Minimum',
|
||||||
|
})}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'max']}
|
||||||
|
noStyle
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'common.not_empty',
|
||||||
|
defaultMessage: 'Cannot be empty!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.condition.length_limit_max',
|
||||||
|
defaultMessage: 'Maximum',
|
||||||
|
})}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'from']}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.condition.ban_time',
|
||||||
|
defaultMessage: 'Time',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'common.not_empty',
|
||||||
|
defaultMessage: 'Cannot be empty!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<RangePicker
|
||||||
|
picker={selectedType === 'month_range' ? 'month' : 'date'}
|
||||||
|
format={
|
||||||
|
selectedType === 'month_range' ? 'MM/YYYY' : 'DD/MM/YYYY'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AddConditionFormProps {
|
||||||
|
initialData?: AreaCondition[];
|
||||||
|
isVisible: boolean;
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
onFinish?: (values: AreaCondition[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddConditionForm = ({
|
||||||
|
initialData,
|
||||||
|
isVisible,
|
||||||
|
setVisible,
|
||||||
|
onFinish,
|
||||||
|
}: AddConditionFormProps) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
form.resetFields();
|
||||||
|
|
||||||
|
if (initialData && initialData.length > 0) {
|
||||||
|
form.setFieldsValue({ conditions: transformToFormValues(initialData) });
|
||||||
|
} else {
|
||||||
|
form.setFieldsValue({ conditions: [{}] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isVisible, initialData]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const transformedConditions = transformToAreaCondition(values.conditions);
|
||||||
|
onFinish?.(transformedConditions);
|
||||||
|
setVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Validation failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
form={form}
|
||||||
|
open={isVisible}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) form.resetFields();
|
||||||
|
setVisible(open);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.category.add"
|
||||||
|
defaultMessage="Add category"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
width="600px"
|
||||||
|
submitter={{
|
||||||
|
searchConfig: { submitText: 'Lưu' },
|
||||||
|
render: (_, doms) => (
|
||||||
|
<Flex justify="center" gap={16}>
|
||||||
|
{doms}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
>
|
||||||
|
<Form.List name={ZoneFormField.AreaConditions}>
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<ConditionRow
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
remove={remove}
|
||||||
|
// isFirst={index === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Form.Item>
|
||||||
|
<Flex justify="center">
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => add()}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
style={{ width: 300 }}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.category.add"
|
||||||
|
defaultMessage="Add category"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddConditionForm;
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { getCircleRadius } from '@/utils/slave/sgw/geomUtils';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
ProFormDigit,
|
||||||
|
ProFormInstance,
|
||||||
|
ProFormItem,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
import { Button, Col, Flex, Form, Input, Row, Tag, Tooltip } from 'antd';
|
||||||
|
import { MutableRefObject, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
CircleGeometry,
|
||||||
|
GeometryType,
|
||||||
|
PolygonGeometry,
|
||||||
|
tagPlusStyle,
|
||||||
|
validateGeometry,
|
||||||
|
ZoneFormData,
|
||||||
|
ZoneFormField,
|
||||||
|
} from '../type';
|
||||||
|
import PolygonModal from './PolygonModal';
|
||||||
|
|
||||||
|
type GeometryFormProps = {
|
||||||
|
shape?: number;
|
||||||
|
form: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
|
||||||
|
zoneData?: SgwModel.Geom;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GeometryForm = ({ shape, form }: GeometryFormProps) => {
|
||||||
|
const [isPolygonModalOpen, setIsPolygonModalOpen] = useState<boolean>(false);
|
||||||
|
const [indexTag, setIndexTag] = useState<number>(-1);
|
||||||
|
const intl = useIntl();
|
||||||
|
const polygonGeometry =
|
||||||
|
(Form.useWatch(
|
||||||
|
ZoneFormField.PolygonGeometry,
|
||||||
|
form.current || undefined,
|
||||||
|
) as PolygonGeometry[]) || [];
|
||||||
|
|
||||||
|
// Circle area calculation (subscribe to Radius so it updates)
|
||||||
|
const area = Form.useWatch(
|
||||||
|
[ZoneFormField.CircleData, 'area'],
|
||||||
|
form.current || undefined,
|
||||||
|
) as number | undefined;
|
||||||
|
|
||||||
|
const radiusArea = useMemo(() => {
|
||||||
|
if (shape === GeometryType.CIRCLE) {
|
||||||
|
if (area && area > 0) {
|
||||||
|
return getCircleRadius(area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [shape, area]);
|
||||||
|
|
||||||
|
const handleRemovePolygon = (index: number) => {
|
||||||
|
const newPolygons = polygonGeometry.filter(
|
||||||
|
(_, i) => i !== index,
|
||||||
|
) as PolygonGeometry[];
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.PolygonGeometry]: newPolygons,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditPolygon = (index: number) => {
|
||||||
|
setIsPolygonModalOpen(true);
|
||||||
|
setIndexTag(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format coordinates for display
|
||||||
|
const formatCoords = (coords: number[][]) => {
|
||||||
|
return coords.map((c) => `[${c[0]}, ${c[1]}]`).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (shape) {
|
||||||
|
case GeometryType.POLYGON:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProFormItem
|
||||||
|
name={ZoneFormField.PolygonGeometry}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates',
|
||||||
|
defaultMessage: 'Tọa độ',
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Flex gap="10px 4px" wrap>
|
||||||
|
{polygonGeometry.map((polygon, index) => (
|
||||||
|
<Tooltip
|
||||||
|
key={index}
|
||||||
|
title={formatCoords(polygon.geometry) || ''}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
closable
|
||||||
|
onClose={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRemovePolygon(index);
|
||||||
|
}}
|
||||||
|
onClick={() => handleEditPolygon(index)}
|
||||||
|
color="blue"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'banzones.title',
|
||||||
|
defaultMessage: 'Khu vực',
|
||||||
|
})}{' '}
|
||||||
|
{index + 1}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
style={tagPlusStyle}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setIndexTag(-1);
|
||||||
|
setIsPolygonModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.geometry.add_zone"
|
||||||
|
defaultMessage="Thêm vùng"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</ProFormItem>
|
||||||
|
<PolygonModal
|
||||||
|
isVisible={isPolygonModalOpen}
|
||||||
|
setVisible={setIsPolygonModalOpen}
|
||||||
|
initialData={polygonGeometry}
|
||||||
|
index={indexTag}
|
||||||
|
handleSubmit={async (values) => {
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.PolygonGeometry]: values,
|
||||||
|
});
|
||||||
|
setIndexTag(-1);
|
||||||
|
setIsPolygonModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case GeometryType.LINESTRING:
|
||||||
|
return (
|
||||||
|
<ProFormItem
|
||||||
|
required
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.not_empty',
|
||||||
|
defaultMessage: 'Tọa độ không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: (_: any, value: string) => {
|
||||||
|
return validateGeometry(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
name={ZoneFormField.PolylineData}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates',
|
||||||
|
defaultMessage: 'Tọa độ',
|
||||||
|
})}
|
||||||
|
tooltip={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.tooltip',
|
||||||
|
defaultMessage:
|
||||||
|
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
autoSize={{ minRows: 5, maxRows: 10 }}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.placeholder',
|
||||||
|
defaultMessage:
|
||||||
|
'Ví dụ: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ProFormItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
case GeometryType.CIRCLE:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormText
|
||||||
|
name={[ZoneFormField.CircleData, 'center', 0]}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.longitude',
|
||||||
|
defaultMessage: 'Kinh độ',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.longitude.placeholder',
|
||||||
|
defaultMessage: 'Nhập kinh độ',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.longitude.required',
|
||||||
|
defaultMessage: 'Kinh độ không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fieldProps={{
|
||||||
|
type: 'number',
|
||||||
|
step: '0.000001',
|
||||||
|
onChange: (e) => {
|
||||||
|
const lng = parseFloat(e.target.value);
|
||||||
|
const currentCircle = (form.current?.getFieldValue(
|
||||||
|
ZoneFormField.CircleData,
|
||||||
|
) as CircleGeometry) || { center: [0, 0], area: 0 };
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.CircleData]: {
|
||||||
|
...currentCircle,
|
||||||
|
center: [lng, currentCircle.center?.[1] || 0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormText
|
||||||
|
name={[ZoneFormField.CircleData, 'center', 1]}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.latitude',
|
||||||
|
defaultMessage: 'Vĩ độ',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.latitude.placeholder',
|
||||||
|
defaultMessage: 'Nhập vĩ độ',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.latitude.required',
|
||||||
|
defaultMessage: 'Vĩ độ không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fieldProps={{
|
||||||
|
type: 'number',
|
||||||
|
step: '0.000001',
|
||||||
|
onChange: (e) => {
|
||||||
|
const lat = parseFloat(e.target.value);
|
||||||
|
const currentCircle = (form.current?.getFieldValue(
|
||||||
|
ZoneFormField.CircleData,
|
||||||
|
) as CircleGeometry) || { center: [0, 0], radius: 0 };
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.CircleData]: {
|
||||||
|
...currentCircle,
|
||||||
|
center: [currentCircle.center?.[0] || 0, lat],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormDigit
|
||||||
|
name={[ZoneFormField.CircleData, 'area']}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.area',
|
||||||
|
defaultMessage: 'Diện tích (Hecta)',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.area.placeholder',
|
||||||
|
defaultMessage: 'Nhập diện tích',
|
||||||
|
})}
|
||||||
|
min={1}
|
||||||
|
fieldProps={{
|
||||||
|
precision: 0,
|
||||||
|
onChange: (value) => {
|
||||||
|
const currentCircle = (form.current?.getFieldValue(
|
||||||
|
ZoneFormField.CircleData,
|
||||||
|
) as CircleGeometry) || { center: [0, 0], radius: 0 };
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.CircleData]: {
|
||||||
|
center: currentCircle.center || [0, 0],
|
||||||
|
area: value || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.area.required',
|
||||||
|
defaultMessage: 'Diện tích không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormItem
|
||||||
|
name={[ZoneFormField.CircleData, 'radius']}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.radius',
|
||||||
|
defaultMessage: 'Bán kính (m)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
value={
|
||||||
|
radiusArea > 0
|
||||||
|
? `${radiusArea} ${intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.metrics',
|
||||||
|
defaultMessage: 'mét',
|
||||||
|
})}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
addonAfter={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.auto_calculate',
|
||||||
|
defaultMessage: 'Tự động tính',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ProFormItem>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <div>Vui lòng chọn loại hình học để nhập toạ độ.</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeometryForm;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProFormItem,
|
||||||
|
ProFormList,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Flex, Input, theme } from 'antd';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { PolygonGeometry, validateGeometry } from '../type';
|
||||||
|
|
||||||
|
interface PolygonModalProps {
|
||||||
|
initialData?: PolygonGeometry[];
|
||||||
|
index?: number;
|
||||||
|
isVisible: boolean;
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
handleSubmit: (values: PolygonGeometry[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PolygonModal = ({
|
||||||
|
isVisible,
|
||||||
|
setVisible,
|
||||||
|
handleSubmit,
|
||||||
|
initialData,
|
||||||
|
index,
|
||||||
|
}: PolygonModalProps) => {
|
||||||
|
const formRef = useRef<any>();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const intl = useIntl();
|
||||||
|
// Counter to track item index during render
|
||||||
|
let itemIndex = 0;
|
||||||
|
// Convert initialData to form format
|
||||||
|
const initialValues = useMemo(() => {
|
||||||
|
if (!initialData || initialData.length === 0) {
|
||||||
|
return { conditions: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
conditions: initialData.map((item) => ({
|
||||||
|
geometry: JSON.stringify(item.geometry),
|
||||||
|
id: item.id || undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
formRef.current?.setFieldsValue(initialValues);
|
||||||
|
}
|
||||||
|
}, [isVisible, initialValues]);
|
||||||
|
|
||||||
|
// Handle form submit - convert back to AreaGeometry format
|
||||||
|
const handleFinish = async (values: any) => {
|
||||||
|
const conditions = values.conditions || [];
|
||||||
|
const result: PolygonGeometry[] = conditions.map((item: any) => ({
|
||||||
|
geometry: JSON.parse(item.geometry),
|
||||||
|
id: item.id,
|
||||||
|
}));
|
||||||
|
await handleSubmit(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Global style for highlighting TextArea border */}
|
||||||
|
<style>{`
|
||||||
|
.highlighted-item .ant-input {
|
||||||
|
border-color: ${token.colorWarningActive} !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<ModalForm
|
||||||
|
formRef={formRef}
|
||||||
|
open={isVisible}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) formRef.current?.resetFields();
|
||||||
|
setVisible(open);
|
||||||
|
}}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'banzone.polygon_modal.title',
|
||||||
|
defaultMessage: 'Thêm toạ độ',
|
||||||
|
})}
|
||||||
|
width="40%"
|
||||||
|
initialValues={initialValues}
|
||||||
|
submitter={{
|
||||||
|
searchConfig: {
|
||||||
|
submitText: intl.formatMessage({
|
||||||
|
id: 'common.save',
|
||||||
|
defaultMessage: 'Lưu',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
render: (_, doms) => (
|
||||||
|
<Flex justify="center" gap={16}>
|
||||||
|
{doms}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
onFinish={handleFinish}
|
||||||
|
>
|
||||||
|
<ProFormList
|
||||||
|
required
|
||||||
|
name="conditions"
|
||||||
|
creatorButtonProps={{
|
||||||
|
position: 'bottom',
|
||||||
|
creatorButtonText: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.add_zone',
|
||||||
|
defaultMessage: 'Thêm toạ độ',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
copyIconProps={false}
|
||||||
|
deleteIconProps={{
|
||||||
|
tooltipText: intl.formatMessage({
|
||||||
|
id: 'common.delete',
|
||||||
|
defaultMessage: 'Xoá',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
itemRender={({ listDom, action }) => {
|
||||||
|
const currentIndex = itemIndex++;
|
||||||
|
const isHighlighted = index !== undefined && currentIndex === index;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={isHighlighted ? 'highlighted-item' : ''}
|
||||||
|
data-item-index={currentIndex}
|
||||||
|
>
|
||||||
|
{listDom}
|
||||||
|
<Flex gap={8} justify="flex-end">
|
||||||
|
{action}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Hidden field for id - needed to preserve id when editing */}
|
||||||
|
<ProFormItem name="id" hidden>
|
||||||
|
<input type="hidden" />
|
||||||
|
</ProFormItem>
|
||||||
|
<ProFormItem
|
||||||
|
name="geometry"
|
||||||
|
required
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.not_empty',
|
||||||
|
defaultMessage: 'Coordinates cannot be empty!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: (_rule: any, value: any) => {
|
||||||
|
return validateGeometry(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates',
|
||||||
|
defaultMessage: 'Coordinates',
|
||||||
|
})}
|
||||||
|
tooltip={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.tooltip',
|
||||||
|
defaultMessage:
|
||||||
|
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
autoSize={{ minRows: 5, maxRows: 10 }}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.placeholder',
|
||||||
|
defaultMessage:
|
||||||
|
'Example: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ProFormItem>
|
||||||
|
</ProFormList>
|
||||||
|
</ModalForm>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PolygonModal;
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
ProForm,
|
||||||
|
ProFormInstance,
|
||||||
|
ProFormSelect,
|
||||||
|
ProFormSwitch,
|
||||||
|
ProFormText,
|
||||||
|
ProFormTextArea,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
import { Button, Col, Flex, Form, Row, Tag, Tooltip } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { MutableRefObject, useState } from 'react';
|
||||||
|
import { tagPlusStyle, ZoneFormData, ZoneFormField } from '../type';
|
||||||
|
import AddConditionForm from './AddConditionForm';
|
||||||
|
import GeometryForm from './GeometryForm';
|
||||||
|
interface ZoneFormProps {
|
||||||
|
shape?: number;
|
||||||
|
formRef: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZoneForm = ({ formRef, shape }: ZoneFormProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [isConditionModalOpen, setIsConditionModalOpen] = useState(false);
|
||||||
|
const handleGroupSelect = (groupId: string | string[] | null) => {
|
||||||
|
formRef.current?.setFieldsValue({ [ZoneFormField.AreaId]: groupId });
|
||||||
|
};
|
||||||
|
const selectedGroupIds = Form.useWatch(
|
||||||
|
ZoneFormField.AreaId,
|
||||||
|
formRef.current || undefined,
|
||||||
|
) as string | string[] | null | undefined;
|
||||||
|
const conditionData = Form.useWatch(
|
||||||
|
ZoneFormField.AreaConditions,
|
||||||
|
formRef.current || undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConditionsClose = (indexToRemove: number) => {
|
||||||
|
formRef.current?.setFieldValue(
|
||||||
|
ZoneFormField.AreaConditions,
|
||||||
|
conditionData?.filter((_, index) => index !== indexToRemove),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row gutter={16}>
|
||||||
|
{/* Tên */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<ProFormText
|
||||||
|
name={ZoneFormField.AreaName}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'common.name',
|
||||||
|
defaultMessage: 'Tên',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.form.name.required.message',
|
||||||
|
defaultMessage: 'Tên khu vực không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Loại */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<ProFormSelect
|
||||||
|
name={ZoneFormField.AreaType}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'common.type',
|
||||||
|
defaultMessage: 'Loại',
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.form.area_type.required.message',
|
||||||
|
defaultMessage: 'Loại khu vực không được để trống',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.form.area_type.placeholder',
|
||||||
|
defaultMessage: 'Chọn loại khu vực',
|
||||||
|
})}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.area.fishing_ban',
|
||||||
|
defaultMessage: 'Cấm đánh bắt',
|
||||||
|
}),
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.area.move_ban',
|
||||||
|
defaultMessage: 'Cấm di chuyển',
|
||||||
|
}),
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.area.safe',
|
||||||
|
defaultMessage: 'Vùng an toàn',
|
||||||
|
}),
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
{/* Tỉnh */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<ProForm.Item
|
||||||
|
name={ZoneFormField.AreaId}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'common.province',
|
||||||
|
defaultMessage: 'Tỉnh',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.form.province.required.message',
|
||||||
|
defaultMessage: 'Tỉnh quản lý không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TreeSelectedGroup
|
||||||
|
groupIds={selectedGroupIds ?? ''}
|
||||||
|
onSelected={handleGroupSelect}
|
||||||
|
/>
|
||||||
|
</ProForm.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Có hiệu lực */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<ProFormSwitch
|
||||||
|
name={ZoneFormField.AreaEnabled}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.is_enable',
|
||||||
|
defaultMessage: 'Có hiệu lực',
|
||||||
|
})}
|
||||||
|
valuePropName="checked"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<ProFormTextArea
|
||||||
|
name={ZoneFormField.AreaDescription}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'common.description',
|
||||||
|
defaultMessage: 'Mô tả',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.form.description.placeholder',
|
||||||
|
defaultMessage: 'Nhập mô tả khu vực',
|
||||||
|
})}
|
||||||
|
autoSize={{ minRows: 3, maxRows: 5 }}
|
||||||
|
/>
|
||||||
|
<Form.Item
|
||||||
|
name={ZoneFormField.AreaConditions}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.condition',
|
||||||
|
defaultMessage: 'Điều kiện',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Flex gap="10px 4px" wrap>
|
||||||
|
{(conditionData || []).map((condition, index) => {
|
||||||
|
// console.log("Condition: ", condition);
|
||||||
|
|
||||||
|
let tootip = '';
|
||||||
|
let label = '';
|
||||||
|
|
||||||
|
const { type } = condition;
|
||||||
|
switch (type) {
|
||||||
|
case 'month_range': {
|
||||||
|
label = intl.formatMessage({
|
||||||
|
id: 'banzone.condition.yearly',
|
||||||
|
defaultMessage: 'Hàng năm',
|
||||||
|
});
|
||||||
|
const fromMonth = condition.from + 1;
|
||||||
|
const toMonth = condition.to + 1;
|
||||||
|
tootip = `Tháng từ ${fromMonth} đến tháng ${toMonth}`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'date_range': {
|
||||||
|
label = intl.formatMessage({
|
||||||
|
id: 'banzone.condition.specific_time',
|
||||||
|
defaultMessage: 'Thời gian cụ thể',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromDate = dayjs(condition.from).format('DD/MM/YYYY');
|
||||||
|
const toDate = dayjs(condition.to).format('DD/MM/YYYY');
|
||||||
|
tootip = `Từ ${fromDate} đến ${toDate}`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'length_limit':
|
||||||
|
label = intl.formatMessage({
|
||||||
|
id: 'banzone.condition.length_limit',
|
||||||
|
defaultMessage: 'Chiều dài cho phép',
|
||||||
|
});
|
||||||
|
tootip = `Chiều dài tàu từ ${condition.min} đến ${condition.max} mét`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
label = intl.formatMessage({
|
||||||
|
id: 'common.undefined',
|
||||||
|
defaultMessage: 'Không xác định',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={tootip} key={index}>
|
||||||
|
<Tag
|
||||||
|
closable
|
||||||
|
onClick={() => setIsConditionModalOpen(true)}
|
||||||
|
onClose={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleConditionsClose(index);
|
||||||
|
}}
|
||||||
|
color={
|
||||||
|
type === 'month_range'
|
||||||
|
? 'blue'
|
||||||
|
: type === 'date_range'
|
||||||
|
? 'green'
|
||||||
|
: 'volcano'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
style={tagPlusStyle}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setIsConditionModalOpen(true)}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.condition.add"
|
||||||
|
defaultMessage="Thêm điều kiện"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Form.Item>
|
||||||
|
<GeometryForm shape={shape} form={formRef} />
|
||||||
|
<AddConditionForm
|
||||||
|
isVisible={isConditionModalOpen}
|
||||||
|
setVisible={setIsConditionModalOpen}
|
||||||
|
initialData={conditionData}
|
||||||
|
onFinish={(newConditions) => {
|
||||||
|
try {
|
||||||
|
formRef.current?.setFieldValue(
|
||||||
|
ZoneFormField.AreaConditions,
|
||||||
|
newConditions,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error setting form value:', e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZoneForm;
|
||||||