feat(core): sgw-device-ui

This commit is contained in:
Tran Anh Tuan
2025-09-26 18:22:04 +07:00
parent 466e931537
commit 2707b92f7e
88 changed files with 19104 additions and 0 deletions

3
.eslintrc.js Normal file
View File

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

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

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

1
.husky/pre-commit Normal file
View File

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

17
.lintstagedrc Normal file
View File

@@ -0,0 +1,17 @@
{
"*.{md,json}": [
"prettier --cache --write"
],
"*.{js,jsx}": [
"max lint --fix --eslint-only",
"prettier --cache --write"
],
"*.{css,less}": [
"max lint --fix --stylelint-only",
"prettier --cache --write"
],
"*.ts?(x)": [
"max lint --fix --eslint-only",
"prettier --cache --parser=typescript --write"
]
}

2
.npmrc Normal file
View File

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

3
.prettierignore Normal file
View File

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

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"printWidth": 80,
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never",
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
}

3
.stylelintrc.js Normal file
View File

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

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

@@ -0,0 +1,112 @@
import { ROUTE_LOGIN } from '@/constants';
import { getToken, removeToken } from '@/utils/localStorageUtils';
import { history, RequestConfig } from '@umijs/max';
import { message } from 'antd';
// Error handling scheme: Error types
enum ErrorShowType {
SILENT = 0,
WARN_MESSAGE = 1,
ERROR_MESSAGE = 2,
NOTIFICATION = 3,
REDIRECT = 9,
}
// Response data structure agreed with the backend
interface ResponseStructure<T = any> {
success: boolean;
data: T;
errorCode?: number;
errorMessage?: string;
showType?: ErrorShowType;
}
const codeMessage = {
200: 'The server successfully returned the requested data。',
201: 'New or modified data succeeded。',
202: 'A request has been queued in the background (asynchronous task)。',
204: 'Data deleted successfully。',
400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
401: 'The user does not have permission (token, username, password is wrong) 。',
403: 'User is authorized, but access is prohibited。',
404: 'The request issued was for a non-existent record, the server did not operate。',
406: 'The requested format is not available。',
410: 'The requested resource is permanently deleted and will no longer be available。',
422: 'When creating an object, a validation error occurred。',
500: 'Server error, please check the server。',
502: 'Gateway error。',
503: 'Service unavailable, server temporarily overloaded or maintained。',
504: 'Gateway timeout。',
};
// Runtime configuration
export const handleRequestConfig: RequestConfig = {
// Unified request settings
timeout: 20000,
headers: { 'X-Requested-With': 'XMLHttpRequest' },
// Error handling: umi@3's error handling scheme.
errorConfig: {
// Error throwing
errorThrower: (res: any) => {
console.log('Response from backend:', res);
const { success, data, errorCode, errorMessage, showType } = res;
if (!success) {
const error: any = new Error(errorMessage);
error.name = 'BizError';
error.info = { errorCode, errorMessage, showType, data };
throw error; // Throw custom error
}
},
// Error catching and handling
errorHandler: (error: any) => {
if (error.response) {
const { status, statusText, data } = error.response;
// Ưu tiên: 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/owner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

10
src/access.ts Normal file
View 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
View 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
View File

BIN
src/assets/alarm_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
src/assets/exclamation.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
src/assets/marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src/assets/ship_alarm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
src/assets/ship_alarm_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
src/assets/ship_online.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
src/assets/ship_warning.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
src/assets/sos_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
src/assets/warning_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { Button, Result } from 'antd';
const UnAccessPage: React.FC = () => (
<Result
status="403"
title="403"
subTitle="Sorry, you are not authorized to access this page."
extra={<Button type="primary">Back Home</Button>}
/>
);
export default UnAccessPage;

View File

@@ -0,0 +1,13 @@
import { Button, Result } from 'antd';
import React from 'react';
const NotFoundPage: React.FC = () => (
<Result
status="404"
title="404"
subTitle="Sorry, the page you visited does not exist."
extra={<Button type="primary">Back Home</Button>}
/>
);
export default <NotFoundPage />;

View File

@@ -0,0 +1,13 @@
import { Button, Result } from 'antd';
import React from 'react';
const InternalServerErrorPage: React.FC = () => (
<Result
status="500"
title="500"
subTitle="Sorry, something went wrong."
extra={<Button type="primary">Back Home</Button>}
/>
);
export default InternalServerErrorPage;

View File

@@ -0,0 +1,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;

View File

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

View File

@@ -0,0 +1,23 @@
import { Layout, Row, Typography } from 'antd';
import React from 'react';
import styles from './Guide.less';
interface Props {
name: string;
}
// 脚手架示例组件
const Guide: React.FC<Props> = (props) => {
const { name } = props;
return (
<Layout>
<Row>
<Typography.Title level={3} className={styles.title}>
使 <strong>{name}</strong>
</Typography.Title>
</Row>
</Layout>
);
};
export default Guide;

View File

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,3 @@
.container {
padding-top: 80px;
}

163
src/pages/Home/index.tsx Normal file
View 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;

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

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

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

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

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

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

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

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

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

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

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

View 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' }}> đ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;

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

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

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

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

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

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

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

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

@@ -0,0 +1,4 @@
declare module '*.geojson' {
const value: any;
export default value;
}

111
src/utils/fishRarity.ts Normal file
View 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
View File

@@ -0,0 +1,21 @@
// 示例方法,没有实际意义
export function trim(str: string) {
return str.trim();
}
export const DURATION_POLLING = 15000; //15s
export const DURATION_CHART = 300000; //5m
export const DEFAULT_LIMIT = 200;
export const DATE_TIME_FORMAT = 'DD/MM/YY HH:mm:ss';
export const TIME_FORMAT = 'HH:mm:ss';
export const DATE_FORMAT = 'DD/MM/YYYY';
export const DURATION_DISCONNECTED = 300; //senconds
export const DURATION_POLLING_PRESENTATIONS = 120000; //miliseconds
export const DURATION_POLLING_CHART = 60000; //miliseconds
export const WAIT_DURATION = 1500;
export const WAIT_ACK_DURATION = 5000;
export const STATUS_NORMAL = 0;
export const STATUS_WARNING = 1;
export const STATUS_DANGEROUS = 2;
export const STATUS_SOS = 3;

View File

@@ -0,0 +1,13 @@
export function parseJwt(token: string) {
if (!token) return null;
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join(''),
);
return JSON.parse(jsonPayload);
}

View File

@@ -0,0 +1,13 @@
import { TOKEN } from '@/constants';
export function getToken(): string {
return localStorage.getItem(TOKEN) || '';
}
export function setToken(token: string) {
localStorage.setItem(TOKEN, token);
}
export function removeToken() {
localStorage.removeItem(TOKEN);
}

73
src/utils/mapUtils.ts Normal file
View 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
View 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
View File

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

3
tailwind.css Normal file
View File

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

3
tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./src/.umi/tsconfig.json"
}

1
typings.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
import '@umijs/max/typings';