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