Compare commits
11 Commits
256ce06ea2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 057c7885cf | |||
| ea5fc0a617 | |||
| 9d211ed43c | |||
| 674d53bcc5 | |||
| 4af34eab3e | |||
| d619534a73 | |||
| 78162fc0cb | |||
| 155101491b | |||
| a011405d92 | |||
| afe50dbd07 | |||
|
|
8af31a0435 |
@@ -5,6 +5,8 @@ import {
|
|||||||
forgotPasswordRoute,
|
forgotPasswordRoute,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
managerCameraRoute,
|
managerCameraRoute,
|
||||||
|
managerDashboardRoute,
|
||||||
|
managerDeviceTerminalRoute,
|
||||||
managerRouteBase,
|
managerRouteBase,
|
||||||
notFoundRoute,
|
notFoundRoute,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
@@ -28,7 +30,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
...managerRouteBase,
|
...managerRouteBase,
|
||||||
routes: [...commonManagerRoutes, managerCameraRoute],
|
routes: [
|
||||||
|
managerDashboardRoute,
|
||||||
|
...commonManagerRoutes,
|
||||||
|
managerCameraRoute,
|
||||||
|
managerDeviceTerminalRoute,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
getRefreshToken,
|
getRefreshToken,
|
||||||
setAccessToken,
|
setAccessToken,
|
||||||
} from '@/utils/storage';
|
} from '@/utils/storage';
|
||||||
import { history, request, RequestConfig } from '@umijs/max';
|
import { history, RequestConfig } from '@umijs/max';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
|
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
|
||||||
let refreshingTokenPromise: Promise<string | null> | null = null;
|
let refreshingTokenPromise: Promise<string | null> | null = null;
|
||||||
@@ -52,7 +53,8 @@ export const handleRequestConfig: RequestConfig = {
|
|||||||
return (
|
return (
|
||||||
(status >= 200 && status < 300) ||
|
(status >= 200 && status < 300) ||
|
||||||
status === HTTPSTATUS.HTTP_NOTFOUND ||
|
status === HTTPSTATUS.HTTP_NOTFOUND ||
|
||||||
status === HTTPSTATUS.HTTP_UNAUTHORIZED
|
status === HTTPSTATUS.HTTP_UNAUTHORIZED ||
|
||||||
|
status === HTTPSTATUS.HTTP_FORBIDDEN
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
@@ -121,15 +123,16 @@ export const handleRequestConfig: RequestConfig = {
|
|||||||
// Unwrap data from backend response
|
// Unwrap data from backend response
|
||||||
responseInterceptors: [
|
responseInterceptors: [
|
||||||
async (response: any, options: any) => {
|
async (response: any, options: any) => {
|
||||||
const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN);
|
const isRefreshRequest = response.config.url?.includes(
|
||||||
const alreadyRetried = options?.skipAuthRefresh === true;
|
API_PATH_REFRESH_TOKEN,
|
||||||
|
);
|
||||||
|
// console.log('Is refresh request:', isRefreshRequest);
|
||||||
|
|
||||||
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
|
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
|
||||||
if (
|
if (
|
||||||
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
|
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
|
||||||
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
|
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
|
||||||
!isRefreshRequest &&
|
!isRefreshRequest
|
||||||
!alreadyRetried
|
|
||||||
) {
|
) {
|
||||||
const newToken = await getValidAccessToken();
|
const newToken = await getValidAccessToken();
|
||||||
console.log('Access Token hết hạn, đang refresh...');
|
console.log('Access Token hết hạn, đang refresh...');
|
||||||
@@ -157,22 +160,37 @@ export const handleRequestConfig: RequestConfig = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild request options from config
|
||||||
|
const originalConfig = response.config;
|
||||||
|
|
||||||
|
// Parse data if it is a JSON string to avoid double serialization when using axios directly
|
||||||
|
let data = originalConfig.data;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parse error, use original string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newOptions = {
|
const newOptions = {
|
||||||
...options,
|
url: originalConfig.url,
|
||||||
|
method: originalConfig.method,
|
||||||
headers: {
|
headers: {
|
||||||
...(options.headers || {}),
|
...(originalConfig.headers || {}),
|
||||||
Authorization: `${newToken}`,
|
Authorization: `${newToken}`,
|
||||||
},
|
},
|
||||||
skipAuthRefresh: true,
|
data: data,
|
||||||
|
params: originalConfig.params,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gọi lại request gốc với accessToken mới
|
// Gọi lại request gốc với accessToken mới bằng axios để tránh double-unwrap
|
||||||
return request(response.url, newOptions);
|
return axios(newOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
|
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
|
||||||
(isRefreshRequest || alreadyRetried)
|
isRefreshRequest
|
||||||
) {
|
) {
|
||||||
clearAllData();
|
clearAllData();
|
||||||
history.push(ROUTE_LOGIN);
|
history.push(ROUTE_LOGIN);
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
getRefreshToken,
|
getRefreshToken,
|
||||||
setAccessToken,
|
setAccessToken,
|
||||||
} from '@/utils/storage';
|
} from '@/utils/storage';
|
||||||
import { history, request, RequestConfig } from '@umijs/max';
|
import { history, RequestConfig } from '@umijs/max';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
|
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
|
||||||
let refreshingTokenPromise: Promise<string | null> | null = null;
|
let refreshingTokenPromise: Promise<string | null> | null = null;
|
||||||
@@ -52,7 +53,8 @@ export const handleRequestConfig: RequestConfig = {
|
|||||||
return (
|
return (
|
||||||
(status >= 200 && status < 300) ||
|
(status >= 200 && status < 300) ||
|
||||||
status === HTTPSTATUS.HTTP_NOTFOUND ||
|
status === HTTPSTATUS.HTTP_NOTFOUND ||
|
||||||
status === HTTPSTATUS.HTTP_UNAUTHORIZED
|
status === HTTPSTATUS.HTTP_UNAUTHORIZED ||
|
||||||
|
status === HTTPSTATUS.HTTP_FORBIDDEN
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
@@ -123,15 +125,17 @@ export const handleRequestConfig: RequestConfig = {
|
|||||||
// Unwrap data from backend response
|
// Unwrap data from backend response
|
||||||
responseInterceptors: [
|
responseInterceptors: [
|
||||||
async (response: any, options: any) => {
|
async (response: any, options: any) => {
|
||||||
const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN);
|
// const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN); // response.url may be undefined or different in prod
|
||||||
const alreadyRetried = options?.skipAuthRefresh === true;
|
const isRefreshRequest = response.config.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
|
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
|
||||||
if (
|
if (
|
||||||
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
|
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
|
||||||
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
|
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
|
||||||
!isRefreshRequest &&
|
!isRefreshRequest
|
||||||
!alreadyRetried
|
|
||||||
) {
|
) {
|
||||||
const newToken = await getValidAccessToken();
|
const newToken = await getValidAccessToken();
|
||||||
|
|
||||||
@@ -149,22 +153,37 @@ export const handleRequestConfig: RequestConfig = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild request options from config
|
||||||
|
const originalConfig = response.config;
|
||||||
|
|
||||||
|
// Parse data if it is a JSON string to avoid double serialization when using axios directly
|
||||||
|
let data = originalConfig.data;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parse error, use original string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newOptions = {
|
const newOptions = {
|
||||||
...options,
|
url: originalConfig.url,
|
||||||
|
method: originalConfig.method,
|
||||||
headers: {
|
headers: {
|
||||||
...(options.headers || {}),
|
...(originalConfig.headers || {}),
|
||||||
Authorization: `${newToken}`,
|
Authorization: `${newToken}`,
|
||||||
},
|
},
|
||||||
skipAuthRefresh: true,
|
data: data,
|
||||||
|
params: originalConfig.params,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gọi lại request gốc với accessToken mới
|
// Gọi lại request gốc với accessToken mới bằng axios để tránh double-unwrap
|
||||||
return request(response.url, newOptions);
|
return axios(newOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
|
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
|
||||||
(isRefreshRequest || alreadyRetried)
|
isRefreshRequest
|
||||||
) {
|
) {
|
||||||
clearAllData();
|
clearAllData();
|
||||||
history.push(ROUTE_LOGIN);
|
history.push(ROUTE_LOGIN);
|
||||||
|
|||||||
@@ -97,11 +97,22 @@ export const managerCameraRoute = {
|
|||||||
component: './Manager/Device/Camera',
|
component: './Manager/Device/Camera',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const managerDeviceTerminalRoute = {
|
||||||
|
path: '/manager/devices/:thingId/terminal',
|
||||||
|
component: './Manager/Device/Terminal',
|
||||||
|
};
|
||||||
|
|
||||||
export const managerRouteBase = {
|
export const managerRouteBase = {
|
||||||
name: 'manager',
|
name: 'manager',
|
||||||
icon: 'icon-setting',
|
icon: 'icon-setting',
|
||||||
path: '/manager',
|
path: '/manager',
|
||||||
access: 'canAdmin_SysAdmin',
|
access: 'canAdmin_SysAdmin',
|
||||||
|
hideChildrenInMenu: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const managerDashboardRoute = {
|
||||||
|
path: '/manager',
|
||||||
|
component: './Manager/Dashboard',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const notFoundRoute = {
|
export const notFoundRoute = {
|
||||||
|
|||||||
595
docs/encryption-flow.md
Normal file
595
docs/encryption-flow.md
Normal file
@@ -0,0 +1,595 @@
|
|||||||
|
# 🔐 Luồng Hoạt Động - Hệ Thống Lưu Mật Khẩu AES-256
|
||||||
|
|
||||||
|
**Version:** 2.0 (AES-256 Encryption)
|
||||||
|
**File:** `src/utils/rememberMe.ts`
|
||||||
|
**Loại Bảo Mật:** AES-256 (Advanced Encryption Standard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Mục Lục
|
||||||
|
|
||||||
|
1. [Tổng Quan](#tổng-quan)
|
||||||
|
2. [Kiến Thức Nền Tảng](#kiến-thức-nền-tảng)
|
||||||
|
3. [Luồng Lưu Thông Tin](#luồng-lưu-thông-tin)
|
||||||
|
4. [Luồng Tải Thông Tin](#luồng-tải-thông-tin)
|
||||||
|
5. [Chi Tiết Mã Hóa AES-256](#chi-tiết-mã-hóa-aes-256)
|
||||||
|
6. [Ví Dụ Cụ Thể](#ví-dụ-cụ-thể)
|
||||||
|
7. [Xử Lý Lỗi](#xử-lý-lỗi)
|
||||||
|
8. [So Sánh Bảo Mật](#so-sánh-bảo-mật)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Tổng Quan
|
||||||
|
|
||||||
|
Hệ thống lưu mật khẩu "Remember Me" sử dụng **AES-256 encryption** để bảo vệ thông tin đăng nhập người dùng khi lưu vào `localStorage`.
|
||||||
|
|
||||||
|
### Các Bước Chính:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. Nhập Email + Password từ form đăng nhập │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ 2. Tạo Object: { email, password } │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ 3. Chuyển thành JSON string │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ 4. Mã hóa AES-256 (với SECRET_KEY) │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ 5. Lưu ciphertext vào localStorage │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ 6. Khi cần: Giải mã và lấy lại thông tin │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏫 Kiến Thức Nền Tảng
|
||||||
|
|
||||||
|
### AES-256 là gì?
|
||||||
|
|
||||||
|
**AES** = Advanced Encryption Standard (chuẩn mã hóa quốc tế)
|
||||||
|
**256** = Khóa bí mật dài 256 bit (32 bytes)
|
||||||
|
|
||||||
|
### Tại sao dùng AES-256?
|
||||||
|
|
||||||
|
| Tiêu Chí | Chi Tiết |
|
||||||
|
| --- | --- |
|
||||||
|
| **Độ An Toàn** | ✅ Hầu như không thể bẻ khóa (2^256 khả năng) |
|
||||||
|
| **Tốc Độ** | ✅ Nhanh (xử lý được tỷ tấn dữ liệu/giây) |
|
||||||
|
| **Tiêu Chuẩn** | ✅ Được chính phủ Mỹ & thế giới sử dụng |
|
||||||
|
| **IV (Initialization Vector)** | ✅ Tự động tạo ngẫu nhiên mỗi lần |
|
||||||
|
| **Pattern Hiding** | ✅ Cùng plaintext → khác ciphertext |
|
||||||
|
|
||||||
|
### IV (Initialization Vector) là gì?
|
||||||
|
|
||||||
|
- **Định nghĩa:** Một chuỗi bit ngẫu nhiên 128-bit (16 bytes)
|
||||||
|
- **Chức năng:** Đảm bảo cùng plaintext lại được mã hóa khác
|
||||||
|
- **Ví dụ:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Plaintext: "password"
|
||||||
|
|
||||||
|
Lần 1: IV = random_1 → Ciphertext = "aB3dE7..."
|
||||||
|
Lần 2: IV = random_2 → Ciphertext = "xY9pQ2..." (khác!)
|
||||||
|
Lần 3: IV = random_3 → Ciphertext = "kL5mR8..." (khác!)
|
||||||
|
|
||||||
|
Cả 3 lần đếu giải mã lại thành "password"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Luồng Lưu Thông Tin
|
||||||
|
|
||||||
|
### Hàm: `saveCredentials(email, password)`
|
||||||
|
|
||||||
|
```
|
||||||
|
Đầu Vào:
|
||||||
|
email = "user@gmail.com"
|
||||||
|
password = "MyPassword123"
|
||||||
|
↓
|
||||||
|
┌────────────────────────────────┐
|
||||||
|
│ Bước 1: Tạo Object Credentials │
|
||||||
|
└────────────────────────────────┘
|
||||||
|
credentials = {
|
||||||
|
email: "user@gmail.com",
|
||||||
|
password: "MyPassword123"
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Bước 2: Chuyển Object → JSON │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
json = '{"email":"user@gmail.com","password":"MyPassword123"}'
|
||||||
|
↓
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────────────────┐
|
||||||
|
│ Bước 3: Mã Hóa (AES-256) │
|
||||||
|
│ │
|
||||||
|
│ Hàm: encrypt(json) │
|
||||||
|
│ CryptoJS.AES.encrypt(json, SECRET_KEY) │
|
||||||
|
└───────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
↓
|
||||||
|
encrypted = "U2FsdGVkX1+ZzO2jNxNKbvH..."
|
||||||
|
(output = Salt + IV + Ciphertext, tất cả dạng Base64)
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Bước 4: Lưu vào localStorage │
|
||||||
|
│ localStorage.setItem(REMEMBER_ME_KEY, │
|
||||||
|
│ encrypted) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
localStorage['smatec_remember_login'] = "U2FsdGVkX1+ZzO2jNxNKbvH..."
|
||||||
|
|
||||||
|
Đầu Ra: ✅ Mật khẩu được mã hóa & lưu an toàn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Chi Tiết:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function saveCredentials(email: string, password: string): void {
|
||||||
|
try {
|
||||||
|
// Bước 1: Tạo object
|
||||||
|
const credentials: RememberedCredentials = { email, password };
|
||||||
|
|
||||||
|
// Bước 2: JSON stringify
|
||||||
|
const json = JSON.stringify(credentials);
|
||||||
|
// Kết quả: '{"email":"user@gmail.com","password":"MyPassword123"}'
|
||||||
|
|
||||||
|
// Bước 3: Mã hóa AES-256
|
||||||
|
const encrypted = encrypt(json);
|
||||||
|
// Kết quả: "U2FsdGVkX1+ZzO2jNxNKbvH..."
|
||||||
|
|
||||||
|
// Bước 4: Lưu vào localStorage
|
||||||
|
localStorage.setItem(REMEMBER_ME_KEY, encrypted);
|
||||||
|
// Lưu thành công ✅
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving credentials:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Luồng Tải Thông Tin
|
||||||
|
|
||||||
|
### Hàm: `loadCredentials()`
|
||||||
|
|
||||||
|
```
|
||||||
|
Đầu Vào: (Không có tham số - tự động từ localStorage)
|
||||||
|
↓
|
||||||
|
┌────────────────────────────────────────┐
|
||||||
|
│ Bước 1: Lấy Dữ Liệu Từ localStorage │
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
encrypted = localStorage.getItem(REMEMBER_ME_KEY)
|
||||||
|
// Kết quả: "U2FsdGVkX1+ZzO2jNxNKbvH..." hoặc null
|
||||||
|
↓
|
||||||
|
┌────────────────────────────────┐
|
||||||
|
│ Bước 2: Kiểm Tra Null │
|
||||||
|
└────────────────────────────────┘
|
||||||
|
if (!encrypted) {
|
||||||
|
return null ❌ (Không có dữ liệu lưu)
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
┌───────────────────────────────────────────┐
|
||||||
|
│ Bước 3: Giải Mã (AES-256) │
|
||||||
|
│ │
|
||||||
|
│ Hàm: decrypt(encrypted) │
|
||||||
|
│ CryptoJS.AES.decrypt(encrypted, │
|
||||||
|
│ SECRET_KEY) │
|
||||||
|
└───────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
↓
|
||||||
|
decrypted_text = '{"email":"user@gmail.com","password":"MyPassword123"}'
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Bước 4: Kiểm Tra Decode Hợp Lệ │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
if (!decrypted_text) {
|
||||||
|
clearCredentials() // Xóa dữ liệu lỗi
|
||||||
|
return null ❌
|
||||||
|
}
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ Bước 5: Parse JSON │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
credentials = JSON.parse(decrypted_text)
|
||||||
|
// Kết quả:
|
||||||
|
// {
|
||||||
|
// email: "user@gmail.com",
|
||||||
|
// password: "MyPassword123"
|
||||||
|
// }
|
||||||
|
↓
|
||||||
|
return credentials ✅
|
||||||
|
|
||||||
|
Đầu Ra: RememberedCredentials object hoặc null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Chi Tiết:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function loadCredentials(): RememberedCredentials | null {
|
||||||
|
try {
|
||||||
|
// Bước 1: Lấy từ localStorage
|
||||||
|
const encrypted = localStorage.getItem(REMEMBER_ME_KEY);
|
||||||
|
|
||||||
|
// Bước 2: Kiểm tra null
|
||||||
|
if (!encrypted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bước 3: Giải mã AES-256
|
||||||
|
const decrypted = decrypt(encrypted);
|
||||||
|
// Kết quả: '{"email":"user@gmail.com","password":"MyPassword123"}'
|
||||||
|
|
||||||
|
// Bước 4: Kiểm tra giải mã hợp lệ
|
||||||
|
if (!decrypted) {
|
||||||
|
clearCredentials();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bước 5: Parse JSON
|
||||||
|
const credentials: RememberedCredentials = JSON.parse(decrypted);
|
||||||
|
|
||||||
|
// Trả về object credentials ✅
|
||||||
|
return credentials;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading credentials:', error);
|
||||||
|
clearCredentials();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Chi Tiết Mã Hóa AES-256
|
||||||
|
|
||||||
|
### Hàm Encrypt
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function encrypt(data: string): string {
|
||||||
|
try {
|
||||||
|
// CryptoJS.AES.encrypt() tự động:
|
||||||
|
// 1. Tạo salt ngẫu nhiên (8 bytes)
|
||||||
|
// 2. Từ salt + SECRET_KEY → tạo key & IV (PBKDF2)
|
||||||
|
// 3. Mã hóa data bằng AES-256-CBC
|
||||||
|
// 4. Trả về: Salt + IV + Ciphertext (tất cả Base64)
|
||||||
|
|
||||||
|
const encrypted = CryptoJS.AES.encrypt(data, SECRET_KEY).toString();
|
||||||
|
return encrypted;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Encryption error:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cấu Trúc Output:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Ciphertext cuối cùng = "U2FsdGVkX1+ZzO2jNxNKbvH..."
|
||||||
|
↓
|
||||||
|
Được chia thành 3 phần:
|
||||||
|
│ Part 1 │ Part 2 │ Part 3 │
|
||||||
|
│ Magic String │ Salt (8) │ IV + Ciphertext │
|
||||||
|
│ "Salted__" │ random │ │
|
||||||
|
└──────────────┴───────────┴─────────────────┘
|
||||||
|
↓
|
||||||
|
Tất cả encode Base64
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hàm Decrypt
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function decrypt(encrypted: string): string {
|
||||||
|
try {
|
||||||
|
// CryptoJS.AES.decrypt() tự động:
|
||||||
|
// 1. Decode Base64 ciphertext
|
||||||
|
// 2. Trích xuất Salt từ đầu
|
||||||
|
// 3. Từ salt + SECRET_KEY → tạo key & IV (PBKDF2)
|
||||||
|
// 4. Giải mã dữ liệu bằng AES-256-CBC
|
||||||
|
// 5. Trả về plaintext
|
||||||
|
|
||||||
|
const decrypted = CryptoJS.AES.decrypt(encrypted, SECRET_KEY);
|
||||||
|
|
||||||
|
// Chuyển từ WordArray sang UTF8 string
|
||||||
|
const result = decrypted.toString(CryptoJS.enc.Utf8);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Decryption error:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Biểu Đồ Quy Trình AES-256
|
||||||
|
|
||||||
|
```
|
||||||
|
ENCRYPTION SIDE (Lưu Mật Khẩu)
|
||||||
|
═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Plaintext: "password"
|
||||||
|
│
|
||||||
|
├─→ [PBKDF2 + Salt] ──→ Key (256-bit) + IV (128-bit)
|
||||||
|
│ │
|
||||||
|
│ ↓
|
||||||
|
└──────────────────→ [AES-256-CBC Engine]
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
Ciphertext
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
[Base64 Encode]
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
"U2FsdGVkX1+ZzO2jNxNKbvH..."
|
||||||
|
|
||||||
|
|
||||||
|
DECRYPTION SIDE (Tải Mật Khẩu)
|
||||||
|
═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
Ciphertext: "U2FsdGVkX1+ZzO2jNxNKbvH..."
|
||||||
|
│
|
||||||
|
├─→ [Base64 Decode]
|
||||||
|
│
|
||||||
|
├─→ Extract Salt (8 bytes)
|
||||||
|
│ │
|
||||||
|
│ └──→ [PBKDF2] ──→ Key (256-bit) + IV (128-bit)
|
||||||
|
│
|
||||||
|
├─→ Extract Ciphertext
|
||||||
|
│ │
|
||||||
|
│ └──→ [AES-256-CBC Engine]
|
||||||
|
│ │
|
||||||
|
│ ↓
|
||||||
|
└──────────→ Plaintext: "password"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Ví Dụ Cụ Thể
|
||||||
|
|
||||||
|
### Ví Dụ 1: Lưu Email & Mật Khẩu
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
saveCredentials('john@example.com', 'SecurePass2024!');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bước Bước:**
|
||||||
|
|
||||||
|
| Bước | Dữ Liệu | Mô Tả |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `{ email: "john@example.com", password: "SecurePass2024!" }` | Object credentials |
|
||||||
|
| 2 | `'{"email":"john@example.com","password":"SecurePass2024!"}'` | JSON stringify |
|
||||||
|
| 3 | `U2FsdGVkX1...abc123...` | AES-256 encrypt + Base64 |
|
||||||
|
| 4 | localStorage | Lưu thành công ✅ |
|
||||||
|
|
||||||
|
**Trong localStorage:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
localStorage = {
|
||||||
|
smatec_remember_login: 'U2FsdGVkX1+ZzO2jNxNKbvHJmVaFptBWc0p...',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ví Dụ 2: Tải Email & Mật Khẩu
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
loadCredentials();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bước Bước:**
|
||||||
|
|
||||||
|
| Bước | Dữ Liệu | Mô Tả |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `"U2FsdGVkX1+ZzO2jNxNKbvHJmVaFptBWc0p..."` | Lấy từ localStorage |
|
||||||
|
| 2 | ✅ Tồn tại | Kiểm tra có dữ liệu |
|
||||||
|
| 3 | `'{"email":"john@example.com","password":"SecurePass2024!"}'` | AES-256 decrypt |
|
||||||
|
| 4 | ✅ Valid JSON | Kiểm tra hợp lệ |
|
||||||
|
| 5 | `{ email: "john@example.com", password: "SecurePass2024!" }` | JSON parse |
|
||||||
|
| 6 | Trả về object | ✅ |
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
email: "john@example.com",
|
||||||
|
password: "SecurePass2024!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Xử Lý Lỗi
|
||||||
|
|
||||||
|
### Trường Hợp 1: Không Có Dữ Liệu Lưu
|
||||||
|
|
||||||
|
```
|
||||||
|
loadCredentials()
|
||||||
|
↓
|
||||||
|
Kiểm tra localStorage
|
||||||
|
↓
|
||||||
|
encrypted === null
|
||||||
|
↓
|
||||||
|
return null ← Không có gì để tải
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trường Hợp 2: Dữ Liệu Bị Hỏng
|
||||||
|
|
||||||
|
```
|
||||||
|
loadCredentials()
|
||||||
|
↓
|
||||||
|
Lấy được dữ liệu
|
||||||
|
↓
|
||||||
|
Giải mã thất bại (decrypt() trả về '')
|
||||||
|
↓
|
||||||
|
clearCredentials() ← Xóa dữ liệu lỗi
|
||||||
|
↓
|
||||||
|
return null ← Không thể tải
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trường Hợp 3: JSON Parse Lỗi
|
||||||
|
|
||||||
|
```
|
||||||
|
loadCredentials()
|
||||||
|
↓
|
||||||
|
Giải mã thành công
|
||||||
|
↓
|
||||||
|
JSON.parse() thất bại (dữ liệu không phải JSON)
|
||||||
|
↓
|
||||||
|
catch block → clearCredentials()
|
||||||
|
↓
|
||||||
|
return null ← Lỗi JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trường Hợp 4: LocalStorage Không Khả Dụng
|
||||||
|
|
||||||
|
```
|
||||||
|
Các hàm try-catch sẽ bắt lỗi
|
||||||
|
↓
|
||||||
|
Trả về false / null / log error
|
||||||
|
↓
|
||||||
|
Ứng dụng vẫn hoạt động bình thường (graceful degradation)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 So Sánh Bảo Mật
|
||||||
|
|
||||||
|
### XOR Cipher (Cũ) vs AES-256 (Mới)
|
||||||
|
|
||||||
|
```
|
||||||
|
╔════════════════════════════════════════════════════════════════╗
|
||||||
|
║ XOR CIPHER (CŨ) │ AES-256 (MỚI) ║
|
||||||
|
╠════════════════════════════════════════════════════════════════╣
|
||||||
|
║ Mã Hóa Kiểu │ XOR từng ký tự │ Mã hóa khối ║
|
||||||
|
║ Độ An Toàn │ ⚠️ Rất Thấp │ ✅ Cực Cao ║
|
||||||
|
║ Key Length │ Xoay vòng bằng khóa │ 256-bit (32 bytes) ║
|
||||||
|
║ IV │ ❌ Không có │ ✅ Ngẫu nhiên ║
|
||||||
|
║ Brute Force │ ⚠️ Dễ (2^key_length) │ ✅ Không Thể ║
|
||||||
|
║ Pattern │ ⚠️ Có pattern │ ✅ Ẩn pattern ║
|
||||||
|
║ │ (cùng text → lại) │ (random IV) ║
|
||||||
|
║ Block Mode │ N/A │ ✅ CBC Mode ║
|
||||||
|
║ Salt │ ❌ Không │ ✅ Có PBKDF2 ║
|
||||||
|
║ Performance │ ✅ Cực Nhanh │ ✅ Nhanh (HW opt) ║
|
||||||
|
║ Standard │ ❌ Custom/Weak │ ✅ NIST/Military ║
|
||||||
|
╚════════════════════════════════════════════════════════════════╝
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ví Dụ Tấn Công:
|
||||||
|
|
||||||
|
**XOR Cipher:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Nếu biết:
|
||||||
|
- Ciphertext = "abc123"
|
||||||
|
- Plaintext đối với ciphertext khác = "password"
|
||||||
|
|
||||||
|
Có thể dễ dàng suy ra SECRET_KEY bằng XOR:
|
||||||
|
plaintext XOR ciphertext = key
|
||||||
|
```
|
||||||
|
|
||||||
|
**AES-256:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Ngay cả biết:
|
||||||
|
- Ciphertext = "U2FsdGVkX1+ZzO2jNxNKbvH..."
|
||||||
|
- Plaintext = "password"
|
||||||
|
- IV được sử dụng
|
||||||
|
|
||||||
|
Vẫn không thể suy ra SECRET_KEY (cần brute force 2^256 khả năng)
|
||||||
|
≈ 10^77 năm với máy tính hiện đại
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Các Hàm Hỗ Trợ
|
||||||
|
|
||||||
|
### 1. `clearCredentials()`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function clearCredentials(): void {
|
||||||
|
localStorage.removeItem(REMEMBER_ME_KEY);
|
||||||
|
// Xóa hoàn toàn dữ liệu khỏi localStorage
|
||||||
|
// Dùng khi: Người dùng logout hoặc "Forget Me"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `hasSavedCredentials()`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function hasSavedCredentials(): boolean {
|
||||||
|
try {
|
||||||
|
const encrypted = localStorage.getItem(REMEMBER_ME_KEY);
|
||||||
|
return encrypted !== null;
|
||||||
|
// Kiểm tra xem có dữ liệu lưu hay không
|
||||||
|
// Dùng khi: Hiển thị checkbox "Remember Me"
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Luồng Sử Dụng Trong Ứng Dụng
|
||||||
|
|
||||||
|
### Sơ Đồ Hoàn Chỉnh:
|
||||||
|
|
||||||
|
```
|
||||||
|
LOGIN PAGE
|
||||||
|
│
|
||||||
|
├─→ User nhập email + password
|
||||||
|
│
|
||||||
|
├─→ Tick checkbox "Remember Me"?
|
||||||
|
│ │
|
||||||
|
│ ├─→ YES: saveCredentials(email, pwd)
|
||||||
|
│ │ └─→ Mã hóa + Lưu localStorage ✅
|
||||||
|
│ │
|
||||||
|
│ └─→ NO: Không lưu gì
|
||||||
|
│
|
||||||
|
├─→ Gửi login request lên server
|
||||||
|
│
|
||||||
|
└─→ Server xác thực OK ✅
|
||||||
|
│
|
||||||
|
└─→ Redirect tới Dashboard
|
||||||
|
|
||||||
|
|
||||||
|
SUBSEQUENT LOGIN (Lần Đăng Nhập Tiếp Theo)
|
||||||
|
│
|
||||||
|
├─→ Check: hasSavedCredentials()?
|
||||||
|
│ │
|
||||||
|
│ ├─→ YES: loadCredentials()
|
||||||
|
│ │ └─→ Giải mã + Parse ✅
|
||||||
|
│ │ └─→ Auto-fill form
|
||||||
|
│ │
|
||||||
|
│ └─→ NO: Form trống (User nhập thủ công)
|
||||||
|
│
|
||||||
|
└─→ User xác nhận + submit
|
||||||
|
│
|
||||||
|
└─→ Login
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Tóm Tắt
|
||||||
|
|
||||||
|
| Chức Năng | Hàm | Mô Tả |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Lưu | `saveCredentials(email, pwd)` | Mã hóa AES-256 + lưu localStorage |
|
||||||
|
| Tải | `loadCredentials()` | Lấy từ localStorage + giải mã |
|
||||||
|
| Xóa | `clearCredentials()` | Xóa hoàn toàn từ localStorage |
|
||||||
|
| Kiểm Tra | `hasSavedCredentials()` | Kiểm tra có dữ liệu hay không |
|
||||||
|
|
||||||
|
## 🛡️ Kết Luận
|
||||||
|
|
||||||
|
✅ **AES-256** được sử dụng để mã hóa thông tin sensitive
|
||||||
|
✅ **Random IV** đảm bảo mã hóa khác lần nữa
|
||||||
|
✅ **PBKDF2** làm việc với SECRET_KEY để tạo key mạnh
|
||||||
|
✅ **Try-Catch** xử lý tất cả lỗi gracefully
|
||||||
|
✅ **localStorage** là vị trí lưu phù hợp (with encrypted data)
|
||||||
|
|
||||||
|
**Bảo mật hiện tại:** ⭐⭐⭐⭐⭐ (Military Grade)
|
||||||
303
docs/mqtt-client.md
Normal file
303
docs/mqtt-client.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# MQTT Client - Hướng dẫn sử dụng
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
|
||||||
|
MQTT Client (`mqttClient`) là một utility singleton được thiết kế để quản lý kết nối MQTT trong ứng dụng. File này nằm tại `src/utils/mqttClient.ts`.
|
||||||
|
|
||||||
|
## Cài đặt
|
||||||
|
|
||||||
|
Package `mqtt` được cài đặt qua npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install mqtt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cấu trúc
|
||||||
|
|
||||||
|
```
|
||||||
|
src/utils/
|
||||||
|
├── mqttClient.ts # MQTT Client utility
|
||||||
|
└── wsClient.ts # WebSocket Client utility (legacy)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cách sử dụng
|
||||||
|
|
||||||
|
### 1. Import
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mqttClient } from '@/utils/mqttClient';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Kết nối
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Lấy credentials từ user metadata
|
||||||
|
const { frontend_thing_id, frontend_thing_key } =
|
||||||
|
initialState?.currentUserProfile?.metadata || {};
|
||||||
|
|
||||||
|
// Kết nối
|
||||||
|
mqttClient.connect({
|
||||||
|
username: frontend_thing_id,
|
||||||
|
password: frontend_thing_key,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Đăng ký Event Handlers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Khi kết nối thành công
|
||||||
|
const unConnect = mqttClient.onConnect(() => {
|
||||||
|
console.log('MQTT Connected!');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Khi có lỗi
|
||||||
|
const unError = mqttClient.onError((error) => {
|
||||||
|
console.error('MQTT Error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Khi kết nối đóng
|
||||||
|
const unClose = mqttClient.onClose(() => {
|
||||||
|
console.log('MQTT Closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup khi component unmount
|
||||||
|
return () => {
|
||||||
|
unConnect();
|
||||||
|
unError();
|
||||||
|
unClose();
|
||||||
|
mqttClient.disconnect();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Publish Message
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const topic = `channels/${cfg_channel_id}/messages/cameraconfig/gmsv6`;
|
||||||
|
const payload = JSON.stringify(senmlData);
|
||||||
|
|
||||||
|
if (mqttClient.isConnected()) {
|
||||||
|
mqttClient.publish(topic, payload);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Subscribe Topic
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Subscribe
|
||||||
|
mqttClient.subscribe('channels/123/messages/#');
|
||||||
|
|
||||||
|
// Nhận message
|
||||||
|
const unMessage = mqttClient.onMessage((topic, message, packet) => {
|
||||||
|
console.log('Received:', topic, message.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe
|
||||||
|
mqttClient.unsubscribe('channels/123/messages/#');
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
| Method | Mô tả |
|
||||||
|
| ---------------------------- | ---------------------------------- |
|
||||||
|
| `connect(credentials, url?)` | Kết nối MQTT với username/password |
|
||||||
|
| `disconnect()` | Ngắt kết nối |
|
||||||
|
| `subscribe(topic)` | Subscribe vào topic |
|
||||||
|
| `unsubscribe(topic)` | Unsubscribe khỏi topic |
|
||||||
|
| `publish(topic, payload)` | Publish message |
|
||||||
|
| `onConnect(callback)` | Đăng ký callback khi kết nối |
|
||||||
|
| `onClose(callback)` | Đăng ký callback khi đóng |
|
||||||
|
| `onError(callback)` | Đăng ký callback khi lỗi |
|
||||||
|
| `onMessage(callback)` | Đăng ký callback khi nhận message |
|
||||||
|
| `isConnected()` | Kiểm tra trạng thái kết nối |
|
||||||
|
| `getClient()` | Lấy MQTT client gốc |
|
||||||
|
|
||||||
|
## Cấu hình Proxy (Development)
|
||||||
|
|
||||||
|
Trong môi trường development, MQTT được proxy qua config trong `config/proxy.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"/mqtt": {
|
||||||
|
target: "https://gms.smatec.com.vn",
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
## Format SenML
|
||||||
|
|
||||||
|
Dữ liệu MQTT được format theo chuẩn SenML:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const senml = [
|
||||||
|
{
|
||||||
|
bn: `urn:dev:mac:${mac}:`, // Base name
|
||||||
|
n: 'ack', // Name
|
||||||
|
t: Date.now() / 1000, // Timestamp (seconds)
|
||||||
|
vs: uuidv4(), // Value string (ACK ID)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: 'user@email.com', // Email (@ → :)
|
||||||
|
t: Date.now() / 1000,
|
||||||
|
vs: JSON.stringify(config), // Config dạng JSON string
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Topic Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
channels/${channel_id}/messages/${type}/${gw_type}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Phần | Mô tả |
|
||||||
|
| ------------ | ------------------------------------------------------- |
|
||||||
|
| `channel_id` | ID của kênh (từ `thing.metadata.cfg_channel_id`) |
|
||||||
|
| `type` | Loại message: `cameraconfig`, `config`, `log`, `notify` |
|
||||||
|
| `gw_type` | Loại gateway: `gmsv6`, `gmsv5` |
|
||||||
|
|
||||||
|
## Ví dụ hoàn chỉnh
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mqttClient } from '@/utils/mqttClient';
|
||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
const MyComponent = ({ thing }) => {
|
||||||
|
const { initialState } = useModel('@@initialState');
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { frontend_thing_id, frontend_thing_key } =
|
||||||
|
initialState?.currentUserProfile?.metadata || {};
|
||||||
|
|
||||||
|
if (!frontend_thing_id || !frontend_thing_key) return;
|
||||||
|
|
||||||
|
mqttClient.connect({
|
||||||
|
username: frontend_thing_id,
|
||||||
|
password: frontend_thing_key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unConnect = mqttClient.onConnect(() => setConnected(true));
|
||||||
|
const unClose = mqttClient.onClose(() => setConnected(false));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unConnect();
|
||||||
|
unClose();
|
||||||
|
mqttClient.disconnect();
|
||||||
|
};
|
||||||
|
}, [initialState]);
|
||||||
|
|
||||||
|
const handlePublish = () => {
|
||||||
|
const { cfg_channel_id, external_id } = thing.metadata;
|
||||||
|
const topic = `channels/${cfg_channel_id}/messages/cameraconfig/gmsv6`;
|
||||||
|
|
||||||
|
const payload = [
|
||||||
|
{
|
||||||
|
bn: `urn:dev:mac:${external_id.replaceAll('-', '')}:`,
|
||||||
|
n: 'ack',
|
||||||
|
t: Date.now() / 1000,
|
||||||
|
vs: uuidv4(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: initialState?.currentUserProfile?.email?.replaceAll('@', ':'),
|
||||||
|
t: Date.now() / 1000,
|
||||||
|
vs: JSON.stringify({ record_type: 'all' }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (mqttClient.isConnected()) {
|
||||||
|
mqttClient.publish(topic, JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={handlePublish} disabled={!connected}>
|
||||||
|
{connected ? 'Publish' : 'Connecting...'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## So sánh mqttClient vs wsClient
|
||||||
|
|
||||||
|
Dự án có 2 utilities để giao tiếp real-time:
|
||||||
|
|
||||||
|
### Bảng so sánh
|
||||||
|
|
||||||
|
| Tiêu chí | mqttClient | wsClient |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Thư viện** | `mqtt` (MQTT.js) | `reconnecting-websocket` |
|
||||||
|
| **Protocol** | MQTT over WebSocket | WebSocket thuần |
|
||||||
|
| **Xác thực** | Username/Password (MQTT credentials) | Token (access_token) hoặc không |
|
||||||
|
| **Topics** | Hỗ trợ MQTT topics, subscribe/unsubscribe | Không có khái niệm topic |
|
||||||
|
| **Publish** | `publish(topic, payload)` | `send(data)` |
|
||||||
|
| **Subscribe** | `subscribe(topic)` + `onMessage()` | `subscribe(callback)` |
|
||||||
|
| **Reconnect** | Tự động (built-in) | Tự động (reconnecting-websocket) |
|
||||||
|
| **Use case** | Giao tiếp với IoT Gateway/Devices | Giao tiếp WebSocket server |
|
||||||
|
|
||||||
|
### Khi nào dùng mqttClient?
|
||||||
|
|
||||||
|
✅ **Dùng mqttClient khi:**
|
||||||
|
|
||||||
|
- Gửi/nhận dữ liệu với IoT Gateways (GMSv5, GMSv6)
|
||||||
|
- Cấu hình thiết bị (camera, nodes, schedules)
|
||||||
|
- Cần subscribe nhiều topics khác nhau
|
||||||
|
- Làm việc với Mainflux platform
|
||||||
|
|
||||||
|
### Khi nào dùng wsClient?
|
||||||
|
|
||||||
|
✅ **Dùng wsClient khi:**
|
||||||
|
|
||||||
|
- Cần WebSocket connection đơn giản
|
||||||
|
- Giao tiếp với WebSocket server không phải MQTT broker
|
||||||
|
- Xác thực bằng access_token
|
||||||
|
|
||||||
|
### Code comparison
|
||||||
|
|
||||||
|
**mqttClient:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mqttClient } from '@/utils/mqttClient';
|
||||||
|
|
||||||
|
// Connect với username/password
|
||||||
|
mqttClient.connect({
|
||||||
|
username: 'thing_id',
|
||||||
|
password: 'thing_key',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe topic
|
||||||
|
mqttClient.subscribe('channels/123/messages/#');
|
||||||
|
|
||||||
|
// Publish với topic
|
||||||
|
mqttClient.publish('channels/123/messages/config', payload);
|
||||||
|
|
||||||
|
// Nhận message
|
||||||
|
mqttClient.onMessage((topic, message) => {
|
||||||
|
console.log(topic, message.toString());
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**wsClient:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { wsClient } from '@/utils/wsClient';
|
||||||
|
|
||||||
|
// Connect với hoặc không có token
|
||||||
|
wsClient.connect('/mqtt', false);
|
||||||
|
|
||||||
|
// Không có subscribe topic
|
||||||
|
// Chỉ nhận tất cả messages
|
||||||
|
wsClient.subscribe((data) => {
|
||||||
|
console.log(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gửi data (không có topic)
|
||||||
|
wsClient.send({ action: 'update', data: payload });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Xem thêm
|
||||||
|
|
||||||
|
- [Mainflux.md](../Mainflux.md) - Tài liệu giao tiếp MQTT chi tiết
|
||||||
|
- [mqttClient.ts](../src/utils/mqttClient.ts) - Source code MQTT Client
|
||||||
|
- [wsClient.ts](../src/utils/wsClient.ts) - Source code WebSocket Client
|
||||||
424
package-lock.json
generated
424
package-lock.json
generated
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "smatec-frontend",
|
"name": "SMATEC-FRONTEND",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -10,15 +10,22 @@
|
|||||||
"@ant-design/pro-components": "^2.8.10",
|
"@ant-design/pro-components": "^2.8.10",
|
||||||
"@umijs/max": "^4.6.23",
|
"@umijs/max": "^4.6.23",
|
||||||
"antd": "^5.4.0",
|
"antd": "^5.4.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
"mqtt": "^5.15.0",
|
||||||
"ol": "^10.6.1",
|
"ol": "^10.6.1",
|
||||||
"reconnecting-websocket": "^4.4.0"
|
"react-chartjs-2": "^5.3.1",
|
||||||
|
"reconnecting-websocket": "^4.4.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-fit": "^0.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.0.33",
|
"@types/react": "^18.0.33",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||||
"baseline-browser-mapping": "^2.9.6",
|
"baseline-browser-mapping": "^2.9.6",
|
||||||
"husky": "^9",
|
"husky": "^9",
|
||||||
@@ -2842,6 +2849,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@loadable/component": {
|
"node_modules/@loadable/component": {
|
||||||
"version": "5.15.2",
|
"version": "5.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/@loadable/component/-/component-5.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/@loadable/component/-/component-5.15.2.tgz",
|
||||||
@@ -4036,6 +4049,15 @@
|
|||||||
"redux": ">= 3.7.2"
|
"redux": ">= 3.7.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/readable-stream": {
|
||||||
|
"version": "4.0.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
|
||||||
|
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/resolve": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.20.6",
|
"version": "1.20.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
|
||||||
@@ -4060,6 +4082,22 @@
|
|||||||
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==",
|
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "16.0.11",
|
"version": "16.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz",
|
||||||
@@ -7058,6 +7096,18 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/abort-controller": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"event-target-shim": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -7944,6 +7994,58 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bl": {
|
||||||
|
"version": "6.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
|
||||||
|
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/readable-stream": "^4.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"inherits": "^2.0.4",
|
||||||
|
"readable-stream": "^4.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bl/node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bl/node_modules/readable-stream": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"string_decoder": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bn.js": {
|
"node_modules/bn.js": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
|
||||||
@@ -8029,6 +8131,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/broker-factory": {
|
||||||
|
"version": "3.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz",
|
||||||
|
"integrity": "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"fast-unique-numbers": "^9.0.26",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"worker-factory": "^7.0.48"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brorand": {
|
"node_modules/brorand": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
|
||||||
@@ -8422,6 +8536,18 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.5.3",
|
"version": "3.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||||
@@ -8687,6 +8813,12 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/commist": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/common-path-prefix": {
|
"node_modules/common-path-prefix": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz",
|
||||||
@@ -8750,6 +8882,21 @@
|
|||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/connect-history-api-fallback": {
|
"node_modules/connect-history-api-fallback": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz",
|
||||||
@@ -10731,6 +10878,15 @@
|
|||||||
"es5-ext": "~0.10.14"
|
"es5-ext": "~0.10.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/event-target-shim": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eventemitter3": {
|
"node_modules/eventemitter3": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||||
@@ -10943,6 +11099,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-unique-numbers": {
|
||||||
|
"version": "9.0.26",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz",
|
||||||
|
"integrity": "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
@@ -11846,6 +12015,12 @@
|
|||||||
"he": "bin/he"
|
"he": "bin/he"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/help-me": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/history": {
|
"node_modules/history": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
|
||||||
@@ -12350,6 +12525,15 @@
|
|||||||
"loose-envify": "^1.0.0"
|
"loose-envify": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -14290,6 +14474,15 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimist-options": {
|
"node_modules/minimist-options": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz",
|
||||||
@@ -14331,6 +14524,95 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mqtt": {
|
||||||
|
"version": "5.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.0.tgz",
|
||||||
|
"integrity": "sha512-KC+wAssYk83Qu5bT8YDzDYgUJxPhbLeVsDvpY2QvL28PnXYJzC2WkKruyMUgBAZaQ7h9lo9k2g4neRNUUxzgMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/readable-stream": "^4.0.21",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"commist": "^3.2.0",
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"help-me": "^5.0.0",
|
||||||
|
"lru-cache": "^10.4.3",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"mqtt-packet": "^9.0.2",
|
||||||
|
"number-allocator": "^1.0.14",
|
||||||
|
"readable-stream": "^4.7.0",
|
||||||
|
"rfdc": "^1.4.1",
|
||||||
|
"socks": "^2.8.6",
|
||||||
|
"split2": "^4.2.0",
|
||||||
|
"worker-timers": "^8.0.23",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mqtt": "build/bin/mqtt.js",
|
||||||
|
"mqtt_pub": "build/bin/pub.js",
|
||||||
|
"mqtt_sub": "build/bin/sub.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mqtt-packet": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bl": "^6.0.8",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"process-nextick-args": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mqtt/node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mqtt/node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/mqtt/node_modules/readable-stream": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"string_decoder": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -14711,6 +14993,26 @@
|
|||||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/number-allocator": {
|
||||||
|
"version": "1.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
|
||||||
|
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.3.1",
|
||||||
|
"js-sdsl": "4.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/number-allocator/node_modules/js-sdsl": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/js-sdsl"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -17295,6 +17597,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-chartjs-2": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
@@ -17999,7 +18311,6 @@
|
|||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rimraf": {
|
"node_modules/rimraf": {
|
||||||
@@ -18759,6 +19070,30 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/smart-buffer": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0",
|
||||||
|
"npm": ">= 3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socks": {
|
||||||
|
"version": "2.8.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||||
|
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^10.0.1",
|
||||||
|
"smart-buffer": "^4.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0",
|
||||||
|
"npm": ">= 3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonic-boom": {
|
"node_modules/sonic-boom": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz",
|
||||||
@@ -20985,6 +21320,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -21328,6 +21669,19 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist-node/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/v8-compile-cache": {
|
"node_modules/v8-compile-cache": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz",
|
||||||
@@ -22168,6 +22522,53 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/worker-factory": {
|
||||||
|
"version": "7.0.48",
|
||||||
|
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz",
|
||||||
|
"integrity": "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"fast-unique-numbers": "^9.0.26",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/worker-timers": {
|
||||||
|
"version": "8.0.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.30.tgz",
|
||||||
|
"integrity": "sha512-8P7YoMHWN0Tz7mg+9oEhuZdjBIn2z6gfjlJqFcHiDd9no/oLnMGCARCDkV1LR3ccQus62ZdtIp7t3aTKrMLHOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"worker-timers-broker": "^8.0.15",
|
||||||
|
"worker-timers-worker": "^9.0.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/worker-timers-broker": {
|
||||||
|
"version": "8.0.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.15.tgz",
|
||||||
|
"integrity": "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"broker-factory": "^3.1.13",
|
||||||
|
"fast-unique-numbers": "^9.0.26",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"worker-timers-worker": "^9.0.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/worker-timers-worker": {
|
||||||
|
"version": "9.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.13.tgz",
|
||||||
|
"integrity": "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"worker-factory": "^7.0.48"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
@@ -22326,6 +22727,23 @@
|
|||||||
"node": ">=0.4"
|
"node": ">=0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xterm": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||||
|
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/xterm-addon-fit": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
|
||||||
|
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -21,15 +21,25 @@
|
|||||||
"@ant-design/pro-components": "^2.8.10",
|
"@ant-design/pro-components": "^2.8.10",
|
||||||
"@umijs/max": "^4.6.23",
|
"@umijs/max": "^4.6.23",
|
||||||
"antd": "^5.4.0",
|
"antd": "^5.4.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
"mqtt": "^5.15.0",
|
||||||
"ol": "^10.6.1",
|
"ol": "^10.6.1",
|
||||||
"reconnecting-websocket": "^4.4.0"
|
"react-chartjs-2": "^5.3.1",
|
||||||
|
"react-countup": "^6.5.3",
|
||||||
|
"reconnecting-websocket": "^4.4.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"xterm": "^5.3.0",
|
||||||
|
"xterm-addon-fit": "^0.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/react": "^18.0.33",
|
"@types/react": "^18.0.33",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||||
"baseline-browser-mapping": "^2.9.6",
|
"baseline-browser-mapping": "^2.9.6",
|
||||||
"husky": "^9",
|
"husky": "^9",
|
||||||
|
|||||||
330
pnpm-lock.yaml
generated
330
pnpm-lock.yaml
generated
@@ -20,28 +20,58 @@ importers:
|
|||||||
antd:
|
antd:
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.29.3(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 5.29.3(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
chart.js:
|
||||||
|
specifier: ^4.5.1
|
||||||
|
version: 4.5.1
|
||||||
classnames:
|
classnames:
|
||||||
specifier: ^2.5.1
|
specifier: ^2.5.1
|
||||||
version: 2.5.1
|
version: 2.5.1
|
||||||
|
crypto-js:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.19
|
specifier: ^1.11.19
|
||||||
version: 1.11.19
|
version: 1.11.19
|
||||||
moment:
|
moment:
|
||||||
specifier: ^2.30.1
|
specifier: ^2.30.1
|
||||||
version: 2.30.1
|
version: 2.30.1
|
||||||
|
mqtt:
|
||||||
|
specifier: ^5.15.0
|
||||||
|
version: 5.15.0
|
||||||
ol:
|
ol:
|
||||||
specifier: ^10.6.1
|
specifier: ^10.6.1
|
||||||
version: 10.7.0
|
version: 10.7.0
|
||||||
|
react-chartjs-2:
|
||||||
|
specifier: ^5.3.1
|
||||||
|
version: 5.3.1(chart.js@4.5.1)(react@18.3.1)
|
||||||
|
react-countup:
|
||||||
|
specifier: ^6.5.3
|
||||||
|
version: 6.5.3(react@18.3.1)
|
||||||
reconnecting-websocket:
|
reconnecting-websocket:
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
|
uuid:
|
||||||
|
specifier: ^13.0.0
|
||||||
|
version: 13.0.0
|
||||||
|
xterm:
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
|
xterm-addon-fit:
|
||||||
|
specifier: ^0.8.0
|
||||||
|
version: 0.8.0(xterm@5.3.0)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/crypto-js':
|
||||||
|
specifier: ^4.2.2
|
||||||
|
version: 4.2.2
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.0.33
|
specifier: ^18.0.33
|
||||||
version: 18.3.27
|
version: 18.3.27
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^18.0.11
|
specifier: ^18.0.11
|
||||||
version: 18.3.7(@types/react@18.3.27)
|
version: 18.3.7(@types/react@18.3.27)
|
||||||
|
'@types/uuid':
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0
|
||||||
babel-plugin-transform-remove-console:
|
babel-plugin-transform-remove-console:
|
||||||
specifier: ^6.9.4
|
specifier: ^6.9.4
|
||||||
version: 6.9.4
|
version: 6.9.4
|
||||||
@@ -958,6 +988,9 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz}
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.4':
|
||||||
|
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==, tarball: https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz}
|
||||||
|
|
||||||
'@loadable/component@5.15.2':
|
'@loadable/component@5.15.2':
|
||||||
resolution: {integrity: sha512-ryFAZOX5P2vFkUdzaAtTG88IGnr9qxSdvLRvJySXcUA4B4xVWurUNADu3AnKPksxOZajljqTrDEDcYjeL4lvLw==, tarball: https://registry.npmjs.org/@loadable/component/-/component-5.15.2.tgz}
|
resolution: {integrity: sha512-ryFAZOX5P2vFkUdzaAtTG88IGnr9qxSdvLRvJySXcUA4B4xVWurUNADu3AnKPksxOZajljqTrDEDcYjeL4lvLw==, tarball: https://registry.npmjs.org/@loadable/component/-/component-5.15.2.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1302,6 +1335,9 @@ packages:
|
|||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==, tarball: https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz}
|
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==, tarball: https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz}
|
||||||
|
|
||||||
|
'@types/crypto-js@4.2.2':
|
||||||
|
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==, tarball: https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==, tarball: https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz}
|
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==, tarball: https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz}
|
||||||
|
|
||||||
@@ -1388,6 +1424,9 @@ packages:
|
|||||||
'@types/react@18.3.27':
|
'@types/react@18.3.27':
|
||||||
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==, tarball: https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz}
|
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==, tarball: https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz}
|
||||||
|
|
||||||
|
'@types/readable-stream@4.0.23':
|
||||||
|
resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==, tarball: https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz}
|
||||||
|
|
||||||
'@types/resolve@1.20.6':
|
'@types/resolve@1.20.6':
|
||||||
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==, tarball: https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz}
|
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==, tarball: https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz}
|
||||||
|
|
||||||
@@ -1400,6 +1439,12 @@ packages:
|
|||||||
'@types/use-sync-external-store@0.0.3':
|
'@types/use-sync-external-store@0.0.3':
|
||||||
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==, tarball: https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz}
|
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==, tarball: https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz}
|
||||||
|
|
||||||
|
'@types/uuid@10.0.0':
|
||||||
|
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==, tarball: https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==, tarball: https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz}
|
||||||
|
|
||||||
'@types/yargs-parser@21.0.3':
|
'@types/yargs-parser@21.0.3':
|
||||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==, tarball: https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz}
|
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==, tarball: https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz}
|
||||||
|
|
||||||
@@ -1815,6 +1860,10 @@ packages:
|
|||||||
'@xtuc/long@4.2.2':
|
'@xtuc/long@4.2.2':
|
||||||
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==, tarball: https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz}
|
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==, tarball: https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz}
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==, tarball: https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz}
|
||||||
|
engines: {node: '>=6.5'}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==, tarball: https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz}
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==, tarball: https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2081,6 +2130,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
bl@6.1.6:
|
||||||
|
resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==, tarball: https://registry.npmjs.org/bl/-/bl-6.1.6.tgz}
|
||||||
|
|
||||||
bn.js@4.12.2:
|
bn.js@4.12.2:
|
||||||
resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==, tarball: https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz}
|
resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==, tarball: https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz}
|
||||||
|
|
||||||
@@ -2108,6 +2160,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, tarball: https://registry.npmjs.org/braces/-/braces-3.0.3.tgz}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, tarball: https://registry.npmjs.org/braces/-/braces-3.0.3.tgz}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
broker-factory@3.1.13:
|
||||||
|
resolution: {integrity: sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==, tarball: https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz}
|
||||||
|
|
||||||
brorand@1.1.0:
|
brorand@1.1.0:
|
||||||
resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==, tarball: https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz}
|
resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==, tarball: https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz}
|
||||||
|
|
||||||
@@ -2151,6 +2206,9 @@ packages:
|
|||||||
buffer@4.9.2:
|
buffer@4.9.2:
|
||||||
resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==, tarball: https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz}
|
resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==, tarball: https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz}
|
||||||
|
|
||||||
|
buffer@6.0.3:
|
||||||
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==, tarball: https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz}
|
||||||
|
|
||||||
builtin-status-codes@3.0.0:
|
builtin-status-codes@3.0.0:
|
||||||
resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==, tarball: https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz}
|
resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==, tarball: https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz}
|
||||||
|
|
||||||
@@ -2215,6 +2273,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==, tarball: https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz}
|
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==, tarball: https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz}
|
||||||
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||||
|
|
||||||
|
chart.js@4.5.1:
|
||||||
|
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==, tarball: https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz}
|
||||||
|
engines: {pnpm: '>=8'}
|
||||||
|
|
||||||
chokidar@3.5.3:
|
chokidar@3.5.3:
|
||||||
resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz}
|
resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
@@ -2305,6 +2367,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==, tarball: https://registry.npmjs.org/commander/-/commander-8.3.0.tgz}
|
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==, tarball: https://registry.npmjs.org/commander/-/commander-8.3.0.tgz}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
|
commist@3.2.0:
|
||||||
|
resolution: {integrity: sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==, tarball: https://registry.npmjs.org/commist/-/commist-3.2.0.tgz}
|
||||||
|
|
||||||
common-path-prefix@3.0.0:
|
common-path-prefix@3.0.0:
|
||||||
resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==, tarball: https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz}
|
resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==, tarball: https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz}
|
||||||
|
|
||||||
@@ -2325,6 +2390,10 @@ packages:
|
|||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, tarball: https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, tarball: https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz}
|
||||||
|
|
||||||
|
concat-stream@2.0.0:
|
||||||
|
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==, tarball: https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz}
|
||||||
|
engines: {'0': node >= 6.0}
|
||||||
|
|
||||||
connect-history-api-fallback@2.0.0:
|
connect-history-api-fallback@2.0.0:
|
||||||
resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==, tarball: https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz}
|
resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==, tarball: https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -2392,6 +2461,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
countup.js@2.9.0:
|
||||||
|
resolution: {integrity: sha512-llqrvyXztRFPp6+i8jx25phHWcVWhrHO4Nlt0uAOSKHB8778zzQswa4MU3qKBvkXfJKftRYFJuVHez67lyKdHg==, tarball: https://registry.npmjs.org/countup.js/-/countup.js-2.9.0.tgz}
|
||||||
|
|
||||||
create-ecdh@4.0.4:
|
create-ecdh@4.0.4:
|
||||||
resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==, tarball: https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz}
|
resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==, tarball: https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz}
|
||||||
|
|
||||||
@@ -2409,6 +2481,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==, tarball: https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz}
|
resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==, tarball: https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
crypto-js@4.2.0:
|
||||||
|
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==, tarball: https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz}
|
||||||
|
|
||||||
css-blank-pseudo@3.0.3:
|
css-blank-pseudo@3.0.3:
|
||||||
resolution: {integrity: sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==, tarball: https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz}
|
resolution: {integrity: sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==, tarball: https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz}
|
||||||
engines: {node: ^12 || ^14 || >=16}
|
engines: {node: ^12 || ^14 || >=16}
|
||||||
@@ -3002,6 +3077,10 @@ packages:
|
|||||||
event-emitter@0.3.5:
|
event-emitter@0.3.5:
|
||||||
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==, tarball: https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz}
|
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==, tarball: https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1:
|
||||||
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==, tarball: https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
eventemitter3@5.0.1:
|
eventemitter3@5.0.1:
|
||||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz}
|
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz}
|
||||||
|
|
||||||
@@ -3060,6 +3139,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==, tarball: https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz}
|
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==, tarball: https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
fast-unique-numbers@9.0.26:
|
||||||
|
resolution: {integrity: sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==, tarball: https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz}
|
||||||
|
engines: {node: '>=18.2.0'}
|
||||||
|
|
||||||
fast-uri@3.1.0:
|
fast-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==, tarball: https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz}
|
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==, tarball: https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz}
|
||||||
|
|
||||||
@@ -3359,6 +3442,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==, tarball: https://registry.npmjs.org/he/-/he-1.2.0.tgz}
|
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==, tarball: https://registry.npmjs.org/he/-/he-1.2.0.tgz}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
help-me@5.0.0:
|
||||||
|
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==, tarball: https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz}
|
||||||
|
|
||||||
history@4.10.1:
|
history@4.10.1:
|
||||||
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==, tarball: https://registry.npmjs.org/history/-/history-4.10.1.tgz}
|
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==, tarball: https://registry.npmjs.org/history/-/history-4.10.1.tgz}
|
||||||
|
|
||||||
@@ -3526,6 +3612,10 @@ packages:
|
|||||||
invariant@2.2.4:
|
invariant@2.2.4:
|
||||||
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==, tarball: https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz}
|
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==, tarball: https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz}
|
||||||
|
|
||||||
|
ip-address@10.1.0:
|
||||||
|
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==, tarball: https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz}
|
||||||
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
ipaddr.js@1.9.1:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==, tarball: https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==, tarball: https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -3795,6 +3885,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, tarball: https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, tarball: https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
js-sdsl@4.3.0:
|
||||||
|
resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==, tarball: https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz}
|
||||||
|
|
||||||
js-sdsl@4.4.2:
|
js-sdsl@4.4.2:
|
||||||
resolution: {integrity: sha512-dwXFwByc/ajSV6m5bcKAPwe4yDDF6D614pxmIi5odytzxRlwqF6nwoiCek80Ixc7Cvma5awClxrzFtxCQvcM8w==, tarball: https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.2.tgz}
|
resolution: {integrity: sha512-dwXFwByc/ajSV6m5bcKAPwe4yDDF6D614pxmIi5odytzxRlwqF6nwoiCek80Ixc7Cvma5awClxrzFtxCQvcM8w==, tarball: https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.2.tgz}
|
||||||
|
|
||||||
@@ -4156,6 +4249,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==, tarball: https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz}
|
resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==, tarball: https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
minimist@1.2.8:
|
||||||
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, tarball: https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz}
|
||||||
|
|
||||||
minipass@7.1.2:
|
minipass@7.1.2:
|
||||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==, tarball: https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz}
|
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==, tarball: https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
@@ -4163,6 +4259,14 @@ packages:
|
|||||||
moment@2.30.1:
|
moment@2.30.1:
|
||||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==, tarball: https://registry.npmjs.org/moment/-/moment-2.30.1.tgz}
|
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==, tarball: https://registry.npmjs.org/moment/-/moment-2.30.1.tgz}
|
||||||
|
|
||||||
|
mqtt-packet@9.0.2:
|
||||||
|
resolution: {integrity: sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==, tarball: https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz}
|
||||||
|
|
||||||
|
mqtt@5.15.0:
|
||||||
|
resolution: {integrity: sha512-KC+wAssYk83Qu5bT8YDzDYgUJxPhbLeVsDvpY2QvL28PnXYJzC2WkKruyMUgBAZaQ7h9lo9k2g4neRNUUxzgMw==, tarball: https://registry.npmjs.org/mqtt/-/mqtt-5.15.0.tgz}
|
||||||
|
engines: {node: '>=16.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
ms@2.0.0:
|
ms@2.0.0:
|
||||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==, tarball: https://registry.npmjs.org/ms/-/ms-2.0.0.tgz}
|
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==, tarball: https://registry.npmjs.org/ms/-/ms-2.0.0.tgz}
|
||||||
|
|
||||||
@@ -4263,6 +4367,9 @@ packages:
|
|||||||
nth-check@2.1.1:
|
nth-check@2.1.1:
|
||||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, tarball: https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz}
|
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, tarball: https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz}
|
||||||
|
|
||||||
|
number-allocator@1.0.14:
|
||||||
|
resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==, tarball: https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -5382,6 +5489,17 @@ packages:
|
|||||||
react: '>=16.9.0'
|
react: '>=16.9.0'
|
||||||
react-dom: '>=16.9.0'
|
react-dom: '>=16.9.0'
|
||||||
|
|
||||||
|
react-chartjs-2@5.3.1:
|
||||||
|
resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==, tarball: https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz}
|
||||||
|
peerDependencies:
|
||||||
|
chart.js: ^4.1.1
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
react-countup@6.5.3:
|
||||||
|
resolution: {integrity: sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==, tarball: https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.3.0'
|
||||||
|
|
||||||
react-dom@18.3.1:
|
react-dom@18.3.1:
|
||||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==, tarball: https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz}
|
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==, tarball: https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5505,6 +5623,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==, tarball: https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==, tarball: https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
readable-stream@4.7.0:
|
||||||
|
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==, tarball: https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz}
|
||||||
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
|
|
||||||
readdirp@3.6.0:
|
readdirp@3.6.0:
|
||||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz}
|
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz}
|
||||||
engines: {node: '>=8.10.0'}
|
engines: {node: '>=8.10.0'}
|
||||||
@@ -5840,6 +5962,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==, tarball: https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz}
|
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==, tarball: https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
smart-buffer@4.2.0:
|
||||||
|
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==, tarball: https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz}
|
||||||
|
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||||
|
|
||||||
|
socks@2.8.7:
|
||||||
|
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==, tarball: https://registry.npmjs.org/socks/-/socks-2.8.7.tgz}
|
||||||
|
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||||
|
|
||||||
sonic-boom@2.8.0:
|
sonic-boom@2.8.0:
|
||||||
resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==, tarball: https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz}
|
resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==, tarball: https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz}
|
||||||
|
|
||||||
@@ -6269,6 +6399,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==, tarball: https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz}
|
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==, tarball: https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
typedarray@0.0.6:
|
||||||
|
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==, tarball: https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz}
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -6358,6 +6491,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==, tarball: https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==, tarball: https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
|
|
||||||
|
uuid@13.0.0:
|
||||||
|
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==, tarball: https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
v8-compile-cache@2.4.0:
|
v8-compile-cache@2.4.0:
|
||||||
resolution: {integrity: sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==, tarball: https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz}
|
resolution: {integrity: sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==, tarball: https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz}
|
||||||
|
|
||||||
@@ -6487,6 +6624,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, tarball: https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, tarball: https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
worker-factory@7.0.48:
|
||||||
|
resolution: {integrity: sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==, tarball: https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz}
|
||||||
|
|
||||||
|
worker-timers-broker@8.0.15:
|
||||||
|
resolution: {integrity: sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==, tarball: https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.15.tgz}
|
||||||
|
|
||||||
|
worker-timers-worker@9.0.13:
|
||||||
|
resolution: {integrity: sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==, tarball: https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.13.tgz}
|
||||||
|
|
||||||
|
worker-timers@8.0.30:
|
||||||
|
resolution: {integrity: sha512-8P7YoMHWN0Tz7mg+9oEhuZdjBIn2z6gfjlJqFcHiDd9no/oLnMGCARCDkV1LR3ccQus62ZdtIp7t3aTKrMLHOg==, tarball: https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.30.tgz}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, tarball: https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, tarball: https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -6521,6 +6670,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, tarball: https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, tarball: https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
xterm-addon-fit@0.8.0:
|
||||||
|
resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==, tarball: https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz}
|
||||||
|
deprecated: This package is now deprecated. Move to @xterm/addon-fit instead.
|
||||||
|
peerDependencies:
|
||||||
|
xterm: ^5.0.0
|
||||||
|
|
||||||
|
xterm@5.3.0:
|
||||||
|
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==, tarball: https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz}
|
||||||
|
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, tarball: https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, tarball: https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -7759,6 +7918,8 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.4': {}
|
||||||
|
|
||||||
'@loadable/component@5.15.2(react@18.3.1)':
|
'@loadable/component@5.15.2(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.23.6
|
'@babel/runtime': 7.23.6
|
||||||
@@ -8099,6 +8260,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.6
|
'@babel/types': 7.28.6
|
||||||
|
|
||||||
|
'@types/crypto-js@4.2.2': {}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint': 9.6.1
|
'@types/eslint': 9.6.1
|
||||||
@@ -8192,6 +8355,10 @@ snapshots:
|
|||||||
'@types/prop-types': 15.7.15
|
'@types/prop-types': 15.7.15
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/readable-stream@4.0.23':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 25.0.9
|
||||||
|
|
||||||
'@types/resolve@1.20.6': {}
|
'@types/resolve@1.20.6': {}
|
||||||
|
|
||||||
'@types/semver@7.7.1': {}
|
'@types/semver@7.7.1': {}
|
||||||
@@ -8200,6 +8367,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/use-sync-external-store@0.0.3': {}
|
'@types/use-sync-external-store@0.0.3': {}
|
||||||
|
|
||||||
|
'@types/uuid@10.0.0': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 25.0.9
|
||||||
|
|
||||||
'@types/yargs-parser@21.0.3': {}
|
'@types/yargs-parser@21.0.3': {}
|
||||||
|
|
||||||
'@types/yargs@13.0.12':
|
'@types/yargs@13.0.12':
|
||||||
@@ -8497,7 +8670,7 @@ snapshots:
|
|||||||
|
|
||||||
'@umijs/history@5.3.1':
|
'@umijs/history@5.3.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.23.6
|
'@babel/runtime': 7.28.6
|
||||||
query-string: 6.14.1
|
query-string: 6.14.1
|
||||||
|
|
||||||
'@umijs/lint@4.6.23(eslint@8.35.0)(stylelint@14.8.2)(typescript@5.9.3)':
|
'@umijs/lint@4.6.23(eslint@8.35.0)(stylelint@14.8.2)(typescript@5.9.3)':
|
||||||
@@ -8991,6 +9164,10 @@ snapshots:
|
|||||||
|
|
||||||
'@xtuc/long@4.2.2': {}
|
'@xtuc/long@4.2.2': {}
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
event-target-shim: 5.0.1
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
@@ -9398,6 +9575,13 @@ snapshots:
|
|||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
|
bl@6.1.6:
|
||||||
|
dependencies:
|
||||||
|
'@types/readable-stream': 4.0.23
|
||||||
|
buffer: 6.0.3
|
||||||
|
inherits: 2.0.4
|
||||||
|
readable-stream: 4.7.0
|
||||||
|
|
||||||
bn.js@4.12.2: {}
|
bn.js@4.12.2: {}
|
||||||
|
|
||||||
bn.js@5.2.2: {}
|
bn.js@5.2.2: {}
|
||||||
@@ -9438,6 +9622,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fill-range: 7.1.1
|
fill-range: 7.1.1
|
||||||
|
|
||||||
|
broker-factory@3.1.13:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
fast-unique-numbers: 9.0.26
|
||||||
|
tslib: 2.8.1
|
||||||
|
worker-factory: 7.0.48
|
||||||
|
|
||||||
brorand@1.1.0: {}
|
brorand@1.1.0: {}
|
||||||
|
|
||||||
browserify-aes@1.2.0:
|
browserify-aes@1.2.0:
|
||||||
@@ -9512,6 +9703,11 @@ snapshots:
|
|||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
isarray: 1.0.0
|
isarray: 1.0.0
|
||||||
|
|
||||||
|
buffer@6.0.3:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
ieee754: 1.2.1
|
||||||
|
|
||||||
builtin-status-codes@3.0.0: {}
|
builtin-status-codes@3.0.0: {}
|
||||||
|
|
||||||
bundle-name@3.0.0:
|
bundle-name@3.0.0:
|
||||||
@@ -9573,6 +9769,10 @@ snapshots:
|
|||||||
|
|
||||||
chalk@5.3.0: {}
|
chalk@5.3.0: {}
|
||||||
|
|
||||||
|
chart.js@4.5.1:
|
||||||
|
dependencies:
|
||||||
|
'@kurkle/color': 0.3.4
|
||||||
|
|
||||||
chokidar@3.5.3:
|
chokidar@3.5.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
@@ -9672,6 +9872,8 @@ snapshots:
|
|||||||
|
|
||||||
commander@8.3.0: {}
|
commander@8.3.0: {}
|
||||||
|
|
||||||
|
commist@3.2.0: {}
|
||||||
|
|
||||||
common-path-prefix@3.0.0: {}
|
common-path-prefix@3.0.0: {}
|
||||||
|
|
||||||
compressible@2.0.18:
|
compressible@2.0.18:
|
||||||
@@ -9696,6 +9898,13 @@ snapshots:
|
|||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
|
concat-stream@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
buffer-from: 1.1.2
|
||||||
|
inherits: 2.0.4
|
||||||
|
readable-stream: 3.6.2
|
||||||
|
typedarray: 0.0.6
|
||||||
|
|
||||||
connect-history-api-fallback@2.0.0: {}
|
connect-history-api-fallback@2.0.0: {}
|
||||||
|
|
||||||
console-browserify@1.2.0: {}
|
console-browserify@1.2.0: {}
|
||||||
@@ -9756,6 +9965,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
countup.js@2.9.0: {}
|
||||||
|
|
||||||
create-ecdh@4.0.4:
|
create-ecdh@4.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
bn.js: 4.12.2
|
bn.js: 4.12.2
|
||||||
@@ -9799,6 +10010,8 @@ snapshots:
|
|||||||
randombytes: 2.1.0
|
randombytes: 2.1.0
|
||||||
randomfill: 1.0.4
|
randomfill: 1.0.4
|
||||||
|
|
||||||
|
crypto-js@4.2.0: {}
|
||||||
|
|
||||||
css-blank-pseudo@3.0.3(postcss@8.5.6):
|
css-blank-pseudo@3.0.3(postcss@8.5.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
@@ -10526,6 +10739,8 @@ snapshots:
|
|||||||
d: 1.0.2
|
d: 1.0.2
|
||||||
es5-ext: 0.10.64
|
es5-ext: 0.10.64
|
||||||
|
|
||||||
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
eventemitter3@5.0.1: {}
|
eventemitter3@5.0.1: {}
|
||||||
|
|
||||||
events-okam@3.3.0: {}
|
events-okam@3.3.0: {}
|
||||||
@@ -10637,6 +10852,11 @@ snapshots:
|
|||||||
|
|
||||||
fast-redact@3.5.0: {}
|
fast-redact@3.5.0: {}
|
||||||
|
|
||||||
|
fast-unique-numbers@9.0.26:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
fast-uri@3.1.0: {}
|
fast-uri@3.1.0: {}
|
||||||
|
|
||||||
fastest-levenshtein@1.0.16: {}
|
fastest-levenshtein@1.0.16: {}
|
||||||
@@ -10959,6 +11179,8 @@ snapshots:
|
|||||||
|
|
||||||
he@1.2.0: {}
|
he@1.2.0: {}
|
||||||
|
|
||||||
|
help-me@5.0.0: {}
|
||||||
|
|
||||||
history@4.10.1:
|
history@4.10.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.28.6
|
'@babel/runtime': 7.28.6
|
||||||
@@ -11133,6 +11355,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|
||||||
|
ip-address@10.1.0: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
is-arguments@1.2.0:
|
is-arguments@1.2.0:
|
||||||
@@ -11425,6 +11649,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
|
js-sdsl@4.3.0: {}
|
||||||
|
|
||||||
js-sdsl@4.4.2: {}
|
js-sdsl@4.4.2: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
@@ -11764,10 +11990,43 @@ snapshots:
|
|||||||
is-plain-obj: 1.1.0
|
is-plain-obj: 1.1.0
|
||||||
kind-of: 6.0.3
|
kind-of: 6.0.3
|
||||||
|
|
||||||
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
minipass@7.1.2: {}
|
minipass@7.1.2: {}
|
||||||
|
|
||||||
moment@2.30.1: {}
|
moment@2.30.1: {}
|
||||||
|
|
||||||
|
mqtt-packet@9.0.2:
|
||||||
|
dependencies:
|
||||||
|
bl: 6.1.6
|
||||||
|
debug: 4.4.3
|
||||||
|
process-nextick-args: 2.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
mqtt@5.15.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/readable-stream': 4.0.23
|
||||||
|
'@types/ws': 8.18.1
|
||||||
|
commist: 3.2.0
|
||||||
|
concat-stream: 2.0.0
|
||||||
|
debug: 4.4.3
|
||||||
|
help-me: 5.0.0
|
||||||
|
lru-cache: 10.4.3
|
||||||
|
minimist: 1.2.8
|
||||||
|
mqtt-packet: 9.0.2
|
||||||
|
number-allocator: 1.0.14
|
||||||
|
readable-stream: 4.7.0
|
||||||
|
rfdc: 1.4.1
|
||||||
|
socks: 2.8.7
|
||||||
|
split2: 4.2.0
|
||||||
|
worker-timers: 8.0.30
|
||||||
|
ws: 8.19.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
ms@2.0.0: {}
|
ms@2.0.0: {}
|
||||||
|
|
||||||
ms@2.1.1: {}
|
ms@2.1.1: {}
|
||||||
@@ -11908,6 +12167,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
boolbase: 1.0.0
|
boolbase: 1.0.0
|
||||||
|
|
||||||
|
number-allocator@1.0.14:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
js-sdsl: 4.3.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-hash@3.0.0: {}
|
object-hash@3.0.0: {}
|
||||||
@@ -13208,6 +13474,16 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(react@18.3.1)
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
|
react-chartjs-2@5.3.1(chart.js@4.5.1)(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
chart.js: 4.5.1
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
|
react-countup@6.5.3(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
countup.js: 2.9.0
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
react-dom@18.3.1(react@18.3.1):
|
react-dom@18.3.1(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
@@ -13367,6 +13643,14 @@ snapshots:
|
|||||||
string_decoder: 1.3.0
|
string_decoder: 1.3.0
|
||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
|
readable-stream@4.7.0:
|
||||||
|
dependencies:
|
||||||
|
abort-controller: 3.0.0
|
||||||
|
buffer: 6.0.3
|
||||||
|
events: 3.3.0
|
||||||
|
process: 0.11.10
|
||||||
|
string_decoder: 1.3.0
|
||||||
|
|
||||||
readdirp@3.6.0:
|
readdirp@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
@@ -13738,6 +14022,13 @@ snapshots:
|
|||||||
ansi-styles: 6.2.3
|
ansi-styles: 6.2.3
|
||||||
is-fullwidth-code-point: 4.0.0
|
is-fullwidth-code-point: 4.0.0
|
||||||
|
|
||||||
|
smart-buffer@4.2.0: {}
|
||||||
|
|
||||||
|
socks@2.8.7:
|
||||||
|
dependencies:
|
||||||
|
ip-address: 10.1.0
|
||||||
|
smart-buffer: 4.2.0
|
||||||
|
|
||||||
sonic-boom@2.8.0:
|
sonic-boom@2.8.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
atomic-sleep: 1.0.0
|
atomic-sleep: 1.0.0
|
||||||
@@ -14263,6 +14554,8 @@ snapshots:
|
|||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
reflect.getprototypeof: 1.0.10
|
reflect.getprototypeof: 1.0.10
|
||||||
|
|
||||||
|
typedarray@0.0.6: {}
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
umi@4.6.23(@babel/core@7.28.6)(@types/node@25.0.9)(@types/react@18.3.27)(eslint@8.35.0)(less-loader@12.3.0(less@4.5.1)(webpack@5.104.1))(less@4.5.1)(lightningcss@1.22.1)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(resolve-url-loader@5.0.0)(rollup@3.29.5)(sass-loader@16.0.6(sass@1.54.0)(webpack@5.104.1))(sass@1.54.0)(stylelint@14.8.2)(terser@5.46.0)(type-fest@1.4.0)(typescript@5.9.3)(webpack@5.104.1):
|
umi@4.6.23(@babel/core@7.28.6)(@types/node@25.0.9)(@types/react@18.3.27)(eslint@8.35.0)(less-loader@12.3.0(less@4.5.1)(webpack@5.104.1))(less@4.5.1)(lightningcss@1.22.1)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(resolve-url-loader@5.0.0)(rollup@3.29.5)(sass-loader@16.0.6(sass@1.54.0)(webpack@5.104.1))(sass@1.54.0)(stylelint@14.8.2)(terser@5.46.0)(type-fest@1.4.0)(typescript@5.9.3)(webpack@5.104.1):
|
||||||
@@ -14394,6 +14687,8 @@ snapshots:
|
|||||||
|
|
||||||
utils-merge@1.0.1: {}
|
utils-merge@1.0.1: {}
|
||||||
|
|
||||||
|
uuid@13.0.0: {}
|
||||||
|
|
||||||
v8-compile-cache@2.4.0: {}
|
v8-compile-cache@2.4.0: {}
|
||||||
|
|
||||||
validate-npm-package-license@3.0.4:
|
validate-npm-package-license@3.0.4:
|
||||||
@@ -14545,6 +14840,33 @@ snapshots:
|
|||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
|
worker-factory@7.0.48:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
fast-unique-numbers: 9.0.26
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
worker-timers-broker@8.0.15:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
broker-factory: 3.1.13
|
||||||
|
fast-unique-numbers: 9.0.26
|
||||||
|
tslib: 2.8.1
|
||||||
|
worker-timers-worker: 9.0.13
|
||||||
|
|
||||||
|
worker-timers-worker@9.0.13:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
worker-factory: 7.0.48
|
||||||
|
|
||||||
|
worker-timers@8.0.30:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
tslib: 2.8.1
|
||||||
|
worker-timers-broker: 8.0.15
|
||||||
|
worker-timers-worker: 9.0.13
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -14570,6 +14892,12 @@ snapshots:
|
|||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
|
xterm-addon-fit@0.8.0(xterm@5.3.0):
|
||||||
|
dependencies:
|
||||||
|
xterm: 5.3.0
|
||||||
|
|
||||||
|
xterm@5.3.0: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|||||||
20
src/app.tsx
20
src/app.tsx
@@ -1,5 +1,6 @@
|
|||||||
// 运行时配置
|
// 运行时配置
|
||||||
|
|
||||||
|
import { getTheme } from '@/utils/storage';
|
||||||
import { getLocale, history, Link, RunTimeLayoutConfig } from '@umijs/max';
|
import { getLocale, history, Link, RunTimeLayoutConfig } from '@umijs/max';
|
||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -10,7 +11,6 @@ import IconFont from './components/IconFont';
|
|||||||
import LanguageSwitcher from './components/Lang/LanguageSwitcher';
|
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 { ROUTE_FORGOT_PASSWORD, 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';
|
||||||
@@ -52,8 +52,7 @@ export async function getInitialState(): Promise<InitialStateResponse> {
|
|||||||
|
|
||||||
// Public routes that don't require authentication
|
// Public routes that don't require authentication
|
||||||
if (publicRoutes.includes(pathname)) {
|
if (publicRoutes.includes(pathname)) {
|
||||||
const currentTheme =
|
const currentTheme = (getTheme() as 'light' | 'dark') || 'light';
|
||||||
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
|
|
||||||
return {
|
return {
|
||||||
theme: currentTheme,
|
theme: currentTheme,
|
||||||
};
|
};
|
||||||
@@ -101,8 +100,7 @@ export async function getInitialState(): Promise<InitialStateResponse> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const resp = await getUserProfile();
|
const resp = await getUserProfile();
|
||||||
const currentTheme =
|
const currentTheme = (getTheme() as 'light' | 'dark') || 'light';
|
||||||
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
|
|
||||||
return {
|
return {
|
||||||
getUserProfile: getUserProfile!,
|
getUserProfile: getUserProfile!,
|
||||||
currentUserProfile: resp,
|
currentUserProfile: resp,
|
||||||
@@ -121,7 +119,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_0wzlbggptqk.js',
|
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_3sthrd9e6y4.js',
|
||||||
contentStyle: {
|
contentStyle: {
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
@@ -159,6 +157,12 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
|
|||||||
},
|
},
|
||||||
layout: 'top',
|
layout: 'top',
|
||||||
menuHeaderRender: undefined,
|
menuHeaderRender: undefined,
|
||||||
|
subMenuItemRender: (item, dom) => {
|
||||||
|
if (item.path) {
|
||||||
|
return <Link to={item.path}>{dom}</Link>;
|
||||||
|
}
|
||||||
|
return dom;
|
||||||
|
},
|
||||||
menuItemRender: (item, dom) => {
|
menuItemRender: (item, dom) => {
|
||||||
if (item.path) {
|
if (item.path) {
|
||||||
// Coerce values to string to satisfy TypeScript expectations
|
// Coerce values to string to satisfy TypeScript expectations
|
||||||
@@ -176,11 +180,11 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
|
|||||||
},
|
},
|
||||||
token: {
|
token: {
|
||||||
header: {
|
header: {
|
||||||
colorBgMenuItemSelected: '#EEF7FF', // background khi chọn
|
colorBgMenuItemSelected: isDark ? '#111b26' : '#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: {
|
pageContainer: {
|
||||||
paddingInlinePageContainerContent: 8,
|
// paddingInlinePageContainerContent: 0,
|
||||||
paddingBlockPageContainerContent: 8,
|
paddingBlockPageContainerContent: 8,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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_0wzlbggptqk.js',
|
scriptUrl: '//at.alicdn.com/t/c/font_5096559_3sthrd9e6y4.js',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default IconFont;
|
export default IconFont;
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
|
import { useModel } from '@umijs/max';
|
||||||
import { ConfigProvider, theme } from 'antd';
|
import { ConfigProvider, theme } from 'antd';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { getTheme } from './ThemeSwitcher';
|
|
||||||
|
|
||||||
interface ThemeProviderProps {
|
interface ThemeProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||||
const [isDark, setIsDark] = useState(getTheme() === 'dark');
|
const { initialState } = useModel('@@initialState');
|
||||||
|
const isDark = (initialState?.theme as 'light' | 'dark') === 'dark';
|
||||||
useEffect(() => {
|
|
||||||
const handleThemeChange = (e: CustomEvent) => {
|
|
||||||
setIsDark(e.detail.theme === 'dark');
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('theme-change', handleThemeChange as EventListener);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener(
|
|
||||||
'theme-change',
|
|
||||||
handleThemeChange as EventListener,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { THEME_KEY } from '@/constants';
|
import { setTheme } from '@/utils/storage';
|
||||||
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
|
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
|
||||||
import { useModel } from '@umijs/max';
|
import { useModel } from '@umijs/max';
|
||||||
import { Segmented } from 'antd';
|
import { Segmented } from 'antd';
|
||||||
@@ -34,7 +34,7 @@ const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => {
|
|||||||
|
|
||||||
if (!supportsViewTransition) {
|
if (!supportsViewTransition) {
|
||||||
// Fallback: just change theme without animation
|
// Fallback: just change theme without animation
|
||||||
localStorage.setItem(THEME_KEY, newTheme);
|
setTheme(newTheme);
|
||||||
setIsDark(newTheme === 'dark');
|
setIsDark(newTheme === 'dark');
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
|
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
|
||||||
@@ -58,7 +58,7 @@ const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => {
|
|||||||
|
|
||||||
// Start the view transition
|
// Start the view transition
|
||||||
const transition = (document as any).startViewTransition(() => {
|
const transition = (document as any).startViewTransition(() => {
|
||||||
localStorage.setItem(THEME_KEY, newTheme);
|
setTheme(newTheme);
|
||||||
setIsDark(newTheme === 'dark');
|
setIsDark(newTheme === 'dark');
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
|
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
|
||||||
@@ -107,8 +107,3 @@ const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ThemeSwitcher;
|
export default ThemeSwitcher;
|
||||||
|
|
||||||
// Helper function để get theme từ localStorage
|
|
||||||
export const getTheme = (): 'light' | 'dark' => {
|
|
||||||
return (localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { THEME_KEY } from '@/constants';
|
import { getTheme, setTheme } from '@/utils/storage';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { getTheme } from './ThemeSwitcher';
|
|
||||||
import './style.less';
|
import './style.less';
|
||||||
|
|
||||||
const ThemeSwitcherAuth = () => {
|
const ThemeSwitcherAuth = () => {
|
||||||
@@ -21,7 +20,7 @@ const ThemeSwitcherAuth = () => {
|
|||||||
|
|
||||||
const handleSwitch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSwitch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newTheme = e.target.checked ? 'dark' : 'light';
|
const newTheme = e.target.checked ? 'dark' : 'light';
|
||||||
localStorage.setItem(THEME_KEY, newTheme);
|
setTheme(newTheme);
|
||||||
setIsDark(newTheme === 'dark');
|
setIsDark(newTheme === 'dark');
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
|
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
// Auth API Paths
|
// Auth API Paths
|
||||||
export const API_PATH_LOGIN = '/api/tokens';
|
export const API_PATH_LOGIN = '/api/login';
|
||||||
|
export const API_PATH_LOGIN_2FA = '/api/login/2fa';
|
||||||
|
export const API_PATH_LOGOUT = '/api/logout';
|
||||||
export const API_PATH_REFRESH_TOKEN = '/api/keys/refresh';
|
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';
|
export const API_FORGOT_PASSWORD = '/api/password/reset-request';
|
||||||
|
export const API_ENABLE_2FA = '/api/users/2fa/enable';
|
||||||
|
export const API_ENABLE_2FA_VERIFY = '/api/users/2fa/verify';
|
||||||
|
export const API_DISABLE_2FA = '/api/users/2fa/disable';
|
||||||
|
export const API_ADMIN_DISABLE_2FA = '/api/2fa/disable/';
|
||||||
|
|
||||||
// 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';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const DURATION_POLLING_PRESENTATIONS = 120000; //milliseconds
|
|||||||
export const STATUS_NORMAL = 0;
|
export const STATUS_NORMAL = 0;
|
||||||
export const STATUS_WARNING = 1;
|
export const STATUS_WARNING = 1;
|
||||||
export const STATUS_DANGEROUS = 2;
|
export const STATUS_DANGEROUS = 2;
|
||||||
|
|
||||||
export const STATUS_SOS = 3;
|
export const STATUS_SOS = 3;
|
||||||
|
|
||||||
export const COLOR_DISCONNECT = '#d9d9d9';
|
export const COLOR_DISCONNECT = '#d9d9d9';
|
||||||
@@ -22,6 +23,8 @@ export const COLOR_SOS = '#ff0000';
|
|||||||
export const ACCESS_TOKEN = 'access_token';
|
export const ACCESS_TOKEN = 'access_token';
|
||||||
export const REFRESH_TOKEN = 'refresh_token';
|
export const REFRESH_TOKEN = 'refresh_token';
|
||||||
export const THEME_KEY = 'theme';
|
export const THEME_KEY = 'theme';
|
||||||
|
export const TERMINAL_THEME_KEY = 'terminal_theme_key';
|
||||||
|
export const REMEMBER_ME_KEY = 'smatec_remember_login';
|
||||||
// Global Constants
|
// Global Constants
|
||||||
export const LIMIT_TREE_LEVEL = 5;
|
export const LIMIT_TREE_LEVEL = 5;
|
||||||
export const DEFAULT_PAGE_SIZE = 5;
|
export const DEFAULT_PAGE_SIZE = 5;
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ export default {
|
|||||||
'master.auth.validation.email': 'Email is required',
|
'master.auth.validation.email': 'Email is required',
|
||||||
'master.auth.password': 'Password',
|
'master.auth.password': 'Password',
|
||||||
'master.auth.validation.password': 'Password is required',
|
'master.auth.validation.password': 'Password is required',
|
||||||
'master.auth.login.subtitle': 'Ship Monitoring System',
|
|
||||||
'master.auth.login.description': 'Login to continue monitoring vessels',
|
|
||||||
'master.auth.login.invalid': 'Invalid username or password',
|
|
||||||
'master.auth.login.success': 'Login successful',
|
|
||||||
'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',
|
||||||
@@ -18,6 +14,11 @@ export default {
|
|||||||
'master.auth.forgot.message.success':
|
'master.auth.forgot.message.success':
|
||||||
'Request sent successfully, please check your email!',
|
'Request sent successfully, please check your email!',
|
||||||
'master.auth.forgot.message.fail': 'Request failed, please try again later!',
|
'master.auth.forgot.message.fail': 'Request failed, please try again later!',
|
||||||
|
'master.auth.otp.error': 'Invalid OTP code. Please try again.',
|
||||||
|
'master.auth.otp.button.title': 'Verify OTP',
|
||||||
|
'master.auth.otp.placeholder': 'Enter OTP code',
|
||||||
|
'master.auth.otp.required': 'OTP code is required',
|
||||||
|
'master.auth.otp.length': 'OTP must be 6 digits',
|
||||||
'master.auth.reset.success': 'Password reset successful',
|
'master.auth.reset.success': 'Password reset successful',
|
||||||
'master.auth.reset.error': 'An error occurred, please try again later!',
|
'master.auth.reset.error': 'An error occurred, please try again later!',
|
||||||
'master.auth.reset.invalid':
|
'master.auth.reset.invalid':
|
||||||
|
|||||||
@@ -17,4 +17,28 @@ export default {
|
|||||||
'master.profile.change-password.fail': 'Change password failed',
|
'master.profile.change-password.fail': 'Change password failed',
|
||||||
'master.profile.change-password.password.strong':
|
'master.profile.change-password.password.strong':
|
||||||
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
|
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
|
||||||
|
'master.profile.2fa.status': '2FA Status',
|
||||||
|
'master.profile.2fa.description':
|
||||||
|
'Enable two-step verification to enhance the security of your account',
|
||||||
|
'master.profile.2fa.enabled': 'Enabled',
|
||||||
|
'master.profile.2fa.disabled': 'Disabled',
|
||||||
|
'master.profile.2fa.setup.title': 'Set up two-step verification',
|
||||||
|
'master.profile.2fa.verify': 'Confirm',
|
||||||
|
'master.profile.2fa.cancel': 'Cancel',
|
||||||
|
'master.profile.2fa.scan.instruction':
|
||||||
|
'Scan the QR code with an authentication app (Google Authenticator, Authy, ...)',
|
||||||
|
'master.profile.2fa.otp.instruction':
|
||||||
|
'Enter the 6-digit code from the authentication app:',
|
||||||
|
'master.profile.2fa.enable.error': 'Unable to enable 2FA. Please try again.',
|
||||||
|
'master.profile.2fa.otp.invalid': 'Please enter a 6-digit OTP code',
|
||||||
|
'master.profile.2fa.enable.success': '2FA enabled successfully!',
|
||||||
|
'master.profile.2fa.verify.error': 'Invalid OTP code. Please try again.',
|
||||||
|
'master.profile.2fa.disable.confirm.title': 'Confirm disable 2FA',
|
||||||
|
'master.profile.2fa.disable.confirm.content':
|
||||||
|
'Are you sure you want to disable two-step verification? This will reduce the security of your account.',
|
||||||
|
'master.profile.2fa.disable.confirm.ok': 'Disable 2FA',
|
||||||
|
'master.profile.2fa.disable.confirm.cancel': 'Cancel',
|
||||||
|
'master.profile.2fa.disable.success': '2FA disabled successfully!',
|
||||||
|
'master.profile.2fa.disable.error':
|
||||||
|
'Unable to disable 2FA. Please try again.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,4 +44,81 @@ export default {
|
|||||||
'master.devices.location.placeholder': 'Enter data',
|
'master.devices.location.placeholder': 'Enter data',
|
||||||
'master.devices.location.update.success': 'Location updated successfully',
|
'master.devices.location.update.success': 'Location updated successfully',
|
||||||
'master.devices.location.update.error': 'Location update failed',
|
'master.devices.location.update.error': 'Location update failed',
|
||||||
|
|
||||||
|
// Camera translations
|
||||||
|
'master.devices.camera.loading': 'Loading...',
|
||||||
|
'master.devices.camera.config.success': 'Configuration sent successfully',
|
||||||
|
'master.devices.camera.config.error.deviceOffline':
|
||||||
|
'Device is offline, cannot send configuration',
|
||||||
|
'master.devices.camera.config.error.missingConfig':
|
||||||
|
'Missing device configuration information',
|
||||||
|
'master.devices.camera.config.error.mqttNotConnected': 'MQTT not connected',
|
||||||
|
// Camera Form Modal
|
||||||
|
'master.devices.camera.form.title.add': 'Add New Camera',
|
||||||
|
'master.devices.camera.form.title.edit': 'Edit Camera',
|
||||||
|
'master.devices.camera.form.name': 'Name',
|
||||||
|
'master.devices.camera.form.name.placeholder': 'Enter name',
|
||||||
|
'master.devices.camera.form.name.required': 'Please enter name',
|
||||||
|
'master.devices.camera.form.type': 'Type',
|
||||||
|
'master.devices.camera.form.type.required': 'Please select type',
|
||||||
|
'master.devices.camera.form.username': 'Username',
|
||||||
|
'master.devices.camera.form.username.placeholder': 'Enter username',
|
||||||
|
'master.devices.camera.form.username.required': 'Please enter username',
|
||||||
|
'master.devices.camera.form.password': 'Password',
|
||||||
|
'master.devices.camera.form.password.placeholder': 'Enter password',
|
||||||
|
'master.devices.camera.form.password.required': 'Please enter password',
|
||||||
|
'master.devices.camera.form.ip': 'IP Address',
|
||||||
|
'master.devices.camera.form.ip.placeholder': '192.168.1.10',
|
||||||
|
'master.devices.camera.form.ip.required': 'Please enter IP address',
|
||||||
|
'master.devices.camera.form.rtspPort': 'RTSP Port',
|
||||||
|
'master.devices.camera.form.rtspPort.required': 'Please enter RTSP port',
|
||||||
|
'master.devices.camera.form.httpPort': 'HTTP Port',
|
||||||
|
'master.devices.camera.form.httpPort.required': 'Please enter HTTP port',
|
||||||
|
'master.devices.camera.form.stream': 'Stream',
|
||||||
|
'master.devices.camera.form.stream.required': 'Please enter stream',
|
||||||
|
'master.devices.camera.form.channel': 'Channel',
|
||||||
|
'master.devices.camera.form.channel.required': 'Please enter channel',
|
||||||
|
'master.devices.camera.form.cancel': 'Cancel',
|
||||||
|
'master.devices.camera.form.submit': 'OK',
|
||||||
|
'master.devices.camera.form.update': 'Update',
|
||||||
|
// Camera Table
|
||||||
|
'master.devices.camera.table.add': 'Add New Camera',
|
||||||
|
'master.devices.camera.table.column.name': 'Name',
|
||||||
|
'master.devices.camera.table.column.type': 'Type',
|
||||||
|
'master.devices.camera.table.column.ip': 'IP Address',
|
||||||
|
'master.devices.camera.table.column.action': 'Actions',
|
||||||
|
'master.devices.camera.table.offline.tooltip': 'Device is offline',
|
||||||
|
'master.devices.camera.table.pagination': 'Showing {0}-{1} of {2} cameras',
|
||||||
|
// Camera Config V6
|
||||||
|
'master.devices.camera.config.recording': 'Camera Recording',
|
||||||
|
'master.devices.camera.config.send': 'Send',
|
||||||
|
'master.devices.camera.config.alarmList': 'Alarm List',
|
||||||
|
'master.devices.camera.config.selected': '{0} items selected',
|
||||||
|
'master.devices.camera.config.clear': 'Clear',
|
||||||
|
'master.devices.camera.config.recordingMode.none': 'No Recording',
|
||||||
|
'master.devices.camera.config.recordingMode.alarm': 'On Alarm',
|
||||||
|
'master.devices.camera.config.recordingMode.all': '24/7',
|
||||||
|
|
||||||
|
// Terminal translations
|
||||||
|
'master.devices.terminal.pageTitle': 'Terminal',
|
||||||
|
'master.devices.terminal.loadDeviceError': 'Cannot load device information.',
|
||||||
|
'master.devices.terminal.mqttError': 'Cannot connect to MQTT.',
|
||||||
|
'master.devices.terminal.genericError': 'An error occurred',
|
||||||
|
'master.devices.terminal.unsupported.title':
|
||||||
|
'Device does not support terminal',
|
||||||
|
'master.devices.terminal.unsupported.desc':
|
||||||
|
'GMSv5 devices are not supported. Please use a different device.',
|
||||||
|
'master.devices.terminal.missingChannel.title':
|
||||||
|
'Missing control channel information',
|
||||||
|
'master.devices.terminal.missingChannel.desc':
|
||||||
|
'Device has not been configured with ctrl_channel_id, cannot open terminal.',
|
||||||
|
'master.devices.terminal.missingCredential.title':
|
||||||
|
'Missing authentication information',
|
||||||
|
'master.devices.terminal.missingCredential.desc':
|
||||||
|
'Current account has not been granted frontend_thing_id/frontend_thing_key.',
|
||||||
|
'master.devices.terminal.offline':
|
||||||
|
'Device is offline. Terminal is in view-only mode.',
|
||||||
|
'master.devices.terminal.connecting': 'Preparing terminal session...',
|
||||||
|
'master.devices.terminal.action.clear': 'Clear screen',
|
||||||
|
'master.devices.terminal.action.theme': 'Theme',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,4 +74,12 @@ export default {
|
|||||||
'master.users.resetPassword.modal.title': 'Reset Password For User',
|
'master.users.resetPassword.modal.title': 'Reset Password For User',
|
||||||
'master.users.resetPassword.success': 'Password reset successful',
|
'master.users.resetPassword.success': 'Password reset successful',
|
||||||
'master.users.resetPassword.error': 'Password reset failed',
|
'master.users.resetPassword.error': 'Password reset failed',
|
||||||
|
'master.users.disable2fa.title': 'Disable 2FA',
|
||||||
|
'master.users.disable2fa.success': '2FA has been disabled successfully',
|
||||||
|
'master.users.disable2fa.error': 'Failed to disable 2FA',
|
||||||
|
'master.users.disable2fa.modal.title': 'Disable Two-Factor Authentication',
|
||||||
|
'master.users.disable2fa.modal.warning':
|
||||||
|
'Are you sure you want to disable 2FA for this user?',
|
||||||
|
'master.users.disable2fa.modal.caution':
|
||||||
|
'Warning: Disabling 2FA will reduce account security. The user will need to re-enable 2FA from their profile settings.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ export default {
|
|||||||
'master.auth.login.email': 'Email',
|
'master.auth.login.email': 'Email',
|
||||||
'master.auth.login.title': 'Đăng nhập',
|
'master.auth.login.title': 'Đăng nhập',
|
||||||
'master.auth.login.subtitle': 'Hệ thống giám sát tàu cá',
|
'master.auth.login.subtitle': 'Hệ thống giám sát tàu cá',
|
||||||
'master.auth.login.description': 'Đăng nhập để tiếp tục giám sát tàu thuyền',
|
|
||||||
'master.auth.login.invalid': 'Tên người dùng hoặc mật khẩu không hợp lệ',
|
|
||||||
'master.auth.login.success': 'Đăng nhập thành công',
|
|
||||||
'master.auth.logout.title': 'Đăng xuất',
|
'master.auth.logout.title': 'Đăng xuất',
|
||||||
'master.auth.logout.confirm': 'Bạn có chắc chắn muốn đăng xuất?',
|
'master.auth.logout.confirm': 'Bạn có chắc chắn muốn đăng xuất?',
|
||||||
'master.auth.logout.success': 'Đăng xuất thành công',
|
'master.auth.logout.success': 'Đăng xuất thành công',
|
||||||
@@ -19,6 +16,11 @@ export default {
|
|||||||
'Gửi yêu cầu thành công, vui lòng kiểm tra email của bạn!',
|
'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':
|
'master.auth.forgot.message.fail':
|
||||||
'Gửi yêu cầu thất bại, vui lòng thử lại sau!',
|
'Gửi yêu cầu thất bại, vui lòng thử lại sau!',
|
||||||
|
'master.auth.otp.error': 'Mã OTP không hợp lệ. Vui lòng thử lại.',
|
||||||
|
'master.auth.otp.button.title': 'Xác thực OTP',
|
||||||
|
'master.auth.otp.placeholder': 'Nhập mã OTP',
|
||||||
|
'master.auth.otp.required': 'Vui lòng nhập mã OTP',
|
||||||
|
'master.auth.otp.length': 'Mã OTP phải có 6 chữ số',
|
||||||
'master.auth.reset.success': 'Đặt lại mật khẩu thành công',
|
'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.error': 'Có lỗi xảy ra, vui lòng thử lại sau!',
|
||||||
'master.auth.reset.invalid':
|
'master.auth.reset.invalid':
|
||||||
|
|||||||
@@ -17,4 +17,26 @@ export default {
|
|||||||
'master.profile.change-profile.update-fail': 'Cập nhật thông tin thất bại',
|
'master.profile.change-profile.update-fail': 'Cập nhật thông tin thất bại',
|
||||||
'master.profile.change-password.success': 'Đổi mật khẩu thành công',
|
'master.profile.change-password.success': 'Đổi mật khẩu thành công',
|
||||||
'master.profile.change-password.fail': 'Đổi mật khẩu thất bại',
|
'master.profile.change-password.fail': 'Đổi mật khẩu thất bại',
|
||||||
|
'master.profile.2fa.status': 'Trạng thái 2FA',
|
||||||
|
'master.profile.2fa.description':
|
||||||
|
'Bật xác thực 2 bước để tăng cường bảo mật cho tài khoản của bạn',
|
||||||
|
'master.profile.2fa.enabled': 'Bật',
|
||||||
|
'master.profile.2fa.disabled': 'Tắt',
|
||||||
|
'master.profile.2fa.setup.title': 'Thiết lập xác thực 2 bước',
|
||||||
|
'master.profile.2fa.verify': 'Xác nhận',
|
||||||
|
'master.profile.2fa.cancel': 'Hủy',
|
||||||
|
'master.profile.2fa.scan.instruction':
|
||||||
|
'Quét mã QR bằng ứng dụng xác thực (Google Authenticator, Authy, ...)',
|
||||||
|
'master.profile.2fa.otp.instruction': 'Nhập mã 6 số từ ứng dụng xác thực:',
|
||||||
|
'master.profile.2fa.enable.error': 'Không thể bật 2FA. Vui lòng thử lại.',
|
||||||
|
'master.profile.2fa.otp.invalid': 'Vui lòng nhập mã OTP 6 số',
|
||||||
|
'master.profile.2fa.enable.success': 'Bật 2FA thành công!',
|
||||||
|
'master.profile.2fa.verify.error': 'Mã OTP không đúng. Vui lòng thử lại.',
|
||||||
|
'master.profile.2fa.disable.confirm.title': 'Xác nhận tắt 2FA',
|
||||||
|
'master.profile.2fa.disable.confirm.content':
|
||||||
|
'Bạn có chắc chắn muốn tắt xác thực 2 bước? Điều này sẽ giảm bảo mật cho tài khoản của bạn.',
|
||||||
|
'master.profile.2fa.disable.confirm.ok': 'Tắt 2FA',
|
||||||
|
'master.profile.2fa.disable.confirm.cancel': 'Hủy',
|
||||||
|
'master.profile.2fa.disable.success': 'Đã tắt 2FA thành công!',
|
||||||
|
'master.profile.2fa.disable.error': 'Không thể tắt 2FA. Vui lòng thử lại.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,4 +44,80 @@ export default {
|
|||||||
'master.devices.location.placeholder': 'Nhập dữ liệu',
|
'master.devices.location.placeholder': 'Nhập dữ liệu',
|
||||||
'master.devices.location.update.success': 'Cập nhật vị trí thành công',
|
'master.devices.location.update.success': 'Cập nhật vị trí thành công',
|
||||||
'master.devices.location.update.error': 'Cập nhật vị trí thất bại',
|
'master.devices.location.update.error': 'Cập nhật vị trí thất bại',
|
||||||
|
|
||||||
|
// Camera translations
|
||||||
|
'master.devices.camera.loading': 'Đang tải...',
|
||||||
|
'master.devices.camera.config.success': 'Đã gửi cấu hình thành công',
|
||||||
|
'master.devices.camera.config.error.deviceOffline':
|
||||||
|
'Thiết bị đang ngoại tuyến, không thể gửi cấu hình',
|
||||||
|
'master.devices.camera.config.error.missingConfig':
|
||||||
|
'Thiếu thông tin cấu hình thiết bị',
|
||||||
|
'master.devices.camera.config.error.mqttNotConnected': 'MQTT chưa kết nối',
|
||||||
|
// Camera Form Modal
|
||||||
|
'master.devices.camera.form.title.add': 'Tạo mới camera',
|
||||||
|
'master.devices.camera.form.title.edit': 'Chỉnh sửa camera',
|
||||||
|
'master.devices.camera.form.name': 'Tên',
|
||||||
|
'master.devices.camera.form.name.placeholder': 'Nhập tên',
|
||||||
|
'master.devices.camera.form.name.required': 'Vui lòng nhập tên',
|
||||||
|
'master.devices.camera.form.type': 'Loại',
|
||||||
|
'master.devices.camera.form.type.required': 'Vui lòng chọn loại',
|
||||||
|
'master.devices.camera.form.username': 'Tài khoản',
|
||||||
|
'master.devices.camera.form.username.placeholder': 'Nhập tài khoản',
|
||||||
|
'master.devices.camera.form.username.required': 'Vui lòng nhập tài khoản',
|
||||||
|
'master.devices.camera.form.password': 'Mật khẩu',
|
||||||
|
'master.devices.camera.form.password.placeholder': 'Nhập mật khẩu',
|
||||||
|
'master.devices.camera.form.password.required': 'Vui lòng nhập mật khẩu',
|
||||||
|
'master.devices.camera.form.ip': 'Địa chỉ IP',
|
||||||
|
'master.devices.camera.form.ip.placeholder': '192.168.1.10',
|
||||||
|
'master.devices.camera.form.ip.required': 'Vui lòng nhập địa chỉ IP',
|
||||||
|
'master.devices.camera.form.rtspPort': 'Cổng RTSP',
|
||||||
|
'master.devices.camera.form.rtspPort.required': 'Vui lòng nhập cổng RTSP',
|
||||||
|
'master.devices.camera.form.httpPort': 'Cổng HTTP',
|
||||||
|
'master.devices.camera.form.httpPort.required': 'Vui lòng nhập cổng HTTP',
|
||||||
|
'master.devices.camera.form.stream': 'Luồng',
|
||||||
|
'master.devices.camera.form.stream.required': 'Vui lòng nhập luồng',
|
||||||
|
'master.devices.camera.form.channel': 'Kênh',
|
||||||
|
'master.devices.camera.form.channel.required': 'Vui lòng nhập kênh',
|
||||||
|
'master.devices.camera.form.cancel': 'Hủy',
|
||||||
|
'master.devices.camera.form.submit': 'Đồng ý',
|
||||||
|
'master.devices.camera.form.update': 'Cập nhật',
|
||||||
|
// Camera Table
|
||||||
|
'master.devices.camera.table.add': 'Tạo mới camera',
|
||||||
|
'master.devices.camera.table.column.name': 'Tên',
|
||||||
|
'master.devices.camera.table.column.type': 'Loại',
|
||||||
|
'master.devices.camera.table.column.ip': 'Địa chỉ IP',
|
||||||
|
'master.devices.camera.table.column.action': 'Thao tác',
|
||||||
|
'master.devices.camera.table.offline.tooltip': 'Thiết bị đang ngoại tuyến',
|
||||||
|
'master.devices.camera.table.pagination': 'Hiển thị {0}-{1} của {2} camera',
|
||||||
|
// Camera Config V6
|
||||||
|
'master.devices.camera.config.recording': 'Ghi dữ liệu camera',
|
||||||
|
'master.devices.camera.config.send': 'Gửi đi',
|
||||||
|
'master.devices.camera.config.alarmList': 'Danh sách cảnh báo',
|
||||||
|
'master.devices.camera.config.selected': 'đã chọn {0} mục',
|
||||||
|
'master.devices.camera.config.clear': 'Xóa',
|
||||||
|
'master.devices.camera.config.recordingMode.none': 'Không ghi',
|
||||||
|
'master.devices.camera.config.recordingMode.alarm': 'Theo cảnh báo',
|
||||||
|
'master.devices.camera.config.recordingMode.all': '24/24',
|
||||||
|
|
||||||
|
// Terminal translations
|
||||||
|
'master.devices.terminal.pageTitle': 'Terminal',
|
||||||
|
'master.devices.terminal.loadDeviceError':
|
||||||
|
'Không thể tải thông tin thiết bị.',
|
||||||
|
'master.devices.terminal.mqttError': 'Không thể kết nối MQTT.',
|
||||||
|
'master.devices.terminal.genericError': 'Đã có lỗi xảy ra',
|
||||||
|
'master.devices.terminal.unsupported.title': 'Thiết bị không hỗ trợ terminal',
|
||||||
|
'master.devices.terminal.unsupported.desc':
|
||||||
|
'Thiết bị GMSv5 không được hỗ trợ. Vui lòng sử dụng thiết bị khác.',
|
||||||
|
'master.devices.terminal.missingChannel.title':
|
||||||
|
'Thiếu thông tin kênh điều khiển',
|
||||||
|
'master.devices.terminal.missingChannel.desc':
|
||||||
|
'Thiết bị chưa được cấu hình ctrl_channel_id nên không thể mở terminal.',
|
||||||
|
'master.devices.terminal.missingCredential.title': 'Thiếu thông tin xác thực',
|
||||||
|
'master.devices.terminal.missingCredential.desc':
|
||||||
|
'Tài khoản hiện tại chưa được cấp frontend_thing_id/frontend_thing_key.',
|
||||||
|
'master.devices.terminal.offline':
|
||||||
|
'Thiết bị đang ngoại tuyến. Terminal chuyển sang chế độ chỉ xem.',
|
||||||
|
'master.devices.terminal.connecting': 'Đang chuẩn bị phiên terminal...',
|
||||||
|
'master.devices.terminal.action.clear': 'Xóa màn hình',
|
||||||
|
'master.devices.terminal.action.theme': 'Giao diện',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,4 +73,12 @@ export default {
|
|||||||
'master.users.resetPassword.modal.title': 'Đặt lại mật khẩu cho người dùng',
|
'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.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',
|
'master.users.resetPassword.error': 'Đặt lại mật khẩu thất bại',
|
||||||
|
'master.users.disable2fa.title': 'Tắt 2FA',
|
||||||
|
'master.users.disable2fa.success': 'Đã tắt 2FA thành công',
|
||||||
|
'master.users.disable2fa.error': 'Tắt 2FA thất bại',
|
||||||
|
'master.users.disable2fa.modal.title': 'Tắt xác thực hai yếu tố',
|
||||||
|
'master.users.disable2fa.modal.warning':
|
||||||
|
'Bạn có chắc chắn muốn tắt 2FA cho người dùng này không?',
|
||||||
|
'master.users.disable2fa.modal.caution':
|
||||||
|
'Cảnh báo: Việc tắt 2FA sẽ làm giảm bảo mật tài khoản. Người dùng sẽ cần thiết lập lại 2FA từ cài đặt hồ sơ của họ.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
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 { ROUTE_LOGIN } from '@/constants/routes';
|
import { ROUTE_LOGIN } from '@/constants/routes';
|
||||||
import { apiUserResetPassword } from '@/services/master/UserController';
|
import { apiUserResetPassword } from '@/services/master/UserController';
|
||||||
import { parseAccessToken } from '@/utils/jwt';
|
import { parseAccessToken } from '@/utils/jwt';
|
||||||
import { getDomainTitle, getLogoImage } from '@/utils/logo';
|
import { getDomainTitle, getLogoImage } from '@/utils/logo';
|
||||||
|
import { getTheme } from '@/utils/storage';
|
||||||
import { ProForm, ProFormText } from '@ant-design/pro-components';
|
import { ProForm, ProFormText } from '@ant-design/pro-components';
|
||||||
import {
|
import {
|
||||||
FormattedMessage,
|
FormattedMessage,
|
||||||
@@ -28,9 +28,7 @@ const ResetPassword = () => {
|
|||||||
const [tokenValid, setTokenValid] = useState(false);
|
const [tokenValid, setTokenValid] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [isDark, setIsDark] = useState(
|
const [isDark, setIsDark] = useState(getTheme() === 'dark');
|
||||||
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') === 'dark',
|
|
||||||
);
|
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|||||||
47
src/pages/Auth/components/ForgotPasswordForm.tsx
Normal file
47
src/pages/Auth/components/ForgotPasswordForm.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { UserOutlined } from '@ant-design/icons';
|
||||||
|
import { ProFormText } from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
|
||||||
|
const ForgotPasswordForm = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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.auth.validation.email"
|
||||||
|
defaultMessage="Email is required"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'email',
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id="master.users.email.invalid"
|
||||||
|
defaultMessage="Invalid email address"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForgotPasswordForm;
|
||||||
68
src/pages/Auth/components/LoginForm.tsx
Normal file
68
src/pages/Auth/components/LoginForm.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import { ProFormText } from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
|
||||||
|
const LoginForm = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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.auth.validation.email"
|
||||||
|
defaultMessage="Email is required"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'email',
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id="master.users.email.invalid"
|
||||||
|
defaultMessage="Invalid email address"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="password"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
autoComplete: 'current-password',
|
||||||
|
prefix: <LockOutlined className={'prefixIcon'} />,
|
||||||
|
}}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'master.auth.password',
|
||||||
|
defaultMessage: 'Mật khẩu',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.auth.validation.password',
|
||||||
|
defaultMessage: 'Mật khẩu không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginForm;
|
||||||
48
src/pages/Auth/components/OtpForm.tsx
Normal file
48
src/pages/Auth/components/OtpForm.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { LockOutlined } from '@ant-design/icons';
|
||||||
|
import { ProFormText } from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
|
||||||
|
const OtpForm = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProFormText
|
||||||
|
name="otp"
|
||||||
|
fieldProps={{
|
||||||
|
autoComplete: 'one-time-code',
|
||||||
|
autoFocus: true,
|
||||||
|
size: 'large',
|
||||||
|
maxLength: 6,
|
||||||
|
prefix: <LockOutlined className={'prefixIcon'} />,
|
||||||
|
}}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'master.auth.otp.placeholder',
|
||||||
|
defaultMessage: 'Enter OTP code',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id="master.auth.otp.required"
|
||||||
|
defaultMessage="OTP code is required"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
len: 6,
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id="master.auth.otp.length"
|
||||||
|
defaultMessage="OTP must be 6 digits"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OtpForm;
|
||||||
@@ -1,46 +1,65 @@
|
|||||||
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 {
|
import {
|
||||||
apiForgotPassword,
|
apiForgotPassword,
|
||||||
apiLogin,
|
apiLogin,
|
||||||
|
apiLogin2FA,
|
||||||
apiQueryProfile,
|
apiQueryProfile,
|
||||||
} from '@/services/master/AuthController';
|
} from '@/services/master/AuthController';
|
||||||
import { checkRefreshTokenExpired } from '@/utils/jwt';
|
import { checkRefreshTokenExpired } from '@/utils/jwt';
|
||||||
import { getDomainTitle, getLogoImage } from '@/utils/logo';
|
import { getDomainTitle, getLogoImage } from '@/utils/logo';
|
||||||
|
import {
|
||||||
|
clearCredentials,
|
||||||
|
hasSavedCredentials,
|
||||||
|
loadCredentials,
|
||||||
|
saveCredentials,
|
||||||
|
} from '@/utils/rememberMe';
|
||||||
import {
|
import {
|
||||||
getBrowserId,
|
getBrowserId,
|
||||||
getRefreshToken,
|
getRefreshToken,
|
||||||
|
getTheme,
|
||||||
removeAccessToken,
|
removeAccessToken,
|
||||||
removeRefreshToken,
|
removeRefreshToken,
|
||||||
setAccessToken,
|
setAccessToken,
|
||||||
setRefreshToken,
|
setRefreshToken,
|
||||||
} from '@/utils/storage';
|
} from '@/utils/storage';
|
||||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
import { LoginFormPage } from '@ant-design/pro-components';
|
||||||
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
|
|
||||||
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
|
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
|
||||||
import { Button, ConfigProvider, Flex, Image, message, theme } from 'antd';
|
import {
|
||||||
import { CSSProperties, useEffect, useState } from 'react';
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
ConfigProvider,
|
||||||
|
Flex,
|
||||||
|
Form,
|
||||||
|
Image,
|
||||||
|
message,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import { CSSProperties, useEffect, useRef, 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';
|
import ForgotPasswordForm from './components/ForgotPasswordForm';
|
||||||
|
import LoginForm from './components/LoginForm';
|
||||||
|
import OtpForm from './components/OtpForm';
|
||||||
|
|
||||||
|
type LoginType = 'login' | 'forgot' | 'otp';
|
||||||
|
|
||||||
// Form wrapper with animation
|
// Form wrapper with animation
|
||||||
const FormWrapper = ({
|
const FormWrapper = ({
|
||||||
children,
|
children,
|
||||||
key,
|
animationKey,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
key: string;
|
animationKey: string;
|
||||||
}) => {
|
}) => {
|
||||||
const style: CSSProperties = {
|
const style: CSSProperties = {
|
||||||
animation: 'fadeInSlide 0.4s ease-out forwards',
|
animation: 'fadeInSlide 0.4s ease-out forwards',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} style={style}>
|
<div key={animationKey} style={style}>
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes fadeInSlide {
|
@keyframes fadeInSlide {
|
||||||
from {
|
from {
|
||||||
@@ -59,9 +78,7 @@ const FormWrapper = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const [isDark, setIsDark] = useState(
|
const [isDark, setIsDark] = useState(getTheme() === 'dark');
|
||||||
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') === 'dark',
|
|
||||||
);
|
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const urlParams = new URL(window.location.href).searchParams;
|
const urlParams = new URL(window.location.href).searchParams;
|
||||||
@@ -69,6 +86,13 @@ const LoginPage = () => {
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { setInitialState } = useModel('@@initialState');
|
const { setInitialState } = useModel('@@initialState');
|
||||||
const [loginType, setLoginType] = useState<LoginType>('login');
|
const [loginType, setLoginType] = useState<LoginType>('login');
|
||||||
|
const [pending2faToken, setPending2faToken] = useState<string>('');
|
||||||
|
const [pendingCredentials, setPendingCredentials] = useState<{
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
rememberMe: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const formRef = useRef<any>(null);
|
||||||
|
|
||||||
// Listen for theme changes from ThemeSwitcherAuth
|
// Listen for theme changes from ThemeSwitcherAuth
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,6 +109,7 @@ const LoginPage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkLogin = async () => {
|
const checkLogin = async () => {
|
||||||
const refreshToken = getRefreshToken();
|
const refreshToken = getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
@@ -102,6 +127,7 @@ const LoginPage = () => {
|
|||||||
setInitialState((s: any) => ({
|
setInitialState((s: any) => ({
|
||||||
...s,
|
...s,
|
||||||
currentUserProfile: userInfo,
|
currentUserProfile: userInfo,
|
||||||
|
theme: getTheme() as 'light' | 'dark',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -117,8 +143,32 @@ const LoginPage = () => {
|
|||||||
checkLogin();
|
checkLogin();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogin = async (values: MasterModel.LoginRequestBody) => {
|
// Load saved credentials on mount
|
||||||
const { email, password } = values;
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (hasSavedCredentials()) {
|
||||||
|
const saved = loadCredentials();
|
||||||
|
if (saved && formRef.current) {
|
||||||
|
// Use setTimeout to ensure form is mounted before setting values
|
||||||
|
setTimeout(() => {
|
||||||
|
if (formRef.current) {
|
||||||
|
formRef.current.setFieldsValue({
|
||||||
|
email: saved.email,
|
||||||
|
password: saved.password,
|
||||||
|
rememberMe: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading saved credentials:', e);
|
||||||
|
clearCredentials();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async (values: any) => {
|
||||||
|
const { email, password, rememberMe } = values;
|
||||||
if (loginType === 'login') {
|
if (loginType === 'login') {
|
||||||
try {
|
try {
|
||||||
const resp = await apiLogin({
|
const resp = await apiLogin({
|
||||||
@@ -126,15 +176,31 @@ const LoginPage = () => {
|
|||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
// Check if 2FA is enabled
|
||||||
|
if (resp?.enabled2fa && resp?.token) {
|
||||||
|
// Save pending credentials and 2FA token and switch to OTP form
|
||||||
|
setPendingCredentials({ email, password, rememberMe });
|
||||||
|
setPending2faToken(resp.token);
|
||||||
|
setLoginType('otp');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (resp?.token) {
|
if (resp?.token) {
|
||||||
|
// Handle remember me - save or clear credentials
|
||||||
|
if (rememberMe) {
|
||||||
|
saveCredentials(email, password);
|
||||||
|
} else {
|
||||||
|
clearCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
setAccessToken(resp.token);
|
setAccessToken(resp.token);
|
||||||
setRefreshToken(resp.refresh_token);
|
setRefreshToken(resp.refresh_token || '');
|
||||||
const userInfo = await apiQueryProfile();
|
const userInfo = await apiQueryProfile();
|
||||||
if (userInfo) {
|
if (userInfo) {
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
setInitialState((s: any) => ({
|
setInitialState((s: any) => ({
|
||||||
...s,
|
...s,
|
||||||
currentUserProfile: userInfo,
|
currentUserProfile: userInfo,
|
||||||
|
theme: getTheme() as 'light' | 'dark',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -147,6 +213,52 @@ const LoginPage = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
}
|
}
|
||||||
|
} else if (loginType === 'otp') {
|
||||||
|
// Handle OTP verification
|
||||||
|
try {
|
||||||
|
const resp = await apiLogin2FA(pending2faToken, {
|
||||||
|
otp: values.otp || '',
|
||||||
|
});
|
||||||
|
if (resp?.token) {
|
||||||
|
// Handle remember me - save or clear credentials
|
||||||
|
if (pendingCredentials) {
|
||||||
|
if (pendingCredentials.rememberMe) {
|
||||||
|
saveCredentials(
|
||||||
|
pendingCredentials.email,
|
||||||
|
pendingCredentials.password,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
clearCredentials();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccessToken(resp.token);
|
||||||
|
setRefreshToken(resp.refresh_token || '');
|
||||||
|
const userInfo = await apiQueryProfile();
|
||||||
|
if (userInfo) {
|
||||||
|
flushSync(() => {
|
||||||
|
setInitialState((s: any) => ({
|
||||||
|
...s,
|
||||||
|
currentUserProfile: userInfo,
|
||||||
|
theme: getTheme() as 'light' | 'dark',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (redirect) {
|
||||||
|
history.push(redirect);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTER_HOME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('2FA verification error:', error);
|
||||||
|
messageApi.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.auth.otp.error',
|
||||||
|
defaultMessage: 'Invalid OTP code. Please try again.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const host = window.location.origin;
|
const host = window.location.origin;
|
||||||
@@ -176,6 +288,31 @@ const LoginPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSubmitText = () => {
|
||||||
|
if (loginType === 'login') {
|
||||||
|
return intl.formatMessage({
|
||||||
|
id: 'master.auth.login.title',
|
||||||
|
defaultMessage: 'Đăng nhập',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (loginType === 'otp') {
|
||||||
|
return intl.formatMessage({
|
||||||
|
id: 'master.auth.otp.button.title',
|
||||||
|
defaultMessage: 'Verify OTP',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return intl.formatMessage({
|
||||||
|
id: 'master.auth.forgot.button.title',
|
||||||
|
defaultMessage: 'Send Reset Link',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackToLogin = () => {
|
||||||
|
setPending2faToken('');
|
||||||
|
setPendingCredentials(null);
|
||||||
|
setLoginType('login');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
theme={{
|
theme={{
|
||||||
@@ -190,6 +327,7 @@ const LoginPage = () => {
|
|||||||
>
|
>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<LoginFormPage
|
<LoginFormPage
|
||||||
|
formRef={formRef}
|
||||||
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()}
|
||||||
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
|
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
|
||||||
@@ -208,131 +346,36 @@ const LoginPage = () => {
|
|||||||
subTitle={<Image preview={false} src={mobifontLogo} />}
|
subTitle={<Image preview={false} src={mobifontLogo} />}
|
||||||
submitter={{
|
submitter={{
|
||||||
searchConfig: {
|
searchConfig: {
|
||||||
submitText:
|
submitText: getSubmitText(),
|
||||||
loginType === 'login'
|
|
||||||
? intl.formatMessage({
|
|
||||||
id: 'master.auth.login.title',
|
|
||||||
defaultMessage: 'Đăng nhập',
|
|
||||||
})
|
|
||||||
: intl.formatMessage({
|
|
||||||
id: 'master.auth.forgot.button.title',
|
|
||||||
defaultMessage: 'Đăng nhập',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onFinish={async (values: MasterModel.LoginRequestBody) =>
|
onFinish={async (values: any) => handleLogin(values)}
|
||||||
handleLogin(values)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<FormWrapper key={loginType}>
|
<FormWrapper animationKey={loginType}>
|
||||||
{loginType === 'login' && (
|
{loginType === 'login' && <LoginForm />}
|
||||||
<>
|
{loginType === 'forgot' && <ForgotPasswordForm />}
|
||||||
<ProFormText
|
{loginType === 'otp' && <OtpForm />}
|
||||||
name="email"
|
|
||||||
fieldProps={{
|
|
||||||
autoComplete: 'email',
|
|
||||||
autoFocus: true,
|
|
||||||
size: 'large',
|
|
||||||
prefix: (
|
|
||||||
<UserOutlined
|
|
||||||
// style={{
|
|
||||||
// color: token.colorText,
|
|
||||||
// }}
|
|
||||||
className={'prefixIcon'}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
placeholder={intl.formatMessage({
|
|
||||||
id: 'master.auth.login.email',
|
|
||||||
defaultMessage: 'Email',
|
|
||||||
})}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: (
|
|
||||||
<FormattedMessage
|
|
||||||
id="master.users.email.required"
|
|
||||||
defaultMessage="The email is required"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'email',
|
|
||||||
message: (
|
|
||||||
<FormattedMessage
|
|
||||||
id="master.users.email.invalid"
|
|
||||||
defaultMessage="Invalid email address"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<ProFormText.Password
|
|
||||||
name="password"
|
|
||||||
fieldProps={{
|
|
||||||
size: 'large',
|
|
||||||
autoComplete: 'current-password',
|
|
||||||
prefix: <LockOutlined className={'prefixIcon'} />,
|
|
||||||
}}
|
|
||||||
placeholder={intl.formatMessage({
|
|
||||||
id: 'master.auth.password',
|
|
||||||
defaultMessage: 'Mật khẩu',
|
|
||||||
})}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: intl.formatMessage({
|
|
||||||
id: 'master.auth.validation.password',
|
|
||||||
defaultMessage: 'Mật khẩu không được để trống!',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{loginType === 'forgot' && (
|
|
||||||
<>
|
|
||||||
<ProFormText
|
|
||||||
name="email"
|
|
||||||
fieldProps={{
|
|
||||||
autoComplete: 'email',
|
|
||||||
autoFocus: true,
|
|
||||||
size: 'large',
|
|
||||||
prefix: <UserOutlined className={'prefixIcon'} />,
|
|
||||||
}}
|
|
||||||
placeholder={intl.formatMessage({
|
|
||||||
id: 'master.auth.login.email',
|
|
||||||
defaultMessage: 'Email',
|
|
||||||
})}
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: (
|
|
||||||
<FormattedMessage
|
|
||||||
id="master.users.email.required"
|
|
||||||
defaultMessage="The email is required"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'email',
|
|
||||||
message: (
|
|
||||||
<FormattedMessage
|
|
||||||
id="master.users.email.invalid"
|
|
||||||
defaultMessage="Invalid email address"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FormWrapper>
|
</FormWrapper>
|
||||||
<Flex
|
<Flex
|
||||||
justify="flex-end"
|
justify="space-between"
|
||||||
align="flex-start"
|
align="center"
|
||||||
style={{ marginBlockEnd: 16 }}
|
style={{ marginBlockEnd: 16 }}
|
||||||
>
|
>
|
||||||
|
{loginType === 'login' && (
|
||||||
|
<Form.Item
|
||||||
|
name="rememberMe"
|
||||||
|
valuePropName="checked"
|
||||||
|
initialValue={false}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
>
|
||||||
|
<Checkbox>
|
||||||
|
<FormattedMessage
|
||||||
|
id="master.auth.rememberMe"
|
||||||
|
defaultMessage="Ghi nhớ mật khẩu"
|
||||||
|
/>
|
||||||
|
</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -340,7 +383,7 @@ const LoginPage = () => {
|
|||||||
if (loginType === 'login') {
|
if (loginType === 'login') {
|
||||||
setLoginType('forgot');
|
setLoginType('forgot');
|
||||||
} else {
|
} else {
|
||||||
setLoginType('login');
|
handleBackToLogin();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -377,4 +420,5 @@ const LoginPage = () => {
|
|||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LoginPage;
|
export default LoginPage;
|
||||||
|
|||||||
259
src/pages/Manager/Dashboard/index.tsx
Normal file
259
src/pages/Manager/Dashboard/index.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import IconFont from '@/components/IconFont';
|
||||||
|
import { apiQueryGroups } from '@/services/master/GroupController';
|
||||||
|
import { apiQueryLogs } from '@/services/master/LogController';
|
||||||
|
import { apiSearchThings } from '@/services/master/ThingController';
|
||||||
|
import { apiQueryUsers } from '@/services/master/UserController';
|
||||||
|
import { RightOutlined } from '@ant-design/icons';
|
||||||
|
import { PageContainer, ProCard } from '@ant-design/pro-components';
|
||||||
|
import { history, useIntl } from '@umijs/max';
|
||||||
|
import { Button, Col, Divider, Row, Statistic, Typography } from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import CountUp from 'react-countup';
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const ManagerDashboard = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [counts, setCounts] = useState({
|
||||||
|
devices: 0,
|
||||||
|
groups: 0,
|
||||||
|
users: 0,
|
||||||
|
logs: 0,
|
||||||
|
});
|
||||||
|
const [deviceMetadata, setDeviceMetadata] =
|
||||||
|
useState<MasterModel.ThingsResponseMetadata | null>(null);
|
||||||
|
|
||||||
|
const formatter = (value: number | string) => (
|
||||||
|
<CountUp end={Number(value)} separator="," duration={2} />
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [devicesRes, groupsRes, usersRes, logsRes] = await Promise.all([
|
||||||
|
apiSearchThings({ limit: 1 }),
|
||||||
|
apiQueryGroups({}),
|
||||||
|
apiQueryUsers({ limit: 1 }),
|
||||||
|
apiQueryLogs({ limit: 1 }, 'user_logs'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const devicesTotal = devicesRes?.total || 0;
|
||||||
|
const metadata = (devicesRes as any)?.metadata || null;
|
||||||
|
|
||||||
|
// Group response handling
|
||||||
|
const groupsTotal =
|
||||||
|
(Array.isArray(groupsRes)
|
||||||
|
? groupsRes.length
|
||||||
|
: (groupsRes as any)?.total) || 0;
|
||||||
|
const usersTotal = usersRes?.total || 0;
|
||||||
|
const logsTotal = logsRes?.total || 0;
|
||||||
|
|
||||||
|
setCounts({
|
||||||
|
devices: devicesTotal,
|
||||||
|
groups: groupsTotal,
|
||||||
|
users: usersTotal,
|
||||||
|
logs: logsTotal,
|
||||||
|
});
|
||||||
|
setDeviceMetadata(metadata);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch dashboard counts:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<ProCard gutter={[16, 16]} ghost>
|
||||||
|
<ProCard colSpan={{ xs: 24, md: 10 }} ghost gutter={[16, 16]} wrap>
|
||||||
|
<ProCard
|
||||||
|
colSpan={24}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'menu.manager.devices',
|
||||||
|
defaultMessage: 'Thiết bị',
|
||||||
|
})}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
onClick={() => history.push('/manager/devices')}
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
>
|
||||||
|
Xem chi tiết
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={counts.devices}
|
||||||
|
formatter={formatter as any}
|
||||||
|
valueStyle={{ color: '#1890ff' }}
|
||||||
|
prefix={
|
||||||
|
<IconFont
|
||||||
|
type="icon-gateway"
|
||||||
|
style={{ fontSize: 24, marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
<ProCard
|
||||||
|
colSpan={24}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'menu.manager.groups',
|
||||||
|
defaultMessage: 'Đơn vị',
|
||||||
|
})}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
onClick={() => history.push('/manager/groups')}
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
>
|
||||||
|
Xem chi tiết
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={counts.groups}
|
||||||
|
formatter={formatter as any}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
prefix={
|
||||||
|
<IconFont
|
||||||
|
type="icon-tree"
|
||||||
|
style={{ fontSize: 24, marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
<ProCard
|
||||||
|
colSpan={24}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'menu.manager.users',
|
||||||
|
defaultMessage: 'Người dùng',
|
||||||
|
})}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
onClick={() => history.push('/manager/users')}
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
>
|
||||||
|
Xem chi tiết
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={counts.users}
|
||||||
|
formatter={formatter as any}
|
||||||
|
valueStyle={{ color: '#faad14' }}
|
||||||
|
prefix={
|
||||||
|
<IconFont
|
||||||
|
type="icon-users"
|
||||||
|
style={{ fontSize: 24, marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
<ProCard
|
||||||
|
colSpan={24}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'menu.manager.logs',
|
||||||
|
defaultMessage: 'Hoạt động',
|
||||||
|
})}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
onClick={() => history.push('/manager/logs')}
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
>
|
||||||
|
Xem chi tiết
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={counts.logs}
|
||||||
|
formatter={formatter as any}
|
||||||
|
valueStyle={{ color: '#f5222d' }}
|
||||||
|
prefix={
|
||||||
|
<IconFont
|
||||||
|
type="icon-diary"
|
||||||
|
style={{ fontSize: 24, marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
|
<ProCard
|
||||||
|
colSpan={{ xs: 24, md: 8 }}
|
||||||
|
title="Trạng thái thiết bị"
|
||||||
|
headerBordered
|
||||||
|
>
|
||||||
|
{deviceMetadata && (
|
||||||
|
<Row gutter={[0, 16]}>
|
||||||
|
<Col span={24}>
|
||||||
|
<Text type="secondary">Kết nối</Text>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 'bold' }}>
|
||||||
|
<CountUp end={deviceMetadata.total_connected || 0} /> /{' '}
|
||||||
|
{deviceMetadata.total_thing}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
<Col span={24}>
|
||||||
|
<Text type="secondary">SOS</Text>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color:
|
||||||
|
(deviceMetadata.total_sos || 0) > 0
|
||||||
|
? '#ff4d4f'
|
||||||
|
: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CountUp end={deviceMetadata.total_sos || 0} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
<Col span={24}>
|
||||||
|
<Text type="secondary">Bình thường</Text>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#52c41a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CountUp end={deviceMetadata.total_state_level_0 || 0} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
<Col span={24}>
|
||||||
|
<Text type="secondary">Cảnh báo</Text>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#faad14',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CountUp end={deviceMetadata.total_state_level_1 || 0} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Divider style={{ margin: 0 }} />
|
||||||
|
<Col span={24}>
|
||||||
|
<Text type="secondary">Nghiêm trọng</Text>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#f5222d',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CountUp end={deviceMetadata.total_state_level_2 || 0} />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ManagerDashboard;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useIntl } from '@umijs/max';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Col,
|
Col,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
// Camera types
|
// Camera types
|
||||||
const CAMERA_TYPES = [
|
const CAMERA_TYPES = [
|
||||||
@@ -16,35 +18,50 @@ const CAMERA_TYPES = [
|
|||||||
{ label: 'GENERIC', value: 'GENERIC' },
|
{ 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 {
|
interface CameraFormModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (values: CameraFormValues) => void;
|
onSubmit: (camera: MasterModel.Camera) => void;
|
||||||
|
isOnline?: boolean;
|
||||||
|
editingCamera?: MasterModel.Camera | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
||||||
open,
|
open,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
isOnline = true,
|
||||||
|
editingCamera,
|
||||||
}) => {
|
}) => {
|
||||||
const [form] = Form.useForm<CameraFormValues>();
|
const [form] = Form.useForm<MasterModel.Camera>();
|
||||||
|
const intl = useIntl();
|
||||||
|
const isEditMode = !!editingCamera;
|
||||||
|
|
||||||
|
// Populate form when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && editingCamera) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
...editingCamera,
|
||||||
|
cate_id: editingCamera.cate_id || 'HIKVISION',
|
||||||
|
rtsp_port: editingCamera.rtsp_port || 554,
|
||||||
|
http_port: editingCamera.http_port || 80,
|
||||||
|
});
|
||||||
|
} else if (open) {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}, [open, editingCamera, form]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
onSubmit(values);
|
// Values already in MasterModel.Camera format
|
||||||
|
const camera: MasterModel.Camera = {
|
||||||
|
...editingCamera,
|
||||||
|
...values,
|
||||||
|
// Ensure ID is set for new cameras
|
||||||
|
id: editingCamera?.id || `cam_${Date.now()}`,
|
||||||
|
};
|
||||||
|
onSubmit(camera);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Validation failed:', error);
|
console.error('Validation failed:', error);
|
||||||
@@ -58,15 +75,28 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title="Tạo mới camera"
|
title={intl.formatMessage({
|
||||||
|
id: isEditMode
|
||||||
|
? 'master.devices.camera.form.title.edit'
|
||||||
|
: 'master.devices.camera.form.title.add',
|
||||||
|
defaultMessage: isEditMode ? 'Chỉnh sửa camera' : 'Tạo mới camera',
|
||||||
|
})}
|
||||||
open={open}
|
open={open}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="cancel" onClick={handleCancel}>
|
<Button key="cancel" onClick={handleCancel}>
|
||||||
Hủy
|
{intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.cancel',
|
||||||
|
defaultMessage: 'Hủy',
|
||||||
|
})}
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button key="submit" type="primary" onClick={handleSubmit}>
|
<Button key="submit" type="primary" onClick={handleSubmit}>
|
||||||
Đồng ý
|
{intl.formatMessage({
|
||||||
|
id: isEditMode
|
||||||
|
? 'master.devices.camera.form.update'
|
||||||
|
: 'master.devices.camera.form.submit',
|
||||||
|
defaultMessage: isEditMode ? 'Cập nhật' : 'Đồng ý',
|
||||||
|
})}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
width={500}
|
width={500}
|
||||||
@@ -75,71 +105,168 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
|||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
type: 'HIKVISION',
|
cate_id: 'HIKVISION',
|
||||||
rtspPort: 554,
|
ip: '192.168.1.10',
|
||||||
httpPort: 80,
|
rtsp_port: 554,
|
||||||
|
http_port: 80,
|
||||||
stream: 0,
|
stream: 0,
|
||||||
channel: 0,
|
channel: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Tên"
|
label={intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.name',
|
||||||
|
defaultMessage: 'Tên',
|
||||||
|
})}
|
||||||
name="name"
|
name="name"
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.name.required',
|
||||||
|
defaultMessage: 'Vui lòng nhập tên',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="nhập dữ liệu" />
|
<Input
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.name.placeholder',
|
||||||
|
defaultMessage: 'nhập dữ liệu',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Loại"
|
label={intl.formatMessage({
|
||||||
name="type"
|
id: 'master.devices.camera.form.type',
|
||||||
rules={[{ required: true, message: 'Vui lòng chọn loại' }]}
|
defaultMessage: 'Loại',
|
||||||
|
})}
|
||||||
|
name="cate_id"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.type.required',
|
||||||
|
defaultMessage: 'Vui lòng chọn loại',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Select options={CAMERA_TYPES} />
|
<Select options={CAMERA_TYPES} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Tài khoản"
|
label={intl.formatMessage({
|
||||||
name="account"
|
id: 'master.devices.camera.form.username',
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập tài khoản' }]}
|
defaultMessage: 'Tài khoản',
|
||||||
|
})}
|
||||||
|
name="username"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.username.required',
|
||||||
|
defaultMessage: 'Vui lòng nhập tài khoản',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="nhập tài khoản" autoComplete="off" />
|
<Input
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.username.placeholder',
|
||||||
|
defaultMessage: 'nhập tài khoản',
|
||||||
|
})}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Mật khẩu"
|
label={intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.password',
|
||||||
|
defaultMessage: 'Mật khẩu',
|
||||||
|
})}
|
||||||
name="password"
|
name="password"
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập mật khẩu' }]}
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.password.required',
|
||||||
|
defaultMessage: 'Vui lòng nhập mật khẩu',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input.Password
|
<Input.Password
|
||||||
placeholder="nhập mật khẩu"
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.password.placeholder',
|
||||||
|
defaultMessage: 'nhập mật khẩu',
|
||||||
|
})}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Địa chỉ IP"
|
label={intl.formatMessage({
|
||||||
name="ipAddress"
|
id: 'master.devices.camera.form.ip',
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập địa chỉ IP' }]}
|
defaultMessage: 'Địa chỉ IP',
|
||||||
|
})}
|
||||||
|
name="ip"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.ip.required',
|
||||||
|
defaultMessage: 'Vui lòng nhập địa chỉ IP',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="192.168.1.10" />
|
<Input
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.ip.placeholder',
|
||||||
|
defaultMessage: '192.168.1.10',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Cổng RTSP"
|
label={intl.formatMessage({
|
||||||
name="rtspPort"
|
id: 'master.devices.camera.form.rtspPort',
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập cổng RTSP' }]}
|
defaultMessage: 'Cổng RTSP',
|
||||||
|
})}
|
||||||
|
name="rtsp_port"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.rtspPort.required',
|
||||||
|
defaultMessage: 'Vui lòng nhập cổng RTSP',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Cổng HTTP"
|
label={intl.formatMessage({
|
||||||
name="httpPort"
|
id: 'master.devices.camera.form.httpPort',
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập cổng HTTP' }]}
|
defaultMessage: 'Cổng HTTP',
|
||||||
|
})}
|
||||||
|
name="http_port"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.httpPort.required',
|
||||||
|
defaultMessage: 'Vui lòng nhập cổng HTTP',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -149,18 +276,40 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
|||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Luồng"
|
label={intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.stream',
|
||||||
|
defaultMessage: 'Luồng',
|
||||||
|
})}
|
||||||
name="stream"
|
name="stream"
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập luồng' }]}
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.stream.required',
|
||||||
|
defaultMessage: 'Vui lòng nhập luồng',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber style={{ width: '100%' }} min={0} />
|
<InputNumber style={{ width: '100%' }} min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Kênh"
|
label={intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.channel',
|
||||||
|
defaultMessage: 'Kênh',
|
||||||
|
})}
|
||||||
name="channel"
|
name="channel"
|
||||||
rules={[{ required: true, message: 'Vui lòng nhập kênh' }]}
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.form.channel.required',
|
||||||
|
defaultMessage: 'Vui lòng nhập kênh',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<InputNumber style={{ width: '100%' }} min={0} />
|
<InputNumber style={{ width: '100%' }} min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -4,22 +4,32 @@ import {
|
|||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Button, Card, Checkbox, Space, Table, theme } from 'antd';
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button, Card, Space, Table, theme, Tooltip } from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface CameraTableProps {
|
interface CameraTableProps {
|
||||||
cameraData: MasterModel.Camera[] | null;
|
cameraData: MasterModel.Camera[] | null;
|
||||||
onCreateCamera: () => void;
|
onCreateCamera: () => void;
|
||||||
|
onEditCamera?: (camera: MasterModel.Camera) => void;
|
||||||
|
onDeleteCameras?: (cameraIds: string[]) => void;
|
||||||
onReload?: () => void;
|
onReload?: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
isOnline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CameraTable: React.FC<CameraTableProps> = ({
|
const CameraTable: React.FC<CameraTableProps> = ({
|
||||||
cameraData,
|
cameraData,
|
||||||
onCreateCamera,
|
onCreateCamera,
|
||||||
|
onEditCamera,
|
||||||
|
onDeleteCameras,
|
||||||
onReload,
|
onReload,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
isOnline = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
const intl = useIntl();
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
|
||||||
const handleReload = () => {
|
const handleReload = () => {
|
||||||
console.log('Reload cameras');
|
console.log('Reload cameras');
|
||||||
@@ -27,24 +37,23 @@ const CameraTable: React.FC<CameraTableProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
console.log('Delete selected cameras');
|
if (selectedRowKeys.length === 0) {
|
||||||
// TODO: Implement delete functionality
|
return;
|
||||||
|
}
|
||||||
|
onDeleteCameras?.(selectedRowKeys as string[]);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (camera: MasterModel.Camera) => {
|
const handleEdit = (camera: MasterModel.Camera) => {
|
||||||
console.log('Edit camera:', camera);
|
onEditCamera?.(camera);
|
||||||
// TODO: Implement edit functionality
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '',
|
title: intl.formatMessage({
|
||||||
dataIndex: 'checkbox',
|
id: 'master.devices.camera.table.column.name',
|
||||||
width: 50,
|
defaultMessage: 'Tên',
|
||||||
render: () => <Checkbox />,
|
}),
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Tên',
|
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
render: (text: string) => (
|
render: (text: string) => (
|
||||||
@@ -52,26 +61,47 @@ const CameraTable: React.FC<CameraTableProps> = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Loại',
|
title: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.table.column.type',
|
||||||
|
defaultMessage: 'Loại',
|
||||||
|
}),
|
||||||
dataIndex: 'cate_id',
|
dataIndex: 'cate_id',
|
||||||
key: 'cate_id',
|
key: 'cate_id',
|
||||||
render: (text: string) => text || '-',
|
render: (text: string) => text || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Địa chỉ IP',
|
title: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.table.column.ip',
|
||||||
|
defaultMessage: 'Địa chỉ IP',
|
||||||
|
}),
|
||||||
dataIndex: 'ip',
|
dataIndex: 'ip',
|
||||||
key: 'ip',
|
key: 'ip',
|
||||||
render: (text: string) => text || '-',
|
render: (text: string) => text || '-',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Thao tác',
|
title: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.table.column.action',
|
||||||
|
defaultMessage: 'Thao tác',
|
||||||
|
}),
|
||||||
key: 'action',
|
key: 'action',
|
||||||
render: (_: any, record: MasterModel.Camera) => (
|
render: (_: any, record: MasterModel.Camera) => (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
!isOnline
|
||||||
|
? intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.table.offline.tooltip',
|
||||||
|
defaultMessage: 'Thiết bị đang ngoại tuyến',
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
onClick={() => handleEdit(record)}
|
onClick={() => handleEdit(record)}
|
||||||
|
disabled={!isOnline}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -79,11 +109,50 @@ const CameraTable: React.FC<CameraTableProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Card bodyStyle={{ padding: 16 }}>
|
<Card bodyStyle={{ padding: 16 }}>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }}>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateCamera}>
|
<Tooltip
|
||||||
Tạo mới camera
|
title={
|
||||||
|
!isOnline
|
||||||
|
? intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.table.offline.tooltip',
|
||||||
|
defaultMessage: 'Thiết bị đang ngoại tuyến',
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onCreateCamera}
|
||||||
|
disabled={!isOnline}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.table.add',
|
||||||
|
defaultMessage: 'Tạo mới camera',
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<ReloadOutlined />} onClick={handleReload} />
|
</Tooltip>
|
||||||
<Button icon={<DeleteOutlined />} onClick={handleDelete} />
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleReload}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
!isOnline
|
||||||
|
? intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.table.offline.tooltip',
|
||||||
|
defaultMessage: 'Thiết bị đang ngoại tuyến',
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!isOnline || selectedRowKeys.length === 0}
|
||||||
|
danger
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -92,10 +161,26 @@ const CameraTable: React.FC<CameraTableProps> = ({
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
rowSelection={{
|
||||||
|
type: 'checkbox',
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (newSelectedRowKeys) =>
|
||||||
|
setSelectedRowKeys(newSelectedRowKeys),
|
||||||
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
size: 'small',
|
size: 'small',
|
||||||
showTotal: (total: number, range: [number, number]) =>
|
showTotal: (total: number, range: [number, number]) =>
|
||||||
`Hiển thị ${range[0]}-${range[1]} của ${total} camera`,
|
intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: 'master.devices.camera.table.pagination',
|
||||||
|
defaultMessage: 'Hiển thị {0}-{1} của {2} camera',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
0: range[0],
|
||||||
|
1: range[1],
|
||||||
|
2: total,
|
||||||
|
},
|
||||||
|
),
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
|
import { useIntl } from '@umijs/max';
|
||||||
import { Button, Card, Select, Typography } from 'antd';
|
import { Button, Card, Select, Typography } from 'antd';
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
// Recording modes for V5 - chỉ có không ghi và ghi 24/24
|
// 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 {
|
interface CameraV5Props {
|
||||||
thing: MasterModel.Thing | null;
|
thing: MasterModel.Thing | null;
|
||||||
@@ -18,9 +15,29 @@ const CameraV5: React.FC<CameraV5Props> = ({
|
|||||||
thing,
|
thing,
|
||||||
initialRecordingMode = 'none',
|
initialRecordingMode = 'none',
|
||||||
}) => {
|
}) => {
|
||||||
const [recordingMode, setRecordingMode] = useState(initialRecordingMode);
|
const intl = useIntl();
|
||||||
|
|
||||||
console.log('ConfigCameraV5 - thing:', thing);
|
const RECORDING_MODES = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.recordingMode.none',
|
||||||
|
defaultMessage: 'Không ghi',
|
||||||
|
}),
|
||||||
|
value: 'none',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.recordingMode.all',
|
||||||
|
defaultMessage: '24/24',
|
||||||
|
}),
|
||||||
|
value: 'all',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[intl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [recordingMode, setRecordingMode] = useState(initialRecordingMode);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
console.log('Submit recording mode:', recordingMode);
|
console.log('Submit recording mode:', recordingMode);
|
||||||
@@ -30,24 +47,31 @@ const CameraV5: React.FC<CameraV5Props> = ({
|
|||||||
return (
|
return (
|
||||||
<Card bodyStyle={{ padding: 16 }}>
|
<Card bodyStyle={{ padding: 16 }}>
|
||||||
{/* Recording Mode */}
|
{/* Recording Mode */}
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div className="mb-6">
|
||||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 items-start sm:items-end">
|
||||||
Ghi dữ liệu camera
|
<div className="w-full sm:w-1/3 lg:w-1/4">
|
||||||
|
<Text strong className="block mb-2">
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.recording',
|
||||||
|
defaultMessage: 'Ghi dữ liệu camera',
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Select
|
<Select
|
||||||
value={recordingMode}
|
value={recordingMode}
|
||||||
onChange={setRecordingMode}
|
onChange={setRecordingMode}
|
||||||
options={RECORDING_MODES}
|
options={RECORDING_MODES}
|
||||||
style={{ width: 200 }}
|
className="w-full"
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Button type="primary" onClick={handleSubmit}>
|
<Button type="primary" onClick={handleSubmit}>
|
||||||
Gửi đi
|
{intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.send',
|
||||||
|
defaultMessage: 'Gửi đi',
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +1,70 @@
|
|||||||
import { apiQueryConfigAlarm } from '@/services/master/MessageController';
|
import { apiQueryConfigAlarm } from '@/services/master/MessageController';
|
||||||
import { useModel } from '@umijs/max';
|
import { useIntl, useModel } from '@umijs/max';
|
||||||
import { Button, Card, Col, Row, Select, theme, Typography } from 'antd';
|
import {
|
||||||
import { useEffect, useState } from 'react';
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
theme,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
const { Text } = Typography;
|
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 {
|
interface CameraV6Props {
|
||||||
thing: MasterModel.Thing | null;
|
thing: MasterModel.Thing | null;
|
||||||
cameraConfig?: MasterModel.CameraV6 | null;
|
cameraConfig?: MasterModel.CameraV6 | null;
|
||||||
|
onSubmit?: (config: MasterModel.CameraV6) => void;
|
||||||
|
isOnline?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
const CameraV6: React.FC<CameraV6Props> = ({
|
||||||
|
thing,
|
||||||
|
cameraConfig,
|
||||||
|
onSubmit,
|
||||||
|
isOnline = false,
|
||||||
|
}) => {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
const intl = useIntl();
|
||||||
const { initialState } = useModel('@@initialState');
|
const { initialState } = useModel('@@initialState');
|
||||||
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
|
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
|
||||||
const [recordingMode, setRecordingMode] = useState<'none' | 'alarm' | 'all'>(
|
const [recordingMode, setRecordingMode] =
|
||||||
'none',
|
useState<MasterModel.CameraRecordType>('none');
|
||||||
);
|
|
||||||
const [alarmConfig, setAlarmConfig] = useState<MasterModel.Alarm[] | null>(
|
const [alarmConfig, setAlarmConfig] = useState<MasterModel.Alarm[] | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Recording modes for V6 - using useMemo for i18n
|
||||||
|
const RECORDING_MODES = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.recordingMode.none',
|
||||||
|
defaultMessage: 'Không ghi',
|
||||||
|
}),
|
||||||
|
value: 'none',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.recordingMode.alarm',
|
||||||
|
defaultMessage: 'Theo cảnh báo',
|
||||||
|
}),
|
||||||
|
value: 'alarm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.recordingMode.all',
|
||||||
|
defaultMessage: '24/24',
|
||||||
|
}),
|
||||||
|
value: 'all',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[intl],
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize states from cameraConfig when it's available
|
// Initialize states from cameraConfig when it's available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cameraConfig) {
|
if (cameraConfig) {
|
||||||
@@ -95,31 +132,55 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
|||||||
setSelectedAlerts([]);
|
setSelectedAlerts([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitAlerts = () => {
|
const handleSubmitConfig = () => {
|
||||||
console.log('Submit alerts:', {
|
onSubmit?.({
|
||||||
recordingMode,
|
...cameraConfig,
|
||||||
selectedAlerts,
|
record_type: recordingMode,
|
||||||
|
record_alarm_list: recordingMode === 'alarm' ? selectedAlerts : [],
|
||||||
});
|
});
|
||||||
// TODO: Call API to save alert configuration
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
{/* Recording Mode */}
|
{/* Recording Mode */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 items-start sm:items-end">
|
||||||
|
<div className="w-full sm:w-1/3 lg:w-1/4">
|
||||||
<Text strong className="block mb-2">
|
<Text strong className="block mb-2">
|
||||||
Ghi dữ liệu camera
|
{intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.recording',
|
||||||
|
defaultMessage: 'Ghi dữ liệu camera',
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<div className="flex gap-8 items-center">
|
|
||||||
<Select
|
<Select
|
||||||
value={recordingMode}
|
value={recordingMode}
|
||||||
onChange={setRecordingMode}
|
onChange={setRecordingMode}
|
||||||
options={RECORDING_MODES}
|
options={RECORDING_MODES}
|
||||||
className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4"
|
className="w-full"
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
/>
|
/>
|
||||||
<Button type="primary" onClick={handleSubmitAlerts}>
|
</div>
|
||||||
Gửi đi
|
<Tooltip
|
||||||
|
title={
|
||||||
|
!isOnline
|
||||||
|
? intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.table.offline.tooltip',
|
||||||
|
defaultMessage: 'Thiết bị đang ngoại tuyến',
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleSubmitConfig}
|
||||||
|
disabled={!isOnline}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.send',
|
||||||
|
defaultMessage: 'Gửi đi',
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -127,7 +188,10 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
|||||||
{recordingMode === 'alarm' && (
|
{recordingMode === 'alarm' && (
|
||||||
<div>
|
<div>
|
||||||
<Text strong className="block mb-2">
|
<Text strong className="block mb-2">
|
||||||
Danh sách cảnh báo
|
{intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.alarmList',
|
||||||
|
defaultMessage: 'Danh sách cảnh báo',
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -137,9 +201,22 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
|||||||
borderColor: token.colorBorder,
|
borderColor: token.colorBorder,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text type="secondary">đã chọn {selectedAlerts.length} mục</Text>
|
<Text type="secondary">
|
||||||
|
{intl.formatMessage(
|
||||||
|
{
|
||||||
|
id: 'master.devices.camera.config.selected',
|
||||||
|
defaultMessage: 'đã chọn {0} mục',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
0: selectedAlerts.length,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
<Button type="link" onClick={handleClearAlerts}>
|
<Button type="link" onClick={handleClearAlerts}>
|
||||||
Xóa
|
{intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.clear',
|
||||||
|
defaultMessage: 'Xóa',
|
||||||
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -155,7 +232,7 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
|||||||
size="small"
|
size="small"
|
||||||
hoverable
|
hoverable
|
||||||
onClick={() => handleAlertToggle(alarmId)}
|
onClick={() => handleAlertToggle(alarmId)}
|
||||||
className="cursor-pointer h-20 flex items-center justify-center"
|
className="cursor-pointer h-24 flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
borderColor: isSelected
|
borderColor: isSelected
|
||||||
? token.colorPrimary
|
? token.colorPrimary
|
||||||
@@ -166,14 +243,21 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
|||||||
: token.colorBgContainer,
|
: token.colorBgContainer,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="p-2 text-center w-full">
|
<div className="p-1 text-center w-full flex items-center justify-center h-full">
|
||||||
<Text
|
<Text
|
||||||
className="text-xs break-words"
|
className="text-xs"
|
||||||
style={{
|
style={{
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? token.colorPrimary
|
? token.colorPrimary
|
||||||
: token.colorText,
|
: token.colorText,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 3,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
lineHeight: '1.2em',
|
||||||
}}
|
}}
|
||||||
|
title={alarm.name}
|
||||||
>
|
>
|
||||||
{alarm.name}
|
{alarm.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { apiQueryCamera } from '@/services/master/MessageController';
|
import { apiQueryCamera } from '@/services/master/MessageController';
|
||||||
import { apiGetThingDetail } from '@/services/master/ThingController';
|
import { apiGetThingDetail } from '@/services/master/ThingController';
|
||||||
import { wsClient } from '@/utils/wsClient';
|
import { mqttClient } from '@/utils/mqttClient';
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
import { PageContainer } from '@ant-design/pro-components';
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
import { history, useModel, useParams } from '@umijs/max';
|
import { history, useIntl, useModel, useParams } from '@umijs/max';
|
||||||
import { Button, Col, Row, Space, Spin } from 'antd';
|
import { Badge, Button, Col, message, Row, Space, Spin } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import CameraFormModal from './components/CameraFormModal';
|
import CameraFormModal from './components/CameraFormModal';
|
||||||
import CameraTable from './components/CameraTable';
|
import CameraTable from './components/CameraTable';
|
||||||
import ConfigCameraV5 from './components/ConfigCameraV5';
|
import ConfigCameraV5 from './components/ConfigCameraV5';
|
||||||
@@ -13,6 +14,7 @@ import ConfigCameraV6 from './components/ConfigCameraV6';
|
|||||||
|
|
||||||
const CameraConfigPage = () => {
|
const CameraConfigPage = () => {
|
||||||
const { thingId } = useParams<{ thingId: string }>();
|
const { thingId } = useParams<{ thingId: string }>();
|
||||||
|
const intl = useIntl();
|
||||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [cameraLoading, setCameraLoading] = useState(false);
|
const [cameraLoading, setCameraLoading] = useState(false);
|
||||||
@@ -21,18 +23,49 @@ const CameraConfigPage = () => {
|
|||||||
const [cameraConfig, setCameraConfig] = useState<MasterModel.CameraV6 | null>(
|
const [cameraConfig, setCameraConfig] = useState<MasterModel.CameraV6 | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [isOnline, setIsOnline] = useState(false);
|
||||||
|
const [editingCamera, setEditingCamera] = useState<MasterModel.Camera | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const { initialState } = useModel('@@initialState');
|
const { initialState } = useModel('@@initialState');
|
||||||
|
|
||||||
|
// Initialize MQTT connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
wsClient.connect('/mqtt', false);
|
const { frontend_thing_id, frontend_thing_key } =
|
||||||
const unsubscribe = wsClient.subscribe((data: any) => {
|
initialState?.currentUserProfile?.metadata || {};
|
||||||
console.log('Received WS data:', data);
|
|
||||||
|
if (!frontend_thing_id || !frontend_thing_key) return;
|
||||||
|
|
||||||
|
// Connect using mqttClient utility
|
||||||
|
mqttClient.connect({
|
||||||
|
username: frontend_thing_id,
|
||||||
|
password: frontend_thing_key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const unConnect = mqttClient.onConnect(() => {
|
||||||
|
// MQTT connected
|
||||||
|
});
|
||||||
|
|
||||||
|
const unError = mqttClient.onError((error) => {
|
||||||
|
console.error('MQTT Error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const unClose = mqttClient.onClose(() => {
|
||||||
|
// MQTT connection closed
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unConnect();
|
||||||
|
unError();
|
||||||
|
unClose();
|
||||||
|
mqttClient.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [initialState]);
|
||||||
|
|
||||||
|
// Check device online status using connected property
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOnline(thing?.metadata?.connected ?? false);
|
||||||
|
}, [thing]);
|
||||||
|
|
||||||
// Fetch thing info on mount
|
// Fetch thing info on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,27 +132,148 @@ const CameraConfigPage = () => {
|
|||||||
|
|
||||||
const handleCloseModal = () => {
|
const handleCloseModal = () => {
|
||||||
setIsModalVisible(false);
|
setIsModalVisible(false);
|
||||||
|
setEditingCamera(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitCamera = (values: any) => {
|
// Core function to send camera config via MQTT
|
||||||
console.log('Camera values:', values);
|
const sendCameraConfig = (configPayload: MasterModel.CameraV6) => {
|
||||||
// TODO: Call API to create camera
|
// Skip sending for gmsv5
|
||||||
|
if (thing?.metadata?.type === 'gmsv5') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if device is online
|
||||||
|
if (!isOnline) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.error.deviceOffline',
|
||||||
|
defaultMessage: 'Thiết bị đang ngoại tuyến, không thể gửi cấu hình',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!thing?.metadata?.cfg_channel_id || !thing?.metadata?.external_id) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.error.missingConfig',
|
||||||
|
defaultMessage: 'Thiếu thông tin cấu hình thiết bị',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { cfg_channel_id, external_id } = thing.metadata;
|
||||||
|
const pubTopic = `channels/${cfg_channel_id}/messages/cameraconfig/${thing.metadata?.type}`;
|
||||||
|
const mac = external_id?.replaceAll('-', '');
|
||||||
|
const ack = uuidv4();
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
|
||||||
|
const senml: MasterModel.CameraV6ConfigRequest[] = [
|
||||||
|
{
|
||||||
|
bn: `urn:dev:mac:${mac?.toLowerCase()}:`,
|
||||||
|
n: 'ack',
|
||||||
|
t: now,
|
||||||
|
vs: ack,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
n: initialState?.currentUserProfile?.email?.replaceAll('@', ':') || '',
|
||||||
|
vs: JSON.stringify(configPayload),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const payload = JSON.stringify(senml);
|
||||||
|
|
||||||
|
if (mqttClient.isConnected()) {
|
||||||
|
mqttClient.publish(pubTopic, payload);
|
||||||
|
message.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.success',
|
||||||
|
defaultMessage: 'Đã gửi cấu hình thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.config.error.mqttNotConnected',
|
||||||
|
defaultMessage: 'MQTT chưa kết nối',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle camera list config submission (add/edit/delete cameras)
|
||||||
|
const handleSubmitCameraConfig = (updatedCams: MasterModel.Camera[]) => {
|
||||||
|
const configPayload: MasterModel.CameraV6 = {
|
||||||
|
cams: updatedCams,
|
||||||
|
record_type: cameraConfig?.record_type || 'all',
|
||||||
|
...(cameraConfig?.record_type === 'alarm' && {
|
||||||
|
record_alarm_list: cameraConfig?.record_alarm_list || [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sendCameraConfig(configPayload)) {
|
||||||
|
// Update local state
|
||||||
|
setCameraConfig(configPayload);
|
||||||
|
setCameras(updatedCams);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle open edit modal - open modal with camera data for editing
|
||||||
|
const handleOpenEditModal = (camera: MasterModel.Camera) => {
|
||||||
|
setEditingCamera(camera);
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle submit camera (add or edit)
|
||||||
|
const handleSubmitCamera = (camera: MasterModel.Camera) => {
|
||||||
|
if (editingCamera) {
|
||||||
|
// Edit mode: update existing camera
|
||||||
|
const updatedCams = (cameraConfig?.cams || []).map((cam) =>
|
||||||
|
cam.id === camera.id ? camera : cam,
|
||||||
|
);
|
||||||
|
handleSubmitCameraConfig(updatedCams);
|
||||||
|
} else {
|
||||||
|
// Add mode: add new camera
|
||||||
|
const updatedCams = [...(cameraConfig?.cams || []), camera];
|
||||||
|
handleSubmitCameraConfig(updatedCams);
|
||||||
|
}
|
||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle delete cameras - submit config with cameras removed
|
||||||
|
const handleDeleteCameras = (cameraIds: string[]) => {
|
||||||
|
const updatedCams = (cameraConfig?.cams || []).filter(
|
||||||
|
(cam) => !cameraIds.includes(cam.id || ''),
|
||||||
|
);
|
||||||
|
handleSubmitCameraConfig(updatedCams);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle recording config submission from ConfigCameraV6
|
||||||
|
const handleSubmitConfig = (configPayload: MasterModel.CameraV6) => {
|
||||||
|
if (sendCameraConfig(configPayload)) {
|
||||||
|
// Update local state
|
||||||
|
setCameraConfig(configPayload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Helper function to determine which camera component to render
|
// Helper function to determine which camera component to render
|
||||||
const renderCameraRecordingComponent = () => {
|
const renderCameraRecordingComponent = () => {
|
||||||
const thingType = thing?.metadata?.type;
|
if (thing?.metadata?.type === 'gmsv5') {
|
||||||
|
|
||||||
if (thingType === 'gmsv5') {
|
|
||||||
return <ConfigCameraV5 thing={thing} />;
|
return <ConfigCameraV5 thing={thing} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thingType === 'spole' || thingType === 'gmsv6') {
|
// Default: gmsv6, spole, and other types
|
||||||
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
|
return (
|
||||||
}
|
<ConfigCameraV6
|
||||||
|
thing={thing}
|
||||||
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
|
cameraConfig={cameraConfig}
|
||||||
|
onSubmit={handleSubmitConfig}
|
||||||
|
isOnline={isOnline}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,7 +287,27 @@ const CameraConfigPage = () => {
|
|||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
onClick={() => history.push('/manager/devices')}
|
onClick={() => history.push('/manager/devices')}
|
||||||
/>
|
/>
|
||||||
<span>{thing?.name || 'Loading...'}</span>
|
<span>
|
||||||
|
{thing?.name ||
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.camera.loading',
|
||||||
|
defaultMessage: 'Loading...',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
status={isOnline ? 'success' : 'default'}
|
||||||
|
text={
|
||||||
|
isOnline
|
||||||
|
? intl.formatMessage({
|
||||||
|
id: 'master.devices.online',
|
||||||
|
defaultMessage: 'Trực tuyến',
|
||||||
|
})
|
||||||
|
: intl.formatMessage({
|
||||||
|
id: 'master.devices.offline',
|
||||||
|
defaultMessage: 'Ngoại tuyến',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@@ -144,8 +318,11 @@ const CameraConfigPage = () => {
|
|||||||
<CameraTable
|
<CameraTable
|
||||||
cameraData={cameras}
|
cameraData={cameras}
|
||||||
onCreateCamera={handleOpenModal}
|
onCreateCamera={handleOpenModal}
|
||||||
|
onEditCamera={handleOpenEditModal}
|
||||||
|
onDeleteCameras={handleDeleteCameras}
|
||||||
onReload={fetchCameraConfig}
|
onReload={fetchCameraConfig}
|
||||||
loading={cameraLoading}
|
loading={cameraLoading}
|
||||||
|
isOnline={isOnline}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
@@ -155,11 +332,13 @@ const CameraConfigPage = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Create Camera Modal */}
|
{/* Create/Edit Camera Modal */}
|
||||||
<CameraFormModal
|
<CameraFormModal
|
||||||
open={isModalVisible}
|
open={isModalVisible}
|
||||||
onCancel={handleCloseModal}
|
onCancel={handleCloseModal}
|
||||||
onSubmit={handleSubmitCamera}
|
onSubmit={handleSubmitCamera}
|
||||||
|
isOnline={isOnline}
|
||||||
|
editingCamera={editingCamera}
|
||||||
/>
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import IconFont from '@/components/IconFont';
|
import IconFont from '@/components/IconFont';
|
||||||
import { StatisticCard } from '@ant-design/pro-components';
|
import { StatisticCard } from '@ant-design/pro-components';
|
||||||
import {
|
import { Flex, GlobalToken, Grid, theme, Tooltip, Typography } from 'antd';
|
||||||
Divider,
|
|
||||||
Flex,
|
|
||||||
GlobalToken,
|
|
||||||
Grid,
|
|
||||||
theme,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from 'antd';
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
type BinarySensorsProps = {
|
type BinarySensorsProps = {
|
||||||
nodeConfigs: MasterModel.NodeConfig[];
|
nodeConfigs: MasterModel.NodeConfig[];
|
||||||
@@ -148,7 +140,6 @@ const BinarySensors = ({ nodeConfigs }: BinarySensorsProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex wrap="wrap" gap="middle">
|
<Flex wrap="wrap" gap="middle">
|
||||||
<Divider orientation="left">Cảm biến</Divider>
|
|
||||||
{binarySensors.map((entity) => (
|
{binarySensors.map((entity) => (
|
||||||
<div
|
<div
|
||||||
key={entity.entityId}
|
key={entity.entityId}
|
||||||
|
|||||||
610
src/pages/Manager/Device/Detail/components/Chart.tsx
Normal file
610
src/pages/Manager/Device/Detail/components/Chart.tsx
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
import '@/utils/chart';
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
type ChartComponentProps = {
|
||||||
|
message: MasterModel.SensorLogMessage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const exampleSensorLogMessages: MasterModel.SensorLogMessage[] = [
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770260026,
|
||||||
|
value: 76.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770259725,
|
||||||
|
value: 77.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770259424,
|
||||||
|
value: 75.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770259242,
|
||||||
|
value: 72.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770259142,
|
||||||
|
value: 69.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770258926,
|
||||||
|
value: 66.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770258877,
|
||||||
|
value: 69.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770258839,
|
||||||
|
value: 73,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770258678,
|
||||||
|
value: 76.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770258377,
|
||||||
|
value: 77.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770258077,
|
||||||
|
value: 77.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770257776,
|
||||||
|
value: 75.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770257596,
|
||||||
|
value: 72.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770257487,
|
||||||
|
value: 69.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770257412,
|
||||||
|
value: 66.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770257349,
|
||||||
|
value: 63.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770257117,
|
||||||
|
value: 60.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770256816,
|
||||||
|
value: 62.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770256625,
|
||||||
|
value: 65.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770256563,
|
||||||
|
value: 68.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770256529,
|
||||||
|
value: 71.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770256363,
|
||||||
|
value: 74.7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770256062,
|
||||||
|
value: 75.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770255761,
|
||||||
|
value: 74.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770255590,
|
||||||
|
value: 71.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770255498,
|
||||||
|
value: 68.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770255436,
|
||||||
|
value: 64.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770255331,
|
||||||
|
value: 61.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770255111,
|
||||||
|
value: 64.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770255038,
|
||||||
|
value: 67.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770255002,
|
||||||
|
value: 71.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770254968,
|
||||||
|
value: 74.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770254688,
|
||||||
|
value: 77.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770254387,
|
||||||
|
value: 78.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770254086,
|
||||||
|
value: 77.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770253786,
|
||||||
|
value: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770253631,
|
||||||
|
value: 72,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770253549,
|
||||||
|
value: 68.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770253481,
|
||||||
|
value: 65.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770253434,
|
||||||
|
value: 62.7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770253364,
|
||||||
|
value: 59.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770253063,
|
||||||
|
value: 60.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770252763,
|
||||||
|
value: 62.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770252695,
|
||||||
|
value: 65.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770252657,
|
||||||
|
value: 68.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770252489,
|
||||||
|
value: 71.7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770252188,
|
||||||
|
value: 71.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770251886,
|
||||||
|
value: 68.7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770251585,
|
||||||
|
value: 65.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
|
||||||
|
subtopic: 'log.gmsv5.hum.0:20',
|
||||||
|
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
|
||||||
|
protocol: 'mqtt',
|
||||||
|
name: 'urn:dev:mac:0038310555e4:0:20',
|
||||||
|
unit: '%',
|
||||||
|
time: 1770251469,
|
||||||
|
value: 68.9,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Custom plugin để vẽ đường kẻ dọc khi hover
|
||||||
|
const verticalLinePlugin = {
|
||||||
|
id: 'verticalLine',
|
||||||
|
afterDraw: (chart: any) => {
|
||||||
|
if (chart.tooltip?._active?.length) {
|
||||||
|
const ctx = chart.ctx;
|
||||||
|
const x = chart.tooltip._active[0].element.x;
|
||||||
|
const topY = chart.scales.y.top;
|
||||||
|
const bottomY = chart.scales.y.bottom;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, topY);
|
||||||
|
ctx.lineTo(x, bottomY);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartComponent = () => {
|
||||||
|
const data: ChartData<'line', number[], string> = {
|
||||||
|
labels: exampleSensorLogMessages.map((msg) =>
|
||||||
|
new Date(msg.time! * 1000).toLocaleTimeString(),
|
||||||
|
),
|
||||||
|
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: exampleSensorLogMessages[0]?.unit || '',
|
||||||
|
data: exampleSensorLogMessages.map((msg) => msg.value),
|
||||||
|
borderColor: 'rgba(75, 192, 192, 0.2)',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||||
|
tension: 0.1,
|
||||||
|
fill: 'start',
|
||||||
|
pointRadius: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: ChartOptions<'line'> = {
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: true,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: false,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
return `${context.parsed.y} ${
|
||||||
|
exampleSensorLogMessages[0]?.unit || ''
|
||||||
|
}`;
|
||||||
|
},
|
||||||
|
title: (context) => {
|
||||||
|
const index = context[0]?.dataIndex;
|
||||||
|
if (index !== undefined) {
|
||||||
|
const msg = exampleSensorLogMessages[index];
|
||||||
|
return new Date(msg.time! * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
title="Nooo"
|
||||||
|
data={data}
|
||||||
|
options={options}
|
||||||
|
plugins={[verticalLinePlugin]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChartComponent;
|
||||||
@@ -3,11 +3,17 @@ import TooltipIconFontButton from '@/components/shared/TooltipIconFontButton';
|
|||||||
import { ROUTER_HOME } from '@/constants/routes';
|
import { ROUTER_HOME } from '@/constants/routes';
|
||||||
import { apiQueryNodeConfigMessage } from '@/services/master/MessageController';
|
import { apiQueryNodeConfigMessage } from '@/services/master/MessageController';
|
||||||
import { apiGetThingDetail } from '@/services/master/ThingController';
|
import { apiGetThingDetail } from '@/services/master/ThingController';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
EllipsisOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
import { PageContainer, ProCard } from '@ant-design/pro-components';
|
import { PageContainer, ProCard } from '@ant-design/pro-components';
|
||||||
import { history, useIntl, useModel, useParams } from '@umijs/max';
|
import { history, useIntl, useModel, useParams } from '@umijs/max';
|
||||||
import { Divider, Flex, Grid } from 'antd';
|
import { Divider, Flex, Grid } from 'antd';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import BinarySensors from './components/BinarySensors';
|
import BinarySensors from './components/BinarySensors';
|
||||||
|
import ChartComponent from './components/Chart';
|
||||||
import ThingTitle from './components/ThingTitle';
|
import ThingTitle from './components/ThingTitle';
|
||||||
|
|
||||||
const DetailDevicePage = () => {
|
const DetailDevicePage = () => {
|
||||||
@@ -109,8 +115,21 @@ const DetailDevicePage = () => {
|
|||||||
</ProCard>
|
</ProCard>
|
||||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 24, lg: 18, xl: 18 }}>
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 24, lg: 18, xl: 18 }}>
|
||||||
<Flex wrap gap="small">
|
<Flex wrap gap="small">
|
||||||
|
<Divider orientation="left">Cảm biến</Divider>
|
||||||
<BinarySensors nodeConfigs={nodeConfigs} />
|
<BinarySensors nodeConfigs={nodeConfigs} />
|
||||||
<Divider orientation="left">Trạng thái</Divider>
|
<Divider orientation="left">Trạng thái</Divider>
|
||||||
|
<ProCard
|
||||||
|
title="Mẫu 1"
|
||||||
|
bordered
|
||||||
|
style={{ maxWidth: 600 }}
|
||||||
|
actions={[
|
||||||
|
<SettingOutlined key="setting" />,
|
||||||
|
<EditOutlined key="edit" />,
|
||||||
|
<EllipsisOutlined key="ellipsis" />,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ChartComponent />
|
||||||
|
</ProCard>
|
||||||
</Flex>
|
</Flex>
|
||||||
</ProCard>
|
</ProCard>
|
||||||
</ProCard>
|
</ProCard>
|
||||||
|
|||||||
142
src/pages/Manager/Device/Terminal/components/XTerm.tsx
Normal file
142
src/pages/Manager/Device/Terminal/components/XTerm.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||||
|
import { Terminal } from 'xterm';
|
||||||
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
|
import 'xterm/css/xterm.css';
|
||||||
|
|
||||||
|
export interface XTermHandle {
|
||||||
|
write: (text: string) => void;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XTermProps {
|
||||||
|
onData?: (data: string) => void;
|
||||||
|
cursorBlink?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
disabled?: boolean;
|
||||||
|
welcomeMessage?: string;
|
||||||
|
theme?: {
|
||||||
|
background?: string;
|
||||||
|
foreground?: string;
|
||||||
|
cursor?: string;
|
||||||
|
selectionBackground?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_WELCOME = 'Welcome to Smatec IoT Agent';
|
||||||
|
|
||||||
|
const XTerm = forwardRef<XTermHandle, XTermProps>(function XTerm(
|
||||||
|
{
|
||||||
|
onData,
|
||||||
|
cursorBlink = false,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
disabled = false,
|
||||||
|
welcomeMessage = DEFAULT_WELCOME,
|
||||||
|
theme,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const terminalRef = useRef<Terminal | null>(null);
|
||||||
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
|
const onDataRef = useRef(onData);
|
||||||
|
const disabledRef = useRef(disabled);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
write: (text: string) => {
|
||||||
|
if (terminalRef.current && text) {
|
||||||
|
terminalRef.current.write(text);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
terminalRef.current?.clear();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDataRef.current = onData;
|
||||||
|
}, [onData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
disabledRef.current = disabled;
|
||||||
|
// Update terminal's disableStdin option when disabled prop changes
|
||||||
|
if (terminalRef.current) {
|
||||||
|
terminalRef.current.options.disableStdin = disabled;
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const terminal = new Terminal({
|
||||||
|
cursorBlink,
|
||||||
|
scrollback: 1000,
|
||||||
|
convertEol: true,
|
||||||
|
fontSize: 14,
|
||||||
|
disableStdin: disabled,
|
||||||
|
allowProposedApi: true,
|
||||||
|
theme,
|
||||||
|
});
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
|
||||||
|
if (containerRef.current) {
|
||||||
|
terminal.open(containerRef.current);
|
||||||
|
fitAddon.fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (welcomeMessage) {
|
||||||
|
terminal.writeln(welcomeMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDisposable = terminal.onData((data) => {
|
||||||
|
if (!disabledRef.current) {
|
||||||
|
onDataRef.current?.(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
terminalRef.current = terminal;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
|
const observer =
|
||||||
|
typeof ResizeObserver !== 'undefined' && containerRef.current
|
||||||
|
? new ResizeObserver(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (observer && containerRef.current) {
|
||||||
|
observer.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dataDisposable.dispose();
|
||||||
|
observer?.disconnect();
|
||||||
|
terminal.dispose();
|
||||||
|
fitAddon.dispose();
|
||||||
|
terminalRef.current = null;
|
||||||
|
fitAddonRef.current = null;
|
||||||
|
};
|
||||||
|
// We intentionally want to run this once on mount.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (terminalRef.current && theme) {
|
||||||
|
terminalRef.current.options.theme = theme;
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{ height: '100%', ...style }}
|
||||||
|
ref={containerRef}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default XTerm;
|
||||||
663
src/pages/Manager/Device/Terminal/index.tsx
Normal file
663
src/pages/Manager/Device/Terminal/index.tsx
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
// Component Terminal - Giao diện điều khiển thiết bị từ xa qua MQTT
|
||||||
|
// Không hỗ trợ thiết bị loại GMSv5
|
||||||
|
|
||||||
|
import { getBadgeConnection } from '@/components/shared/ThingShared';
|
||||||
|
import { ROUTE_MANAGER_DEVICES } from '@/constants/routes';
|
||||||
|
import { apiGetThingDetail } from '@/services/master/ThingController';
|
||||||
|
import { mqttClient } from '@/utils/mqttClient';
|
||||||
|
import { getTerminalTheme, setTerminalTheme } from '@/utils/storage';
|
||||||
|
import { BgColorsOutlined, ClearOutlined } from '@ant-design/icons';
|
||||||
|
import { PageContainer, ProCard } from '@ant-design/pro-components';
|
||||||
|
import { history, useIntl, useModel, useParams } from '@umijs/max';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
MenuProps,
|
||||||
|
Result,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Typography,
|
||||||
|
theme,
|
||||||
|
} from 'antd';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import XTerm, { XTermHandle } from './components/XTerm';
|
||||||
|
|
||||||
|
// Format SenML record nhận từ MQTT
|
||||||
|
|
||||||
|
const TERMINAL_THEMES: MasterModel.TerminalThemes = {
|
||||||
|
dark: {
|
||||||
|
name: 'Dark',
|
||||||
|
theme: {
|
||||||
|
background: '#141414',
|
||||||
|
foreground: '#ffffff',
|
||||||
|
cursor: '#ffffff',
|
||||||
|
selectionBackground: 'rgba(255, 255, 255, 0.3)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
name: 'Light',
|
||||||
|
theme: {
|
||||||
|
background: '#ffffff',
|
||||||
|
foreground: '#000000',
|
||||||
|
cursor: '#000000',
|
||||||
|
selectionBackground: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
name: 'Green (Classic)',
|
||||||
|
theme: {
|
||||||
|
background: '#000000',
|
||||||
|
foreground: '#00ff00',
|
||||||
|
cursor: '#00ff00',
|
||||||
|
selectionBackground: 'rgba(0, 255, 0, 0.3)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
name: 'Amber',
|
||||||
|
theme: {
|
||||||
|
background: '#1a0f00',
|
||||||
|
foreground: '#ffaa00',
|
||||||
|
cursor: '#ffaa00',
|
||||||
|
selectionBackground: 'rgba(255, 170, 0, 0.3)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
solarized: {
|
||||||
|
name: 'Solarized Dark',
|
||||||
|
theme: {
|
||||||
|
background: '#002b36',
|
||||||
|
foreground: '#839496',
|
||||||
|
cursor: '#93a1a1',
|
||||||
|
selectionBackground: 'rgba(7, 54, 66, 0.99)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode chuỗi sang Base64 để gửi qua MQTT
|
||||||
|
* Hỗ trợ Unicode characters qua UTF-8 normalization
|
||||||
|
* Áp dụng cho tất cả thiết bị (ngoại trừ GMSv5)
|
||||||
|
*/
|
||||||
|
const encodeBase64 = (value: string) => {
|
||||||
|
try {
|
||||||
|
const normalized = encodeURIComponent(value).replace(
|
||||||
|
/%([0-9A-F]{2})/g,
|
||||||
|
(_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)),
|
||||||
|
);
|
||||||
|
if (typeof globalThis !== 'undefined' && globalThis.btoa) {
|
||||||
|
return globalThis.btoa(normalized);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to encode terminal payload', error);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse payload nhận từ MQTT (format SenML)
|
||||||
|
* Trích xuất các field 'vs' và join thành string hoàn chỉnh
|
||||||
|
*/
|
||||||
|
const parseTerminalPayload = (payload: string): string => {
|
||||||
|
try {
|
||||||
|
const records: MasterModel.SenmlRecord[] = JSON.parse(payload);
|
||||||
|
if (!Array.isArray(records)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
.map((record) => record?.vs ?? '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse terminal payload', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeviceTerminalPage = () => {
|
||||||
|
// Lấy thingId từ URL params
|
||||||
|
const { thingId } = useParams<{ thingId: string }>();
|
||||||
|
const { initialState } = useModel('@@initialState');
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
// States quản lý trạng thái thiết bị và terminal
|
||||||
|
const [thing, setThing] = useState<MasterModel.Thing | null>(null); // Thông tin thiết bị
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false); // Đang load dữ liệu
|
||||||
|
const [terminalError, setTerminalError] = useState<string | null>(null); // Lỗi terminal
|
||||||
|
const [isSessionReady, setIsSessionReady] = useState<boolean>(false); // Session đã sẵn sàng
|
||||||
|
const [connectionState, setConnectionState] =
|
||||||
|
useState<MasterModel.TerminalConnection>({
|
||||||
|
online: false,
|
||||||
|
}); // Trạng thái online/offline
|
||||||
|
const [sessionAttempt, setSessionAttempt] = useState<number>(0); // Số lần thử kết nối lại
|
||||||
|
const [selectedThemeKey, setSelectedThemeKey] = useState<string>('dark'); // Theme được chọn
|
||||||
|
|
||||||
|
// Refs lưu trữ các giá trị không trigger re-render
|
||||||
|
const sessionIdRef = useRef(uuidv4()); // ID phiên terminal duy nhất
|
||||||
|
const terminalRef = useRef<XTermHandle | null>(null); // Reference đến XTerm component
|
||||||
|
const responseTopicRef = useRef<string | null>(null); // Topic MQTT nhận phản hồi
|
||||||
|
const requestTopicRef = useRef<string | null>(null); // Topic MQTT gửi yêu cầu
|
||||||
|
const handshakeCompletedRef = useRef<boolean>(false); // Đã hoàn thành handshake chưa
|
||||||
|
const mqttCleanupRef = useRef<Array<() => void>>([]); // Mảng cleanup functions
|
||||||
|
|
||||||
|
const credentialMetadata = initialState?.currentUserProfile?.metadata;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy MQTT credentials từ metadata user
|
||||||
|
* Username: frontend_thing_id
|
||||||
|
* Password: frontend_thing_key
|
||||||
|
*/
|
||||||
|
const mqttCredentials = useMemo(() => {
|
||||||
|
const username = credentialMetadata?.frontend_thing_id;
|
||||||
|
const password = credentialMetadata?.frontend_thing_key;
|
||||||
|
if (username && password) {
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [credentialMetadata]);
|
||||||
|
|
||||||
|
// Kiểm tra thiết bị có hỗ trợ terminal không (không hỗ trợ GMSv5)
|
||||||
|
const supportsTerminal = thing?.metadata?.type !== 'gmsv5';
|
||||||
|
// Channel ID để gửi lệnh điều khiển
|
||||||
|
const ctrlChannelId = thing?.metadata?.ctrl_channel_id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dọn dẹp MQTT connection khi unmount hoặc reconnect
|
||||||
|
- Hủy subscribe topic
|
||||||
|
- Reset các ref
|
||||||
|
*/
|
||||||
|
const tearDownMqtt = useCallback(() => {
|
||||||
|
mqttCleanupRef.current.forEach((dispose) => {
|
||||||
|
try {
|
||||||
|
dispose();
|
||||||
|
} catch {
|
||||||
|
// ignore individual cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mqttCleanupRef.current = [];
|
||||||
|
if (responseTopicRef.current) {
|
||||||
|
mqttClient.unsubscribe(responseTopicRef.current);
|
||||||
|
responseTopicRef.current = null;
|
||||||
|
}
|
||||||
|
requestTopicRef.current = null;
|
||||||
|
handshakeCompletedRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch thông tin chi tiết thiết bị từ API
|
||||||
|
- Cập nhật state thing
|
||||||
|
- Cập nhật trạng thái online/offline
|
||||||
|
- Xóa lỗi nếu fetch thành công
|
||||||
|
*/
|
||||||
|
const fetchThingDetail = useCallback(async () => {
|
||||||
|
if (!thingId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await apiGetThingDetail(thingId);
|
||||||
|
setThing(resp);
|
||||||
|
const isConnected = Boolean(resp?.metadata?.connected);
|
||||||
|
setConnectionState({
|
||||||
|
online: isConnected,
|
||||||
|
lastSeen: isConnected ? undefined : Date.now(),
|
||||||
|
});
|
||||||
|
setTerminalError(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load device details', error);
|
||||||
|
setTerminalError(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.loadDeviceError',
|
||||||
|
defaultMessage: 'Không thể tải thông tin thiết bị.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [intl, thingId]);
|
||||||
|
|
||||||
|
// Fetch thông tin thiết bị khi component mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchThingDetail();
|
||||||
|
}, [fetchThingDetail]);
|
||||||
|
|
||||||
|
// Tạo sessionId mới khi thingId thay đổi
|
||||||
|
useEffect(() => {
|
||||||
|
sessionIdRef.current = uuidv4();
|
||||||
|
}, [thingId]);
|
||||||
|
|
||||||
|
// Khôi phục theme từ localStorage khi component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = getTerminalTheme();
|
||||||
|
if (savedTheme && TERMINAL_THEMES[savedTheme]) {
|
||||||
|
setSelectedThemeKey(savedTheme);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gửi lệnh tới thiết bị qua MQTT
|
||||||
|
* Format SenML: bn(base name), n(name), bt(base time), vs(value string)
|
||||||
|
*/
|
||||||
|
const publishTerminalCommand = useCallback((command: string) => {
|
||||||
|
const topic = requestTopicRef.current;
|
||||||
|
if (!topic || !command) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload: MasterModel.MqttTerminalPayload = [
|
||||||
|
{
|
||||||
|
bn: `${sessionIdRef.current}:`,
|
||||||
|
n: 'term',
|
||||||
|
bt: Math.floor(Date.now() / 1000),
|
||||||
|
vs: encodeBase64(command),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mqttClient.publish(topic, JSON.stringify(payload));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup MQTT connection và handlers
|
||||||
|
- Tạo topics để giao tiếp với thiết bị
|
||||||
|
- Đăng ký handlers cho các sự kiện MQTT
|
||||||
|
- Thực hiện handshake khi kết nối thành công
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supportsTerminal || !mqttCredentials || !ctrlChannelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tearDownMqtt();
|
||||||
|
// Tạo topics: response topic duy nhất cho mỗi session, request topic chung
|
||||||
|
const responseTopic = `channels/${ctrlChannelId}/messages/res/term/${sessionIdRef.current}`;
|
||||||
|
const requestTopic = `channels/${ctrlChannelId}/messages/req`;
|
||||||
|
responseTopicRef.current = responseTopic;
|
||||||
|
requestTopicRef.current = requestTopic;
|
||||||
|
handshakeCompletedRef.current = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler khi MQTT kết nối thành công
|
||||||
|
- Subscribe response topic
|
||||||
|
- Gửi lệnh handshake: 'open' và 'cd /'
|
||||||
|
*/
|
||||||
|
const handleConnect = () => {
|
||||||
|
setTerminalError(null);
|
||||||
|
if (!handshakeCompletedRef.current) {
|
||||||
|
mqttClient.subscribe(responseTopic);
|
||||||
|
publishTerminalCommand('open');
|
||||||
|
publishTerminalCommand('cd /');
|
||||||
|
handshakeCompletedRef.current = true;
|
||||||
|
}
|
||||||
|
setIsSessionReady(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler nhận message từ thiết bị
|
||||||
|
- Parse payload SenML
|
||||||
|
- Ghi dữ liệu ra terminal
|
||||||
|
*/
|
||||||
|
const handleMessage = (topic: string, message: Buffer) => {
|
||||||
|
if (topic !== responseTopic) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = parseTerminalPayload(message.toString());
|
||||||
|
if (text) {
|
||||||
|
terminalRef.current?.write(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler khi MQTT bị ngắt kết nối
|
||||||
|
*/
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsSessionReady(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler khi có lỗi MQTT
|
||||||
|
- Dọn dẹp connection
|
||||||
|
- Hiển thị thông báo lỗi
|
||||||
|
*/
|
||||||
|
const handleError = (error: Error) => {
|
||||||
|
console.error('MQTT terminal error', error);
|
||||||
|
setIsSessionReady(false);
|
||||||
|
tearDownMqtt();
|
||||||
|
mqttClient.disconnect();
|
||||||
|
setTerminalError(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.mqttError',
|
||||||
|
defaultMessage: 'Không thể kết nối MQTT.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kết nối MQTT và đăng ký handlers
|
||||||
|
mqttClient.connect(mqttCredentials);
|
||||||
|
const offConnect = mqttClient.onConnect(handleConnect);
|
||||||
|
const offMessage = mqttClient.onMessage(handleMessage);
|
||||||
|
const offClose = mqttClient.onClose(handleClose);
|
||||||
|
const offError = mqttClient.onError(handleError);
|
||||||
|
mqttCleanupRef.current = [offConnect, offMessage, offClose, offError];
|
||||||
|
|
||||||
|
// Nếu đã kết nối thì xử lý luôn
|
||||||
|
if (mqttClient.isConnected()) {
|
||||||
|
handleConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup khi unmount hoặc dependencies thay đổi
|
||||||
|
return () => {
|
||||||
|
tearDownMqtt();
|
||||||
|
mqttClient.disconnect();
|
||||||
|
setIsSessionReady(false);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
ctrlChannelId,
|
||||||
|
intl,
|
||||||
|
mqttCredentials,
|
||||||
|
publishTerminalCommand,
|
||||||
|
sessionAttempt,
|
||||||
|
supportsTerminal,
|
||||||
|
tearDownMqtt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Xử lý khi người dùng nhập liệu vào terminal
|
||||||
|
- Gửi từng ký tự với prefix 'c,'
|
||||||
|
- Chỉ gửi khi session đã sẵn sàng
|
||||||
|
*/
|
||||||
|
const handleTyping = useCallback(
|
||||||
|
(data: string) => {
|
||||||
|
if (!isSessionReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publishTerminalCommand(`c,${data}`);
|
||||||
|
},
|
||||||
|
[isSessionReady, publishTerminalCommand],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Xử lý retry khi có lỗi
|
||||||
|
- Tạo sessionId mới
|
||||||
|
- Tăng sessionAttempt để trigger reconnect
|
||||||
|
- Fetch lại thông tin thiết bị
|
||||||
|
*/
|
||||||
|
const handleRetry = () => {
|
||||||
|
setTerminalError(null);
|
||||||
|
setSessionAttempt((prev) => prev + 1);
|
||||||
|
sessionIdRef.current = uuidv4();
|
||||||
|
fetchThingDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Label hiển thị trạng thái kết nối
|
||||||
|
const connectionLabel = connectionState.online
|
||||||
|
? intl.formatMessage({ id: 'common.online', defaultMessage: 'Online' })
|
||||||
|
: intl.formatMessage({ id: 'common.offline', defaultMessage: 'Offline' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render kết quả blocking (lỗi, warning, info)
|
||||||
|
- Hiển thị nút quay lại và thử lại
|
||||||
|
*/
|
||||||
|
const renderBlockingResult = (
|
||||||
|
status: 'info' | 'warning' | 'error' | '404',
|
||||||
|
title: string,
|
||||||
|
subTitle?: string,
|
||||||
|
) => (
|
||||||
|
<Result
|
||||||
|
status={status}
|
||||||
|
title={title}
|
||||||
|
subTitle={subTitle}
|
||||||
|
extra={[
|
||||||
|
<Button key="back" onClick={() => history.push(ROUTE_MANAGER_DEVICES)}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'common.back',
|
||||||
|
defaultMessage: 'Quay lại',
|
||||||
|
})}
|
||||||
|
</Button>,
|
||||||
|
<Button key="retry" type="primary" onClick={handleRetry}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'common.retry',
|
||||||
|
defaultMessage: 'Thử lại',
|
||||||
|
})}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tiêu đề trang
|
||||||
|
const pageTitle =
|
||||||
|
thing?.name ||
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.pageTitle',
|
||||||
|
defaultMessage: 'Terminal',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Điều kiện hiển thị terminal
|
||||||
|
- Tất cả điều kiện phải true
|
||||||
|
*/
|
||||||
|
const showTerminal =
|
||||||
|
!isLoading &&
|
||||||
|
!terminalError &&
|
||||||
|
thing &&
|
||||||
|
supportsTerminal &&
|
||||||
|
ctrlChannelId &&
|
||||||
|
mqttCredentials;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme của terminal theo dark/light mode
|
||||||
|
*/
|
||||||
|
const terminalTheme = useMemo(() => {
|
||||||
|
return (
|
||||||
|
TERMINAL_THEMES[selectedThemeKey]?.theme || TERMINAL_THEMES.dark.theme
|
||||||
|
);
|
||||||
|
}, [selectedThemeKey]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Xử lý thay đổi theme
|
||||||
|
* - Cập nhật state
|
||||||
|
* - Lưu vào localStorage thông qua storage utility
|
||||||
|
*/
|
||||||
|
const handleThemeChange: MenuProps['onClick'] = (e) => {
|
||||||
|
const themeKey = e.key;
|
||||||
|
setSelectedThemeKey(themeKey);
|
||||||
|
setTerminalTheme(themeKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu items cho theme selector
|
||||||
|
*/
|
||||||
|
const themeMenuItems: MenuProps['items'] = Object.entries(
|
||||||
|
TERMINAL_THEMES,
|
||||||
|
).map(([key, preset]) => ({
|
||||||
|
key,
|
||||||
|
label: preset.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ===== RENDER =====
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
header={{
|
||||||
|
title: pageTitle,
|
||||||
|
onBack: () => history.push(ROUTE_MANAGER_DEVICES),
|
||||||
|
tags: (
|
||||||
|
<Space size={8}>
|
||||||
|
{getBadgeConnection(connectionState.online)}
|
||||||
|
<Typography.Text>{connectionLabel}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: 320,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Generic error state */}
|
||||||
|
{!isLoading && terminalError && (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.genericError',
|
||||||
|
defaultMessage: 'Đã có lỗi xảy ra',
|
||||||
|
})}
|
||||||
|
subTitle={terminalError}
|
||||||
|
extra={[
|
||||||
|
<Button key="retry" type="primary" onClick={handleRetry}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'common.retry',
|
||||||
|
defaultMessage: 'Thử lại',
|
||||||
|
})}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thiết bị không hỗ trợ terminal (không phải GMSv6) */}
|
||||||
|
{!isLoading &&
|
||||||
|
!terminalError &&
|
||||||
|
thing &&
|
||||||
|
!supportsTerminal &&
|
||||||
|
renderBlockingResult(
|
||||||
|
'info',
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.unsupported.title',
|
||||||
|
defaultMessage: 'Thiết bị không hỗ trợ terminal',
|
||||||
|
}),
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.unsupported.desc',
|
||||||
|
defaultMessage:
|
||||||
|
'Thiết bị GMSv5 không được hỗ trợ. Vui lòng sử dụng thiết bị khác.',
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thiếu ctrl_channel_id */}
|
||||||
|
{!isLoading &&
|
||||||
|
!terminalError &&
|
||||||
|
thing &&
|
||||||
|
supportsTerminal &&
|
||||||
|
!ctrlChannelId &&
|
||||||
|
renderBlockingResult(
|
||||||
|
'warning',
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.missingChannel.title',
|
||||||
|
defaultMessage: 'Thiếu thông tin kênh điều khiển',
|
||||||
|
}),
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.missingChannel.desc',
|
||||||
|
defaultMessage:
|
||||||
|
'Thiết bị chưa được cấu hình ctrl_channel_id nên không thể mở terminal.',
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thiếu MQTT credentials */}
|
||||||
|
{!isLoading &&
|
||||||
|
!terminalError &&
|
||||||
|
thing &&
|
||||||
|
supportsTerminal &&
|
||||||
|
ctrlChannelId &&
|
||||||
|
!mqttCredentials &&
|
||||||
|
renderBlockingResult(
|
||||||
|
'warning',
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.missingCredential.title',
|
||||||
|
defaultMessage: 'Thiếu thông tin xác thực',
|
||||||
|
}),
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.missingCredential.desc',
|
||||||
|
defaultMessage:
|
||||||
|
'Tài khoản hiện tại chưa được cấp frontend_thing_id/frontend_thing_key.',
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Terminal UI - chỉ hiển thị khi tất cả điều kiện thỏa mãn */}
|
||||||
|
{showTerminal && (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
{/* Cảnh báo khi thiết bị offline */}
|
||||||
|
{!connectionState.online && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message={intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.offline',
|
||||||
|
defaultMessage:
|
||||||
|
'Thiết bị đang ngoại tuyến. Terminal chuyển sang chế độ chỉ xem.',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Đang kết nối MQTT */}
|
||||||
|
{connectionState.online && !isSessionReady && (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
message={intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.connecting',
|
||||||
|
defaultMessage:
|
||||||
|
'Đang chuẩn bị phiên master.devices.terminal...',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* XTerm component */}
|
||||||
|
<ProCard
|
||||||
|
bordered
|
||||||
|
bodyStyle={{ padding: 0, height: 560 }}
|
||||||
|
style={{
|
||||||
|
minHeight: 560,
|
||||||
|
border: `2px solid ${token.colorBorder}`,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XTerm
|
||||||
|
ref={terminalRef}
|
||||||
|
cursorBlink
|
||||||
|
disabled={!connectionState.online || !isSessionReady}
|
||||||
|
onData={handleTyping}
|
||||||
|
theme={terminalTheme}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
{/* Nút xóa màn hình */}
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<ClearOutlined />}
|
||||||
|
onClick={() => terminalRef.current?.clear()}
|
||||||
|
disabled={!isSessionReady}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.action.clear',
|
||||||
|
defaultMessage: 'Xóa màn hình',
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: themeMenuItems,
|
||||||
|
onClick: handleThemeChange,
|
||||||
|
selectedKeys: [selectedThemeKey],
|
||||||
|
}}
|
||||||
|
placement="topLeft"
|
||||||
|
>
|
||||||
|
<Button icon={<BgColorsOutlined />}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.devices.terminal.action.theme',
|
||||||
|
defaultMessage: 'Theme',
|
||||||
|
})}
|
||||||
|
: {TERMINAL_THEMES[selectedThemeKey]?.name || 'Dark'}
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeviceTerminalPage;
|
||||||
@@ -13,7 +13,7 @@ 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, Divider, Grid, Space, Tag, theme } from 'antd';
|
import { Button, Divider, Grid, Space, Tag, Tooltip, 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';
|
||||||
@@ -165,7 +165,6 @@ const ManagerDevicePage = () => {
|
|||||||
'-'
|
'-'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: 'type',
|
key: 'type',
|
||||||
hideInSearch: true,
|
hideInSearch: true,
|
||||||
@@ -232,11 +231,21 @@ const ManagerDevicePage = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{device?.metadata?.type === 'gmsv6' && (
|
{device?.metadata?.type === 'gmsv6' && (
|
||||||
|
<Tooltip
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'master.devices.openTerminal',
|
||||||
|
defaultMessage: 'Open terminal',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconFont type="icon-terminal" />}
|
icon={<IconFont type="icon-terminal" />}
|
||||||
|
onClick={() =>
|
||||||
|
history.push(`/manager/devices/${device.id}/terminal`)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
|
|||||||
136
src/pages/Manager/User/components/Disable2FA.tsx
Normal file
136
src/pages/Manager/User/components/Disable2FA.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import IconFont from '@/components/IconFont';
|
||||||
|
import { apiAdminDisable2FA } from '@/services/master/AuthController';
|
||||||
|
import { ExclamationCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Button, Modal, Tooltip } from 'antd';
|
||||||
|
import { MessageInstance } from 'antd/es/message/interface';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Disable2FAProps {
|
||||||
|
user: MasterModel.UserResponse;
|
||||||
|
message: MessageInstance;
|
||||||
|
onSuccess?: (isSuccess: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Disable2FA = ({ user, message, onSuccess }: Disable2FAProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Only show if user has 2FA enabled
|
||||||
|
if (!user.enable_2fa) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmDisable = async () => {
|
||||||
|
if (!user.id) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await apiAdminDisable2FA(user.id);
|
||||||
|
message.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.users.disable2fa.success',
|
||||||
|
defaultMessage: '2FA has been disabled successfully',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
onSuccess?.(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Disable 2FA error:', error);
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.users.disable2fa.error',
|
||||||
|
defaultMessage: 'Failed to disable 2FA',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onSuccess?.(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'master.users.disable2fa.title',
|
||||||
|
defaultMessage: 'Disable 2FA',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
shape="default"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<IconFont type="icon-security" />}
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<span style={{ color: '#ff4d4f' }}>
|
||||||
|
<ExclamationCircleOutlined style={{ marginRight: 8 }} />
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.users.disable2fa.modal.title',
|
||||||
|
defaultMessage: 'Disable Two-Factor Authentication',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={() => setIsModalOpen(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={() => setIsModalOpen(false)}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'common.cancel',
|
||||||
|
defaultMessage: 'Cancel',
|
||||||
|
})}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="confirm"
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={handleConfirmDisable}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'common.confirm',
|
||||||
|
defaultMessage: 'Confirm',
|
||||||
|
})}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<p>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.users.disable2fa.modal.warning',
|
||||||
|
defaultMessage:
|
||||||
|
'Are you sure you want to disable 2FA for this user?',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontWeight: 'bold' }}>{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#fff2f0',
|
||||||
|
border: '1px solid #ffccc7',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ color: '#ff4d4f', margin: 0 }}>
|
||||||
|
<ExclamationCircleOutlined style={{ marginRight: 8 }} />
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.users.disable2fa.modal.caution',
|
||||||
|
defaultMessage:
|
||||||
|
'Warning: Disabling 2FA will reduce account security. The user will need to re-enable 2FA from their profile settings.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Disable2FA;
|
||||||
@@ -24,6 +24,7 @@ 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 Disable2FA from './components/Disable2FA';
|
||||||
import ResetPassword from './components/ResetPassword';
|
import ResetPassword from './components/ResetPassword';
|
||||||
type ResetUserPaswordProps = {
|
type ResetUserPaswordProps = {
|
||||||
user: MasterModel.UserResponse | null;
|
user: MasterModel.UserResponse | null;
|
||||||
@@ -158,6 +159,13 @@ const ManagerUserPage = () => {
|
|||||||
})}
|
})}
|
||||||
onClick={() => handleClickResetPassword(user)}
|
onClick={() => handleClickResetPassword(user)}
|
||||||
/>
|
/>
|
||||||
|
<Disable2FA
|
||||||
|
user={user}
|
||||||
|
message={messageApi}
|
||||||
|
onSuccess={(isSuccess) => {
|
||||||
|
if (isSuccess) actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,257 @@
|
|||||||
|
import {
|
||||||
|
apiDisable2FA,
|
||||||
|
apiEnable2FA,
|
||||||
|
apiVerify2FA,
|
||||||
|
} from '@/services/master/AuthController';
|
||||||
|
import { useIntl, useModel } from '@umijs/max';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
QRCode,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
const TwoFactorAuthentication = () => {
|
const TwoFactorAuthentication = () => {
|
||||||
return <div>TwoFactorAuthentication</div>;
|
const intl = useIntl();
|
||||||
|
const { initialState, refresh } = useModel('@@initialState');
|
||||||
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
|
|
||||||
|
const profile = initialState?.currentUserProfile;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [qrData, setQrData] = useState<MasterModel.Enable2FAResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [otpCode, setOtpCode] = useState('');
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
|
||||||
|
const handleEnable2FA = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiEnable2FA();
|
||||||
|
if (response?.data) {
|
||||||
|
setQrData(response.data);
|
||||||
|
setModalVisible(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.enable.error',
|
||||||
|
defaultMessage: 'Không thể bật 2FA. Vui lòng thử lại.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyOTP = async () => {
|
||||||
|
if (!otpCode || otpCode.length !== 6) {
|
||||||
|
message.warning(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.otp.invalid',
|
||||||
|
defaultMessage: 'Vui lòng nhập mã OTP 6 số',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerifying(true);
|
||||||
|
try {
|
||||||
|
const response = await apiVerify2FA({ otp: otpCode });
|
||||||
|
if (response?.status === 200 || response?.status === 201) {
|
||||||
|
message.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.enable.success',
|
||||||
|
defaultMessage: 'Bật 2FA thành công!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setModalVisible(false);
|
||||||
|
setOtpCode('');
|
||||||
|
setQrData(null);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.verify.error',
|
||||||
|
defaultMessage: 'Mã OTP không đúng. Vui lòng thử lại.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setVerifying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisable2FA = async () => {
|
||||||
|
modal.confirm({
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.disable.confirm.title',
|
||||||
|
defaultMessage: 'Xác nhận tắt 2FA',
|
||||||
|
}),
|
||||||
|
content: intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.disable.confirm.content',
|
||||||
|
defaultMessage:
|
||||||
|
'Bạn có chắc chắn muốn tắt xác thực 2 bước? Điều này sẽ giảm bảo mật cho tài khoản của bạn.',
|
||||||
|
}),
|
||||||
|
okText: intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.disable.confirm.ok',
|
||||||
|
defaultMessage: 'Tắt 2FA',
|
||||||
|
}),
|
||||||
|
cancelText: intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.disable.confirm.cancel',
|
||||||
|
defaultMessage: 'Hủy',
|
||||||
|
}),
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiDisable2FA();
|
||||||
|
if (response?.status === 200 || response?.status === 204) {
|
||||||
|
message.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.disable.success',
|
||||||
|
defaultMessage: 'Đã tắt 2FA thành công!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.disable.error',
|
||||||
|
defaultMessage: 'Không thể tắt 2FA. Vui lòng thử lại.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle2FA = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
handleEnable2FA();
|
||||||
|
} else {
|
||||||
|
handleDisable2FA();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModalCancel = () => {
|
||||||
|
setModalVisible(false);
|
||||||
|
setOtpCode('');
|
||||||
|
setQrData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{contextHolder}
|
||||||
|
<Card>
|
||||||
|
<Title level={4}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.profile.two-factor-authentication',
|
||||||
|
})}
|
||||||
|
</Title>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text strong>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.status',
|
||||||
|
defaultMessage: 'Trạng thái 2FA',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Bật xác thực 2 bước để tăng cường bảo mật cho tài khoản của bạn',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={profile?.enable_2fa}
|
||||||
|
onChange={handleToggle2FA}
|
||||||
|
loading={loading}
|
||||||
|
checkedChildren={intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.enabled',
|
||||||
|
defaultMessage: 'Bật',
|
||||||
|
})}
|
||||||
|
unCheckedChildren={intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.disabled',
|
||||||
|
defaultMessage: 'Tắt',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.setup.title',
|
||||||
|
defaultMessage: 'Thiết lập xác thực 2 bước',
|
||||||
|
})}
|
||||||
|
open={modalVisible}
|
||||||
|
onOk={handleVerifyOTP}
|
||||||
|
onCancel={handleModalCancel}
|
||||||
|
confirmLoading={verifying}
|
||||||
|
okText={intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.verify',
|
||||||
|
defaultMessage: 'Xác nhận',
|
||||||
|
})}
|
||||||
|
cancelText={intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.cancel',
|
||||||
|
defaultMessage: 'Hủy',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" align="center" style={{ width: '100%' }}>
|
||||||
|
<Text>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.scan.instruction',
|
||||||
|
defaultMessage:
|
||||||
|
'Quét mã QR bằng ứng dụng xác thực (Google Authenticator, Authy, ...)',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{qrData?.qr_code_base64 ? (
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${qrData.qr_code_base64}`}
|
||||||
|
alt="QR Code"
|
||||||
|
style={{ width: 200, height: 200 }}
|
||||||
|
/>
|
||||||
|
) : qrData?.otp_auth_url ? (
|
||||||
|
<QRCode value={qrData.otp_auth_url} size={200} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Text type="secondary" style={{ marginTop: 16 }}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'master.profile.2fa.otp.instruction',
|
||||||
|
defaultMessage: 'Nhập mã 6 số từ ứng dụng xác thực:',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Input.OTP
|
||||||
|
length={6}
|
||||||
|
value={otpCode}
|
||||||
|
onChange={setOtpCode}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TwoFactorAuthentication;
|
export default TwoFactorAuthentication;
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ const ProfilePage = () => {
|
|||||||
label: intl.formatMessage({
|
label: intl.formatMessage({
|
||||||
id: 'master.profile.two-factor-authentication',
|
id: 'master.profile.two-factor-authentication',
|
||||||
}),
|
}),
|
||||||
disabled: true,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const handleMenuSelect = (e: { key: string }) => {
|
const handleMenuSelect = (e: { key: string }) => {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
API_ADMIN_DISABLE_2FA,
|
||||||
API_CHANGE_PASSWORD,
|
API_CHANGE_PASSWORD,
|
||||||
|
API_DISABLE_2FA,
|
||||||
|
API_ENABLE_2FA,
|
||||||
|
API_ENABLE_2FA_VERIFY,
|
||||||
API_FORGOT_PASSWORD,
|
API_FORGOT_PASSWORD,
|
||||||
API_PATH_GET_PROFILE,
|
API_PATH_GET_PROFILE,
|
||||||
API_PATH_LOGIN,
|
API_PATH_LOGIN,
|
||||||
|
API_PATH_LOGIN_2FA,
|
||||||
API_PATH_REFRESH_TOKEN,
|
API_PATH_REFRESH_TOKEN,
|
||||||
API_USERS,
|
API_USERS,
|
||||||
} from '@/constants/api';
|
} from '@/constants/api';
|
||||||
@@ -59,3 +64,46 @@ export async function apiForgotPassword(
|
|||||||
data: body,
|
data: body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2FA
|
||||||
|
export async function apiLogin2FA(
|
||||||
|
token: string,
|
||||||
|
body: MasterModel.Verify2FARequestBody,
|
||||||
|
) {
|
||||||
|
return request<MasterModel.LoginResponse>(API_PATH_LOGIN_2FA, {
|
||||||
|
method: 'POST',
|
||||||
|
data: body,
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiEnable2FA() {
|
||||||
|
return request<MasterModel.Enable2FAResponse>(API_ENABLE_2FA, {
|
||||||
|
method: 'GET',
|
||||||
|
getResponse: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiVerify2FA(body: MasterModel.Verify2FARequestBody) {
|
||||||
|
return request(API_ENABLE_2FA_VERIFY, {
|
||||||
|
method: 'POST',
|
||||||
|
data: body,
|
||||||
|
getResponse: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDisable2FA() {
|
||||||
|
return request(API_DISABLE_2FA, {
|
||||||
|
method: 'PUT',
|
||||||
|
getResponse: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminDisable2FA(userId: string) {
|
||||||
|
return request(API_ADMIN_DISABLE_2FA + userId, {
|
||||||
|
method: 'PUT',
|
||||||
|
getResponse: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ export async function apiQueryLogs(
|
|||||||
params: MasterModel.SearchLogPaginationBody,
|
params: MasterModel.SearchLogPaginationBody,
|
||||||
type: MasterModel.LogTypeRequest,
|
type: MasterModel.LogTypeRequest,
|
||||||
) {
|
) {
|
||||||
return request<MasterModel.LogResponse>(`${API_READER}/${type}/messages`, {
|
return request<MasterModel.MesageReaderResponse>(
|
||||||
|
`${API_READER}/${type}/messages`,
|
||||||
|
{
|
||||||
params: params,
|
params: params,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,3 +211,19 @@ export async function apiQueryConfigAlarm(
|
|||||||
|
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiQuerySensorLogMessage(
|
||||||
|
dataChanelId: string,
|
||||||
|
authorization: string,
|
||||||
|
params: MasterModel.SearchMessagePaginationBody,
|
||||||
|
) {
|
||||||
|
return request<
|
||||||
|
MasterModel.MesageReaderResponse<MasterModel.SensorLogMessage[]>
|
||||||
|
>(`${API_READER}/${dataChanelId}/messages`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: authorization,
|
||||||
|
},
|
||||||
|
params: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
4
src/services/master/typings/auth.d.ts
vendored
4
src/services/master/typings/auth.d.ts
vendored
@@ -7,8 +7,8 @@ declare namespace MasterModel {
|
|||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
token?: string;
|
token?: string;
|
||||||
refresh_token: string;
|
refresh_token?: string;
|
||||||
enabled_2fa: boolean;
|
enabled2fa?: boolean;
|
||||||
}
|
}
|
||||||
interface RefreshTokenRequestBody {
|
interface RefreshTokenRequestBody {
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
|
|||||||
38
src/services/master/typings/camera.d.ts
vendored
Normal file
38
src/services/master/typings/camera.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
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 CameraV5 {
|
||||||
|
cams?: Camera[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type CameraRecordType = 'none' | 'alarm' | 'all';
|
||||||
|
|
||||||
|
interface CameraV6 extends CameraV5 {
|
||||||
|
record_type?: CameraRecordType;
|
||||||
|
record_alarm_list?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type CameraMessage = Message<CameraV5>;
|
||||||
|
type CameraV6Message = Message<CameraV6>;
|
||||||
|
|
||||||
|
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
|
||||||
|
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
|
||||||
|
|
||||||
|
interface CameraV6ConfigRequest {
|
||||||
|
bn?: string;
|
||||||
|
n?: string;
|
||||||
|
t?: number;
|
||||||
|
vs?: string | CameraV6;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/services/master/typings/log.d.ts
vendored
49
src/services/master/typings/log.d.ts
vendored
@@ -6,6 +6,15 @@ declare namespace MasterModel {
|
|||||||
subtopic?: string;
|
subtopic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MessageBasicInfo {
|
||||||
|
channel?: string;
|
||||||
|
subtopic?: string;
|
||||||
|
publisher?: string;
|
||||||
|
protocol?: string;
|
||||||
|
name?: string;
|
||||||
|
time?: number;
|
||||||
|
}
|
||||||
|
|
||||||
type LogTypeRequest = 'user_logs' | undefined;
|
type LogTypeRequest = 'user_logs' | undefined;
|
||||||
|
|
||||||
interface MesageReaderResponse<T = MessageDataType> {
|
interface MesageReaderResponse<T = MessageDataType> {
|
||||||
@@ -20,53 +29,27 @@ declare namespace MasterModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Response types cho từng domain
|
// Response types cho từng domain
|
||||||
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
|
|
||||||
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
|
|
||||||
type AlarmMessageResponse = MesageReaderResponse<Alarm>;
|
type AlarmMessageResponse = MesageReaderResponse<Alarm>;
|
||||||
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
|
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
|
||||||
|
|
||||||
type MessageDataType = NodeConfig[] | CameraV5 | CameraV6;
|
type MessageDataType = NodeConfig[];
|
||||||
|
|
||||||
interface Message<T = MessageDataType> {
|
interface Message<T = MessageDataType> extends MessageBasicInfo {
|
||||||
channel?: string;
|
|
||||||
subtopic?: string;
|
|
||||||
publisher?: string;
|
|
||||||
protocol?: string;
|
|
||||||
name?: string;
|
|
||||||
time?: number;
|
|
||||||
string_value?: string;
|
string_value?: string;
|
||||||
string_value_parsed?: T;
|
string_value_parsed?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message types cho từng domain
|
// Message types cho từng domain
|
||||||
type CameraMessage = Message<CameraV5>;
|
|
||||||
type CameraV6Message = Message<CameraV6>;
|
|
||||||
type NodeConfigMessage = Message<NodeConfig[]>;
|
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 {
|
interface Alarm {
|
||||||
id: string;
|
id: string;
|
||||||
type: Type;
|
type: Type;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SensorLogMessage extends MessageBasicInfo {
|
||||||
|
unit: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/services/master/typings/terminal.d.ts
vendored
Normal file
59
src/services/master/typings/terminal.d.ts
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
/**
|
||||||
|
* Trạng thái kết nối terminal
|
||||||
|
*/
|
||||||
|
interface TerminalConnection {
|
||||||
|
online: boolean;
|
||||||
|
lastSeen?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format SenML record nhận từ MQTT
|
||||||
|
*/
|
||||||
|
interface SenmlRecord {
|
||||||
|
vs?: string; // value string - dữ liệu terminal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset theme cho terminal
|
||||||
|
*/
|
||||||
|
interface TerminalThemePreset {
|
||||||
|
name: string;
|
||||||
|
theme: {
|
||||||
|
background: string;
|
||||||
|
foreground: string;
|
||||||
|
cursor: string;
|
||||||
|
selectionBackground: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Định nghĩa các preset themes cho terminal
|
||||||
|
*/
|
||||||
|
type TerminalThemes = Record<string, TerminalThemePreset>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record SenML gửi tới MQTT (dùng cho terminal commands)
|
||||||
|
* Format: SenML (Sensor Measurement List)
|
||||||
|
*/
|
||||||
|
interface MqttTerminalRecord {
|
||||||
|
bn: string; // base name - session ID
|
||||||
|
n: string; // name - tên field
|
||||||
|
bt: number; // base time - timestamp (seconds)
|
||||||
|
vs: string; // value string - command được encode Base64
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload gửi tới MQTT cho terminal
|
||||||
|
* Là mảng các SenML records
|
||||||
|
*/
|
||||||
|
type MqttTerminalPayload = MqttTerminalRecord[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options cho MQTT publish
|
||||||
|
*/
|
||||||
|
interface MqttPublishOptions {
|
||||||
|
topic: string;
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/services/master/typings/user.d.ts
vendored
11
src/services/master/typings/user.d.ts
vendored
@@ -12,6 +12,7 @@ declare namespace MasterModel {
|
|||||||
interface UserResponse {
|
interface UserResponse {
|
||||||
id?: string;
|
id?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
enable_2fa?: boolean;
|
||||||
metadata?: UserMetadata;
|
metadata?: UserMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,4 +47,14 @@ declare namespace MasterModel {
|
|||||||
password: string;
|
password: string;
|
||||||
confirm_password?: string;
|
confirm_password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2FA
|
||||||
|
interface Enable2FAResponse {
|
||||||
|
otp_auth_url?: string;
|
||||||
|
qr_code_base64?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Verify2FARequestBody {
|
||||||
|
otp?: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/utils/chart.ts
Normal file
22
src/utils/chart.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
Chart as ChartJS,
|
||||||
|
Filler,
|
||||||
|
Legend,
|
||||||
|
LinearScale,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip,
|
||||||
|
} from 'chart.js';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
BarElement,
|
||||||
|
Filler,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
151
src/utils/mqttClient.ts
Normal file
151
src/utils/mqttClient.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import mqtt, { IClientOptions, MqttClient, Packet } from 'mqtt';
|
||||||
|
|
||||||
|
type MessageHandler = (topic: string, message: Buffer, packet: Packet) => void;
|
||||||
|
type ConnectionHandler = () => void;
|
||||||
|
type ErrorHandler = (error: Error) => void;
|
||||||
|
|
||||||
|
interface MqttCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MQTTClientManager {
|
||||||
|
private client: MqttClient | null = null;
|
||||||
|
private messageHandlers = new Set<MessageHandler>();
|
||||||
|
private connectHandlers = new Set<ConnectionHandler>();
|
||||||
|
private closeHandlers = new Set<ConnectionHandler>();
|
||||||
|
private errorHandlers = new Set<ErrorHandler>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kết nối tới MQTT broker.
|
||||||
|
* @param credentials Thông tin xác thực (username, password)
|
||||||
|
* @param url Địa chỉ MQTT broker (mặc định là /mqtt)
|
||||||
|
*/
|
||||||
|
connect(credentials: MqttCredentials, url: string = '/mqtt') {
|
||||||
|
if (this.client?.connected) return;
|
||||||
|
|
||||||
|
// Build WebSocket URL
|
||||||
|
let mqttUrl = url;
|
||||||
|
if (url.startsWith('/')) {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
mqttUrl = `${protocol}//${window.location.host}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts: IClientOptions = {
|
||||||
|
clean: true,
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
reconnectPeriod: 5000,
|
||||||
|
connectTimeout: 30 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.client = mqtt.connect(mqttUrl, opts);
|
||||||
|
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
console.log('MQTT Connected successfully!');
|
||||||
|
this.connectHandlers.forEach((fn) => fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('close', () => {
|
||||||
|
console.log('MQTT Connection closed');
|
||||||
|
this.closeHandlers.forEach((fn) => fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (error: Error) => {
|
||||||
|
console.error('MQTT Error:', error);
|
||||||
|
this.errorHandlers.forEach((fn) => fn(error));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('message', (topic, message, packet) => {
|
||||||
|
this.messageHandlers.forEach((fn) => fn(topic, message, packet));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ngắt kết nối MQTT và giải phóng tài nguyên.
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
if (this.client) {
|
||||||
|
this.client.end();
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe vào một topic.
|
||||||
|
* @param topic Topic cần subscribe
|
||||||
|
*/
|
||||||
|
subscribe(topic: string | string[]) {
|
||||||
|
this.client?.subscribe(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe khỏi một topic.
|
||||||
|
* @param topic Topic cần unsubscribe
|
||||||
|
*/
|
||||||
|
unsubscribe(topic: string | string[]) {
|
||||||
|
this.client?.unsubscribe(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish message tới một topic.
|
||||||
|
* @param topic Topic để publish
|
||||||
|
* @param payload Payload (string hoặc object sẽ được stringify)
|
||||||
|
*/
|
||||||
|
publish(topic: string, payload: string | object) {
|
||||||
|
const payloadStr =
|
||||||
|
typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||||
|
this.client?.publish(topic, payloadStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Đăng ký callback khi nhận được message.
|
||||||
|
* @param cb Hàm callback xử lý message
|
||||||
|
* @returns Hàm hủy đăng ký callback
|
||||||
|
*/
|
||||||
|
onMessage(cb: MessageHandler) {
|
||||||
|
this.messageHandlers.add(cb);
|
||||||
|
return () => this.messageHandlers.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Đăng ký callback khi kết nối thành công.
|
||||||
|
*/
|
||||||
|
onConnect(cb: ConnectionHandler) {
|
||||||
|
this.connectHandlers.add(cb);
|
||||||
|
return () => this.connectHandlers.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Đăng ký callback khi kết nối bị đóng.
|
||||||
|
*/
|
||||||
|
onClose(cb: ConnectionHandler) {
|
||||||
|
this.closeHandlers.add(cb);
|
||||||
|
return () => this.closeHandlers.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Đăng ký callback khi có lỗi.
|
||||||
|
*/
|
||||||
|
onError(cb: ErrorHandler) {
|
||||||
|
this.errorHandlers.add(cb);
|
||||||
|
return () => this.errorHandlers.delete(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiểm tra trạng thái kết nối MQTT.
|
||||||
|
* @returns true nếu đã kết nối, ngược lại là false
|
||||||
|
*/
|
||||||
|
isConnected() {
|
||||||
|
return this.client?.connected ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lấy instance client MQTT gốc (nếu cần thao tác nâng cao).
|
||||||
|
*/
|
||||||
|
getClient() {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mqttClient = new MQTTClientManager();
|
||||||
111
src/utils/rememberMe.ts
Normal file
111
src/utils/rememberMe.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for managing "Remember me" password saving functionality
|
||||||
|
* Uses AES-256 encryption for secure credential storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CryptoJS from 'crypto-js';
|
||||||
|
import {
|
||||||
|
getRememberMeData,
|
||||||
|
removeRememberMeData,
|
||||||
|
setRememberMeData,
|
||||||
|
} from './storage';
|
||||||
|
|
||||||
|
const SECRET_KEY = 'smatec_secret_key_2024_secure_encryption';
|
||||||
|
|
||||||
|
export interface RememberedCredentials {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256 encryption using CryptoJS
|
||||||
|
* Automatically generates random IV and encodes to Base64
|
||||||
|
* @param data Data to encrypt
|
||||||
|
* @returns Encrypted string (base64 encoded with IV)
|
||||||
|
*/
|
||||||
|
function encrypt(data: string): string {
|
||||||
|
try {
|
||||||
|
const encrypted = CryptoJS.AES.encrypt(data, SECRET_KEY).toString();
|
||||||
|
return encrypted;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Encryption error:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256 decryption using CryptoJS
|
||||||
|
* Automatically extracts IV and decrypts
|
||||||
|
* @param encrypted Encrypted data (base64 encoded with IV)
|
||||||
|
* @returns Decrypted string
|
||||||
|
*/
|
||||||
|
function decrypt(encrypted: string): string {
|
||||||
|
try {
|
||||||
|
const decrypted = CryptoJS.AES.decrypt(encrypted, SECRET_KEY);
|
||||||
|
const result = decrypted.toString(CryptoJS.enc.Utf8);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Decryption error:', error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear saved credentials from localStorage
|
||||||
|
*/
|
||||||
|
export function clearCredentials(): void {
|
||||||
|
removeRememberMeData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save credentials to localStorage (encrypted with secret key)
|
||||||
|
* @param email User's email
|
||||||
|
* @param password User's password
|
||||||
|
*/
|
||||||
|
export function saveCredentials(email: string, password: string): void {
|
||||||
|
try {
|
||||||
|
const credentials: RememberedCredentials = { email, password };
|
||||||
|
const json = JSON.stringify(credentials);
|
||||||
|
const encrypted = encrypt(json);
|
||||||
|
setRememberMeData(encrypted);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving credentials:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load credentials from localStorage
|
||||||
|
* @returns RememberedCredentials or null if not found or error occurs
|
||||||
|
*/
|
||||||
|
export function loadCredentials(): RememberedCredentials | null {
|
||||||
|
try {
|
||||||
|
const encrypted = getRememberMeData();
|
||||||
|
if (!encrypted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const decrypted = decrypt(encrypted);
|
||||||
|
if (!decrypted) {
|
||||||
|
clearCredentials();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const credentials: RememberedCredentials = JSON.parse(decrypted);
|
||||||
|
return credentials;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading credentials:', error);
|
||||||
|
clearCredentials();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are saved credentials
|
||||||
|
* @returns true if credentials exist, false otherwise
|
||||||
|
*/
|
||||||
|
export function hasSavedCredentials(): boolean {
|
||||||
|
try {
|
||||||
|
const encrypted = getRememberMeData();
|
||||||
|
return encrypted !== null;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { ACCESS_TOKEN, REFRESH_TOKEN } from '@/constants';
|
import {
|
||||||
|
ACCESS_TOKEN,
|
||||||
|
REFRESH_TOKEN,
|
||||||
|
REMEMBER_ME_KEY,
|
||||||
|
TERMINAL_THEME_KEY,
|
||||||
|
THEME_KEY,
|
||||||
|
} from '@/constants';
|
||||||
|
|
||||||
export function getAccessToken(): string {
|
export function getAccessToken(): string {
|
||||||
return localStorage.getItem(ACCESS_TOKEN) || '';
|
return localStorage.getItem(ACCESS_TOKEN) || '';
|
||||||
@@ -24,6 +30,30 @@ export function removeRefreshToken() {
|
|||||||
localStorage.removeItem(REFRESH_TOKEN);
|
localStorage.removeItem(REFRESH_TOKEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTerminalTheme(): string {
|
||||||
|
return localStorage.getItem(TERMINAL_THEME_KEY) || 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTerminalTheme(themeKey: string) {
|
||||||
|
localStorage.setItem(TERMINAL_THEME_KEY, themeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTerminalTheme() {
|
||||||
|
localStorage.removeItem(TERMINAL_THEME_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTheme(): string {
|
||||||
|
return localStorage.getItem(THEME_KEY) || 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTheme(themeKey: string) {
|
||||||
|
localStorage.setItem(THEME_KEY, themeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTheme() {
|
||||||
|
localStorage.removeItem(THEME_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
export function getBrowserId() {
|
export function getBrowserId() {
|
||||||
const id = localStorage.getItem('sip-browserid');
|
const id = localStorage.getItem('sip-browserid');
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -41,15 +71,62 @@ export function getBrowserId() {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Remember Me Storage Operations
|
||||||
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all localStorage data except browserId
|
* Get encrypted credentials from localStorage
|
||||||
|
*/
|
||||||
|
export function getRememberMeData(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(REMEMBER_ME_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading remember me data:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save encrypted credentials to localStorage
|
||||||
|
*/
|
||||||
|
export function setRememberMeData(encryptedData: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(REMEMBER_ME_KEY, encryptedData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving remember me data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove encrypted credentials from localStorage
|
||||||
|
*/
|
||||||
|
export function removeRememberMeData(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(REMEMBER_ME_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing remember me data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all localStorage data except browserId, theme and terminal theme
|
||||||
*/
|
*/
|
||||||
export function clearAllData() {
|
export function clearAllData() {
|
||||||
const browserId = localStorage.getItem('sip-browserid');
|
const browserId = localStorage.getItem('sip-browserid');
|
||||||
|
const rememberMe = getRememberMeData();
|
||||||
|
const theme = getTheme();
|
||||||
|
const terminalTheme = getTerminalTheme();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
// Khôi phục các giá trị cần thiết
|
||||||
if (browserId) {
|
if (browserId) {
|
||||||
localStorage.setItem('sip-browserid', browserId);
|
localStorage.setItem('sip-browserid', browserId);
|
||||||
}
|
}
|
||||||
|
if (rememberMe) {
|
||||||
|
setRememberMeData(rememberMe);
|
||||||
|
}
|
||||||
|
setTheme(theme);
|
||||||
|
setTerminalTheme(terminalTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
118
update-iconfont.sh
Executable file
118
update-iconfont.sh
Executable file
@@ -0,0 +1,118 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Update Iconfont - Git Push Script with Merge Main ==="
|
||||||
|
|
||||||
|
# Hàm kiểm tra lỗi và thoát
|
||||||
|
handle_error() {
|
||||||
|
echo "❌ Lỗi: $1"
|
||||||
|
# Nếu có stash, hiển thị thông báo để người dùng biết cách xử lý
|
||||||
|
if [ "$stashed" = true ]; then
|
||||||
|
echo "⚠️ Có stash được lưu, hãy kiểm tra bằng 'git stash list' và xử lý thủ công nếu cần."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kiểm tra xem có trong Git repository không
|
||||||
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
handle_error "Thư mục hiện tại không phải là Git repository!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Các file cần commit
|
||||||
|
FILE1="src/components/IconFont/index.tsx"
|
||||||
|
FILE2="src/app.tsx"
|
||||||
|
|
||||||
|
# Kiểm tra file tồn tại
|
||||||
|
if [ ! -f "$FILE1" ]; then
|
||||||
|
handle_error "Không tìm thấy file $FILE1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$FILE2" ]; then
|
||||||
|
handle_error "Không tìm thấy file $FILE2"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Lấy nhánh hiện tại
|
||||||
|
current_branch=$(git branch --show-current)
|
||||||
|
if [ -z "$current_branch" ]; then
|
||||||
|
handle_error "Không thể xác định nhánh hiện tại!"
|
||||||
|
fi
|
||||||
|
echo "👉 Bạn đang ở nhánh: $current_branch"
|
||||||
|
|
||||||
|
# Hỏi nhánh chính, mặc định là 'master' nếu người dùng không nhập
|
||||||
|
read -p "Nhập tên nhánh chính (mặc định: master): " target_branch
|
||||||
|
target_branch=${target_branch:-master}
|
||||||
|
|
||||||
|
# Kiểm tra xem nhánh chính có tồn tại trên remote không
|
||||||
|
if ! git ls-remote --heads origin "$target_branch" >/dev/null 2>&1; then
|
||||||
|
handle_error "Nhánh $target_branch không tồn tại trên remote!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Hiển thị thay đổi của 2 file
|
||||||
|
echo ""
|
||||||
|
echo "📋 Các thay đổi trong 2 file iconfont:"
|
||||||
|
echo "---"
|
||||||
|
git --no-pager diff "$FILE1" "$FILE2" 2>/dev/null || echo "(Không có thay đổi hoặc file chưa được track)"
|
||||||
|
echo "---"
|
||||||
|
|
||||||
|
# Kiểm tra có thay đổi không
|
||||||
|
if git diff --quiet "$FILE1" "$FILE2" 2>/dev/null && git diff --cached --quiet "$FILE1" "$FILE2" 2>/dev/null; then
|
||||||
|
echo "⚠️ Không có thay đổi nào trong 2 file iconfont."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Bước 1: Stash nếu có thay đổi chưa commit ---
|
||||||
|
stashed=false
|
||||||
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
|
echo "💾 Có thay đổi chưa commit -> stash lại..."
|
||||||
|
git stash push -m "auto-stash-before-iconfont-$(date +%s)" || handle_error "Lỗi khi stash code!"
|
||||||
|
stashed=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Bước 2: Đồng bộ code từ nhánh chính về nhánh hiện tại ---
|
||||||
|
echo "🔄 Đồng bộ code từ $target_branch về $current_branch..."
|
||||||
|
git fetch origin "$target_branch" || handle_error "Lỗi khi fetch $target_branch!"
|
||||||
|
git merge origin/"$target_branch" --no-edit || {
|
||||||
|
handle_error "Merge từ $target_branch về $current_branch bị conflict, hãy xử lý thủ công rồi chạy lại."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Bước 3: Nếu có stash thì pop lại ---
|
||||||
|
if [ "$stashed" = true ]; then
|
||||||
|
echo "📥 Pop lại code đã stash..."
|
||||||
|
git stash pop || handle_error "Stash pop bị conflict, hãy xử lý thủ công bằng 'git stash list' và 'git stash apply'!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Bước 4: Add và Commit 2 file iconfont ---
|
||||||
|
echo "📥 Đang add 2 file iconfont..."
|
||||||
|
git add "$FILE1" "$FILE2" || handle_error "Lỗi khi add files!"
|
||||||
|
|
||||||
|
read -p "Nhập commit message (mặc định: 'chore: update iconfont url'): " commit_message
|
||||||
|
commit_message=${commit_message:-"chore: update iconfont url"}
|
||||||
|
git commit -m "$commit_message" || handle_error "Lỗi khi commit!"
|
||||||
|
|
||||||
|
# --- Bước 5: Push nhánh hiện tại ---
|
||||||
|
echo "🚀 Đang push nhánh $current_branch lên remote..."
|
||||||
|
git push origin "$current_branch" || handle_error "Push nhánh $current_branch thất bại!"
|
||||||
|
|
||||||
|
# --- Bước 6: Checkout sang nhánh chính ---
|
||||||
|
echo "🔄 Chuyển sang nhánh $target_branch..."
|
||||||
|
git checkout "$target_branch" || handle_error "Checkout sang $target_branch thất bại!"
|
||||||
|
|
||||||
|
# --- Bước 7: Pull nhánh chính ---
|
||||||
|
echo "🔄 Pull code mới nhất từ remote $target_branch..."
|
||||||
|
git pull origin "$target_branch" --no-rebase || handle_error "Pull $target_branch thất bại!"
|
||||||
|
|
||||||
|
# --- Bước 8: Merge nhánh hiện tại vào nhánh chính ---
|
||||||
|
echo "🔀 Merge $current_branch vào $target_branch..."
|
||||||
|
git merge "$current_branch" --no-edit || {
|
||||||
|
handle_error "Merge từ $current_branch vào $target_branch bị conflict, hãy xử lý thủ công rồi chạy lại."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Bước 9: Push nhánh chính ---
|
||||||
|
git push origin "$target_branch" || handle_error "Push $target_branch thất bại!"
|
||||||
|
|
||||||
|
# --- Quay lại nhánh hiện tại ---
|
||||||
|
echo "🔄 Quay lại nhánh $current_branch..."
|
||||||
|
git checkout "$current_branch" || handle_error "Checkout về $current_branch thất bại!"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Hoàn tất! Đã commit 2 file iconfont và merge vào $target_branch."
|
||||||
Reference in New Issue
Block a user