feat(core): sgw-device-ui
3
.eslintrc.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: require.resolve('@umijs/max/eslint'),
|
||||||
|
};
|
||||||
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/node_modules
|
||||||
|
/.env.local
|
||||||
|
/.umirc.local.ts
|
||||||
|
/config/config.local.ts
|
||||||
|
/src/.umi
|
||||||
|
/src/.umi-production
|
||||||
|
/src/.umi-test
|
||||||
|
/.umi
|
||||||
|
/.umi-production
|
||||||
|
/.umi-test
|
||||||
|
/dist
|
||||||
|
/.mfsu
|
||||||
|
.swc
|
||||||
|
.DS_Store
|
||||||
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx --no-install max verify-commit $1
|
||||||
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx --no-install lint-staged --quiet
|
||||||
17
.lintstagedrc
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"*.{md,json}": [
|
||||||
|
"prettier --cache --write"
|
||||||
|
],
|
||||||
|
"*.{js,jsx}": [
|
||||||
|
"max lint --fix --eslint-only",
|
||||||
|
"prettier --cache --write"
|
||||||
|
],
|
||||||
|
"*.{css,less}": [
|
||||||
|
"max lint --fix --stylelint-only",
|
||||||
|
"prettier --cache --write"
|
||||||
|
],
|
||||||
|
"*.ts?(x)": [
|
||||||
|
"max lint --fix --eslint-only",
|
||||||
|
"prettier --cache --parser=typescript --write"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.umi
|
||||||
|
.umi-production
|
||||||
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"proseWrap": "never",
|
||||||
|
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
|
||||||
|
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
|
||||||
|
}
|
||||||
3
.stylelintrc.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: require.resolve('@umijs/max/stylelint'),
|
||||||
|
};
|
||||||
48
.umirc.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineConfig } from '@umijs/max';
|
||||||
|
import proxy from './config/proxy';
|
||||||
|
|
||||||
|
const { REACT_APP_ENV = 'dev' } = process.env as {
|
||||||
|
REACT_APP_ENV: 'dev' | 'test' | 'prod';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
antd: {},
|
||||||
|
access: {},
|
||||||
|
model: {},
|
||||||
|
initialState: {},
|
||||||
|
request: {},
|
||||||
|
locale: {
|
||||||
|
default: 'vi-VN',
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
title: '2025 Sản phẩm của Mobifone v1.0',
|
||||||
|
},
|
||||||
|
proxy: proxy[REACT_APP_ENV],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: 'Login',
|
||||||
|
path: '/login',
|
||||||
|
component: './Auth',
|
||||||
|
layout: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/map',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Giám sát',
|
||||||
|
path: '/map',
|
||||||
|
component: './Home',
|
||||||
|
icon: 'icon-Map',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Chuyến đi',
|
||||||
|
path: '/trip',
|
||||||
|
component: './Trip',
|
||||||
|
icon: 'icon-specification',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
npmClient: 'pnpm',
|
||||||
|
tailwindcss: {},
|
||||||
|
});
|
||||||
112
config/Request.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { ROUTE_LOGIN } from '@/constants';
|
||||||
|
import { getToken, removeToken } from '@/utils/localStorageUtils';
|
||||||
|
import { history, RequestConfig } from '@umijs/max';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
|
// Error handling scheme: Error types
|
||||||
|
enum ErrorShowType {
|
||||||
|
SILENT = 0,
|
||||||
|
WARN_MESSAGE = 1,
|
||||||
|
ERROR_MESSAGE = 2,
|
||||||
|
NOTIFICATION = 3,
|
||||||
|
REDIRECT = 9,
|
||||||
|
}
|
||||||
|
// Response data structure agreed with the backend
|
||||||
|
interface ResponseStructure<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
errorCode?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
showType?: ErrorShowType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeMessage = {
|
||||||
|
200: 'The server successfully returned the requested data。',
|
||||||
|
201: 'New or modified data succeeded。',
|
||||||
|
202: 'A request has been queued in the background (asynchronous task)。',
|
||||||
|
204: 'Data deleted successfully。',
|
||||||
|
400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
|
||||||
|
401: 'The user does not have permission (token, username, password is wrong) 。',
|
||||||
|
403: 'User is authorized, but access is prohibited。',
|
||||||
|
404: 'The request issued was for a non-existent record, the server did not operate。',
|
||||||
|
406: 'The requested format is not available。',
|
||||||
|
410: 'The requested resource is permanently deleted and will no longer be available。',
|
||||||
|
422: 'When creating an object, a validation error occurred。',
|
||||||
|
500: 'Server error, please check the server。',
|
||||||
|
502: 'Gateway error。',
|
||||||
|
503: 'Service unavailable, server temporarily overloaded or maintained。',
|
||||||
|
504: 'Gateway timeout。',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runtime configuration
|
||||||
|
export const handleRequestConfig: RequestConfig = {
|
||||||
|
// Unified request settings
|
||||||
|
timeout: 20000,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
// Error handling: umi@3's error handling scheme.
|
||||||
|
errorConfig: {
|
||||||
|
// Error throwing
|
||||||
|
errorThrower: (res: any) => {
|
||||||
|
console.log('Response from backend:', res);
|
||||||
|
const { success, data, errorCode, errorMessage, showType } = res;
|
||||||
|
if (!success) {
|
||||||
|
const error: any = new Error(errorMessage);
|
||||||
|
error.name = 'BizError';
|
||||||
|
error.info = { errorCode, errorMessage, showType, data };
|
||||||
|
throw error; // Throw custom error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Error catching and handling
|
||||||
|
errorHandler: (error: any) => {
|
||||||
|
if (error.response) {
|
||||||
|
const { status, statusText, data } = error.response;
|
||||||
|
|
||||||
|
// Ưu tiên: codeMessage → backend message → statusText
|
||||||
|
const errMsg =
|
||||||
|
codeMessage[status as keyof typeof codeMessage] ||
|
||||||
|
data?.message ||
|
||||||
|
statusText ||
|
||||||
|
'Unknown error';
|
||||||
|
|
||||||
|
message.error(`❌ ${status}: ${errMsg}`);
|
||||||
|
if (status === 401) {
|
||||||
|
removeToken();
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
message.error('🚨 No response from server!');
|
||||||
|
} else {
|
||||||
|
message.error(`⚠️ Request setup error: ${error.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Request interceptors
|
||||||
|
requestInterceptors: [
|
||||||
|
(url: string, options: any) => {
|
||||||
|
const token = getToken();
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
options: {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
...(token ? { Authorization: `${token}` } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Unwrap data from backend response
|
||||||
|
// responseInterceptors: [
|
||||||
|
// (response) => {
|
||||||
|
// const res = response.data as ResponseStructure<any>;
|
||||||
|
// if (res && res.success) {
|
||||||
|
// // ✅ Trả ra data luôn thay vì cả object
|
||||||
|
// return res.data;
|
||||||
|
// }
|
||||||
|
// return response.data;
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
};
|
||||||
24
config/proxy.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const proxy: Record<string, any> = {
|
||||||
|
dev: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://192.168.30.85:9001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
'/test': {
|
||||||
|
target: 'https://test-sgw-device.gms.vn',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prod: {
|
||||||
|
'/test': {
|
||||||
|
target: 'https://prod-sgw-device.gms.vn',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default proxy;
|
||||||
20
mock/userAPI.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const users = [
|
||||||
|
{ id: 0, name: 'Umi', nickName: 'U', gender: 'MALE' },
|
||||||
|
{ id: 1, name: 'Fish', nickName: 'B', gender: 'FEMALE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
'GET /api/v1/queryUserList': (req: any, res: any) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: { list: users },
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
'PUT /api/v1/user/': (req: any, res: any) => {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"author": "",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "max dev",
|
||||||
|
"build": "max build",
|
||||||
|
"format": "prettier --cache --write .",
|
||||||
|
"prepare": "husky",
|
||||||
|
"postinstall": "max setup",
|
||||||
|
"setup": "max setup",
|
||||||
|
"start": "npm run dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.0.1",
|
||||||
|
"@ant-design/pro-components": "^2.4.4",
|
||||||
|
"@umijs/max": "^4.5.0",
|
||||||
|
"antd": "^5.4.0",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"moment": "^2.30.1",
|
||||||
|
"ol": "^10.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.0.33",
|
||||||
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"husky": "^9",
|
||||||
|
"lint-staged": "^13",
|
||||||
|
"prettier": "^2",
|
||||||
|
"prettier-plugin-organize-imports": "^3.2.2",
|
||||||
|
"prettier-plugin-packagejson": "^2.4.3",
|
||||||
|
"tailwindcss": "^3",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
14556
pnpm-lock.yaml
generated
Normal file
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/owner.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
10
src/access.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default (initialState: API.UserInfo) => {
|
||||||
|
// 在这里按照初始化数据定义项目中的权限,统一管理
|
||||||
|
// 参考文档 https://umijs.org/docs/max/access
|
||||||
|
const canSeeAdmin = !!(
|
||||||
|
initialState && initialState.name !== 'dontHaveAccess'
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
canSeeAdmin,
|
||||||
|
};
|
||||||
|
};
|
||||||
109
src/app.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { history, RunTimeLayoutConfig } from '@umijs/max';
|
||||||
|
import { handleRequestConfig } from '../config/Request';
|
||||||
|
import logo from '../public/logo.png';
|
||||||
|
import UnAccessPage from './components/403/403Page';
|
||||||
|
import Footer from './components/Footer/Footer';
|
||||||
|
import { ROUTE_LOGIN } from './constants';
|
||||||
|
import { parseJwt } from './utils/jwtTokenUtils';
|
||||||
|
import { getToken, removeToken } from './utils/localStorageUtils';
|
||||||
|
// 全局初始化数据配置,用于 Layout 用户信息和权限初始化
|
||||||
|
// 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate
|
||||||
|
export async function getInitialState() {
|
||||||
|
const token: string = getToken();
|
||||||
|
const { pathname } = history.location;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
if (pathname !== ROUTE_LOGIN) {
|
||||||
|
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseJwt(token);
|
||||||
|
if (!parsed) {
|
||||||
|
removeToken();
|
||||||
|
if (pathname !== ROUTE_LOGIN) {
|
||||||
|
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sub, exp } = parsed;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const oneHour = 60 * 60;
|
||||||
|
|
||||||
|
if (exp - now < oneHour) {
|
||||||
|
console.warn('Token expired or nearly expired, redirecting...');
|
||||||
|
removeToken();
|
||||||
|
if (pathname !== ROUTE_LOGIN) {
|
||||||
|
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentUser: sub,
|
||||||
|
exp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layout: RunTimeLayoutConfig = (initialState) => {
|
||||||
|
console.log('initialState', initialState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logo: logo,
|
||||||
|
fixedHeader: true,
|
||||||
|
contentWidth: 'Fluid',
|
||||||
|
navTheme: 'light',
|
||||||
|
splitMenus: true,
|
||||||
|
title: 'SGW',
|
||||||
|
iconfontUrl: '//at.alicdn.com/t/c/font_1970684_76mmjhln75w.js',
|
||||||
|
menu: {
|
||||||
|
locale: false,
|
||||||
|
},
|
||||||
|
contentStyle: {
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
layout: 'mix',
|
||||||
|
logout: () => {
|
||||||
|
removeToken();
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
},
|
||||||
|
footerRender: () => <Footer />,
|
||||||
|
onPageChange: () => {
|
||||||
|
if (!initialState.initialState) {
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
menuHeaderRender: undefined,
|
||||||
|
bgLayoutImgList: [
|
||||||
|
{
|
||||||
|
src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/D2LWSqNny4sAAAAAAAAAAAAAFl94AQBr',
|
||||||
|
left: 85,
|
||||||
|
bottom: 100,
|
||||||
|
height: '303px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/C2TWRpJpiC0AAAAAAAAAAAAAFl94AQBr',
|
||||||
|
bottom: -68,
|
||||||
|
right: -45,
|
||||||
|
height: '303px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/F6vSTbj8KpYAAAAAAAAAAAAAFl94AQBr',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '331px',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
unAccessible: <UnAccessPage />,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const request = handleRequestConfig;
|
||||||
0
src/assets/.gitkeep
Normal file
BIN
src/assets/alarm_icon.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src/assets/exclamation.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
src/assets/marker.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/ship_alarm.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src/assets/ship_alarm_2.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/ship_alarm_fishing.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
src/assets/ship_online.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src/assets/ship_online_fishing.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src/assets/ship_undefine.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src/assets/ship_warning.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src/assets/ship_warning_fishing.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src/assets/sos_icon.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
src/assets/warning_icon.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
13
src/components/403/403Page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Result } from 'antd';
|
||||||
|
|
||||||
|
const UnAccessPage: React.FC = () => (
|
||||||
|
<Result
|
||||||
|
status="403"
|
||||||
|
title="403"
|
||||||
|
subTitle="Sorry, you are not authorized to access this page."
|
||||||
|
extra={<Button type="primary">Back Home</Button>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default UnAccessPage;
|
||||||
13
src/components/404/404Page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Button, Result } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const NotFoundPage: React.FC = () => (
|
||||||
|
<Result
|
||||||
|
status="404"
|
||||||
|
title="404"
|
||||||
|
subTitle="Sorry, the page you visited does not exist."
|
||||||
|
extra={<Button type="primary">Back Home</Button>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default <NotFoundPage />;
|
||||||
13
src/components/500/500Page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Button, Result } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const InternalServerErrorPage: React.FC = () => (
|
||||||
|
<Result
|
||||||
|
status="500"
|
||||||
|
title="500"
|
||||||
|
subTitle="Sorry, something went wrong."
|
||||||
|
extra={<Button type="primary">Back Home</Button>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default InternalServerErrorPage;
|
||||||
7
src/components/Footer/Footer.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { DefaultFooter } from '@ant-design/pro-components';
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
return <DefaultFooter copyright="2025 Sản phẩm của Mobifone v1.0" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
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
@@ -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
@@ -0,0 +1,2 @@
|
|||||||
|
import Guide from './Guide';
|
||||||
|
export default Guide;
|
||||||
8
src/constants/enums.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export enum STATUS {
|
||||||
|
CREATE_FISHING_LOG_SUCCESS = 'CREATE_FISHING_LOG_SUCCESS',
|
||||||
|
CREATE_FISHING_LOG_FAIL = 'CREATE_FISHING_LOG_FAIL',
|
||||||
|
START_TRIP_SUCCESS = 'START_TRIP_SUCCESS',
|
||||||
|
START_TRIP_FAIL = 'START_TRIP_FAIL',
|
||||||
|
UPDATE_FISHING_LOG_SUCCESS = 'UPDATE_FISHING_LOG_SUCCESS',
|
||||||
|
UPDATE_FISHING_LOG_FAIL = 'UPDATE_FISHING_LOG_FAIL',
|
||||||
|
}
|
||||||
24
src/constants/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export const DEFAULT_NAME = 'Umi Max';
|
||||||
|
export const TOKEN = 'token';
|
||||||
|
export const BASE_URL = 'https://sgw-device.gms.vn';
|
||||||
|
// Global Constants
|
||||||
|
|
||||||
|
// Route Constants
|
||||||
|
export const ROUTE_LOGIN = '/login';
|
||||||
|
export const ROUTE_HOME = '/map';
|
||||||
|
export const ROUTE_TRIP = '/trip';
|
||||||
|
|
||||||
|
// API Path Constants
|
||||||
|
export const API_PATH_LOGIN = '/api/agent/login';
|
||||||
|
export const API_PATH_ENTITIES = '/api/agent/entities';
|
||||||
|
export const API_PATH_SHIP_INFO = '/api/sgw/shipinfo';
|
||||||
|
export const API_GET_ALL_LAYER = '/api/sgw/geojsonlist';
|
||||||
|
export const API_GET_LAYER_INFO = '/api/sgw/geojson';
|
||||||
|
export const API_GET_TRIP = '/api/sgw/trip';
|
||||||
|
export const API_GET_ALARMS = '/api/agent/alarms';
|
||||||
|
export const API_UPDATE_TRIP_STATUS = '/api/sgw/tripState';
|
||||||
|
export const API_HAUL_HANDLE = '/api/sgw/fishingLog';
|
||||||
|
export const API_GET_GPS = '/api/sgw/gps';
|
||||||
|
export const API_GET_FISH = '/api/sgw/fishspecies';
|
||||||
|
export const API_UPDATE_FISHING_LOGS = '/api/sgw/fishingLog';
|
||||||
|
export const API_SOS = '/api/sgw/sos';
|
||||||
25
src/models/getAlarm.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { queryAlarms } from '@/services/controller/DeviceController';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export default function useAlarmModel() {
|
||||||
|
const [alarmData, setAlarmData] = useState<API.AlarmResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const getAlarmData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await queryAlarms(); // đổi URL cho phù hợp
|
||||||
|
console.log('Alarm Data fetched:', res);
|
||||||
|
|
||||||
|
setAlarmData(res || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch alarm data failed', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return {
|
||||||
|
alarmData,
|
||||||
|
loading,
|
||||||
|
getAlarmData,
|
||||||
|
};
|
||||||
|
}
|
||||||
25
src/models/getSos.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { getGPS } from '@/services/controller/DeviceController';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export default function useGetGpsModel() {
|
||||||
|
const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const getGPSData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getGPS(); // đổi URL cho phù hợp
|
||||||
|
console.log('GPS Data fetched:', res);
|
||||||
|
|
||||||
|
setGpsData(res || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch gps data failed', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return {
|
||||||
|
gpsData,
|
||||||
|
loading,
|
||||||
|
getGPSData,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
src/models/getTrip.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { getTrip } from '@/services/controller/TripController';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export default function useGetTripModel() {
|
||||||
|
const [data, setData] = useState<API.Trip | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const getApi = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getTrip(); // đổi URL cho phù hợp
|
||||||
|
setData(res || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch data failed', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
getApi,
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/models/global.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// 全局共享数据示例
|
||||||
|
import { DEFAULT_NAME } from '@/constants';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const useUser = () => {
|
||||||
|
const [name, setName] = useState<string>(DEFAULT_NAME);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useUser;
|
||||||
133
src/pages/Auth/index.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { ROUTE_HOME } from '@/constants';
|
||||||
|
import { login } from '@/services/controller/AuthController';
|
||||||
|
import { parseJwt } from '@/utils/jwtTokenUtils';
|
||||||
|
import { getToken, removeToken, setToken } from '@/utils/localStorageUtils';
|
||||||
|
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
|
||||||
|
import { history } from '@umijs/max';
|
||||||
|
import { Image, theme } from 'antd';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import logoImg from '../../../public/logo.png';
|
||||||
|
import mobifontImg from '../../../public/owner.png';
|
||||||
|
|
||||||
|
const LoginPage = () => {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const urlParams = new URL(window.location.href).searchParams;
|
||||||
|
const redirect = urlParams.get('redirect');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkLogin();
|
||||||
|
}, []);
|
||||||
|
const checkLogin = () => {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parseJwt(token);
|
||||||
|
const { sub, exp } = parsed;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const oneHour = 60 * 60;
|
||||||
|
if (exp - now < oneHour) {
|
||||||
|
removeToken();
|
||||||
|
} else {
|
||||||
|
if (redirect) {
|
||||||
|
history.push(redirect);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTE_HOME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async (values: API.LoginRequestBody) => {
|
||||||
|
try {
|
||||||
|
const resp = await login(values);
|
||||||
|
if (resp?.token) {
|
||||||
|
setToken(resp.token);
|
||||||
|
if (redirect) {
|
||||||
|
history.push(redirect);
|
||||||
|
} else {
|
||||||
|
history.push(ROUTE_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={logoImg}
|
||||||
|
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
|
||||||
|
title={
|
||||||
|
<span style={{ color: token.colorBgContainer }}>
|
||||||
|
Hệ thống giám sát tàu cá
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
containerStyle={{
|
||||||
|
backgroundColor: 'rgba(0, 0, 0,0.65)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
}}
|
||||||
|
subTitle={<Image preview={false} src={mobifontImg} />}
|
||||||
|
submitter={{
|
||||||
|
searchConfig: {
|
||||||
|
submitText: 'Đăng nhập',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onFinish={async (values: API.LoginRequestBody) => handleLogin(values)}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<ProFormText
|
||||||
|
name="username"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: (
|
||||||
|
<UserOutlined
|
||||||
|
style={{
|
||||||
|
color: token.colorText,
|
||||||
|
}}
|
||||||
|
className={'prefixIcon'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
placeholder={'Tài khoản'}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Tài khoản không được để trống!',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="password"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: (
|
||||||
|
<LockOutlined
|
||||||
|
style={{
|
||||||
|
color: token.colorText,
|
||||||
|
}}
|
||||||
|
className={'prefixIcon'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
placeholder={'Mật khẩu'}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Mật khẩu không được để trống!',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</LoginFormPage>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default LoginPage;
|
||||||
664
src/pages/Home/components/BaseMap.tsx
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
import { getAllLayer, getLayer } from '@/services/controller/MapController';
|
||||||
|
import { INITIAL_VIEW_CONFIG, osmLayer } from '@/services/service/MapService';
|
||||||
|
import {
|
||||||
|
createGeoJSONLayer,
|
||||||
|
createLabelAndFillStyle,
|
||||||
|
OL_PROJECTION,
|
||||||
|
} from '@/utils/mapUtils';
|
||||||
|
import { Feature, Map, View } from 'ol';
|
||||||
|
import { Coordinate } from 'ol/coordinate';
|
||||||
|
import { createEmpty, extend, Extent, isEmpty } from 'ol/extent';
|
||||||
|
import { MultiPolygon, Point, Polygon } from 'ol/geom';
|
||||||
|
import BaseLayer from 'ol/layer/Base';
|
||||||
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
|
import { fromLonLat } from 'ol/proj';
|
||||||
|
import VectorSource from 'ol/source/Vector';
|
||||||
|
import Fill from 'ol/style/Fill';
|
||||||
|
import Icon from 'ol/style/Icon';
|
||||||
|
import Stroke from 'ol/style/Stroke';
|
||||||
|
import Style from 'ol/style/Style';
|
||||||
|
import Text from 'ol/style/Text';
|
||||||
|
|
||||||
|
interface MapManagerConfig {
|
||||||
|
onFeatureClick?: (feature: any) => void;
|
||||||
|
onFeatureSelect?: (feature: any, pixel: any) => void;
|
||||||
|
onFeaturesClick?: (features: any[]) => void;
|
||||||
|
onError?: (error: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StyleConfig {
|
||||||
|
icon?: string; // URL của icon
|
||||||
|
font?: string; // font chữ cho text
|
||||||
|
textColor?: string; // màu chữ
|
||||||
|
textStrokeColor?: string; // màu viền chữ
|
||||||
|
textOffsetY?: number; // độ lệch theo trục Y
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for feature data
|
||||||
|
interface FeatureData {
|
||||||
|
bearing?: number;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for layer object
|
||||||
|
interface Layer {
|
||||||
|
layer: VectorLayer<VectorSource>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapManager {
|
||||||
|
mapRef: React.RefObject<HTMLDivElement>;
|
||||||
|
map: Map | null;
|
||||||
|
featureLayer: VectorLayer<VectorSource> | null;
|
||||||
|
private features: Feature[];
|
||||||
|
private zoomListenerAdded: boolean;
|
||||||
|
private onFeatureClick: (feature: Feature) => void;
|
||||||
|
private onFeatureSelect: (feature: Feature, pixel: number[]) => void;
|
||||||
|
private onFeaturesClick: (features: Feature[]) => void;
|
||||||
|
private onError: (errors: string[]) => void;
|
||||||
|
private errors: string[];
|
||||||
|
private layers: BaseLayer[]; // Assuming layers is defined elsewhere
|
||||||
|
private isInitialized: boolean; // Thêm thuộc tính để theo dõi trạng thái khởi tạo
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
mapRef: React.RefObject<HTMLDivElement>,
|
||||||
|
config: MapManagerConfig = {},
|
||||||
|
) {
|
||||||
|
this.mapRef = mapRef;
|
||||||
|
this.map = null;
|
||||||
|
this.featureLayer = null;
|
||||||
|
this.features = [];
|
||||||
|
this.zoomListenerAdded = false;
|
||||||
|
this.onFeatureClick = config.onFeatureClick || (() => {});
|
||||||
|
this.onFeatureSelect = config.onFeatureSelect || (() => {});
|
||||||
|
this.onFeaturesClick = config.onFeaturesClick || (() => {});
|
||||||
|
this.onError = config.onError || (() => {});
|
||||||
|
this.errors = [];
|
||||||
|
this.layers = []; // Initialize layers (adjust based on actual usage)
|
||||||
|
this.isInitialized = false; // Khởi tạo là false
|
||||||
|
}
|
||||||
|
|
||||||
|
async getListLayers(): Promise<[]> {
|
||||||
|
const resp: [] = await getAllLayer();
|
||||||
|
console.log('resp', resp);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeMap(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const listLayers: string[] = await this.getListLayers(); // định nghĩa LayerMeta { id: string; name: string; ... }
|
||||||
|
const dynamicLayers: BaseLayer[] = [];
|
||||||
|
|
||||||
|
for (const layerMeta of listLayers) {
|
||||||
|
try {
|
||||||
|
const data = await getLayer(layerMeta); // lấy GeoJSON từ server
|
||||||
|
|
||||||
|
const vectorLayer = createGeoJSONLayer({
|
||||||
|
data,
|
||||||
|
style: createLabelAndFillStyle('#77BEF0', '#000000'),
|
||||||
|
});
|
||||||
|
|
||||||
|
dynamicLayers.push(vectorLayer);
|
||||||
|
|
||||||
|
// gom tất cả features vào this.features để sau này click kiểm tra
|
||||||
|
const features = vectorLayer.getSource()?.getFeatures() ?? [];
|
||||||
|
this.features.push(...features);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Không load được layer ${layerMeta}`, err);
|
||||||
|
this.errors.push(`Layer ${layerMeta} load thất bại`);
|
||||||
|
this.onError(this.errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.mapRef.current) {
|
||||||
|
console.error('Map reference is not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.featureLayer = new VectorLayer({
|
||||||
|
source: new VectorSource({
|
||||||
|
features: [],
|
||||||
|
}),
|
||||||
|
zIndex: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.zoomListenerAdded = false;
|
||||||
|
|
||||||
|
this.map = new Map({
|
||||||
|
target: this.mapRef.current,
|
||||||
|
layers: [osmLayer, ...dynamicLayers, this.featureLayer],
|
||||||
|
view: new View({
|
||||||
|
projection: OL_PROJECTION,
|
||||||
|
center: fromLonLat(INITIAL_VIEW_CONFIG.center),
|
||||||
|
zoom: INITIAL_VIEW_CONFIG.zoom,
|
||||||
|
minZoom: INITIAL_VIEW_CONFIG.minZoom,
|
||||||
|
maxZoom: INITIAL_VIEW_CONFIG.maxZoom,
|
||||||
|
}),
|
||||||
|
controls: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.layers = [osmLayer, ...dynamicLayers, this.featureLayer];
|
||||||
|
|
||||||
|
this.initZoomListener();
|
||||||
|
this.isInitialized = true;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'Map initialized successfully at',
|
||||||
|
new Date().toLocaleTimeString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.map.on('singleclick', (evt: any) => {
|
||||||
|
const featuresAtPixel: {
|
||||||
|
feature: Feature;
|
||||||
|
layer: VectorLayer<VectorSource>;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
this.map!.forEachFeatureAtPixel(
|
||||||
|
evt.pixel,
|
||||||
|
(feature: any, layer: any) => {
|
||||||
|
featuresAtPixel.push({ feature, layer });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (featuresAtPixel.length === 0) return;
|
||||||
|
|
||||||
|
if (featuresAtPixel.length === 1) {
|
||||||
|
const { feature } = featuresAtPixel[0];
|
||||||
|
if (this.features.includes(feature)) {
|
||||||
|
this.onFeatureClick(feature);
|
||||||
|
this.onFeatureSelect(feature, evt.pixel);
|
||||||
|
console.log(
|
||||||
|
'Feature clicked at',
|
||||||
|
new Date().toLocaleTimeString(),
|
||||||
|
':',
|
||||||
|
feature.getProperties(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.onFeaturesClick(featuresAtPixel.map((f) => f.feature));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Lỗi khi khởi tạo bản đồ:', err);
|
||||||
|
this.errors.push('Map initialization failed');
|
||||||
|
this.onError(this.errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isMapInitialized(): boolean {
|
||||||
|
return this.isInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
initZoomListener() {
|
||||||
|
// console.log("initZoomListener: Adding zoom listener"); // Debug
|
||||||
|
if (!this.zoomListenerAdded) {
|
||||||
|
if (!this.map || !this.map.getView()) {
|
||||||
|
console.error('Map or view not initialized in initZoomListener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.map.getView().on('change:resolution', () => {
|
||||||
|
// console.log("change:resolution event triggered"); // Debug
|
||||||
|
this.updateFeatureStyles();
|
||||||
|
});
|
||||||
|
this.zoomListenerAdded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hàm cập nhật style cho tất cả features
|
||||||
|
updateFeatureStyles() {
|
||||||
|
if (!this.map || !this.map.getView()) {
|
||||||
|
console.error('Map or view not initialized in updateFeatureStyles');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentZoom = this.map.getView().getZoom() ?? 5;
|
||||||
|
// console.log(`Updating feature styles with zoom: ${currentZoom}`); // Debug
|
||||||
|
this.features.forEach((feature) => {
|
||||||
|
const data = feature.get('data') || feature.getProperties();
|
||||||
|
const styleConfig = feature.get('styleConfig') || {};
|
||||||
|
const featureType = feature.get('type');
|
||||||
|
// console.log(
|
||||||
|
// `Updating style for feature type: ${featureType}, data:`,
|
||||||
|
// data,
|
||||||
|
// `styleConfig:`,
|
||||||
|
// styleConfig,
|
||||||
|
// ); // Debug
|
||||||
|
|
||||||
|
if (featureType === 'vms') {
|
||||||
|
const styles = this.createIconStyle(data, styleConfig, currentZoom);
|
||||||
|
feature.setStyle(styles);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Hàm tạo style cho feature
|
||||||
|
createIconStyle = (
|
||||||
|
data: FeatureData,
|
||||||
|
styleConfig: StyleConfig,
|
||||||
|
zoom: number,
|
||||||
|
): Style[] => {
|
||||||
|
const styles: Style[] = [];
|
||||||
|
if (styleConfig.icon) {
|
||||||
|
const scale = this.calculateScale(zoom);
|
||||||
|
// console.log(`Creating icon style with zoom: ${zoom}, scale: ${scale}`); // Debug
|
||||||
|
styles.push(
|
||||||
|
new Style({
|
||||||
|
image: new Icon({
|
||||||
|
anchor: [0.5, 30],
|
||||||
|
anchorXUnits: 'fraction',
|
||||||
|
anchorYUnits: 'pixels',
|
||||||
|
src: styleConfig.icon,
|
||||||
|
scale, // Use calculated scale
|
||||||
|
rotateWithView: false,
|
||||||
|
rotation:
|
||||||
|
data.bearing && !isNaN(data.bearing)
|
||||||
|
? (data.bearing * Math.PI) / 180
|
||||||
|
: 0,
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn('No icon provided in styleConfig, skipping icon style'); // Debug
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.text) {
|
||||||
|
styles.push(
|
||||||
|
new Style({
|
||||||
|
text: new Text({
|
||||||
|
text: data.text,
|
||||||
|
font: styleConfig.font || '14px Arial',
|
||||||
|
fill: new Fill({ color: styleConfig.textColor || 'black' }),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: styleConfig.textStrokeColor || 'white',
|
||||||
|
width: 2,
|
||||||
|
}),
|
||||||
|
offsetY: styleConfig.textOffsetY || -40,
|
||||||
|
textAlign: 'center',
|
||||||
|
textBaseline: 'bottom',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return styles;
|
||||||
|
};
|
||||||
|
|
||||||
|
addPoint(
|
||||||
|
coord: Coordinate,
|
||||||
|
data: Record<string, any> = {},
|
||||||
|
styleConfig: Record<string, any> = {},
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Kiểm tra tọa độ hợp lệ
|
||||||
|
if (
|
||||||
|
!Array.isArray(coord) ||
|
||||||
|
coord.length < 2 ||
|
||||||
|
typeof coord[0] !== 'number' ||
|
||||||
|
typeof coord[1] !== 'number'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid coordinates for Point: ${JSON.stringify(coord)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo geometry
|
||||||
|
const geometry = new Point(fromLonLat(coord));
|
||||||
|
const feature = new Feature({
|
||||||
|
geometry,
|
||||||
|
type: 'vms', // Explicitly set featureType to 'vms' for scaling
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lưu config
|
||||||
|
feature.set('styleConfig', styleConfig);
|
||||||
|
feature.set('data', data);
|
||||||
|
|
||||||
|
// Lấy zoom hiện tại (fallback 5 nếu map chưa sẵn sàng)
|
||||||
|
const currentZoom = this.map?.getView()?.getZoom() ?? 5;
|
||||||
|
// console.log(`Adding point with zoom: ${currentZoom}`); // Debug
|
||||||
|
|
||||||
|
// Gán style mặc định
|
||||||
|
const styles = this.createIconStyle(data, styleConfig, currentZoom);
|
||||||
|
feature.setStyle(styles);
|
||||||
|
|
||||||
|
// Lưu feature
|
||||||
|
this.features.push(feature);
|
||||||
|
this.flyToFeature(feature);
|
||||||
|
this.featureLayer?.getSource()?.addFeature(feature);
|
||||||
|
|
||||||
|
console.log('Point added successfully:', { coord, data, styleConfig });
|
||||||
|
|
||||||
|
return feature;
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.message || 'Unknown error';
|
||||||
|
this.errors.push(message);
|
||||||
|
this.onError?.(this.errors);
|
||||||
|
console.error('Error adding Point:', message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addPolygon(
|
||||||
|
coords: Coordinate[][],
|
||||||
|
data: Record<string, any> = {},
|
||||||
|
styleConfig: Record<string, any> = {},
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
let normalizedCoords: Coordinate[][] | Coordinate[] = coords;
|
||||||
|
|
||||||
|
// Nếu truyền vào là [[ [lon, lat], [lon, lat], ... ]]
|
||||||
|
if (
|
||||||
|
Array.isArray(coords) &&
|
||||||
|
coords.length === 1 &&
|
||||||
|
Array.isArray(coords[0]) &&
|
||||||
|
(coords[0] as Coordinate[]).every(
|
||||||
|
(coord) =>
|
||||||
|
Array.isArray(coord) &&
|
||||||
|
coord.length === 2 &&
|
||||||
|
coord.every((val) => !isNaN(val)),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
normalizedCoords = coords[0] as Coordinate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMultiPolygon =
|
||||||
|
Array.isArray(normalizedCoords) &&
|
||||||
|
(normalizedCoords as Coordinate[][]).every(
|
||||||
|
(ring) =>
|
||||||
|
Array.isArray(ring) &&
|
||||||
|
(ring as Coordinate[]).every(
|
||||||
|
(coord) =>
|
||||||
|
Array.isArray(coord) &&
|
||||||
|
coord.length === 2 &&
|
||||||
|
coord.every((val) => !isNaN(val)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSinglePolygon =
|
||||||
|
Array.isArray(normalizedCoords) &&
|
||||||
|
(normalizedCoords as Coordinate[]).every(
|
||||||
|
(coord) =>
|
||||||
|
Array.isArray(coord) &&
|
||||||
|
coord.length === 2 &&
|
||||||
|
coord.every((val) => !isNaN(val)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isMultiPolygon && !isSinglePolygon) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid coordinates for Polygon/MultiPolygon: ${JSON.stringify(
|
||||||
|
coords,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSinglePolygon && (normalizedCoords as Coordinate[]).length < 3) {
|
||||||
|
throw new Error(
|
||||||
|
`Polygon must have at least 3 coordinates: ${JSON.stringify(coords)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isMultiPolygon &&
|
||||||
|
(normalizedCoords as Coordinate[][]).some((ring) => ring.length < 3)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Each ring in MultiPolygon must have at least 3 coordinates: ${JSON.stringify(
|
||||||
|
coords,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let geometry: Polygon | MultiPolygon;
|
||||||
|
if (isMultiPolygon) {
|
||||||
|
const transformedCoords = (normalizedCoords as Coordinate[][]).map(
|
||||||
|
(ring) => ring.map(([lon, lat]) => fromLonLat([lon, lat])),
|
||||||
|
);
|
||||||
|
geometry = new MultiPolygon([transformedCoords]);
|
||||||
|
} else {
|
||||||
|
const transformedCoords = (normalizedCoords as Coordinate[]).map(
|
||||||
|
([lon, lat]) => fromLonLat([lon, lat]),
|
||||||
|
);
|
||||||
|
geometry = new Polygon([transformedCoords]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const feature = new Feature<Polygon | MultiPolygon>({
|
||||||
|
geometry,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
|
||||||
|
feature.set('styleConfig', styleConfig);
|
||||||
|
feature.set('data', data);
|
||||||
|
feature.set('type', 'polygon');
|
||||||
|
|
||||||
|
// Nếu bạn có sẵn hàm tạo style theo zoom
|
||||||
|
const currentZoom = this.map?.getView().getZoom();
|
||||||
|
feature.setStyle(
|
||||||
|
this.createPolygonStyle(data, styleConfig, currentZoom || 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.features.push(feature);
|
||||||
|
this.featureLayer?.getSource()?.addFeature(feature);
|
||||||
|
|
||||||
|
return feature;
|
||||||
|
} catch (error) {
|
||||||
|
this.errors.push('Error adding Polygon: ' + (error as Error).message);
|
||||||
|
this.onError?.(this.errors);
|
||||||
|
console.error('Error adding Polygon:', (error as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createPolygonStyle = (
|
||||||
|
data: Record<string, any> = {},
|
||||||
|
styleConfig: Record<string, any> = {},
|
||||||
|
zoom: number,
|
||||||
|
) => {
|
||||||
|
console.log(
|
||||||
|
`createPolygonStyle called with zoom: ${zoom}, styleConfig:`,
|
||||||
|
styleConfig,
|
||||||
|
); // Debug
|
||||||
|
const textContent = `Tên: ${data?.name ?? 'Chưa có'}\n\n Loại: ${
|
||||||
|
data?.type ?? 0
|
||||||
|
}\n\nThông tin: ${data?.description ?? 'Chưa có'}`;
|
||||||
|
|
||||||
|
const textStyle = new Text({
|
||||||
|
text: textContent,
|
||||||
|
font: styleConfig.font || '14px Arial',
|
||||||
|
fill: new Fill({ color: styleConfig.textColor || 'black' }),
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: styleConfig.textStrokeColor || 'white',
|
||||||
|
width: 1,
|
||||||
|
}),
|
||||||
|
offsetY: styleConfig.textOffsetY || -40,
|
||||||
|
textAlign: 'center',
|
||||||
|
textBaseline: 'bottom',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Điều chỉnh fillColor dựa trên zoom (tùy chọn)
|
||||||
|
let fillColor = styleConfig.fillColor || 'rgba(255,0,0,0.3)';
|
||||||
|
// Nếu muốn tăng độ đậm của fill khi zoom gần
|
||||||
|
if (zoom > 10) {
|
||||||
|
fillColor = styleConfig.fillColor
|
||||||
|
? styleConfig.fillColor.replace(/,[\d.]*\)/, ',0.5)') // Tăng độ đậm
|
||||||
|
: 'rgba(255,0,0,0.5)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: styleConfig.borderColor || 'red',
|
||||||
|
width: styleConfig.borderWidth || 2,
|
||||||
|
}),
|
||||||
|
fill: new Fill({
|
||||||
|
color: fillColor,
|
||||||
|
}),
|
||||||
|
text: textStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Polygon style created:`, style); // Debug
|
||||||
|
return [style];
|
||||||
|
};
|
||||||
|
|
||||||
|
private calculateScale = (zoom: number): number => {
|
||||||
|
// console.log(`calculateScale called with zoom: ${zoom}`); // Debug
|
||||||
|
const minZoom = INITIAL_VIEW_CONFIG.minZoom; // Zoom tối thiểu
|
||||||
|
const maxZoom = INITIAL_VIEW_CONFIG.maxZoom; // Zoom tối đa
|
||||||
|
const minScale = INITIAL_VIEW_CONFIG.minScale; // Scale nhỏ nhất
|
||||||
|
const maxScale = INITIAL_VIEW_CONFIG.maxScale; // Scale lớn nhất
|
||||||
|
const clampedZoom = Math.min(Math.max(zoom, minZoom), maxZoom);
|
||||||
|
const scale =
|
||||||
|
minScale +
|
||||||
|
((clampedZoom - minZoom) / (maxZoom - minZoom)) * (maxScale - minScale);
|
||||||
|
// console.log(`Calculated scale: ${scale}`); // Debug
|
||||||
|
return scale;
|
||||||
|
};
|
||||||
|
|
||||||
|
zoomToFeatures(): void {
|
||||||
|
if (!this.map) {
|
||||||
|
console.error('Map is not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.features.length === 0) {
|
||||||
|
this.map.getView().animate({
|
||||||
|
center: fromLonLat(INITIAL_VIEW_CONFIG.center),
|
||||||
|
zoom: INITIAL_VIEW_CONFIG.zoom,
|
||||||
|
duration: 500,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let extent: Extent = createEmpty();
|
||||||
|
this.features.forEach((feature, index) => {
|
||||||
|
try {
|
||||||
|
const featureExtent = feature.getGeometry()?.getExtent();
|
||||||
|
if (featureExtent && !isEmpty(featureExtent)) {
|
||||||
|
extent = extend(extent, featureExtent);
|
||||||
|
} else {
|
||||||
|
this.errors.push(`Empty extent for feature at index ${index}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.errors.push(
|
||||||
|
`Error getting extent for feature at index ${index}: ${
|
||||||
|
(error as Error).message
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isEmpty(extent)) {
|
||||||
|
this.map.getView().fit(extent, {
|
||||||
|
padding: [300, 300, 300, 300],
|
||||||
|
maxZoom: 12,
|
||||||
|
duration: 500,
|
||||||
|
callback: () => {
|
||||||
|
this.updateFeatureStyles();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.errors.push('No valid features to zoom to.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.errors.length > 0) {
|
||||||
|
this.onError(this.errors);
|
||||||
|
console.error(
|
||||||
|
'Zoom errors at',
|
||||||
|
new Date().toLocaleTimeString(),
|
||||||
|
':',
|
||||||
|
this.errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flyToFeature(
|
||||||
|
feature: Feature | Feature[],
|
||||||
|
done: (complete: boolean) => void = () => {},
|
||||||
|
): void {
|
||||||
|
if (!this.map) {
|
||||||
|
console.error('Map is not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = Array.isArray(feature) ? feature : [feature];
|
||||||
|
|
||||||
|
if (!features || features.length === 0) {
|
||||||
|
this.errors.push('No feature provided for flyToFeature');
|
||||||
|
this.onError(this.errors);
|
||||||
|
console.error(
|
||||||
|
'Fly to feature error at',
|
||||||
|
new Date().toLocaleTimeString(),
|
||||||
|
': No feature provided',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = this.map.getView();
|
||||||
|
let extent: Extent = createEmpty();
|
||||||
|
const validFeatures: Feature[] = [];
|
||||||
|
|
||||||
|
features.forEach((f, index) => {
|
||||||
|
if (f && typeof f.getGeometry === 'function') {
|
||||||
|
const featureExtent = f.getGeometry()?.getExtent();
|
||||||
|
if (featureExtent && !isEmpty(featureExtent)) {
|
||||||
|
extent = extend(extent, featureExtent);
|
||||||
|
validFeatures.push(f);
|
||||||
|
} else {
|
||||||
|
this.errors.push(`Empty extent for feature at index ${index}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.errors.push(`Invalid feature at index ${index}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isEmpty(extent)) {
|
||||||
|
this.errors.push('Empty extent for selected features');
|
||||||
|
this.onError(this.errors);
|
||||||
|
console.error(
|
||||||
|
'Fly to feature error at',
|
||||||
|
new Date().toLocaleTimeString(),
|
||||||
|
': Empty extent',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = 1000;
|
||||||
|
const zoom = view.getZoom() || 5;
|
||||||
|
let parts = 2;
|
||||||
|
let called = false;
|
||||||
|
|
||||||
|
const callback = (complete: boolean) => {
|
||||||
|
parts--;
|
||||||
|
if (called) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parts === 0 || !complete) {
|
||||||
|
called = true;
|
||||||
|
this.updateFeatureStyles();
|
||||||
|
done(complete);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view.fit(extent, {
|
||||||
|
padding: [300, 300, 300, 300],
|
||||||
|
maxZoom: features.length === 1 ? 10 : 12,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
view.animate(
|
||||||
|
{
|
||||||
|
zoom: zoom - 1,
|
||||||
|
duration: duration / 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
zoom: 14,
|
||||||
|
duration: duration / 2,
|
||||||
|
},
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.map) {
|
||||||
|
this.map.setTarget(undefined);
|
||||||
|
this.isInitialized = false;
|
||||||
|
console.log('Map destroyed at', new Date().toLocaleTimeString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MapManager };
|
||||||
55
src/pages/Home/components/GpsInfo.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { ProDescriptions } from '@ant-design/pro-components';
|
||||||
|
import { GpsData } from '..';
|
||||||
|
const GpsInfo = ({ gpsData }: { gpsData: GpsData | null }) => {
|
||||||
|
return (
|
||||||
|
<ProDescriptions<GpsData>
|
||||||
|
dataSource={gpsData || undefined}
|
||||||
|
layout="horizontal"
|
||||||
|
// responsive columns
|
||||||
|
column={{
|
||||||
|
xs: 1, // mobile
|
||||||
|
sm: 1,
|
||||||
|
md: 2, // tablet
|
||||||
|
lg: 2,
|
||||||
|
xl: 2,
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Kinh độ',
|
||||||
|
dataIndex: 'lat',
|
||||||
|
render: (_, record) =>
|
||||||
|
record?.lat != null ? `${Number(record.lat).toFixed(5)}°` : '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vĩ độ',
|
||||||
|
dataIndex: 'lon',
|
||||||
|
render: (_, record) =>
|
||||||
|
record?.lon != null ? `${Number(record.lon).toFixed(5)}°` : '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tốc độ',
|
||||||
|
dataIndex: 's',
|
||||||
|
valueType: 'digit',
|
||||||
|
render: (_, record) => `${record.s} km/h`,
|
||||||
|
span: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Hướng',
|
||||||
|
dataIndex: 'h',
|
||||||
|
valueType: 'digit',
|
||||||
|
render: (_, record) => `${record.h}°`,
|
||||||
|
span: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trạng thái',
|
||||||
|
tooltip: 'Thuyền có đang đánh bắt hay không',
|
||||||
|
dataIndex: 'fishing',
|
||||||
|
render: (_, record) =>
|
||||||
|
record?.fishing ? 'Đang đánh bắt' : 'Không đánh bắt',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GpsInfo;
|
||||||
80
src/pages/Home/components/ShipInfo.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { getShipInfo } from '@/services/controller/DeviceController';
|
||||||
|
import { ProDescriptions } from '@ant-design/pro-components';
|
||||||
|
import { Modal } from 'antd';
|
||||||
|
|
||||||
|
interface ShipInfoProps {
|
||||||
|
isOpen?: boolean;
|
||||||
|
setIsOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
const ShipInfo: React.FC<ShipInfoProps> = ({ isOpen, setIsOpen }) => {
|
||||||
|
const fetchShipData = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await getShipInfo();
|
||||||
|
console.log('Ship Info Response:', resp);
|
||||||
|
return resp;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching ship data:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Thông tin tàu"
|
||||||
|
open={isOpen}
|
||||||
|
onCancel={() => setIsOpen(false)}
|
||||||
|
onOk={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<ProDescriptions<API.ShipDetail>
|
||||||
|
column={{
|
||||||
|
xs: 1, // mobile
|
||||||
|
sm: 2,
|
||||||
|
md: 2, // tablet
|
||||||
|
lg: 2,
|
||||||
|
xl: 2,
|
||||||
|
}}
|
||||||
|
request={async () => {
|
||||||
|
const resp = await fetchShipData();
|
||||||
|
console.log('Fetched ship info:', resp);
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
data: resp,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Tên',
|
||||||
|
dataIndex: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IMO',
|
||||||
|
dataIndex: 'imo_number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'MMSI',
|
||||||
|
dataIndex: 'mmsi_number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Số đăng ký',
|
||||||
|
dataIndex: 'reg_number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Chiều dài',
|
||||||
|
dataIndex: 'ship_length',
|
||||||
|
render: (_, record) =>
|
||||||
|
record?.ship_length ? `${record.ship_length} m` : '--',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Công suất',
|
||||||
|
dataIndex: 'ship_power',
|
||||||
|
render: (_, record) =>
|
||||||
|
record?.ship_power ? `${record.ship_power} kW` : '--',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShipInfo;
|
||||||
160
src/pages/Home/components/SosButton.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
deleteSos,
|
||||||
|
getSos,
|
||||||
|
sendSosMessage,
|
||||||
|
} from '@/services/controller/DeviceController';
|
||||||
|
import { sosMessage } from '@/utils/sosUtil';
|
||||||
|
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProFormDependency,
|
||||||
|
ProFormSelect,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { Button, Form, Grid, message, Typography } from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import './index.less';
|
||||||
|
|
||||||
|
interface SosButtonProps {
|
||||||
|
onRefresh?: (isTrue: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
|
||||||
|
const [form] = Form.useForm<{ message: string; messageOther?: string }>();
|
||||||
|
const [sosData, setSosData] = useState<API.SosResponse>();
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
useEffect(() => {
|
||||||
|
getSosData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getSosData = async () => {
|
||||||
|
try {
|
||||||
|
const sosData = await getSos();
|
||||||
|
console.log('SOS Data: ', sosData);
|
||||||
|
|
||||||
|
setSosData(sosData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch SOS data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// map width cho từng breakpoint
|
||||||
|
const getWidth = () => {
|
||||||
|
if (screens.xs) return '95%'; // mobile
|
||||||
|
if (screens.sm) return '30%';
|
||||||
|
if (screens.md) return '30%';
|
||||||
|
if (screens.lg) return '30%';
|
||||||
|
return '40%'; // xl, xxl
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSos = async (messageData?: string) => {
|
||||||
|
if (messageData) {
|
||||||
|
try {
|
||||||
|
await sendSosMessage(messageData);
|
||||||
|
message.success('Gửi tín hiệu SOS thành công!');
|
||||||
|
getSosData(); // Cập nhật lại trạng thái SOS
|
||||||
|
onRefresh?.(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send SOS:', error);
|
||||||
|
message.error('Gửi tín hiệu SOS thất bại!');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await deleteSos();
|
||||||
|
message.success('Huỷ tín hiệu SOS thành công!');
|
||||||
|
getSosData(); // Cập nhật lại trạng thái SOS
|
||||||
|
onRefresh?.(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete SOS:', error);
|
||||||
|
message.error('Huỷ tín hiệu SOS thất bại!');
|
||||||
|
}
|
||||||
|
console.log('Sending SOS without message');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return sosData && sosData.active == true ? (
|
||||||
|
<div className=" flex flex-col sm:flex-row sm:justify-between sm:items-center bg-red-500 px-4 py-3 gap-3 rounded-xl animate-pulse">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<WarningOutlined style={{ color: 'white', fontSize: '20px' }} />
|
||||||
|
<Typography.Text className="text-white text-sm sm:text-base">
|
||||||
|
Đang trong trạng thái khẩn cấp
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
variant="outlined"
|
||||||
|
className="self-end sm:self-auto"
|
||||||
|
onClick={async () => await handleSos()}
|
||||||
|
>
|
||||||
|
Kết thúc
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ModalForm
|
||||||
|
title="Thông báo khẩn cấp"
|
||||||
|
initialValues={{ message: 'Tình huống khẩn cấp, không kịp chọn !!!' }}
|
||||||
|
form={form}
|
||||||
|
width={getWidth()}
|
||||||
|
modalProps={{
|
||||||
|
destroyOnHidden: true,
|
||||||
|
onCancel: () => console.log('run'),
|
||||||
|
}}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
icon={<InfoCircleOutlined />}
|
||||||
|
size="large"
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
shape="round"
|
||||||
|
>
|
||||||
|
Khẩn cấp
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
console.log('Form Values: ', values);
|
||||||
|
|
||||||
|
// Nếu chọn "Khác" thì lấy messageOther, ngược lại lấy message
|
||||||
|
const finalMessage =
|
||||||
|
values.message === 'other' ? values.messageOther : values.message;
|
||||||
|
|
||||||
|
await handleSos(finalMessage);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormSelect
|
||||||
|
options={[
|
||||||
|
{ value: 'other', label: 'Khác' },
|
||||||
|
...sosMessage.map((item) => ({
|
||||||
|
value: item.moTa,
|
||||||
|
label: item.moTa,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
name="message"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng chọn hoặc nhập lý do!' }]}
|
||||||
|
placeholder="Chọn hoặc nhập lý do..."
|
||||||
|
label="Nội dung:"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormDependency name={['message']}>
|
||||||
|
{({ message }) =>
|
||||||
|
message === 'other' ? (
|
||||||
|
<ProFormText
|
||||||
|
name="messageOther"
|
||||||
|
placeholder="Nhập lý do khác..."
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập lý do khác!' }]}
|
||||||
|
label="Lý do khác"
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
</ProFormDependency>
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SosButton;
|
||||||
72
src/pages/Home/components/VietNamMap.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { MapManager } from './BaseMap';
|
||||||
|
|
||||||
|
interface VietNamMapProps {
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
onFeatureClick?: (feature: any) => void;
|
||||||
|
onFeatureSelect?: (feature: any) => void;
|
||||||
|
onFeaturesClick?: (features: any[]) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
mapManager?: MapManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VietNamMap: React.FC<VietNamMapProps> = React.memo(
|
||||||
|
({
|
||||||
|
style = {},
|
||||||
|
onFeatureClick,
|
||||||
|
onFeatureSelect,
|
||||||
|
onFeaturesClick,
|
||||||
|
onError,
|
||||||
|
mapManager,
|
||||||
|
}) => {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('useEffect in VietNamMap triggered');
|
||||||
|
let manager = mapManager;
|
||||||
|
|
||||||
|
if (!manager) {
|
||||||
|
manager = new MapManager(mapRef, {
|
||||||
|
onFeatureClick,
|
||||||
|
onFeatureSelect,
|
||||||
|
onFeaturesClick,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialize = (retryCount = 0, maxRetries = 5) => {
|
||||||
|
if (mapRef.current) {
|
||||||
|
manager!.mapRef = mapRef;
|
||||||
|
if (!manager!.map) {
|
||||||
|
manager!.initializeMap();
|
||||||
|
console.log(
|
||||||
|
'Initialized new MapManager instance at',
|
||||||
|
new Date().toLocaleTimeString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (retryCount < maxRetries) {
|
||||||
|
console.error('mapRef.current is not ready, retrying...');
|
||||||
|
setTimeout(() => initialize(retryCount + 1, maxRetries), 100);
|
||||||
|
} else {
|
||||||
|
console.error('Max retries reached, failed to initialize map');
|
||||||
|
onError?.(new Error('Failed to initialize map: mapRef not ready'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initialize();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('Cleanup in VietNamMap triggered');
|
||||||
|
// Không gọi destroy ở đây để tránh phá hủy bản đồ không cần thiết
|
||||||
|
};
|
||||||
|
}, [mapManager, onFeatureClick, onFeatureSelect, onFeaturesClick, onError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div ref={mapRef} style={{ ...style }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default VietNamMap;
|
||||||
|
export { MapManager };
|
||||||
33
src/pages/Home/components/index.less
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
@keyframes spin-border {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block; // để ô bọc theo thẻ con
|
||||||
|
background-color: antiquewhite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-wrapper::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: -4px;
|
||||||
|
right: -4px;
|
||||||
|
bottom: -4px;
|
||||||
|
border: 3px solid rgba(229, 228, 228, 0.6);
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
animation: spin-border 1s linear infinite;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing-wrapper > div {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
3
src/pages/Home/index.less
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.container {
|
||||||
|
padding-top: 80px;
|
||||||
|
}
|
||||||
163
src/pages/Home/index.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { ROUTE_TRIP } from '@/constants';
|
||||||
|
import { queryAlarms } from '@/services/controller/DeviceController';
|
||||||
|
import { getShipIcon } from '@/services/service/MapService';
|
||||||
|
import {
|
||||||
|
CommentOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
InfoOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { history, useModel } from '@umijs/max';
|
||||||
|
import { FloatButton, Popover } from 'antd';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import GpsInfo from './components/GpsInfo';
|
||||||
|
import ShipInfo from './components/ShipInfo';
|
||||||
|
import SosButton from './components/SosButton';
|
||||||
|
import VietNamMap, { MapManager } from './components/VietNamMap';
|
||||||
|
|
||||||
|
export interface GpsData {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
s?: number; // Speed
|
||||||
|
h?: number; // Heading
|
||||||
|
fishing?: boolean;
|
||||||
|
}
|
||||||
|
const HomePage: React.FC = () => {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isShipInfoOpen, setIsShipInfoOpen] = useState(false);
|
||||||
|
const { gpsData, getGPSData } = useModel('getSos');
|
||||||
|
|
||||||
|
const onFeatureClick = useCallback((feature: any) => {
|
||||||
|
console.log('OnClick Feature: ', feature);
|
||||||
|
console.log(
|
||||||
|
'Clicked ship at',
|
||||||
|
new Date().toLocaleTimeString(),
|
||||||
|
'Properties:',
|
||||||
|
feature.getProperties(),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onFeaturesClick = useCallback((features: any[]) => {
|
||||||
|
console.log('Multiple features clicked:', features);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onError = useCallback((error: any) => {
|
||||||
|
console.error(
|
||||||
|
'Lỗi khi thêm vào map at',
|
||||||
|
new Date().toLocaleTimeString(),
|
||||||
|
':',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const mapManagerRef = useRef(
|
||||||
|
new MapManager(mapRef, {
|
||||||
|
onFeatureClick,
|
||||||
|
onFeaturesClick,
|
||||||
|
onError,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isShowGPSData, setIsShowGPSData] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getGPSData();
|
||||||
|
return () => {
|
||||||
|
mapManagerRef.current?.destroy();
|
||||||
|
console.log('MapManager destroyed in HomePage cleanup');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
getEntitiesData();
|
||||||
|
}, 500); // delay 1s
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [gpsData]);
|
||||||
|
|
||||||
|
const getEntitiesData = async () => {
|
||||||
|
try {
|
||||||
|
const alarm = await queryAlarms();
|
||||||
|
console.log('GPS Data:', gpsData);
|
||||||
|
|
||||||
|
if (mapManagerRef.current?.featureLayer) {
|
||||||
|
mapManagerRef.current.featureLayer.getSource()?.clear();
|
||||||
|
try {
|
||||||
|
mapManagerRef.current.addPoint(
|
||||||
|
[gpsData!.lon, gpsData!.lat],
|
||||||
|
{
|
||||||
|
bearing: gpsData!.h || 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: getShipIcon(alarm.level || 1, gpsData?.fishing || false),
|
||||||
|
scale: 0.1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error(
|
||||||
|
`Error parsing valueString for entity: ${parseError}`,
|
||||||
|
parseError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching entities:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<VietNamMap
|
||||||
|
style={{
|
||||||
|
height: '97vh',
|
||||||
|
width: '100vw',
|
||||||
|
}}
|
||||||
|
mapManager={mapManagerRef.current}
|
||||||
|
onFeatureClick={onFeatureClick}
|
||||||
|
onFeaturesClick={onFeaturesClick}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
<Popover
|
||||||
|
styles={{
|
||||||
|
root: { width: '85%', maxWidth: 500, paddingLeft: 15 },
|
||||||
|
}}
|
||||||
|
placement="left"
|
||||||
|
title="Trạng thái hiện tại"
|
||||||
|
content={
|
||||||
|
<GpsInfo gpsData={gpsData} />
|
||||||
|
// <ShipInfo />
|
||||||
|
}
|
||||||
|
open={isShowGPSData}
|
||||||
|
>
|
||||||
|
<FloatButton.Group
|
||||||
|
trigger="click"
|
||||||
|
style={{ insetInlineEnd: 5, insetBlockEnd: 10 }}
|
||||||
|
icon={<InfoCircleOutlined />}
|
||||||
|
onOpenChange={(open) => setIsShowGPSData(!open)}
|
||||||
|
>
|
||||||
|
<FloatButton
|
||||||
|
icon={<CommentOutlined />}
|
||||||
|
onClick={() => history.push(ROUTE_TRIP)}
|
||||||
|
tooltip="Thông tin chuyến đi"
|
||||||
|
/>
|
||||||
|
<FloatButton
|
||||||
|
icon={<InfoOutlined />}
|
||||||
|
tooltip="Thông tin tàu"
|
||||||
|
onClick={() => setIsShipInfoOpen(true)}
|
||||||
|
/>
|
||||||
|
</FloatButton.Group>
|
||||||
|
</Popover>
|
||||||
|
<div className="absolute top-3 right-3 ">
|
||||||
|
<SosButton
|
||||||
|
onRefresh={(value) => {
|
||||||
|
if (value) {
|
||||||
|
getEntitiesData();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ShipInfo isOpen={isShipInfoOpen} setIsOpen={setIsShipInfoOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
103
src/pages/Trip/components/AlarmTable.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
DATE_TIME_FORMAT,
|
||||||
|
DURATION_POLLING_PRESENTATIONS,
|
||||||
|
STATUS_DANGEROUS,
|
||||||
|
STATUS_NORMAL,
|
||||||
|
STATUS_SOS,
|
||||||
|
STATUS_WARNING,
|
||||||
|
} from '@/utils/format';
|
||||||
|
import { ProList, ProListMetas } from '@ant-design/pro-components';
|
||||||
|
import { Badge, theme, Typography } from 'antd';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
const getBadgeLevel = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case STATUS_NORMAL:
|
||||||
|
return <Badge status="success" />;
|
||||||
|
case STATUS_WARNING:
|
||||||
|
return <Badge status="warning" />;
|
||||||
|
case STATUS_DANGEROUS:
|
||||||
|
return <Badge status="error" />;
|
||||||
|
case STATUS_SOS:
|
||||||
|
return <Badge status="error" />;
|
||||||
|
default:
|
||||||
|
return <Badge status="default" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AlarmTableProps {
|
||||||
|
alarmList?: API.Alarm[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlarmTable: React.FC<AlarmTableProps> = ({
|
||||||
|
alarmList,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
// const intl = useIntl();
|
||||||
|
const actionRef = useRef();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
// const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
// const [confirmModalVisible, handleConfirmModalVisible] = useState(false);
|
||||||
|
// const [currentRow, setCurrentRow] = useState({});
|
||||||
|
|
||||||
|
const getTitleAlarmColor = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case STATUS_NORMAL:
|
||||||
|
return token.colorSuccess;
|
||||||
|
case STATUS_WARNING:
|
||||||
|
return token.colorWarning;
|
||||||
|
case STATUS_DANGEROUS:
|
||||||
|
return token.colorError;
|
||||||
|
case STATUS_SOS:
|
||||||
|
return token.colorError;
|
||||||
|
default:
|
||||||
|
return token.colorText;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ProListMetas<API.Alarm> = {
|
||||||
|
title: {
|
||||||
|
dataIndex: 'name',
|
||||||
|
render(_, item) {
|
||||||
|
return (
|
||||||
|
<Typography.Text style={{ color: getTitleAlarmColor(item.level) }}>
|
||||||
|
{item.name}
|
||||||
|
</Typography.Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
render: (_, item) => getBadgeLevel(item.level),
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
dataIndex: 'time',
|
||||||
|
render: (_, item) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>{moment.unix(item?.t).format(DATE_TIME_FORMAT)}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProList<API.Alarm>
|
||||||
|
// bordered
|
||||||
|
actionRef={actionRef}
|
||||||
|
metas={columns}
|
||||||
|
polling={DURATION_POLLING_PRESENTATIONS}
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={alarmList}
|
||||||
|
search={false}
|
||||||
|
dateFormatter="string"
|
||||||
|
cardProps={{
|
||||||
|
bodyStyle: { paddingInline: 16, paddingBlock: 8 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
46
src/pages/Trip/components/BadgeTripStatus.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { BadgeProps } from 'antd';
|
||||||
|
import { Badge } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface BadgeTripStatusProps {
|
||||||
|
status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BadgeTripStatus: React.FC<BadgeTripStatusProps> = ({ status }) => {
|
||||||
|
// Khai báo kiểu cho map
|
||||||
|
const statusBadgeMap: Record<number, BadgeProps> = {
|
||||||
|
0: { status: 'default' }, // Đã khởi tạo
|
||||||
|
1: { status: 'processing' }, // Chờ duyệt
|
||||||
|
2: { status: 'success' }, // Đã duyệt
|
||||||
|
3: { status: 'success' }, // Đang hoạt động
|
||||||
|
4: { status: 'success' }, // Hoàn thành
|
||||||
|
5: { status: 'error' }, // Đã huỷ
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBadgeProps = (status: number | undefined) => {
|
||||||
|
switch (status) {
|
||||||
|
case 0:
|
||||||
|
return 'Chưa được phê duyệt';
|
||||||
|
case 1:
|
||||||
|
return 'Đang chờ duyệt';
|
||||||
|
case 2:
|
||||||
|
return 'Đã duyệt';
|
||||||
|
case 3:
|
||||||
|
return 'Đang hoạt động';
|
||||||
|
case 4:
|
||||||
|
return 'Đã hoàn thành';
|
||||||
|
case 5:
|
||||||
|
return 'Đã huỷ';
|
||||||
|
default:
|
||||||
|
return 'Trạng thái không xác định';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeProps: BadgeProps = statusBadgeMap[status ?? -1] || {
|
||||||
|
status: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Badge {...badgeProps} text={getBadgeProps(status)} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BadgeTripStatus;
|
||||||
38
src/pages/Trip/components/CancelTrip.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { ModalForm, ProFormTextArea } from '@ant-design/pro-components';
|
||||||
|
import { Button, Form } from 'antd';
|
||||||
|
interface CancelTripProps {
|
||||||
|
onFinished?: (note: string) => void;
|
||||||
|
}
|
||||||
|
const CancelTrip: React.FC<CancelTripProps> = ({ onFinished }) => {
|
||||||
|
const [form] = Form.useForm<{ note: string }>();
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
title="Xác nhận huỷ chuyến đi"
|
||||||
|
form={form}
|
||||||
|
modalProps={{
|
||||||
|
destroyOnHidden: true,
|
||||||
|
onCancel: () => console.log('run'),
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<Button color="danger" variant="solid">
|
||||||
|
Huỷ chuyến đi
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
onFinished?.(values.note);
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormTextArea
|
||||||
|
name="note"
|
||||||
|
label="Lý do: "
|
||||||
|
placeholder={'Nhập lý do huỷ chuyến đi...'}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Vui lòng nhập lý do huỷ chuyến đi' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CancelTrip;
|
||||||
133
src/pages/Trip/components/CreateNewHaulOrTrip.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { STATUS } from '@/constants/enums';
|
||||||
|
import { getGPS } from '@/services/controller/DeviceController';
|
||||||
|
import {
|
||||||
|
startNewHaul,
|
||||||
|
updateTripState,
|
||||||
|
} from '@/services/controller/TripController';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
import { Button, message, theme } from 'antd';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import CreateOrUpdateFishingLog from './CreateOrUpdateFishingLog';
|
||||||
|
|
||||||
|
interface CreateNewHaulOrTripProps {
|
||||||
|
trips?: API.Trip;
|
||||||
|
onCallBack?: (success: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
|
||||||
|
trips,
|
||||||
|
onCallBack,
|
||||||
|
}) => {
|
||||||
|
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const { getApi } = useModel('getTrip');
|
||||||
|
const checkHaulFinished = () => {
|
||||||
|
return trips?.fishing_logs?.some((h) => h.status === 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNewHaul = async () => {
|
||||||
|
if (trips?.fishing_logs?.some((f) => f.status === 0)) {
|
||||||
|
message.warning(
|
||||||
|
'Vui lòng kết thúc mẻ lưới hiện tại trước khi bắt đầu mẻ mới',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gpsData = await getGPS();
|
||||||
|
console.log('GPS Data:', gpsData);
|
||||||
|
|
||||||
|
const body: API.NewFishingLogRequest = {
|
||||||
|
trip_id: trips?.id || '',
|
||||||
|
start_at: new Date(),
|
||||||
|
start_lat: gpsData.lat,
|
||||||
|
start_lon: gpsData.lon,
|
||||||
|
weather_description: 'Nắng đẹp',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await startNewHaul(body);
|
||||||
|
onCallBack?.(STATUS.CREATE_FISHING_LOG_SUCCESS);
|
||||||
|
getApi();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
onCallBack?.(STATUS.CREATE_FISHING_LOG_FAIL);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartTrip = async (state: number, note?: string) => {
|
||||||
|
if (trips?.trip_status !== 2) {
|
||||||
|
message.warning('Chuyến đi đã được bắt đầu hoặc hoàn thành.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await updateTripState({ status: state, note: note || '' });
|
||||||
|
onCallBack?.(STATUS.START_TRIP_SUCCESS);
|
||||||
|
getApi();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error stating trip :', error);
|
||||||
|
onCallBack?.(STATUS.START_TRIP_FAIL);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Không render gì nếu trip đã hoàn thành hoặc bị hủy
|
||||||
|
if (trips?.trip_status === 4 || trips?.trip_status === 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{trips?.trip_status === 2 ? (
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
variant="solid"
|
||||||
|
onClick={async () => handleStartTrip(3)}
|
||||||
|
>
|
||||||
|
Bắt đầu chuyến đi
|
||||||
|
</Button>
|
||||||
|
) : checkHaulFinished() ? (
|
||||||
|
<Button
|
||||||
|
key="button"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsFinishHaulModalOpen(true);
|
||||||
|
}}
|
||||||
|
color="geekblue"
|
||||||
|
variant="solid"
|
||||||
|
>
|
||||||
|
Kết thúc mẻ lưới
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key="button"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={async () => {
|
||||||
|
createNewHaul();
|
||||||
|
}}
|
||||||
|
color="cyan"
|
||||||
|
variant="solid"
|
||||||
|
>
|
||||||
|
Bắt đầu mẻ lưới
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateOrUpdateFishingLog
|
||||||
|
trip={trips!}
|
||||||
|
isFinished={false}
|
||||||
|
fishingLogs={undefined}
|
||||||
|
isOpen={isFinishHaulModalOpen}
|
||||||
|
onOpenChange={setIsFinishHaulModalOpen}
|
||||||
|
onFinished={(success) => {
|
||||||
|
if (success) {
|
||||||
|
onCallBack?.(STATUS.UPDATE_FISHING_LOG_SUCCESS);
|
||||||
|
getApi();
|
||||||
|
} else {
|
||||||
|
onCallBack?.(STATUS.UPDATE_FISHING_LOG_FAIL);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateNewHaulOrTrip;
|
||||||
359
src/pages/Trip/components/CreateOrUpdateFishingLog.tsx
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { getGPS } from '@/services/controller/DeviceController';
|
||||||
|
import {
|
||||||
|
getFishSpecies,
|
||||||
|
updateFishingLogs,
|
||||||
|
} from '@/services/controller/TripController';
|
||||||
|
import { getColorByRarityLevel, getRarityById } from '@/utils/fishRarity';
|
||||||
|
import { DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { EditableProTable, ProColumns } from '@ant-design/pro-components';
|
||||||
|
import { Button, Flex, message, Modal, Tag, Tooltip, Typography } from 'antd';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface CreateOrUpdateFishingLogProps {
|
||||||
|
trip: API.Trip;
|
||||||
|
fishingLogs?: API.FishingLog;
|
||||||
|
isFinished: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onFinished?: (success: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FishingLogInfoWithKey extends API.FishingLogInfo {
|
||||||
|
key: React.Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
|
||||||
|
trip,
|
||||||
|
fishingLogs,
|
||||||
|
isFinished,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
onFinished,
|
||||||
|
}) => {
|
||||||
|
const [dataSource, setDataSource] = useState<
|
||||||
|
readonly FishingLogInfoWithKey[]
|
||||||
|
>([]);
|
||||||
|
const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
|
||||||
|
const [fishDatas, setFishDatas] = useState<API.FishSpeciesResponse[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
getAllFish();
|
||||||
|
if (isOpen) {
|
||||||
|
console.log('Modal opened with fishingLogs:', fishingLogs);
|
||||||
|
|
||||||
|
if (fishingLogs?.info && fishingLogs.info.length > 0) {
|
||||||
|
const dataWithKeys: FishingLogInfoWithKey[] = fishingLogs.info.map(
|
||||||
|
(item, index) => ({
|
||||||
|
...item,
|
||||||
|
key: index,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setDataSource(dataWithKeys);
|
||||||
|
setEditableRowKeys(dataWithKeys.map((item) => item.key));
|
||||||
|
} else {
|
||||||
|
// Nếu không có info thì reset table
|
||||||
|
setDataSource([]);
|
||||||
|
setEditableRowKeys([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, fishingLogs]);
|
||||||
|
|
||||||
|
const getAllFish = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await getFishSpecies();
|
||||||
|
setFishDatas(resp);
|
||||||
|
console.log('Fetched fish species:', resp);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching fish species:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const columns: ProColumns<FishingLogInfoWithKey>[] = [
|
||||||
|
{
|
||||||
|
title: 'Tên cá',
|
||||||
|
dataIndex: 'fish_species_id',
|
||||||
|
valueType: 'select',
|
||||||
|
fieldProps: {
|
||||||
|
showSearch: true,
|
||||||
|
options: fishDatas.map((f) => ({
|
||||||
|
label: f.name,
|
||||||
|
value: f.id,
|
||||||
|
data: JSON.stringify(f),
|
||||||
|
})),
|
||||||
|
optionRender: (option: any) => {
|
||||||
|
const fish: API.FishSpeciesResponse = JSON.parse(option.data.data);
|
||||||
|
const fishRarity = getRarityById(fish.rarity_level || 1);
|
||||||
|
return (
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<Typography.Text>{fish.name}</Typography.Text>
|
||||||
|
<Tooltip title={fishRarity?.rarityDescription || ''}>
|
||||||
|
<Tag color={getColorByRarityLevel(fish.rarity_level || 1)}>
|
||||||
|
{fishRarity?.rarityLabel}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
formItemProps: {
|
||||||
|
rules: [{ required: true, message: 'Vui lòng chọn tên cá' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kích thước (cm)',
|
||||||
|
dataIndex: 'fish_size',
|
||||||
|
valueType: 'digit',
|
||||||
|
width: '15%',
|
||||||
|
formItemProps: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Vui lòng nhập kích thước',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Số lượng',
|
||||||
|
dataIndex: 'catch_number',
|
||||||
|
valueType: 'digit',
|
||||||
|
width: '15%',
|
||||||
|
formItemProps: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Vui lòng nhập số lượng',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Đơn vị',
|
||||||
|
dataIndex: 'catch_unit',
|
||||||
|
valueType: 'select',
|
||||||
|
width: '15%',
|
||||||
|
valueEnum: {
|
||||||
|
kg: 'kg',
|
||||||
|
con: 'con',
|
||||||
|
tấn: 'tấn',
|
||||||
|
},
|
||||||
|
formItemProps: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Vui lòng chọn đơn vị',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Thao tác',
|
||||||
|
valueType: 'option',
|
||||||
|
width: 120,
|
||||||
|
render: () => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function createOrCreateOrUpdateFishingLog(
|
||||||
|
fishingLog: FishingLogInfoWithKey[],
|
||||||
|
) {
|
||||||
|
console.log('Is finished:', isFinished);
|
||||||
|
console.log('Trip:', trip);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gpsData = await getGPS();
|
||||||
|
if (gpsData) {
|
||||||
|
if (isFinished == false) {
|
||||||
|
// Tạo mẻ mới
|
||||||
|
const logStatus0 = trip.fishing_logs?.find((log) => log.status === 0);
|
||||||
|
console.log('ok', logStatus0);
|
||||||
|
console.log('ok', fishingLog);
|
||||||
|
const body: API.FishingLog = {
|
||||||
|
fishing_log_id: logStatus0?.fishing_log_id || '',
|
||||||
|
trip_id: trip.id,
|
||||||
|
start_at: logStatus0?.start_at!,
|
||||||
|
start_lat: logStatus0?.start_lat!,
|
||||||
|
start_lon: logStatus0?.start_lon!,
|
||||||
|
haul_lat: gpsData.lat,
|
||||||
|
haul_lon: gpsData.lon,
|
||||||
|
end_at: new Date(),
|
||||||
|
status: 1,
|
||||||
|
weather_description: logStatus0?.weather_description || '',
|
||||||
|
info: fishingLog.map((item) => ({
|
||||||
|
fish_species_id: item.fish_species_id,
|
||||||
|
fish_name: item.fish_name,
|
||||||
|
catch_number: item.catch_number,
|
||||||
|
catch_unit: item.catch_unit,
|
||||||
|
fish_size: item.fish_size,
|
||||||
|
fish_rarity: item.fish_rarity,
|
||||||
|
fish_condition: '',
|
||||||
|
gear_usage: '',
|
||||||
|
})),
|
||||||
|
sync: true,
|
||||||
|
};
|
||||||
|
const resp = await updateFishingLogs(body);
|
||||||
|
console.log('Resp', resp);
|
||||||
|
|
||||||
|
onFinished?.(true);
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
const body: API.FishingLog = {
|
||||||
|
fishing_log_id: fishingLogs?.fishing_log_id || '',
|
||||||
|
trip_id: fishingLogs?.trip_id!,
|
||||||
|
start_at: fishingLogs?.start_at!,
|
||||||
|
start_lat: fishingLogs?.start_lat!,
|
||||||
|
start_lon: fishingLogs?.start_lon!,
|
||||||
|
haul_lat: fishingLogs?.haul_lat!,
|
||||||
|
haul_lon: fishingLogs?.haul_lon!,
|
||||||
|
end_at: fishingLogs?.end_at!,
|
||||||
|
status: fishingLogs?.status!,
|
||||||
|
weather_description: fishingLogs?.weather_description || '',
|
||||||
|
info: fishingLog.map((item) => ({
|
||||||
|
fish_species_id: item.fish_species_id,
|
||||||
|
fish_name: item.fish_name,
|
||||||
|
catch_number: item.catch_number,
|
||||||
|
catch_unit: item.catch_unit,
|
||||||
|
fish_size: item.fish_size,
|
||||||
|
fish_rarity: item.fish_rarity,
|
||||||
|
fish_condition: '',
|
||||||
|
gear_usage: '',
|
||||||
|
})),
|
||||||
|
sync: true,
|
||||||
|
};
|
||||||
|
// console.log('Update body:', body);
|
||||||
|
|
||||||
|
const resp = await updateFishingLogs(body);
|
||||||
|
console.log('Resp', resp);
|
||||||
|
|
||||||
|
onFinished?.(true);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
setDataSource([]);
|
||||||
|
setEditableRowKeys([]);
|
||||||
|
} else {
|
||||||
|
message.error('Không thể lấy dữ liệu GPS. Vui lòng thử lại.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onFinished?.(false);
|
||||||
|
console.error('Error creating/updating haul:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
width="70%"
|
||||||
|
title={isFinished ? 'Cập nhật mẻ lưới' : 'Kết thúc mẻ lưới'}
|
||||||
|
open={isOpen}
|
||||||
|
cancelText="Huỷ"
|
||||||
|
maskClosable={false}
|
||||||
|
okText={isFinished ? 'Cập nhật' : 'Kết thúc'}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
// Validate data trước khi submit
|
||||||
|
const validData = dataSource.filter(
|
||||||
|
(item) =>
|
||||||
|
item.fish_name &&
|
||||||
|
item.fish_size &&
|
||||||
|
item.catch_number &&
|
||||||
|
item.catch_unit,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validData.length === 0) {
|
||||||
|
message.error(
|
||||||
|
'Vui lòng nhập ít nhất một loài cá với đầy đủ thông tin',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createOrCreateOrUpdateFishingLog(validData);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditableProTable<FishingLogInfoWithKey>
|
||||||
|
key={fishingLogs?.fishing_log_id}
|
||||||
|
headerTitle="Danh sách cá đánh bắt"
|
||||||
|
columns={columns}
|
||||||
|
rowKey="key"
|
||||||
|
scroll={{
|
||||||
|
x: 960,
|
||||||
|
}}
|
||||||
|
value={dataSource}
|
||||||
|
onChange={setDataSource}
|
||||||
|
recordCreatorProps={{
|
||||||
|
newRecordType: 'dataSource',
|
||||||
|
creatorButtonText: 'Thêm loài',
|
||||||
|
record: () => ({
|
||||||
|
key: Date.now(),
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
editable={{
|
||||||
|
type: 'multiple',
|
||||||
|
editableKeys,
|
||||||
|
actionRender: (row, config, defaultDoms) => {
|
||||||
|
return [defaultDoms.delete];
|
||||||
|
},
|
||||||
|
deletePopconfirmMessage: 'Bạn chắc chắn muốn xoá?',
|
||||||
|
onValuesChange: (
|
||||||
|
record: Partial<FishingLogInfoWithKey> | undefined,
|
||||||
|
recordList: FishingLogInfoWithKey[],
|
||||||
|
) => {
|
||||||
|
// Nếu không có record (sự kiện không liên quan tới 1 dòng cụ thể) thì chỉ cập nhật dataSource
|
||||||
|
if (!record) {
|
||||||
|
setDataSource([...recordList]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy giá trị species id (cẩn trọng string/number)
|
||||||
|
const speciesId = (record as any).fish_species_id;
|
||||||
|
if (speciesId === undefined || speciesId === null) {
|
||||||
|
// Nếu không phải là thay đổi chọn loài cá, chỉ cập nhật dataSource
|
||||||
|
setDataSource([...recordList]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tìm loài cá tương ứng, so sánh bằng String để tránh khác kiểu number/string
|
||||||
|
const fish = fishDatas.find(
|
||||||
|
(f) => String(f.id) === String(speciesId),
|
||||||
|
);
|
||||||
|
if (!fish) {
|
||||||
|
setDataSource([...recordList]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo record mới (merge thông tin loài cá vào dòng hiện tại)
|
||||||
|
const mergedRecord: FishingLogInfoWithKey = {
|
||||||
|
...(record as FishingLogInfoWithKey),
|
||||||
|
fish_species_id: fish.id,
|
||||||
|
fish_name: fish.name,
|
||||||
|
catch_unit: fish.default_unit,
|
||||||
|
fish_rarity: fish.rarity_level,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Áp lại vào recordList dựa theo key (so khớp key bằng String để an toàn)
|
||||||
|
const newList = recordList.map((r) =>
|
||||||
|
String(r.key) === String(mergedRecord.key) ? mergedRecord : r,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cập nhật state (sao chép mảng để tránh vấn đề readonly/type)
|
||||||
|
setDataSource([...newList]);
|
||||||
|
|
||||||
|
// Đảm bảo dòng này đang ở trạng thái editable (nếu cần)
|
||||||
|
setEditableRowKeys((prev) =>
|
||||||
|
prev.includes(mergedRecord.key)
|
||||||
|
? prev
|
||||||
|
: [...prev, mergedRecord.key],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onChange: setEditableRowKeys,
|
||||||
|
deleteText: (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
shape="circle"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateOrUpdateFishingLog;
|
||||||
83
src/pages/Trip/components/HaulFishList.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { ProCard, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
|
import { Form, Modal } from 'antd';
|
||||||
|
|
||||||
|
interface HaulFishListProp {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
fishList?: API.FishingLogInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HaulFishList: React.FC<HaulFishListProp> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
fishList,
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const fish_columns: ProColumns<API.FishingLogInfo>[] = [
|
||||||
|
{
|
||||||
|
title: 'Tên cá',
|
||||||
|
dataIndex: 'fish_name',
|
||||||
|
key: 'fish_name',
|
||||||
|
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trạng thái',
|
||||||
|
dataIndex: 'fish_condition',
|
||||||
|
key: 'fish_condition',
|
||||||
|
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Độ hiếm',
|
||||||
|
dataIndex: 'fish_rarity',
|
||||||
|
key: 'fish_rarity',
|
||||||
|
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Kích thước (cm)',
|
||||||
|
dataIndex: 'fish_size',
|
||||||
|
key: 'fish_size',
|
||||||
|
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Cân nặng (kg)',
|
||||||
|
dataIndex: 'catch_number',
|
||||||
|
key: 'catch_number',
|
||||||
|
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ngư cụ sử dụng',
|
||||||
|
dataIndex: 'gear_usage',
|
||||||
|
key: 'gear_usage',
|
||||||
|
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Danh sách cá"
|
||||||
|
open={open}
|
||||||
|
footer={null}
|
||||||
|
closable
|
||||||
|
afterClose={() => onOpenChange(false)}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
width={1000}
|
||||||
|
>
|
||||||
|
<ProCard split="vertical">
|
||||||
|
<ProTable<API.FishingLogInfo>
|
||||||
|
columns={fish_columns}
|
||||||
|
dataSource={fishList}
|
||||||
|
search={false}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 5,
|
||||||
|
showSizeChanger: true,
|
||||||
|
}}
|
||||||
|
options={false}
|
||||||
|
bordered
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: 600 }}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HaulFishList;
|
||||||
183
src/pages/Trip/components/HaulTable.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { EditOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
|
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
|
import { useIntl, useModel } from '@umijs/max';
|
||||||
|
import { Button, Flex, message } from 'antd';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import CreateOrUpdateFishingLog from './CreateOrUpdateFishingLog';
|
||||||
|
import HaulFishList from './HaulFishList';
|
||||||
|
|
||||||
|
export interface HaulTableProps {
|
||||||
|
hauls: API.FishingLog[];
|
||||||
|
trip?: API.Trip;
|
||||||
|
onReload?: (isTrue: boolean) => void;
|
||||||
|
}
|
||||||
|
const HaulTable: React.FC<HaulTableProps> = ({ hauls, trip, onReload }) => {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editFishingLogOpen, setEditFishingLogOpen] = useState(false);
|
||||||
|
const [currentRow, setCurrentRow] = useState<API.FishingLogInfo[]>([]);
|
||||||
|
const [currentFishingLog, setCurrentFishingLog] =
|
||||||
|
useState<API.FishingLog | null>(null);
|
||||||
|
const intl = useIntl();
|
||||||
|
const { getApi } = useModel('getTrip');
|
||||||
|
console.log('HaulTable received hauls:', hauls);
|
||||||
|
|
||||||
|
const fishing_logs_columns: ProColumns<API.FishingLog>[] = [
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>STT</div>,
|
||||||
|
dataIndex: 'fishing_log_id',
|
||||||
|
align: 'center',
|
||||||
|
render: (_, __, index, action) => {
|
||||||
|
return `Mẻ ${hauls.length - index}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Trạng Thái</div>,
|
||||||
|
dataIndex: ['status'], // 👈 lấy từ status 1: đang
|
||||||
|
align: 'center',
|
||||||
|
valueEnum: {
|
||||||
|
0: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'pages.trips.status.fishing',
|
||||||
|
defaultMessage: 'Đang đánh bắt',
|
||||||
|
}),
|
||||||
|
status: 'Processing',
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'pages.trips.status.end_fishing',
|
||||||
|
defaultMessage: 'Đã hoàn thành',
|
||||||
|
}),
|
||||||
|
status: 'Success',
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'pages.trips.status.cancel_fishing',
|
||||||
|
defaultMessage: 'Đã huỷ',
|
||||||
|
}),
|
||||||
|
status: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Thời tiết</div>,
|
||||||
|
dataIndex: ['weather_description'], // 👈 lấy từ weather
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Thời điểm bắt đầu</div>,
|
||||||
|
dataIndex: ['start_at'], // birth_date là date of birth
|
||||||
|
align: 'center',
|
||||||
|
render: (start_at: any) => {
|
||||||
|
if (!start_at) return '-';
|
||||||
|
const date = new Date(start_at);
|
||||||
|
return date.toLocaleString('vi-VN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Thời điểm kết thúc</div>,
|
||||||
|
dataIndex: ['end_at'], // birth_date là date of birth
|
||||||
|
align: 'center',
|
||||||
|
render: (end_at: any) => {
|
||||||
|
// console.log('End at value:', end_at);
|
||||||
|
if (end_at == '0001-01-01T00:00:00Z') return '-';
|
||||||
|
const date = new Date(end_at);
|
||||||
|
return date.toLocaleString('vi-VN', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Thao tác',
|
||||||
|
align: 'center',
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => {
|
||||||
|
console.log('Rendering action column for record:', record);
|
||||||
|
return (
|
||||||
|
<Flex align="center" justify="center" gap={5}>
|
||||||
|
{/* Nút Edit */}
|
||||||
|
<Button
|
||||||
|
shape="default"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
if (record.info) {
|
||||||
|
setCurrentRow(record.info!); // record là dòng hiện tại trong table
|
||||||
|
setEditOpen(true);
|
||||||
|
} else {
|
||||||
|
message.warning('Không có dữ liệu cá trong mẻ lưới này');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{editOpen && currentRow && (
|
||||||
|
<HaulFishList
|
||||||
|
fishList={currentRow} // truyền luôn cả record nếu cần
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
shape="default"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentFishingLog(record);
|
||||||
|
setEditFishingLogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProTable<API.FishingLog>
|
||||||
|
style={{ width: '90%' }}
|
||||||
|
columns={fishing_logs_columns}
|
||||||
|
dataSource={hauls.slice().reverse()} // đảo ngược thứ tự hiển thị
|
||||||
|
search={false}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 10,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
|
||||||
|
}}
|
||||||
|
options={false}
|
||||||
|
bordered
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: 600 }}
|
||||||
|
/>
|
||||||
|
<CreateOrUpdateFishingLog
|
||||||
|
trip={trip!}
|
||||||
|
isFinished={currentFishingLog?.status === 0 ? false : true}
|
||||||
|
fishingLogs={currentFishingLog || undefined}
|
||||||
|
isOpen={editFishingLogOpen}
|
||||||
|
onOpenChange={setEditFishingLogOpen}
|
||||||
|
onFinished={(success) => {
|
||||||
|
if (success) {
|
||||||
|
message.success('Cập nhật mẻ lưới thành công');
|
||||||
|
getApi();
|
||||||
|
} else {
|
||||||
|
message.error('Cập nhật mẻ lưới thất bại');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HaulTable;
|
||||||
77
src/pages/Trip/components/ListSkeleton.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Col, Row, Skeleton } from 'antd';
|
||||||
|
const ListSkeleton = ({}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col span={2}>
|
||||||
|
<Skeleton.Avatar active={true} size="small" />
|
||||||
|
</Col>
|
||||||
|
<Col span={16}>
|
||||||
|
<Skeleton.Input active={true} size="small" block={true} />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Skeleton.Button
|
||||||
|
active={true}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
block={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<p />
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col span={2}>
|
||||||
|
<Skeleton.Avatar active={true} size="small" />
|
||||||
|
</Col>
|
||||||
|
<Col span={16}>
|
||||||
|
<Skeleton.Input active={true} size="small" block={true} />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Skeleton.Button
|
||||||
|
active={true}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
block={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<p />
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col span={2}>
|
||||||
|
<Skeleton.Avatar active={true} size="small" />
|
||||||
|
</Col>
|
||||||
|
<Col span={16}>
|
||||||
|
<Skeleton.Input active={true} size="small" block={true} />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Skeleton.Button
|
||||||
|
active={true}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
block={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<p />
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col span={2}>
|
||||||
|
<Skeleton.Avatar active={true} size="small" />
|
||||||
|
</Col>
|
||||||
|
<Col span={16}>
|
||||||
|
<Skeleton.Input active={true} size="small" block={true} />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Skeleton.Button
|
||||||
|
active={true}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
block={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<p />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListSkeleton;
|
||||||
158
src/pages/Trip/components/MainTripBody.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { ProCard } from '@ant-design/pro-components';
|
||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
import { Flex } from 'antd';
|
||||||
|
import HaulTable from './HaulTable';
|
||||||
|
import TripCostTable from './TripCost';
|
||||||
|
import TripCrews from './TripCrews';
|
||||||
|
import TripFishingGearTable from './TripFishingGear';
|
||||||
|
// Props cho component
|
||||||
|
interface MainTripBodyProps {
|
||||||
|
trip_id?: string;
|
||||||
|
tripInfo: API.Trip | null;
|
||||||
|
onReload?: (isTrue: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MainTripBody: React.FC<MainTripBodyProps> = ({
|
||||||
|
trip_id,
|
||||||
|
tripInfo,
|
||||||
|
onReload,
|
||||||
|
}) => {
|
||||||
|
// console.log('MainTripBody received:');
|
||||||
|
// console.log("trip_id:", trip_id);
|
||||||
|
// console.log('tripInfo:', tripInfo);
|
||||||
|
const { data, getApi } = useModel('getTrip');
|
||||||
|
const tripCosts = Array.isArray(tripInfo?.trip_cost)
|
||||||
|
? tripInfo.trip_cost
|
||||||
|
: [];
|
||||||
|
const fishingGears = Array.isArray(tripInfo?.fishing_gears)
|
||||||
|
? tripInfo.fishing_gears
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const fishing_logs_columns = [
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Tên</div>,
|
||||||
|
dataIndex: 'name',
|
||||||
|
valueType: 'select',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Số lượng</div>,
|
||||||
|
dataIndex: 'number',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tranship_columns = [
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Tên</div>,
|
||||||
|
dataIndex: 'name',
|
||||||
|
valueType: 'select',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Chức vụ</div>,
|
||||||
|
dataIndex: 'role',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap={10}>
|
||||||
|
<ProCard
|
||||||
|
ghost
|
||||||
|
gutter={{
|
||||||
|
xs: 8,
|
||||||
|
sm: 8,
|
||||||
|
md: 8,
|
||||||
|
lg: 8,
|
||||||
|
xl: 8,
|
||||||
|
xxl: 8,
|
||||||
|
}}
|
||||||
|
direction="column"
|
||||||
|
bodyStyle={{
|
||||||
|
padding: 0,
|
||||||
|
paddingInline: 0,
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProCard bodyStyle={{ padding: 0, gap: 5 }}>
|
||||||
|
<ProCard
|
||||||
|
colSpan={{ xs: 2, sm: 4, md: 6, lg: 8, xl: 12 }}
|
||||||
|
layout="center"
|
||||||
|
bordered
|
||||||
|
headStyle={{
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
}}
|
||||||
|
title="Chi phí chuyến đi"
|
||||||
|
style={{ minHeight: 300 }}
|
||||||
|
>
|
||||||
|
<TripCostTable tripCosts={tripCosts} />
|
||||||
|
</ProCard>
|
||||||
|
<ProCard
|
||||||
|
colSpan={{ xs: 2, sm: 4, md: 6, lg: 8, xl: 12 }}
|
||||||
|
layout="center"
|
||||||
|
bordered
|
||||||
|
headStyle={{
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
}}
|
||||||
|
title="Danh sách ngư cụ"
|
||||||
|
style={{ minHeight: 300 }}
|
||||||
|
>
|
||||||
|
<TripFishingGearTable fishingGears={fishingGears} />
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
|
<ProCard
|
||||||
|
style={{ paddingInlineEnd: 0 }}
|
||||||
|
ghost={true}
|
||||||
|
colSpan={{ xs: 4, sm: 8, md: 12, lg: 16, xl: 24 }}
|
||||||
|
layout="center"
|
||||||
|
bordered
|
||||||
|
headStyle={{
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
title="Danh sách thuyền viên"
|
||||||
|
>
|
||||||
|
<TripCrews crew={tripInfo?.crews} />
|
||||||
|
</ProCard>
|
||||||
|
<ProCard
|
||||||
|
style={{ paddingInlineEnd: 0 }}
|
||||||
|
ghost={true}
|
||||||
|
colSpan={{ xs: 4, sm: 8, md: 12, lg: 16, xl: 24 }}
|
||||||
|
layout="center"
|
||||||
|
bordered
|
||||||
|
headStyle={{
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
title="Danh sách mẻ lưới"
|
||||||
|
>
|
||||||
|
<HaulTable
|
||||||
|
trip={tripInfo!}
|
||||||
|
hauls={tripInfo?.fishing_logs || []}
|
||||||
|
onReload={(isTrue) => {
|
||||||
|
if (isTrue) {
|
||||||
|
// onReload?.(true);
|
||||||
|
getApi();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainTripBody;
|
||||||
60
src/pages/Trip/components/TripCancelOrFinishButton.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { updateTripState } from '@/services/controller/TripController';
|
||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
import { Button, message, Popconfirm } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import CancelTrip from './CancelTrip';
|
||||||
|
|
||||||
|
interface TripCancleOrFinishedButtonProps {
|
||||||
|
tripStatus?: number;
|
||||||
|
onCallBack?: (success: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TripCancleOrFinishedButton: React.FC<TripCancleOrFinishedButtonProps> = ({
|
||||||
|
tripStatus,
|
||||||
|
onCallBack,
|
||||||
|
}) => {
|
||||||
|
const { getApi } = useModel('getTrip');
|
||||||
|
const handleClickButton = async (state: number, note?: string) => {
|
||||||
|
try {
|
||||||
|
const resp = await updateTripState({ status: state, note: note || '' });
|
||||||
|
message.success('Cập nhật trạng thái thành công');
|
||||||
|
getApi();
|
||||||
|
onCallBack?.(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating trip status:', error);
|
||||||
|
message.error('Cập nhật trạng thái thất bại');
|
||||||
|
onCallBack?.(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const renderButton = () => {
|
||||||
|
switch (tripStatus) {
|
||||||
|
case 3: // Đang hoạt động
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<CancelTrip
|
||||||
|
onFinished={async (note) => {
|
||||||
|
await handleClickButton(5, note);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title="Thông báo"
|
||||||
|
description="Bạn chắc chắn muốn kết thúc chuyến đi?"
|
||||||
|
onConfirm={async () => handleClickButton(4)}
|
||||||
|
okText="Chắc chắn"
|
||||||
|
cancelText="Không"
|
||||||
|
>
|
||||||
|
<Button color="orange" variant="solid">
|
||||||
|
Kết thúc
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div>{renderButton()}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripCancleOrFinishedButton;
|
||||||
86
src/pages/Trip/components/TripCost.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
|
||||||
|
interface TripCostTableProps {
|
||||||
|
tripCosts: API.TripCost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
|
||||||
|
// Tính tổng chi phí
|
||||||
|
const total_trip_cost = tripCosts.reduce(
|
||||||
|
(sum, item) => sum + (Number(item.total_cost) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const trip_cost_columns: ProColumns<API.TripCost>[] = [
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Loại</div>,
|
||||||
|
dataIndex: 'type',
|
||||||
|
valueEnum: {
|
||||||
|
fuel: { text: 'Nhiên liệu' },
|
||||||
|
crew_salary: { text: 'Lương thuyền viên' },
|
||||||
|
food: { text: 'Lương thực' },
|
||||||
|
ice_salt_cost: { text: 'Muối đá' },
|
||||||
|
},
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Số lượng</div>,
|
||||||
|
dataIndex: 'amount',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Đơn vị</div>,
|
||||||
|
dataIndex: 'unit',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Chi phí</div>,
|
||||||
|
dataIndex: 'cost_per_unit',
|
||||||
|
align: 'center',
|
||||||
|
render: (val: any) => (val ? Number(val).toLocaleString() : ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Tổng chi phí</div>,
|
||||||
|
dataIndex: 'total_cost',
|
||||||
|
align: 'center',
|
||||||
|
render: (val: any) =>
|
||||||
|
val ? (
|
||||||
|
<b style={{ color: '#fa541c' }}>{Number(val).toLocaleString()}</b>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<ProTable<API.TripCost>
|
||||||
|
columns={trip_cost_columns}
|
||||||
|
dataSource={tripCosts}
|
||||||
|
search={false}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 5,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
|
||||||
|
}}
|
||||||
|
options={false}
|
||||||
|
bordered
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: 600 }}
|
||||||
|
summary={() => (
|
||||||
|
<ProTable.Summary.Row>
|
||||||
|
<ProTable.Summary.Cell index={0} colSpan={4} align="right">
|
||||||
|
<Typography.Text strong style={{ color: '#1890ff' }}>
|
||||||
|
Tổng cộng
|
||||||
|
</Typography.Text>
|
||||||
|
</ProTable.Summary.Cell>
|
||||||
|
<ProTable.Summary.Cell index={4} align="center">
|
||||||
|
<Typography.Text strong style={{ color: '#fa541c', fontSize: 16 }}>
|
||||||
|
{total_trip_cost.toLocaleString()}
|
||||||
|
</Typography.Text>
|
||||||
|
</ProTable.Summary.Cell>
|
||||||
|
</ProTable.Summary.Row>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripCostTable;
|
||||||
84
src/pages/Trip/components/TripCrews.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TripCrewsProps {
|
||||||
|
crew?: API.TripCrews[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TripCrews: React.FC<TripCrewsProps> = ({ crew }) => {
|
||||||
|
console.log('TripCrews received crew:', crew);
|
||||||
|
|
||||||
|
const crew_columns: ProColumns<API.TripCrews>[] = [
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Mã định danh</div>,
|
||||||
|
dataIndex: ['Person', 'personal_id'], // 👈 lấy từ Person.personal_id
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Tên</div>,
|
||||||
|
dataIndex: ['Person', 'name'], // 👈 lấy từ Person.name
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Chức vụ</div>,
|
||||||
|
dataIndex: 'role',
|
||||||
|
align: 'center',
|
||||||
|
render: (val: any) => {
|
||||||
|
switch (val) {
|
||||||
|
case 'captain':
|
||||||
|
return <span style={{ color: 'blue' }}>Thuyền trưởng</span>;
|
||||||
|
case 'crew':
|
||||||
|
return <span style={{ color: 'green' }}>Thuyền viên</span>;
|
||||||
|
default:
|
||||||
|
return <span>{val}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Email</div>,
|
||||||
|
dataIndex: ['Person', 'email'], // 👈 lấy từ Person.email
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Số điện thoại</div>,
|
||||||
|
dataIndex: ['Person', 'phone'], // 👈 lấy từ Person.phone
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Ngày sinh</div>,
|
||||||
|
dataIndex: ['Person', 'birth_date'], // birth_date là date of birth
|
||||||
|
align: 'center',
|
||||||
|
render: (birth_date: any) => {
|
||||||
|
if (!birth_date) return '-';
|
||||||
|
const date = new Date(birth_date);
|
||||||
|
return date.toLocaleDateString('vi-VN'); // 👉 tự động ra dd/mm/yyyy
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Địa chỉ</div>,
|
||||||
|
dataIndex: ['Person', 'address'], // 👈 lấy từ Person.address
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProTable<API.TripCrews>
|
||||||
|
style={{ width: '90%' }}
|
||||||
|
columns={crew_columns}
|
||||||
|
dataSource={crew}
|
||||||
|
search={false}
|
||||||
|
rowKey={(record, idx: any) => record.Person.personal_id || idx.toString()}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 5,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
|
||||||
|
}}
|
||||||
|
options={false}
|
||||||
|
bordered
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: 600 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripCrews;
|
||||||
44
src/pages/Trip/components/TripFishingGear.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TripFishingGearTableProps {
|
||||||
|
fishingGears: API.FishingGear[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TripFishingGearTable: React.FC<TripFishingGearTableProps> = ({
|
||||||
|
fishingGears,
|
||||||
|
}) => {
|
||||||
|
const fishing_gears_columns: ProColumns<API.FishingGear>[] = [
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Tên</div>,
|
||||||
|
dataIndex: 'name',
|
||||||
|
valueType: 'select',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <div style={{ textAlign: 'center' }}>Số lượng</div>,
|
||||||
|
dataIndex: 'number',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProTable<API.FishingGear>
|
||||||
|
columns={fishing_gears_columns}
|
||||||
|
dataSource={fishingGears}
|
||||||
|
search={false}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 5,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
|
||||||
|
}}
|
||||||
|
options={false}
|
||||||
|
bordered
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: 600 }}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripFishingGearTable;
|
||||||
170
src/pages/Trip/index.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { STATUS } from '@/constants/enums';
|
||||||
|
import { queryAlarms } from '@/services/controller/DeviceController';
|
||||||
|
import { PageContainer, ProCard } from '@ant-design/pro-components';
|
||||||
|
import { useIntl, useModel } from '@umijs/max';
|
||||||
|
import { Flex, message, theme } from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AlarmTable } from './components/AlarmTable';
|
||||||
|
import BadgeTripStatus from './components/BadgeTripStatus';
|
||||||
|
import CreateNewHaulOrTrip from './components/CreateNewHaulOrTrip';
|
||||||
|
import ListSkeleton from './components/ListSkeleton';
|
||||||
|
import MainTripBody from './components/MainTripBody';
|
||||||
|
import TripCancleOrFinishedButton from './components/TripCancelOrFinishButton';
|
||||||
|
const DetailTrip = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [responsive, setResponsive] = useState(false);
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
// const [tripInfo, setTripInfo] = useState<API.Trip | null>(null);
|
||||||
|
const [showAlarmList, setShowAlarmList] = useState(true);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [alarmList, setAlarmList] = useState<API.Alarm[]>([]);
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const { data, getApi } = useModel('getTrip');
|
||||||
|
const queryDataSource = async (): Promise<API.AlarmResponse> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const resp: API.AlarmResponse = await queryAlarms();
|
||||||
|
if (resp.alarms.length == 0) {
|
||||||
|
setShowAlarmList(false);
|
||||||
|
} else {
|
||||||
|
setAlarmList(resp.alarms);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
return resp;
|
||||||
|
} catch (error) {
|
||||||
|
setIsLoading(false);
|
||||||
|
setShowAlarmList(false);
|
||||||
|
return { alarms: [], level: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// const fetchTrip = async () => {
|
||||||
|
// try {
|
||||||
|
// const resp = await getTrip();
|
||||||
|
// setTripInfo(resp);
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('Error when get Trip: ', error);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// fetchTrip();
|
||||||
|
getApi();
|
||||||
|
queryDataSource();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
console.log('Rendering with tripInfo:', data),
|
||||||
|
(
|
||||||
|
<PageContainer
|
||||||
|
header={{
|
||||||
|
title: data ? data.name : 'Chuyến đi',
|
||||||
|
tags: <BadgeTripStatus status={data?.trip_status || 0} />,
|
||||||
|
}}
|
||||||
|
loading={isLoading}
|
||||||
|
extra={[
|
||||||
|
<CreateNewHaulOrTrip
|
||||||
|
trips={data || undefined}
|
||||||
|
onCallBack={async (success) => {
|
||||||
|
switch (success) {
|
||||||
|
case STATUS.CREATE_FISHING_LOG_SUCCESS:
|
||||||
|
message.success('Tạo mẻ lưới thành công');
|
||||||
|
// await fetchTrip();
|
||||||
|
break;
|
||||||
|
case STATUS.CREATE_FISHING_LOG_FAIL:
|
||||||
|
message.error('Tạo mẻ lưới thất bại');
|
||||||
|
break;
|
||||||
|
case STATUS.START_TRIP_SUCCESS:
|
||||||
|
message.success('Bắt đầu chuyến đi thành công');
|
||||||
|
// await fetchTrip();
|
||||||
|
break;
|
||||||
|
case STATUS.START_TRIP_FAIL:
|
||||||
|
message.error('Bắt đầu chuyến đi thất bại');
|
||||||
|
break;
|
||||||
|
case STATUS.UPDATE_FISHING_LOG_SUCCESS:
|
||||||
|
message.success('Cập nhật mẻ lưới thành công');
|
||||||
|
// await fetchTrip();
|
||||||
|
break;
|
||||||
|
case STATUS.UPDATE_FISHING_LOG_FAIL:
|
||||||
|
message.error('Cập nhật mẻ lưới thất bại');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{contextHolder}
|
||||||
|
<ProCard
|
||||||
|
// bordered={false}
|
||||||
|
split={responsive ? 'horizontal' : 'vertical'}
|
||||||
|
// style={{ backgroundColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
{/* Bên trái */}
|
||||||
|
{showAlarmList ? (
|
||||||
|
<ProCard
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'pages.gmsv5.alarm.list',
|
||||||
|
defaultMessage: 'Cảnh báo',
|
||||||
|
})}
|
||||||
|
colSpan={{ xs: 24, sm: 24, lg: 5 }}
|
||||||
|
bodyStyle={{ paddingInline: 0, paddingBlock: 8 }}
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
{data ? (
|
||||||
|
<AlarmTable alarmList={alarmList} isLoading={isLoading} />
|
||||||
|
) : (
|
||||||
|
<ListSkeleton />
|
||||||
|
)}
|
||||||
|
</ProCard>
|
||||||
|
) : null}
|
||||||
|
{/* */}
|
||||||
|
|
||||||
|
{/* Bên phải */}
|
||||||
|
<ProCard
|
||||||
|
colSpan={
|
||||||
|
showAlarmList
|
||||||
|
? { xs: 24, sm: 24, lg: 19 }
|
||||||
|
: { xs: 24, sm: 24, lg: 24 }
|
||||||
|
}
|
||||||
|
// bodyStyle={{ padding: 0 }}
|
||||||
|
// style={{ backgroundColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
<MainTripBody
|
||||||
|
trip_id={data?.id}
|
||||||
|
tripInfo={data || null}
|
||||||
|
onReload={(isReload) => {
|
||||||
|
console.log('Nhanaj dduowcj hàm, onReload:', isReload);
|
||||||
|
|
||||||
|
// if (isReload) {
|
||||||
|
// fetchTrip();
|
||||||
|
// }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
|
<Flex
|
||||||
|
style={{
|
||||||
|
padding: 10,
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: token.colorBgContainer,
|
||||||
|
}}
|
||||||
|
justify="center"
|
||||||
|
gap={10}
|
||||||
|
>
|
||||||
|
<TripCancleOrFinishedButton
|
||||||
|
tripStatus={data?.trip_status}
|
||||||
|
onCallBack={(value) => async () => {
|
||||||
|
if (value) {
|
||||||
|
// await fetchTrip();
|
||||||
|
await queryDataSource();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</PageContainer>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DetailTrip;
|
||||||
11
src/services/controller/AuthController.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { API_PATH_LOGIN } from '@/constants';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
export async function login(body: API.LoginRequestBody) {
|
||||||
|
console.log('Login request body:', body);
|
||||||
|
|
||||||
|
return request<API.LoginResponse>(API_PATH_LOGIN, {
|
||||||
|
method: 'POST',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
54
src/services/controller/DeviceController.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
API_GET_ALARMS,
|
||||||
|
API_GET_GPS,
|
||||||
|
API_PATH_ENTITIES,
|
||||||
|
API_PATH_SHIP_INFO,
|
||||||
|
API_SOS,
|
||||||
|
} from '@/constants';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
function transformEntityResponse(
|
||||||
|
raw: API.EntityResponse,
|
||||||
|
): API.TransformedEntity {
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
value: raw.v,
|
||||||
|
valueString: raw.vs,
|
||||||
|
time: raw.t,
|
||||||
|
type: raw.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEntities(): Promise<API.TransformedEntity[]> {
|
||||||
|
const rawList = await request<API.EntityResponse[]>(API_PATH_ENTITIES);
|
||||||
|
return rawList.map(transformEntityResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getShipInfo(): Promise<API.ShipDetail> {
|
||||||
|
return await request<API.ShipDetail>(API_PATH_SHIP_INFO);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryAlarms(): Promise<API.AlarmResponse> {
|
||||||
|
return await request<API.AlarmResponse>(API_GET_ALARMS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGPS() {
|
||||||
|
return await request<API.GPSResonse>(API_GET_GPS);
|
||||||
|
}
|
||||||
|
export async function getSos() {
|
||||||
|
return await request<API.SosResponse>(API_SOS);
|
||||||
|
}
|
||||||
|
export async function deleteSos() {
|
||||||
|
return await request<API.SosResponse>(API_SOS, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendSosMessage(message: string) {
|
||||||
|
return await request<API.SosRequest>(API_SOS, {
|
||||||
|
method: 'PUT',
|
||||||
|
params: {
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
10
src/services/controller/MapController.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { API_GET_ALL_LAYER, API_GET_LAYER_INFO } from '@/constants';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
export async function getLayer(name: string) {
|
||||||
|
return request(`${API_GET_LAYER_INFO}/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllLayer() {
|
||||||
|
return request(API_GET_ALL_LAYER);
|
||||||
|
}
|
||||||
37
src/services/controller/TripController.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
API_GET_FISH,
|
||||||
|
API_GET_TRIP,
|
||||||
|
API_HAUL_HANDLE,
|
||||||
|
API_UPDATE_FISHING_LOGS,
|
||||||
|
API_UPDATE_TRIP_STATUS,
|
||||||
|
} from '@/constants';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
export async function getTrip(): Promise<API.Trip> {
|
||||||
|
return request<API.Trip>(API_GET_TRIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTripState(body: API.TripUpdateStateRequest) {
|
||||||
|
return request(API_UPDATE_TRIP_STATUS, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startNewHaul(body: API.NewFishingLogRequest) {
|
||||||
|
return request(API_HAUL_HANDLE, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFishSpecies() {
|
||||||
|
return request<API.FishSpeciesResponse[]>(API_GET_FISH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFishingLogs(body: API.FishingLog) {
|
||||||
|
return request(API_UPDATE_FISHING_LOGS, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
97
src/services/controller/UserController.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||||
|
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
/** 此处后端没有提供注释 GET /api/v1/queryUserList */
|
||||||
|
export async function queryUserList(
|
||||||
|
params: {
|
||||||
|
// query
|
||||||
|
/** keyword */
|
||||||
|
keyword?: string;
|
||||||
|
/** current */
|
||||||
|
current?: number;
|
||||||
|
/** pageSize */
|
||||||
|
pageSize?: number;
|
||||||
|
},
|
||||||
|
options?: { [key: string]: any },
|
||||||
|
) {
|
||||||
|
return request<API.Result_PageInfo_UserInfo__>('/api/v1/queryUserList', {
|
||||||
|
method: 'GET',
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 此处后端没有提供注释 POST /api/v1/user */
|
||||||
|
export async function addUser(
|
||||||
|
body?: API.UserInfoVO,
|
||||||
|
options?: { [key: string]: any },
|
||||||
|
) {
|
||||||
|
return request<API.Result_UserInfo_>('/api/v1/user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 此处后端没有提供注释 GET /api/v1/user/${param0} */
|
||||||
|
export async function getUserDetail(
|
||||||
|
params: {
|
||||||
|
// path
|
||||||
|
/** userId */
|
||||||
|
userId?: string;
|
||||||
|
},
|
||||||
|
options?: { [key: string]: any },
|
||||||
|
) {
|
||||||
|
const { userId: param0 } = params;
|
||||||
|
return request<API.Result_UserInfo_>(`/api/v1/user/${param0}`, {
|
||||||
|
method: 'GET',
|
||||||
|
params: { ...params },
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 此处后端没有提供注释 PUT /api/v1/user/${param0} */
|
||||||
|
export async function modifyUser(
|
||||||
|
params: {
|
||||||
|
// path
|
||||||
|
/** userId */
|
||||||
|
userId?: string;
|
||||||
|
},
|
||||||
|
body?: API.UserInfoVO,
|
||||||
|
options?: { [key: string]: any },
|
||||||
|
) {
|
||||||
|
const { userId: param0 } = params;
|
||||||
|
return request<API.Result_UserInfo_>(`/api/v1/user/${param0}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
params: { ...params },
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 此处后端没有提供注释 DELETE /api/v1/user/${param0} */
|
||||||
|
export async function deleteUser(
|
||||||
|
params: {
|
||||||
|
// path
|
||||||
|
/** userId */
|
||||||
|
userId?: string;
|
||||||
|
},
|
||||||
|
options?: { [key: string]: any },
|
||||||
|
) {
|
||||||
|
const { userId: param0 } = params;
|
||||||
|
return request<API.Result_string_>(`/api/v1/user/${param0}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
params: { ...params },
|
||||||
|
...(options || {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/services/controller/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||||
|
|
||||||
|
import * as AuthController from './AuthController';
|
||||||
|
import * as DeviceController from './DeviceController';
|
||||||
|
import * as MapController from './MapController';
|
||||||
|
import * as TripController from './TripController';
|
||||||
|
import * as UserController from './UserController';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
UserController,
|
||||||
|
AuthController,
|
||||||
|
DeviceController,
|
||||||
|
MapController,
|
||||||
|
TripController,
|
||||||
|
};
|
||||||
331
src/services/controller/typings.d.ts
vendored
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||||
|
|
||||||
|
declare namespace API {
|
||||||
|
interface LoginRequestBody {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thông tin 1 entity
|
||||||
|
interface Entity {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger điều kiện trong TCR
|
||||||
|
interface Trigger {
|
||||||
|
entityID: string;
|
||||||
|
gt: number;
|
||||||
|
lt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command thực thi trong TCR
|
||||||
|
interface Command {
|
||||||
|
to: string;
|
||||||
|
params: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cấu hình ngày/tuần/tháng/năm của TCR
|
||||||
|
interface ActiveDay {
|
||||||
|
activeDateRange: string[];
|
||||||
|
dayInWeek: string[];
|
||||||
|
dayInMonth: string[];
|
||||||
|
deactiveInYear: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCR = Trigger Condition Rule
|
||||||
|
interface TCR {
|
||||||
|
id: string;
|
||||||
|
enable: boolean;
|
||||||
|
description: string;
|
||||||
|
name: string;
|
||||||
|
trigger: Trigger[];
|
||||||
|
conditions: any[]; // chưa rõ cấu trúc nên tạm để any[]
|
||||||
|
duration: number;
|
||||||
|
commands: Command[];
|
||||||
|
sendEvent: boolean;
|
||||||
|
eventID: string;
|
||||||
|
makeAlarm: string;
|
||||||
|
alarmLevel: number;
|
||||||
|
activeDay: ActiveDay;
|
||||||
|
activeTime: number[]; // [start, end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sự kiện
|
||||||
|
interface Event {
|
||||||
|
evtid: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node chính (mỗi thiết bị / sensor)
|
||||||
|
interface Node {
|
||||||
|
nid: string;
|
||||||
|
subtype?: string; // có thể không có (ví dụ hum:0:21)
|
||||||
|
enable: boolean;
|
||||||
|
name: string;
|
||||||
|
entities: Entity[];
|
||||||
|
TCR?: TCR[]; // không phải node nào cũng có
|
||||||
|
events?: Event[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response root
|
||||||
|
interface NodeResponse {
|
||||||
|
nodes: Node[];
|
||||||
|
synced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EntityResponse {
|
||||||
|
id: string;
|
||||||
|
v: number;
|
||||||
|
vs: string;
|
||||||
|
t: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
interface TransformedEntity {
|
||||||
|
id: string;
|
||||||
|
value: number;
|
||||||
|
valueString: string;
|
||||||
|
time: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipDetail {
|
||||||
|
id: string;
|
||||||
|
thing_id: string;
|
||||||
|
owner_id: string;
|
||||||
|
name: string;
|
||||||
|
ship_type: number;
|
||||||
|
home_port: number;
|
||||||
|
ship_length: number;
|
||||||
|
ship_power: number;
|
||||||
|
reg_number: string;
|
||||||
|
imo_number: string;
|
||||||
|
mmsi_number: string;
|
||||||
|
fishing_license_number: string;
|
||||||
|
fishing_license_expiry_date: Date;
|
||||||
|
province_code: string;
|
||||||
|
ship_group_id: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GPSResonse {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
s: number;
|
||||||
|
h: number;
|
||||||
|
fishing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trips
|
||||||
|
interface FishingGear {
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCost {
|
||||||
|
type: string;
|
||||||
|
unit: string;
|
||||||
|
amount: string;
|
||||||
|
total_cost: string;
|
||||||
|
cost_per_unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCrewPerson {
|
||||||
|
personal_id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
birth_date: Date; // ISO string (có thể chuyển sang Date nếu parse trước)
|
||||||
|
note: string;
|
||||||
|
address: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCrews {
|
||||||
|
role: string;
|
||||||
|
joined_at: Date;
|
||||||
|
left_at: Date | null;
|
||||||
|
note: string | null;
|
||||||
|
Person: TripCrewPerson;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FishingLogInfo {
|
||||||
|
fish_species_id?: number;
|
||||||
|
fish_name?: string;
|
||||||
|
catch_number?: number;
|
||||||
|
catch_unit?: string;
|
||||||
|
fish_size?: number;
|
||||||
|
fish_rarity?: number;
|
||||||
|
fish_condition?: string;
|
||||||
|
gear_usage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FishingLog {
|
||||||
|
fishing_log_id: string;
|
||||||
|
trip_id: string;
|
||||||
|
start_at: Date; // ISO datetime
|
||||||
|
end_at: Date; // ISO datetime
|
||||||
|
start_lat: number;
|
||||||
|
start_lon: number;
|
||||||
|
haul_lat: number;
|
||||||
|
haul_lon: number;
|
||||||
|
status: number;
|
||||||
|
weather_description: string;
|
||||||
|
info?: FishingLogInfo[];
|
||||||
|
sync: boolean;
|
||||||
|
}
|
||||||
|
interface NewFishingLogRequest {
|
||||||
|
trip_id: string;
|
||||||
|
start_at: Date; // ISO datetime
|
||||||
|
start_lat: number;
|
||||||
|
start_lon: number;
|
||||||
|
weather_description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Trip {
|
||||||
|
id: string;
|
||||||
|
ship_id: string;
|
||||||
|
ship_length: number;
|
||||||
|
vms_id: string;
|
||||||
|
name: string;
|
||||||
|
fishing_gears: FishingGear[];
|
||||||
|
crews?: TripCrews[];
|
||||||
|
departure_time: string; // ISO datetime string
|
||||||
|
departure_port_id: number;
|
||||||
|
arrival_time: string; // ISO datetime string
|
||||||
|
arrival_port_id: number;
|
||||||
|
fishing_ground_codes: number[];
|
||||||
|
total_catch_weight: number | null;
|
||||||
|
total_species_caught: number | null;
|
||||||
|
trip_cost: TripCost[];
|
||||||
|
trip_status: number;
|
||||||
|
approved_by: string;
|
||||||
|
notes: string | null;
|
||||||
|
fishing_logs: FishingLog[] | null; // tuỳ dữ liệu chi tiết có thể định nghĩa thêm
|
||||||
|
sync: boolean;
|
||||||
|
}
|
||||||
|
interface TripUpdateStateRequest {
|
||||||
|
status: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Alarm {
|
||||||
|
name: string;
|
||||||
|
t: number; // timestamp (epoch seconds)
|
||||||
|
level: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlarmResponse {
|
||||||
|
alarms: Alarm[];
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Fish
|
||||||
|
interface FishSpeciesResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
scientific_name: string;
|
||||||
|
group_name: string;
|
||||||
|
species_code: string;
|
||||||
|
note: string;
|
||||||
|
default_unit: string;
|
||||||
|
rarity_level: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
|
interface FishRarity {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
iucn_code: any;
|
||||||
|
cites_appendix: any;
|
||||||
|
vn_law: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SOS
|
||||||
|
|
||||||
|
interface SosRequest {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SosResponse {
|
||||||
|
active: boolean;
|
||||||
|
message?: string;
|
||||||
|
started_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node
|
||||||
|
interface Welcome {
|
||||||
|
nodes?: Node[];
|
||||||
|
synced?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Node {
|
||||||
|
nid?: string;
|
||||||
|
subtype?: string;
|
||||||
|
enable?: boolean;
|
||||||
|
name?: string;
|
||||||
|
entities?: Entity[];
|
||||||
|
TCR?: Tcr[];
|
||||||
|
events?: Event[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tcr {
|
||||||
|
id?: string;
|
||||||
|
enable?: boolean;
|
||||||
|
description?: string;
|
||||||
|
name?: string;
|
||||||
|
trigger?: Trigger[];
|
||||||
|
conditions?: any[];
|
||||||
|
duration?: number;
|
||||||
|
commands?: Command[];
|
||||||
|
sendEvent?: boolean;
|
||||||
|
eventID?: string;
|
||||||
|
makeAlarm?: string;
|
||||||
|
alarmLevel?: number;
|
||||||
|
activeDay?: ActiveDay;
|
||||||
|
activeTime?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveDay {
|
||||||
|
activeDateRange?: any[];
|
||||||
|
dayInWeek?: any[];
|
||||||
|
dayInMonth?: any[];
|
||||||
|
deactiveInYear?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Command {
|
||||||
|
to?: string;
|
||||||
|
params?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Trigger {
|
||||||
|
entityID?: string;
|
||||||
|
gt?: number;
|
||||||
|
lt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entity {
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Event {
|
||||||
|
evtid?: string;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/services/service/MapService.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { createGeoJSONLayer, createLabelAndFillStyle } from '@/utils/mapUtils';
|
||||||
|
import TileLayer from 'ol/layer/Tile';
|
||||||
|
import { XYZ } from 'ol/source';
|
||||||
|
import shipAlarmIcon from '../../assets/ship_alarm.png';
|
||||||
|
import shipAlarmFishingIcon from '../../assets/ship_alarm_fishing.png';
|
||||||
|
import shipOnlineIcon from '../../assets/ship_online.png';
|
||||||
|
import shipOnlineFishingIcon from '../../assets/ship_online_fishing.png';
|
||||||
|
import shipUndefineIcon from '../../assets/ship_undefine.png';
|
||||||
|
import shipWarningIcon from '../../assets/ship_warning.png';
|
||||||
|
import shipWarningFishingIcon from '../../assets/ship_warning_fishing.png';
|
||||||
|
import shipSosIcon from '../../assets/sos_icon.png';
|
||||||
|
export const BASEMAP_URL =
|
||||||
|
'https://cartodb-basemaps-a.global.ssl.fastly.net/light_nolabels/{z}/{x}/{y}.png';
|
||||||
|
export const BASEMAP_ATTRIBUTIONS = '© OpenStreetMap contributors, © CartoDB';
|
||||||
|
|
||||||
|
export const INITIAL_VIEW_CONFIG = {
|
||||||
|
// Dịch tâm bản đồ ra phía biển và zoom ra xa hơn để thấy bao quát
|
||||||
|
center: [109.5, 16.0],
|
||||||
|
zoom: 5.5,
|
||||||
|
minZoom: 5,
|
||||||
|
maxZoom: 12,
|
||||||
|
minScale: 0.1,
|
||||||
|
maxScale: 0.4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const osmLayer = new TileLayer({
|
||||||
|
source: new XYZ({
|
||||||
|
url: BASEMAP_URL,
|
||||||
|
attributions: BASEMAP_ATTRIBUTIONS,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// const layerVN = await getLayer('base-vn');
|
||||||
|
|
||||||
|
export const tinhGeoLayer = createGeoJSONLayer({
|
||||||
|
data: '',
|
||||||
|
style: createLabelAndFillStyle('#77BEF0', '#000000'), // vừa màu vừa label
|
||||||
|
properties: {
|
||||||
|
name: 'tenTinh',
|
||||||
|
popupProperties: { 'Dân số': 'danSo', 'Diện tích (km²)': 'dienTich' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getShipIcon = (type: number, isFishing: boolean) => {
|
||||||
|
console.log('type, isFishing', type, isFishing);
|
||||||
|
|
||||||
|
if (type === 1 && !isFishing) {
|
||||||
|
return shipWarningIcon;
|
||||||
|
} else if (type === 2 && !isFishing) {
|
||||||
|
return shipAlarmIcon;
|
||||||
|
} else if (type === 0 && !isFishing) {
|
||||||
|
return shipOnlineIcon;
|
||||||
|
} else if (type === 1 && isFishing) {
|
||||||
|
return shipWarningFishingIcon;
|
||||||
|
} else if (type === 2 && isFishing) {
|
||||||
|
return shipAlarmFishingIcon;
|
||||||
|
} else if (type === 0 && isFishing) {
|
||||||
|
return shipOnlineFishingIcon;
|
||||||
|
} else if (type === 3) {
|
||||||
|
return shipSosIcon;
|
||||||
|
} else {
|
||||||
|
return shipUndefineIcon;
|
||||||
|
}
|
||||||
|
};
|
||||||
4
src/types/geojson.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.geojson' {
|
||||||
|
const value: any;
|
||||||
|
export default value;
|
||||||
|
}
|
||||||
111
src/utils/fishRarity.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
export const fishRarities: API.FishRarity[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: 'common',
|
||||||
|
label: 'Phổ biến',
|
||||||
|
description: 'Loài không có nguy cơ bị đe dọa',
|
||||||
|
iucn_code: null,
|
||||||
|
cites_appendix: null,
|
||||||
|
vn_law: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
code: 'near-threatened',
|
||||||
|
label: 'Gần bị đe dọa',
|
||||||
|
description: 'Có nguy cơ trong tương lai gần',
|
||||||
|
iucn_code: 'NT',
|
||||||
|
cites_appendix: null,
|
||||||
|
vn_law: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
code: 'vulnerable',
|
||||||
|
label: 'Dễ bị tổn thương',
|
||||||
|
description: 'Loài có nguy cơ tuyệt chủng mức trung bình',
|
||||||
|
iucn_code: 'VU',
|
||||||
|
cites_appendix: null,
|
||||||
|
vn_law: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
code: 'endangered',
|
||||||
|
label: 'Nguy cấp',
|
||||||
|
description: 'Nguy cơ tuyệt chủng cao',
|
||||||
|
iucn_code: 'EN',
|
||||||
|
cites_appendix: null,
|
||||||
|
vn_law: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
code: 'critically-endangered',
|
||||||
|
label: 'Cực kỳ nguy cấp',
|
||||||
|
description: 'Nguy cơ tuyệt chủng rất cao',
|
||||||
|
iucn_code: 'CR',
|
||||||
|
cites_appendix: null,
|
||||||
|
vn_law: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
code: 'cites-app-i',
|
||||||
|
label: 'CITES Phụ lục I',
|
||||||
|
description: 'Cấm hoàn toàn buôn bán quốc tế',
|
||||||
|
iucn_code: null,
|
||||||
|
cites_appendix: null,
|
||||||
|
vn_law: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
code: 'cites-app-ii',
|
||||||
|
label: 'CITES Phụ lục II',
|
||||||
|
description: 'Kiểm soát buôn bán quốc tế',
|
||||||
|
iucn_code: null,
|
||||||
|
cites_appendix: null,
|
||||||
|
vn_law: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
code: 'protected-vn',
|
||||||
|
label: 'Bảo vệ theo Luật VN',
|
||||||
|
description: 'Cấm đánh bắt hoặc hạn chế tại VN',
|
||||||
|
iucn_code: null,
|
||||||
|
cites_appendix: null,
|
||||||
|
vn_law: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getRarityById(id: number) {
|
||||||
|
const rarity = fishRarities.find((item) => item.id === id);
|
||||||
|
|
||||||
|
// Kiểm tra xem đối tượng có được tìm thấy hay không
|
||||||
|
if (rarity) {
|
||||||
|
// Nếu tìm thấy, trả về label và description
|
||||||
|
return {
|
||||||
|
rarityLabel: rarity.label,
|
||||||
|
rarityDescription: rarity.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu không tìm thấy, trả về null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getColorByRarityLevel = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case 2:
|
||||||
|
return '#FFCC00';
|
||||||
|
case 3:
|
||||||
|
return '#FFA500';
|
||||||
|
case 4:
|
||||||
|
return '#FF7F50';
|
||||||
|
case 5:
|
||||||
|
return '#FF6347';
|
||||||
|
case 6:
|
||||||
|
return '#FF4500';
|
||||||
|
case 7:
|
||||||
|
return '#FF0000';
|
||||||
|
case 8:
|
||||||
|
return '#8B0000';
|
||||||
|
default:
|
||||||
|
return 'B6F500';
|
||||||
|
}
|
||||||
|
};
|
||||||
21
src/utils/format.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// 示例方法,没有实际意义
|
||||||
|
export function trim(str: string) {
|
||||||
|
return str.trim();
|
||||||
|
}
|
||||||
|
export const DURATION_POLLING = 15000; //15s
|
||||||
|
export const DURATION_CHART = 300000; //5m
|
||||||
|
export const DEFAULT_LIMIT = 200;
|
||||||
|
export const DATE_TIME_FORMAT = 'DD/MM/YY HH:mm:ss';
|
||||||
|
export const TIME_FORMAT = 'HH:mm:ss';
|
||||||
|
export const DATE_FORMAT = 'DD/MM/YYYY';
|
||||||
|
|
||||||
|
export const DURATION_DISCONNECTED = 300; //senconds
|
||||||
|
export const DURATION_POLLING_PRESENTATIONS = 120000; //miliseconds
|
||||||
|
export const DURATION_POLLING_CHART = 60000; //miliseconds
|
||||||
|
export const WAIT_DURATION = 1500;
|
||||||
|
export const WAIT_ACK_DURATION = 5000;
|
||||||
|
|
||||||
|
export const STATUS_NORMAL = 0;
|
||||||
|
export const STATUS_WARNING = 1;
|
||||||
|
export const STATUS_DANGEROUS = 2;
|
||||||
|
export const STATUS_SOS = 3;
|
||||||
13
src/utils/jwtTokenUtils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function parseJwt(token: string) {
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const base64Url = token.split('.')[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const jsonPayload = decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split('')
|
||||||
|
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join(''),
|
||||||
|
);
|
||||||
|
return JSON.parse(jsonPayload);
|
||||||
|
}
|
||||||
13
src/utils/localStorageUtils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { TOKEN } from '@/constants';
|
||||||
|
|
||||||
|
export function getToken(): string {
|
||||||
|
return localStorage.getItem(TOKEN) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string) {
|
||||||
|
localStorage.setItem(TOKEN, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeToken() {
|
||||||
|
localStorage.removeItem(TOKEN);
|
||||||
|
}
|
||||||
73
src/utils/mapUtils.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import GeoJSON from 'ol/format/GeoJSON';
|
||||||
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
|
import VectorSource from 'ol/source/Vector';
|
||||||
|
import Fill from 'ol/style/Fill';
|
||||||
|
import Stroke from 'ol/style/Stroke';
|
||||||
|
import Style from 'ol/style/Style';
|
||||||
|
import Text from 'ol/style/Text';
|
||||||
|
|
||||||
|
export const OL_PROJECTION = 'EPSG:3857';
|
||||||
|
|
||||||
|
export const createGeoJSONLayer = (options: any) => {
|
||||||
|
const { data, style = null, popupProperties = null } = options;
|
||||||
|
|
||||||
|
const source =
|
||||||
|
typeof data === 'string'
|
||||||
|
? new VectorSource({
|
||||||
|
url: data,
|
||||||
|
format: new GeoJSON(),
|
||||||
|
})
|
||||||
|
: new VectorSource({
|
||||||
|
features: new GeoJSON().readFeatures(data, {
|
||||||
|
dataProjection: 'EPSG:4326', // projection của dữ liệu
|
||||||
|
featureProjection: 'EPSG:3857', // projection bản đồ
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new VectorLayer({
|
||||||
|
source,
|
||||||
|
style: style,
|
||||||
|
properties: {
|
||||||
|
popupProperties,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLabelStyle = (feature: any) => {
|
||||||
|
const tenTinh = feature.get('tenTinh');
|
||||||
|
return new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: '#333',
|
||||||
|
width: 1,
|
||||||
|
}),
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(0, 150, 255, 0.2)',
|
||||||
|
}),
|
||||||
|
text: new Text({
|
||||||
|
font: '14px Calibri,sans-serif',
|
||||||
|
fill: new Fill({ color: '#000' }),
|
||||||
|
stroke: new Stroke({ color: '#fff', width: 3 }),
|
||||||
|
text: tenTinh || '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createLabelAndFillStyle = (
|
||||||
|
fillColor = 'rgba(255,0,0,0.1)',
|
||||||
|
strokeColor = '#ff0000',
|
||||||
|
) => {
|
||||||
|
return (feature: any) => {
|
||||||
|
const tenTinh = feature.get('tenTinh');
|
||||||
|
|
||||||
|
return new Style({
|
||||||
|
stroke: new Stroke({ color: strokeColor, width: 1 }),
|
||||||
|
fill: new Fill({ color: fillColor }),
|
||||||
|
text: new Text({
|
||||||
|
font: '14px Calibri,sans-serif',
|
||||||
|
fill: new Fill({ color: '#000' }),
|
||||||
|
stroke: new Stroke({ color: '#fff', width: 3 }),
|
||||||
|
text: tenTinh || '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
91
src/utils/sosUtil.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Định nghĩa cấu trúc cho mỗi lý do cần hỗ trợ/SOS
|
||||||
|
*/
|
||||||
|
interface SosMessage {
|
||||||
|
ma: number; // Mã số thứ tự của lý do
|
||||||
|
moTa: string; // Mô tả ngắn gọn về sự cố
|
||||||
|
mucDoNghiemTrong: string;
|
||||||
|
chiTiet: string; // Chi tiết sự cố
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mảng 10 lý do phát tín hiệu SOS/Yêu cầu trợ giúp trên biển
|
||||||
|
* Sắp xếp từ nhẹ (yêu cầu hỗ trợ) đến nặng (SOS khẩn cấp)
|
||||||
|
*/
|
||||||
|
export const sosMessage: SosMessage[] = [
|
||||||
|
{
|
||||||
|
ma: 11,
|
||||||
|
moTa: 'Tình huống khẩn cấp, không kịp chọn !!!',
|
||||||
|
mucDoNghiemTrong: 'Nguy Hiem Can Ke (SOS)',
|
||||||
|
chiTiet:
|
||||||
|
'Tình huống nghiêm trọng nhất, đe dọa trực tiếp đến tính mạng tất cả người trên tàu.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 1,
|
||||||
|
moTa: 'Hỏng hóc động cơ không tự khắc phục được',
|
||||||
|
mucDoNghiemTrong: 'Nhe',
|
||||||
|
chiTiet: 'Tàu bị trôi hoặc mắc cạn nhẹ; cần tàu lai hoặc thợ máy.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 2,
|
||||||
|
moTa: 'Thiếu nhiên liệu/thực phẩm/nước uống nghiêm trọng',
|
||||||
|
mucDoNghiemTrong: 'Nhe',
|
||||||
|
chiTiet:
|
||||||
|
'Dự trữ thiết yếu cạn kiệt do hành trình kéo dài không lường trước được.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 3,
|
||||||
|
moTa: 'Sự cố y tế không nguy hiểm đến tính mạng',
|
||||||
|
mucDoNghiemTrong: 'Trung Binh',
|
||||||
|
chiTiet:
|
||||||
|
'Cần chăm sóc y tế chuyên nghiệp khẩn cấp (ví dụ: gãy xương, viêm ruột thừa).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 4,
|
||||||
|
moTa: 'Hỏng hóc thiết bị định vị/thông tin liên lạc chính',
|
||||||
|
mucDoNghiemTrong: 'Trung Binh',
|
||||||
|
chiTiet: 'Mất khả năng xác định vị trí hoặc liên lạc, tăng rủi ro bị lạc.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 5,
|
||||||
|
moTa: 'Thời tiết cực đoan sắp tới không kịp trú ẩn',
|
||||||
|
mucDoNghiemTrong: 'Trung Binh',
|
||||||
|
chiTiet:
|
||||||
|
'Tàu không kịp chạy vào nơi trú ẩn an toàn trước cơn bão lớn hoặc gió giật mạnh.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 6,
|
||||||
|
moTa: 'Va chạm gây hư hỏng cấu trúc',
|
||||||
|
mucDoNghiemTrong: 'Nang',
|
||||||
|
chiTiet:
|
||||||
|
'Tàu bị hư hại một phần do va chạm, cần kiểm tra và hỗ trợ lai dắt khẩn cấp.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 7,
|
||||||
|
moTa: 'Có cháy/hỏa hoạn trên tàu không kiểm soát được',
|
||||||
|
mucDoNghiemTrong: 'Nang',
|
||||||
|
chiTiet:
|
||||||
|
'Lửa bùng phát vượt quá khả năng chữa cháy của tàu, nguy cơ cháy lan.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 8,
|
||||||
|
moTa: 'Tàu bị thủng/nước vào không kiểm soát được',
|
||||||
|
mucDoNghiemTrong: 'Rat Nang',
|
||||||
|
chiTiet:
|
||||||
|
'Nước tràn vào khoang quá nhanh, vượt quá khả năng bơm tát, đe dọa tàu chìm.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 9,
|
||||||
|
moTa: 'Sự cố y tế nguy hiểm đến tính mạng (MEDEVAC)',
|
||||||
|
mucDoNghiemTrong: 'Rat Nang',
|
||||||
|
chiTiet:
|
||||||
|
'Thương tích/bệnh tật nghiêm trọng, cần sơ tán y tế (MEDEVAC) ngay lập tức bằng trực thăng/tàu cứu hộ.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ma: 10,
|
||||||
|
moTa: 'Tàu bị chìm/lật úp hoàn toàn hoặc sắp xảy ra',
|
||||||
|
mucDoNghiemTrong: 'Nguy Hiem Can Ke (SOS)',
|
||||||
|
chiTiet:
|
||||||
|
'Tình huống nghiêm trọng nhất, đe dọa trực tiếp đến tính mạng tất cả người trên tàu.',
|
||||||
|
},
|
||||||
|
];
|
||||||
7
tailwind.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.tsx',
|
||||||
|
'./src/components/**/*.tsx',
|
||||||
|
'./src/layouts/**/*.tsx',
|
||||||
|
],
|
||||||
|
}
|
||||||
3
tailwind.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./src/.umi/tsconfig.json"
|
||||||
|
}
|
||||||
1
typings.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@umijs/max/typings';
|
||||||