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