5 Commits

62 changed files with 3266 additions and 831 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
package-lock.json generated
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

View File

@@ -11,17 +11,20 @@ import LanguageSwitcher from './components/Lang/LanguageSwitcher';
import ThemeProvider from './components/Theme/ThemeProvider'; import 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_pwy498d2aw.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 />,

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
import { HTTPSTATUS } from '@/constants';
import { apiUnconfirmAlarm } from '@/services/master/AlarmController';
import { CloseOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Popconfirm } from 'antd';
import { MessageInstance } from 'antd/es/message/interface';
type AlarmUnConfirmButtonProps = {
alarm: MasterModel.Alarm;
onFinish?: (isReload: boolean) => void;
message: MessageInstance;
button?: React.ReactNode;
};
const AlarmUnConfirmButton = ({
alarm,
message,
button,
onFinish,
}: AlarmUnConfirmButtonProps) => {
const intl = useIntl();
return (
<Popconfirm
title={intl.formatMessage({
id: 'master.alarms.unconfirm.body',
defaultMessage: 'Are you sure you want to unconfirm this alarm?',
})}
okText={intl.formatMessage({
id: 'common.yes',
defaultMessage: 'Yes',
})}
cancelText={intl.formatMessage({
id: 'common.no',
defaultMessage: 'No',
})}
onConfirm={async () => {
const body: MasterModel.ConfirmAlarmRequest = {
id: alarm.id,
thing_id: alarm.thing_id,
time: alarm.time,
};
try {
const resp = await apiUnconfirmAlarm(body);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success({
content: intl.formatMessage({
id: 'master.alarms.unconfirm.success',
defaultMessage: 'Confirm alarm successfully',
}),
});
onFinish?.(true);
} else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) {
message.warning({
content: intl.formatMessage({
id: 'master.alarms.not_found',
defaultMessage: 'Alarm has expired or does not exist',
}),
});
onFinish?.(true);
} else {
throw new Error('Failed to confirm alarm');
}
} catch (error) {
console.error('Error when unconfirm alarm: ', error);
message.error({
content: intl.formatMessage({
id: 'master.alarms.unconfirm.fail',
defaultMessage: 'Unconfirm alarm failed',
}),
});
}
}}
>
{button ? (
button
) : (
<Button danger icon={<CloseOutlined />} size="small" />
)}
</Popconfirm>
);
};
export default AlarmUnConfirmButton;

View File

@@ -0,0 +1,157 @@
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE, DURATION_POLLING } from '@/const';
import AlarmDescription from '@/pages/Alarm/components/AlarmDescription';
import AlarmFormConfirm from '@/pages/Alarm/components/AlarmFormConfirm';
import { apiGetAlarms } from '@/services/master/AlarmController';
import { CheckOutlined } from '@ant-design/icons';
import { ActionType, ProList, ProListMetas } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Button, message, theme, Typography } from 'antd';
import moment from 'moment';
import { useRef, useState } from 'react';
import AlarmUnConfirmButton from './Alarm/AlarmUnConfirmButton';
import { getBadgeStatus, getTitleColorByDeviceStateLevel } from './ThingShared';
type DeviceAlarmListProps = {
thingId: string;
};
const DeviceAlarmList = ({ thingId }: DeviceAlarmListProps) => {
const { token } = theme.useToken();
const [messageApi, contextHolder] = message.useMessage();
const intl = useIntl();
const actionRef = useRef<ActionType>();
const [loading, setIsLoading] = useState<boolean>(false);
const [alarmConfirmed, setAlarmConfirmed] = useState<
MasterModel.Alarm | undefined
>(undefined);
const columns: ProListMetas<MasterModel.Alarm> = {
title: {
dataIndex: 'name',
render(_, item) {
return (
<Typography.Text
style={{
color: getTitleColorByDeviceStateLevel(item.level || 0, token),
}}
>
{item.name}
</Typography.Text>
);
},
},
avatar: {
render: (_, item) => getBadgeStatus(item.level || 0),
},
description: {
dataIndex: 'time',
render: (_, item) => {
return (
<>
{item.confirmed ? (
<AlarmDescription alarm={item} size="small" />
) : (
<div>{moment.unix(item?.time || 0).format(DATE_TIME_FORMAT)}</div>
)}
</>
);
},
},
actions: {
render: (_, entity) => [
entity.confirmed ? (
<AlarmUnConfirmButton
key="unconfirm"
alarm={entity}
message={messageApi}
onFinish={(isReload) => {
if (isReload) actionRef.current?.reload();
}}
/>
) : (
<Button
key="confirm"
icon={<CheckOutlined />}
type="dashed"
className="green-btn"
size="small"
onClick={() => setAlarmConfirmed(entity)}
></Button>
),
],
},
};
return (
<>
{contextHolder}
<AlarmFormConfirm
isOpen={!!alarmConfirmed}
setIsOpen={(v) => {
if (!v) setAlarmConfirmed(undefined);
}}
alarm={alarmConfirmed || ({} as MasterModel.Alarm)}
trigger={<></>}
message={messageApi}
onFinish={(isReload) => {
if (isReload) actionRef.current?.reload();
}}
/>
<ProList<MasterModel.Alarm>
rowKey={(record) => `${record.thing_id}_${record.id}`}
bordered
actionRef={actionRef}
metas={columns}
polling={DURATION_POLLING}
loading={loading}
search={false}
dateFormatter="string"
cardProps={{
bodyStyle: { paddingInline: 16, paddingBlock: 8 },
}}
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
showSizeChanger: false,
pageSizeOptions: ['5', '10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.paginations.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'master.alarms.table.pagination',
defaultMessage: 'alarms',
})}`,
}}
request={async (params) => {
setIsLoading(true);
try {
const { current, pageSize } = params;
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
const body: MasterModel.SearchAlarmPaginationBody = {
limit: pageSize,
offset: offset,
thing_id: thingId,
dir: 'desc',
};
const res = await apiGetAlarms(body);
return {
data: res.alarms,
total: res.total,
success: true,
};
} catch (error) {
return {
data: [],
total: 0,
success: false,
};
} finally {
setIsLoading(false);
}
}}
/>
</>
);
};
export default DeviceAlarmList;

View File

@@ -4,7 +4,7 @@ import {
STATUS_SOS, STATUS_SOS,
STATUS_WARNING, STATUS_WARNING,
} from '@/constants'; } from '@/constants';
import { Badge } from 'antd'; import { Badge, GlobalToken } from 'antd';
import IconFont from '../IconFont'; import IconFont from '../IconFont';
export const getBadgeStatus = (status: number) => { export const getBadgeStatus = (status: number) => {
@@ -30,3 +30,21 @@ export const getBadgeConnection = (online: boolean) => {
return <IconFont type="icon-cloud-disconnect" />; return <IconFont type="icon-cloud-disconnect" />;
} }
}; };
export const getTitleColorByDeviceStateLevel = (
level: number,
token: GlobalToken,
) => {
switch (level) {
case STATUS_NORMAL:
return token.colorSuccess;
case STATUS_WARNING:
return token.colorWarning;
case STATUS_DANGEROUS:
return token.colorError;
case STATUS_SOS:
return token.colorError;
default:
return token.colorText;
}
};

View File

@@ -0,0 +1,70 @@
import type { ButtonProps } from 'antd';
import { Button, theme, Tooltip } from 'antd';
import React from 'react';
import IconFont from '../IconFont';
type TooltipIconFontButtonProps = {
tooltip?: string;
iconFontName: string;
color?: string;
onClick?: () => void;
} & Omit<ButtonProps, 'icon' | 'color'>;
const TooltipIconFontButton: React.FC<TooltipIconFontButtonProps> = ({
tooltip,
iconFontName,
color,
onClick,
...buttonProps
}) => {
const { token } = theme.useToken();
const wrapperClassName = `tooltip-iconfont-wrapper-${color?.replace(
/[^a-zA-Z0-9]/g,
'-',
)}`;
const icon = (
<IconFont
type={iconFontName}
style={{ color: color || token.colorText }}
className={wrapperClassName}
/>
);
if (tooltip) {
return (
<Tooltip title={tooltip}>
<Button
onClick={onClick}
icon={icon}
{...buttonProps}
style={
color
? {
...buttonProps.style,
['--icon-button-color' as string]: color,
}
: buttonProps.style
}
/>
</Tooltip>
);
}
return (
<Button
onClick={onClick}
icon={icon}
{...buttonProps}
style={
color
? {
...buttonProps.style,
['--icon-button-color' as string]: color,
}
: buttonProps.style
}
/>
);
};
export default TooltipIconFontButton;

View File

@@ -76,3 +76,29 @@
.table-row-select tbody tr:hover { .table-row-select tbody tr:hover {
cursor: pointer; cursor: pointer;
} }
// Target icon inside the color wrapper - higher specificity
:local(.tooltip-iconfont-wrapper) .iconfont,
.tooltip-iconfont-wrapper .iconfont,
.tooltip-iconfont-wrapper svg {
color: currentcolor !important;
}
// Even more specific - target within Button
.ant-btn .tooltip-iconfont-wrapper .iconfont {
color: currentcolor !important;
}
// Most aggressive - global selector
:global {
.ant-btn .tooltip-iconfont-wrapper .iconfont,
.ant-btn .tooltip-iconfont-wrapper svg {
color: currentcolor !important;
}
// Use CSS variable approach
.ant-btn[style*='--icon-button-color'] .iconfont,
.ant-btn[style*='--icon-button-color'] svg {
color: var(--icon-button-color) !important;
}
}

View File

@@ -1,7 +1,9 @@
// Auth API Paths // Auth API Paths
export const API_PATH_LOGIN = '/api/tokens'; export const API_PATH_LOGIN = '/api/tokens';
export const API_PATH_REFRESH_TOKEN = '/api/keys/refresh';
export const API_PATH_GET_PROFILE = '/api/users/profile'; export const API_PATH_GET_PROFILE = '/api/users/profile';
export const API_CHANGE_PASSWORD = '/api/password'; export const API_CHANGE_PASSWORD = '/api/password';
export const API_FORGOT_PASSWORD = '/api/password/reset-request';
// Alarm API Constants // Alarm API Constants
export const API_ALARMS = '/api/alarms'; export const API_ALARMS = '/api/alarms';
export const API_ALARMS_CONFIRM = '/api/alarms/confirm'; export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
@@ -9,7 +11,7 @@ export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
// Thing API Constants // Thing API Constants
export const API_THINGS_SEARCH = '/api/things/search'; export const API_THINGS_SEARCH = '/api/things/search';
export const API_THING_POLICY = '/api/things/policy2'; export const API_THING_POLICY = '/api/things/policy2';
export const API_SHARE_THING = '/api/things'; export const API_THING = '/api/things';
// Group API Constants // Group API Constants
export const API_GROUPS = '/api/groups'; export const API_GROUPS = '/api/groups';
@@ -17,8 +19,9 @@ export const API_GROUP_MEMBERS = '/api/members';
export const API_GROUP_CHILDREN = '/api/groups'; export const API_GROUP_CHILDREN = '/api/groups';
// Log API Constants // Log API Constants
export const API_LOGS = '/api/reader/channels'; export const API_READER = '/api/reader/channels';
// User API Constants // User API Constants
export const API_USERS = '/api/users'; export const API_USERS = '/api/users';
export const API_USER_RESET_PASSWORD = '/api/password/reset';
export const API_USERS_BY_GROUP = '/api/users/groups'; export const API_USERS_BY_GROUP = '/api/users/groups';

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import masterGroupEn from './master-group-en';
import masterSysLogEn from './master-log-en'; import masterSysLogEn from './master-log-en';
import masterMenuEn from './master-menu-en'; import masterMenuEn from './master-menu-en';
import masterMenuProfileEn from './master-profile-en'; import masterMenuProfileEn from './master-profile-en';
import masterThingDetailEn from './master-thing-detail-en';
import masterThingEn from './master-thing-en'; import masterThingEn from './master-thing-en';
import masterUserEn from './master-user-en'; import masterUserEn from './master-user-en';
export default { export default {
@@ -16,4 +17,5 @@ export default {
...masterSysLogEn, ...masterSysLogEn,
...masterUserEn, ...masterUserEn,
...masterGroupEn, ...masterGroupEn,
...masterThingDetailEn,
}; };

View File

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

View File

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

View File

@@ -18,7 +18,10 @@ export default {
'master.users.full_name.placeholder': 'Enter full name', 'master.users.full_name.placeholder': 'Enter full name',
'master.users.full_name.required': 'Please enter full name', 'master.users.full_name.required': 'Please enter full name',
'master.users.password.placeholder': 'Password', 'master.users.password.placeholder': 'Password',
'master.users.confirmpassword.required': 'Confirm password is required', 'master.users.confirmPassword': 'Confirm Password',
'master.users.confirmPassword.placeholder': 'Enter Confirm Password',
'master.users.confirmPassword.required': 'Confirm password is required',
'master.users.confirmPassword.mismatch': 'Passwords do not match',
'master.users.email.placeholder': 'Email', 'master.users.email.placeholder': 'Email',
'master.users.phone_number': 'Phone number', 'master.users.phone_number': 'Phone number',
'master.users.phone_number.tip': 'The phone number is the unique key', 'master.users.phone_number.tip': 'The phone number is the unique key',
@@ -32,6 +35,7 @@ export default {
'master.users.role.sgw.end_user': 'Ship Owner', 'master.users.role.sgw.end_user': 'Ship Owner',
'master.users.create.error': 'User creation failed', 'master.users.create.error': 'User creation failed',
'master.users.create.success': 'User created successfully', 'master.users.create.success': 'User created successfully',
'master.users.change_role.title': 'Set Permissions',
'master.users.change_role.confirm.title': 'Confirm role change', 'master.users.change_role.confirm.title': 'Confirm role change',
'master.users.change_role.admin.content': 'master.users.change_role.admin.content':
'Are you sure you want to change the role to Unit Manager?', 'Are you sure you want to change the role to Unit Manager?',
@@ -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',
}; };

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,10 @@ export default {
'master.users.password.minimum': 'Mật khẩu ít nhất 8 kí tự', 'master.users.password.minimum': 'Mật khẩu ít nhất 8 kí tự',
'master.users.password': 'Mật khẩu', 'master.users.password': 'Mật khẩu',
'master.users.password.placeholder': 'Nhập mật khẩu', 'master.users.password.placeholder': 'Nhập mật khẩu',
'master.users.confirmpassword.required': 'Vui lòng nhập lại mật khẩu', 'master.users.confirmPassword': 'Xác nhận mật khẩu',
'master.users.confirmPassword.placeholder': 'Nhập lại mật khẩu',
'master.users.confirmPassword.required': 'Vui lòng nhập lại mật khẩu',
'master.users.confirmPassword.mismatch': 'Mật khẩu không khớp',
'master.users.full_name': 'Tên đầy đủ', 'master.users.full_name': 'Tên đầy đủ',
'master.users.full_name.placeholder': 'Nhập tên đầy đủ', 'master.users.full_name.placeholder': 'Nhập tên đầy đủ',
'master.users.full_name.required': 'Vui lòng nhập tên đầy đủ', 'master.users.full_name.required': 'Vui lòng nhập tên đầy đủ',
@@ -32,6 +35,7 @@ export default {
'master.users.role.sgw.end_user': 'Chủ tàu', 'master.users.role.sgw.end_user': 'Chủ tàu',
'master.users.create.error': 'Tạo người dùng lỗi', 'master.users.create.error': 'Tạo người dùng lỗi',
'master.users.create.success': 'Tạo người dùng thành công', 'master.users.create.success': 'Tạo người dùng thành công',
'master.users.change_role.title': 'Cài đặt quyền',
'master.users.change_role.confirm.title': 'Xác nhận thay đổi vai trò', 'master.users.change_role.confirm.title': 'Xác nhận thay đổi vai trò',
'master.users.change_role.admin.content': 'master.users.change_role.admin.content':
'Bạn có chắc muốn thay đổi vai trò thành quản lý đơn vị không?', 'Bạn có chắc muốn thay đổi vai trò thành quản lý đơn vị không?',
@@ -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',
}; };

View File

@@ -4,6 +4,7 @@ import masterGroupVi from './master-group-vi';
import masterSysLogVi from './master-log-vi'; import masterSysLogVi from './master-log-vi';
import masterMenuVi from './master-menu-vi'; import masterMenuVi from './master-menu-vi';
import masterProfileVi from './master-profile-vi'; import masterProfileVi from './master-profile-vi';
import masterThingDetailVi from './master-thing-detail-vi';
import masterThingVi from './master-thing-vi'; import masterThingVi from './master-thing-vi';
import masterUserVi from './master-user-vi'; import masterUserVi from './master-user-vi';
export default { export default {
@@ -16,4 +17,5 @@ export default {
...masterSysLogVi, ...masterSysLogVi,
...masterUserVi, ...masterUserVi,
...masterGroupVi, ...masterGroupVi,
...masterThingDetailVi,
}; };

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
import AlarmUnConfirmButton from '@/components/shared/Alarm/AlarmUnConfirmButton';
import ThingsFilter from '@/components/shared/ThingFilterModal'; import ThingsFilter from '@/components/shared/ThingFilterModal';
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE, HTTPSTATUS } from '@/constants'; import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE } from '@/constants';
import { import { apiGetAlarms } from '@/services/master/AlarmController';
apiGetAlarms,
apiUnconfirmAlarm,
} from '@/services/master/AlarmController';
import { import {
CloseOutlined, CloseOutlined,
DeleteOutlined, DeleteOutlined,
@@ -11,7 +9,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components'; import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { FormattedMessage, useIntl, useModel } from '@umijs/max'; import { FormattedMessage, useIntl, useModel } from '@umijs/max';
import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd'; import { Button, Flex, message, Progress, Tooltip } from 'antd';
import moment from 'moment'; import moment from 'moment';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import AlarmDescription from './components/AlarmDescription'; import AlarmDescription from './components/AlarmDescription';
@@ -26,7 +24,7 @@ const AlarmPage = () => {
const [messageApi, contextHolder] = message.useMessage(); const [messageApi, contextHolder] = message.useMessage();
const { initialState } = useModel('@@initialState'); const { initialState } = useModel('@@initialState');
const { currentUserProfile } = initialState || {}; const { currentUserProfile } = initialState || {};
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState<boolean>(false);
const columns: ProColumns<MasterModel.Alarm>[] = [ const columns: ProColumns<MasterModel.Alarm>[] = [
{ {
title: intl.formatMessage({ title: intl.formatMessage({
@@ -202,65 +200,22 @@ const AlarmPage = () => {
return ( return (
<Flex gap={10}> <Flex gap={10}>
{alarm?.confirmed || false ? ( {alarm?.confirmed || false ? (
<Popconfirm <AlarmUnConfirmButton
title={intl.formatMessage({ alarm={alarm}
id: 'master.alarms.unconfirm.body', message={messageApi}
defaultMessage: button={
'Are you sure you want to unconfirm this alarm?',
})}
okText={intl.formatMessage({
id: 'common.yes',
defaultMessage: 'Yes',
})}
cancelText={intl.formatMessage({
id: 'common.no',
defaultMessage: 'No',
})}
onConfirm={async () => {
const body: MasterModel.ConfirmAlarmRequest = {
id: alarm.id,
thing_id: alarm.thing_id,
time: alarm.time,
};
try {
const resp = await apiUnconfirmAlarm(body);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success({
content: intl.formatMessage({
id: 'master.alarms.unconfirm.success',
defaultMessage: 'Confirm alarm successfully',
}),
});
tableRef.current?.reload();
} else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) {
message.warning({
content: intl.formatMessage({
id: 'master.alarms.not_found',
defaultMessage:
'Alarm has expired or does not exist',
}),
});
tableRef.current?.reload();
} else {
throw new Error('Failed to confirm alarm');
}
} catch (error) {
console.error('Error when unconfirm alarm: ', error);
message.error({
content: intl.formatMessage({
id: 'master.alarms.unconfirm.fail',
defaultMessage: 'Unconfirm alarm failed',
}),
});
}
}}
>
<Button danger icon={<DeleteOutlined />} size="small"> <Button danger icon={<DeleteOutlined />} size="small">
<FormattedMessage id="master.alarms.unconfirm.title" /> <FormattedMessage id="master.alarms.unconfirm.title" />
</Button> </Button>
</Popconfirm> }
onFinish={(isReload) => {
if (isReload) tableRef.current?.reload();
}}
/>
) : ( ) : (
<AlarmFormConfirm <AlarmFormConfirm
isOpen={isConfirmModalOpen}
setIsOpen={setIsConfirmModalOpen}
alarm={alarm} alarm={alarm}
message={messageApi} message={messageApi}
onFinish={(isReload) => { onFinish={(isReload) => {

View File

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

View File

@@ -1,48 +1,100 @@
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import LangSwitches from '@/components/Lang/LanguageSwitcherAuth'; import LangSwitches from '@/components/Lang/LanguageSwitcherAuth';
import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth'; import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth';
import { THEME_KEY } from '@/constants';
import { ROUTER_HOME } from '@/constants/routes'; import { ROUTER_HOME } from '@/constants/routes';
import { apiLogin, apiQueryProfile } from '@/services/master/AuthController'; import {
import { parseJwt } from '@/utils/jwt'; apiForgotPassword,
import { getLogoImage } from '@/utils/logo'; apiLogin,
import { getBrowserId, getToken, removeToken, setToken } from '@/utils/storage'; apiQueryProfile,
} from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt';
import { getDomainTitle, getLogoImage } from '@/utils/logo';
import {
getBrowserId,
getRefreshToken,
removeAccessToken,
removeRefreshToken,
setAccessToken,
setRefreshToken,
} from '@/utils/storage';
import { LockOutlined, UserOutlined } from '@ant-design/icons'; import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginFormPage, ProFormText } from '@ant-design/pro-components'; import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
import { history, useIntl, useModel } from '@umijs/max'; import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
import { Image, theme } from 'antd'; import { Button, ConfigProvider, Flex, Image, message, theme } from 'antd';
import { useEffect } from 'react'; import { CSSProperties, useEffect, useState } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import mobifontLogo from '../../../public/mobifont-logo.png'; import mobifontLogo from '../../../public/mobifont-logo.png';
type LoginType = 'login' | 'forgot';
// Form wrapper with animation
const FormWrapper = ({
children,
key,
}: {
children: React.ReactNode;
key: string;
}) => {
const style: CSSProperties = {
animation: 'fadeInSlide 0.4s ease-out forwards',
};
return (
<div key={key} style={style}>
<style>{`
@keyframes fadeInSlide {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
`}</style>
{children}
</div>
);
};
const LoginPage = () => { const LoginPage = () => {
const [isDark, setIsDark] = useState(
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') === 'dark',
);
const { token } = theme.useToken(); const { token } = theme.useToken();
const [messageApi, contextHolder] = message.useMessage();
const urlParams = new URL(window.location.href).searchParams; const urlParams = new URL(window.location.href).searchParams;
const redirect = urlParams.get('redirect'); const redirect = urlParams.get('redirect');
const intl = useIntl(); const intl = useIntl();
const { setInitialState } = useModel('@@initialState'); const { setInitialState } = useModel('@@initialState');
const getDomainTitle = () => { const [loginType, setLoginType] = useState<LoginType>('login');
switch (process.env.DOMAIN_ENV) {
case 'gms': // 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;

View File

@@ -0,0 +1,174 @@
import {
Button,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
} from 'antd';
// Camera types
const CAMERA_TYPES = [
{ label: 'HIKVISION', value: 'HIKVISION' },
{ label: 'DAHUA', value: 'DAHUA' },
{ label: 'GENERIC', value: 'GENERIC' },
];
interface CameraFormValues {
name: string;
type: string;
account: string;
password: string;
ipAddress: string;
rtspPort: number;
httpPort: number;
stream: number;
channel: number;
}
interface CameraFormModalProps {
open: boolean;
onCancel: () => void;
onSubmit: (values: CameraFormValues) => void;
}
const CameraFormModal: React.FC<CameraFormModalProps> = ({
open,
onCancel,
onSubmit,
}) => {
const [form] = Form.useForm<CameraFormValues>();
const handleSubmit = async () => {
try {
const values = await form.validateFields();
onSubmit(values);
form.resetFields();
} catch (error) {
console.error('Validation failed:', error);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
return (
<Modal
title="Tạo mới camera"
open={open}
onCancel={handleCancel}
footer={[
<Button key="cancel" onClick={handleCancel}>
Hủy
</Button>,
<Button key="submit" type="primary" onClick={handleSubmit}>
Đng ý
</Button>,
]}
width={500}
>
<Form
form={form}
layout="vertical"
initialValues={{
type: 'HIKVISION',
rtspPort: 554,
httpPort: 80,
stream: 0,
channel: 0,
}}
>
<Form.Item
label="Tên"
name="name"
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
>
<Input placeholder="nhập dữ liệu" />
</Form.Item>
<Form.Item
label="Loại"
name="type"
rules={[{ required: true, message: 'Vui lòng chọn loại' }]}
>
<Select options={CAMERA_TYPES} />
</Form.Item>
<Form.Item
label="Tài khoản"
name="account"
rules={[{ required: true, message: 'Vui lòng nhập tài khoản' }]}
>
<Input placeholder="nhập tài khoản" autoComplete="off" />
</Form.Item>
<Form.Item
label="Mật khẩu"
name="password"
rules={[{ required: true, message: 'Vui lòng nhập mật khẩu' }]}
>
<Input.Password
placeholder="nhập mật khẩu"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
label="Địa chỉ IP"
name="ipAddress"
rules={[{ required: true, message: 'Vui lòng nhập địa chỉ IP' }]}
>
<Input placeholder="192.168.1.10" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="Cổng RTSP"
name="rtspPort"
rules={[{ required: true, message: 'Vui lòng nhập cổng RTSP' }]}
>
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Cổng HTTP"
name="httpPort"
rules={[{ required: true, message: 'Vui lòng nhập cổng HTTP' }]}
>
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="Luồng"
name="stream"
rules={[{ required: true, message: 'Vui lòng nhập luồng' }]}
>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Kênh"
name="channel"
rules={[{ required: true, message: 'Vui lòng nhập kênh' }]}
>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
);
};
export default CameraFormModal;

View File

@@ -0,0 +1,106 @@
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Button, Card, Checkbox, Space, Table, theme } from 'antd';
interface CameraTableProps {
cameraData: MasterModel.Camera[] | null;
onCreateCamera: () => void;
onReload?: () => void;
loading?: boolean;
}
const CameraTable: React.FC<CameraTableProps> = ({
cameraData,
onCreateCamera,
onReload,
loading = false,
}) => {
const { token } = theme.useToken();
const handleReload = () => {
console.log('Reload cameras');
onReload?.();
};
const handleDelete = () => {
console.log('Delete selected cameras');
// TODO: Implement delete functionality
};
const handleEdit = (camera: MasterModel.Camera) => {
console.log('Edit camera:', camera);
// TODO: Implement edit functionality
};
const columns = [
{
title: '',
dataIndex: 'checkbox',
width: 50,
render: () => <Checkbox />,
},
{
title: 'Tên',
dataIndex: 'name',
key: 'name',
render: (text: string) => (
<a style={{ color: token.colorPrimary }}>{text || '-'}</a>
),
},
{
title: 'Loại',
dataIndex: 'cate_id',
key: 'cate_id',
render: (text: string) => text || '-',
},
{
title: 'Địa chỉ IP',
dataIndex: 'ip',
key: 'ip',
render: (text: string) => text || '-',
},
{
title: 'Thao tác',
key: 'action',
render: (_: any, record: MasterModel.Camera) => (
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
),
},
];
return (
<Card bodyStyle={{ padding: 16 }}>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateCamera}>
Tạo mới camera
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReload} />
<Button icon={<DeleteOutlined />} onClick={handleDelete} />
</Space>
<Table
dataSource={cameraData || []}
columns={columns}
rowKey="id"
size="small"
loading={loading}
pagination={{
size: 'small',
showTotal: (total: number, range: [number, number]) =>
`Hiển thị ${range[0]}-${range[1]} của ${total} camera`,
pageSize: 10,
}}
/>
</Card>
);
};
export default CameraTable;

View File

@@ -0,0 +1,55 @@
import { Button, Card, Select, Typography } from 'antd';
import { useState } from 'react';
const { Text } = Typography;
// Recording modes for V5 - chỉ có không ghi và ghi 24/24
const RECORDING_MODES = [
{ label: 'Không ghi', value: 'none' },
{ label: 'Ghi 24/24', value: '24/7' },
];
interface CameraV5Props {
thing: MasterModel.Thing | null;
initialRecordingMode?: string;
}
const CameraV5: React.FC<CameraV5Props> = ({
thing,
initialRecordingMode = 'none',
}) => {
const [recordingMode, setRecordingMode] = useState(initialRecordingMode);
console.log('ConfigCameraV5 - thing:', thing);
const handleSubmit = () => {
console.log('Submit recording mode:', recordingMode);
// TODO: Call API to save recording configuration
};
return (
<Card bodyStyle={{ padding: 16 }}>
{/* Recording Mode */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
Ghi dữ liệu camera
</Text>
<Select
value={recordingMode}
onChange={setRecordingMode}
options={RECORDING_MODES}
style={{ width: 200 }}
/>
</div>
{/* Submit Button */}
<div style={{ textAlign: 'center' }}>
<Button type="primary" onClick={handleSubmit}>
Gửi đi
</Button>
</div>
</Card>
);
};
export default CameraV5;

View File

@@ -0,0 +1,192 @@
import { apiQueryConfigAlarm } from '@/services/master/MessageController';
import { useModel } from '@umijs/max';
import { Button, Card, Col, Row, Select, theme, Typography } from 'antd';
import { useEffect, useState } from 'react';
const { Text } = Typography;
// Recording modes for V6
const RECORDING_MODES = [
{ label: 'Không ghi', value: 'none' },
{ label: 'Theo cảnh báo', value: 'alarm' },
{ label: '24/24', value: 'all' },
];
interface CameraV6Props {
thing: MasterModel.Thing | null;
cameraConfig?: MasterModel.CameraV6 | null;
}
const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
const { token } = theme.useToken();
const { initialState } = useModel('@@initialState');
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
const [recordingMode, setRecordingMode] = useState<'none' | 'alarm' | 'all'>(
'none',
);
const [alarmConfig, setAlarmConfig] = useState<MasterModel.Alarm[] | null>(
null,
);
// Initialize states from cameraConfig when it's available
useEffect(() => {
if (cameraConfig) {
// Set recording mode from config
if (cameraConfig.record_type) {
setRecordingMode(cameraConfig.record_type);
}
// Set selected alerts from config
if (
cameraConfig.record_alarm_list &&
Array.isArray(cameraConfig.record_alarm_list)
) {
setSelectedAlerts(cameraConfig.record_alarm_list);
}
}
}, [cameraConfig]);
// Fetch alarm config when thing data is available and recording mode is 'alarm'
useEffect(() => {
const fetchAlarmConfig = async () => {
if (
!thing ||
!initialState?.currentUserProfile?.metadata?.frontend_thing_key ||
recordingMode !== 'alarm'
) {
return;
}
try {
const resp = await apiQueryConfigAlarm(
thing.metadata?.data_channel_id || '',
initialState.currentUserProfile.metadata.frontend_thing_key,
{
offset: 0,
limit: 1,
subtopic: `config.${thing.metadata?.type}.alarms`,
},
);
if (resp.messages && resp.messages.length > 0) {
const parsed = resp.messages[0].string_value_parsed;
if (Array.isArray(parsed)) {
setAlarmConfig(parsed as MasterModel.Alarm[]);
} else {
setAlarmConfig([]);
}
}
} catch (error) {
console.error('Failed to fetch alarm config:', error);
}
};
fetchAlarmConfig();
}, [thing, initialState, recordingMode]);
const handleAlertToggle = (alertId: string) => {
if (selectedAlerts.includes(alertId)) {
setSelectedAlerts(selectedAlerts.filter((id) => id !== alertId));
} else {
setSelectedAlerts([...selectedAlerts, alertId]);
}
};
const handleClearAlerts = () => {
setSelectedAlerts([]);
};
const handleSubmitAlerts = () => {
console.log('Submit alerts:', {
recordingMode,
selectedAlerts,
});
// TODO: Call API to save alert configuration
};
return (
<Card className="p-4">
{/* Recording Mode */}
<div className="mb-6">
<Text strong className="block mb-2">
Ghi dữ liệu camera
</Text>
<div className="flex gap-8 items-center">
<Select
value={recordingMode}
onChange={setRecordingMode}
options={RECORDING_MODES}
className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4"
/>
<Button type="primary" onClick={handleSubmitAlerts}>
Gửi đi
</Button>
</div>
</div>
{/* Alert List - Only show when mode is 'alarm' */}
{recordingMode === 'alarm' && (
<div>
<Text strong className="block mb-2">
Danh sách cảnh báo
</Text>
<div
className="flex justify-between items-center mb-4 px-3 py-2 rounded border"
style={{
background: token.colorBgContainer,
borderColor: token.colorBorder,
}}
>
<Text type="secondary">đã chọn {selectedAlerts.length} mục</Text>
<Button type="link" onClick={handleClearAlerts}>
Xóa
</Button>
</div>
{/* Alert Cards Grid */}
<Row gutter={[12, 12]}>
{alarmConfig?.map((alarm) => {
const alarmId = alarm.id ?? '';
const isSelected =
alarmId !== '' && selectedAlerts.includes(alarmId);
return (
<Col xs={12} sm={8} md={6} lg={4} xl={4} key={alarmId}>
<Card
size="small"
hoverable
onClick={() => handleAlertToggle(alarmId)}
className="cursor-pointer h-20 flex items-center justify-center"
style={{
borderColor: isSelected
? token.colorPrimary
: token.colorBorder,
borderWidth: isSelected ? 2 : 1,
background: isSelected
? token.colorPrimaryBg
: token.colorBgContainer,
}}
>
<div className="p-2 text-center w-full">
<Text
className="text-xs break-words"
style={{
color: isSelected
? token.colorPrimary
: token.colorText,
}}
>
{alarm.name}
</Text>
</div>
</Card>
</Col>
);
})}
</Row>
</div>
)}
</Card>
);
};
export default CameraV6;

View File

@@ -1,120 +1,34 @@
import { apiSearchThings } from '@/services/master/ThingController'; import { apiQueryCamera } from '@/services/master/MessageController';
import { apiGetThingDetail } from '@/services/master/ThingController';
import { wsClient } from '@/utils/wsClient'; import { 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('wss://gms.smatec.com.vn/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,118 +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 response = await apiSearchThings({ const thingData = await apiGetThingDetail(thingId);
offset: 0, setThing(thingData);
limit: 1,
id: thingId,
});
if (response?.things && response.things.length > 0) {
setThingName(response.things[0].name || thingId);
} else {
setThingName(thingId);
}
} 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
@@ -245,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>
), ),
}} }}
@@ -255,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>
); );

View File

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

View File

@@ -0,0 +1,34 @@
import { getBadgeConnection } from '@/components/shared/ThingShared';
import { useIntl } from '@umijs/max';
import { Flex, Typography } from 'antd';
import moment from 'moment';
const { Text, Title } = Typography;
const ThingTitle = ({ thing }: { thing: MasterModel.Thing | null }) => {
const intl = useIntl();
if (thing === null) {
return <Text>{intl.formatMessage({ id: 'common.undefined' })}</Text>;
}
const connectionDuration = thing.metadata!.connected
? thing.metadata!.uptime! * 1000
: (Math.round(new Date().getTime() / 1000) -
thing.metadata!.updated_time!) *
1000;
return (
<Flex gap={10}>
<Title level={4} style={{ margin: 0 }}>
{thing.name || intl.formatMessage({ id: 'common.undefined' })}
</Title>
<Flex gap={5} align="center" justify="center">
{getBadgeConnection(thing.metadata!.connected || false)}
<Text type={thing.metadata?.connected ? undefined : 'secondary'}>
{connectionDuration > 0
? moment.duration(connectionDuration).humanize()
: ''}
</Text>
</Flex>
</Flex>
);
};
export default ThingTitle;

View File

@@ -0,0 +1,121 @@
import DeviceAlarmList from '@/components/shared/DeviceAlarmList';
import TooltipIconFontButton from '@/components/shared/TooltipIconFontButton';
import { ROUTER_HOME } from '@/constants/routes';
import { apiQueryNodeConfigMessage } from '@/services/master/MessageController';
import { apiGetThingDetail } from '@/services/master/ThingController';
import { PageContainer, ProCard } from '@ant-design/pro-components';
import { history, useIntl, useModel, useParams } from '@umijs/max';
import { Divider, Flex, Grid } from 'antd';
import { useEffect, useState } from 'react';
import BinarySensors from './components/BinarySensors';
import ThingTitle from './components/ThingTitle';
const DetailDevicePage = () => {
const { thingId } = useParams();
const [thing, setThing] = useState<MasterModel.Thing | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { initialState } = useModel('@@initialState');
const { useBreakpoint } = Grid;
const screens = useBreakpoint();
const intl = useIntl();
const [nodeConfigs, setNodeConfigs] = useState<MasterModel.NodeConfig[]>([]);
const getThingDetail = async () => {
setIsLoading(true);
try {
const thing = await apiGetThingDetail(thingId || '');
setThing(thing);
} catch (error) {
console.error('Error when get Thing: ', error);
} finally {
setIsLoading(false);
}
};
const getDeviceConfig = async () => {
try {
const resp = await apiQueryNodeConfigMessage(
thing?.metadata?.data_channel_id || '',
initialState?.currentUserProfile?.metadata?.frontend_thing_key || '',
{
offset: 0,
limit: 1,
subtopic: `config.${thing?.metadata?.type}.node`,
},
);
if (resp.messages && resp.messages.length > 0) {
console.log('Node Configs: ', resp.messages[0].string_value_parsed);
setNodeConfigs(resp.messages[0].string_value_parsed ?? []);
}
} catch (error) {}
};
useEffect(() => {
if (thingId) {
getThingDetail();
}
}, [thingId]);
useEffect(() => {
if (thing) {
getDeviceConfig();
}
}, [thing]);
return (
<PageContainer
title={isLoading ? 'Loading...' : <ThingTitle thing={thing} />}
header={{
onBack: () => history.push(ROUTER_HOME),
breadcrumb: undefined,
}}
extra={[
<TooltipIconFontButton
key="logs"
tooltip="Nhật ký"
iconFontName="icon-system-diary"
shape="circle"
size="middle"
onClick={() => {}}
/>,
<TooltipIconFontButton
key="notifications"
tooltip="Thông báo"
iconFontName="icon-bell"
shape="circle"
size="middle"
onClick={() => {}}
/>,
<TooltipIconFontButton
key="settings"
tooltip="Cài đặt"
iconFontName="icon-setting-device"
shape="circle"
size="middle"
onClick={() => {}}
/>,
]}
>
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard
bodyStyle={{
paddingInline: 12,
paddingBlock: 8,
}}
title={intl.formatMessage({
id: 'master.thing.detail.alarmList.title',
})}
colSpan={{ xs: 24, sm: 24, md: 24, lg: 6, xl: 6 }}
bordered
>
<DeviceAlarmList key="thing-alarms-key" thingId={thingId || ''} />
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 24, lg: 18, xl: 18 }}>
<Flex wrap gap="small">
<BinarySensors nodeConfigs={nodeConfigs} />
<Divider orientation="left">Trạng thái</Divider>
</Flex>
</ProCard>
</ProCard>
</PageContainer>
);
};
export default DetailDevicePage;

View File

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

View File

@@ -1,4 +1,4 @@
import IconFont from '@/components/IconFont'; import TooltipIconFontButton from '@/components/shared/TooltipIconFontButton';
import TreeGroup from '@/components/shared/TreeGroup'; import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants'; import { DEFAULT_PAGE_SIZE } from '@/constants';
import { import {
@@ -19,12 +19,16 @@ import {
ProTable, ProTable,
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl } from '@umijs/max'; import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Button, Grid, Popconfirm, theme } from 'antd'; import { Button, Grid, Popconfirm, Space, theme } from 'antd';
import message from 'antd/es/message'; import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph'; import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import CreateUser from './components/CreateUser'; import CreateUser from './components/CreateUser';
import ResetPassword from './components/ResetPassword';
type ResetUserPaswordProps = {
user: MasterModel.UserResponse | null;
isOpen: boolean;
};
const ManagerUserPage = () => { const ManagerUserPage = () => {
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
const intl = useIntl(); const intl = useIntl();
@@ -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

View File

@@ -5,6 +5,7 @@ import {
} from '@/components/shared/ThingShared'; } from '@/components/shared/ThingShared';
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 { ROUTE_MANAGER_DEVICES } from '@/constants/routes';
import { apiSearchThings } from '@/services/master/ThingController'; import { apiSearchThings } from '@/services/master/ThingController';
import { import {
ActionType, ActionType,
@@ -12,12 +13,12 @@ import {
ProColumns, ProColumns,
ProTable, ProTable,
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max'; import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Flex, Grid, theme, Typography } from 'antd'; import { Flex, Grid, theme, Typography } from 'antd';
import moment from 'moment'; import moment from 'moment';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { TagStateCallbackPayload } from '../../SGW/Map/type'; import { TagStateCallbackPayload } from '../../SGW/Map/type';
const { Text } = Typography; const { Text, Link } = Typography;
const SpoleHome: React.FC = () => { const SpoleHome: React.FC = () => {
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
const intl = useIntl(); const intl = useIntl();
@@ -39,9 +40,6 @@ const SpoleHome: React.FC = () => {
{ {
key: 'name', key: 'name',
ellipsis: true, ellipsis: true,
title: (
<FormattedMessage id="master.devices.name" defaultMessage="Name" />
),
tip: intl.formatMessage({ tip: intl.formatMessage({
id: 'master.devices.name.tip', id: 'master.devices.name.tip',
defaultMessage: 'The device name', defaultMessage: 'The device name',
@@ -49,6 +47,20 @@ const SpoleHome: React.FC = () => {
dataIndex: 'name', dataIndex: 'name',
hideInSearch: true, hideInSearch: true,
copyable: true, copyable: true,
render: (_, row) => {
return (
<Link
copyable
onClick={() => history.push(`${ROUTE_MANAGER_DEVICES}/${row.id}`)}
>
{row.name ||
intl.formatMessage({
id: 'common.undefined',
defaultMessage: 'Undefined',
})}
</Link>
);
},
}, },
{ {
key: 'connected', key: 'connected',

View File

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

View File

@@ -1,11 +1,11 @@
import { API_LOGS } from '@/constants/api'; import { API_READER } from '@/constants/api';
import { request } from '@umijs/max'; import { request } from '@umijs/max';
export async function apiQueryLogs( export async function apiQueryLogs(
params: MasterModel.SearchLogPaginationBody, params: MasterModel.SearchLogPaginationBody,
type: MasterModel.LogTypeRequest, type: MasterModel.LogTypeRequest,
) { ) {
return request<MasterModel.LogResponse>(`${API_LOGS}/${type}/messages`, { return request<MasterModel.LogResponse>(`${API_READER}/${type}/messages`, {
params: params, params: params,
}); });
} }

View File

@@ -0,0 +1,213 @@
import { API_READER } from '@/constants/api';
import { request } from '@umijs/max';
// Transform functions
function transformEntityConfigChildDetail(
raw: MasterModel.PurpleC,
): MasterModel.EntityConfigChildDetail {
return {
entityId: raw.eid || '',
value: raw.v,
operation: raw.op,
duration: raw.for,
};
}
function transformEntityConfigChild(
raw: MasterModel.CElement,
): MasterModel.EntityConfigChild {
return {
type: raw.t || '',
children: raw.c ? [transformEntityConfigChildDetail(raw.c)] : undefined,
};
}
function transformEntityConfig(raw: MasterModel.EC): MasterModel.EntityConfig {
return {
level: raw.l as 0 | 1 | 2 | undefined,
normalCondition: raw.nc,
subType: raw.st,
children: raw.c?.map(transformEntityConfigChild),
};
}
function transformEntity(raw: MasterModel.E): MasterModel.Entity {
return {
entityId: raw.eid || '',
type: raw.t || '',
name: raw.n || '',
active: raw.a,
value: raw.v,
config: raw.c ? [transformEntityConfig(raw.c)] : undefined,
};
}
export function transformNodeConfig(
raw: MasterModel.RawNodeConfig,
): MasterModel.NodeConfig {
return {
nodeId: raw.nid || '',
type: raw.t || '',
name: raw.n || '',
entities: raw.e?.map(transformEntity) || [],
};
}
// Reverse transform functions
function reverseTransformEntityConfigChildDetail(
detail: MasterModel.EntityConfigChildDetail,
): MasterModel.PurpleC {
return {
eid: detail.entityId,
v: detail.value,
op: detail.operation,
for: detail.duration,
};
}
function reverseTransformEntityConfigChild(
child: MasterModel.EntityConfigChild,
): MasterModel.CElement {
return {
t: child.type,
c: child.children?.[0]
? reverseTransformEntityConfigChildDetail(child.children[0])
: undefined,
};
}
function reverseTransformEntityConfig(
raw: MasterModel.EntityConfig,
): MasterModel.EC {
return {
l: raw.level,
nc: raw.normalCondition,
st: raw.subType,
c: raw.children?.map(reverseTransformEntityConfigChild),
};
}
function reverseTransformEntity(entity: MasterModel.Entity): MasterModel.E {
return {
eid: entity.entityId,
t: entity.type,
n: entity.name,
a: entity.active,
v: entity.value,
c: entity.config?.[0]
? reverseTransformEntityConfig(entity.config[0])
: undefined,
};
}
export function transformRawNodeConfig(
node: MasterModel.NodeConfig,
): MasterModel.RawNodeConfig {
return {
nid: node.nodeId,
t: node.type,
n: node.name,
e: node.entities.map(reverseTransformEntity),
};
}
export async function apiQueryNodeConfigMessage(
dataChanelId: string,
authorization: string,
params: MasterModel.SearchMessagePaginationBody,
) {
const resp = await request<
MasterModel.MesageReaderResponse<MasterModel.NodeConfig[]>
>(`${API_READER}/${dataChanelId}/messages`, {
method: 'GET',
headers: {
Authorization: authorization,
},
params: params,
});
// Process messages to add string_value_parsed
if (resp.messages) {
resp.messages = resp.messages.map((message) => {
if (message.string_value) {
try {
const rawNodeConfigs: MasterModel.RawNodeConfig[] = JSON.parse(
message.string_value,
);
message.string_value_parsed = rawNodeConfigs.map(transformNodeConfig);
} catch (error) {
console.error('Failed to parse string_value:', error);
}
}
return message;
});
}
return resp;
}
export async function apiQueryCamera(
dataChanelId: string,
authorization: string,
params: MasterModel.SearchMessagePaginationBody,
) {
const resp = await request<MasterModel.CameraV6MessageResponse>(
`${API_READER}/${dataChanelId}/messages`,
{
method: 'GET',
headers: {
Authorization: authorization,
},
params: params,
},
);
// Process messages to add string_value_parsed
if (resp.messages) {
resp.messages = resp.messages.map((message) => {
if (message.string_value) {
try {
message.string_value_parsed = JSON.parse(message.string_value);
} catch (error) {
console.error('Failed to parse string_value:', error);
}
}
return message;
});
}
return resp;
}
export async function apiQueryConfigAlarm(
dataChanelId: string,
authorization: string,
params: MasterModel.SearchMessagePaginationBody,
) {
const resp = await request<MasterModel.AlarmMessageResponse>(
`${API_READER}/${dataChanelId}/messages`,
{
method: 'GET',
headers: {
Authorization: authorization,
},
params: params,
},
);
// Process messages to add string_value_parsed
if (resp.messages) {
resp.messages = resp.messages.map((message) => {
if (message.string_value) {
try {
message.string_value_parsed = JSON.parse(message.string_value);
} catch (error) {
console.error('Failed to parse string_value:', error);
}
}
return message;
});
}
return resp;
}

View File

@@ -1,5 +1,5 @@
import { import {
API_SHARE_THING, API_THING,
API_THING_POLICY, API_THING_POLICY,
API_THINGS_SEARCH, API_THINGS_SEARCH,
} from '@/constants/api'; } from '@/constants/api';
@@ -55,7 +55,7 @@ export async function apiSearchThings(
export async function apiUpdateThing(value: MasterModel.Thing) { export async function apiUpdateThing(value: MasterModel.Thing) {
if (!value.id) throw new Error('Thing id is required'); if (!value.id) throw new Error('Thing id is required');
return request<MasterModel.Thing>(`${API_SHARE_THING}/${value.id}`, { return request<MasterModel.Thing>(`${API_THING}/${value.id}`, {
method: 'PUT', method: 'PUT',
data: value, data: value,
}); });
@@ -77,7 +77,7 @@ export async function apiDeleteUserThingPolicy(
thing_id: string, thing_id: string,
user_id: string, user_id: string,
) { ) {
return request(`${API_SHARE_THING}/${thing_id}/share`, { return request(`${API_THING}/${thing_id}/share`, {
method: 'DELETE', method: 'DELETE',
data: { data: {
policies: ['read', 'write', 'delete'], policies: ['read', 'write', 'delete'],
@@ -91,7 +91,7 @@ export async function apiShareThingToUser(
user_id: string, user_id: string,
policies: string[], policies: string[],
) { ) {
return request(`${API_SHARE_THING}/${thing_id}/share`, { return request(`${API_THING}/${thing_id}/share`, {
method: 'POST', method: 'POST',
data: { data: {
policies: policies, policies: policies,
@@ -100,3 +100,7 @@ export async function apiShareThingToUser(
getResponse: true, getResponse: true,
}); });
} }
export async function apiGetThingDetail(thing_id: string) {
return request<MasterModel.Thing>(`${API_THING}/${thing_id}`);
}

View File

@@ -1,4 +1,9 @@
import { API_GROUPS, API_USERS, API_USERS_BY_GROUP } from '@/constants/api'; import {
API_GROUPS,
API_USER_RESET_PASSWORD,
API_USERS,
API_USERS_BY_GROUP,
} from '@/constants/api';
import { request } from '@umijs/max'; 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,
});
}

View File

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

View File

@@ -8,7 +8,7 @@ declare namespace MasterModel {
type LogTypeRequest = 'user_logs' | undefined; type LogTypeRequest = 'user_logs' | undefined;
interface LogResponse { interface MesageReaderResponse<T = MessageDataType> {
offset?: number; offset?: number;
limit?: number; limit?: number;
publisher?: string; publisher?: string;
@@ -16,10 +16,18 @@ declare namespace MasterModel {
to?: number; to?: number;
format?: string; format?: string;
total?: number; total?: number;
messages?: Message[]; messages?: Message<T>[];
} }
interface Message { // Response types cho từng domain
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
type AlarmMessageResponse = MesageReaderResponse<Alarm>;
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
type MessageDataType = NodeConfig[] | CameraV5 | CameraV6;
interface Message<T = MessageDataType> {
channel?: string; channel?: string;
subtopic?: string; subtopic?: string;
publisher?: string; publisher?: string;
@@ -27,5 +35,38 @@ declare namespace MasterModel {
name?: string; name?: string;
time?: number; time?: number;
string_value?: string; string_value?: string;
string_value_parsed?: T;
}
// Message types cho từng domain
type CameraMessage = Message<CameraV5>;
type CameraV6Message = Message<CameraV6>;
type NodeConfigMessage = Message<NodeConfig[]>;
interface CameraV5 {
cams?: Camera[];
}
interface CameraV6 extends CameraV5 {
record_type?: 'none' | 'alarm' | 'all';
record_alarm_list?: string[];
}
interface Camera {
id?: string;
name?: string;
cate_id?: string;
username?: string;
password?: string;
rtsp_port?: number;
http_port?: number;
channel?: number;
ip?: string;
stream?: number;
}
interface Alarm {
id: string;
type: Type;
name: string;
} }
} }

101
src/services/master/typings/message.d.ts vendored Normal file
View File

@@ -0,0 +1,101 @@
declare namespace MasterModel {
interface SearchMessagePaginationBody extends SearchPaginationBody {
subtopic?: string;
}
interface RawNodeConfig {
nid?: string;
t?: string;
n?: string;
e?: E[];
}
interface E {
eid?: string;
t?: string;
n?: string;
a?: number;
v?: number;
c?: EC;
vs?: string;
}
interface EC {
l?: number;
nc?: number;
st?: string;
c?: CElement[];
}
interface CElement {
t?: string;
c?: PurpleC;
}
interface PurpleC {
eid?: string;
v?: number;
op?: number;
for?: number;
}
// Interface Tranformed RawNodeConfig
interface NodeConfig {
/** Node ID - Định danh duy nhất */
nodeId: string;
/** Type - Loại thiết bị */
type: string;
/** Name - Tên hiển thị */
name: string;
/** Entities - Danh sách cảm biến */
entities: Entity[];
}
interface Entity {
/** Entity ID - Định danh duy nhất của cảm biến */
entityId: string;
/** Type - Loại cảm biến (vd: 'bin' - nhị phân 0/1, 'bin_t' - nhị phân có trigger) */
type: string;
/** Name - Tên hiển thị */
name: string;
/** Active - Đã kích hoạt cảm biến này hay chưa (1=đã kích hoạt, 0=chưa kích hoạt) */
active?: number;
/** Value - Giá trị hiện tại (1=có, 0=không) */
value?: number;
/** EntityConfig - Cấu hình bổ sung */
config?: EntityConfig[];
}
interface EntityConfig {
/** Level - Mức độ cảnh báo
0 = info,
1 = warning,
2 = critical */
level?: 0 | 1 | 2;
/** Normal Condition - Điều kiện bình thường */
normalCondition?: number;
/** SubType - Phân loại chi tiết */
subType?: string;
/** Children - Các cấu hình con */
children?: EntityConfigChild[];
}
interface EntityConfigChild {
/** Type - Loại điều kiện */
type: string;
children?: EntityConfigChildDetail[];
}
interface EntityConfigChildDetail {
/** entity ID - Cảm biến được theo dõi */
entityId: string;
/** Value - Ngưỡng giá trị */
value?: number;
/** Operation - Toán tử so sánh:
* 0 = == (bằng)
* 1 = != (khác)
* 2 = > (lớn hơn)
* 3 = >= (lớn hơn hoặc bằng)
* 4 = < (nhỏ hơn)
* 5 = <= (nhỏ hơn hoặc bằng) */
operation?: number;
/** Duration - Thời gian duy trì (giây) */
duration?: number;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

@@ -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;
@@ -9,16 +9,22 @@ class WSClient {
/** /**
* Kết nối tới WebSocket server. * Kết nối tới WebSocket server.
* @param url Địa chỉ WebSocket server * @param url Địa chỉ WebSocket server (có thể là relative path như /mqtt)
* @param isAuthenticated Có sử dụng token xác thực hay không * @param isAuthenticated Có sử dụng token xác thực hay không
*/ */
connect(url: string, isAuthenticated: boolean) { connect(url: string, isAuthenticated: boolean) {
if (this.ws) return; if (this.ws) return;
let token = ''; let token = '';
if (isAuthenticated) { if (isAuthenticated) {
token = getToken(); token = getAccessToken();
} }
const wsUrl = isAuthenticated ? `${url}?token=${token}` : url; let wsUrl = url;
if (url.startsWith('/')) {
// Relative path, prepend base WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}${url}`;
}
wsUrl = isAuthenticated ? `${wsUrl}?token=${token}` : wsUrl;
this.ws = new ReconnectingWebSocket(wsUrl, [], { this.ws = new ReconnectingWebSocket(wsUrl, [], {
maxRetries: 10, maxRetries: 10,
maxReconnectionDelay: 10000, maxReconnectionDelay: 10000,