first commit
This commit is contained in:
3
.eslintrc.js
Normal file
3
.eslintrc.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: require.resolve('@umijs/max/eslint'),
|
||||||
|
};
|
||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/node_modules
|
||||||
|
/.env.local
|
||||||
|
/.umirc.local.ts
|
||||||
|
/config/config.local.ts
|
||||||
|
/src/.umi
|
||||||
|
/src/.umi-production
|
||||||
|
/src/.umi-test
|
||||||
|
/.umi
|
||||||
|
/.umi-production
|
||||||
|
/.umi-test
|
||||||
|
/dist
|
||||||
|
/.mfsu
|
||||||
|
.swc
|
||||||
|
.DS_Store
|
||||||
|
claude_code_zai_env.sh
|
||||||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx --no-install max verify-commit $1
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx --no-install --quiet
|
||||||
17
.lintstagedrc
Normal file
17
.lintstagedrc
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"*.{md,json}": [
|
||||||
|
"prettier --cache --write"
|
||||||
|
],
|
||||||
|
"*.{js,jsx}": [
|
||||||
|
"max lint --fix --eslint-only",
|
||||||
|
"prettier --cache --write"
|
||||||
|
],
|
||||||
|
"*.{css,less}": [
|
||||||
|
"max lint --fix --stylelint-only",
|
||||||
|
"prettier --cache --write"
|
||||||
|
],
|
||||||
|
"*.ts?(x)": [
|
||||||
|
"max lint --fix --eslint-only",
|
||||||
|
"prettier --cache --parser=typescript --write"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.umi
|
||||||
|
.umi-production
|
||||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"proseWrap": "never",
|
||||||
|
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
|
||||||
|
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
|
||||||
|
}
|
||||||
3
.stylelintrc.js
Normal file
3
.stylelintrc.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: require.resolve('@umijs/max/stylelint'),
|
||||||
|
};
|
||||||
47
.umirc.ts
Normal file
47
.umirc.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineConfig } from '@umijs/max';
|
||||||
|
import proxyDev from './config/proxy_dev';
|
||||||
|
import proxyProd from './config/proxy_prod';
|
||||||
|
const envConfig = process.env as { REACT_APP_ENV?: 'dev' | 'test' | 'prod' };
|
||||||
|
const rawEnv = envConfig.REACT_APP_ENV;
|
||||||
|
const isProdBuild = process.env.NODE_ENV === 'production';
|
||||||
|
const resolvedEnv = isProdBuild && !rawEnv ? 'prod' : rawEnv || 'dev';
|
||||||
|
const proxyConfig = isProdBuild ? proxyProd : proxyDev;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
antd: {},
|
||||||
|
access: {},
|
||||||
|
model: {},
|
||||||
|
initialState: {},
|
||||||
|
request: {},
|
||||||
|
locale: {
|
||||||
|
default: 'vi-VN',
|
||||||
|
baseNavigator: false,
|
||||||
|
antd: true,
|
||||||
|
title: false,
|
||||||
|
baseSeparator: '-',
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
title: 'SMT Production Management',
|
||||||
|
},
|
||||||
|
proxy: proxyConfig[resolvedEnv],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
title: 'Login',
|
||||||
|
path: '/login',
|
||||||
|
component: './Auth',
|
||||||
|
layout: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dashboard',
|
||||||
|
path: '/dashboard',
|
||||||
|
component: './Dashboard',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
npmClient: 'pnpm',
|
||||||
|
tailwindcss: {},
|
||||||
|
});
|
||||||
25
config/proxy_dev.ts
Normal file
25
config/proxy_dev.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const proxyDev: Record<string, any> = {
|
||||||
|
dev: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://apisanxuat.nguyennhatminh.io.vn',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://apisanxuat.nguyennhatminh.io.vn',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prod: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://apisanxuat.nguyennhatminh.io.vn',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default proxyDev;
|
||||||
25
config/proxy_prod.ts
Normal file
25
config/proxy_prod.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const proxyProd: Record<string, any> = {
|
||||||
|
dev: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://apisanxuat.nguyennhatminh.io.vn',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://apisanxuat.nguyennhatminh.io.vn',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prod: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://apisanxuat.nguyennhatminh.io.vn',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default proxyProd;
|
||||||
112
config/request_dev.ts
Normal file
112
config/request_dev.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { ROUTE_LOGIN } from '@/constants';
|
||||||
|
import { getToken, removeToken } from '@/utils/localStorageUtils';
|
||||||
|
import { history, RequestConfig } from '@umijs/max';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
|
// Error handling scheme: Error types
|
||||||
|
// enum ErrorShowType {
|
||||||
|
// SILENT = 0,
|
||||||
|
// WARN_MESSAGE = 1,
|
||||||
|
// ERROR_MESSAGE = 2,
|
||||||
|
// NOTIFICATION = 3,
|
||||||
|
// REDIRECT = 9,
|
||||||
|
// }
|
||||||
|
// Response data structure agreed with the backend
|
||||||
|
// interface ResponseStructure<T = any> {
|
||||||
|
// success: boolean;
|
||||||
|
// data: T;
|
||||||
|
// errorCode?: number;
|
||||||
|
// errorMessage?: string;
|
||||||
|
// showType?: ErrorShowType;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const codeMessage = {
|
||||||
|
200: 'The server successfully returned the requested data。',
|
||||||
|
201: 'New or modified data succeeded。',
|
||||||
|
202: 'A request has been queued in the background (asynchronous task)。',
|
||||||
|
204: 'Data deleted successfully。',
|
||||||
|
400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
|
||||||
|
401: 'The user does not have permission (token, username, password is wrong) 。',
|
||||||
|
403: 'User is authorized, but access is prohibited。',
|
||||||
|
404: 'The request issued was for a non-existent record, the server did not operate。',
|
||||||
|
406: 'The requested format is not available。',
|
||||||
|
410: 'The requested resource is permanently deleted and will no longer be available。',
|
||||||
|
422: 'When creating an object, a validation error occurred。',
|
||||||
|
500: 'Server error, please check the server。',
|
||||||
|
502: 'Gateway error。',
|
||||||
|
503: 'Service unavailable, server temporarily overloaded or maintained。',
|
||||||
|
504: 'Gateway timeout。',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runtime configuration
|
||||||
|
export const handleRequestConfig: RequestConfig = {
|
||||||
|
// Unified request settings
|
||||||
|
timeout: 20000,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
// Error handling: umi@3's error handling scheme.
|
||||||
|
errorConfig: {
|
||||||
|
// Error throwing
|
||||||
|
errorThrower: (res: any) => {
|
||||||
|
// console.log('Response from backend:', res);
|
||||||
|
const { success, data, errorCode, errorMessage, showType } = res;
|
||||||
|
if (!success) {
|
||||||
|
const error: any = new Error(errorMessage);
|
||||||
|
error.name = 'BizError';
|
||||||
|
error.info = { errorCode, errorMessage, showType, data };
|
||||||
|
throw error; // Throw custom error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Error catching and handling
|
||||||
|
errorHandler: (error: any) => {
|
||||||
|
if (error.response) {
|
||||||
|
const { status, statusText, data } = error.response;
|
||||||
|
|
||||||
|
// Ưu tiên: backend message → codeMessage → statusText
|
||||||
|
const errMsg =
|
||||||
|
data?.message ||
|
||||||
|
codeMessage[status as keyof typeof codeMessage] ||
|
||||||
|
statusText ||
|
||||||
|
'Unknown error';
|
||||||
|
|
||||||
|
message.error(`❌ ${status}: ${errMsg}`);
|
||||||
|
if (status === 401) {
|
||||||
|
removeToken();
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
message.error('🚨 No response from server!');
|
||||||
|
} else {
|
||||||
|
message.error(`⚠️ Request setup error: ${error.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Request interceptors
|
||||||
|
requestInterceptors: [
|
||||||
|
(url: string, options: any) => {
|
||||||
|
const token = getToken();
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
options: {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
...(token ? { Authorization: `${token}` } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Unwrap data from backend response
|
||||||
|
// responseInterceptors: [
|
||||||
|
// (response) => {
|
||||||
|
// const res = response.data as ResponseStructure<any>;
|
||||||
|
// if (res && res.success) {
|
||||||
|
// // ✅ Trả ra data luôn thay vì cả object
|
||||||
|
// return res.data;
|
||||||
|
// }
|
||||||
|
// return response.data;
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
};
|
||||||
90
config/request_prod.ts
Normal file
90
config/request_prod.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { ROUTE_LOGIN } from '@/constants';
|
||||||
|
import { getToken, removeToken } from '@/utils/localStorageUtils';
|
||||||
|
import { history, RequestConfig } from '@umijs/max';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
|
const API_BASE_URL = 'https://apisanxuat.nguyennhatminh.io.vn';
|
||||||
|
|
||||||
|
const codeMessage = {
|
||||||
|
200: 'The server successfully returned the requested data。',
|
||||||
|
201: 'New or modified data succeeded。',
|
||||||
|
202: 'A request has been queued in the background (asynchronous task)。',
|
||||||
|
204: 'Data deleted successfully。',
|
||||||
|
400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
|
||||||
|
401: 'The user does not have permission (token, username, password is wrong) 。',
|
||||||
|
403: 'User is authorized, but access is prohibited。',
|
||||||
|
404: 'The request issued was for a non-existent record, the server did not operate。',
|
||||||
|
406: 'The requested format is not available。',
|
||||||
|
410: 'The requested resource is permanently deleted and will no longer be available。',
|
||||||
|
422: 'When creating an object, a validation error occurred。',
|
||||||
|
500: 'Server error, please check the server。',
|
||||||
|
502: 'Gateway error。',
|
||||||
|
503: 'Service unavailable, server temporarily overloaded or maintained。',
|
||||||
|
504: 'Gateway timeout。',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runtime configuration
|
||||||
|
export const handleRequestConfig: RequestConfig = {
|
||||||
|
// Unified request settings
|
||||||
|
timeout: 20000,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
// Error handling: umi@3's error handling scheme.
|
||||||
|
errorConfig: {
|
||||||
|
// Error throwing
|
||||||
|
errorThrower: (res: any) => {
|
||||||
|
const { success, data, errorCode, errorMessage, showType } = res;
|
||||||
|
if (!success) {
|
||||||
|
const error: any = new Error(errorMessage);
|
||||||
|
error.name = 'BizError';
|
||||||
|
error.info = { errorCode, errorMessage, showType, data };
|
||||||
|
throw error; // Throw custom error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Error catching and handling
|
||||||
|
errorHandler: (error: any) => {
|
||||||
|
if (error.response) {
|
||||||
|
const { status, statusText, data } = error.response;
|
||||||
|
|
||||||
|
// Ưu tiên: backend message → codeMessage → statusText
|
||||||
|
const errMsg =
|
||||||
|
data?.message ||
|
||||||
|
codeMessage[status as keyof typeof codeMessage] ||
|
||||||
|
statusText ||
|
||||||
|
'Unknown error';
|
||||||
|
|
||||||
|
message.error(`❌ ${status}: ${errMsg}`);
|
||||||
|
if (status === 401) {
|
||||||
|
removeToken();
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
message.error('🚨 No response from server!');
|
||||||
|
} else {
|
||||||
|
message.error(`⚠️ Request setup error: ${error.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Request interceptors
|
||||||
|
requestInterceptors: [
|
||||||
|
(url: string, options: any) => {
|
||||||
|
// Nếu URL không phải absolute URL, thêm base URL
|
||||||
|
let finalUrl = url;
|
||||||
|
if (!url.startsWith('http')) {
|
||||||
|
finalUrl = `${API_BASE_URL}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getToken();
|
||||||
|
return {
|
||||||
|
url: finalUrl,
|
||||||
|
options: {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
...(token ? { Authorization: `${token}` } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
22439
package-lock.json
generated
Normal file
22439
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "",
|
||||||
|
"scripts": {
|
||||||
|
"build": "max build",
|
||||||
|
"dev": "max dev",
|
||||||
|
"format": "prettier --cache --write .",
|
||||||
|
"postinstall": "max setup",
|
||||||
|
"prepare": "husky",
|
||||||
|
"setup": "max setup",
|
||||||
|
"start": "npm run dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.0.1",
|
||||||
|
"@ant-design/pro-components": "^2.4.4",
|
||||||
|
"@umijs/max": "^4.5.0",
|
||||||
|
"antd": "^5.4.0",
|
||||||
|
"classnames": "^2.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.0.33",
|
||||||
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"husky": "^9",
|
||||||
|
"lint-staged": "^13",
|
||||||
|
"prettier": "^2",
|
||||||
|
"prettier-plugin-organize-imports": "^3.2.2",
|
||||||
|
"prettier-plugin-packagejson": "^2.4.3",
|
||||||
|
"tailwindcss": "^3",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
14556
pnpm-lock.yaml
generated
Normal file
14556
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
src/access.ts
Normal file
6
src/access.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default (initialState: API.UserInfo) => {
|
||||||
|
const canSeeAdmin = !!(initialState && initialState.role === 'admin');
|
||||||
|
return {
|
||||||
|
canSeeAdmin,
|
||||||
|
};
|
||||||
|
};
|
||||||
135
src/app.tsx
Normal file
135
src/app.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { history, RunTimeLayoutConfig, useIntl } from '@umijs/max';
|
||||||
|
import { Dropdown } from 'antd';
|
||||||
|
import { handleRequestConfig as devRequestConfig } from '../config/request_dev';
|
||||||
|
import { handleRequestConfig as prodRequestConfig } from '../config/request_prod';
|
||||||
|
import UnAccessPage from './components/403/403Page';
|
||||||
|
|
||||||
|
import { ROUTE_LOGIN } from './constants';
|
||||||
|
import { parseJwt } from './utils/jwtTokenUtils';
|
||||||
|
import { getToken, removeToken } from './utils/localStorageUtils';
|
||||||
|
|
||||||
|
// Avatar component with i18n support
|
||||||
|
const AvatarDropdown = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
label: intl.formatMessage({ id: 'common.logout' }),
|
||||||
|
onClick: () => {
|
||||||
|
removeToken();
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: '#1890ff',
|
||||||
|
color: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({ id: 'common.user' })}
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getInitialState() {
|
||||||
|
const token: string = getToken();
|
||||||
|
const { pathname } = history.location;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
if (pathname !== ROUTE_LOGIN) {
|
||||||
|
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseJwt(token);
|
||||||
|
if (!parsed) {
|
||||||
|
removeToken();
|
||||||
|
if (pathname !== ROUTE_LOGIN) {
|
||||||
|
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sub, exp } = parsed;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const oneHour = 60 * 60;
|
||||||
|
|
||||||
|
if (exp - now < oneHour) {
|
||||||
|
console.warn('Token expired or nearly expired, redirecting...');
|
||||||
|
removeToken();
|
||||||
|
if (pathname !== ROUTE_LOGIN) {
|
||||||
|
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentUser: sub,
|
||||||
|
exp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layout: RunTimeLayoutConfig = (initialState) => {
|
||||||
|
return {
|
||||||
|
title: 'SMT Production Management',
|
||||||
|
fixedHeader: true,
|
||||||
|
contentWidth: 'Fluid',
|
||||||
|
navTheme: 'light',
|
||||||
|
splitMenus: true,
|
||||||
|
menu: {
|
||||||
|
locale: true,
|
||||||
|
},
|
||||||
|
contentStyle: {
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
paddingInline: 0,
|
||||||
|
},
|
||||||
|
avatarProps: {
|
||||||
|
size: 'small',
|
||||||
|
render: () => <AvatarDropdown />,
|
||||||
|
},
|
||||||
|
layout: 'top',
|
||||||
|
logout: () => {
|
||||||
|
removeToken();
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
},
|
||||||
|
onPageChange: () => {
|
||||||
|
if (!initialState.initialState) {
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
menuHeaderRender: undefined,
|
||||||
|
unAccessible: <UnAccessPage />,
|
||||||
|
token: {
|
||||||
|
pageContainer: {
|
||||||
|
paddingInlinePageContainerContent: 0,
|
||||||
|
paddingBlockPageContainerContent: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isProdBuild = process.env.NODE_ENV === 'production';
|
||||||
|
export const request = isProdBuild ? prodRequestConfig : devRequestConfig;
|
||||||
0
src/assets/.gitkeep
Normal file
0
src/assets/.gitkeep
Normal file
13
src/components/403/403Page.tsx
Normal file
13
src/components/403/403Page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Result } from 'antd';
|
||||||
|
|
||||||
|
const UnAccessPage: React.FC = () => (
|
||||||
|
<Result
|
||||||
|
status="403"
|
||||||
|
title="403"
|
||||||
|
subTitle="Sorry, you are not authorized to access this page."
|
||||||
|
extra={<Button type="primary">Back Home</Button>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default UnAccessPage;
|
||||||
13
src/components/404/404Page.tsx
Normal file
13
src/components/404/404Page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Button, Result } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const NotFoundPage: React.FC = () => (
|
||||||
|
<Result
|
||||||
|
status="404"
|
||||||
|
title="404"
|
||||||
|
subTitle="Sorry, the page you visited does not exist."
|
||||||
|
extra={<Button type="primary">Back Home</Button>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default <NotFoundPage />;
|
||||||
13
src/components/500/500Page.tsx
Normal file
13
src/components/500/500Page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Button, Result } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const InternalServerErrorPage: React.FC = () => (
|
||||||
|
<Result
|
||||||
|
status="500"
|
||||||
|
title="500"
|
||||||
|
subTitle="Sorry, something went wrong."
|
||||||
|
extra={<Button type="primary">Back Home</Button>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default InternalServerErrorPage;
|
||||||
17
src/components/Footer/Footer.tsx
Normal file
17
src/components/Footer/Footer.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { DefaultFooter } from '@ant-design/pro-components';
|
||||||
|
import './style.less';
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
return (
|
||||||
|
<DefaultFooter
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
copyright={`${currentYear} SMATEC Production Management - v1.0.0`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
3
src/components/Footer/style.less
Normal file
3
src/components/Footer/style.less
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.ant-pro-global-footer-copyright {
|
||||||
|
color: white !important; /* hoặc mã màu bạn muốn */
|
||||||
|
}
|
||||||
4
src/components/Guide/Guide.less
Normal file
4
src/components/Guide/Guide.less
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.title {
|
||||||
|
margin: 0 auto;
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
23
src/components/Guide/Guide.tsx
Normal file
23
src/components/Guide/Guide.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Layout, Row, Typography } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import styles from './Guide.less';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脚手架示例组件
|
||||||
|
const Guide: React.FC<Props> = (props) => {
|
||||||
|
const { name } = props;
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Row>
|
||||||
|
<Typography.Title level={3} className={styles.title}>
|
||||||
|
欢迎使用 <strong>{name}</strong> !
|
||||||
|
</Typography.Title>
|
||||||
|
</Row>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Guide;
|
||||||
2
src/components/Guide/index.ts
Normal file
2
src/components/Guide/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import Guide from './Guide';
|
||||||
|
export default Guide;
|
||||||
5
src/constants/enums.ts
Normal file
5
src/constants/enums.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Application Enums - Add your enums here
|
||||||
|
export enum USER_ROLE {
|
||||||
|
ADMIN = 'admin',
|
||||||
|
USER = 'user',
|
||||||
|
}
|
||||||
9
src/constants/index.ts
Normal file
9
src/constants/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const DEFAULT_NAME = 'Admin';
|
||||||
|
export const TOKEN = 'token';
|
||||||
|
|
||||||
|
// Route Constants
|
||||||
|
export const ROUTE_LOGIN = '/login';
|
||||||
|
export const ROUTE_DASHBOARD = '/dashboard';
|
||||||
|
|
||||||
|
// API Path Constants
|
||||||
|
export const API_PATH_LOGIN = '/api/auth/login';
|
||||||
76
src/hooks/useTranslation.ts
Normal file
76
src/hooks/useTranslation.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for easier internationalization usage
|
||||||
|
* Provides a simplified API for common i18n operations
|
||||||
|
*/
|
||||||
|
export const useTranslation = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple translation function
|
||||||
|
* @param id - Translation key
|
||||||
|
* @param values - Values to interpolate into the translation
|
||||||
|
* @returns Translated string
|
||||||
|
*/
|
||||||
|
const t = (id: string, values?: Record<string, any>): string => {
|
||||||
|
return intl.formatMessage({ id }, values);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current locale is the specified one
|
||||||
|
* @param locale - Locale code (e.g., 'vi-VN', 'en-US')
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
const isLocale = (locale: string): boolean => {
|
||||||
|
return intl.locale === locale;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current locale code
|
||||||
|
* @returns Current locale string
|
||||||
|
*/
|
||||||
|
const getCurrentLocale = (): string => {
|
||||||
|
return intl.locale;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date with locale consideration
|
||||||
|
* @param date - Date to format
|
||||||
|
* @param options - Intl.DateTimeFormatOptions
|
||||||
|
* @returns Formatted date string
|
||||||
|
*/
|
||||||
|
const formatDate = (
|
||||||
|
date: Date | number,
|
||||||
|
options?: Intl.DateTimeFormatOptions
|
||||||
|
): string => {
|
||||||
|
return new Intl.DateTimeFormat(intl.locale, options).format(
|
||||||
|
typeof date === 'number' ? new Date(date) : date
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number with locale consideration
|
||||||
|
* @param number - Number to format
|
||||||
|
* @param options - Intl.NumberFormatOptions
|
||||||
|
* @returns Formatted number string
|
||||||
|
*/
|
||||||
|
const formatNumber = (
|
||||||
|
number: number,
|
||||||
|
options?: Intl.NumberFormatOptions
|
||||||
|
): string => {
|
||||||
|
return new Intl.NumberFormat(intl.locale, options).format(number);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
isLocale,
|
||||||
|
getCurrentLocale,
|
||||||
|
formatDate,
|
||||||
|
formatNumber,
|
||||||
|
// Expose original intl object for advanced usage
|
||||||
|
intl,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useTranslation;
|
||||||
18
src/locales/vi-VN.ts
Normal file
18
src/locales/vi-VN.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export default {
|
||||||
|
'common.login': 'Đăng nhập',
|
||||||
|
'common.username': 'Tên người dùng',
|
||||||
|
'common.password': 'Mật khẩu',
|
||||||
|
'common.logout': 'Đăng xuất',
|
||||||
|
'common.user': 'U',
|
||||||
|
|
||||||
|
'menu.dashboard': 'Bảng điều khiển',
|
||||||
|
|
||||||
|
'dashboard.welcome': 'Chào mừng đến với SMT Production Management',
|
||||||
|
'dashboard.totalUsers': 'Tổng người dùng',
|
||||||
|
'dashboard.activeUsers': 'Người dùng hoạt động',
|
||||||
|
'dashboard.totalRecords': 'Tổng bản ghi',
|
||||||
|
'dashboard.newToday': 'Mới hôm nay',
|
||||||
|
|
||||||
|
'validation.username': 'Tài khoản không được để trống!',
|
||||||
|
'validation.password': 'Mật khẩu không được để trống!',
|
||||||
|
};
|
||||||
12
src/models/global.ts
Normal file
12
src/models/global.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { DEFAULT_NAME } from '@/constants';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const useUser = () => {
|
||||||
|
const [name, setName] = useState<string>(DEFAULT_NAME);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUser;
|
||||||
169
src/pages/Auth/index.tsx
Normal file
169
src/pages/Auth/index.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import Footer from '@/components/Footer/Footer';
|
||||||
|
import { ROUTE_DASHBOARD } from '@/constants';
|
||||||
|
import { login } from '@/services/controller/AuthController';
|
||||||
|
import { parseJwt } from '@/utils/jwtTokenUtils';
|
||||||
|
import { getToken, removeToken, setToken } from '@/utils/localStorageUtils';
|
||||||
|
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
|
||||||
|
import { history, useIntl } from '@umijs/max';
|
||||||
|
import { message, theme } from 'antd';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const LoginPage = () => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const urlParams = new URL(window.location.href).searchParams;
|
||||||
|
const redirect = urlParams.get('redirect');
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const checkLogin = () => {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parseJwt(token);
|
||||||
|
const { exp } = parsed;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const oneHour = 60 * 60;
|
||||||
|
if (exp - now < oneHour) {
|
||||||
|
removeToken();
|
||||||
|
} else {
|
||||||
|
if (redirect) {
|
||||||
|
history.push(redirect);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTE_DASHBOARD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkLogin();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = async (values: API.LoginRequestBody) => {
|
||||||
|
try {
|
||||||
|
const resp = await login(values);
|
||||||
|
if (resp?.code === 200 && resp?.data?.token) {
|
||||||
|
message.success(
|
||||||
|
resp?.message ||
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'common.loginSuccess',
|
||||||
|
defaultMessage: 'Đăng nhập thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setToken(resp.data.token);
|
||||||
|
if (redirect) {
|
||||||
|
history.push(redirect);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTE_DASHBOARD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
const apiMessage = error?.response?.data?.message;
|
||||||
|
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
message.error(apiMessage || `Lỗi ${status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.error(
|
||||||
|
apiMessage ||
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'common.loginFail',
|
||||||
|
defaultMessage: 'Đăng nhập thất bại',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.error('Login error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LoginFormPage
|
||||||
|
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
|
||||||
|
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
|
||||||
|
title={
|
||||||
|
<span style={{ color: token.colorBgContainer }}>
|
||||||
|
SMT Production Management
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
containerStyle={{
|
||||||
|
backgroundColor: 'rgba(0, 0, 0,0.65)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
submitter={{
|
||||||
|
searchConfig: {
|
||||||
|
submitText: intl.formatMessage({
|
||||||
|
id: 'common.login',
|
||||||
|
defaultMessage: 'Đăng nhập',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onFinish={async (values: API.LoginRequestBody) => handleLogin(values)}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<ProFormText
|
||||||
|
name="identifier"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
}}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'common.username',
|
||||||
|
defaultMessage: 'Tài khoản',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'validation.username',
|
||||||
|
defaultMessage: 'Tài khoản không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="password"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
}}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'common.password',
|
||||||
|
defaultMessage: 'Mật khẩu',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'validation.password',
|
||||||
|
defaultMessage: 'Mật khẩu không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</LoginFormPage>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 99,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
78
src/pages/Dashboard/index.tsx
Normal file
78
src/pages/Dashboard/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Card, Col, Row, Statistic, Typography } from 'antd';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
const DashboardPage = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
header={{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'menu.dashboard',
|
||||||
|
defaultMessage: 'Dashboard',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Title level={4}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'dashboard.welcome',
|
||||||
|
defaultMessage: 'Welcome to Admin Panel',
|
||||||
|
})}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginTop: 24 }}>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'dashboard.totalUsers',
|
||||||
|
defaultMessage: 'Total Users',
|
||||||
|
})}
|
||||||
|
value={0}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'dashboard.activeUsers',
|
||||||
|
defaultMessage: 'Active Users',
|
||||||
|
})}
|
||||||
|
value={0}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'dashboard.totalRecords',
|
||||||
|
defaultMessage: 'Total Records',
|
||||||
|
})}
|
||||||
|
value={0}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'dashboard.newToday',
|
||||||
|
defaultMessage: 'New Today',
|
||||||
|
})}
|
||||||
|
value={0}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
12
src/services/controller/AuthController.ts
Normal file
12
src/services/controller/AuthController.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { API_PATH_LOGIN } from '@/constants';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
export async function login(body: API.LoginRequestBody) {
|
||||||
|
// console.log('Login request body:', body);
|
||||||
|
|
||||||
|
return request<API.LoginResponse>(API_PATH_LOGIN, {
|
||||||
|
method: 'POST',
|
||||||
|
data: body,
|
||||||
|
skipErrorHandler: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
5
src/services/controller/index.ts
Normal file
5
src/services/controller/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import * as AuthController from './AuthController';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
AuthController,
|
||||||
|
};
|
||||||
31
src/services/controller/typings.d.ts
vendored
Normal file
31
src/services/controller/typings.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
declare namespace API {
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
status: string;
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginRequestBody {
|
||||||
|
identifier: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
workerCode: string;
|
||||||
|
fullName: string;
|
||||||
|
type: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
disable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginData {
|
||||||
|
token: string;
|
||||||
|
user: UserInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse = ApiResponse<LoginData>;
|
||||||
|
}
|
||||||
4
src/typings.d.ts
vendored
Normal file
4
src/typings.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.png' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
30
src/utils/cacheStore.ts
Normal file
30
src/utils/cacheStore.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// src/utils/cacheStore.ts
|
||||||
|
interface CacheItem<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL = 60 * 1000; // 60 seconds default cache TTL
|
||||||
|
const cache: Record<string, CacheItem<any>> = {};
|
||||||
|
|
||||||
|
export function getCache<T>(key: string): T | null {
|
||||||
|
const item = cache[key];
|
||||||
|
if (!item) return null;
|
||||||
|
if (Date.now() - item.timestamp > TTL) {
|
||||||
|
delete cache[key];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return item.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCache<T>(key: string, data: T) {
|
||||||
|
cache[key] = { data, timestamp: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidate(key: string) {
|
||||||
|
delete cache[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllCacheKeys(): string[] {
|
||||||
|
return Object.keys(cache);
|
||||||
|
}
|
||||||
24
src/utils/eventBus.ts
Normal file
24
src/utils/eventBus.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// src/utils/eventBus.ts
|
||||||
|
type EventHandler<T = any> = (data: T) => void;
|
||||||
|
|
||||||
|
class EventBus {
|
||||||
|
private listeners: Record<string, EventHandler[]> = {};
|
||||||
|
|
||||||
|
on(event: string, handler: EventHandler) {
|
||||||
|
if (!this.listeners[event]) this.listeners[event] = [];
|
||||||
|
this.listeners[event].push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, handler: EventHandler) {
|
||||||
|
this.listeners[event] = (this.listeners[event] || []).filter(
|
||||||
|
(h) => h !== handler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, data?: any) {
|
||||||
|
(this.listeners[event] || []).forEach((h) => h(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ phải có dòng này
|
||||||
|
export const eventBus = new EventBus();
|
||||||
21
src/utils/format.ts
Normal file
21
src/utils/format.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// 示例方法,没有实际意义
|
||||||
|
export function trim(str: string) {
|
||||||
|
return str.trim();
|
||||||
|
}
|
||||||
|
export const DURATION_POLLING = 15000; //15s
|
||||||
|
export const DURATION_CHART = 300000; //5m
|
||||||
|
export const DEFAULT_LIMIT = 200;
|
||||||
|
export const DATE_TIME_FORMAT = 'DD/MM/YY HH:mm:ss';
|
||||||
|
export const TIME_FORMAT = 'HH:mm:ss';
|
||||||
|
export const DATE_FORMAT = 'DD/MM/YYYY';
|
||||||
|
|
||||||
|
export const DURATION_DISCONNECTED = 300; //senconds
|
||||||
|
export const DURATION_POLLING_PRESENTATIONS = 120000; //miliseconds
|
||||||
|
export const DURATION_POLLING_CHART = 60000; //miliseconds
|
||||||
|
export const WAIT_DURATION = 1500;
|
||||||
|
export const WAIT_ACK_DURATION = 5000;
|
||||||
|
|
||||||
|
export const STATUS_NORMAL = 0;
|
||||||
|
export const STATUS_WARNING = 1;
|
||||||
|
export const STATUS_DANGEROUS = 2;
|
||||||
|
export const STATUS_SOS = 3;
|
||||||
13
src/utils/jwtTokenUtils.ts
Normal file
13
src/utils/jwtTokenUtils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function parseJwt(token: string) {
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const base64Url = token.split('.')[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const jsonPayload = decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split('')
|
||||||
|
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join(''),
|
||||||
|
);
|
||||||
|
return JSON.parse(jsonPayload);
|
||||||
|
}
|
||||||
13
src/utils/localStorageUtils.ts
Normal file
13
src/utils/localStorageUtils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { TOKEN } from '@/constants';
|
||||||
|
|
||||||
|
export function getToken(): string {
|
||||||
|
return localStorage.getItem(TOKEN) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string) {
|
||||||
|
localStorage.setItem(TOKEN, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeToken() {
|
||||||
|
localStorage.removeItem(TOKEN);
|
||||||
|
}
|
||||||
7
tailwind.config.js
Normal file
7
tailwind.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.tsx',
|
||||||
|
'./src/components/**/*.tsx',
|
||||||
|
'./src/layouts/**/*.tsx',
|
||||||
|
],
|
||||||
|
}
|
||||||
3
tailwind.css
Normal file
3
tailwind.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "./src/.umi/tsconfig.json",
|
||||||
|
// "compilerOptions": {
|
||||||
|
// "target": "es2017",
|
||||||
|
// "lib": ["dom", "es2017"],
|
||||||
|
// "module": "esnext",
|
||||||
|
// "moduleResolution": "node",
|
||||||
|
// "jsx": "react-jsx",
|
||||||
|
// "esModuleInterop": true,
|
||||||
|
// "skipLibCheck": true,
|
||||||
|
// "strict": true,
|
||||||
|
// "forceConsistentCasingInFileNames": true,
|
||||||
|
// },
|
||||||
|
// "include": ["src"]
|
||||||
|
}
|
||||||
6
typings.d.ts
vendored
Normal file
6
typings.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import '@umijs/max/typings';
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user