first commit

This commit is contained in:
2026-05-05 22:55:31 +07:00
commit c2c7f91e88
48 changed files with 38159 additions and 0 deletions

3
.eslintrc.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
extends: require.resolve('@umijs/max/eslint'),
};

15
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1 @@
npx --no-install max verify-commit $1

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx --no-install --quiet

17
.lintstagedrc Normal file
View 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"
]
}

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
registry=https://registry.npmjs.com/

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.umi
.umi-production

8
.prettierrc Normal file
View 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
View File

@@ -0,0 +1,3 @@
module.exports = {
extends: require.resolve('@umijs/max/stylelint'),
};

47
.umirc.ts Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

6
src/access.ts Normal file
View File

@@ -0,0 +1,6 @@
export default (initialState: API.UserInfo) => {
const canSeeAdmin = !!(initialState && initialState.role === 'admin');
return {
canSeeAdmin,
};
};

135
src/app.tsx Normal file
View 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
View File

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

View 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 />;

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

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

View File

@@ -0,0 +1,3 @@
.ant-pro-global-footer-copyright {
color: white !important; /* hoặc mã màu bạn muốn */
}

View File

@@ -0,0 +1,4 @@
.title {
margin: 0 auto;
font-weight: 200;
}

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

View File

@@ -0,0 +1,2 @@
import Guide from './Guide';
export default Guide;

5
src/constants/enums.ts Normal file
View 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
View 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';

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

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

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

View File

@@ -0,0 +1,5 @@
import * as AuthController from './AuthController';
export default {
AuthController,
};

31
src/services/controller/typings.d.ts vendored Normal file
View 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
View File

@@ -0,0 +1,4 @@
declare module '*.png' {
const src: string;
export default src;
}

30
src/utils/cacheStore.ts Normal file
View 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
View 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
View 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;

View 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);
}

View 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
View File

@@ -0,0 +1,7 @@
module.exports = {
content: [
'./src/pages/**/*.tsx',
'./src/components/**/*.tsx',
'./src/layouts/**/*.tsx',
],
}

3
tailwind.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

15
tsconfig.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
import '@umijs/max/typings';
declare module '*.png' {
const src: string;
export default src;
}