Compare commits
2 Commits
ea07d0c99e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dca363275e | ||
| 9bc15192ec |
@@ -2,6 +2,7 @@ import { defineConfig } from '@umijs/max';
|
|||||||
import {
|
import {
|
||||||
alarmsRoute,
|
alarmsRoute,
|
||||||
commonManagerRoutes,
|
commonManagerRoutes,
|
||||||
|
forgotPasswordRoute,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
managerCameraRoute,
|
managerCameraRoute,
|
||||||
managerRouteBase,
|
managerRouteBase,
|
||||||
@@ -16,6 +17,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
loginRoute,
|
loginRoute,
|
||||||
|
forgotPasswordRoute,
|
||||||
alarmsRoute,
|
alarmsRoute,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -113,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,
|
||||||
@@ -129,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,7 +104,7 @@ 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: {
|
||||||
@@ -96,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',
|
||||||
|
|||||||
2
package-lock.json
generated
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
BIN
public/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 518 KiB |
85
src/app.tsx
85
src/app.tsx
@@ -11,17 +11,20 @@ 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 = {
|
||||||
@@ -30,27 +33,62 @@ export type InitialStateResponse = {
|
|||||||
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;
|
||||||
|
|
||||||
dayjs.locale(getLocale() === 'en-US' ? 'en' : 'vi');
|
// Public routes that don't require authentication
|
||||||
if (!userToken) {
|
if (publicRoutes.includes(pathname)) {
|
||||||
if (pathname !== ROUTE_LOGIN) {
|
const currentTheme =
|
||||||
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
|
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
|
||||||
} else {
|
return {
|
||||||
history.push(ROUTE_LOGIN);
|
theme: currentTheme,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dayjs.locale(getLocale() === 'en-US' ? 'en' : 'vi');
|
||||||
|
if (!refreshToken) {
|
||||||
|
handleBackToLogin();
|
||||||
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_qeg2471go4g.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 />,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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_qeg2471go4g.js',
|
scriptUrl: '//at.alicdn.com/t/c/font_5096559_919jssa0os9.js',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default IconFont;
|
export default IconFont;
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ const DeviceAlarmList = ({ thingId }: DeviceAlarmListProps) => {
|
|||||||
icon={<CheckOutlined />}
|
icon={<CheckOutlined />}
|
||||||
type="dashed"
|
type="dashed"
|
||||||
className="green-btn"
|
className="green-btn"
|
||||||
style={{ color: 'green', borderColor: 'green' }}
|
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => setAlarmConfirmed(entity)}
|
onClick={() => setAlarmConfirmed(entity)}
|
||||||
></Button>
|
></Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ButtonProps } from 'antd';
|
import type { ButtonProps } from 'antd';
|
||||||
import { Button, Tooltip } from 'antd';
|
import { Button, theme, Tooltip } from 'antd';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import IconFont from '../IconFont';
|
import IconFont from '../IconFont';
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ const TooltipIconFontButton: React.FC<TooltipIconFontButtonProps> = ({
|
|||||||
onClick,
|
onClick,
|
||||||
...buttonProps
|
...buttonProps
|
||||||
}) => {
|
}) => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
const wrapperClassName = `tooltip-iconfont-wrapper-${color?.replace(
|
const wrapperClassName = `tooltip-iconfont-wrapper-${color?.replace(
|
||||||
/[^a-zA-Z0-9]/g,
|
/[^a-zA-Z0-9]/g,
|
||||||
'-',
|
'-',
|
||||||
@@ -24,7 +25,7 @@ const TooltipIconFontButton: React.FC<TooltipIconFontButtonProps> = ({
|
|||||||
const icon = (
|
const icon = (
|
||||||
<IconFont
|
<IconFont
|
||||||
type={iconFontName}
|
type={iconFontName}
|
||||||
style={{ color: color || 'black' }}
|
style={{ color: color || token.colorText }}
|
||||||
className={wrapperClassName}
|
className={wrapperClassName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -21,4 +23,5 @@ 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';
|
||||||
|
|||||||
@@ -19,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,4 +1,5 @@
|
|||||||
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';
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default {
|
|||||||
'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ị',
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
421
src/pages/Auth/ForgotPassword/index.tsx
Normal file
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':
|
// Listen for theme changes from ThemeSwitcherAuth
|
||||||
return 'gms.title';
|
useEffect(() => {
|
||||||
case 'sgw':
|
const handleThemeChange = (e: Event) => {
|
||||||
return 'sgw.title';
|
const customEvent = e as CustomEvent<{ theme: 'light' | 'dark' }>;
|
||||||
case 'spole':
|
setIsDark(customEvent.detail.theme === 'dark');
|
||||||
return 'spole.title';
|
|
||||||
default:
|
|
||||||
return 'Smatec Master';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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,15 +118,17 @@ 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') {
|
||||||
|
try {
|
||||||
const resp = await apiLogin({
|
const resp = await apiLogin({
|
||||||
guid: getBrowserId(),
|
guid: getBrowserId(),
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
if (resp?.token) {
|
if (resp?.token) {
|
||||||
setToken(resp.token);
|
setAccessToken(resp.token);
|
||||||
|
setRefreshToken(resp.refresh_token);
|
||||||
const userInfo = await apiQueryProfile();
|
const userInfo = await apiQueryProfile();
|
||||||
if (userInfo) {
|
if (userInfo) {
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
@@ -93,15 +147,48 @@ const LoginPage = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', 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!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'white',
|
backgroundColor: isDark ? '#000' : 'white',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{contextHolder}
|
||||||
<LoginFormPage
|
<LoginFormPage
|
||||||
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
|
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
|
||||||
logo={getLogoImage()}
|
logo={getLogoImage()}
|
||||||
@@ -121,9 +208,15 @@ const LoginPage = () => {
|
|||||||
subTitle={<Image preview={false} src={mobifontLogo} />}
|
subTitle={<Image preview={false} src={mobifontLogo} />}
|
||||||
submitter={{
|
submitter={{
|
||||||
searchConfig: {
|
searchConfig: {
|
||||||
submitText: intl.formatMessage({
|
submitText:
|
||||||
|
loginType === 'login'
|
||||||
|
? intl.formatMessage({
|
||||||
id: 'master.auth.login.title',
|
id: 'master.auth.login.title',
|
||||||
defaultMessage: 'Đăng nhập',
|
defaultMessage: 'Đăng nhập',
|
||||||
|
})
|
||||||
|
: intl.formatMessage({
|
||||||
|
id: 'master.auth.forgot.button.title',
|
||||||
|
defaultMessage: 'Đăng nhập',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -131,6 +224,8 @@ const LoginPage = () => {
|
|||||||
handleLogin(values)
|
handleLogin(values)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<FormWrapper key={loginType}>
|
||||||
|
{loginType === 'login' && (
|
||||||
<>
|
<>
|
||||||
<ProFormText
|
<ProFormText
|
||||||
name="email"
|
name="email"
|
||||||
@@ -140,9 +235,9 @@ const LoginPage = () => {
|
|||||||
size: 'large',
|
size: 'large',
|
||||||
prefix: (
|
prefix: (
|
||||||
<UserOutlined
|
<UserOutlined
|
||||||
style={{
|
// style={{
|
||||||
color: token.colorText,
|
// color: token.colorText,
|
||||||
}}
|
// }}
|
||||||
className={'prefixIcon'}
|
className={'prefixIcon'}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -154,10 +249,21 @@ const LoginPage = () => {
|
|||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: intl.formatMessage({
|
message: (
|
||||||
id: 'master.auth.validation.email',
|
<FormattedMessage
|
||||||
defaultMessage: 'Email không được để trống!',
|
id="master.users.email.required"
|
||||||
}),
|
defaultMessage="The email is required"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'email',
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id="master.users.email.invalid"
|
||||||
|
defaultMessage="Invalid email address"
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -166,14 +272,7 @@ const LoginPage = () => {
|
|||||||
fieldProps={{
|
fieldProps={{
|
||||||
size: 'large',
|
size: 'large',
|
||||||
autoComplete: 'current-password',
|
autoComplete: 'current-password',
|
||||||
prefix: (
|
prefix: <LockOutlined className={'prefixIcon'} />,
|
||||||
<LockOutlined
|
|
||||||
style={{
|
|
||||||
color: token.colorText,
|
|
||||||
}}
|
|
||||||
className={'prefixIcon'}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
placeholder={intl.formatMessage({
|
placeholder={intl.formatMessage({
|
||||||
id: 'master.auth.password',
|
id: 'master.auth.password',
|
||||||
@@ -190,6 +289,74 @@ const LoginPage = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
{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>
|
</LoginFormPage>
|
||||||
<div className="absolute top-5 right-5 z-50 flex gap-4">
|
<div className="absolute top-5 right-5 z-50 flex gap-4">
|
||||||
<ThemeSwitcherAuth />
|
<ThemeSwitcherAuth />
|
||||||
@@ -207,6 +374,7 @@ const LoginPage = () => {
|
|||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default LoginPage;
|
export default LoginPage;
|
||||||
|
|||||||
174
src/pages/Manager/Device/Camera/components/CameraFormModal.tsx
Normal file
174
src/pages/Manager/Device/Camera/components/CameraFormModal.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
} from 'antd';
|
||||||
|
|
||||||
|
// Camera types
|
||||||
|
const CAMERA_TYPES = [
|
||||||
|
{ label: 'HIKVISION', value: 'HIKVISION' },
|
||||||
|
{ label: 'DAHUA', value: 'DAHUA' },
|
||||||
|
{ label: 'GENERIC', value: 'GENERIC' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CameraFormValues {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
account: string;
|
||||||
|
password: string;
|
||||||
|
ipAddress: string;
|
||||||
|
rtspPort: number;
|
||||||
|
httpPort: number;
|
||||||
|
stream: number;
|
||||||
|
channel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CameraFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (values: CameraFormValues) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
||||||
|
open,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm<CameraFormValues>();
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
onSubmit(values);
|
||||||
|
form.resetFields();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Validation failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
form.resetFields();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Tạo mới camera"
|
||||||
|
open={open}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={handleCancel}>
|
||||||
|
Hủy
|
||||||
|
</Button>,
|
||||||
|
<Button key="submit" type="primary" onClick={handleSubmit}>
|
||||||
|
Đồng ý
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
type: 'HIKVISION',
|
||||||
|
rtspPort: 554,
|
||||||
|
httpPort: 80,
|
||||||
|
stream: 0,
|
||||||
|
channel: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="Tên"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="nhập dữ liệu" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Loại"
|
||||||
|
name="type"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng chọn loại' }]}
|
||||||
|
>
|
||||||
|
<Select options={CAMERA_TYPES} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Tài khoản"
|
||||||
|
name="account"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập tài khoản' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="nhập tài khoản" autoComplete="off" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Mật khẩu"
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập mật khẩu' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
placeholder="nhập mật khẩu"
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Địa chỉ IP"
|
||||||
|
name="ipAddress"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập địa chỉ IP' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="192.168.1.10" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Cổng RTSP"
|
||||||
|
name="rtspPort"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập cổng RTSP' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Cổng HTTP"
|
||||||
|
name="httpPort"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập cổng HTTP' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Luồng"
|
||||||
|
name="stream"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập luồng' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Kênh"
|
||||||
|
name="channel"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập kênh' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CameraFormModal;
|
||||||
106
src/pages/Manager/Device/Camera/components/CameraTable.tsx
Normal file
106
src/pages/Manager/Device/Camera/components/CameraTable.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { Button, Card, Checkbox, Space, Table, theme } from 'antd';
|
||||||
|
|
||||||
|
interface CameraTableProps {
|
||||||
|
cameraData: MasterModel.Camera[] | null;
|
||||||
|
onCreateCamera: () => void;
|
||||||
|
onReload?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CameraTable: React.FC<CameraTableProps> = ({
|
||||||
|
cameraData,
|
||||||
|
onCreateCamera,
|
||||||
|
onReload,
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const handleReload = () => {
|
||||||
|
console.log('Reload cameras');
|
||||||
|
onReload?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
console.log('Delete selected cameras');
|
||||||
|
// TODO: Implement delete functionality
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (camera: MasterModel.Camera) => {
|
||||||
|
console.log('Edit camera:', camera);
|
||||||
|
// TODO: Implement edit functionality
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'checkbox',
|
||||||
|
width: 50,
|
||||||
|
render: () => <Checkbox />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tên',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (text: string) => (
|
||||||
|
<a style={{ color: token.colorPrimary }}>{text || '-'}</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Loại',
|
||||||
|
dataIndex: 'cate_id',
|
||||||
|
key: 'cate_id',
|
||||||
|
render: (text: string) => text || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Địa chỉ IP',
|
||||||
|
dataIndex: 'ip',
|
||||||
|
key: 'ip',
|
||||||
|
render: (text: string) => text || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Thao tác',
|
||||||
|
key: 'action',
|
||||||
|
render: (_: any, record: MasterModel.Camera) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card bodyStyle={{ padding: 16 }}>
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateCamera}>
|
||||||
|
Tạo mới camera
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={handleReload} />
|
||||||
|
<Button icon={<DeleteOutlined />} onClick={handleDelete} />
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={cameraData || []}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
size: 'small',
|
||||||
|
showTotal: (total: number, range: [number, number]) =>
|
||||||
|
`Hiển thị ${range[0]}-${range[1]} của ${total} camera`,
|
||||||
|
pageSize: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CameraTable;
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Button, Card, Select, Typography } from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// Recording modes for V5 - chỉ có không ghi và ghi 24/24
|
||||||
|
const RECORDING_MODES = [
|
||||||
|
{ label: 'Không ghi', value: 'none' },
|
||||||
|
{ label: 'Ghi 24/24', value: '24/7' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CameraV5Props {
|
||||||
|
thing: MasterModel.Thing | null;
|
||||||
|
initialRecordingMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CameraV5: React.FC<CameraV5Props> = ({
|
||||||
|
thing,
|
||||||
|
initialRecordingMode = 'none',
|
||||||
|
}) => {
|
||||||
|
const [recordingMode, setRecordingMode] = useState(initialRecordingMode);
|
||||||
|
|
||||||
|
console.log('ConfigCameraV5 - thing:', thing);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
console.log('Submit recording mode:', recordingMode);
|
||||||
|
// TODO: Call API to save recording configuration
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card bodyStyle={{ padding: 16 }}>
|
||||||
|
{/* Recording Mode */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
Ghi dữ liệu camera
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={recordingMode}
|
||||||
|
onChange={setRecordingMode}
|
||||||
|
options={RECORDING_MODES}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Button type="primary" onClick={handleSubmit}>
|
||||||
|
Gửi đi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CameraV5;
|
||||||
192
src/pages/Manager/Device/Camera/components/ConfigCameraV6.tsx
Normal file
192
src/pages/Manager/Device/Camera/components/ConfigCameraV6.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { apiQueryConfigAlarm } from '@/services/master/MessageController';
|
||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
import { Button, Card, Col, Row, Select, theme, Typography } from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// Recording modes for V6
|
||||||
|
const RECORDING_MODES = [
|
||||||
|
{ label: 'Không ghi', value: 'none' },
|
||||||
|
{ label: 'Theo cảnh báo', value: 'alarm' },
|
||||||
|
{ label: '24/24', value: 'all' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CameraV6Props {
|
||||||
|
thing: MasterModel.Thing | null;
|
||||||
|
cameraConfig?: MasterModel.CameraV6 | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const { initialState } = useModel('@@initialState');
|
||||||
|
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
|
||||||
|
const [recordingMode, setRecordingMode] = useState<'none' | 'alarm' | 'all'>(
|
||||||
|
'none',
|
||||||
|
);
|
||||||
|
const [alarmConfig, setAlarmConfig] = useState<MasterModel.Alarm[] | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initialize states from cameraConfig when it's available
|
||||||
|
useEffect(() => {
|
||||||
|
if (cameraConfig) {
|
||||||
|
// Set recording mode from config
|
||||||
|
if (cameraConfig.record_type) {
|
||||||
|
setRecordingMode(cameraConfig.record_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set selected alerts from config
|
||||||
|
if (
|
||||||
|
cameraConfig.record_alarm_list &&
|
||||||
|
Array.isArray(cameraConfig.record_alarm_list)
|
||||||
|
) {
|
||||||
|
setSelectedAlerts(cameraConfig.record_alarm_list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [cameraConfig]);
|
||||||
|
|
||||||
|
// Fetch alarm config when thing data is available and recording mode is 'alarm'
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAlarmConfig = async () => {
|
||||||
|
if (
|
||||||
|
!thing ||
|
||||||
|
!initialState?.currentUserProfile?.metadata?.frontend_thing_key ||
|
||||||
|
recordingMode !== 'alarm'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await apiQueryConfigAlarm(
|
||||||
|
thing.metadata?.data_channel_id || '',
|
||||||
|
initialState.currentUserProfile.metadata.frontend_thing_key,
|
||||||
|
{
|
||||||
|
offset: 0,
|
||||||
|
limit: 1,
|
||||||
|
subtopic: `config.${thing.metadata?.type}.alarms`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (resp.messages && resp.messages.length > 0) {
|
||||||
|
const parsed = resp.messages[0].string_value_parsed;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
setAlarmConfig(parsed as MasterModel.Alarm[]);
|
||||||
|
} else {
|
||||||
|
setAlarmConfig([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch alarm config:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAlarmConfig();
|
||||||
|
}, [thing, initialState, recordingMode]);
|
||||||
|
|
||||||
|
const handleAlertToggle = (alertId: string) => {
|
||||||
|
if (selectedAlerts.includes(alertId)) {
|
||||||
|
setSelectedAlerts(selectedAlerts.filter((id) => id !== alertId));
|
||||||
|
} else {
|
||||||
|
setSelectedAlerts([...selectedAlerts, alertId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAlerts = () => {
|
||||||
|
setSelectedAlerts([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitAlerts = () => {
|
||||||
|
console.log('Submit alerts:', {
|
||||||
|
recordingMode,
|
||||||
|
selectedAlerts,
|
||||||
|
});
|
||||||
|
// TODO: Call API to save alert configuration
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
{/* Recording Mode */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Text strong className="block mb-2">
|
||||||
|
Ghi dữ liệu camera
|
||||||
|
</Text>
|
||||||
|
<div className="flex gap-8 items-center">
|
||||||
|
<Select
|
||||||
|
value={recordingMode}
|
||||||
|
onChange={setRecordingMode}
|
||||||
|
options={RECORDING_MODES}
|
||||||
|
className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4"
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={handleSubmitAlerts}>
|
||||||
|
Gửi đi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alert List - Only show when mode is 'alarm' */}
|
||||||
|
{recordingMode === 'alarm' && (
|
||||||
|
<div>
|
||||||
|
<Text strong className="block mb-2">
|
||||||
|
Danh sách cảnh báo
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center mb-4 px-3 py-2 rounded border"
|
||||||
|
style={{
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderColor: token.colorBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary">đã chọn {selectedAlerts.length} mục</Text>
|
||||||
|
<Button type="link" onClick={handleClearAlerts}>
|
||||||
|
Xóa
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alert Cards Grid */}
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
{alarmConfig?.map((alarm) => {
|
||||||
|
const alarmId = alarm.id ?? '';
|
||||||
|
const isSelected =
|
||||||
|
alarmId !== '' && selectedAlerts.includes(alarmId);
|
||||||
|
return (
|
||||||
|
<Col xs={12} sm={8} md={6} lg={4} xl={4} key={alarmId}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
hoverable
|
||||||
|
onClick={() => handleAlertToggle(alarmId)}
|
||||||
|
className="cursor-pointer h-20 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
borderColor: isSelected
|
||||||
|
? token.colorPrimary
|
||||||
|
: token.colorBorder,
|
||||||
|
borderWidth: isSelected ? 2 : 1,
|
||||||
|
background: isSelected
|
||||||
|
? token.colorPrimaryBg
|
||||||
|
: token.colorBgContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-2 text-center w-full">
|
||||||
|
<Text
|
||||||
|
className="text-xs break-words"
|
||||||
|
style={{
|
||||||
|
color: isSelected
|
||||||
|
? token.colorPrimary
|
||||||
|
: token.colorText,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alarm.name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CameraV6;
|
||||||
@@ -1,120 +1,34 @@
|
|||||||
|
import { apiQueryCamera } from '@/services/master/MessageController';
|
||||||
import { apiGetThingDetail } from '@/services/master/ThingController';
|
import { apiGetThingDetail } from '@/services/master/ThingController';
|
||||||
import { wsClient } from '@/utils/wsClient';
|
import { wsClient } from '@/utils/wsClient';
|
||||||
import {
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
ArrowLeftOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { PageContainer } from '@ant-design/pro-components';
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
import { history, useParams } from '@umijs/max';
|
import { history, useModel, useParams } from '@umijs/max';
|
||||||
import {
|
import { Button, Col, Row, Space, Spin } from 'antd';
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Checkbox,
|
|
||||||
Col,
|
|
||||||
Form,
|
|
||||||
Input,
|
|
||||||
InputNumber,
|
|
||||||
Modal,
|
|
||||||
Row,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Spin,
|
|
||||||
Table,
|
|
||||||
theme,
|
|
||||||
Typography,
|
|
||||||
} from 'antd';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import CameraFormModal from './components/CameraFormModal';
|
||||||
const { Text } = Typography;
|
import CameraTable from './components/CameraTable';
|
||||||
|
import ConfigCameraV5 from './components/ConfigCameraV5';
|
||||||
// Camera types
|
import ConfigCameraV6 from './components/ConfigCameraV6';
|
||||||
const CAMERA_TYPES = [
|
|
||||||
{ label: 'HIKVISION', value: 'HIKVISION' },
|
|
||||||
{ label: 'DAHUA', value: 'DAHUA' },
|
|
||||||
{ label: 'GENERIC', value: 'GENERIC' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Recording modes
|
|
||||||
const RECORDING_MODES = [
|
|
||||||
{ label: 'Theo cảnh báo', value: 'alarm' },
|
|
||||||
{ label: 'Liên tục', value: 'continuous' },
|
|
||||||
{ label: 'Thủ công', value: 'manual' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Alert types for configuration
|
|
||||||
const ALERT_TYPES = [
|
|
||||||
{ id: 'motion', name: 'Chuyển Động có cảnh báo' },
|
|
||||||
{ id: 'smoke', name: 'Khói có cảnh báo' },
|
|
||||||
{ id: 'door', name: 'Cửa có cảnh báo' },
|
|
||||||
{ id: 'ac1_high', name: 'Điện AC 1 cao' },
|
|
||||||
{ id: 'ac1_low', name: 'Điện AC 1 thấp' },
|
|
||||||
{ id: 'ac1_lost', name: 'Điện AC 1 mất' },
|
|
||||||
{ id: 'load_high', name: 'Điện tải cao' },
|
|
||||||
{ id: 'load_low', name: 'Điện tải thấp' },
|
|
||||||
{ id: 'load_lost', name: 'Điện tải mất' },
|
|
||||||
{ id: 'grid_high', name: 'Điện lưới cao' },
|
|
||||||
{ id: 'grid_low', name: 'Điện lưới thấp' },
|
|
||||||
{ id: 'grid_lost', name: 'Điện lưới mất' },
|
|
||||||
{ id: 'ac1_on_error', name: 'Điều hòa 1 bật lỗi' },
|
|
||||||
{ id: 'ac1_off_error', name: 'Điều hòa 1 tắt lỗi' },
|
|
||||||
{ id: 'ac1_has_error', name: 'Điều hòa 1 có thể lỗi' },
|
|
||||||
{ id: 'ac2_on_error', name: 'Điều hòa 2 bật lỗi' },
|
|
||||||
{ id: 'ac2_off_error', name: 'Điều hòa 2 tắt lỗi' },
|
|
||||||
{ id: 'ac2_has_error', name: 'Điều hòa 2 điều hòa có thể lỗi' },
|
|
||||||
{ id: 'room_temp_high', name: 'Nhiệt độ phòng máy nhiệt độ phòng máy cao' },
|
|
||||||
{ id: 'rectifier_error', name: 'Rectifier bật lỗi' },
|
|
||||||
{ id: 'meter_volt_high', name: 'Công tơ điện điện áp cao' },
|
|
||||||
{ id: 'meter_volt_low', name: 'Công tơ điện điện áp thấp' },
|
|
||||||
{ id: 'meter_lost', name: 'Công tơ điện mất điện áp' },
|
|
||||||
{ id: 'lithium_volt_low', name: 'Pin lithium điện áp thấp' },
|
|
||||||
{ id: 'lithium_temp_high', name: 'Pin lithium nhiệt độ cao' },
|
|
||||||
{ id: 'lithium_capacity_low', name: 'Pin lithium dung lượng thấp' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Camera interface
|
|
||||||
interface Camera {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
ipAddress: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CameraFormValues {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
account: string;
|
|
||||||
password: string;
|
|
||||||
ipAddress: string;
|
|
||||||
rtspPort: number;
|
|
||||||
httpPort: number;
|
|
||||||
stream: number;
|
|
||||||
channel: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CameraConfigPage = () => {
|
const CameraConfigPage = () => {
|
||||||
const { thingId } = useParams<{ thingId: string }>();
|
const { thingId } = useParams<{ thingId: string }>();
|
||||||
const { token } = theme.useToken();
|
|
||||||
const [form] = Form.useForm<CameraFormValues>();
|
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
const [cameras, setCameras] = useState<Camera[]>([]);
|
|
||||||
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([
|
|
||||||
'motion',
|
|
||||||
'smoke',
|
|
||||||
'door',
|
|
||||||
]);
|
|
||||||
const [recordingMode, setRecordingMode] = useState('alarm');
|
|
||||||
const [thingName, setThingName] = useState<string>('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [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(() => {
|
useEffect(() => {
|
||||||
wsClient.connect('/mqtt', false);
|
wsClient.connect('/mqtt', false);
|
||||||
const unsubscribe = wsClient.subscribe((data: any) => {
|
const unsubscribe = wsClient.subscribe((data: any) => {
|
||||||
console.log('Received WS data:', data);
|
console.log('Received WS data:', data);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
@@ -124,110 +38,90 @@ const CameraConfigPage = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchThingInfo = async () => {
|
const fetchThingInfo = async () => {
|
||||||
if (!thingId) return;
|
if (!thingId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const thing = await apiGetThingDetail(thingId);
|
const thingData = await apiGetThingDetail(thingId);
|
||||||
setThingName(thing.name || thingId);
|
setThing(thingData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch thing info:', error);
|
console.error('Failed to fetch thing info:', error);
|
||||||
setThingName(thingId);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchThingInfo();
|
fetchThingInfo();
|
||||||
}, [thingId]);
|
}, [thingId]);
|
||||||
|
|
||||||
const handleBack = () => {
|
// Fetch camera config when thing data is available
|
||||||
history.push('/manager/devices');
|
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 = () => {
|
const handleOpenModal = () => {
|
||||||
form.resetFields();
|
|
||||||
form.setFieldsValue({
|
|
||||||
type: 'HIKVISION',
|
|
||||||
rtspPort: 554,
|
|
||||||
httpPort: 80,
|
|
||||||
stream: 0,
|
|
||||||
channel: 0,
|
|
||||||
});
|
|
||||||
setIsModalVisible(true);
|
setIsModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
const handleCloseModal = () => {
|
||||||
setIsModalVisible(false);
|
setIsModalVisible(false);
|
||||||
form.resetFields();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitCamera = async () => {
|
const handleSubmitCamera = (values: any) => {
|
||||||
try {
|
|
||||||
const values = await form.validateFields();
|
|
||||||
console.log('Camera values:', values);
|
console.log('Camera values:', values);
|
||||||
// TODO: Call API to create camera
|
// TODO: Call API to create camera
|
||||||
setCameras([
|
|
||||||
...cameras,
|
|
||||||
{
|
|
||||||
id: String(cameras.length + 1),
|
|
||||||
name: values.name,
|
|
||||||
type: values.type,
|
|
||||||
ipAddress: values.ipAddress,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
} catch (error) {
|
};
|
||||||
console.error('Validation failed:', error);
|
|
||||||
|
// Helper function to determine which camera component to render
|
||||||
|
const renderCameraRecordingComponent = () => {
|
||||||
|
const thingType = thing?.metadata?.type;
|
||||||
|
|
||||||
|
if (thingType === 'gmsv5') {
|
||||||
|
return <ConfigCameraV5 thing={thing} />;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleAlertToggle = (alertId: string) => {
|
if (thingType === 'spole' || thingType === 'gmsv6') {
|
||||||
if (selectedAlerts.includes(alertId)) {
|
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
|
||||||
setSelectedAlerts(selectedAlerts.filter((id) => id !== alertId));
|
|
||||||
} else {
|
|
||||||
setSelectedAlerts([...selectedAlerts, alertId]);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearAlerts = () => {
|
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
|
||||||
setSelectedAlerts([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitAlerts = () => {
|
|
||||||
console.log('Submit alerts:', selectedAlerts);
|
|
||||||
// TODO: Call API to save alert configuration
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
dataIndex: 'checkbox',
|
|
||||||
width: 50,
|
|
||||||
render: () => <Checkbox />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Tên',
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
render: (text: string) => (
|
|
||||||
<a style={{ color: token.colorPrimary }}>{text}</a>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Loại',
|
|
||||||
dataIndex: 'type',
|
|
||||||
key: 'type',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Địa chỉ IP',
|
|
||||||
dataIndex: 'ipAddress',
|
|
||||||
key: 'ipAddress',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Thao tác',
|
|
||||||
key: 'action',
|
|
||||||
render: () => <Button size="small" icon={<EditOutlined />} />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
<PageContainer
|
<PageContainer
|
||||||
@@ -237,9 +131,9 @@ const CameraConfigPage = () => {
|
|||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
onClick={handleBack}
|
onClick={() => history.push('/manager/devices')}
|
||||||
/>
|
/>
|
||||||
<span>{thingName || 'Loading...'}</span>
|
<span>{thing?.name || 'Loading...'}</span>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@@ -247,248 +141,26 @@ const CameraConfigPage = () => {
|
|||||||
<Row gutter={24}>
|
<Row gutter={24}>
|
||||||
{/* Left Column - Camera Table */}
|
{/* Left Column - Camera Table */}
|
||||||
<Col xs={24} md={10} lg={8}>
|
<Col xs={24} md={10} lg={8}>
|
||||||
<Card bodyStyle={{ padding: 16 }}>
|
<CameraTable
|
||||||
<Space style={{ marginBottom: 16 }}>
|
cameraData={cameras}
|
||||||
<Button
|
onCreateCamera={handleOpenModal}
|
||||||
type="primary"
|
onReload={fetchCameraConfig}
|
||||||
icon={<PlusOutlined />}
|
loading={cameraLoading}
|
||||||
onClick={handleOpenModal}
|
|
||||||
>
|
|
||||||
Tạo mới camera
|
|
||||||
</Button>
|
|
||||||
<Button icon={<ReloadOutlined />} />
|
|
||||||
<Button icon={<SettingOutlined />} />
|
|
||||||
<Button icon={<DeleteOutlined />} />
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Table
|
|
||||||
dataSource={cameras}
|
|
||||||
columns={columns}
|
|
||||||
rowKey="id"
|
|
||||||
size="small"
|
|
||||||
pagination={{
|
|
||||||
size: 'small',
|
|
||||||
showTotal: (total, range) =>
|
|
||||||
`${range[0]}-${range[1]} trên ${total} mặt hàng`,
|
|
||||||
pageSize: 10,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{/* Right Column - Alert Configuration */}
|
{/* Right Column - Camera Recording Configuration */}
|
||||||
<Col xs={24} md={14} lg={16}>
|
<Col xs={24} md={14} lg={16}>
|
||||||
<Card bodyStyle={{ padding: 16 }}>
|
{renderCameraRecordingComponent()}
|
||||||
{/* Recording Mode */}
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
|
||||||
Ghi dữ liệu camera
|
|
||||||
</Text>
|
|
||||||
<Select
|
|
||||||
value={recordingMode}
|
|
||||||
onChange={setRecordingMode}
|
|
||||||
options={RECORDING_MODES}
|
|
||||||
style={{ width: 200 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alert List */}
|
|
||||||
<div>
|
|
||||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
|
||||||
Danh sách cảnh báo
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
padding: '8px 12px',
|
|
||||||
background: token.colorBgContainer,
|
|
||||||
borderRadius: token.borderRadius,
|
|
||||||
border: `1px solid ${token.colorBorder}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text type="secondary">
|
|
||||||
đã chọn {selectedAlerts.length} mục
|
|
||||||
</Text>
|
|
||||||
<Button type="link" onClick={handleClearAlerts}>
|
|
||||||
Xóa
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alert Cards Grid */}
|
|
||||||
<Row gutter={[12, 12]}>
|
|
||||||
{ALERT_TYPES.map((alert) => {
|
|
||||||
const isSelected = selectedAlerts.includes(alert.id);
|
|
||||||
return (
|
|
||||||
<Col xs={12} sm={8} md={6} lg={4} xl={4} key={alert.id}>
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
hoverable
|
|
||||||
onClick={() => handleAlertToggle(alert.id)}
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderColor: isSelected
|
|
||||||
? token.colorPrimary
|
|
||||||
: token.colorBorder,
|
|
||||||
borderWidth: isSelected ? 2 : 1,
|
|
||||||
background: isSelected
|
|
||||||
? token.colorPrimaryBg
|
|
||||||
: token.colorBgContainer,
|
|
||||||
height: 80,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
bodyStyle={{
|
|
||||||
padding: 8,
|
|
||||||
textAlign: 'center',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: isSelected
|
|
||||||
? token.colorPrimary
|
|
||||||
: token.colorText,
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{alert.name}
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div style={{ marginTop: 24, textAlign: 'center' }}>
|
|
||||||
<Button type="primary" onClick={handleSubmitAlerts}>
|
|
||||||
Gửi đi
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Create Camera Modal */}
|
{/* Create Camera Modal */}
|
||||||
<Modal
|
<CameraFormModal
|
||||||
title="Tạo mới"
|
|
||||||
open={isModalVisible}
|
open={isModalVisible}
|
||||||
onCancel={handleCloseModal}
|
onCancel={handleCloseModal}
|
||||||
footer={[
|
onSubmit={handleSubmitCamera}
|
||||||
<Button key="cancel" onClick={handleCloseModal}>
|
/>
|
||||||
Hủy
|
|
||||||
</Button>,
|
|
||||||
<Button key="submit" type="primary" onClick={handleSubmitCamera}>
|
|
||||||
Đồng ý
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
width={500}
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
layout="vertical"
|
|
||||||
initialValues={{
|
|
||||||
type: 'HIKVISION',
|
|
||||||
rtspPort: 554,
|
|
||||||
httpPort: 80,
|
|
||||||
stream: 0,
|
|
||||||
channel: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Form.Item
|
|
||||||
label="Tên"
|
|
||||||
name="name"
|
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="nhập dữ liệu" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label="Loại"
|
|
||||||
name="type"
|
|
||||||
rules={[{ required: true, message: 'Vui lòng chọn loại' }]}
|
|
||||||
>
|
|
||||||
<Select options={CAMERA_TYPES} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label="Tài khoản"
|
|
||||||
name="account"
|
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập tài khoản' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="nhập tài khoản" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label="Mật khẩu"
|
|
||||||
name="password"
|
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập mật khẩu' }]}
|
|
||||||
>
|
|
||||||
<Input.Password placeholder="nhập mật khẩu" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label="Địa chỉ IP"
|
|
||||||
name="ipAddress"
|
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập địa chỉ IP' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="192.168.1.10" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
label="Cổng RTSP"
|
|
||||||
name="rtspPort"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: 'Vui lòng nhập cổng RTSP' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
label="Cổng HTTP"
|
|
||||||
name="httpPort"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: 'Vui lòng nhập cổng HTTP' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<Row gutter={16}>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
label="Luồng"
|
|
||||||
name="stream"
|
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập luồng' }]}
|
|
||||||
>
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col span={12}>
|
|
||||||
<Form.Item
|
|
||||||
label="Kênh"
|
|
||||||
name="channel"
|
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập kênh' }]}
|
|
||||||
>
|
|
||||||
<InputNumber style={{ width: '100%' }} min={0} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</Spin>
|
</Spin>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import IconFont from '@/components/IconFont';
|
import IconFont from '@/components/IconFont';
|
||||||
import { StatisticCard } from '@ant-design/pro-components';
|
import { StatisticCard } from '@ant-design/pro-components';
|
||||||
import { Flex, GlobalToken, Grid, theme } from 'antd';
|
import {
|
||||||
|
Divider,
|
||||||
|
Flex,
|
||||||
|
GlobalToken,
|
||||||
|
Grid,
|
||||||
|
theme,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
const { Text } = Typography;
|
||||||
type BinarySensorsProps = {
|
type BinarySensorsProps = {
|
||||||
nodeConfigs: MasterModel.NodeConfig[];
|
nodeConfigs: MasterModel.NodeConfig[];
|
||||||
};
|
};
|
||||||
@@ -19,6 +27,7 @@ export const getBinaryEntities = (
|
|||||||
interface IconTypeResult {
|
interface IconTypeResult {
|
||||||
iconType: string;
|
iconType: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getIconTypeByEntity = (
|
const getIconTypeByEntity = (
|
||||||
@@ -27,60 +36,103 @@ const getIconTypeByEntity = (
|
|||||||
): IconTypeResult => {
|
): IconTypeResult => {
|
||||||
if (!entity.config || entity.config.length === 0) {
|
if (!entity.config || entity.config.length === 0) {
|
||||||
return {
|
return {
|
||||||
iconType: 'icon-device-setting',
|
iconType: 'icon-not-found',
|
||||||
color: token.colorPrimary,
|
color: token.colorPrimary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
switch (entity.config[0].subType) {
|
switch (entity.config[0].subType) {
|
||||||
case 'smoke':
|
case 'smoke':
|
||||||
return {
|
return {
|
||||||
iconType: 'icon-fire',
|
iconType: 'icon-smoke1',
|
||||||
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
|
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
|
||||||
|
name: entity.value === 0 ? 'Bình thường' : 'Phát hiện',
|
||||||
};
|
};
|
||||||
case 'heat':
|
case 'heat':
|
||||||
return {
|
return {
|
||||||
iconType: 'icon-fire',
|
iconType: 'icon-fire',
|
||||||
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
|
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
|
||||||
|
name: entity.value === 0 ? 'Bình thường' : 'Phát hiện',
|
||||||
};
|
};
|
||||||
case 'motion':
|
case 'motion':
|
||||||
return {
|
return {
|
||||||
iconType: 'icon-motion',
|
iconType: 'icon-motion',
|
||||||
color: entity.value === 0 ? token.colorTextBase : token.colorInfoActive,
|
color: entity.value === 0 ? token.colorTextBase : token.colorInfoActive,
|
||||||
|
name: entity.value === 0 ? 'Không' : 'Phát hiện',
|
||||||
};
|
};
|
||||||
case 'flood':
|
case 'flood':
|
||||||
return {
|
return {
|
||||||
iconType: 'icon-water-ingress',
|
iconType: 'icon-water-ingress',
|
||||||
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
|
color: entity.value === 0 ? token.colorSuccess : token.colorWarning,
|
||||||
|
name: entity.value === 0 ? 'Không' : 'Phát hiện',
|
||||||
};
|
};
|
||||||
case 'door':
|
case 'door':
|
||||||
return {
|
return {
|
||||||
iconType: entity.value === 0 ? 'icon-door' : 'icon-door-open',
|
iconType: entity.value === 0 ? 'icon-door' : 'icon-door-open1',
|
||||||
color: entity.value === 0 ? token.colorText : token.colorWarning,
|
color: entity.value === 0 ? token.colorText : token.colorWarning,
|
||||||
|
name: entity.value === 0 ? 'Đóng' : 'Mở',
|
||||||
};
|
};
|
||||||
case 'button':
|
case 'button':
|
||||||
return {
|
return {
|
||||||
iconType: 'icon-alarm-button',
|
iconType: 'icon-alarm-button',
|
||||||
color: entity.value === 0 ? token.colorText : token.colorSuccess,
|
color: entity.value === 0 ? token.colorText : token.colorSuccess,
|
||||||
|
name: entity.value === 0 ? 'Tắt' : 'Bật',
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
iconType: 'icon-door',
|
iconType: 'icon-not-found',
|
||||||
color: token.colorPrimary,
|
color: token.colorPrimary,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatisticCardItem = (entity: MasterModel.Entity, token: GlobalToken) => {
|
const StatisticCardItem = (entity: MasterModel.Entity, token: GlobalToken) => {
|
||||||
const { iconType, color } = getIconTypeByEntity(entity, token);
|
const { iconType, color, name } = getIconTypeByEntity(entity, token);
|
||||||
return (
|
return (
|
||||||
<StatisticCard
|
<StatisticCard
|
||||||
|
bordered={false}
|
||||||
key={entity.entityId}
|
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={{
|
statistic={{
|
||||||
title: entity.name,
|
|
||||||
icon: (
|
icon: (
|
||||||
<IconFont type={iconType} style={{ color: color, fontSize: 24 }} />
|
<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>
|
||||||
),
|
),
|
||||||
value: entity.active === 1 ? 'Active' : 'Inactive',
|
title: (
|
||||||
|
<Text
|
||||||
|
ellipsis={{ tooltip: entity.name }}
|
||||||
|
style={{ fontSize: 13, fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{entity.name}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
value: name,
|
||||||
|
valueStyle: { fontSize: 12, color, fontWeight: 600, marginTop: 8 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -93,11 +145,20 @@ const BinarySensors = ({ nodeConfigs }: BinarySensorsProps) => {
|
|||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex wrap="wrap">
|
<Flex wrap="wrap" gap="middle">
|
||||||
<StatisticCard.Group direction={screens.sm ? 'row' : 'column'}>
|
<Divider orientation="left">Cảm biến</Divider>
|
||||||
{binarySensors.map((entity) => StatisticCardItem(entity, token))}
|
{binarySensors.map((entity) => (
|
||||||
</StatisticCard.Group>
|
<div
|
||||||
|
key={entity.entityId}
|
||||||
|
style={{
|
||||||
|
width: 'fit-content',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{StatisticCardItem(entity, token)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { apiQueryNodeConfigMessage } from '@/services/master/MessageController';
|
|||||||
import { apiGetThingDetail } from '@/services/master/ThingController';
|
import { apiGetThingDetail } from '@/services/master/ThingController';
|
||||||
import { PageContainer, ProCard } from '@ant-design/pro-components';
|
import { PageContainer, ProCard } from '@ant-design/pro-components';
|
||||||
import { history, useIntl, useModel, useParams } from '@umijs/max';
|
import { history, useIntl, useModel, useParams } from '@umijs/max';
|
||||||
import { Grid } from 'antd';
|
import { Divider, Flex, Grid } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import BinarySensors from './components/BinarySensors';
|
import BinarySensors from './components/BinarySensors';
|
||||||
import ThingTitle from './components/ThingTitle';
|
import ThingTitle from './components/ThingTitle';
|
||||||
@@ -95,17 +95,23 @@ const DetailDevicePage = () => {
|
|||||||
>
|
>
|
||||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||||
<ProCard
|
<ProCard
|
||||||
|
bodyStyle={{
|
||||||
|
paddingInline: 12,
|
||||||
|
paddingBlock: 8,
|
||||||
|
}}
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
id: 'master.thing.detail.alarmList.title',
|
id: 'master.thing.detail.alarmList.title',
|
||||||
})}
|
})}
|
||||||
colSpan={{ xs: 24, sm: 24, md: 24, lg: 6, xl: 6 }}
|
colSpan={{ xs: 24, sm: 24, md: 24, lg: 6, xl: 6 }}
|
||||||
bodyStyle={{ paddingInline: 0, paddingBlock: 0 }}
|
|
||||||
bordered
|
bordered
|
||||||
>
|
>
|
||||||
<DeviceAlarmList key="thing-alarms-key" thingId={thingId || ''} />
|
<DeviceAlarmList key="thing-alarms-key" thingId={thingId || ''} />
|
||||||
</ProCard>
|
</ProCard>
|
||||||
<ProCard>
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 24, lg: 18, xl: 18 }}>
|
||||||
|
<Flex wrap gap="small">
|
||||||
<BinarySensors nodeConfigs={nodeConfigs} />
|
<BinarySensors nodeConfigs={nodeConfigs} />
|
||||||
|
<Divider orientation="left">Trạng thái</Divider>
|
||||||
|
</Flex>
|
||||||
</ProCard>
|
</ProCard>
|
||||||
</ProCard>
|
</ProCard>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
168
src/pages/Manager/User/components/ResetPassword.tsx
Normal file
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();
|
||||||
@@ -41,11 +45,21 @@ const ManagerUserPage = () => {
|
|||||||
string | string[] | null
|
string | string[] | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
|
const [resetPasswordUser, setResetPasswordUser] =
|
||||||
|
useState<ResetUserPaswordProps>({
|
||||||
|
user: null,
|
||||||
|
isOpen: false,
|
||||||
|
});
|
||||||
|
|
||||||
const handleClickAssign = (user: MasterModel.UserResponse) => {
|
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 handleClickResetPassword = (user: MasterModel.UserResponse) => {
|
||||||
|
setResetPasswordUser({ user: user, isOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ProColumns<MasterModel.UserResponse>[] = [
|
const columns: ProColumns<MasterModel.UserResponse>[] = [
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
@@ -123,14 +137,28 @@ 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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
API_CHANGE_PASSWORD,
|
API_CHANGE_PASSWORD,
|
||||||
|
API_FORGOT_PASSWORD,
|
||||||
API_PATH_GET_PROFILE,
|
API_PATH_GET_PROFILE,
|
||||||
API_PATH_LOGIN,
|
API_PATH_LOGIN,
|
||||||
|
API_PATH_REFRESH_TOKEN,
|
||||||
API_USERS,
|
API_USERS,
|
||||||
} from '@/constants/api';
|
} from '@/constants/api';
|
||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
@@ -15,6 +17,15 @@ export async function apiLogin(body: MasterModel.LoginRequestBody) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiRefreshToken(
|
||||||
|
body: MasterModel.RefreshTokenRequestBody,
|
||||||
|
) {
|
||||||
|
return request<MasterModel.RefreshTokenReponse>(API_PATH_REFRESH_TOKEN, {
|
||||||
|
method: 'POST',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiQueryProfile() {
|
export async function apiQueryProfile() {
|
||||||
return request<MasterModel.UserResponse>(API_PATH_GET_PROFILE);
|
return request<MasterModel.UserResponse>(API_PATH_GET_PROFILE);
|
||||||
}
|
}
|
||||||
@@ -40,3 +51,11 @@ export async function apiChangePassword(
|
|||||||
getResponse: true,
|
getResponse: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
export async function apiForgotPassword(
|
||||||
|
body: MasterModel.ForgotPasswordRequestBody,
|
||||||
|
) {
|
||||||
|
return request<MasterModel.ForgotPasswordResponse>(API_FORGOT_PASSWORD, {
|
||||||
|
method: 'POST',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,3 +145,69 @@ export async function apiQueryNodeConfigMessage(
|
|||||||
|
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiQueryCamera(
|
||||||
|
dataChanelId: string,
|
||||||
|
authorization: string,
|
||||||
|
params: MasterModel.SearchMessagePaginationBody,
|
||||||
|
) {
|
||||||
|
const resp = await request<MasterModel.CameraV6MessageResponse>(
|
||||||
|
`${API_READER}/${dataChanelId}/messages`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: authorization,
|
||||||
|
},
|
||||||
|
params: params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process messages to add string_value_parsed
|
||||||
|
if (resp.messages) {
|
||||||
|
resp.messages = resp.messages.map((message) => {
|
||||||
|
if (message.string_value) {
|
||||||
|
try {
|
||||||
|
message.string_value_parsed = JSON.parse(message.string_value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse string_value:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiQueryConfigAlarm(
|
||||||
|
dataChanelId: string,
|
||||||
|
authorization: string,
|
||||||
|
params: MasterModel.SearchMessagePaginationBody,
|
||||||
|
) {
|
||||||
|
const resp = await request<MasterModel.AlarmMessageResponse>(
|
||||||
|
`${API_READER}/${dataChanelId}/messages`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: authorization,
|
||||||
|
},
|
||||||
|
params: params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process messages to add string_value_parsed
|
||||||
|
if (resp.messages) {
|
||||||
|
resp.messages = resp.messages.map((message) => {
|
||||||
|
if (message.string_value) {
|
||||||
|
try {
|
||||||
|
message.string_value_parsed = JSON.parse(message.string_value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse string_value:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { API_GROUPS, API_USERS, API_USERS_BY_GROUP } from '@/constants/api';
|
import {
|
||||||
|
API_GROUPS,
|
||||||
|
API_USER_RESET_PASSWORD,
|
||||||
|
API_USERS,
|
||||||
|
API_USERS_BY_GROUP,
|
||||||
|
} from '@/constants/api';
|
||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
export async function apiQueryUsers(
|
export async function apiQueryUsers(
|
||||||
@@ -44,3 +49,23 @@ export async function apiDeleteUser(userId: string) {
|
|||||||
getResponse: true,
|
getResponse: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiResetUserPassword(
|
||||||
|
userId: string,
|
||||||
|
newPassword: string,
|
||||||
|
) {
|
||||||
|
return request(`${API_USERS}/${userId}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: { new_password: newPassword },
|
||||||
|
getResponse: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiUserResetPassword(
|
||||||
|
body: MasterModel.UserResetPasswordRequest,
|
||||||
|
) {
|
||||||
|
return request(API_USER_RESET_PASSWORD, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
46
src/services/master/typings/auth.d.ts
vendored
46
src/services/master/typings/auth.d.ts
vendored
@@ -7,5 +7,51 @@ declare namespace MasterModel {
|
|||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
refresh_token: string;
|
||||||
|
enabled_2fa: boolean;
|
||||||
|
}
|
||||||
|
interface RefreshTokenRequestBody {
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
interface RefreshTokenReponse {
|
||||||
|
access_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenParsed {
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
iss: string;
|
||||||
|
sub: string;
|
||||||
|
issuer_id: string;
|
||||||
|
type: number;
|
||||||
|
purpose: 'access' | 'refresh';
|
||||||
|
}
|
||||||
|
interface RefreshTokenParsed extends TokenParsed {
|
||||||
|
jti: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenParsedTransformed {
|
||||||
|
expriresAt: number;
|
||||||
|
issuedAt: number;
|
||||||
|
issuer: string;
|
||||||
|
/** User Email */
|
||||||
|
subject: string;
|
||||||
|
issuerId: string;
|
||||||
|
type: number;
|
||||||
|
purpose: 'access' | 'refresh';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshTokenParsedTransformed extends TokenParsedTransformed {
|
||||||
|
jwtID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForgotPasswordRequestBody {
|
||||||
|
email: string;
|
||||||
|
host: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForgotPasswordResponse {
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/services/master/typings/log.d.ts
vendored
9
src/services/master/typings/log.d.ts
vendored
@@ -22,6 +22,7 @@ declare namespace MasterModel {
|
|||||||
// Response types cho từng domain
|
// Response types cho từng domain
|
||||||
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
|
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
|
||||||
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
|
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
|
||||||
|
type AlarmMessageResponse = MesageReaderResponse<Alarm>;
|
||||||
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
|
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
|
||||||
|
|
||||||
type MessageDataType = NodeConfig[] | CameraV5 | CameraV6;
|
type MessageDataType = NodeConfig[] | CameraV5 | CameraV6;
|
||||||
@@ -46,7 +47,7 @@ declare namespace MasterModel {
|
|||||||
cams?: Camera[];
|
cams?: Camera[];
|
||||||
}
|
}
|
||||||
interface CameraV6 extends CameraV5 {
|
interface CameraV6 extends CameraV5 {
|
||||||
record_type?: string;
|
record_type?: 'none' | 'alarm' | 'all';
|
||||||
record_alarm_list?: string[];
|
record_alarm_list?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,4 +63,10 @@ declare namespace MasterModel {
|
|||||||
ip?: string;
|
ip?: string;
|
||||||
stream?: number;
|
stream?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Alarm {
|
||||||
|
id: string;
|
||||||
|
type: Type;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/services/master/typings/user.d.ts
vendored
5
src/services/master/typings/user.d.ts
vendored
@@ -41,4 +41,9 @@ declare namespace MasterModel {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
users: UserResponse[];
|
users: UserResponse[];
|
||||||
}
|
}
|
||||||
|
interface UserResetPasswordRequest {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
confirm_password?: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
export function parseJwt(token: string) {
|
export function parseAccessToken(
|
||||||
|
token: string,
|
||||||
|
): MasterModel.TokenParsedTransformed | null {
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
const base64Url = token.split('.')[1];
|
const base64Url = token.split('.')[1];
|
||||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
const jsonPayload = decodeURIComponent(
|
const jsonPayload = decodeURIComponent(
|
||||||
@@ -9,16 +12,54 @@ export function parseJwt(token: string) {
|
|||||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
.join(''),
|
.join(''),
|
||||||
);
|
);
|
||||||
return JSON.parse(jsonPayload);
|
const accessToken: MasterModel.TokenParsed = JSON.parse(jsonPayload);
|
||||||
|
return {
|
||||||
|
expriresAt: accessToken.exp,
|
||||||
|
issuedAt: accessToken.iat,
|
||||||
|
issuer: accessToken.iss,
|
||||||
|
subject: accessToken.sub,
|
||||||
|
issuerId: accessToken.issuer_id,
|
||||||
|
type: accessToken.type,
|
||||||
|
purpose: 'access',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function parseRefreshToken(
|
||||||
|
refreshToken: string,
|
||||||
|
): MasterModel.RefreshTokenParsedTransformed | null {
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
|
||||||
|
const base64Url = refreshToken.split('.')[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const jsonPayload = decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split('')
|
||||||
|
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join(''),
|
||||||
|
);
|
||||||
|
const refreshTokenParsed: MasterModel.RefreshTokenParsed =
|
||||||
|
JSON.parse(jsonPayload);
|
||||||
|
return {
|
||||||
|
expriresAt: refreshTokenParsed.exp,
|
||||||
|
issuedAt: refreshTokenParsed.iat,
|
||||||
|
issuer: refreshTokenParsed.iss,
|
||||||
|
subject: refreshTokenParsed.sub,
|
||||||
|
issuerId: refreshTokenParsed.issuer_id,
|
||||||
|
type: refreshTokenParsed.type,
|
||||||
|
purpose: 'refresh',
|
||||||
|
jwtID: refreshTokenParsed.jti,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkTokenExpired(token: string): boolean {
|
export function checkRefreshTokenExpired(token: string): boolean {
|
||||||
const parsed = parseJwt(token);
|
const parsed = parseRefreshToken(token);
|
||||||
|
if (!parsed) return true;
|
||||||
const { exp } = parsed;
|
const { expriresAt } = parsed;
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const oneHour = 60 * 60;
|
const fiveMinutes = 60 * 5; // 5 minutes
|
||||||
if (exp - now < oneHour) {
|
if (expriresAt - now < fiveMinutes) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -14,3 +14,15 @@ export const getLogoImage = () => {
|
|||||||
return smatecLogo;
|
return smatecLogo;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
export const getDomainTitle = () => {
|
||||||
|
switch (process.env.DOMAIN_ENV) {
|
||||||
|
case 'gms':
|
||||||
|
return 'gms.title';
|
||||||
|
case 'sgw':
|
||||||
|
return 'sgw.title';
|
||||||
|
case 'spole':
|
||||||
|
return 'spole.title';
|
||||||
|
default:
|
||||||
|
return 'Smatec Master';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { TOKEN } from '@/constants';
|
import { ACCESS_TOKEN, REFRESH_TOKEN } from '@/constants';
|
||||||
|
|
||||||
export function getToken(): string {
|
export function getAccessToken(): string {
|
||||||
return localStorage.getItem(TOKEN) || '';
|
return localStorage.getItem(ACCESS_TOKEN) || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(token: string) {
|
export function setAccessToken(accessToken: string) {
|
||||||
localStorage.setItem(TOKEN, token);
|
localStorage.setItem(ACCESS_TOKEN, accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeToken() {
|
export function removeAccessToken() {
|
||||||
localStorage.removeItem(TOKEN);
|
localStorage.removeItem(ACCESS_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string {
|
||||||
|
return localStorage.getItem(REFRESH_TOKEN) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRefreshToken(refreshToken: string) {
|
||||||
|
localStorage.setItem(REFRESH_TOKEN, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeRefreshToken() {
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBrowserId() {
|
export function getBrowserId() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import ReconnectingWebSocket from 'reconnecting-websocket';
|
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||||
import { getToken } from './storage';
|
import { getAccessToken } from './storage';
|
||||||
|
|
||||||
type MessageHandler = (data: any) => void;
|
type MessageHandler = (data: any) => void;
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ class WSClient {
|
|||||||
if (this.ws) return;
|
if (this.ws) return;
|
||||||
let token = '';
|
let token = '';
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
token = getToken();
|
token = getAccessToken();
|
||||||
}
|
}
|
||||||
let wsUrl = url;
|
let wsUrl = url;
|
||||||
if (url.startsWith('/')) {
|
if (url.startsWith('/')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user