feat(project): base smatec's frontend
This commit is contained in:
131
src/access.ts
Normal file
131
src/access.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { InitialStateResponse } from './app';
|
||||
import { SGW_ROLE } from './constants/slave/sgw';
|
||||
|
||||
export default (initialState: InitialStateResponse) => {
|
||||
// Lấy role của user hiện tại
|
||||
const userType = initialState?.currentUserProfile?.metadata?.user_type;
|
||||
|
||||
// Lấy biến môi trường domain
|
||||
const env = process.env.DOMAIN_ENV;
|
||||
|
||||
// Khởi tạo object quyền mặc định (tất cả là false)
|
||||
let permissions = {
|
||||
canAdmin: false,
|
||||
canAdmin_SysAdmin: false,
|
||||
canEndUser_Admin: false,
|
||||
canEndUser_User_Admin: false,
|
||||
canEndUser_User: false,
|
||||
canEndUser: false,
|
||||
canAll: true,
|
||||
|
||||
// Cờ đánh dấu Domain hiện tại
|
||||
isSGW: env === 'sgw' || !env,
|
||||
isGMS: env === 'gms',
|
||||
isSpole: env === 'spole',
|
||||
};
|
||||
|
||||
// Switch case để xử lý logic quyền riêng cho từng Domain
|
||||
switch (env) {
|
||||
case 'gms':
|
||||
if (userType) {
|
||||
permissions.canAdmin = userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canAdmin_SysAdmin =
|
||||
userType === SGW_ROLE.SYSADMIN || userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canEndUser_Admin =
|
||||
userType === SGW_ROLE.ENDUSER || userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canEndUser_User_Admin = [
|
||||
SGW_ROLE.ENDUSER,
|
||||
SGW_ROLE.USERS,
|
||||
SGW_ROLE.ADMIN,
|
||||
].includes(userType as SGW_ROLE);
|
||||
|
||||
permissions.canEndUser_User = [
|
||||
SGW_ROLE.ENDUSER,
|
||||
SGW_ROLE.USERS,
|
||||
].includes(userType as SGW_ROLE);
|
||||
|
||||
permissions.canEndUser = userType === SGW_ROLE.ENDUSER;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'spole':
|
||||
if (userType) {
|
||||
permissions.canAdmin = userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canAdmin_SysAdmin =
|
||||
userType === SGW_ROLE.SYSADMIN || userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canEndUser_Admin =
|
||||
userType === SGW_ROLE.ENDUSER || userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canEndUser_User_Admin = [
|
||||
SGW_ROLE.ENDUSER,
|
||||
SGW_ROLE.USERS,
|
||||
SGW_ROLE.ADMIN,
|
||||
].includes(userType as SGW_ROLE);
|
||||
|
||||
permissions.canEndUser_User = [
|
||||
SGW_ROLE.ENDUSER,
|
||||
SGW_ROLE.USERS,
|
||||
].includes(userType as SGW_ROLE);
|
||||
|
||||
permissions.canEndUser = userType === SGW_ROLE.ENDUSER;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sgw':
|
||||
if (userType) {
|
||||
permissions.canAdmin = userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canAdmin_SysAdmin =
|
||||
userType === SGW_ROLE.SYSADMIN || userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canEndUser_Admin =
|
||||
userType === SGW_ROLE.ENDUSER || userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canEndUser_User_Admin = [
|
||||
SGW_ROLE.ENDUSER,
|
||||
SGW_ROLE.USERS,
|
||||
SGW_ROLE.ADMIN,
|
||||
].includes(userType as SGW_ROLE);
|
||||
|
||||
permissions.canEndUser_User = [
|
||||
SGW_ROLE.ENDUSER,
|
||||
SGW_ROLE.USERS,
|
||||
].includes(userType as SGW_ROLE);
|
||||
|
||||
permissions.canEndUser = userType === SGW_ROLE.ENDUSER;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Logic phân quyền cho SGW
|
||||
if (userType) {
|
||||
permissions.canAdmin = userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canAdmin_SysAdmin =
|
||||
userType === SGW_ROLE.SYSADMIN || userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canEndUser_Admin =
|
||||
userType === SGW_ROLE.ENDUSER || userType === SGW_ROLE.ADMIN;
|
||||
|
||||
permissions.canEndUser_User_Admin = [
|
||||
SGW_ROLE.ENDUSER,
|
||||
SGW_ROLE.USERS,
|
||||
SGW_ROLE.ADMIN,
|
||||
].includes(userType as SGW_ROLE);
|
||||
|
||||
permissions.canEndUser_User = [
|
||||
SGW_ROLE.ENDUSER,
|
||||
SGW_ROLE.USERS,
|
||||
].includes(userType as SGW_ROLE);
|
||||
|
||||
permissions.canEndUser = userType === SGW_ROLE.ENDUSER;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return permissions;
|
||||
};
|
||||
153
src/app.tsx
Normal file
153
src/app.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
// 运行时配置
|
||||
|
||||
import { getLocale, history, Link, RunTimeLayoutConfig } from '@umijs/max';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { handleRequestConfig as devRequestConfig } from '../config/request_dev';
|
||||
import { handleRequestConfig as prodRequestConfig } from '../config/request_prod';
|
||||
import { AvatarDropdown } from './components/Avatar/AvatarDropdown';
|
||||
import IconFont from './components/IconFont';
|
||||
import LanguageSwitcher from './components/Lang/LanguageSwitcher';
|
||||
import ThemeProvider from './components/Theme/ThemeProvider';
|
||||
import ThemeSwitcher from './components/Theme/ThemeSwitcher';
|
||||
import { THEME_KEY } from './constants';
|
||||
import { ROUTE_LOGIN } from './constants/routes';
|
||||
import NotFoundPage from './pages/Exception/NotFound';
|
||||
import UnAccessPage from './pages/Exception/UnAccess';
|
||||
import { apiQueryProfile } from './services/master/AuthController';
|
||||
import { checkTokenExpired } from './utils/jwt';
|
||||
import { getLogoImage } from './utils/logo';
|
||||
import {
|
||||
clearAllData,
|
||||
clearSessionData,
|
||||
getToken,
|
||||
removeToken,
|
||||
} from './utils/storage';
|
||||
const isProdBuild = process.env.NODE_ENV === 'production';
|
||||
export type InitialStateResponse = {
|
||||
getUserProfile?: () => Promise<MasterModel.ProfileResponse | undefined>;
|
||||
currentUserProfile?: MasterModel.ProfileResponse;
|
||||
theme?: 'light' | 'dark';
|
||||
};
|
||||
|
||||
// 全局初始化数据配置,用于 Layout 用户信息和权限初始化
|
||||
// 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate
|
||||
export async function getInitialState(): Promise<InitialStateResponse> {
|
||||
const userToken: string = getToken();
|
||||
const { pathname } = history.location;
|
||||
|
||||
dayjs.locale(getLocale() === 'en-US' ? 'en' : 'vi');
|
||||
if (!userToken) {
|
||||
if (pathname !== ROUTE_LOGIN) {
|
||||
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
|
||||
} else {
|
||||
history.push(ROUTE_LOGIN);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const isTokenExpried = checkTokenExpired(userToken);
|
||||
if (isTokenExpried) {
|
||||
removeToken();
|
||||
clearAllData();
|
||||
clearSessionData();
|
||||
window.location.href = ROUTE_LOGIN;
|
||||
return {};
|
||||
}
|
||||
|
||||
const getUserProfile = async () => {
|
||||
try {
|
||||
const resp = await apiQueryProfile();
|
||||
return resp;
|
||||
} catch (error) {
|
||||
removeToken();
|
||||
clearAllData();
|
||||
clearSessionData();
|
||||
window.location.href = ROUTE_LOGIN;
|
||||
}
|
||||
};
|
||||
const resp = await getUserProfile();
|
||||
const currentTheme =
|
||||
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
|
||||
return {
|
||||
getUserProfile: getUserProfile!,
|
||||
currentUserProfile: resp,
|
||||
theme: currentTheme,
|
||||
};
|
||||
}
|
||||
|
||||
export const layout: RunTimeLayoutConfig = ({ initialState }) => {
|
||||
const isDark = initialState?.theme === 'dark';
|
||||
return {
|
||||
logo: getLogoImage(),
|
||||
menu: {
|
||||
locale: true,
|
||||
},
|
||||
fixedHeader: true,
|
||||
contentWidth: 'Fluid',
|
||||
navTheme: isDark ? 'realDark' : 'light',
|
||||
splitMenus: true,
|
||||
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_y5mqyqd86f.js',
|
||||
contentStyle: {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
paddingInline: 0,
|
||||
},
|
||||
actionsRender: () => [
|
||||
<ThemeSwitcher key="theme-switcher" />,
|
||||
<LanguageSwitcher key="lang-switcher" type="dropdown" />,
|
||||
],
|
||||
avatarProps: {
|
||||
size: 'small',
|
||||
src: '/avatar.svg',
|
||||
render: () => (
|
||||
<AvatarDropdown currentUserProfile={initialState?.currentUserProfile} />
|
||||
),
|
||||
},
|
||||
childrenRender: (children) => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Collapse: {
|
||||
contentPadding: '0px',
|
||||
borderlessContentPadding: '0px',
|
||||
headerPadding: '0px',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
},
|
||||
layout: 'top',
|
||||
menuHeaderRender: undefined,
|
||||
menuItemRender: (item, dom) => {
|
||||
if (item.path) {
|
||||
// Coerce values to string to satisfy TypeScript expectations
|
||||
const to = String(item.path ?? '');
|
||||
const iconType = String(item.icon ?? '');
|
||||
const label = String(item.name ?? '');
|
||||
return (
|
||||
<Link to={to}>
|
||||
<IconFont type={iconType} />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return dom;
|
||||
},
|
||||
token: {
|
||||
header: {
|
||||
colorBgMenuItemSelected: '#EEF7FF', // background khi chọn
|
||||
colorTextMenuSelected: isDark ? '#fff' : '#1A2130', // màu chữ khi chọn
|
||||
},
|
||||
},
|
||||
unAccessible: <UnAccessPage />,
|
||||
noFound: <NotFoundPage />,
|
||||
};
|
||||
};
|
||||
|
||||
export const request = isProdBuild ? prodRequestConfig : devRequestConfig;
|
||||
0
src/assets/.gitkeep
Normal file
0
src/assets/.gitkeep
Normal file
61
src/components/Avatar/AvatarDropdown.tsx
Normal file
61
src/components/Avatar/AvatarDropdown.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ROUTE_LOGIN, ROUTE_PROFILE } from '@/constants/routes';
|
||||
import { clearAllData, clearSessionData, removeToken } from '@/utils/storage';
|
||||
import { LogoutOutlined, ProfileOutlined } from '@ant-design/icons';
|
||||
import { history, useIntl } from '@umijs/max';
|
||||
|
||||
import { Dropdown } from 'antd';
|
||||
|
||||
// Avatar component with i18n support
|
||||
export const AvatarDropdown = ({
|
||||
currentUserProfile,
|
||||
}: {
|
||||
currentUserProfile?: MasterModel.ProfileResponse;
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: ROUTE_PROFILE,
|
||||
icon: <ProfileOutlined />,
|
||||
label: intl.formatMessage({ id: 'menu.profile' }),
|
||||
onClick: () => {
|
||||
history.push(ROUTE_PROFILE);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: intl.formatMessage({ id: 'common.logout' }),
|
||||
onClick: () => {
|
||||
removeToken();
|
||||
clearAllData();
|
||||
clearSessionData();
|
||||
window.location.href = ROUTE_LOGIN;
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<img
|
||||
src="/avatar.svg"
|
||||
alt="avatar"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<span className="font-bold">
|
||||
{currentUserProfile?.metadata?.full_name || ''}
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
47
src/components/Footer/index.tsx
Normal file
47
src/components/Footer/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DefaultFooter } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import packageJson from '../../../package.json';
|
||||
import './style.less';
|
||||
const Footer = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const defaultMessage = intl.formatMessage({
|
||||
id: 'app.copyright.produced',
|
||||
defaultMessage: 'by SMATEC Team',
|
||||
});
|
||||
const getDomainVersion = () => {
|
||||
switch (process.env.DOMAIN_ENV) {
|
||||
case 'gms':
|
||||
return packageJson['gms-version'];
|
||||
case 'sgw':
|
||||
return packageJson['sgw-version'];
|
||||
case 'spole':
|
||||
return packageJson['spole-version'];
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<DefaultFooter
|
||||
style={{
|
||||
background: 'none',
|
||||
}}
|
||||
copyright={`${currentYear} ${defaultMessage} v${getDomainVersion()}`}
|
||||
links={
|
||||
[
|
||||
// {
|
||||
// key: 'smatec',
|
||||
// title: 'Smatec JSC',
|
||||
// href: 'https://smatec.com.vn',
|
||||
// blankTarget: true,
|
||||
// }
|
||||
]
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
7
src/components/IconFont/index.tsx
Normal file
7
src/components/IconFont/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createFromIconfontCN } from '@ant-design/icons';
|
||||
|
||||
const IconFont = createFromIconfontCN({
|
||||
scriptUrl: '//at.alicdn.com/t/c/font_5096559_y5mqyqd86f.js',
|
||||
});
|
||||
|
||||
export default IconFont;
|
||||
93
src/components/Lang/LanguageSwitcher.tsx
Normal file
93
src/components/Lang/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { getLocale, setLocale, useIntl } from '@umijs/max';
|
||||
import { Button, Dropdown, Space } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
export interface LanguageSwitcherProps {
|
||||
className?: string;
|
||||
type?: 'dropdown' | 'button';
|
||||
size?: 'small' | 'middle' | 'large';
|
||||
}
|
||||
|
||||
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({
|
||||
className,
|
||||
type = 'dropdown',
|
||||
size = 'middle',
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const languageItems = [
|
||||
{
|
||||
key: 'vi-VN',
|
||||
label: intl.formatMessage({ id: 'common.vietnamese' }),
|
||||
flag: '🇻🇳',
|
||||
},
|
||||
{
|
||||
key: 'en-US',
|
||||
label: intl.formatMessage({ id: 'common.english' }),
|
||||
flag: '🇺🇸',
|
||||
},
|
||||
];
|
||||
|
||||
const handleLanguageChange = (locale: string) => {
|
||||
setLocale(locale, false);
|
||||
};
|
||||
|
||||
const getCurrentLanguage = () => {
|
||||
const currentLocale = intl.locale || getLocale() || 'vi-VN';
|
||||
return (
|
||||
languageItems.find((item) => item.key === currentLocale) ||
|
||||
languageItems[0]
|
||||
);
|
||||
};
|
||||
|
||||
const dropdownItems = languageItems.map((item) => ({
|
||||
key: item.key,
|
||||
label: (
|
||||
<Space>
|
||||
<span>{item.flag}</span>
|
||||
<span>{item.label}</span>
|
||||
</Space>
|
||||
),
|
||||
onClick: () => handleLanguageChange(item.key),
|
||||
}));
|
||||
|
||||
if (type === 'button') {
|
||||
const currentLang = getCurrentLanguage();
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
onClick={() => {
|
||||
const nextLocale = currentLang.key === 'vi-VN' ? 'en-US' : 'vi-VN';
|
||||
handleLanguageChange(nextLocale);
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
<Space>
|
||||
<span>{currentLang.flag}</span>
|
||||
<span>{currentLang.label}</span>
|
||||
</Space>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const currentLang = getCurrentLanguage();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{ items: dropdownItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
className={className}
|
||||
>
|
||||
<Button size={size} type="text">
|
||||
<Space>
|
||||
<span>{currentLang.flag}</span>
|
||||
<span>{currentLang.label}</span>
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
43
src/components/Lang/LanguageSwitcherAuth.tsx
Normal file
43
src/components/Lang/LanguageSwitcherAuth.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { getLocale, setLocale } from '@umijs/max';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const LangSwitches = () => {
|
||||
const [isEnglish, setIsEnglish] = useState(false);
|
||||
|
||||
// Keep checkbox in sync with current Umi locale without forcing a reload
|
||||
useEffect(() => {
|
||||
const syncLocale = () => setIsEnglish(getLocale() === 'en-US');
|
||||
|
||||
syncLocale();
|
||||
window.addEventListener('languagechange', syncLocale);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('languagechange', syncLocale);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLanguageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newLocale = e.target.checked ? 'en-US' : 'vi-VN';
|
||||
setIsEnglish(e.target.checked);
|
||||
setLocale(newLocale, false); // Update locale instantly without full page reload
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
className="sr-only peer"
|
||||
type="checkbox"
|
||||
checked={isEnglish}
|
||||
onChange={handleLanguageChange}
|
||||
/>
|
||||
<div
|
||||
className="w-[70px] h-9 rounded-full bg-gradient-to-r from-orange-400 to-red-400 peer-checked:from-blue-400 peer-checked:to-indigo-500
|
||||
transition-all duration-500 after:content-['🇻🇳'] after:absolute after:top-1 peer-checked:left-0 after:left-1 peer-checked:after:left-[-8px] after:bg-white after:rounded-full after:h-7
|
||||
after:w-7 after:flex after:items-center after:justify-center after:transition-all after:duration-500 peer-checked:after:translate-x-10
|
||||
peer-checked:after:content-['🏴'] after:shadow-md after:text-2xl"
|
||||
></div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default LangSwitches;
|
||||
37
src/components/Theme/ThemeProvider.tsx
Normal file
37
src/components/Theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ConfigProvider, theme } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getTheme } from './ThemeSwitcher';
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const [isDark, setIsDark] = useState(getTheme() === 'dark');
|
||||
|
||||
useEffect(() => {
|
||||
const handleThemeChange = (e: CustomEvent) => {
|
||||
setIsDark(e.detail.theme === 'dark');
|
||||
};
|
||||
|
||||
window.addEventListener('theme-change', handleThemeChange as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'theme-change',
|
||||
handleThemeChange as EventListener,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeProvider;
|
||||
81
src/components/Theme/ThemeSwitcher.tsx
Normal file
81
src/components/Theme/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { THEME_KEY } from '@/constants';
|
||||
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Button, Dropdown } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export interface ThemeSwitcherProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => {
|
||||
const { initialState, setInitialState } = useModel('@@initialState');
|
||||
const intl = useIntl();
|
||||
const [isDark, setIsDark] = useState(
|
||||
(initialState?.theme as 'light' | 'dark') === 'dark',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleThemeChange = (e: CustomEvent) => {
|
||||
setIsDark(e.detail.theme === 'dark');
|
||||
};
|
||||
|
||||
window.addEventListener('theme-change', handleThemeChange as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'theme-change',
|
||||
handleThemeChange as EventListener,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleThemeChange = (newTheme: 'light' | 'dark') => {
|
||||
localStorage.setItem(THEME_KEY, newTheme);
|
||||
// Update global state để trigger layout re-render
|
||||
setInitialState({
|
||||
...initialState,
|
||||
theme: newTheme,
|
||||
} as any).then(() => {
|
||||
// Dispatch event để notify ThemeProvider
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'light',
|
||||
label: intl.formatMessage({
|
||||
id: 'common.theme.light',
|
||||
defaultMessage: 'Light Theme',
|
||||
}),
|
||||
icon: <SunOutlined />,
|
||||
onClick: () => handleThemeChange('light'),
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
label: intl.formatMessage({
|
||||
id: 'common.theme.dark',
|
||||
defaultMessage: 'Dark Theme',
|
||||
}),
|
||||
icon: <MoonOutlined />,
|
||||
onClick: () => handleThemeChange('dark'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items }} placement="bottomRight" trigger={['click']}>
|
||||
<Button type="text" className={className}>
|
||||
{isDark ? <MoonOutlined /> : <SunOutlined />}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwitcher;
|
||||
|
||||
// Helper function để get theme từ localStorage
|
||||
export const getTheme = (): 'light' | 'dark' => {
|
||||
return (localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
|
||||
};
|
||||
69
src/components/Theme/ThemeSwitcherAuth.tsx
Normal file
69
src/components/Theme/ThemeSwitcherAuth.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { THEME_KEY } from '@/constants';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getTheme } from './ThemeSwitcher';
|
||||
import './style.less';
|
||||
|
||||
const ThemeSwitcherAuth = () => {
|
||||
const [isDark, setIsDark] = useState(getTheme() === 'dark');
|
||||
|
||||
useEffect(() => {
|
||||
const handleThemeChange = (e: CustomEvent) => {
|
||||
setIsDark(e.detail.theme === 'dark');
|
||||
};
|
||||
window.addEventListener('theme-change', handleThemeChange as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'theme-change',
|
||||
handleThemeChange as EventListener,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSwitch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTheme = e.target.checked ? 'dark' : 'light';
|
||||
localStorage.setItem(THEME_KEY, newTheme);
|
||||
setIsDark(newTheme === 'dark');
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="theme-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="theme-switch-checkbox"
|
||||
checked={isDark}
|
||||
onChange={handleSwitch}
|
||||
/>
|
||||
<div className="theme-switch-container">
|
||||
<div className="theme-switch-clouds"></div>
|
||||
<div className="theme-switch-stars-container">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 144 55"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M135.831 3.00688C135.055 3.85027 134.111 4.29946 133 4.35447C134.111 4.40947 135.055 4.85867 135.831 5.71123C136.607 6.55462 136.996 7.56303 136.996 8.72727C136.996 7.95722 137.172 7.25134 137.525 6.59129C137.886 5.93124 138.372 5.39954 138.98 5.00535C139.598 4.60199 140.268 4.39114 141 4.35447C139.88 4.2903 138.936 3.85027 138.16 3.00688C137.384 2.16348 136.996 1.16425 136.996 0C136.996 1.16425 136.607 2.16348 135.831 3.00688ZM31 23.3545C32.1114 23.2995 33.0551 22.8503 33.8313 22.0069C34.6075 21.1635 34.9956 20.1642 34.9956 19C34.9956 20.1642 35.3837 21.1635 36.1599 22.0069C36.9361 22.8503 37.8798 23.2903 39 23.3545C38.2679 23.3911 37.5976 23.602 36.9802 24.0053C36.3716 24.3995 35.8864 24.9312 35.5248 25.5913C35.172 26.2513 34.9956 26.9572 34.9956 27.7273C34.9956 26.563 34.6075 25.5546 33.8313 24.7112C33.0551 23.8587 32.1114 23.4095 31 23.3545ZM0 36.3545C1.11136 36.2995 2.05513 35.8503 2.83131 35.0069C3.6075 34.1635 3.99559 33.1642 3.99559 32C3.99559 33.1642 4.38368 34.1635 5.15987 35.0069C5.93605 35.8503 6.87982 36.2903 8 36.3545C7.26792 36.3911 6.59757 36.602 5.98015 37.0053C5.37155 37.3995 4.88644 37.9312 4.52481 38.5913C4.172 39.2513 3.99559 39.9572 3.99559 40.7273C3.99559 39.563 3.6075 38.5546 2.83131 37.7112C2.05513 36.8587 1.11136 36.4095 0 36.3545ZM56.8313 24.0069C56.0551 24.8503 55.1114 25.2995 54 25.3545C55.1114 25.4095 56.0551 25.8587 56.8313 26.7112C57.6075 27.5546 57.9956 28.563 57.9956 29.7273C57.9956 28.9572 58.172 28.2513 58.5248 27.5913C58.8864 26.9312 59.3716 26.3995 59.9802 26.0053C60.5976 25.602 61.2679 25.3911 62 25.3545C60.8798 25.2903 59.9361 24.8503 59.1599 24.0069C58.3837 23.1635 57.9956 22.1642 57.9956 21C57.9956 22.1642 57.6075 23.1635 56.8313 24.0069ZM81 25.3545C82.1114 25.2995 83.0551 24.8503 83.8313 24.0069C84.6075 23.1635 84.9956 22.1642 84.9956 21C84.9956 22.1642 85.3837 23.1635 86.1599 24.0069C86.9361 24.8503 87.8798 25.2903 89 25.3545C88.2679 25.3911 87.5976 25.602 86.9802 26.0053C86.3716 26.3995 85.8864 26.9312 85.5248 27.5913C85.172 28.2513 84.9956 28.9572 84.9956 29.7273C84.9956 28.563 84.6075 27.5546 83.8313 26.7112C83.0551 25.8587 82.1114 25.4095 81 25.3545ZM136 36.3545C137.111 36.2995 138.055 35.8503 138.831 35.0069C139.607 34.1635 139.996 33.1642 139.996 32C139.996 33.1642 140.384 34.1635 141.16 35.0069C141.936 35.8503 142.88 36.2903 144 36.3545C143.268 36.3911 142.598 36.602 141.98 37.0053C141.372 37.3995 140.886 37.9312 140.525 38.5913C140.172 39.2513 139.996 39.9572 139.996 40.7273C139.996 39.563 139.607 38.5546 138.831 37.7112C138.055 36.8587 137.111 36.4095 136 36.3545ZM101.831 49.0069C101.055 49.8503 100.111 50.2995 99 50.3545C100.111 50.4095 101.055 50.8587 101.831 51.7112C102.607 52.5546 102.996 53.563 102.996 54.7273C102.996 53.9572 103.172 53.2513 103.525 52.5913C103.886 51.9312 104.372 51.3995 104.98 51.0053C105.598 50.602 106.268 50.3911 107 50.3545C105.88 50.2903 104.936 49.8503 104.16 49.0069C103.384 48.1635 102.996 47.1642 102.996 46C102.996 47.1642 102.607 48.1635 101.831 49.0069Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="theme-switch-circle-container">
|
||||
<div className="theme-switch-sun-moon-container">
|
||||
<div className="theme-switch-moon">
|
||||
<div className="theme-switch-spot"></div>
|
||||
<div className="theme-switch-spot"></div>
|
||||
<div className="theme-switch-spot"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwitcherAuth;
|
||||
202
src/components/Theme/style.less
Normal file
202
src/components/Theme/style.less
Normal file
@@ -0,0 +1,202 @@
|
||||
/* From Uiverse.io by Galahhad */
|
||||
.theme-switch {
|
||||
--toggle-size: 20px;
|
||||
--container-width: 3.75em;
|
||||
--container-height: 1.7em;
|
||||
--container-radius: 3.5em;
|
||||
--container-light-bg: #3d7eae;
|
||||
--container-night-bg: #1d1f2c;
|
||||
--circle-container-diameter: 2.1em;
|
||||
--sun-moon-diameter: 1.3em;
|
||||
--sun-bg: #ecca2f;
|
||||
--moon-bg: #c4c9d1;
|
||||
--spot-color: #959db1;
|
||||
--circle-container-offset: calc(
|
||||
(var(--circle-container-diameter) - var(--container-height)) / 2 * -1
|
||||
);
|
||||
--stars-color: #fff;
|
||||
--clouds-color: #f3fdff;
|
||||
--back-clouds-color: #aacadf;
|
||||
--transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
|
||||
--circle-transition: 0.3s cubic-bezier(0, -0.02, 0.35, 1.17);
|
||||
}
|
||||
|
||||
.theme-switch,
|
||||
.theme-switch *,
|
||||
.theme-switch *::before,
|
||||
.theme-switch *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: var(--toggle-size);
|
||||
}
|
||||
|
||||
.theme-switch-container {
|
||||
width: var(--container-width);
|
||||
height: var(--container-height);
|
||||
background-color: var(--container-light-bg);
|
||||
border-radius: var(--container-radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 -0.062em 0.062em rgba(0, 0, 0, 25%),
|
||||
0 0.062em 0.125em rgba(255, 255, 255, 94%);
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-switch-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
box-shadow: 0 0.05em 0.187em rgba(0, 0, 0, 25%) inset;
|
||||
border-radius: var(--container-radius);
|
||||
}
|
||||
|
||||
.theme-switch-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-switch-circle-container {
|
||||
width: var(--circle-container-diameter);
|
||||
height: var(--circle-container-diameter);
|
||||
background-color: rgba(255, 255, 255, 10%);
|
||||
position: absolute;
|
||||
left: var(--circle-container-offset);
|
||||
top: var(--circle-container-offset);
|
||||
border-radius: var(--container-radius);
|
||||
box-shadow: inset 0 0 0 3.375em rgba(255, 255, 255, 10%),
|
||||
0 0 0 0.625em rgba(255, 255, 255, 10%),
|
||||
0 0 0 1.25em rgba(255, 255, 255, 10%);
|
||||
display: flex;
|
||||
transition: var(--circle-transition);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.theme-switch-sun-moon-container {
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: var(--sun-moon-diameter);
|
||||
height: var(--sun-moon-diameter);
|
||||
margin: auto;
|
||||
border-radius: var(--container-radius);
|
||||
background-color: var(--sun-bg);
|
||||
box-shadow: 0.062em 0.062em 0.062em 0 rgba(254, 255, 239, 61%) inset,
|
||||
0 -0.062em 0.062em 0 #a1872a inset;
|
||||
filter: drop-shadow(0.062em 0.125em 0.125em rgba(0, 0, 0, 25%))
|
||||
drop-shadow(0 0.062em 0.125em rgba(0, 0, 0, 25%));
|
||||
overflow: hidden;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.theme-switch-moon {
|
||||
transform: translateX(100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--moon-bg);
|
||||
border-radius: inherit;
|
||||
box-shadow: 0.062em 0.062em 0.062em 0 rgba(254, 255, 239, 61%) inset,
|
||||
0 -0.062em 0.062em 0 #969696 inset;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-switch-spot {
|
||||
position: absolute;
|
||||
top: 0.75em;
|
||||
left: 0.312em;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
border-radius: var(--container-radius);
|
||||
background-color: var(--spot-color);
|
||||
box-shadow: 0 0.0312em 0.062em rgba(0, 0, 0, 25%) inset;
|
||||
}
|
||||
|
||||
.theme-switch-spot:nth-of-type(2) {
|
||||
width: 0.375em;
|
||||
height: 0.375em;
|
||||
top: 0.937em;
|
||||
left: 1.375em;
|
||||
}
|
||||
|
||||
.theme-switch-spot:nth-last-of-type(3) {
|
||||
width: 0.25em;
|
||||
height: 0.25em;
|
||||
top: 0.312em;
|
||||
left: 0.812em;
|
||||
}
|
||||
|
||||
.theme-switch-clouds {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
background-color: var(--clouds-color);
|
||||
border-radius: var(--container-radius);
|
||||
position: absolute;
|
||||
bottom: -0.625em;
|
||||
left: 0.312em;
|
||||
box-shadow: 0.937em 0.312em var(--clouds-color),
|
||||
-0.312em -0.312em var(--back-clouds-color),
|
||||
1.437em 0.375em var(--clouds-color), 0.5em -0.125em var(--back-clouds-color),
|
||||
2.187em 0 var(--clouds-color), 1.25em -0.062em var(--back-clouds-color),
|
||||
2.937em 0.312em var(--clouds-color), 2em -0.312em var(--back-clouds-color),
|
||||
3.625em -0.062em var(--clouds-color), 2.625em 0 var(--back-clouds-color),
|
||||
4.5em -0.312em var(--clouds-color),
|
||||
3.375em -0.437em var(--back-clouds-color),
|
||||
4.625em -1.75em 0 0.437em var(--clouds-color),
|
||||
4em -0.625em var(--back-clouds-color),
|
||||
4.125em -2.125em 0 0.437em var(--back-clouds-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.theme-switch-stars-container {
|
||||
position: absolute;
|
||||
color: var(--stars-color);
|
||||
top: -100%;
|
||||
left: 0.312em;
|
||||
width: 2.75em;
|
||||
height: auto;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
/* actions */
|
||||
|
||||
.theme-switch-checkbox:checked + .theme-switch-container {
|
||||
background-color: var(--container-night-bg);
|
||||
}
|
||||
|
||||
.theme-switch-checkbox:checked
|
||||
+ .theme-switch-container
|
||||
.theme-switch-circle-container {
|
||||
left: calc(
|
||||
100% - var(--circle-container-offset) - var(--circle-container-diameter)
|
||||
);
|
||||
}
|
||||
|
||||
.theme-switch-checkbox:checked
|
||||
+ .theme-switch-container
|
||||
.theme-switch-circle-container:hover {
|
||||
left: calc(
|
||||
100% - var(--circle-container-offset) - var(--circle-container-diameter) -
|
||||
0.187em
|
||||
);
|
||||
}
|
||||
|
||||
.theme-switch-circle-container:hover {
|
||||
left: calc(var(--circle-container-offset) + 0.187em);
|
||||
}
|
||||
|
||||
.theme-switch-checkbox:checked + .theme-switch-container .theme-switch-moon {
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.theme-switch-checkbox:checked + .theme-switch-container .theme-switch-clouds {
|
||||
bottom: -4.062em;
|
||||
}
|
||||
|
||||
.theme-switch-checkbox:checked
|
||||
+ .theme-switch-container
|
||||
.theme-switch-stars-container {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
196
src/components/shared/ThingFilterModal.tsx
Normal file
196
src/components/shared/ThingFilterModal.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { DEFAULT_PAGE_SIZE, DURATION_POLLING } from '@/constants';
|
||||
import { apiSearchThings } from '@/services/master/ThingController';
|
||||
import {
|
||||
ActionType,
|
||||
ProCard,
|
||||
ProColumns,
|
||||
ProTable,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Grid, Modal, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import TreeGroup from './TreeGroup';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
type ThingsFilterProps = {
|
||||
isOpen?: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
thingIds?: string | string[] | null;
|
||||
onSubmit?: (thingIds: string[]) => void;
|
||||
};
|
||||
|
||||
const ThingsFilter = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
thingIds,
|
||||
onSubmit,
|
||||
}: ThingsFilterProps) => {
|
||||
const intl = useIntl();
|
||||
const { useBreakpoint } = Grid;
|
||||
const screens = useBreakpoint();
|
||||
const tableRef = useRef<ActionType>();
|
||||
const [groupsIds, setGroupIds] = useState<string | string[] | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const domain = process.env.DOMAIN_ENV || 'gms';
|
||||
useEffect(() => {
|
||||
if (thingIds) {
|
||||
const ids = Array.isArray(thingIds) ? thingIds : [thingIds];
|
||||
setSelectedRowKeys(ids);
|
||||
} else {
|
||||
setSelectedRowKeys([]);
|
||||
}
|
||||
}, [thingIds]);
|
||||
|
||||
const handleOk = () => {
|
||||
onSubmit?.(selectedRowKeys as string[]);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const columns: ProColumns<MasterModel.Thing>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: <FormattedMessage id="master.thing.name" defaultMessage="Name" />,
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
key: 'external_id',
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="master.thing.external_id"
|
||||
defaultMessage="External ID"
|
||||
/>
|
||||
),
|
||||
dataIndex: 'metadata.external_id',
|
||||
render: (_, record) =>
|
||||
record?.metadata?.external_id ? (
|
||||
<Paragraph
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
verticalAlign: 'middle',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
copyable
|
||||
>
|
||||
{record?.metadata?.external_id}
|
||||
</Paragraph>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'address',
|
||||
title: (
|
||||
<FormattedMessage id="master.thing.address" defaultMessage="Address" />
|
||||
),
|
||||
dataIndex: 'metadata.address',
|
||||
hideInSearch: true,
|
||||
render: (_, record) => record?.metadata?.address || '-',
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
centered
|
||||
width="80%"
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
maskClosable={false}
|
||||
okText={<FormattedMessage id="common.search" defaultMessage="Search" />}
|
||||
cancelText={
|
||||
<FormattedMessage id="common.cancel" defaultMessage="Cancel" />
|
||||
}
|
||||
>
|
||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||
<ProCard colSpan={{ xs: 24, sm: 8, md: 8, lg: 6, xl: 6 }}>
|
||||
<TreeGroup
|
||||
disable={isLoading}
|
||||
multiple
|
||||
onSelected={(value) => {
|
||||
setGroupIds(value);
|
||||
tableRef.current?.reload();
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
<ProCard colSpan={{ xs: 24, sm: 16, md: 16, lg: 18, xl: 18 }}>
|
||||
<ProTable<MasterModel.Thing>
|
||||
tableClassName="table-row-select cursor-pointer-row"
|
||||
actionRef={tableRef}
|
||||
toolbar={{
|
||||
title: null,
|
||||
}}
|
||||
bordered={true}
|
||||
polling={DURATION_POLLING * 4} // 1 minute
|
||||
rowSelection={{
|
||||
alwaysShowAlert: true,
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys),
|
||||
}}
|
||||
pagination={{
|
||||
size: 'small',
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '15', '20'],
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]} ${intl.formatMessage({
|
||||
id: 'common.paginations.of',
|
||||
})} ${total} ${intl.formatMessage({
|
||||
id: 'common.paginations.things',
|
||||
defaultMessage: 'things',
|
||||
})}`,
|
||||
}}
|
||||
columns={columns}
|
||||
request={async (params) => {
|
||||
setIsLoading(true);
|
||||
const { current = 1, pageSize, name, external_id } = params;
|
||||
const size = pageSize || DEFAULT_PAGE_SIZE * 2;
|
||||
const offset = current === 1 ? 0 : (current - 1) * size;
|
||||
const query: MasterModel.SearchThingPaginationBody = {
|
||||
offset: offset,
|
||||
limit: size,
|
||||
order: 'name',
|
||||
dir: 'asc',
|
||||
};
|
||||
if (name) {
|
||||
query.name = name;
|
||||
}
|
||||
if (!query.metadata) query.metadata = {};
|
||||
if (groupsIds)
|
||||
query.metadata.group_id = Array.isArray(groupsIds)
|
||||
? groupsIds.join(',')
|
||||
: groupsIds || undefined;
|
||||
|
||||
if (external_id) {
|
||||
query.metadata = {
|
||||
...query.metadata,
|
||||
external_id: external_id,
|
||||
};
|
||||
}
|
||||
const resp = await apiSearchThings(query, domain);
|
||||
setIsLoading(false);
|
||||
return {
|
||||
data: resp.things || [],
|
||||
success: true,
|
||||
total: resp.total || 0,
|
||||
};
|
||||
}}
|
||||
options={{
|
||||
search: false,
|
||||
setting: false,
|
||||
density: false,
|
||||
reload: true,
|
||||
}}
|
||||
defaultSize="small"
|
||||
rowKey="id"
|
||||
dateFormatter="string"
|
||||
/>
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThingsFilter;
|
||||
139
src/components/shared/TreeGroup.tsx
Normal file
139
src/components/shared/TreeGroup.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useModel } from '@umijs/max';
|
||||
import { Tree } from 'antd';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface TreeGroupProps {
|
||||
groupIds?: string | string[] | null;
|
||||
multiple?: boolean;
|
||||
allowedGroupIds?: string[];
|
||||
disable?: boolean;
|
||||
onSelected?: (value: string | string[] | null) => void;
|
||||
titleRender?: (nodeData: DataNode) => React.ReactNode;
|
||||
}
|
||||
|
||||
const TreeGroup = ({
|
||||
groupIds,
|
||||
multiple = false,
|
||||
allowedGroupIds,
|
||||
disable = false,
|
||||
onSelected,
|
||||
titleRender,
|
||||
}: TreeGroupProps) => {
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const { currentUserProfile } = initialState || {};
|
||||
const [treeData, setTreeData] = useState<DataNode[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const { groups, getGroups } = useModel('master.useGroups');
|
||||
|
||||
const filterGroupsByAllowedIds = (
|
||||
groups: MasterModel.GroupNode[],
|
||||
allowedIds: Set<string>,
|
||||
): MasterModel.GroupNode[] => {
|
||||
return groups
|
||||
.map((group) => {
|
||||
const filteredChildren = group.children
|
||||
? filterGroupsByAllowedIds(group.children, allowedIds)
|
||||
: [];
|
||||
|
||||
if (allowedIds.has(group.id) || filteredChildren.length > 0) {
|
||||
return {
|
||||
...group,
|
||||
children: filteredChildren,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as MasterModel.GroupNode[];
|
||||
};
|
||||
|
||||
const transformGroupNodeToTreeData = (
|
||||
groups: MasterModel.GroupNode[],
|
||||
): DataNode[] =>
|
||||
groups.map((group) => ({
|
||||
title: group.name,
|
||||
key: group.id,
|
||||
children: group.children?.length
|
||||
? transformGroupNodeToTreeData(group.children)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!groups) return;
|
||||
|
||||
let displayGroups = groups;
|
||||
|
||||
if (allowedGroupIds && allowedGroupIds.length > 0) {
|
||||
const allowedSet = new Set(allowedGroupIds);
|
||||
displayGroups = filterGroupsByAllowedIds(groups, allowedSet);
|
||||
}
|
||||
|
||||
setTreeData(transformGroupNodeToTreeData(displayGroups));
|
||||
}, [groups, allowedGroupIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groups) {
|
||||
getGroups();
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTreeData = async () => {
|
||||
const transformedData = transformGroupNodeToTreeData(groups || []);
|
||||
setTreeData(transformedData);
|
||||
};
|
||||
|
||||
loadTreeData();
|
||||
}, [currentUserProfile, groups]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set selected keys from groupIds prop
|
||||
if (groupIds) {
|
||||
setSelectedKeys(Array.isArray(groupIds) ? groupIds : [groupIds]);
|
||||
} else {
|
||||
setSelectedKeys([]);
|
||||
}
|
||||
}, [groupIds]);
|
||||
|
||||
const handleSelect = (selectedKeys: React.Key[]) => {
|
||||
const keys = selectedKeys.map((k) => String(k));
|
||||
setSelectedKeys(selectedKeys);
|
||||
|
||||
if (multiple) {
|
||||
onSelected?.(keys.length > 0 ? keys : null);
|
||||
} else {
|
||||
onSelected?.(keys.length > 0 ? keys[0] : null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = (
|
||||
checkedKeys:
|
||||
| React.Key[]
|
||||
| { checked: React.Key[]; halfChecked: React.Key[] },
|
||||
) => {
|
||||
const keys = Array.isArray(checkedKeys) ? checkedKeys : checkedKeys.checked;
|
||||
const stringKeys = keys.map((k) => String(k));
|
||||
setSelectedKeys(keys);
|
||||
|
||||
onSelected?.(stringKeys.length > 0 ? stringKeys : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tree
|
||||
disabled={disable}
|
||||
checkable={multiple}
|
||||
treeData={treeData}
|
||||
checkedKeys={multiple ? selectedKeys : undefined}
|
||||
selectedKeys={!multiple ? selectedKeys : undefined}
|
||||
onCheck={multiple ? handleCheck : undefined}
|
||||
onSelect={!multiple ? handleSelect : undefined}
|
||||
defaultExpandAll={true}
|
||||
showLine
|
||||
autoExpandParent
|
||||
{...(titleRender && { titleRender })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TreeGroup;
|
||||
140
src/components/shared/TreeSelectedGroup.tsx
Normal file
140
src/components/shared/TreeSelectedGroup.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Spin, TreeSelect } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface TreeSelectedGroupProps {
|
||||
groupIds?: string | string[] | null;
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onSelected?: (value: string | string[] | null) => void;
|
||||
placeholder?: string;
|
||||
allowClear?: boolean;
|
||||
showSearch?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const TreeSelectedGroup = ({
|
||||
groupIds,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
onSelected,
|
||||
placeholder,
|
||||
allowClear = true,
|
||||
showSearch = true,
|
||||
style = { width: '100%' },
|
||||
}: TreeSelectedGroupProps) => {
|
||||
const intl = useIntl();
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const { currentUserProfile } = initialState || {};
|
||||
const [treeData, setTreeData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<string | string[] | null>(
|
||||
() => {
|
||||
// If disabled is true, don't show any selected values
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
return groupIds || null;
|
||||
},
|
||||
);
|
||||
const { groups, getGroups } = useModel('master.useGroups');
|
||||
useEffect(() => {
|
||||
if (!groups) {
|
||||
getGroups();
|
||||
}
|
||||
}, [groups]);
|
||||
// Update internal state when prop changes
|
||||
useEffect(() => {
|
||||
// If disabled is true, clear the selection
|
||||
if (disabled) {
|
||||
setSelectedValue(null);
|
||||
} else {
|
||||
setSelectedValue(groupIds || null);
|
||||
}
|
||||
}, [groupIds, disabled]);
|
||||
|
||||
const transformGroupNodeToTreeData = (
|
||||
groups: MasterModel.GroupNode[],
|
||||
): any[] => {
|
||||
// console.log('Group tranform: ', groups);
|
||||
|
||||
// Convert groupIds to array for easier checking
|
||||
const groupIdsArray = Array.isArray(groupIds)
|
||||
? groupIds
|
||||
: groupIds
|
||||
? [groupIds]
|
||||
: [];
|
||||
|
||||
return groups.map((group: any) => ({
|
||||
title: group.name,
|
||||
value: group.id,
|
||||
key: group.id,
|
||||
disabled: disabled && groupIdsArray.includes(group.id),
|
||||
children: group.children
|
||||
? transformGroupNodeToTreeData(group.children)
|
||||
: undefined,
|
||||
data: group,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadTreeData = async () => {
|
||||
setLoading(true);
|
||||
const transformedData = transformGroupNodeToTreeData(groups || []);
|
||||
setTreeData(transformedData);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadTreeData();
|
||||
}, [currentUserProfile, groups, disabled, groupIds]);
|
||||
|
||||
const handleChange = (value: string | string[] | null) => {
|
||||
setSelectedValue(value);
|
||||
onSelected?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<TreeSelect
|
||||
disabled={isLoading}
|
||||
value={selectedValue}
|
||||
multiple={multiple}
|
||||
treeCheckable={multiple}
|
||||
showSearch={showSearch}
|
||||
style={style}
|
||||
styles={{
|
||||
popup: {
|
||||
root: {
|
||||
maxHeight: 400,
|
||||
overflow: 'auto',
|
||||
},
|
||||
},
|
||||
}}
|
||||
placeholder={
|
||||
placeholder ||
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.treeselect.placeholder',
|
||||
defaultMessage: 'Please select group',
|
||||
})
|
||||
}
|
||||
allowClear={allowClear}
|
||||
treeDefaultExpandAll
|
||||
treeData={treeData}
|
||||
onChange={handleChange}
|
||||
treeLine
|
||||
filterTreeNode={(search, node) => {
|
||||
return (
|
||||
node.title
|
||||
?.toString()
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()) ?? false
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
export default TreeSelectedGroup;
|
||||
22
src/constants/api.ts
Normal file
22
src/constants/api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Auth API Paths
|
||||
export const API_PATH_LOGIN = '/api/tokens';
|
||||
export const API_PATH_GET_PROFILE = '/api/users/profile';
|
||||
export const API_CHANGE_PASSWORD = '/api/password';
|
||||
// Alarm API Constants
|
||||
export const API_ALARMS = '/api/alarms';
|
||||
export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
|
||||
|
||||
// Thing API Constants
|
||||
export const API_THINGS_SEARCH = '/api/things/search';
|
||||
|
||||
// Group API Constants
|
||||
export const API_GROUPS = '/api/groups';
|
||||
export const API_GROUP_MEMBERS = '/api/members';
|
||||
export const API_GROUP_CHILDREN = '/api/groups';
|
||||
|
||||
// Log API Constants
|
||||
export const API_LOGS = '/api/reader/channels';
|
||||
|
||||
// User API Constants
|
||||
export const API_USERS = '/api/users';
|
||||
export const API_USERS_BY_GROUP = '/api/users/groups';
|
||||
38
src/constants/index.ts
Normal file
38
src/constants/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const DURATION_POLLING = 15000; //15s
|
||||
export const DURATION_CHART = 300000; //5m
|
||||
export const DEFAULT_LIMIT = 200;
|
||||
export const DATE_TIME_FORMAT = 'DD/MM/YYYY HH:mm:ss';
|
||||
export const TIME_FORMAT = 'HH:mm:ss';
|
||||
export const DATE_FORMAT = 'DD/MM/YYYY';
|
||||
|
||||
export const STATUS_NORMAL = 0;
|
||||
export const STATUS_WARNING = 1;
|
||||
export const STATUS_DANGEROUS = 2;
|
||||
export const STATUS_SOS = 3;
|
||||
|
||||
export const COLOR_DISCONNECT = '#d9d9d9';
|
||||
export const COLOR_NORMAL = '#389e0d';
|
||||
export const COLOR_WARNING = '#d48806';
|
||||
export const COLOR_DANGEROUS = '#d9363e';
|
||||
export const COLOR_SOS = '#ff0000';
|
||||
|
||||
export const TOKEN = 'token';
|
||||
export const THEME_KEY = 'theme';
|
||||
// Global Constants
|
||||
export const LIMIT_TREE_LEVEL = 5;
|
||||
export const DEFAULT_PAGE_SIZE = 5;
|
||||
|
||||
export const PADDING_IN_LINE = 4;
|
||||
export const PADDING_BLOCK = 4;
|
||||
|
||||
export enum HTTPSTATUS {
|
||||
HTTP_SUCCESS = 200,
|
||||
HTTP_BADREQUEST = 400,
|
||||
HTTP_UNAUTHORIZED = 401,
|
||||
HTTP_FORBIDDEN = 403,
|
||||
HTTP_NOTFOUND = 404,
|
||||
HTTP_SERVERERROR = 500,
|
||||
HTTP_ACCEPTED = 202,
|
||||
HTTP_CREATED = 201,
|
||||
HTTP_NOCONTENT = 204,
|
||||
}
|
||||
3
src/constants/routes.ts
Normal file
3
src/constants/routes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const ROUTE_LOGIN = '/login';
|
||||
export const ROUTER_HOME = '/';
|
||||
export const ROUTE_PROFILE = '/profile';
|
||||
6
src/constants/slave/sgw/index.ts
Normal file
6
src/constants/slave/sgw/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum SGW_ROLE {
|
||||
SYSADMIN = 'sysadmin',
|
||||
ADMIN = 'admin',
|
||||
USERS = 'users',
|
||||
ENDUSER = 'enduser',
|
||||
}
|
||||
4
src/constants/slave/sgw/routes.ts
Normal file
4
src/constants/slave/sgw/routes.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const SGW_ROUTE_HOME = '/maps';
|
||||
export const SGW_ROUTE_TRIP = '/trip';
|
||||
export const SGW_ROUTE_CREATE_BANZONE = '/manager/banzones/create';
|
||||
export const SGW_ROUTE_MANAGER_BANZONE = '/manager/banzones';
|
||||
5
src/env.d.ts
vendored
Normal file
5
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
DOMAIN_ENV?: 'gms' | 'sgw' | 'spole'; // hoặc các giá trị bạn dùng
|
||||
}
|
||||
}
|
||||
14
src/global.d.ts
vendored
Normal file
14
src/global.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="@ant-design/pro-components" />
|
||||
|
||||
declare module '@ant-design/pro-components' {
|
||||
export * from '@ant-design/pro-card';
|
||||
export * from '@ant-design/pro-descriptions';
|
||||
export * from '@ant-design/pro-field';
|
||||
export * from '@ant-design/pro-form';
|
||||
export * from '@ant-design/pro-layout';
|
||||
export * from '@ant-design/pro-list';
|
||||
export * from '@ant-design/pro-provider';
|
||||
export * from '@ant-design/pro-skeleton';
|
||||
export * from '@ant-design/pro-table';
|
||||
export * from '@ant-design/pro-utils';
|
||||
}
|
||||
63
src/locales/en-US.ts
Normal file
63
src/locales/en-US.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import masterEn from './en-US/master/master-en';
|
||||
import gmsEn from './en-US/slave/gms/gms-en';
|
||||
import sgwEn from './en-US/slave/sgw/sgw-en';
|
||||
import spoleEn from './en-US/slave/spole/spole-en';
|
||||
export default {
|
||||
'app.copyright.produced': 'Produced by Mobifone.',
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.delete_confirm': 'Confirm delete?',
|
||||
'common.delete_messgage': 'Do you want to delete',
|
||||
'common.deleting': 'Deleting...',
|
||||
'common.edit': 'Edit',
|
||||
'common.add': 'Add',
|
||||
'common.actions': 'Actions',
|
||||
'common.search': 'Search',
|
||||
'common.filter': 'Filter',
|
||||
'common.loading': 'Loading...',
|
||||
'common.updating': 'Updating...',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.notification': 'Notification',
|
||||
'common.back': 'Back',
|
||||
'common.next': 'Next',
|
||||
'common.logout': 'Log out',
|
||||
'common.yes': 'Yes',
|
||||
'common.sure': 'Sure',
|
||||
'common.no': 'No',
|
||||
'common.ok': 'OK',
|
||||
'common.error': 'Error',
|
||||
'common.vietnamese': 'Vietnamese',
|
||||
'common.english': 'English',
|
||||
'common.theme.light': 'Light Theme',
|
||||
'common.theme.dark': 'Dark Theme',
|
||||
'common.paginations.things': 'things',
|
||||
'common.paginations.of': 'of',
|
||||
'common.name': 'Name',
|
||||
'common.name.required': 'Name is required',
|
||||
'common.type': 'Type',
|
||||
'common.type.placeholder': 'Select Type',
|
||||
'common.status': 'Status',
|
||||
'common.province': 'Province',
|
||||
'common.description': 'Description',
|
||||
'common.description.required': 'Description is required',
|
||||
'common.description.placeholder': 'Enter description',
|
||||
'common.active': 'Active',
|
||||
'common.inactive': 'Inactive',
|
||||
'common.created_at': 'Created At',
|
||||
'common.updated_at': 'Updated At',
|
||||
'common.undefined': 'Undefined',
|
||||
'common.not_empty': 'Cannot be empty!',
|
||||
'common.level.normal': 'Normal',
|
||||
'common.level.warning': 'Warning',
|
||||
'common.level.critical': 'Critical',
|
||||
'common.level.sos': 'SOS',
|
||||
'common.unaccess': 'Sorry, you do not have permission to access this page',
|
||||
'common.notfound': 'The page you visited does not exist',
|
||||
'common.internalserver': 'Sorry, something went wrong on the server',
|
||||
'common.required_field': 'This field cannot be empty',
|
||||
...masterEn,
|
||||
...sgwEn,
|
||||
...gmsEn,
|
||||
...spoleEn,
|
||||
};
|
||||
17
src/locales/en-US/master/master-alarm-en.ts
Normal file
17
src/locales/en-US/master/master-alarm-en.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export default {
|
||||
'master.alarms.table.pagination': 'alarms',
|
||||
'master.alarms.severity': 'Severity',
|
||||
'master.alarms.occurred_at': 'Occurred time',
|
||||
'master.alarms.confirmed': 'Confirmed',
|
||||
'master.alarms.confirm.description.required': 'The description is required',
|
||||
'master.alarms.confirm.title': 'Confirm Alarm',
|
||||
'master.alarms.confirm.success': 'Confirm alarm successfully',
|
||||
'master.alarms.confirm.fail': 'Confirm alarm failed',
|
||||
'master.alarms.unconfirm.title': 'Unconfirm',
|
||||
'master.alarms.unconfirm.body':
|
||||
'Are you sure you want to unconfirm this alarm?',
|
||||
'master.alarms.unconfirm.success': 'Unconfirm alarm successfully',
|
||||
'master.alarms.unconfirm.fail': 'Unconfirm alarm failed',
|
||||
'master.alarms.not_found': 'Alarm has expired or does not exist',
|
||||
'master.alarms.filter_things': 'Filter by device group',
|
||||
};
|
||||
15
src/locales/en-US/master/master-auth-en.ts
Normal file
15
src/locales/en-US/master/master-auth-en.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
// Authentication
|
||||
'master.auth.login.title': 'Login',
|
||||
'master.auth.login.email': 'Email',
|
||||
'master.auth.validation.email': 'Email is required',
|
||||
'master.auth.password': 'Password',
|
||||
'master.auth.validation.password': 'Password is required',
|
||||
'master.auth.login.subtitle': 'Ship Monitoring System',
|
||||
'master.auth.login.description': 'Login to continue monitoring vessels',
|
||||
'master.auth.login.invalid': 'Invalid username or password',
|
||||
'master.auth.login.success': 'Login successful',
|
||||
'master.auth.logout.title': 'Logout',
|
||||
'master.auth.logout.confirm': 'Are you sure you want to logout?',
|
||||
'master.auth.logout.success': 'Logout successful',
|
||||
};
|
||||
18
src/locales/en-US/master/master-en.ts
Normal file
18
src/locales/en-US/master/master-en.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import masterAlarmEn from './master-alarm-en';
|
||||
import masterAuthEn from './master-auth-en';
|
||||
import masterGroupEn from './master-group-en';
|
||||
import masterSysLogEn from './master-log-en';
|
||||
import masterMenuEn from './master-menu-en';
|
||||
import masterMenuProfileEn from './master-profile-en';
|
||||
import masterThingEn from './master-thing-en';
|
||||
import masterUserEn from './master-user-en';
|
||||
export default {
|
||||
...masterAuthEn,
|
||||
...masterMenuEn,
|
||||
...masterMenuProfileEn,
|
||||
...masterAlarmEn,
|
||||
...masterThingEn,
|
||||
...masterSysLogEn,
|
||||
...masterUserEn,
|
||||
...masterGroupEn,
|
||||
};
|
||||
21
src/locales/en-US/master/master-group-en.ts
Normal file
21
src/locales/en-US/master/master-group-en.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
'master.groups.root': 'Add root',
|
||||
'master.groups.cannot-add-group':
|
||||
'Cannot add child to a group that has things',
|
||||
'master.groups.add': 'Add child',
|
||||
'master.groups.delete.confirm': 'Are you sure you want to delete this group?',
|
||||
'master.groups.code': 'Code',
|
||||
'master.groups.code.exists': 'The code already exists',
|
||||
'master.groups.short_name': 'Short name',
|
||||
'master.groups.short_name.exists': 'The short name already exists',
|
||||
'master.groups.update.success': 'Updated group successfully',
|
||||
'master.groups.update.failed': 'Update group failed, please try again!',
|
||||
'master.groups.create.success': 'Created group successfully',
|
||||
'master.groups.create.failed': 'Create group failed, please try again!',
|
||||
'master.groups.delete.success': 'Deleted group successfully',
|
||||
'master.groups.delete.failed': 'Delete group failed',
|
||||
'master.groups.delete.failed_internal':
|
||||
'The group contains devices or users and cannot be deleted',
|
||||
'master.groups.parent': 'Parent',
|
||||
'master.groups.treeselect.placeholder': 'Please select group',
|
||||
};
|
||||
51
src/locales/en-US/master/master-log-en.ts
Normal file
51
src/locales/en-US/master/master-log-en.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export default {
|
||||
'master.logs.search.yesterday': 'Yesterday',
|
||||
'master.logs.search.last_week': 'Last week',
|
||||
'master.logs.search.last_month': 'Last month',
|
||||
'master.logs.table.pagination': 'activities',
|
||||
'master.logs.things.create': 'Create new thing',
|
||||
'master.logs.things.update': 'Update thing',
|
||||
'master.logs.things.remove': 'Remove thing',
|
||||
'master.logs.things.share': 'Share thing',
|
||||
'master.logs.things.unshare': 'Unshare thing',
|
||||
'master.logs.things.update_key': 'Update key thing',
|
||||
'master.logs.users.create': 'Register user',
|
||||
'master.logs.users.update': 'Update user',
|
||||
'master.logs.users.remove': 'Remove user',
|
||||
'master.logs.users.share': 'User share',
|
||||
'master.logs.groups.create': 'Create new group',
|
||||
'master.logs.groups.update': 'Update group',
|
||||
'master.logs.groups.remove': 'Remove group',
|
||||
'master.logs.groups.assign_thing': 'Assign thing to group',
|
||||
'master.logs.groups.assign_user': 'Assign user to group',
|
||||
'master.logs.groups.unassign_thing': 'Remove thing from group',
|
||||
'master.logs.groups.unassign_user': 'Remove user from group',
|
||||
'master.logs.action.text': 'Action',
|
||||
'master.logs.email.text': 'Email',
|
||||
'master.logs.date.text': 'Date',
|
||||
'master.logs.things.alarm.confirm': 'Alarm confirm',
|
||||
'master.logs.things.alarm.unconfirm': 'Alarm unconfirm',
|
||||
'master.logs.users.login': 'User login',
|
||||
'master.logs.title': 'Logs',
|
||||
'master.logs.things.confirm': 'Confirm',
|
||||
'master.logs.things.unconfirm': 'Unconfirm',
|
||||
'master.logs.things': 'Things',
|
||||
'master.logs.users': 'Users',
|
||||
'master.logs.groups': 'Groups',
|
||||
'master.logs.ships': 'Ships',
|
||||
'master.logs.ships.create': 'Create new ship',
|
||||
'master.logs.ships.update': 'Update ship',
|
||||
'master.logs.ships.remove': 'Remove ship',
|
||||
'master.logs.ships.assign_thing': 'Assign device to ship',
|
||||
'master.logs.ships.assign_user': 'Assign user to ship',
|
||||
'master.logs.ships.unassign_thing': 'Remove device from ship',
|
||||
'master.logs.ships.unassign_user': 'Remove user from ship',
|
||||
'master.logs.trips': 'Trips',
|
||||
'master.logs.trips.create': 'Create new trip',
|
||||
'master.logs.trips.update': 'Update trip',
|
||||
'master.logs.trips.remove': 'Remove trip',
|
||||
'master.logs.trips.approve': 'Approve trip',
|
||||
'master.logs.trips.request_approve': 'Request approval for trip',
|
||||
'master.logs.trips.unassign_thing': 'Remove device from trip',
|
||||
'master.logs.trips.unassign_user': 'Remove user from trip',
|
||||
};
|
||||
10
src/locales/en-US/master/master-menu-en.ts
Normal file
10
src/locales/en-US/master/master-menu-en.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
'menu.alarms': 'Alarms',
|
||||
'menu.monitoring': 'Monitoring',
|
||||
'menu.manager': 'Manager',
|
||||
'menu.manager.devices': 'Devices',
|
||||
'menu.manager.groups': 'Groups',
|
||||
'menu.manager.users': 'Users',
|
||||
'menu.manager.logs': 'Logs',
|
||||
'menu.profile': 'Profile',
|
||||
};
|
||||
20
src/locales/en-US/master/master-profile-en.ts
Normal file
20
src/locales/en-US/master/master-profile-en.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
'master.profile.change-profile': 'Basic Information',
|
||||
'master.profile.change-password': 'Change Password',
|
||||
'master.profile.two-factor-authentication': 'Two-Factor Authentication',
|
||||
'master.profile.change-profile.title': 'Update Personal Information',
|
||||
'master.profile.change-profile.full_name': 'Full Name',
|
||||
'master.profile.change-profile.phone_number': 'Phone Number',
|
||||
'master.profile.change-password.old_password': 'Old Password',
|
||||
'master.profile.change-password.new_password': 'New Password',
|
||||
'master.profile.change-password.confirm_password': 'Confirm Password',
|
||||
'master.profile.change-password.password_not_match':
|
||||
'Password confirmation does not match',
|
||||
'master.profile.change-profile.update-success':
|
||||
'Update information successfully',
|
||||
'master.profile.change-profile.update-fail': 'Update information failed',
|
||||
'master.profile.change-password.success': 'Change password successfully',
|
||||
'master.profile.change-password.fail': 'Change password failed',
|
||||
'master.profile.change-password.password.strong':
|
||||
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
|
||||
};
|
||||
6
src/locales/en-US/master/master-thing-en.ts
Normal file
6
src/locales/en-US/master/master-thing-en.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
'master.thing.name': 'Name',
|
||||
'master.thing.external_id': 'External ID',
|
||||
'master.thing.group': 'Group',
|
||||
'master.thing.address': 'Address',
|
||||
};
|
||||
35
src/locales/en-US/master/master-user-en.ts
Normal file
35
src/locales/en-US/master/master-user-en.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export default {
|
||||
'master.users.title': 'Users',
|
||||
'master.users.table.pagination': 'users',
|
||||
'master.users.register': 'Register',
|
||||
'master.users.register.title': 'Register user',
|
||||
'master.users.email.exists': 'The email is exists',
|
||||
'master.users.email': 'Email',
|
||||
'master.users.email.tip': 'The email is the unique key',
|
||||
'master.users.email.required': 'The email is required',
|
||||
'master.users.email.invalid': 'Invalid email address',
|
||||
'master.users.role': 'Role',
|
||||
'master.users.role.placeholder': 'Please select a role',
|
||||
'master.users.role.tip': 'The role is the unique key',
|
||||
'master.users.password.required': 'Password is required',
|
||||
'master.users.password.minimum': 'Minimum password length is 8',
|
||||
'master.users.password': 'Password',
|
||||
'master.users.full_name': 'Full name',
|
||||
'master.users.full_name.placeholder': 'Enter full name',
|
||||
'master.users.full_name.required': 'Please enter full name',
|
||||
'master.users.password.placeholder': 'Password',
|
||||
'master.users.confirmpassword.required': 'Confirm password is required',
|
||||
'master.users.email.placeholder': 'Email',
|
||||
'master.users.phone_number': 'Phone number',
|
||||
'master.users.phone_number.tip': 'The phone number is the unique key',
|
||||
'master.users.phone_number.required': 'The phone number is required',
|
||||
'master.users.phone_number.notvalid': 'Invalid phone number',
|
||||
'master.users.groups': 'Groups',
|
||||
'master.users.groups.required': 'Please select groups',
|
||||
'master.users.role.sysadmin': 'System Administrator',
|
||||
'master.users.role.admin': 'Unit Manager',
|
||||
'master.users.role.user': 'Unit Supervisor',
|
||||
'master.users.role.sgw.end_user': 'Ship Owner',
|
||||
'master.users.create.error': 'User creation failed',
|
||||
'master.users.create.success': 'User created successfully',
|
||||
};
|
||||
5
src/locales/en-US/slave/gms/gms-en.ts
Normal file
5
src/locales/en-US/slave/gms/gms-en.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import gmsMenuEn from './gms-menu-en';
|
||||
export default {
|
||||
'gms.title': 'SmartGMS',
|
||||
...gmsMenuEn,
|
||||
};
|
||||
3
src/locales/en-US/slave/gms/gms-menu-en.ts
Normal file
3
src/locales/en-US/slave/gms/gms-menu-en.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
'menu.gms.monitor': 'Monitor',
|
||||
};
|
||||
6
src/locales/en-US/slave/sgw/sgw-en.ts
Normal file
6
src/locales/en-US/slave/sgw/sgw-en.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import sgwMenu from './sgw-menu-en';
|
||||
export default {
|
||||
'sgw.title': 'Sea Gateway',
|
||||
'sgw.ship': 'Ship',
|
||||
...sgwMenu,
|
||||
};
|
||||
6
src/locales/en-US/slave/sgw/sgw-menu-en.ts
Normal file
6
src/locales/en-US/slave/sgw/sgw-menu-en.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
'menu.sgw.map': 'Maps',
|
||||
'menu.sgw.trips': 'Trips',
|
||||
'menu.manager.sgw.fishes': 'Fishes',
|
||||
'menu.manager.sgw.zones': 'Zones',
|
||||
};
|
||||
5
src/locales/en-US/slave/spole/spole-en.ts
Normal file
5
src/locales/en-US/slave/spole/spole-en.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import spoleMenuEN from './spole-menu-en';
|
||||
export default {
|
||||
'spole.title': 'Spole',
|
||||
...spoleMenuEN,
|
||||
};
|
||||
8
src/locales/en-US/slave/spole/spole-menu-en.ts
Normal file
8
src/locales/en-US/slave/spole/spole-menu-en.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
'menu.spole.monitoring': 'Monitoring',
|
||||
'menu.spole.mira': 'Mira',
|
||||
'menu.spole.miva': 'Miva',
|
||||
'menu.spole.traffic-light': 'Traffic Light',
|
||||
'menu.spole.street-light': 'Street Light',
|
||||
'menu.spole.media': 'Media',
|
||||
};
|
||||
64
src/locales/vi-VN.ts
Normal file
64
src/locales/vi-VN.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import masterVI from './vi-VN/master/master-vi';
|
||||
import gmsVI from './vi-VN/slave/gms/gms-vi';
|
||||
import sgwVI from './vi-VN/slave/sgw/sgw-vi';
|
||||
import spoleVI from './vi-VN/slave/spole/spole-vi';
|
||||
export default {
|
||||
'app.copyright.produced': 'Sản phẩm của Mobifone.',
|
||||
'common.save': 'Lưu',
|
||||
'common.cancel': 'Hủy',
|
||||
'common.delete': 'Xóa',
|
||||
'common.delete_confirm': 'Xác nhận xóa?',
|
||||
'common.deleting': 'Đang xóa...',
|
||||
'common.edit': 'Chỉnh sửa',
|
||||
'common.add': 'Thêm',
|
||||
'common.actions': 'Hoạt động',
|
||||
'common.search': 'Tìm kiếm',
|
||||
'common.filter': 'Bộ lọc',
|
||||
'common.loading': 'Đang tải...',
|
||||
'common.updating': 'Đang cập nhật...',
|
||||
'common.confirm': 'Xác nhận',
|
||||
'common.notification': 'Thông báo',
|
||||
'common.back': 'Quay lại',
|
||||
'common.next': 'Tiếp theo',
|
||||
'common.logout': 'Đăng xuất',
|
||||
'common.yes': 'Có',
|
||||
'common.sure': 'Chắc chắn',
|
||||
'common.no': 'Không',
|
||||
'common.ok': 'OK',
|
||||
'common.error': 'Lỗi',
|
||||
'common.vietnamese': 'Tiếng Việt',
|
||||
'common.english': 'Tiếng Anh',
|
||||
'common.theme.light': 'Sáng',
|
||||
'common.theme.dark': 'Tối',
|
||||
'common.paginations.things': 'thiết bị',
|
||||
'common.paginations.of': 'trên',
|
||||
'common.name': 'Tên',
|
||||
'common.name.required': 'Tên không được để trống',
|
||||
'common.note': 'Ghi chú',
|
||||
'common.image': 'Ảnh',
|
||||
'common.type': 'Loại',
|
||||
'common.type.placeholder': 'Chọn loại',
|
||||
'common.status': 'Trạng thái',
|
||||
'common.province': 'Tỉnh',
|
||||
'common.description': 'Mô tả',
|
||||
'common.description.required': 'Mô tả không được để trống',
|
||||
'common.description.placeholder': 'Nhập mô tả',
|
||||
'common.active': 'Kích hoạt',
|
||||
'common.inactive': 'Vô hiệu hóa',
|
||||
'common.created_at': 'Ngày tạo',
|
||||
'common.updated_at': 'Ngày cập nhật',
|
||||
'common.undefined': 'Chưa xác định',
|
||||
'common.not_empty': 'Không được để trống!',
|
||||
'common.level.normal': 'Bình thường',
|
||||
'common.level.warning': 'Cảnh báo',
|
||||
'common.level.critical': 'Nguy hiểm',
|
||||
'common.level.sos': 'Khẩn cấp',
|
||||
'common.unaccess': 'Bạn không có quyền truy cập trang này',
|
||||
'common.notfound': 'Trang bạn tìm kiếm không tồn tại',
|
||||
'common.internalserver': 'Xin lỗi, đã có lỗi xảy ra ở máy chủ',
|
||||
'common.required_field': 'Trường này không được để trống',
|
||||
...masterVI,
|
||||
...sgwVI,
|
||||
...gmsVI,
|
||||
...spoleVI,
|
||||
};
|
||||
16
src/locales/vi-VN/master/master-alarm-vi.ts
Normal file
16
src/locales/vi-VN/master/master-alarm-vi.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
'master.alarms.table.pagination': 'cảnh báo',
|
||||
'master.alarms.severity': 'Mức độ',
|
||||
'master.alarms.occurred_at': 'Thời gian xảy ra',
|
||||
'master.alarms.confirmed': 'Đã xác nhận',
|
||||
'master.alarms.confirm.description.required': 'Mô tả không được để trống',
|
||||
'master.alarms.confirm.title': 'Xác nhận cảnh báo',
|
||||
'master.alarms.confirm.success': 'Xác nhận cảnh báo thành công',
|
||||
'master.alarms.confirm.fail': 'Xác nhận cảnh báo thất bại',
|
||||
'master.alarms.unconfirm.title': 'Ngừng xác nhận',
|
||||
'master.alarms.unconfirm.body': 'Bạn chắc chắn ngừng xác nhận cảnh báo này?',
|
||||
'master.alarms.unconfirm.success': 'Ngừng xác nhận cảnh báo thành công',
|
||||
'master.alarms.unconfirm.fail': 'Ngừng xác nhận cảnh báo thất bại',
|
||||
'master.alarms.not_found': 'Cảnh báo đã hết hạn hoặc không tồn tại',
|
||||
'master.alarms.filter_things': 'Lọc theo nhóm thiết bị',
|
||||
};
|
||||
15
src/locales/vi-VN/master/master-auth-vi.ts
Normal file
15
src/locales/vi-VN/master/master-auth-vi.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
// Authentication
|
||||
'master.auth.login.email': 'Email',
|
||||
'master.auth.login.title': 'Đăng nhập',
|
||||
'master.auth.login.subtitle': 'Hệ thống giám sát tàu cá',
|
||||
'master.auth.login.description': 'Đăng nhập để tiếp tục giám sát tàu thuyền',
|
||||
'master.auth.login.invalid': 'Tên người dùng hoặc mật khẩu không hợp lệ',
|
||||
'master.auth.login.success': 'Đăng nhập thành công',
|
||||
'master.auth.logout.title': 'Đăng xuất',
|
||||
'master.auth.logout.confirm': 'Bạn có chắc chắn muốn đăng xuất?',
|
||||
'master.auth.logout.success': 'Đăng xuất thành công',
|
||||
'master.auth.validation.email': 'Email không được để trống!',
|
||||
'master.auth.password': 'Mật khẩu',
|
||||
'master.auth.validation.password': 'Mật khẩu không được để trống!',
|
||||
};
|
||||
22
src/locales/vi-VN/master/master-group-vi.ts
Normal file
22
src/locales/vi-VN/master/master-group-vi.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export default {
|
||||
'master.groups.root': 'Thêm gốc',
|
||||
'master.groups.cannot-add-group':
|
||||
'Không thể tạo đơn vị con khi gốc đã có thiết bị',
|
||||
'master.groups.add': 'Tạo đơn vị cấp dưới',
|
||||
'master.groups.delete.confirm': 'Bạn có chắc muốn xóa nhóm này không?',
|
||||
'master.groups.code': 'Mã',
|
||||
'master.groups.code.exists': 'Mã đã tồn tại',
|
||||
'master.groups.short_name': 'Tên viết tắt',
|
||||
'master.groups.short_name.exists': 'Tên viết tắt đã tồn tại',
|
||||
'master.groups.update.success': 'Cập nhật đơn vị thành công',
|
||||
'master.groups.update.failed': 'Cập nhật đơn vị thất bại, vui lòng thử lại!',
|
||||
'master.groups.create.success': 'Tạo đơn vị thành công',
|
||||
'master.groups.create.failed': 'Tạo đơn vị thất bại, vui lòng thử lại!',
|
||||
|
||||
'master.groups.delete.success': 'Xóa đơn vị thành công',
|
||||
'master.groups.delete.failed': 'Xóa đơn vị thất bại',
|
||||
'master.groups.delete.failed_internal':
|
||||
'Đơn vị có chứa thiết bị hoặc người dùng, không thể xóa',
|
||||
'master.groups.parent': 'Nhóm gốc',
|
||||
'master.groups.treeselect.placeholder': 'Chọn khu vực',
|
||||
};
|
||||
51
src/locales/vi-VN/master/master-log-vi.ts
Normal file
51
src/locales/vi-VN/master/master-log-vi.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export default {
|
||||
'master.logs.search.yesterday': 'Hôm qua',
|
||||
'master.logs.search.last_week': 'Tuần trước',
|
||||
'master.logs.search.last_month': 'Tháng trước',
|
||||
'master.logs.table.pagination': 'hoạt động',
|
||||
'master.logs.things.create': 'Tạo mới thiết bị',
|
||||
'master.logs.things.update': 'Sửa thông tin thiết bị',
|
||||
'master.logs.things.remove': 'Xoá thiết bị',
|
||||
'master.logs.things.share': 'Chia sẻ thiết bị',
|
||||
'master.logs.things.unshare': 'Ngừng chia sẻ thiết bị',
|
||||
'master.logs.things.update_key': 'Cập nhật mã khoá thiết bị',
|
||||
'master.logs.users.create': 'Đăng kí người dùng mới',
|
||||
'master.logs.users.update': 'Cập nhật thông tin người dùng',
|
||||
'master.logs.users.remove': 'Xoá người dùng',
|
||||
'master.logs.users.share': 'Đăng nhập',
|
||||
'master.logs.groups.create': 'Tạo mới đơn vị',
|
||||
'master.logs.groups.update': 'Sửa thông tin đơn vị',
|
||||
'master.logs.groups.remove': 'Xoá đơn vị',
|
||||
'master.logs.groups.assign_thing': 'Thêm thiết bị vào đơn vị',
|
||||
'master.logs.groups.assign_user': 'Thêm người dùng vào đơn vị',
|
||||
'master.logs.groups.unassign_thing': 'Xoá thiết bị khỏi đơn vị',
|
||||
'master.logs.groups.unassign_user': 'Xoá người dùng khỏi đơn vị',
|
||||
'master.logs.action.text': 'Thao tác',
|
||||
'master.logs.email.text': 'Người dùng',
|
||||
'master.logs.date.text': 'Ngày giờ',
|
||||
'master.logs.things.alarm.confirm': 'Xác nhận cảnh báo',
|
||||
'master.logs.things.alarm.unconfirm': 'Ngừng xác nhận cảnh báo',
|
||||
'master.logs.users.login': 'Đăng nhập',
|
||||
'master.logs.title': 'Nhật ký',
|
||||
'master.logs.things.confirm': 'Xác nhận',
|
||||
'master.logs.things.unconfirm': 'Ngừng xác nhận',
|
||||
'master.logs.things': 'Thiết bị',
|
||||
'master.logs.users': 'Người dùng',
|
||||
'master.logs.groups': 'Đơn vị',
|
||||
'master.logs.ships': 'Tàu',
|
||||
'master.logs.ships.create': 'Tạo mới tàu',
|
||||
'master.logs.ships.update': 'Sửa thông tin tàu',
|
||||
'master.logs.ships.remove': 'Xoá tàu',
|
||||
'master.logs.ships.assign_thing': 'Thêm thiết bị vào tàu',
|
||||
'master.logs.ships.assign_user': 'Thêm người dùng vào tàu',
|
||||
'master.logs.ships.unassign_thing': 'Xoá thiết bị khỏi tàu',
|
||||
'master.logs.ships.unassign_user': 'Xoá người dùng khỏi tàu',
|
||||
'master.logs.trips': 'Chuyến đi',
|
||||
'master.logs.trips.create': 'Tạo mới chuyến đi',
|
||||
'master.logs.trips.update': 'Sửa thông tin chuyến đi',
|
||||
'master.logs.trips.remove': 'Xoá chuyến đi',
|
||||
'master.logs.trips.approve': 'Duyệt chuyến đi',
|
||||
'master.logs.trips.request_approve': 'Yêu cầu xác nhận chuyến đi',
|
||||
'master.logs.trips.unassign_thing': 'Xoá thiết bị khỏi chuyến đi',
|
||||
'master.logs.trips.unassign_user': 'Xoá người dùng khỏi chuyến đi',
|
||||
};
|
||||
10
src/locales/vi-VN/master/master-menu-vi.ts
Normal file
10
src/locales/vi-VN/master/master-menu-vi.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
'menu.monitoring': 'Giám sát',
|
||||
'menu.alarms': 'Cảnh báo',
|
||||
'menu.manager': 'Quản lý',
|
||||
'menu.manager.devices': 'Thiết bị',
|
||||
'menu.manager.groups': 'Đơn vị',
|
||||
'menu.manager.users': 'Người dùng',
|
||||
'menu.manager.logs': 'Hoạt động',
|
||||
'menu.profile': 'Thông tin cá nhân',
|
||||
};
|
||||
20
src/locales/vi-VN/master/master-profile-vi.ts
Normal file
20
src/locales/vi-VN/master/master-profile-vi.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export default {
|
||||
'master.profile.change-profile': 'Thông tin cơ bản',
|
||||
'master.profile.change-password': 'Đổi mật khẩu',
|
||||
'master.profile.two-factor-authentication': 'Xác thực 2 bước',
|
||||
'master.profile.change-profile.title': 'Cập nhật thông tin cá nhân',
|
||||
'master.profile.change-profile.full_name': 'Họ và tên',
|
||||
'master.profile.change-profile.phone_number': 'Số điện thoại',
|
||||
'master.profile.change-password.old_password': 'Mật khẩu cũ',
|
||||
'master.profile.change-password.new_password': 'Mật khẩu mới',
|
||||
'master.profile.change-password.password.strong':
|
||||
'Mật khẩu phải chứa ít nhất 8 ký tự, bao gồm ít nhất một số và bao gồm cả chữ thường, chữ hoa và ký tự đặc biệt, ví dụ #, ?, !',
|
||||
'master.profile.change-password.confirm_password': 'Xác nhận mật khẩu',
|
||||
'master.profile.change-password.password_not_match':
|
||||
'Mật khẩu xác nhận không khớp',
|
||||
'master.profile.change-profile.update-success':
|
||||
'Cập nhật thông tin thành công',
|
||||
'master.profile.change-profile.update-fail': 'Cập nhật thông tin thất bại',
|
||||
'master.profile.change-password.success': 'Đổi mật khẩu thành công',
|
||||
'master.profile.change-password.fail': 'Đổi mật khẩu thất bại',
|
||||
};
|
||||
6
src/locales/vi-VN/master/master-thing-vi.ts
Normal file
6
src/locales/vi-VN/master/master-thing-vi.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
'master.thing.name': 'Tên',
|
||||
'master.thing.external_id': 'External ID',
|
||||
'master.thing.group': 'Nhóm',
|
||||
'master.thing.address': 'Địa chỉ',
|
||||
};
|
||||
35
src/locales/vi-VN/master/master-user-vi.ts
Normal file
35
src/locales/vi-VN/master/master-user-vi.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export default {
|
||||
'master.users.title': 'Quản lý người dùng',
|
||||
'master.users.table.pagination': 'người dùng',
|
||||
'master.users.register': 'Thêm người dùng',
|
||||
'master.users.register.title': 'Đăng kí người dùng mới',
|
||||
'master.users.email': 'Email',
|
||||
'master.users.email.tip': 'Email là duy nhất',
|
||||
'master.users.email.required': 'Vui lòng nhập email',
|
||||
'master.users.email.invalid': 'Email không hợp lệ',
|
||||
'master.users.email.exists': 'Email đã sử dụng',
|
||||
'master.users.role': 'Vai trò',
|
||||
'master.users.role.placeholder': 'Vui lòng chọn vai trò',
|
||||
'master.users.role.tip': 'Vai trò là duy nhất',
|
||||
'master.users.password.required': 'Vui lòng nhập mật khẩu',
|
||||
'master.users.password.minimum': 'Mật khẩu ít nhất 8 kí tự',
|
||||
'master.users.password': 'Mật khẩu',
|
||||
'master.users.password.placeholder': 'Nhập mật khẩu',
|
||||
'master.users.confirmpassword.required': 'Vui lòng nhập lại mật khẩu',
|
||||
'master.users.full_name': 'Tên đầy đủ',
|
||||
'master.users.full_name.placeholder': 'Nhập tên đầy đủ',
|
||||
'master.users.full_name.required': 'Vui lòng nhập tên đầy đủ',
|
||||
'master.users.email.placeholder': 'Email',
|
||||
'master.users.phone_number': 'Số điện thoại',
|
||||
'master.users.phone_number.tip': 'Số điện thoại là duy nhất',
|
||||
'master.users.phone_number.required': 'Vui lòng nhập số điện thoại',
|
||||
'master.users.phone_number.notvalid': 'Số điện thoại không hợp lệ',
|
||||
'master.users.groups': 'Đơn vị',
|
||||
'master.users.groups.required': 'Vui lòng chọn đơn vị',
|
||||
'master.users.role.sysadmin': 'Quản lý hệ thống',
|
||||
'master.users.role.admin': 'Quản lý đơn vị',
|
||||
'master.users.role.user': 'Giám sát đơn vị',
|
||||
'master.users.role.sgw.end_user': 'Chủ tàu',
|
||||
'master.users.create.error': 'Tạo người dùng lỗi',
|
||||
'master.users.create.success': 'Tạo người dùng thành công',
|
||||
};
|
||||
18
src/locales/vi-VN/master/master-vi.ts
Normal file
18
src/locales/vi-VN/master/master-vi.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import masterAlarmVi from './master-alarm-vi';
|
||||
import masterAuthVi from './master-auth-vi';
|
||||
import masterGroupVi from './master-group-vi';
|
||||
import masterSysLogVi from './master-log-vi';
|
||||
import masterMenuVi from './master-menu-vi';
|
||||
import masterProfileVi from './master-profile-vi';
|
||||
import masterThingVi from './master-thing-vi';
|
||||
import masterUserVi from './master-user-vi';
|
||||
export default {
|
||||
...masterAuthVi,
|
||||
...masterMenuVi,
|
||||
...masterProfileVi,
|
||||
...masterAlarmVi,
|
||||
...masterThingVi,
|
||||
...masterSysLogVi,
|
||||
...masterUserVi,
|
||||
...masterGroupVi,
|
||||
};
|
||||
3
src/locales/vi-VN/slave/gms/gms-menu-vi.ts
Normal file
3
src/locales/vi-VN/slave/gms/gms-menu-vi.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
'menu.gms.monitor': 'Giám sát',
|
||||
};
|
||||
5
src/locales/vi-VN/slave/gms/gms-vi.ts
Normal file
5
src/locales/vi-VN/slave/gms/gms-vi.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import gmsMenuVi from './gms-menu-vi';
|
||||
export default {
|
||||
'gms.title': 'Quản lý giám sát thông minh',
|
||||
...gmsMenuVi,
|
||||
};
|
||||
6
src/locales/vi-VN/slave/sgw/sgw-menu-vi.ts
Normal file
6
src/locales/vi-VN/slave/sgw/sgw-menu-vi.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
'menu.sgw.map': 'Bản đồ',
|
||||
'menu.sgw.trips': 'Chuyến đi',
|
||||
'menu.manager.sgw.fishes': 'Loài cá',
|
||||
'menu.manager.sgw.zones': 'Khu vực',
|
||||
};
|
||||
6
src/locales/vi-VN/slave/sgw/sgw-vi.ts
Normal file
6
src/locales/vi-VN/slave/sgw/sgw-vi.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import sgwMenu from './sgw-menu-vi';
|
||||
export default {
|
||||
'sgw.title': 'Hệ thống giám sát tàu cá',
|
||||
'sgw.ship': 'Tàu',
|
||||
...sgwMenu,
|
||||
};
|
||||
8
src/locales/vi-VN/slave/spole/spole-menu-vi.ts
Normal file
8
src/locales/vi-VN/slave/spole/spole-menu-vi.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default {
|
||||
'menu.spole.monitoring': 'Giám sát',
|
||||
'menu.spole.mira': 'Mira',
|
||||
'menu.spole.miva': 'Miva',
|
||||
'menu.spole.traffic-light': 'Đèn giao thông',
|
||||
'menu.spole.street-light': 'Đèn',
|
||||
'menu.spole.media': 'Media',
|
||||
};
|
||||
5
src/locales/vi-VN/slave/spole/spole-vi.ts
Normal file
5
src/locales/vi-VN/slave/spole/spole-vi.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import spoleMenuVI from './spole-menu-vi';
|
||||
export default {
|
||||
'spole.title': 'Quản lý giám sát thông minh',
|
||||
...spoleMenuVI,
|
||||
};
|
||||
90
src/models/master/useGroups.ts
Normal file
90
src/models/master/useGroups.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { InitialStateResponse } from '@/app';
|
||||
import { LIMIT_TREE_LEVEL } from '@/constants';
|
||||
import { apiQueryGroups } from '@/services/master/GroupController';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type UseGetGroupsResult = {
|
||||
groups: MasterModel.GroupNode[] | null;
|
||||
groupMap: GroupMap;
|
||||
loading: boolean;
|
||||
getGroups: () => Promise<void>;
|
||||
};
|
||||
|
||||
type GroupMap = Record<string, { name: string; code?: string }>;
|
||||
|
||||
function buildGroupMap(
|
||||
groups: MasterModel.GroupNode[],
|
||||
map: GroupMap = {},
|
||||
): GroupMap {
|
||||
groups.forEach((g) => {
|
||||
map[g.id] = {
|
||||
name: g.name,
|
||||
code: g.metadata?.code,
|
||||
};
|
||||
if (g.children?.length) {
|
||||
buildGroupMap(g.children, map);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
export default function useGetGroups(): UseGetGroupsResult {
|
||||
const [groups, setGroups] = useState<MasterModel.GroupNode[] | null>(null);
|
||||
const [groupMap, setGroupMap] = useState<GroupMap>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { initialState } = useModel('@@initialState') as {
|
||||
initialState?: InitialStateResponse;
|
||||
};
|
||||
|
||||
const currentUserProfile = initialState?.currentUserProfile;
|
||||
|
||||
/**
|
||||
* ✅ FIX CACHE
|
||||
* Khi user đổi (logout / login user khác)
|
||||
* → clear groups cũ
|
||||
*/
|
||||
const prevUserIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentUserId = currentUserProfile?.id ?? null;
|
||||
|
||||
if (prevUserIdRef.current && prevUserIdRef.current !== currentUserId) {
|
||||
// user đổi → clear cache
|
||||
setGroups(null);
|
||||
}
|
||||
|
||||
prevUserIdRef.current = currentUserId;
|
||||
}, [currentUserProfile?.id]);
|
||||
|
||||
/**
|
||||
* ✅ GIỮ NGUYÊN LOGIC CŨ
|
||||
* chỉ thêm guard + dependency đúng
|
||||
**/
|
||||
const getGroups = useCallback(async (): Promise<void> => {
|
||||
if (!currentUserProfile?.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const tree = await apiQueryGroups({
|
||||
level: LIMIT_TREE_LEVEL,
|
||||
tree: true,
|
||||
});
|
||||
setGroups(tree?.groups || []);
|
||||
setGroupMap(buildGroupMap(tree?.groups || []));
|
||||
} catch (err) {
|
||||
console.error('Fetch data failed', err);
|
||||
setGroups([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentUserProfile]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
groupMap,
|
||||
loading,
|
||||
getGroups,
|
||||
};
|
||||
}
|
||||
44
src/pages/Alarm/components/AlarmDescription.tsx
Normal file
44
src/pages/Alarm/components/AlarmDescription.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CommentOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Space, Typography } from 'antd';
|
||||
import moment from 'moment';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface AlarmDescriptionProps {
|
||||
alarm: MasterModel.Alarm;
|
||||
}
|
||||
|
||||
const AlarmDescription = ({ alarm }: AlarmDescriptionProps) => {
|
||||
if (!alarm?.confirmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space size="large" wrap>
|
||||
<Space align="baseline" size={10}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
<Text type="secondary" style={{ fontSize: 15 }}>
|
||||
{alarm.confirmed_time
|
||||
? moment.unix(alarm.confirmed_time).format('YYYY-MM-DD HH:mm:ss')
|
||||
: '-'}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space size={10}>
|
||||
<UserOutlined style={{ color: '#1890ff' }} />
|
||||
<Text style={{ fontSize: 15 }}>{alarm.confirmed_email}</Text>
|
||||
</Space>
|
||||
{alarm.confirmed_desc && (
|
||||
<Space size={10}>
|
||||
<CommentOutlined style={{ color: '#faad14' }} />
|
||||
<Text style={{ fontSize: 15 }}>{alarm.confirmed_desc}</Text>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlarmDescription;
|
||||
152
src/pages/Alarm/components/AlarmFormConfirm.tsx
Normal file
152
src/pages/Alarm/components/AlarmFormConfirm.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { HTTPSTATUS } from '@/constants';
|
||||
import { apiConfirmAlarm } from '@/services/master/AlarmController';
|
||||
import { CheckOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ModalForm,
|
||||
ProForm,
|
||||
ProFormInstance,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { MessageInstance } from 'antd/es/message/interface';
|
||||
import moment from 'moment';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
type AlarmForm = {
|
||||
name: string;
|
||||
time: string;
|
||||
description: string;
|
||||
};
|
||||
type AlarmFormConfirmProps = {
|
||||
alarm: MasterModel.Alarm;
|
||||
message: MessageInstance;
|
||||
onFinish?: (reload: boolean) => void;
|
||||
};
|
||||
const AlarmFormConfirm = ({
|
||||
alarm,
|
||||
message,
|
||||
onFinish,
|
||||
}: AlarmFormConfirmProps) => {
|
||||
const [modalVisit, setModalVisit] = useState(false);
|
||||
const formRef = useRef<ProFormInstance<AlarmForm>>();
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<ModalForm<AlarmForm>
|
||||
open={modalVisit}
|
||||
formRef={formRef}
|
||||
title={
|
||||
<Flex align="center" justify="center">
|
||||
<FormattedMessage id="alarms.confirm.title" />
|
||||
</Flex>
|
||||
}
|
||||
width="40%"
|
||||
layout="horizontal"
|
||||
modalProps={{
|
||||
destroyOnHidden: true,
|
||||
}}
|
||||
onOpenChange={setModalVisit}
|
||||
request={async () => {
|
||||
return {
|
||||
name: alarm.name ?? '',
|
||||
time: moment.unix(alarm.time!).format('DD/MM/YYYY HH:mm:ss'),
|
||||
description: alarm.confirmed_desc ?? '',
|
||||
};
|
||||
}}
|
||||
onFinish={async (values) => {
|
||||
const body: MasterModel.ConfirmAlarmRequest = {
|
||||
id: alarm.id,
|
||||
description: values.description,
|
||||
thing_id: alarm.thing_id,
|
||||
time: alarm.time,
|
||||
};
|
||||
try {
|
||||
const resp = await apiConfirmAlarm(body);
|
||||
if (resp.status === HTTPSTATUS.HTTP_ACCEPTED) {
|
||||
message.success({
|
||||
content: intl.formatMessage({
|
||||
id: 'alarms.confirm.success',
|
||||
defaultMessage: 'Confirm alarm successfully',
|
||||
}),
|
||||
});
|
||||
onFinish?.(true);
|
||||
} else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) {
|
||||
message.warning({
|
||||
content: intl.formatMessage({
|
||||
id: 'alarms.not_found',
|
||||
defaultMessage: 'Alarm has expired or does not exist',
|
||||
}),
|
||||
});
|
||||
onFinish?.(true);
|
||||
} else {
|
||||
throw new Error('Failed to confirm alarm');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error when confirm alarm: ', error);
|
||||
message.error({
|
||||
content: intl.formatMessage({
|
||||
id: 'alarms.confirm.fail',
|
||||
defaultMessage: 'Confirm alarm failed',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
trigger={
|
||||
<Button
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="green"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => setModalVisit(true)}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'common.confirm',
|
||||
})}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ProForm.Group>
|
||||
<ProFormText
|
||||
name="name"
|
||||
label={intl.formatMessage({
|
||||
id: 'common.name',
|
||||
defaultMessage: 'Name',
|
||||
})}
|
||||
readonly={true}
|
||||
/>
|
||||
<ProFormText
|
||||
name="time"
|
||||
label={intl.formatMessage({
|
||||
id: 'alarms.occurred_at',
|
||||
defaultMessage: 'When',
|
||||
})}
|
||||
readonly={true}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProFormText
|
||||
fieldProps={{
|
||||
autoFocus: true,
|
||||
}}
|
||||
name="description"
|
||||
label={intl.formatMessage({
|
||||
id: 'common.description',
|
||||
defaultMessage: 'Description',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'alarms.confirm.description.required',
|
||||
defaultMessage: 'The description is required',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlarmFormConfirm;
|
||||
332
src/pages/Alarm/index.tsx
Normal file
332
src/pages/Alarm/index.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import ThingsFilter from '@/components/shared/ThingFilterModal';
|
||||
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE, HTTPSTATUS } from '@/constants';
|
||||
import {
|
||||
apiGetAlarms,
|
||||
apiUnconfirmAlarm,
|
||||
} from '@/services/master/AlarmController';
|
||||
import {
|
||||
CloseOutlined,
|
||||
DeleteOutlined,
|
||||
FilterOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd';
|
||||
import moment from 'moment';
|
||||
import { useRef, useState } from 'react';
|
||||
import AlarmDescription from './components/AlarmDescription';
|
||||
import AlarmFormConfirm from './components/AlarmFormConfirm';
|
||||
|
||||
const AlarmPage = () => {
|
||||
const tableRef = useRef<ActionType>();
|
||||
const [selectedKey, setSelectedKey] = useState<React.Key>();
|
||||
const [thingFilterVisible, setThingFilterVisible] = useState<boolean>(false);
|
||||
const [thingFilterDatas, setThingFilterDatas] = useState<string[]>([]);
|
||||
const intl = useIntl();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const columns: ProColumns<MasterModel.Alarm>[] = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'common.name',
|
||||
defaultMessage: 'Name',
|
||||
}),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
copyable: true,
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.alarms.severity',
|
||||
defaultMessage: 'Severity',
|
||||
}),
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
responsive: ['lg', 'md', 'sm'],
|
||||
filters: true,
|
||||
onFilter: true,
|
||||
valueType: 'select',
|
||||
valueEnum: {
|
||||
0: {
|
||||
text: intl.formatMessage({
|
||||
id: 'common.level.normal',
|
||||
defaultMessage: 'Normal',
|
||||
}),
|
||||
status: 'Success',
|
||||
},
|
||||
1: {
|
||||
text: intl.formatMessage({
|
||||
id: 'common.level.warning',
|
||||
defaultMessage: 'Warning',
|
||||
}),
|
||||
status: 'Warning',
|
||||
},
|
||||
2: {
|
||||
text: intl.formatMessage({
|
||||
id: 'common.level.critical',
|
||||
defaultMessage: 'Critical',
|
||||
}),
|
||||
status: 'Error',
|
||||
},
|
||||
3: {
|
||||
text: intl.formatMessage({
|
||||
id: 'common.level.sos',
|
||||
defaultMessage: 'SOS',
|
||||
}),
|
||||
status: 'Error',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'sgw.ship',
|
||||
defaultMessage: 'Source',
|
||||
}),
|
||||
dataIndex: 'thing_name',
|
||||
key: 'thing_name',
|
||||
responsive: ['lg', 'md', 'sm'],
|
||||
ellipsis: true,
|
||||
copyable: true,
|
||||
// render: (dom, row) => {
|
||||
// return (
|
||||
// <Paragraph copyable>
|
||||
// <Link
|
||||
// onClick={() => {
|
||||
// const t = row?.thing_type;
|
||||
// const path = `/devices/${row?.thing_id}/${t}`;
|
||||
// history.push({
|
||||
// pathname: path,
|
||||
// });
|
||||
// }}
|
||||
// >
|
||||
// {dom}
|
||||
// </Link>
|
||||
// </Paragraph>
|
||||
// );
|
||||
// },
|
||||
// render: (_, item) => {
|
||||
// return <Paragraph copyable>{item?.thing_name}</Paragraph>
|
||||
// }
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.alarms.occurred_at',
|
||||
defaultMessage: 'Occured time',
|
||||
}),
|
||||
dataIndex: 'time',
|
||||
key: 'time',
|
||||
search: false,
|
||||
render: (_, item) => {
|
||||
return moment.unix(item.time!).format(DATE_TIME_FORMAT);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.alarms.confirmed',
|
||||
defaultMessage: 'Confirmed',
|
||||
}),
|
||||
dataIndex: 'confirmed',
|
||||
responsive: ['lg', 'md'],
|
||||
align: 'center',
|
||||
width: '10%',
|
||||
search: false,
|
||||
render: (_, item) => {
|
||||
return item?.confirmed ? (
|
||||
<Progress type="circle" percent={100} size={24} />
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<ThingsFilter
|
||||
isOpen={thingFilterVisible}
|
||||
setIsOpen={setThingFilterVisible}
|
||||
thingIds={thingFilterDatas}
|
||||
onSubmit={(thingIds) => {
|
||||
console.log('ThingIDs: ', thingIds);
|
||||
setThingFilterDatas(thingIds);
|
||||
tableRef.current?.reload();
|
||||
}}
|
||||
/>
|
||||
{contextHolder}
|
||||
<ProTable<MasterModel.Alarm>
|
||||
actionRef={tableRef}
|
||||
rowKey={(record) => `${record.thing_id}_${record.id}`}
|
||||
columns={columns}
|
||||
rowSelection={{
|
||||
type: 'checkbox',
|
||||
selectedRowKeys: selectedKey ? [selectedKey] : [],
|
||||
onChange: (selectedKeys) => {
|
||||
// Checkbox with max 1 row selection - toggle behavior
|
||||
if (selectedKeys.length === 0) {
|
||||
setSelectedKey(undefined);
|
||||
} else {
|
||||
// Get the key that's different from current selection (the newly clicked row)
|
||||
const newKey = selectedKeys.find((key) => key !== selectedKey);
|
||||
setSelectedKey(newKey ?? selectedKeys[0]);
|
||||
}
|
||||
},
|
||||
}}
|
||||
size="large"
|
||||
tableAlertRender={({ selectedRows }) => {
|
||||
const alarm = selectedRows[0];
|
||||
return <AlarmDescription alarm={alarm} />;
|
||||
}}
|
||||
options={{
|
||||
search: false,
|
||||
setting: false,
|
||||
density: false,
|
||||
reload: true,
|
||||
}}
|
||||
toolbar={{
|
||||
actions: [
|
||||
<Tooltip
|
||||
title={intl.formatMessage({ id: 'master.alarms.filter_things' })}
|
||||
key="thing-filter-tooltip"
|
||||
>
|
||||
<Button
|
||||
color={thingFilterDatas.length > 0 ? 'red' : 'default'}
|
||||
icon={<FilterOutlined />}
|
||||
variant="text"
|
||||
onClick={() => setThingFilterVisible(true)}
|
||||
/>
|
||||
</Tooltip>,
|
||||
],
|
||||
}}
|
||||
tableAlertOptionRender={({ selectedRows, onCleanSelected }) => {
|
||||
const alarm = selectedRows[0];
|
||||
return (
|
||||
<Flex gap={10}>
|
||||
{alarm?.confirmed || false ? (
|
||||
<Popconfirm
|
||||
title={intl.formatMessage({
|
||||
id: 'master.alarms.unconfirm.body',
|
||||
defaultMessage:
|
||||
'Are you sure you want to unconfirm this alarm?',
|
||||
})}
|
||||
okText={intl.formatMessage({
|
||||
id: 'common.yes',
|
||||
defaultMessage: 'Yes',
|
||||
})}
|
||||
cancelText={intl.formatMessage({
|
||||
id: 'common.no',
|
||||
defaultMessage: 'No',
|
||||
})}
|
||||
onConfirm={async () => {
|
||||
const body: MasterModel.ConfirmAlarmRequest = {
|
||||
id: alarm.id,
|
||||
thing_id: alarm.thing_id,
|
||||
time: alarm.time,
|
||||
};
|
||||
try {
|
||||
const resp = await apiUnconfirmAlarm(body);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
message.success({
|
||||
content: intl.formatMessage({
|
||||
id: 'master.alarms.unconfirm.success',
|
||||
defaultMessage: 'Confirm alarm successfully',
|
||||
}),
|
||||
});
|
||||
tableRef.current?.reload();
|
||||
} else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) {
|
||||
message.warning({
|
||||
content: intl.formatMessage({
|
||||
id: 'master.alarms.not_found',
|
||||
defaultMessage:
|
||||
'Alarm has expired or does not exist',
|
||||
}),
|
||||
});
|
||||
tableRef.current?.reload();
|
||||
} else {
|
||||
throw new Error('Failed to confirm alarm');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error when unconfirm alarm: ', error);
|
||||
message.error({
|
||||
content: intl.formatMessage({
|
||||
id: 'master.alarms.unconfirm.fail',
|
||||
defaultMessage: 'Unconfirm alarm failed',
|
||||
}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />} size="small">
|
||||
<FormattedMessage id="master.alarms.unconfirm.title" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<AlarmFormConfirm
|
||||
alarm={alarm}
|
||||
message={messageApi}
|
||||
onFinish={(isReload) => {
|
||||
if (isReload && tableRef.current) {
|
||||
tableRef.current.reload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onCleanSelected}
|
||||
danger
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}}
|
||||
pagination={{
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['5', '10', '15', '20'],
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]}
|
||||
${intl.formatMessage({
|
||||
id: 'common.paginations.of',
|
||||
defaultMessage: 'of',
|
||||
})}
|
||||
${total} ${intl.formatMessage({
|
||||
id: 'master.alarms.table.pagination',
|
||||
defaultMessage: 'alarms',
|
||||
})}`,
|
||||
}}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, name, level, thing_name } = params;
|
||||
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
|
||||
const body: MasterModel.SearchAlarmPaginationBody = {
|
||||
name: name,
|
||||
order: 'name',
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
level: level as number | undefined,
|
||||
thing_name: thing_name,
|
||||
dir: 'desc',
|
||||
};
|
||||
if (thingFilterDatas.length > 0) {
|
||||
body.thing_id = Array.isArray(thingFilterDatas)
|
||||
? thingFilterDatas.join(',')
|
||||
: thingFilterDatas;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiGetAlarms(body);
|
||||
return {
|
||||
data: res.alarms,
|
||||
total: res.total,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
data: [],
|
||||
total: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}}
|
||||
></ProTable>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlarmPage;
|
||||
212
src/pages/Auth/index.tsx
Normal file
212
src/pages/Auth/index.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import Footer from '@/components/Footer';
|
||||
import LangSwitches from '@/components/Lang/LanguageSwitcherAuth';
|
||||
import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth';
|
||||
import { ROUTER_HOME } from '@/constants/routes';
|
||||
import { apiLogin, apiQueryProfile } from '@/services/master/AuthController';
|
||||
import { parseJwt } from '@/utils/jwt';
|
||||
import { getLogoImage } from '@/utils/logo';
|
||||
import { getBrowserId, getToken, removeToken, setToken } from '@/utils/storage';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
|
||||
import { history, useIntl, useModel } from '@umijs/max';
|
||||
import { Image, theme } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import mobifontLogo from '../../../public/mobifont-logo.png';
|
||||
const LoginPage = () => {
|
||||
const { token } = theme.useToken();
|
||||
const urlParams = new URL(window.location.href).searchParams;
|
||||
const redirect = urlParams.get('redirect');
|
||||
const intl = useIntl();
|
||||
const { setInitialState } = useModel('@@initialState');
|
||||
const getDomainTitle = () => {
|
||||
switch (process.env.DOMAIN_ENV) {
|
||||
case 'gms':
|
||||
return 'gms.title';
|
||||
case 'sgw':
|
||||
return 'sgw.title';
|
||||
case 'spole':
|
||||
return 'spole.title';
|
||||
default:
|
||||
return 'Smatec Master';
|
||||
}
|
||||
};
|
||||
|
||||
const checkLogin = async () => {
|
||||
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 {
|
||||
const userInfo = await apiQueryProfile();
|
||||
if (userInfo) {
|
||||
flushSync(() => {
|
||||
setInitialState((s: any) => ({
|
||||
...s,
|
||||
currentUserProfile: userInfo,
|
||||
}));
|
||||
});
|
||||
}
|
||||
if (redirect) {
|
||||
history.push(redirect);
|
||||
} else {
|
||||
history.push(ROUTER_HOME);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkLogin();
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (values: MasterModel.LoginRequestBody) => {
|
||||
try {
|
||||
const { email, password } = values;
|
||||
const resp = await apiLogin({
|
||||
guid: getBrowserId(),
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (resp?.token) {
|
||||
setToken(resp.token);
|
||||
const userInfo = await apiQueryProfile();
|
||||
if (userInfo) {
|
||||
flushSync(() => {
|
||||
setInitialState((s: any) => ({
|
||||
...s,
|
||||
currentUserProfile: userInfo,
|
||||
}));
|
||||
});
|
||||
}
|
||||
if (redirect) {
|
||||
history.push(redirect);
|
||||
} else {
|
||||
history.push(ROUTER_HOME);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
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"
|
||||
logo={getLogoImage()}
|
||||
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
|
||||
title={
|
||||
<span style={{ color: token.colorBgContainer }}>
|
||||
{intl.formatMessage({
|
||||
id: getDomainTitle(),
|
||||
defaultMessage: 'Smatec',
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
containerStyle={{
|
||||
backgroundColor: 'rgba(0, 0, 0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
subTitle={<Image preview={false} src={mobifontLogo} />}
|
||||
submitter={{
|
||||
searchConfig: {
|
||||
submitText: intl.formatMessage({
|
||||
id: 'master.auth.login.title',
|
||||
defaultMessage: 'Đăng nhập',
|
||||
}),
|
||||
},
|
||||
}}
|
||||
onFinish={async (values: MasterModel.LoginRequestBody) =>
|
||||
handleLogin(values)
|
||||
}
|
||||
>
|
||||
<>
|
||||
<ProFormText
|
||||
name="email"
|
||||
fieldProps={{
|
||||
autoComplete: 'email',
|
||||
autoFocus: true,
|
||||
size: 'large',
|
||||
prefix: (
|
||||
<UserOutlined
|
||||
style={{
|
||||
color: token.colorText,
|
||||
}}
|
||||
className={'prefixIcon'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'master.auth.login.email',
|
||||
defaultMessage: 'Email',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.auth.validation.email',
|
||||
defaultMessage: 'Email không được để trống!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProFormText.Password
|
||||
name="password"
|
||||
fieldProps={{
|
||||
size: 'large',
|
||||
autoComplete: 'current-password',
|
||||
prefix: (
|
||||
<LockOutlined
|
||||
style={{
|
||||
color: token.colorText,
|
||||
}}
|
||||
className={'prefixIcon'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'master.auth.password',
|
||||
defaultMessage: 'Mật khẩu',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.auth.validation.password',
|
||||
defaultMessage: 'Mật khẩu không được để trống!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
</LoginFormPage>
|
||||
<div className="absolute top-5 right-5 z-50 flex gap-4">
|
||||
<ThemeSwitcherAuth />
|
||||
<LangSwitches />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
zIndex: 99,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default LoginPage;
|
||||
22
src/pages/Exception/Internal/index.tsx
Normal file
22
src/pages/Exception/Internal/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ROUTER_HOME } from '@/constants/routes';
|
||||
import { history, useIntl } from '@umijs/max';
|
||||
import { Button, Result } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
const InternalServerErrorPage: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Result
|
||||
status="500"
|
||||
title="500"
|
||||
subTitle={intl.formatMessage({ id: 'common.internalserver' })}
|
||||
extra={
|
||||
<Button onClick={() => history.push(ROUTER_HOME)} type="primary">
|
||||
{intl.formatMessage({ id: 'common.back' })}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InternalServerErrorPage;
|
||||
22
src/pages/Exception/NotFound/index.tsx
Normal file
22
src/pages/Exception/NotFound/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ROUTER_HOME } from '@/constants/routes';
|
||||
import { history, useIntl } from '@umijs/max';
|
||||
import { Button, Result } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
const NotFoundPage: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title="404"
|
||||
subTitle={intl.formatMessage({ id: 'common.notfound' })}
|
||||
extra={
|
||||
<Button onClick={() => history.push(ROUTER_HOME)} type="primary">
|
||||
{intl.formatMessage({ id: 'common.back' })}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundPage;
|
||||
26
src/pages/Exception/UnAccess/index.tsx
Normal file
26
src/pages/Exception/UnAccess/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ROUTER_HOME } from '@/constants/routes';
|
||||
import { history, useIntl } from '@umijs/max';
|
||||
import { Button, Result } from 'antd';
|
||||
|
||||
const UnAccessPage = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Result
|
||||
status="403"
|
||||
title="403"
|
||||
subTitle={intl.formatMessage({ id: 'common.unaccess' })}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
history.push(ROUTER_HOME);
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({ id: 'common.back' })}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnAccessPage;
|
||||
5
src/pages/Manager/Device/index.tsx
Normal file
5
src/pages/Manager/Device/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const ManagerDevicePage = () => {
|
||||
return <div>ManagerDevicePage</div>;
|
||||
};
|
||||
|
||||
export default ManagerDevicePage;
|
||||
300
src/pages/Manager/Group/components/CreateOrUpdateGroup.tsx
Normal file
300
src/pages/Manager/Group/components/CreateOrUpdateGroup.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
apiCreateGroup,
|
||||
apiUpdateGroup,
|
||||
} from '@/services/master/GroupController';
|
||||
import {
|
||||
ModalForm,
|
||||
ProFormInstance,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||
import { MessageInstance } from 'antd/es/message/interface';
|
||||
import { useEffect, useRef } from 'react';
|
||||
type CreateOrUpdateGroupProps = {
|
||||
type: 'create-root' | 'create-child' | 'update';
|
||||
isOpen?: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
group?: MasterModel.GroupNode | null;
|
||||
message: MessageInstance;
|
||||
};
|
||||
type HandleGroupForm = {
|
||||
parent_id?: string;
|
||||
parent_name?: string;
|
||||
name: string;
|
||||
code: string;
|
||||
short_name: string;
|
||||
description?: string;
|
||||
};
|
||||
const CreateOrUpdateGroup = ({
|
||||
type,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
group,
|
||||
message,
|
||||
}: CreateOrUpdateGroupProps) => {
|
||||
const formRef = useRef<ProFormInstance<HandleGroupForm>>();
|
||||
const intl = useIntl();
|
||||
const { groups, getGroups } = useModel('master.useGroups');
|
||||
|
||||
// Sync form values when modal opens or group/type changes
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'useEffect trigger - isOpen:',
|
||||
isOpen,
|
||||
'group:',
|
||||
group,
|
||||
'type:',
|
||||
type,
|
||||
);
|
||||
if (isOpen && formRef.current) {
|
||||
if (type === 'update' && group) {
|
||||
formRef.current.setFieldsValue({
|
||||
parent_id: group?.id || '',
|
||||
parent_name: '',
|
||||
name: group?.name || '',
|
||||
code: group?.metadata?.code || '',
|
||||
short_name: group?.metadata?.short_name || '',
|
||||
description: group?.description || '',
|
||||
});
|
||||
} else if (type === 'create-child' && group) {
|
||||
formRef.current.setFieldsValue({
|
||||
parent_id: group?.id,
|
||||
parent_name: group?.name || '',
|
||||
name: '',
|
||||
code: '',
|
||||
short_name: '',
|
||||
description: '',
|
||||
});
|
||||
} else if (type === 'create-root') {
|
||||
formRef.current.setFieldsValue({
|
||||
parent_id: '',
|
||||
parent_name: '',
|
||||
name: '',
|
||||
code: '',
|
||||
short_name: '',
|
||||
description: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, group, type]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
};
|
||||
return (
|
||||
<ModalForm<HandleGroupForm>
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
layout="vertical"
|
||||
formRef={formRef}
|
||||
title={
|
||||
group === undefined ? (
|
||||
<FormattedMessage id="master.groups.root" />
|
||||
) : (
|
||||
<FormattedMessage id="master.groups.add" />
|
||||
)
|
||||
}
|
||||
onFinish={async (values) => {
|
||||
const body: Partial<MasterModel.GroupBodyRequest> = {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
metadata: {
|
||||
code: values.code,
|
||||
short_name: values.short_name,
|
||||
},
|
||||
};
|
||||
switch (type) {
|
||||
case 'update': {
|
||||
body.id = group?.id;
|
||||
try {
|
||||
const response = await apiUpdateGroup(body);
|
||||
if (response.id) {
|
||||
message?.success(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.update.success',
|
||||
defaultMessage: 'Updated successfully',
|
||||
}),
|
||||
);
|
||||
getGroups();
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error when update group: ', error);
|
||||
message?.error(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.update.failed',
|
||||
defaultMessage: 'Update failed, please try again!',
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'create-child': {
|
||||
body.parent_id = group?.id;
|
||||
try {
|
||||
const response = await apiCreateGroup(body);
|
||||
if (response.id) {
|
||||
message?.success(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.create.success',
|
||||
defaultMessage: 'Created group successfully',
|
||||
}),
|
||||
);
|
||||
setIsOpen(false);
|
||||
getGroups();
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error when create group: ', error);
|
||||
message?.error(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.create.failed',
|
||||
defaultMessage: 'Create group failed, please try again!',
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'create-root': {
|
||||
try {
|
||||
const response = await apiCreateGroup(body);
|
||||
if (response.id) {
|
||||
message?.success(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.create.success',
|
||||
defaultMessage: 'Created group successfully',
|
||||
}),
|
||||
);
|
||||
setIsOpen(false);
|
||||
getGroups();
|
||||
return true;
|
||||
} else {
|
||||
throw new Error('Create group failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error when create group: ', error);
|
||||
message?.error(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.create.failed',
|
||||
defaultMessage: 'Create group failed, please try again!',
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ProFormText name="parent_id" hidden />
|
||||
|
||||
{type !== 'update' && (
|
||||
<ProFormText
|
||||
name="parent_name"
|
||||
disabled
|
||||
label={intl.formatMessage({
|
||||
id: 'master.groups.parent',
|
||||
defaultMessage: 'Parent',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProFormText
|
||||
name="name"
|
||||
label={intl.formatMessage({
|
||||
id: 'common.name',
|
||||
defaultMessage: 'Name',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: (
|
||||
<FormattedMessage
|
||||
id="common.name.required"
|
||||
defaultMessage="The name is required"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ProFormText
|
||||
name="code"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.groups.code',
|
||||
defaultMessage: 'Code',
|
||||
})}
|
||||
rules={[
|
||||
{ required: true },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (
|
||||
value &&
|
||||
groups!.some(
|
||||
(g) => g?.metadata?.code === value && type !== 'update',
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.code.exists',
|
||||
defaultMessage: 'The code already exists',
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ProFormText
|
||||
name="short_name"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.groups.short_name',
|
||||
defaultMessage: 'Short name',
|
||||
})}
|
||||
rules={[
|
||||
{ required: true },
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (
|
||||
value &&
|
||||
groups!.some(
|
||||
(g) => g?.metadata?.short_name === value && type !== 'update',
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.short_name.exists',
|
||||
defaultMessage: 'The short name already exists',
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ProFormText
|
||||
name="description"
|
||||
label={intl.formatMessage({
|
||||
id: 'common.description',
|
||||
defaultMessage: 'Description',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: (
|
||||
<FormattedMessage
|
||||
id="common.description.required"
|
||||
defaultMessage="The description is required"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateOrUpdateGroup;
|
||||
451
src/pages/Manager/Group/index.tsx
Normal file
451
src/pages/Manager/Group/index.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import IconFont from '@/components/IconFont';
|
||||
import TreeGroup from '@/components/shared/TreeGroup';
|
||||
import { HTTPSTATUS } from '@/constants';
|
||||
import {
|
||||
apiDeleteGroup,
|
||||
apiUpdateGroup,
|
||||
} from '@/services/master/GroupController';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ProCard, ProDescriptions } from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||
import { Button, Grid, message, Modal } from 'antd';
|
||||
import { Dropdown, MenuProps, Tooltip } from 'antd/lib';
|
||||
import { useState } from 'react';
|
||||
import CreateOrUpdateGroup from './components/CreateOrUpdateGroup';
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
const ManagerGroupPage = () => {
|
||||
const { useBreakpoint } = Grid;
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const { currentUserProfile } = initialState || {};
|
||||
const intl = useIntl();
|
||||
const screens = useBreakpoint();
|
||||
const [selectedItem, setSelectedItem] = useState<
|
||||
MasterModel.GroupNode | undefined
|
||||
>();
|
||||
const [selectedGroup, setSelectedGroup] = useState<
|
||||
MasterModel.GroupNode | undefined
|
||||
>();
|
||||
|
||||
const { groups, getGroups } = useModel('master.useGroups');
|
||||
const [handleChildModal, setHandleChildModal] = useState<boolean>(false);
|
||||
const [type, setType] = useState<'create-root' | 'create-child' | 'update'>(
|
||||
'create-root',
|
||||
);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [modalKey, setModalKey] = useState(0);
|
||||
|
||||
const findGroupById = (
|
||||
groups: MasterModel.GroupNode[],
|
||||
id: string,
|
||||
): MasterModel.GroupNode | undefined => {
|
||||
for (const group of groups) {
|
||||
if (group.id === id) return group;
|
||||
if (group.children) {
|
||||
const found = findGroupById(group.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleUpdate = async (group: Partial<MasterModel.GroupNode>) => {
|
||||
const key = 'update_group';
|
||||
const { name, description, parent_id, code, short_name } = group;
|
||||
const editGroup = parent_id
|
||||
? {
|
||||
...selectedItem,
|
||||
name: name,
|
||||
parent_id: parent_id,
|
||||
metadata: {
|
||||
code: code,
|
||||
short_name: short_name,
|
||||
},
|
||||
description: description,
|
||||
}
|
||||
: {
|
||||
...selectedItem,
|
||||
name: name,
|
||||
metadata: {
|
||||
code: code,
|
||||
short_name: short_name,
|
||||
},
|
||||
description: description,
|
||||
};
|
||||
try {
|
||||
messageApi.open({
|
||||
type: 'loading',
|
||||
content: intl.formatMessage({
|
||||
id: 'common.updating',
|
||||
defaultMessage: 'updating...',
|
||||
}),
|
||||
key,
|
||||
});
|
||||
await apiUpdateGroup({ ...editGroup });
|
||||
messageApi.open({
|
||||
type: 'success',
|
||||
content: intl.formatMessage({
|
||||
id: 'master.groups.update.success',
|
||||
defaultMessage: 'Updated successfully',
|
||||
}),
|
||||
key,
|
||||
});
|
||||
await getGroups();
|
||||
return true;
|
||||
} catch (error) {
|
||||
messageApi.open({
|
||||
type: 'error',
|
||||
content: intl.formatMessage({
|
||||
id: 'master.groups.update.failed',
|
||||
defaultMessage: 'Updating failed, please try again!',
|
||||
}),
|
||||
key,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const showDeleteConfirm = (group: MasterModel.GroupNode) => {
|
||||
Modal.confirm({
|
||||
title: intl.formatMessage({
|
||||
id: 'master.groups.delete.confirm',
|
||||
defaultMessage: 'Are you sure delete this group',
|
||||
}),
|
||||
content: selectedItem?.name,
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
okText: intl.formatMessage({ id: 'common.yes', defaultMessage: 'Yes' }),
|
||||
okType: 'danger',
|
||||
cancelText: intl.formatMessage({ id: 'common.no', defaultMessage: 'No' }),
|
||||
async onOk() {
|
||||
try {
|
||||
const resp = await apiDeleteGroup(group.id);
|
||||
if (resp.status === HTTPSTATUS.HTTP_NOCONTENT) {
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.delete.success',
|
||||
defaultMessage: 'Deleted group successfully',
|
||||
}),
|
||||
);
|
||||
setSelectedItem(undefined);
|
||||
await getGroups();
|
||||
} else if (resp.status === HTTPSTATUS.HTTP_SERVERERROR) {
|
||||
messageApi.warning(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.delete.failed_internal',
|
||||
defaultMessage:
|
||||
'The group contains devices or users and cannot be deleted',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
throw new Error('Delete group failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error when delete group ', error);
|
||||
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'master.groups.delete.failed',
|
||||
defaultMessage: 'Delete group failed',
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const onItemClick = ({ key }: { key: string }) => {
|
||||
const splitIndex = key.indexOf('-');
|
||||
const action = key.substring(0, splitIndex);
|
||||
const groupId = key.substring(splitIndex + 1);
|
||||
|
||||
const groupNode = findGroupById(groups || [], groupId);
|
||||
console.log('GroupName', groupNode?.name);
|
||||
|
||||
setSelectedItem(groupNode);
|
||||
setSelectedGroup(groupNode);
|
||||
if (action === '1') {
|
||||
setType('create-child');
|
||||
} else if (action === '2') {
|
||||
setType('update');
|
||||
} else if (action === '3') {
|
||||
showDeleteConfirm(groupNode!);
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment modalKey to force remount component
|
||||
setModalKey((prev) => prev + 1);
|
||||
setHandleChildModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
|
||||
<CreateOrUpdateGroup
|
||||
type={type}
|
||||
key={modalKey}
|
||||
isOpen={handleChildModal}
|
||||
setIsOpen={setHandleChildModal}
|
||||
group={selectedGroup}
|
||||
message={messageApi}
|
||||
/>
|
||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||
<ProCard
|
||||
colSpan={{ xs: 24, sm: 24, md: 10, lg: 10, xl: 10 }}
|
||||
extra={[
|
||||
currentUserProfile?.metadata?.user_type === 'sysadmin' && (
|
||||
<Button
|
||||
type="primary"
|
||||
key="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setType('create-root');
|
||||
setSelectedItem(undefined);
|
||||
setModalKey((prev) => prev + 1);
|
||||
setHandleChildModal(true);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="master.groups.root"
|
||||
defaultMessage="Add root"
|
||||
/>
|
||||
</Button>
|
||||
),
|
||||
]}
|
||||
>
|
||||
<TreeGroup
|
||||
titleRender={(item) => {
|
||||
const groupNode = findGroupById(groups || [], item.key as string);
|
||||
const menus: MenuItem[] = [
|
||||
{
|
||||
label: (
|
||||
<Tooltip
|
||||
title={
|
||||
groupNode?.metadata?.has_thing
|
||||
? intl.formatMessage({
|
||||
id: 'master.groups.cannot-add-group',
|
||||
defaultMessage:
|
||||
'Cannot add child to a group that has things',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'master.groups.add',
|
||||
defaultMessage: 'Add child',
|
||||
})}
|
||||
</Tooltip>
|
||||
),
|
||||
key: `1-${groupNode?.id}`,
|
||||
|
||||
icon: <IconFont type="icon-leaf" />,
|
||||
disabled: groupNode?.metadata?.has_thing === true,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'common.edit',
|
||||
defaultMessage: 'Update',
|
||||
}),
|
||||
key: `2-${groupNode?.id}`,
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Tooltip
|
||||
title={
|
||||
groupNode?.metadata?.has_thing
|
||||
? intl.formatMessage({
|
||||
id: 'master.groups.delete.failed_internal',
|
||||
defaultMessage:
|
||||
'The group contains devices or users and cannot be deleted',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'common.delete',
|
||||
defaultMessage: 'Delete',
|
||||
})}
|
||||
</Tooltip>
|
||||
),
|
||||
key: `3-${groupNode?.id}`,
|
||||
icon: <DeleteOutlined />,
|
||||
disabled: groupNode?.metadata?.has_thing === true,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menus,
|
||||
onClick: onItemClick,
|
||||
}}
|
||||
trigger={['contextMenu']}
|
||||
placement="bottomRight"
|
||||
arrow
|
||||
>
|
||||
<span>
|
||||
{typeof item.title === 'function'
|
||||
? item.title(item)
|
||||
: item.title}
|
||||
</span>
|
||||
</Dropdown>
|
||||
);
|
||||
}}
|
||||
multiple={false}
|
||||
onSelected={(value: string | string[] | null) => {
|
||||
if (!groups) {
|
||||
setSelectedItem(undefined);
|
||||
return;
|
||||
}
|
||||
// Find the selected group from model
|
||||
if (value && !Array.isArray(value)) {
|
||||
const found = findGroupById(groups, value);
|
||||
setSelectedItem(found);
|
||||
} else {
|
||||
setSelectedItem(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 14, lg: 14, xl: 14 }}>
|
||||
{selectedItem?.name && (
|
||||
<>
|
||||
<ProDescriptions<Partial<MasterModel.GroupNode>>
|
||||
column={1}
|
||||
bordered
|
||||
title={selectedItem.name}
|
||||
extra={[
|
||||
<Tooltip
|
||||
key="add"
|
||||
title={
|
||||
selectedItem?.metadata?.has_thing
|
||||
? intl.formatMessage({
|
||||
id: 'master.groups.cannot-add-group',
|
||||
defaultMessage:
|
||||
'Cannot add child to a group that has things',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
key="add"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setType('create-child');
|
||||
setModalKey((prev) => prev + 1);
|
||||
setHandleChildModal(true);
|
||||
}}
|
||||
disabled={
|
||||
selectedItem.metadata?.has_thing === true ? true : false
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="master.groups.add"
|
||||
defaultMessage="Add Group"
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
<Tooltip
|
||||
key="add-fail"
|
||||
title={
|
||||
selectedItem?.metadata?.has_thing
|
||||
? intl.formatMessage({
|
||||
id: 'master.groups.delete.failed_internal',
|
||||
defaultMessage:
|
||||
'Cannot add child to a group that has things',
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
key="delete"
|
||||
title={intl.formatMessage({
|
||||
id: 'master.groups.delete.confirm',
|
||||
defaultMessage: 'Delete this group?',
|
||||
})}
|
||||
onClick={() => {
|
||||
showDeleteConfirm(selectedItem);
|
||||
}}
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={
|
||||
selectedItem.metadata?.has_thing === true ? true : false
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="common.delete"
|
||||
defaultMessage="Delete"
|
||||
/>
|
||||
</Button>
|
||||
,
|
||||
</Tooltip>,
|
||||
]}
|
||||
dataSource={{
|
||||
name: selectedItem.name,
|
||||
code: selectedItem.metadata?.code || '',
|
||||
short_name: selectedItem.metadata?.short_name || '',
|
||||
description: selectedItem.description || '',
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'common.name',
|
||||
defaultMessage: 'Name',
|
||||
}),
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.groups.code',
|
||||
defaultMessage: 'Code',
|
||||
}),
|
||||
dataIndex: 'code',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.groups.short_name',
|
||||
defaultMessage: 'Short name',
|
||||
}),
|
||||
dataIndex: 'short_name',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'common.description',
|
||||
defaultMessage: 'Description',
|
||||
}),
|
||||
dataIndex: 'description',
|
||||
},
|
||||
]}
|
||||
editable={{
|
||||
onSave: async (_, record) => {
|
||||
const updatedData: MasterModel.GroupNode = {
|
||||
...selectedItem,
|
||||
name: record.name || '',
|
||||
description: record.description || '',
|
||||
metadata: {
|
||||
...selectedItem.metadata,
|
||||
code: record.code,
|
||||
short_name: record.short_name,
|
||||
},
|
||||
};
|
||||
const success = await handleUpdate(updatedData);
|
||||
if (success) {
|
||||
setSelectedItem(updatedData);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagerGroupPage;
|
||||
484
src/pages/Manager/Log/index.tsx
Normal file
484
src/pages/Manager/Log/index.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE } from '@/constants';
|
||||
import { apiQueryLogs } from '@/services/master/LogController';
|
||||
import { apiQueryUsers } from '@/services/master/UserController';
|
||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { DatePicker } from 'antd/lib';
|
||||
import dayjs from 'dayjs';
|
||||
import { useRef } from 'react';
|
||||
|
||||
const SystemLogs = () => {
|
||||
const intl = useIntl();
|
||||
const tableRef = useRef<ActionType>();
|
||||
const actions = [
|
||||
//Alarm
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.things.alarm.confirm',
|
||||
defaultMessage: 'Alarm confirm',
|
||||
}),
|
||||
value: '0-0',
|
||||
selectable: false,
|
||||
children: [
|
||||
{
|
||||
value: 'things.alarm_confirm',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.things.confirm',
|
||||
defaultMessage: 'Confirm',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'things.alarm_unconfirm',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.things.unconfirm',
|
||||
defaultMessage: 'Unconfirm',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
//Things
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.things',
|
||||
defaultMessage: 'Things',
|
||||
}),
|
||||
value: '0-1',
|
||||
selectable: false,
|
||||
children: [
|
||||
{
|
||||
value: 'things.create',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.things.create',
|
||||
defaultMessage: 'Create new thing',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'things.update',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.things.update',
|
||||
defaultMessage: 'Update thing',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'things.remove',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.things.remove',
|
||||
defaultMessage: 'Remove thing',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'things.share',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.things.share',
|
||||
defaultMessage: 'Share thing',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'things.unshare',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.things.unshare',
|
||||
defaultMessage: 'Unshare thing',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'things.update_key',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.things.update_key',
|
||||
defaultMessage: 'Update key thing',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
// Users
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.users',
|
||||
defaultMessage: 'Users',
|
||||
}),
|
||||
value: '0-2',
|
||||
selectable: false,
|
||||
children: [
|
||||
{
|
||||
value: 'users.create',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.users.create',
|
||||
defaultMessage: 'Register user',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'users.update',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.users.update',
|
||||
defaultMessage: 'Update user',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'users.remove',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.users.remove',
|
||||
defaultMessage: 'Remove user',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'users.login',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.users.login',
|
||||
defaultMessage: 'User login',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
// Groups
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.groups',
|
||||
defaultMessage: 'Groups',
|
||||
}),
|
||||
value: '0-3',
|
||||
selectable: false,
|
||||
children: [
|
||||
{
|
||||
value: 'group.create',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.groups.create',
|
||||
defaultMessage: 'Create new group',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'group.update',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.groups.update',
|
||||
defaultMessage: 'Update group',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'group.remove',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.groups.remove',
|
||||
defaultMessage: 'Remove group',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'group.assign_thing',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.groups.assign_thing',
|
||||
defaultMessage: 'Assign thing to group',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'group.assign_user',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.groups.assign_user',
|
||||
defaultMessage: 'Assign user to group',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'group.unassign_thing',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.groups.unassign_thing',
|
||||
defaultMessage: 'Remove thing from group',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'group.unassign_user',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.groups.unassign_user',
|
||||
defaultMessage: 'Remove user from group',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
// Ships
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.ships',
|
||||
defaultMessage: 'Ships',
|
||||
}),
|
||||
value: '0-4',
|
||||
selectable: false,
|
||||
children: [
|
||||
{
|
||||
value: 'ships.create',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.ships.create',
|
||||
defaultMessage: 'Create new ship',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'ships.update',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.ships.update',
|
||||
defaultMessage: 'Update ship',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'ships.remove',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.ships.remove',
|
||||
defaultMessage: 'Remove ship',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'ships.assign_thing',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.ships.assign_thing',
|
||||
defaultMessage: 'Assign thing to ship',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'ships.assign_user',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.ships.assign_user',
|
||||
defaultMessage: 'Assign user to ship',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'ships.unassign_thing',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.ships.unassign_thing',
|
||||
defaultMessage: 'Remove thing from ship',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'ships.unassign_user',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.ships.unassign_user',
|
||||
defaultMessage: 'Remove user from ship',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
// Trips
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.trips',
|
||||
defaultMessage: 'Trips',
|
||||
}),
|
||||
value: '0-5',
|
||||
selectable: false,
|
||||
children: [
|
||||
{
|
||||
value: 'trips.create',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.trips.create',
|
||||
defaultMessage: 'Create new ship',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'trips.update',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.trips.update',
|
||||
defaultMessage: 'Update ship',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'trips.remove',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.trips.remove',
|
||||
defaultMessage: 'Remove ship',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'trips.approve',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.trips.approve',
|
||||
defaultMessage: 'Approve trip',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'trips.request_approve',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.trips.request_approve',
|
||||
defaultMessage: 'Request approval for trip',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'trips.unassign_thing',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.trips.unassign_thing',
|
||||
defaultMessage: 'Remove thing from ship',
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'trips.unassign_user',
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.trips.unassign_user',
|
||||
defaultMessage: 'Remove user from ship',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const queryUserSource = async (): Promise<MasterModel.ProfileResponse[]> => {
|
||||
try {
|
||||
const body: MasterModel.SearchUserPaginationBody = {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
order: 'email',
|
||||
dir: 'asc',
|
||||
};
|
||||
const resp = await apiQueryUsers(body);
|
||||
return resp?.users ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const columns: ProColumns<MasterModel.Message>[] = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.date.text',
|
||||
defaultMessage: 'Date',
|
||||
}),
|
||||
key: 'dateTimeRange',
|
||||
dataIndex: 'time',
|
||||
width: '20%',
|
||||
valueType: 'dateTimeRange',
|
||||
initialValue: [dayjs().add(-1, 'day'), dayjs()],
|
||||
search: {
|
||||
transform: (value) => ({ startTime: value[0], endTime: value[1] }),
|
||||
},
|
||||
render: (_, row) => {
|
||||
return dayjs.unix(row?.time || 0).format(DATE_TIME_FORMAT);
|
||||
},
|
||||
renderFormItem: (_, config, form) => {
|
||||
return (
|
||||
<DatePicker.RangePicker
|
||||
width="50%"
|
||||
presets={[
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'master.logs.search.yesterday',
|
||||
defaultMessage: 'Yesterday',
|
||||
}),
|
||||
value: [dayjs().add(-1, 'day'), dayjs()],
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'master.logs.search.last_week',
|
||||
defaultMessage: 'Last Week',
|
||||
}),
|
||||
value: [dayjs().add(-7, 'day'), dayjs()],
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'master.logs.search.last_month',
|
||||
defaultMessage: 'Last Month',
|
||||
}),
|
||||
value: [dayjs().add(-30, 'day'), dayjs()],
|
||||
},
|
||||
]}
|
||||
onChange={(dates) => {
|
||||
form.setFieldsValue({ dateTimeRange: dates });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.email.text',
|
||||
defaultMessage: 'Email',
|
||||
}),
|
||||
dataIndex: 'publisher',
|
||||
valueType: 'select',
|
||||
fieldProps: { mode: 'multiple' },
|
||||
width: '25%',
|
||||
request: async () => {
|
||||
const users = await queryUserSource();
|
||||
return users.map((u) => ({
|
||||
label: u.email,
|
||||
value: u.email,
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'master.logs.action.text',
|
||||
defaultMessage: 'Action',
|
||||
}),
|
||||
dataIndex: 'subtopic',
|
||||
valueType: 'treeSelect',
|
||||
width: '25%',
|
||||
fieldProps: {
|
||||
treeCheckable: true,
|
||||
multiple: true,
|
||||
},
|
||||
request: async () => actions,
|
||||
render: (_, item) => {
|
||||
const childs = actions.flatMap((a) => a.children || []);
|
||||
const action = childs.find((a) => a?.value === item.subtopic);
|
||||
return action?.title ?? '...';
|
||||
},
|
||||
},
|
||||
];
|
||||
return (
|
||||
<ProTable<MasterModel.Message>
|
||||
actionRef={tableRef}
|
||||
columns={columns}
|
||||
rowKey={(item) =>
|
||||
`${item.subtopic}:${item.time}:${item.publisher}:${item.name}`
|
||||
}
|
||||
size="large"
|
||||
options={{
|
||||
search: false,
|
||||
setting: false,
|
||||
density: false,
|
||||
reload: true,
|
||||
}}
|
||||
pagination={{
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '15', '20'],
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]}
|
||||
${intl.formatMessage({
|
||||
id: 'common.paginations.of',
|
||||
defaultMessage: 'of',
|
||||
})}
|
||||
${total} ${intl.formatMessage({
|
||||
id: 'master.logs.table.pagination',
|
||||
defaultMessage: 'activities',
|
||||
})}`,
|
||||
}}
|
||||
request={async (params) => {
|
||||
try {
|
||||
const { current, pageSize, subtopic, publisher, startTime, endTime } =
|
||||
params;
|
||||
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
|
||||
const body: MasterModel.SearchLogPaginationBody = {
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
from: startTime
|
||||
? typeof startTime === 'string'
|
||||
? dayjs(startTime).unix()
|
||||
: startTime.unix()
|
||||
: undefined,
|
||||
to: endTime
|
||||
? typeof endTime === 'string'
|
||||
? dayjs(endTime).unix()
|
||||
: endTime.unix()
|
||||
: undefined,
|
||||
publisher: publisher?.length ? publisher.join(',') : undefined,
|
||||
subtopic: subtopic?.length ? subtopic.join(',') : undefined,
|
||||
};
|
||||
const resp = await apiQueryLogs(body, 'user_logs');
|
||||
return {
|
||||
data: resp.messages || [],
|
||||
success: true,
|
||||
total: resp.total || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error when get logs: ', error);
|
||||
return {
|
||||
data: [],
|
||||
success: false,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemLogs;
|
||||
368
src/pages/Manager/User/components/CreateUser.tsx
Normal file
368
src/pages/Manager/User/components/CreateUser.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
|
||||
import { HTTPSTATUS } from '@/constants';
|
||||
import {
|
||||
apiCreateUsers,
|
||||
apiQueryUsers,
|
||||
} from '@/services/master/UserController';
|
||||
import { LockOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ModalForm,
|
||||
ProForm,
|
||||
ProFormInstance,
|
||||
ProFormSelect,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Button } from 'antd';
|
||||
import { MessageInstance } from 'antd/es/message/interface';
|
||||
import { useRef, useState } from 'react';
|
||||
type CreateUserProps = {
|
||||
message: MessageInstance;
|
||||
onSuccess?: (isSuccess: boolean) => void;
|
||||
};
|
||||
|
||||
type CreateUserFormValues = {
|
||||
full_name: string;
|
||||
phone_number: string;
|
||||
user_type: 'admin' | 'enduser' | 'sysadmin' | 'users';
|
||||
email: string;
|
||||
password: string;
|
||||
group_id?: string;
|
||||
};
|
||||
|
||||
const RoleSelectForm = () => {
|
||||
const domain = process.env.DOMAIN_ENV;
|
||||
const intl = useIntl();
|
||||
switch (domain) {
|
||||
case 'sgw':
|
||||
return (
|
||||
<ProFormSelect
|
||||
name="user_type"
|
||||
label={intl.formatMessage({ id: 'master.users.role' })}
|
||||
options={[
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'master.users.role.sgw.end_user',
|
||||
}),
|
||||
value: 'endusers',
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: 'master.users.role.user' }),
|
||||
value: 'users',
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: 'master.users.role.admin' }),
|
||||
value: 'admin',
|
||||
},
|
||||
]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.users.role.placeholder',
|
||||
defaultMessage: 'Please select a role',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case 'spole':
|
||||
return (
|
||||
<ProFormSelect
|
||||
name="user_type"
|
||||
label={intl.formatMessage({ id: 'master.users.role' })}
|
||||
options={[
|
||||
{
|
||||
label: intl.formatMessage({ id: 'master.users.role.user' }),
|
||||
value: 'users',
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: 'master.users.role.admin' }),
|
||||
value: 'admin',
|
||||
},
|
||||
]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.users.role.placeholder',
|
||||
defaultMessage: 'Please select a role',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case 'gms':
|
||||
default:
|
||||
return (
|
||||
<ProFormSelect
|
||||
name="user_type"
|
||||
label={intl.formatMessage({ id: 'master.users.role' })}
|
||||
options={[
|
||||
{
|
||||
label: intl.formatMessage({ id: 'master.users.role.user' }),
|
||||
value: 'users',
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: 'master.users.role.admin' }),
|
||||
value: 'admin',
|
||||
},
|
||||
]}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.users.role.placeholder',
|
||||
defaultMessage: 'Please select a role',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const CreateUser = ({ message, onSuccess }: CreateUserProps) => {
|
||||
const formRef = useRef<ProFormInstance<CreateUserFormValues>>();
|
||||
const intl = useIntl();
|
||||
const [group_id, setGroupId] = useState<string | string[] | null>(null);
|
||||
const handleGroupSelect = (group: string | string[] | null) => {
|
||||
setGroupId(group);
|
||||
// Đồng bộ giá trị vào form để validation hoạt động
|
||||
formRef.current?.setFieldsValue({
|
||||
group_id: Array.isArray(group) ? group.join(',') : group || undefined,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<ModalForm<CreateUserFormValues>
|
||||
title={intl.formatMessage({
|
||||
id: 'master.users.register.title',
|
||||
defaultMessage: 'Register New User',
|
||||
})}
|
||||
formRef={formRef}
|
||||
trigger={
|
||||
<Button type="primary" key="primary" icon={<PlusOutlined />}>
|
||||
<FormattedMessage
|
||||
id="master.users.register"
|
||||
defaultMessage="Register"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
autoFocusFirstInput
|
||||
onFinish={async (values: CreateUserFormValues) => {
|
||||
const body: MasterModel.CreateUserBodyRequest = {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
metadata: {
|
||||
full_name: values.full_name,
|
||||
phone_number: values.phone_number,
|
||||
user_type: values.user_type || 'enduser',
|
||||
group_id: values.group_id,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const resp = await apiCreateUsers(body);
|
||||
if (resp.status === HTTPSTATUS.HTTP_CREATED) {
|
||||
message.success(
|
||||
intl.formatMessage({
|
||||
id: 'master.users.create.success',
|
||||
defaultMessage: 'Create user successfully',
|
||||
}),
|
||||
);
|
||||
formRef.current?.resetFields();
|
||||
onSuccess?.(true);
|
||||
return true;
|
||||
} else {
|
||||
throw new Error('Create user failed');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(
|
||||
intl.formatMessage({
|
||||
id: 'master.users.create.error',
|
||||
defaultMessage: 'Failed to create user',
|
||||
}),
|
||||
);
|
||||
onSuccess?.(false);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ProFormText
|
||||
name={'full_name'}
|
||||
label={intl.formatMessage({
|
||||
id: 'master.users.full_name',
|
||||
defaultMessage: 'Full name',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'master.users.full_name.placeholder',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.users.full_name.required',
|
||||
defaultMessage: 'The full name is required',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<RoleSelectForm />
|
||||
|
||||
<ProFormText
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: (
|
||||
<FormattedMessage
|
||||
id="master.users.email.required"
|
||||
defaultMessage="The email is required"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
message: (
|
||||
<FormattedMessage
|
||||
id="master.users.email.invalid"
|
||||
defaultMessage="Invalid email address"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
validator: async (rule, value) => {
|
||||
if (value) {
|
||||
const query: MasterModel.SearchUserPaginationBody = {
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
order: 'email',
|
||||
email: value,
|
||||
dir: 'asc',
|
||||
};
|
||||
const resp = await apiQueryUsers(query);
|
||||
const { total } = resp;
|
||||
if (total && total > 0) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
intl.formatMessage({
|
||||
id: 'master.users.email.exists',
|
||||
defaultMessage: 'The email is exists',
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
name="email"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.users.email',
|
||||
defaultMessage: 'Email',
|
||||
})}
|
||||
fieldProps={{
|
||||
prefix: <UserOutlined />,
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'master.users.email.placeholder',
|
||||
defaultMessage: 'Email',
|
||||
})}
|
||||
validateTrigger={['onBlur']}
|
||||
/>
|
||||
<ProFormText
|
||||
name="phone_number"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.users.phone_number',
|
||||
defaultMessage: 'Phone number',
|
||||
})}
|
||||
fieldProps={{
|
||||
autoComplete: 'phone-number',
|
||||
}}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.users.phone_number.required',
|
||||
defaultMessage: 'The phone number is required',
|
||||
}),
|
||||
},
|
||||
{
|
||||
pattern: /((09|03|07|08|05)+([0-9]{8})\b)/g,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.users.phone_number.notvalid',
|
||||
defaultMessage: 'Invalid phone number',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProFormText.Password
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: (
|
||||
<FormattedMessage
|
||||
id="master.users.password"
|
||||
defaultMessage="Password is required"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
pattern: /^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*.]{6,16}$/g,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.profile.change-password.password.strong',
|
||||
defaultMessage:
|
||||
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
|
||||
}),
|
||||
},
|
||||
{
|
||||
validator: async (rule, value) => {
|
||||
if (value && value.length < 8) {
|
||||
return Promise.reject(
|
||||
intl.formatMessage({
|
||||
id: 'master.users.password.minimum',
|
||||
defaultMessage: 'Minimum password length is 8',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
name="password"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.users.password',
|
||||
defaultMessage: 'Password',
|
||||
})}
|
||||
fieldProps={{
|
||||
prefix: <LockOutlined />,
|
||||
autoComplete: 'current-password',
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'master.users.password.placeholder',
|
||||
defaultMessage: 'Password',
|
||||
})}
|
||||
/>
|
||||
<ProForm.Item
|
||||
name="group_id"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.users.groups',
|
||||
defaultMessage: 'Groups',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.users.groups.required',
|
||||
defaultMessage: 'Please select groups!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TreeSelectedGroup groupIds={group_id} onSelected={handleGroupSelect} />
|
||||
</ProForm.Item>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUser;
|
||||
258
src/pages/Manager/User/index.tsx
Normal file
258
src/pages/Manager/User/index.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import TreeGroup from '@/components/shared/TreeGroup';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants';
|
||||
import {
|
||||
apiQueryUsers,
|
||||
apiQueryUsersByGroup,
|
||||
} from '@/services/master/UserController';
|
||||
import {
|
||||
ActionType,
|
||||
ProCard,
|
||||
ProColumns,
|
||||
ProTable,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Grid } from 'antd';
|
||||
import message from 'antd/es/message';
|
||||
import Paragraph from 'antd/lib/typography/Paragraph';
|
||||
import { useRef, useState } from 'react';
|
||||
import CreateUser from './components/CreateUser';
|
||||
|
||||
const ManagerUserPage = () => {
|
||||
const { useBreakpoint } = Grid;
|
||||
const intl = useIntl();
|
||||
const screens = useBreakpoint();
|
||||
const actionRef = useRef<ActionType | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [selectedRowsState, setSelectedRowsState] = useState<
|
||||
MasterModel.ProfileResponse[]
|
||||
>([]);
|
||||
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
|
||||
string | string[] | null
|
||||
>(null);
|
||||
|
||||
const columns: ProColumns<MasterModel.ProfileResponse>[] = [
|
||||
{
|
||||
key: 'email',
|
||||
title: (
|
||||
<FormattedMessage id="master.users.email" defaultMessage="Email" />
|
||||
),
|
||||
tip: intl.formatMessage({
|
||||
id: 'master.users.email.tip',
|
||||
defaultMessage: 'The email is the unique key',
|
||||
}),
|
||||
dataIndex: 'email',
|
||||
render: (_, record) => (
|
||||
<Paragraph
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
verticalAlign: 'middle',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
copyable
|
||||
>
|
||||
{record?.email}
|
||||
</Paragraph>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
key: 'phone_number',
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="master.users.phone_number"
|
||||
defaultMessage="Phone number"
|
||||
/>
|
||||
),
|
||||
tip: intl.formatMessage({
|
||||
id: 'master.users.phone_number.tip',
|
||||
defaultMessage: 'The phone number is the unique key',
|
||||
}),
|
||||
responsive: ['lg', 'md'],
|
||||
dataIndex: ['metadata', 'phone_number'],
|
||||
render: (_, record) =>
|
||||
record?.metadata?.phone_number ? (
|
||||
<Paragraph
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
verticalAlign: 'middle',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
copyable
|
||||
>
|
||||
{record?.metadata?.phone_number}
|
||||
</Paragraph>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
key: 'user_type',
|
||||
hideInSearch: true,
|
||||
title: <FormattedMessage id="master.users.role" defaultMessage="Role" />,
|
||||
tip: intl.formatMessage({
|
||||
id: 'master.users.role.tip',
|
||||
defaultMessage: 'The role is the unique key',
|
||||
}),
|
||||
dataIndex: ['metadata', 'user_type'],
|
||||
render: (_, record) => record?.metadata?.user_type || '...',
|
||||
},
|
||||
|
||||
{
|
||||
title: (
|
||||
<FormattedMessage id="common.actions" defaultMessage="Operating" />
|
||||
),
|
||||
hideInSearch: true,
|
||||
render: () => {
|
||||
return (
|
||||
<>
|
||||
{/* <PermissionButton
|
||||
user={record}
|
||||
title={intl.formatMessage({
|
||||
id: 'master.users.assign',
|
||||
defaultMessage: 'Assgin',
|
||||
})}
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>
|
||||
<TreeGroup
|
||||
disable={isLoading}
|
||||
multiple={true}
|
||||
groupIds={groupCheckedKeys}
|
||||
onSelected={(value: string | string[] | null) => {
|
||||
setGroupCheckedKeys(value);
|
||||
if (actionRef.current) {
|
||||
actionRef.current.reload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
|
||||
<ProTable<MasterModel.ProfileResponse>
|
||||
columns={columns}
|
||||
tableLayout="auto"
|
||||
actionRef={actionRef}
|
||||
rowKey="id"
|
||||
search={{
|
||||
layout: 'vertical',
|
||||
defaultCollapsed: false,
|
||||
}}
|
||||
dateFormatter="string"
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRowsState.map((row) => row.id!),
|
||||
onChange: (
|
||||
_: React.Key[],
|
||||
selectedRows: MasterModel.ProfileResponse[],
|
||||
) => {
|
||||
setSelectedRowsState(selectedRows);
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '15', '20'],
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]}
|
||||
${intl.formatMessage({
|
||||
id: 'common.paginations.of',
|
||||
defaultMessage: 'of',
|
||||
})}
|
||||
${total} ${intl.formatMessage({
|
||||
id: 'master.users.table.pagination',
|
||||
defaultMessage: 'users',
|
||||
})}`,
|
||||
}}
|
||||
request={async (params = {}) => {
|
||||
const { current = 1, pageSize, email, phone_number } = params;
|
||||
const size = pageSize || DEFAULT_PAGE_SIZE * 2;
|
||||
const offset = current === 1 ? 0 : (current - 1) * size;
|
||||
setIsLoading(true);
|
||||
// If groups are checked, use queryUsersByGroup
|
||||
if (groupCheckedKeys && groupCheckedKeys.length > 0) {
|
||||
// Ensure groupCheckedKeys is an array
|
||||
const groupIdsArray = Array.isArray(groupCheckedKeys)
|
||||
? groupCheckedKeys.join(',')
|
||||
: groupCheckedKeys;
|
||||
|
||||
const userByGroupResponses = await apiQueryUsersByGroup(
|
||||
groupIdsArray,
|
||||
);
|
||||
let users = userByGroupResponses.users || [];
|
||||
// Apply filters
|
||||
if (email) {
|
||||
users = users.filter((user: MasterModel.ProfileResponse) =>
|
||||
user.email?.includes(email),
|
||||
);
|
||||
}
|
||||
if (phone_number) {
|
||||
users = users.filter((user: MasterModel.ProfileResponse) =>
|
||||
user.metadata?.phone_number?.includes(phone_number),
|
||||
);
|
||||
}
|
||||
|
||||
const total = users.length;
|
||||
const paginatedUsers = users.slice(offset, offset + size);
|
||||
setIsLoading(false);
|
||||
return {
|
||||
data: paginatedUsers,
|
||||
success: true,
|
||||
total: total,
|
||||
};
|
||||
} else {
|
||||
// Use regular queryUsers API
|
||||
const metadata: Partial<MasterModel.ProfileMetadata> = {};
|
||||
if (phone_number) metadata.phone_number = phone_number;
|
||||
|
||||
const query: MasterModel.SearchUserPaginationBody = {
|
||||
offset: offset,
|
||||
limit: size,
|
||||
order: 'name',
|
||||
dir: 'asc',
|
||||
};
|
||||
if (email) query.email = email;
|
||||
if (Object.keys(metadata).length > 0) query.metadata = metadata;
|
||||
|
||||
const response = await apiQueryUsers(query);
|
||||
setIsLoading(false);
|
||||
return {
|
||||
data: response.users,
|
||||
success: true,
|
||||
total: response.total,
|
||||
};
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
search: false,
|
||||
setting: false,
|
||||
density: false,
|
||||
reload: true,
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<CreateUser
|
||||
message={messageApi}
|
||||
onSuccess={(isSuccess) => {
|
||||
if (isSuccess) {
|
||||
actionRef.current?.reload();
|
||||
}
|
||||
}}
|
||||
key="create-user"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagerUserPage;
|
||||
162
src/pages/Profile/components/ChangePassword.tsx
Normal file
162
src/pages/Profile/components/ChangePassword.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { HTTPSTATUS } from '@/constants';
|
||||
import { apiChangePassword } from '@/services/master/AuthController';
|
||||
import {
|
||||
ProForm,
|
||||
ProFormInstance,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Button, message } from 'antd';
|
||||
import { useRef } from 'react';
|
||||
|
||||
type ChangePasswordForm = {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
};
|
||||
const ChangePassword = () => {
|
||||
const formRef = useRef<ProFormInstance<ChangePasswordForm>>();
|
||||
const intl = useIntl();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<ProForm<ChangePasswordForm>
|
||||
formRef={formRef}
|
||||
title={intl.formatMessage({
|
||||
id: 'master.profile.change-password',
|
||||
})}
|
||||
autoFocusFirstInput
|
||||
onFinish={async (values) => {
|
||||
if (values.new_password !== values.confirm_password) {
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'master.profile.change-password.password_not_match',
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
} else {
|
||||
console.log('Values: ', values);
|
||||
const changePasswordBody: MasterModel.ChangePasswordRequestBody = {
|
||||
old_password: values.old_password,
|
||||
password: values.new_password,
|
||||
};
|
||||
try {
|
||||
const resp = await apiChangePassword(changePasswordBody);
|
||||
if (resp.status === HTTPSTATUS.HTTP_CREATED) {
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'master.profile.change-password.success',
|
||||
}),
|
||||
);
|
||||
formRef.current?.resetFields();
|
||||
} else {
|
||||
throw new Error('Change password failed');
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'master.profile.change-password.fail',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
submitter={{
|
||||
resetButtonProps: false,
|
||||
render: () => {
|
||||
return [
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
formRef.current?.submit();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="common.confirm"
|
||||
defaultMessage="Cập nhật"
|
||||
/>
|
||||
</Button>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ProFormText.Password
|
||||
name="old_password"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.profile.change-password.old_password',
|
||||
})}
|
||||
fieldProps={{ autoComplete: 'current-password' }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({ id: 'common.required_field' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProFormText.Password
|
||||
name="new_password"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.profile.change-password.new_password',
|
||||
})}
|
||||
fieldProps={{ autoComplete: 'new-password' }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({ id: 'common.required_field' }),
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*.]{6,16}$/g,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.profile.change-password.password.strong',
|
||||
defaultMessage:
|
||||
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
|
||||
}),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(rule, value) {
|
||||
const old_password = getFieldValue('old_password');
|
||||
if (old_password === value) {
|
||||
return Promise.reject(
|
||||
intl.formatMessage({
|
||||
id: 'account.message.newpass.conflict',
|
||||
defaultMessage: 'New password cannot be the same as old',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
<ProFormText.Password
|
||||
name="confirm_password"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.profile.change-password.confirm_password',
|
||||
})}
|
||||
fieldProps={{ autoComplete: 'new-password-confirm' }}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(rule, value) {
|
||||
const new_password = getFieldValue('new_password');
|
||||
if (new_password === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(
|
||||
intl.formatMessage({
|
||||
id: 'master.profile.change-password.password_not_match',
|
||||
defaultMessage: 'Confirmation password does not match',
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
</ProForm>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePassword;
|
||||
106
src/pages/Profile/components/ChangeProfile.tsx
Normal file
106
src/pages/Profile/components/ChangeProfile.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { HTTPSTATUS } from '@/constants';
|
||||
import { apiUpdateProfile } from '@/services/master/AuthController';
|
||||
import {
|
||||
ProForm,
|
||||
ProFormInstance,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||
import { Button } from 'antd';
|
||||
import { message } from 'antd/lib';
|
||||
import { useRef } from 'react';
|
||||
type ChangeProfileForm = {
|
||||
email: string;
|
||||
full_name: string;
|
||||
phone_number: string;
|
||||
};
|
||||
const ChangeProfile = () => {
|
||||
const { initialState, refresh } = useModel('@@initialState');
|
||||
const formRef = useRef<ProFormInstance<ChangeProfileForm>>();
|
||||
const intl = useIntl();
|
||||
const [messageAPI, contextHolder] = message.useMessage();
|
||||
return (
|
||||
<ProForm<ChangeProfileForm>
|
||||
formRef={formRef}
|
||||
title={intl.formatMessage({
|
||||
id: 'master.profile.change-profile.title',
|
||||
})}
|
||||
initialValues={{
|
||||
email: initialState?.currentUserProfile?.email,
|
||||
full_name: initialState?.currentUserProfile?.metadata?.full_name,
|
||||
phone_number: initialState?.currentUserProfile?.metadata?.phone_number,
|
||||
}}
|
||||
onFinish={async (values) => {
|
||||
try {
|
||||
const body: Partial<MasterModel.ProfileMetadata> = {
|
||||
full_name: values.full_name,
|
||||
phone_number: values.phone_number,
|
||||
};
|
||||
const resp = await apiUpdateProfile(body);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
messageAPI.success(
|
||||
intl.formatMessage({
|
||||
id: 'master.profile.change-profile.update-success',
|
||||
}),
|
||||
);
|
||||
await refresh();
|
||||
} else {
|
||||
throw new Error('Update profile failed');
|
||||
}
|
||||
} catch (error) {
|
||||
messageAPI.error(
|
||||
intl.formatMessage({
|
||||
id: 'master.profile.change-profile.update-fail',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
submitter={{
|
||||
resetButtonProps: false,
|
||||
render: () => {
|
||||
return [
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
formRef.current?.submit();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="common.confirm" defaultMessage="Cập nhật" />
|
||||
</Button>,
|
||||
];
|
||||
},
|
||||
}}
|
||||
>
|
||||
{contextHolder}
|
||||
<ProFormText readonly name="email" label="Email" />
|
||||
<ProFormText
|
||||
name="full_name"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.profile.change-profile.full_name',
|
||||
})}
|
||||
fieldProps={{ autoFocus: true }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({ id: 'common.required_field' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProFormText
|
||||
name="phone_number"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.profile.change-profile.phone_number',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({ id: 'common.required_field' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ProForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangeProfile;
|
||||
5
src/pages/Profile/components/TwoFactorAuthentication.tsx
Normal file
5
src/pages/Profile/components/TwoFactorAuthentication.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const TwoFactorAuthentication = () => {
|
||||
return <div>TwoFactorAuthentication</div>;
|
||||
};
|
||||
|
||||
export default TwoFactorAuthentication;
|
||||
60
src/pages/Profile/index.tsx
Normal file
60
src/pages/Profile/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { LockOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Grid, Menu } from 'antd';
|
||||
import { MenuItemType } from 'antd/lib/menu/interface';
|
||||
import { useState } from 'react';
|
||||
import ChangePassword from './components/ChangePassword';
|
||||
import ChangeProfile from './components/ChangeProfile';
|
||||
import TwoFactorAuthentication from './components/TwoFactorAuthentication';
|
||||
|
||||
const ProfilePage = () => {
|
||||
const { useBreakpoint } = Grid;
|
||||
const intl = useIntl();
|
||||
const screens = useBreakpoint();
|
||||
const [keySelected, setKeySelected] = useState<string>('1');
|
||||
const items: MenuItemType[] = [
|
||||
{
|
||||
key: '1',
|
||||
icon: <UserOutlined />,
|
||||
label: intl.formatMessage({ id: 'master.profile.change-profile' }),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <LockOutlined />,
|
||||
label: intl.formatMessage({ id: 'master.profile.change-password' }),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
icon: <SafetyOutlined />,
|
||||
label: intl.formatMessage({
|
||||
id: 'master.profile.two-factor-authentication',
|
||||
}),
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
const handleMenuSelect = (e: { key: string }) => {
|
||||
setKeySelected(e.key);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>
|
||||
<Menu
|
||||
mode={screens.md ? 'vertical' : 'horizontal'}
|
||||
items={items}
|
||||
selectedKeys={[keySelected]}
|
||||
onSelect={handleMenuSelect}
|
||||
/>
|
||||
</ProCard>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
|
||||
{keySelected === '1' && <ChangeProfile />}
|
||||
{keySelected === '2' && <ChangePassword />}
|
||||
{keySelected === '3' && <TwoFactorAuthentication />}
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfilePage;
|
||||
11
src/pages/Slave/GMS/Monitor/index.tsx
Normal file
11
src/pages/Slave/GMS/Monitor/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const GMSMonitor: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Giám sát (GMS)</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GMSMonitor;
|
||||
11
src/pages/Slave/SGW/Manager/Area/index.tsx
Normal file
11
src/pages/Slave/SGW/Manager/Area/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const SGWArea: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Khu vực (SGW Manager)</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SGWArea;
|
||||
11
src/pages/Slave/SGW/Manager/Fish/index.tsx
Normal file
11
src/pages/Slave/SGW/Manager/Fish/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const SGWFish: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Cá (SGW Manager)</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SGWFish;
|
||||
11
src/pages/Slave/SGW/Map/index.tsx
Normal file
11
src/pages/Slave/SGW/Map/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const SGWMap: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Bản đồ (SGW)</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SGWMap;
|
||||
5
src/pages/Slave/SGW/Trip/index.tsx
Normal file
5
src/pages/Slave/SGW/Trip/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const TripPage = () => {
|
||||
return <div>TripPage</div>;
|
||||
};
|
||||
|
||||
export default TripPage;
|
||||
5
src/pages/Slave/Spole/Media/index.tsx
Normal file
5
src/pages/Slave/Spole/Media/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const MediaPage = () => {
|
||||
return <div>MediaPage</div>;
|
||||
};
|
||||
|
||||
export default MediaPage;
|
||||
11
src/pages/Slave/Spole/Mira/index.tsx
Normal file
11
src/pages/Slave/Spole/Mira/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const SpoleMira: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Mira (Spole Manager)</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpoleMira;
|
||||
11
src/pages/Slave/Spole/Miva/index.tsx
Normal file
11
src/pages/Slave/Spole/Miva/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const SpoleMiva: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Miva (Spole Manager)</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpoleMiva;
|
||||
11
src/pages/Slave/Spole/Monitor/index.tsx
Normal file
11
src/pages/Slave/Spole/Monitor/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const SpoleHome: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Trang chủ (Spole)</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpoleHome;
|
||||
5
src/pages/Slave/Spole/StreetLight/index.tsx
Normal file
5
src/pages/Slave/Spole/StreetLight/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const StreetLightPage = () => {
|
||||
return <div>StreetLightPage</div>;
|
||||
};
|
||||
|
||||
export default StreetLightPage;
|
||||
5
src/pages/Slave/Spole/TrafficLight/index.tsx
Normal file
5
src/pages/Slave/Spole/TrafficLight/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const TrafficLightPage = () => {
|
||||
return <div>TrafficLightPage</div>;
|
||||
};
|
||||
|
||||
export default TrafficLightPage;
|
||||
24
src/services/master/AlarmController.ts
Normal file
24
src/services/master/AlarmController.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { API_ALARMS, API_ALARMS_CONFIRM } from '@/constants/api';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
export async function apiGetAlarms(params?: MasterModel.SearchPaginationBody) {
|
||||
return request<MasterModel.AlarmsResponse>(API_ALARMS, {
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiConfirmAlarm(body: MasterModel.ConfirmAlarmRequest) {
|
||||
return request(API_ALARMS_CONFIRM, {
|
||||
method: 'POST',
|
||||
data: body,
|
||||
getResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiUnconfirmAlarm(body: MasterModel.ConfirmAlarmRequest) {
|
||||
return request(API_ALARMS_CONFIRM, {
|
||||
method: 'DELETE',
|
||||
data: body,
|
||||
getResponse: true,
|
||||
});
|
||||
}
|
||||
42
src/services/master/AuthController.ts
Normal file
42
src/services/master/AuthController.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
API_CHANGE_PASSWORD,
|
||||
API_PATH_GET_PROFILE,
|
||||
API_PATH_LOGIN,
|
||||
API_USERS,
|
||||
} from '@/constants/api';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
export async function apiLogin(body: MasterModel.LoginRequestBody) {
|
||||
// console.log('Login request body:', body);
|
||||
|
||||
return request<MasterModel.LoginResponse>(API_PATH_LOGIN, {
|
||||
method: 'POST',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiQueryProfile() {
|
||||
return request<MasterModel.ProfileResponse>(API_PATH_GET_PROFILE);
|
||||
}
|
||||
|
||||
export async function apiUpdateProfile(
|
||||
body: Partial<MasterModel.ProfileMetadata>,
|
||||
) {
|
||||
return request<MasterModel.ProfileResponse>(API_USERS, {
|
||||
method: 'PUT',
|
||||
data: {
|
||||
metadata: body,
|
||||
},
|
||||
getResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiChangePassword(
|
||||
body: MasterModel.ChangePasswordRequestBody,
|
||||
) {
|
||||
return request<void>(API_CHANGE_PASSWORD, {
|
||||
method: 'PATCH',
|
||||
data: body,
|
||||
getResponse: true,
|
||||
});
|
||||
}
|
||||
57
src/services/master/GroupController.ts
Normal file
57
src/services/master/GroupController.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { API_GROUP_MEMBERS, API_GROUPS } from '@/constants/api';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
export async function apiQueryGroups(
|
||||
params: MasterModel.GroupQueryParams,
|
||||
): Promise<MasterModel.GroupResponse> {
|
||||
return request<MasterModel.GroupQueryParams>(API_GROUPS, {
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiQueryGroupsById(
|
||||
id: string,
|
||||
): Promise<MasterModel.GroupResponse> {
|
||||
return request(`${API_GROUPS}/${id}`);
|
||||
}
|
||||
|
||||
export async function apiQueryMembers(
|
||||
user_id: string,
|
||||
): Promise<MasterModel.GroupResponse> {
|
||||
return request(`${API_GROUP_MEMBERS}/${user_id}/groups`);
|
||||
}
|
||||
|
||||
export async function apiQueryChildren(
|
||||
group_id: string,
|
||||
params: MasterModel.GroupQueryParams,
|
||||
): Promise<MasterModel.GroupResponse> {
|
||||
return request(`${API_GROUPS}/${group_id}/children`, {
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiUpdateGroup(body: Partial<MasterModel.GroupNode>) {
|
||||
return request<MasterModel.GroupNode>(`${API_GROUPS}/${body.id}`, {
|
||||
method: 'PUT',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
export async function apiCreateGroup(
|
||||
body: Partial<MasterModel.GroupBodyRequest>,
|
||||
) {
|
||||
return request<MasterModel.AddGroupBodyResponse>(`${API_GROUPS}`, {
|
||||
method: 'POST',
|
||||
data: body,
|
||||
contentType: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiDeleteGroup(group_id: string) {
|
||||
return request<MasterModel.AddGroupBodyResponse>(
|
||||
`${API_GROUPS}/${group_id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
getResponse: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
11
src/services/master/LogController.ts
Normal file
11
src/services/master/LogController.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { API_LOGS } from '@/constants/api';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
export async function apiQueryLogs(
|
||||
params: MasterModel.SearchLogPaginationBody,
|
||||
type: MasterModel.LogTypeRequest,
|
||||
) {
|
||||
return request<MasterModel.LogResponse>(`${API_LOGS}/${type}/messages`, {
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
30
src/services/master/ThingController.ts
Normal file
30
src/services/master/ThingController.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { API_THINGS_SEARCH } from '@/constants/api';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
export async function apiSearchThings(
|
||||
body: MasterModel.SearchPaginationBody,
|
||||
domain: string = process.env.DOMAIN_ENV || 'gms',
|
||||
) {
|
||||
switch (domain) {
|
||||
case 'sgw':
|
||||
return request<SgwModel.SgwThingsResponse>(API_THINGS_SEARCH, {
|
||||
method: 'POST',
|
||||
data: body,
|
||||
});
|
||||
case 'gms':
|
||||
return request<GmsModel.GmsThingsResponse>(API_THINGS_SEARCH, {
|
||||
method: 'POST',
|
||||
data: body,
|
||||
});
|
||||
case 'spole':
|
||||
return request<SpoleModel.SpoleThingsResponse>(API_THINGS_SEARCH, {
|
||||
method: 'POST',
|
||||
data: body,
|
||||
});
|
||||
default:
|
||||
return request<GmsModel.GmsThingsResponse>(API_THINGS_SEARCH, {
|
||||
method: 'POST',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/services/master/UserController.ts
Normal file
24
src/services/master/UserController.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { API_USERS, API_USERS_BY_GROUP } from '@/constants/api';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
export async function apiQueryUsers(
|
||||
params: MasterModel.SearchUserPaginationBody,
|
||||
) {
|
||||
return request<MasterModel.UserResponse>(API_USERS, {
|
||||
params: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiQueryUsersByGroup(
|
||||
group_id: string,
|
||||
): Promise<MasterModel.UserResponse> {
|
||||
return request<MasterModel.UserResponse>(`${API_USERS_BY_GROUP}/${group_id}`);
|
||||
}
|
||||
|
||||
export async function apiCreateUsers(body: MasterModel.CreateUserBodyRequest) {
|
||||
return request(API_USERS, {
|
||||
method: 'POST',
|
||||
data: body,
|
||||
getResponse: true,
|
||||
});
|
||||
}
|
||||
3
src/services/master/index.ts
Normal file
3
src/services/master/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable */
|
||||
|
||||
export default {};
|
||||
225
src/services/master/typings.d.ts
vendored
Normal file
225
src/services/master/typings.d.ts
vendored
Normal file
@@ -0,0 +1,225 @@
|
||||
/* eslint-disable */
|
||||
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||
|
||||
declare namespace MasterModel {
|
||||
interface SearchPaginationBody {
|
||||
name?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
dir?: 'asc' | 'desc';
|
||||
}
|
||||
interface SearchThingPaginationBody extends SearchPaginationBody {
|
||||
order?: string;
|
||||
metadata?: ThingMetadata;
|
||||
}
|
||||
interface ThingMetadata {
|
||||
group_id?: string;
|
||||
external_id?: string;
|
||||
}
|
||||
|
||||
interface SearchAlarmPaginationBody extends SearchPaginationBody {
|
||||
order?: 'name' | undefined;
|
||||
thing_name?: string;
|
||||
thing_id?: string;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
interface SearchUserPaginationBody extends SearchPaginationBody {
|
||||
order?: 'email' | 'name' | undefined;
|
||||
email?: string;
|
||||
metadata?: Partial<ProfileMetadata>;
|
||||
}
|
||||
interface SearchLogPaginationBody extends SearchPaginationBody {
|
||||
from?: number;
|
||||
to?: number;
|
||||
publisher?: string;
|
||||
subtopic?: string;
|
||||
}
|
||||
|
||||
interface LoginRequestBody {
|
||||
guid: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface ChangePasswordRequestBody {
|
||||
old_password: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface ProfileResponse {
|
||||
id?: string;
|
||||
email?: string;
|
||||
metadata?: ProfileMetadata;
|
||||
}
|
||||
|
||||
interface ProfileMetadata {
|
||||
frontend_thing_id?: string;
|
||||
frontend_thing_key?: string;
|
||||
full_name?: string;
|
||||
phone_number?: string;
|
||||
telegram?: string;
|
||||
user_type?: 'admin' | 'enduser' | 'sysadmin' | 'users';
|
||||
}
|
||||
|
||||
interface CreateUserMetadata extends ProfileMetadata {
|
||||
group_id?: string;
|
||||
}
|
||||
|
||||
interface CreateUserBodyRequest extends Partial<ProfileResponse> {
|
||||
password: string;
|
||||
full_name?: string;
|
||||
metadata?: CreateUserMetadata;
|
||||
}
|
||||
|
||||
interface UserResponse {
|
||||
total?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
users: ProfileResponse[];
|
||||
}
|
||||
interface AlarmsResponse {
|
||||
total?: number;
|
||||
limit?: number;
|
||||
order?: string;
|
||||
dir?: string;
|
||||
alarms?: Alarm[];
|
||||
}
|
||||
|
||||
interface ConfirmAlarmRequest {
|
||||
id?: string;
|
||||
description?: string;
|
||||
thing_id?: string;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
interface Alarm {
|
||||
name?: string;
|
||||
time?: number;
|
||||
level?: number;
|
||||
id?: string;
|
||||
confirmed?: boolean;
|
||||
confirmed_email?: string;
|
||||
confirmed_time?: number;
|
||||
confirmed_desc?: string;
|
||||
thing_id?: string;
|
||||
thing_name?: string;
|
||||
thing_type?: string;
|
||||
}
|
||||
interface ThingMetadata {
|
||||
address?: string;
|
||||
alarm_list?: string;
|
||||
cfg_channel_id?: string;
|
||||
connected?: boolean;
|
||||
ctrl_channel_id?: string;
|
||||
data_channel_id?: string;
|
||||
enduser?: string;
|
||||
external_id?: string;
|
||||
group_id?: string;
|
||||
req_channel_id?: string;
|
||||
state?: string;
|
||||
state_level?: number;
|
||||
state_updated_time?: number;
|
||||
type?: string;
|
||||
updated_time?: number;
|
||||
}
|
||||
|
||||
interface ThingsResponse<T extends ThingMetadata = ThingMetadata> {
|
||||
total?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
order?: string;
|
||||
direction?: string;
|
||||
metadata?: ThingsResponseMetadata;
|
||||
things?: Thing<T>[];
|
||||
}
|
||||
|
||||
interface ThingsResponseMetadata {
|
||||
total_connected?: number;
|
||||
total_filter?: number;
|
||||
total_sos?: number;
|
||||
total_state_level_0?: number;
|
||||
total_state_level_1?: number;
|
||||
total_state_level_2?: number;
|
||||
total_thing?: number;
|
||||
}
|
||||
|
||||
interface Thing<T extends ThingMetadata = ThingMetadata> {
|
||||
id?: string;
|
||||
name?: string;
|
||||
key?: string;
|
||||
metadata?: T;
|
||||
}
|
||||
|
||||
interface GroupBodyRequest {
|
||||
id?: string;
|
||||
name: string;
|
||||
parent_id?: string;
|
||||
description?: string;
|
||||
metadata?: GroupMetadata;
|
||||
}
|
||||
interface AddGroupBodyResponse extends Partial<GroupBodyRequest> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface GroupMetadata {
|
||||
code?: string;
|
||||
short_name?: string;
|
||||
has_thing?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface GroupResponse {
|
||||
total?: number;
|
||||
level?: number;
|
||||
name?: string;
|
||||
groups?: GroupNode[];
|
||||
}
|
||||
interface GroupNode {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
description: string;
|
||||
metadata: GroupMetadata;
|
||||
level: number;
|
||||
path: string;
|
||||
parent_id?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
children?: GroupNode[]; // Đệ quy: mỗi node có thể có children là mảng GroupNode
|
||||
[key: string]: any; // Nếu có thêm trường động
|
||||
}
|
||||
|
||||
interface GroupQueryParams {
|
||||
level?: number;
|
||||
tree?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
type LogTypeRequest = 'user_logs' | undefined;
|
||||
|
||||
interface LogResponse {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
publisher?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
format?: string;
|
||||
total?: number;
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
interface Message {
|
||||
channel?: string;
|
||||
subtopic?: string;
|
||||
publisher?: string;
|
||||
protocol?: string;
|
||||
name?: string;
|
||||
time?: number;
|
||||
string_value?: string;
|
||||
}
|
||||
}
|
||||
9
src/services/slave/gms/gms.typings.d.ts
vendored
Normal file
9
src/services/slave/gms/gms.typings.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable */
|
||||
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||
|
||||
declare namespace GmsModel {
|
||||
interface ThingMedata extends MasterModel.ThingMetadata {}
|
||||
|
||||
type GmsThingsResponse = MasterModel.ThingsResponse<GmsModel.ThingMedata>;
|
||||
type GmsThing = MasterModel.Thing<GmsModel.ThingMedata>;
|
||||
}
|
||||
3
src/services/slave/gms/index.ts
Normal file
3
src/services/slave/gms/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable */
|
||||
|
||||
export default {};
|
||||
3
src/services/slave/sgw/index.ts
Normal file
3
src/services/slave/sgw/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable */
|
||||
|
||||
export default {};
|
||||
25
src/services/slave/sgw/sgw.typing.d.ts
vendored
Normal file
25
src/services/slave/sgw/sgw.typing.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
/* eslint-disable */
|
||||
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||
|
||||
declare namespace SgwModel {
|
||||
interface ThingMedata extends MasterModel.ThingMetadata {
|
||||
gps?: string;
|
||||
gps_time?: string;
|
||||
ship_group_id?: string;
|
||||
ship_id?: string;
|
||||
ship_length?: string;
|
||||
ship_name?: string;
|
||||
ship_power?: string;
|
||||
ship_reg_number?: string;
|
||||
ship_type?: string;
|
||||
sos?: string;
|
||||
sos_time?: string;
|
||||
uptime?: number;
|
||||
zone_approaching_alarm_list?: string;
|
||||
zone_entered_alarm_list?: string;
|
||||
zone_fishing_alarm_list?: string;
|
||||
}
|
||||
|
||||
type SgwThingsResponse = MasterModel.ThingsResponse<SgwModel.ThingMedata>;
|
||||
type SgwThing = MasterModel.Thing<SgwModel.ThingMedata>;
|
||||
}
|
||||
3
src/services/slave/spole/index.ts
Normal file
3
src/services/slave/spole/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable */
|
||||
|
||||
export default {};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user