feat(localization): Add en/vi language

This commit is contained in:
Tran Anh Tuan
2025-11-20 16:21:17 +07:00
parent dea435a4ec
commit 216e865ca5
37 changed files with 2356 additions and 455 deletions

3
.gitignore vendored
View File

@@ -11,4 +11,5 @@
/dist /dist
/.mfsu /.mfsu
.swc .swc
.DS_Store .DS_Store
claude_code_zai_env.sh

View File

@@ -12,8 +12,15 @@ export default defineConfig({
request: {}, request: {},
locale: { locale: {
default: 'vi-VN', default: 'vi-VN',
// baseNavigator: false, baseNavigator: false,
// antd: true, antd: true,
title: false,
baseSeparator: '-',
// support for Vietnamese and English
locales: [
['vi-VN', 'Tiếng Việt'],
['en-US', 'English'],
],
}, },
favicons: ['/logo.png'], favicons: ['/logo.png'],
layout: { layout: {
@@ -22,7 +29,7 @@ export default defineConfig({
proxy: proxy[REACT_APP_ENV], proxy: proxy[REACT_APP_ENV],
routes: [ routes: [
{ {
name: 'Login', title: 'Login',
path: '/login', path: '/login',
component: './Auth', component: './Auth',
layout: false, layout: false,
@@ -32,13 +39,13 @@ export default defineConfig({
redirect: '/map', redirect: '/map',
}, },
{ {
name: 'Giám sát', name: 'monitoring',
path: '/map', path: '/map',
component: './Home', component: './Home',
icon: 'icon-Map', icon: 'icon-Map',
}, },
{ {
name: 'Chuyến đi', name: 'trips',
path: '/trip', path: '/trip',
component: './Trip', component: './Trip',
icon: 'icon-specification', icon: 'icon-specification',
@@ -46,5 +53,5 @@ export default defineConfig({
], ],
npmClient: 'pnpm', npmClient: 'pnpm',
tailwindcss: {}, tailwindcss: {}, // Temporarily disabled
}); });

323
CLAUDE.md Normal file
View File

@@ -0,0 +1,323 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a React-based **Ship Monitoring System** (Hệ thống giám sát tàu cá) built with **UmiJS Max** framework for Mobifone. The application provides real-time monitoring of fishing vessels with GPS tracking, alarm management, and trip logging capabilities.
**Domain**: Maritime & Fishing Industry
**Target Users**: Fishing vessel operators and monitoring centers
**Language**: Vietnamese (Primary) with English development comments
## Technology Stack
### Core Framework
- **UmiJS Max 4.5.0** - Enterprise-level React application framework
- **React 18** - UI library with hooks and functional components
- **TypeScript** - Type-safe JavaScript
- **Ant Design 5.x** - UI component library with Pro Components
- **TailwindCSS** - Utility-first CSS framework
### Mapping & Visualization
- **OpenLayers 10.6.1** - Interactive maps and geospatial visualization
- **GeoJSON** - Geospatial data format
- **WKT (Well-Known Text)** - Geometry representation
### State Management
- **UmiJS Model Plugin** - Global state management
- **Event Bus Pattern** - Custom event-driven communication
- **React Hooks** - Local state management
### Development Tools
- **Husky + Lint-staged** - Git hooks for code quality
- **Prettier** - Code formatting
- **ESLint** - Code linting
- **pnpm** - Package manager
## Project Structure
```
src/
├── app.tsx # Global app configuration & layout
├── access.ts # User permissions & access control
├── constants/ # Application constants & enums
│ ├── index.ts # Global constants
│ └── enums.ts # Application enums
├── models/ # Global state models (UmiJS Model plugin)
│ ├── global.ts # Global state
│ ├── getTrip.ts # Trip management state
│ └── getSos.ts # SOS/Alarm state
├── pages/ # Application pages (routes)
│ ├── Auth/ # Authentication page
│ ├── Home/ # Map monitoring page
│ └── Trip/ # Trip management page
├── components/ # Reusable UI components
│ ├── 403/ # Access denied page
│ ├── 404/ # Not found page
│ ├── 500/ # Server error page
│ └── Footer/ # Footer component
├── services/ # API services & controllers
│ ├── controller/ # API endpoint controllers
│ │ ├── AuthController.ts
│ │ ├── DeviceController.ts
│ │ ├── MapController.ts
│ │ ├── TripController.ts
│ │ └── UserController.ts
│ └── service/ # Business logic services
│ └── MapService.ts
├── utils/ # Utility functions
│ ├── eventBus.ts # Custom event bus
│ ├── localStorageUtils.ts
│ ├── jwtTokenUtils.ts
│ ├── mapUtils.ts
│ ├── geomUtils.ts
│ ├── format.ts
│ └── cacheStore.ts
├── types/ # TypeScript type definitions
│ └── geojson.d.ts
├── assets/ # Static assets (icons, images)
└── .umi-production/ # UmiJS build artifacts (auto-generated)
```
## Key Architectural Patterns
### 1. Event-Driven Architecture
The application uses a custom **Event Bus** pattern for decoupled component communication:
```typescript
// src/utils/eventBus.ts
export const eventBus = new EventBus();
// Components emit events
eventBus.emit('gpsData:update', data);
// Components listen to events
eventBus.on('gpsData:update', handleUpdate);
```
**Key Events:**
- `gpsData:update` - GPS position updates
- `ship:update` - Ship marker updates
- `alarm:update` - Alarm status changes
- `trackpoints:update` - Track history updates
- `entities:update` - Geographic entities updates
### 2. Real-time Data Flow
```
GPS API (5s interval) → Event Bus → Multiple Components
Ship Marker Updates
Alarm Status Updates
Track History Updates
Zone Boundary Updates
```
### 3. Controller Pattern
API endpoints are organized in controllers following REST conventions:
```typescript
// src/services/controller/DeviceController.ts
export async function getGPS(): Promise<API.GPSResponse>
export async function queryAlarms(): Promise<API.AlarmResponse>
export async function queryEntities(): Promise<API.EntityResponse[]>
```
### 4. UmiJS Model Pattern
Global state management using UmiJS Models:
```typescript
// src/models/getTrip.ts
export default function useGetTripModel() {
const [data, setData] = useState<API.Trip | null>(null);
const getApi = useCallback(async () => {
// API call and state update
}, []);
return { data, loading, getApi };
}
```
## Development Workflow
### Available Scripts
```bash
npm run dev # Start development server
npm run build # Build for production
npm run format # Format code with Prettier
npm run setup # Initial setup
```
### Package Management
- **Primary**: `pnpm` (configured in .umirc.ts)
- **Fallback**: `npm`
### Code Quality Tools
- **Prettier**: Code formatting (80 char width, single quotes)
- **ESLint**: Code linting via UmiJS `max lint`
- **Husky**: Git hooks for pre-commit checks
- **Lint-staged**: Staged file processing
### Environment Configuration
- **Development**: `REACT_APP_ENV=dev` (localhost:8000)
- **Test**: `REACT_APP_ENV=test` (test server)
- **Production**: `REACT_APP_ENV=prod` (production server)
## Key Features & Components
### 1. Real-time GPS Monitoring (`/map`)
- **VietNamMap**: Interactive OpenLayers map
- **Ship Tracking**: Real-time vessel position updates
- **Alarm Management**: Visual alert system
- **Zone Boundaries**: Geographic restriction zones
- **SOS Functionality**: Emergency button
### 2. Trip Management (`/trip`)
- **Trip Creation/Update**: Fishing trip lifecycle
- **Haul Logging**: Fish catch records
- **Crew Management**: Team member tracking
- **Cost Tracking**: Expense management
- **Alarm Table**: Real-time alerts display
### 3. Authentication System
- **JWT Token Management**: Secure authentication
- **Auto-refresh**: Token expiration handling
- **Route Protection**: Login redirects
### 4. Dynamic API Configuration
The application supports **dynamic API endpoints** based on deployment environment:
```typescript
// Auto-detects device IP and configures API accordingly
const deviceApiUrl = `http://${window.location.hostname}:81`;
```
## Map & Geospatial Features
### Map Components
- **VietNamMap**: Main map component
- **BaseMap**: OpenLayers integration
- **MapManager**: Map state and interaction management
### Geographic Data
- **GPS Coordinates**: WGS84 format
- **GeoJSON**: Layer data format
- **WKT**: Well-Known Text for geometries
- **Zone Types**: Fishing zones, restricted areas
### Ship Icons & States
- **Online/Offline**: Connection status
- **Fishing/Non-fishing**: Activity state
- **Alarm Levels**: Warning, alarm, SOS states
- **Custom Icons**: Activity-specific markers
## API Integration
### Base URL Configuration
- **Development**: `http://192.168.30.103:81`
- **Test**: `https://test-sgw-device.gms.vn`
- **Production**: `https://prod-sgw-device.gms.vn`
### Key API Endpoints
```typescript
GPS: /api/sgw/gps
Trips: /api/sgw/trip
Alarms: /api/io/alarms
Entities: /api/io/entities
Ship Info: /api/sgw/shipinfo
SOS: /api/sgw/sos
```
### Request Interceptors
- **Authentication**: JWT token injection
- **Error Handling**: Global error management
- **Timeout**: 20-second timeout
## State Management
### Global State Models
1. **global.ts** - User authentication state
2. **getTrip.ts** - Trip management state
3. **getSos.ts** - SOS and alarm state
### Local State Patterns
- **useState**: Local component state
- **useEffect**: Side effects and subscriptions
- **useCallback**: Function memoization
- **useRef**: DOM references and persistent values
## Development Guidelines
### Code Style
- **TypeScript**: Strict typing enabled
- **Component Structure**: Functional components with hooks
- **File Naming**: PascalCase for components, camelCase for functions
- **Import Organization**: Third-party libs → Local imports
### Best Practices
1. **Error Boundaries**: Implement error boundaries for maps
2. **Performance**: Use React.memo for expensive components
3. **Memory Management**: Clean up event listeners and intervals
4. **API Calls**: Use proper error handling and loading states
5. **Type Safety**: Define TypeScript interfaces for all API responses
### Testing Approach
- **Component Testing**: React Testing Library (if added)
- **Integration Testing**: Manual testing with real API endpoints
- **E2E Testing**: Browser testing scenarios
## Deployment
### Build Process
```bash
npm run build
# Creates dist/ folder for deployment
```
### Static Deployment
1. Build application
2. Copy `dist` folder to target device
3. Serve via web server (nginx, apache, etc.)
4. Ensure backend API runs on port 81
### Multi-Device Support
- **Auto-detection**: Application detects device IP
- **Dynamic API**: Base URL configured automatically
- **CORS**: Ensure proper CORS configuration on backend
## Troubleshooting
### Common Issues
1. **Map Loading**: Check OpenLayers dependencies
2. **API Calls**: Verify proxy configuration and network access
3. **Event Bus**: Ensure proper event listener cleanup
4. **State Management**: Check model initialization
5. **Authentication**: Verify JWT token expiration
### Debug Mode
- Enable console logging for GPS updates
- Check network requests in browser dev tools
- Monitor event bus emissions
- Verify map coordinate transformations
## Contributing
### Adding New Features
1. Create new page in `src/pages/`
2. Add route in `.umirc.ts`
3. Create API controller in `src/services/controller/`
4. Add TypeScript types in `src/types/`
5. Update constants in `src/constants/`
### Code Review Checklist
- TypeScript types defined
- Error handling implemented
- Performance considerations
- Accessibility compliance
- Code style guidelines followed
- Testing coverage (if applicable)
---
**Last Updated**: 2025-01-20
**Maintainers**: Mobifone Development Team
**Version**: 1.2.2 - Dynamic API

400
I18N_USAGE_GUIDE.md Normal file
View File

@@ -0,0 +1,400 @@
# Internationalization (i18n) Usage Guide
This guide explains how to use the internationalization (i18n) features in your Ship Monitoring System built with Umi Max.
## 📋 Table of Contents
1. [Setup Overview](#setup-overview)
2. [File Structure](#file-structure)
3. [Basic Usage](#basic-usage)
4. [Advanced Usage](#advanced-usage)
5. [Custom Hook](#custom-hook)
6. [Language Switcher](#language-switcher)
7. [Best Practices](#best-practices)
8. [Troubleshooting](#troubleshooting)
## 🚀 Setup Overview
### Configuration Files
**`.umirc.ts`** - Main i18n configuration:
```typescript
locale: {
default: 'vi-VN',
baseNavigator: false,
antd: true,
title: false,
baseSeparator: '-',
locales: [
['vi-VN', 'Tiếng Việt'],
['en-US', 'English'],
],
},
```
**`src/app.tsx`** - Runtime configuration:
```typescript
// Enable locale in menu
menu: {
locale: true,
},
// Add language switcher to header
rightContentRender: () => [
<LanguageSwitcher key="language-switcher" />,
],
```
## 📁 File Structure
```
src/
├── locales/ # Translation files
│ ├── vi-VN.ts # Vietnamese translations
│ └── en-US.ts # English translations
├── components/
│ ├── LanguageSwitcher/ # Language switcher component
│ │ └── index.tsx
│ └── I18nExamples/ # Example components
│ ├── index.tsx
│ ├── BasicUsage.tsx
│ ├── FormValidation.tsx
│ └── CustomHookExample.tsx
├── hooks/
│ └── useTranslation.ts # Custom i18n hook
└── app.tsx # Runtime configuration
```
## 🔧 Basic Usage
### 1. Using `useIntl` Hook
```typescript
import { useIntl } from '@umijs/max';
import React from 'react';
const MyComponent: React.FC = () => {
const intl = useIntl();
return (
<div>
{/* Simple translation */}
<h1>{intl.formatMessage({ id: 'common.save' })}</h1>
{/* Translation with variables */}
<p>{intl.formatMessage(
{ id: 'validation.minLength' },
{ min: 6 }
)}</p>
</div>
);
};
```
### 2. Using `FormattedMessage` Component
```typescript
import { FormattedMessage } from '@umijs/max';
import React from 'react';
const MyComponent: React.FC = () => {
return (
<div>
{/* Simple translation */}
<FormattedMessage id="common.save" />
{/* Translation with variables */}
<FormattedMessage
id="validation.minLength"
values={{ min: 6 }}
/>
</div>
);
};
```
## 🎯 Advanced Usage
### 1. Form Validation with i18n
```typescript
import { Form, Input, Button } from 'antd';
import { useIntl } from '@umijs/max';
import React from 'react';
const MyForm: React.FC = () => {
const intl = useIntl();
return (
<Form>
<Form.Item
label={intl.formatMessage({ id: 'common.username' })}
name="username"
rules={[
{
required: true,
message: intl.formatMessage({ id: 'validation.required' }),
},
{
type: 'email',
message: intl.formatMessage({ id: 'validation.email' }),
},
]}
>
<Input
placeholder={intl.formatMessage({ id: 'common.username' })}
/>
</Form.Item>
</Form>
);
};
```
### 2. Conditional Rendering
```typescript
import { useIntl } from '@umijs/max';
import React from 'react';
const LocalizedContent: React.FC = () => {
const intl = useIntl();
return (
<div>
{intl.locale === 'vi-VN' ? (
<p>Chào mừng đến với hệ thống!</p>
) : (
<p>Welcome to the system!</p>
)}
</div>
);
};
```
## 🪝 Custom Hook Usage
For more convenient usage, use the custom `useTranslation` hook:
```typescript
import { useTranslation } from '@/hooks/useTranslation';
import React from 'react';
const MyComponent: React.FC = () => {
const { t, isLocale, formatDate, formatNumber } = useTranslation();
return (
<div>
{/* Simple translation */}
<h1>{t('common.save')}</h1>
{/* Translation with variables */}
<p>{t('validation.minLength', { min: 6 })}</p>
{/* Locale detection */}
{isLocale('vi-VN') && <p>Xin chào!</p>}
{/* Date formatting */}
<p>{formatDate(new Date(), {
year: 'numeric',
month: 'long',
day: 'numeric'
})}</p>
{/* Number formatting */}
<p>{formatNumber(1234.56, {
style: 'currency',
currency: 'VND'
})}</p>
</div>
);
};
```
## 🌐 Language Switcher
The language switcher component is automatically included in the header. You can also use it manually:
```typescript
import LanguageSwitcher from '@/components/LanguageSwitcher';
import React from 'react';
const Header: React.FC = () => {
return (
<div>
<LanguageSwitcher type="dropdown" />
{/* or */}
<LanguageSwitcher type="button" size="small" />
</div>
);
};
```
### Language Switcher Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `className` | `string` | - | Additional CSS classes |
| `type` | `'dropdown' \| 'button'` | `'dropdown'` | Display type |
| `size` | `'small' \| 'middle' \| 'large'` | `'middle'` | Component size |
## 📝 Translation Keys
Translation keys follow a hierarchical structure using dot notation:
```
common.save // Save button text
auth.login.title // Login page title
map.ship.status.online // Ship online status
validation.required // Validation message
```
### Adding New Translations
1. **Vietnamese** (`src/locales/vi-VN.ts`):
```typescript
export default {
// Add new key
'my.new.key': 'Nội dung mới',
};
```
2. **English** (`src/locales/en-US.ts`):
```typescript
export default {
// Add corresponding English translation
'my.new.key': 'New content',
};
```
## ✅ Best Practices
### 1. **Consistent Key Naming**
- Use dot notation for hierarchical structure
- Group related translations together
- Use descriptive, meaningful names
- Example: `'map.ship.status.online'` instead of `'online'`
### 2. **Variable Interpolation**
- Use descriptive variable names
- Provide context for variables
- Example: `'validation.minLength': 'Minimum {min} characters required'`
### 3. **Component Organization**
- Group translations by feature/module
- Keep related keys together
- Use consistent prefixes for modules
### 4. **Performance Considerations**
- Use the `useTranslation` hook for better performance
- Avoid inline `formatMessage` calls in render loops
- Cache translated strings when used frequently
### 5. **Translation Maintenance**
- Keep all translations in sync
- Add new keys to both language files
- Review translations regularly for consistency
## 🛠 Troubleshooting
### Common Issues
#### 1. **Translation not appearing**
```typescript
// ❌ Wrong
<FormattedMessage id="wrong.key" />
// ✅ Correct - check if key exists in locale files
<FormattedMessage id="common.save" />
```
#### 2. **Locale not changing**
- Ensure `localStorage.setItem('umi_locale', locale)` is called
- Check that locale is properly configured in `.umirc.ts`
- Verify that translation files exist for the target locale
#### 3. **Missing Ant Design translations**
```typescript
// In .umirc.ts
locale: {
antd: true, // Ensure this is enabled
}
```
#### 4. **TypeScript errors**
```typescript
// Ensure proper import
import { useIntl } from '@umijs/max';
// Check if translation key exists in locale files
const intl = useIntl();
const text = intl.formatMessage({ id: 'existing.key' });
```
### Debug Mode
To debug translation issues, add logging to your component:
```typescript
import { useIntl } from '@umijs/max';
import React from 'react';
const DebugComponent: React.FC = () => {
const intl = useIntl();
console.log('Current locale:', intl.locale);
console.log('Available messages:', intl.messages);
return <div>Check console for debug info</div>;
};
```
## 🔄 Testing Translations
To test your i18n implementation:
1. **Manual Testing**:
- Switch languages using the language switcher
- Verify all text updates correctly
- Check forms with validation messages
2. **Component Testing**:
```typescript
import { render } from '@testing-library/react';
import { I18nProvider } from '@umijs/max';
import MyComponent from './MyComponent';
// Test with different locales
render(
<I18nProvider locale="en-US">
<MyComponent />
</I18nProvider>
);
```
3. **Translation Coverage**:
- Ensure all UI text is translatable
- Check that no hardcoded strings remain
- Verify all keys exist in both language files
## 📚 Additional Resources
- [Umi Max i18n Documentation](https://umijs.org/docs/max/i18n)
- [React Intl Documentation](https://formatjs.io/docs/react-intl/)
- [Ant Design i18n Guide](https://ant.design/docs/react/internationalization)
## 🎉 Summary
Your i18n setup is now complete! You have:
**Configuration**: Umi Max i18n properly configured
**Translations**: Vietnamese and English translation files
**Components**: Language switcher and example components
**Custom Hook**: Simplified i18n API
**Best Practices**: Guidelines for maintainable translations
The system is ready for multi-language support and can be easily extended with additional languages or translations as needed.
---
**Last Updated**: 2025-01-20
**Version**: 1.0.0

31
Task.md Normal file
View File

@@ -0,0 +1,31 @@
- Goal:
Add new localization (i18n) to my website using the Internationalization plugin of Umi Max so that users can switch between Vietnamese and English.
- Tasks for you:
1. Create full base setup for i18n in Umi Max, including:
+ Required configuration in config/config.ts
+ Folder structure for locales
+ Base translation files (vi-VN.ts, en-US.ts)
+ Any needed runtime or app-level configuration
2. Provide sample code for:
+ Using i18n inside components (useIntl, <FormattedMessage />, etc.)
+ Creating a language switcher component
3. Write a clear usage guide explaining:
+ Where to place all files
+ How to import and use translations
+ How to switch languages at runtime
Make sure all examples are valid for Umi Max (Umi 4.5.0). Checked task you complete

View File

@@ -4,21 +4,21 @@ import { history, RequestConfig } from '@umijs/max';
import { message } from 'antd'; import { message } from 'antd';
// Error handling scheme: Error types // Error handling scheme: Error types
enum ErrorShowType { // enum ErrorShowType {
SILENT = 0, // SILENT = 0,
WARN_MESSAGE = 1, // WARN_MESSAGE = 1,
ERROR_MESSAGE = 2, // ERROR_MESSAGE = 2,
NOTIFICATION = 3, // NOTIFICATION = 3,
REDIRECT = 9, // REDIRECT = 9,
} // }
// Response data structure agreed with the backend // Response data structure agreed with the backend
interface ResponseStructure<T = any> { // interface ResponseStructure<T = any> {
success: boolean; // success: boolean;
data: T; // data: T;
errorCode?: number; // errorCode?: number;
errorMessage?: string; // errorMessage?: string;
showType?: ErrorShowType; // showType?: ErrorShowType;
} // }
const codeMessage = { const codeMessage = {
200: 'The server successfully returned the requested data。', 200: 'The server successfully returned the requested data。',

2
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{ {
"name": "FE-DEVICE-SGW", "name": "my-app",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View File

@@ -1,12 +1,47 @@
import { LogoutOutlined } from '@ant-design/icons'; import { LogoutOutlined } from '@ant-design/icons';
import { history, RunTimeLayoutConfig } from '@umijs/max'; import { history, RunTimeLayoutConfig, useIntl } from '@umijs/max';
import { Dropdown } from 'antd'; import { Dropdown } from 'antd';
import { handleRequestConfig } from '../config/Request'; import { handleRequestConfig } from '../config/Request';
import logo from '../public/logo.png';
import UnAccessPage from './components/403/403Page'; import UnAccessPage from './components/403/403Page';
import LanguageSwitcher from './components/LanguageSwitcher';
import { ROUTE_LOGIN } from './constants'; import { ROUTE_LOGIN } from './constants';
import { parseJwt } from './utils/jwtTokenUtils'; import { parseJwt } from './utils/jwtTokenUtils';
import { getToken, removeToken } from './utils/localStorageUtils'; import { getToken, removeToken } from './utils/localStorageUtils';
const logo = '/logo.png';
// Avatar component with i18n support
const AvatarDropdown = () => {
const intl = useIntl();
return (
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: intl.formatMessage({ id: 'common.logout' }),
onClick: () => {
removeToken();
history.push(ROUTE_LOGIN);
},
},
],
}}
>
<img
src="/avatar.svg"
alt="avatar"
style={{
width: 32,
height: 32,
borderRadius: '50%',
cursor: 'pointer',
}}
/>
</Dropdown>
);
};
// 全局初始化数据配置,用于 Layout 用户信息和权限初始化 // 全局初始化数据配置,用于 Layout 用户信息和权限初始化
// 更多信息见文档https://umijs.org/docs/api/runtime-config#getinitialstate // 更多信息见文档https://umijs.org/docs/api/runtime-config#getinitialstate
export async function getInitialState() { export async function getInitialState() {
@@ -58,7 +93,7 @@ export const layout: RunTimeLayoutConfig = (initialState) => {
console.log('initialState', initialState); console.log('initialState', initialState);
return { return {
logo: logo, logo,
fixedHeader: true, fixedHeader: true,
contentWidth: 'Fluid', contentWidth: 'Fluid',
navTheme: 'light', navTheme: 'light',
@@ -66,39 +101,22 @@ export const layout: RunTimeLayoutConfig = (initialState) => {
title: 'SGW', title: 'SGW',
iconfontUrl: '//at.alicdn.com/t/c/font_1970684_76mmjhln75w.js', iconfontUrl: '//at.alicdn.com/t/c/font_1970684_76mmjhln75w.js',
menu: { menu: {
locale: false, locale: true,
}, },
contentStyle: { contentStyle: {
padding: 0, padding: 0,
margin: 0, margin: 0,
paddingInline: 0, paddingInline: 0,
}, },
actionsRender: () => [
<LanguageSwitcher key="lang-switcher" type="dropdown" />,
],
avatarProps: { avatarProps: {
size: 'small', size: 'small',
src: '/avatar.svg', // 👈 ở đây dùng icon thay vì src src: '/avatar.svg',
render: (_, dom) => { render: () => <AvatarDropdown />,
return (
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Đăng xuất',
onClick: () => {
removeToken();
history.push(ROUTE_LOGIN);
},
},
],
}}
>
{dom}
</Dropdown>
);
},
}, },
layout: 'mix', layout: 'top', // Thay đổi từ 'mix' sang 'top' để đảm bảo header hiển thị đúng
logout: () => { logout: () => {
removeToken(); removeToken();
history.push(ROUTE_LOGIN); history.push(ROUTE_LOGIN);

View File

@@ -1,13 +1,20 @@
import { DefaultFooter } from '@ant-design/pro-components'; import { DefaultFooter } from '@ant-design/pro-components';
import './style.less'; import './style.less';
import { useIntl } from '@umijs/max';
const Footer = () => { const Footer = () => {
const intl = useIntl();
return ( return (
<DefaultFooter <DefaultFooter
style={{ style={{
background: 'none', background: 'none',
color: 'white', color: 'white',
}} }}
copyright="2025 Sản phẩm của Mobifone v1.2.2" copyright={intl.formatMessage({
id: 'common.footer.copyright',
defaultMessage: '2025 Sản phẩm của Mobifone v1.2.2',
})}
/> />
); );
}; };

View File

@@ -0,0 +1,93 @@
import { getLocale, setLocale, useIntl } from '@umijs/max';
import { Button, Dropdown, Space } from 'antd';
import React from 'react';
export interface LanguageSwitcherProps {
className?: string;
type?: 'dropdown' | 'button';
size?: 'small' | 'middle' | 'large';
}
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({
className,
type = 'dropdown',
size = 'middle',
}) => {
const intl = useIntl();
const languageItems = [
{
key: 'vi-VN',
label: intl.formatMessage({ id: 'common.vietnamese' }),
flag: '🇻🇳',
},
{
key: 'en-US',
label: intl.formatMessage({ id: 'common.english' }),
flag: '🇺🇸',
},
];
const handleLanguageChange = (locale: string) => {
setLocale(locale, false);
};
const getCurrentLanguage = () => {
const currentLocale = intl.locale || getLocale() || 'vi-VN';
return (
languageItems.find((item) => item.key === currentLocale) ||
languageItems[0]
);
};
const dropdownItems = languageItems.map((item) => ({
key: item.key,
label: (
<Space>
<span>{item.flag}</span>
<span>{item.label}</span>
</Space>
),
onClick: () => handleLanguageChange(item.key),
}));
if (type === 'button') {
const currentLang = getCurrentLanguage();
return (
<Button
size={size}
onClick={() => {
const nextLocale = currentLang.key === 'vi-VN' ? 'en-US' : 'vi-VN';
handleLanguageChange(nextLocale);
}}
className={className}
>
<Space>
<span>{currentLang.flag}</span>
<span>{currentLang.label}</span>
</Space>
</Button>
);
}
const currentLang = getCurrentLanguage();
return (
<Dropdown
menu={{ items: dropdownItems }}
placement="bottomRight"
trigger={['click']}
className={className}
>
<Button size={size} type="text">
<Space>
<span>{currentLang.flag}</span>
<span>{currentLang.label}</span>
</Space>
</Button>
</Dropdown>
);
};
export default LanguageSwitcher;

View File

@@ -0,0 +1,38 @@
import { getLocale, setLocale } from '@umijs/max';
import { useEffect, useState } from 'react';
const LangSwitches = () => {
const [isEnglish, setIsEnglish] = useState(false);
// Keep checkbox in sync with current Umi locale without forcing a reload
useEffect(() => {
const syncLocale = () => setIsEnglish(getLocale() === 'en-US');
syncLocale();
window.addEventListener('languagechange', syncLocale);
return () => {
window.removeEventListener('languagechange', syncLocale);
};
}, []);
const handleLanguageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newLocale = e.target.checked ? 'en-US' : 'vi-VN';
setIsEnglish(e.target.checked);
setLocale(newLocale, false); // Update locale instantly without full page reload
};
return (
<label className="relative inline-flex items-center cursor-pointer">
<input
className="sr-only peer"
type="checkbox"
checked={isEnglish}
onChange={handleLanguageChange}
/>
<div className="w-20 h-10 rounded-full bg-gradient-to-r from-orange-400 to-red-400 peer-checked:from-blue-400 peer-checked:to-indigo-500 transition-all duration-500 after:content-['🇻🇳'] after:absolute after:top-1 after:left-1 after:bg-white after:rounded-full after:h-8 after:w-8 after:flex after:items-center after:justify-center after:transition-all after:duration-500 peer-checked:after:translate-x-10 peer-checked:after:content-['🏴󠁧󠁢󠁥󠁮󠁧󠁿'] after:shadow-md after:text-lg"></div>
</label>
);
};
export default LangSwitches;

View File

@@ -0,0 +1,76 @@
import { useIntl } from '@umijs/max';
/**
* Custom hook for easier internationalization usage
* Provides a simplified API for common i18n operations
*/
export const useTranslation = () => {
const intl = useIntl();
/**
* Simple translation function
* @param id - Translation key
* @param values - Values to interpolate into the translation
* @returns Translated string
*/
const t = (id: string, values?: Record<string, any>): string => {
return intl.formatMessage({ id }, values);
};
/**
* Check if current locale is the specified one
* @param locale - Locale code (e.g., 'vi-VN', 'en-US')
* @returns boolean
*/
const isLocale = (locale: string): boolean => {
return intl.locale === locale;
};
/**
* Get current locale code
* @returns Current locale string
*/
const getCurrentLocale = (): string => {
return intl.locale;
};
/**
* Format date with locale consideration
* @param date - Date to format
* @param options - Intl.DateTimeFormatOptions
* @returns Formatted date string
*/
const formatDate = (
date: Date | number,
options?: Intl.DateTimeFormatOptions
): string => {
return new Intl.DateTimeFormat(intl.locale, options).format(
typeof date === 'number' ? new Date(date) : date
);
};
/**
* Format number with locale consideration
* @param number - Number to format
* @param options - Intl.NumberFormatOptions
* @returns Formatted number string
*/
const formatNumber = (
number: number,
options?: Intl.NumberFormatOptions
): string => {
return new Intl.NumberFormat(intl.locale, options).format(number);
};
return {
t,
isLocale,
getCurrentLocale,
formatDate,
formatNumber,
// Expose original intl object for advanced usage
intl,
};
};
export default useTranslation;

360
src/locales/en-US.ts Normal file
View File

@@ -0,0 +1,360 @@
export default {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
'common.search': 'Search',
'common.filter': 'Filter',
'common.loading': 'Loading...',
'common.confirm': 'Confirm',
'common.back': 'Back',
'common.next': 'Next',
'common.previous': 'Previous',
'common.submit': 'Submit',
'common.reset': 'Reset',
'common.refresh': 'Refresh',
'common.export': 'Export',
'common.import': 'Import',
'common.view': 'View',
'common.detail': 'Detail',
'common.list': 'List',
'common.settings': 'Settings',
'common.logout': 'Logout',
'common.login': 'Login',
'common.username': 'Username',
'common.password': 'Password',
'common.remember': 'Remember me',
'common.forgot': 'Forgot password?',
'common.required': 'Required',
'common.optional': 'Optional',
'common.enabled': 'Enabled',
'common.disabled': 'Disabled',
'common.active': 'Active',
'common.inactive': 'Inactive',
'common.online': 'Online',
'common.offline': 'Offline',
'common.yes': 'Yes',
'common.no': 'No',
'common.ok': 'OK',
'common.error': 'Error',
'common.success': 'Success',
'common.warning': 'Warning',
'common.info': 'Information',
'common.vietnamese': 'Vietnamese',
'common.english': 'English',
'common.footer.copyright': '2025 Product of Mobifone v1.2.2',
// Menu
'menu.monitoring': 'Monitoring',
'menu.trips': 'Trips',
'menu.i18nTest': 'i18n Test',
'menu.dashboard': 'Dashboard',
'menu.reports': 'Reports',
'menu.settings': 'Settings',
'menu.profile': 'Profile',
'menu.help': 'Help',
'menu.about': 'About',
// Authentication
'auth.login.title': 'System Login',
'auth.login.subtitle': 'Ship Monitoring System',
'auth.login.welcome': 'Welcome Back',
'auth.login.description': 'Login to continue monitoring vessels',
'auth.login.invalid': 'Invalid username or password',
'auth.login.success': 'Login successful',
'auth.logout.title': 'Logout',
'auth.logout.confirm': 'Are you sure you want to logout?',
'auth.logout.success': 'Logout successful',
// Map & Monitoring
'map.title': 'Monitoring Map',
'map.ship.current': 'Current Position',
'map.ship.tracking': 'Ship Tracking',
'map.ship.speed': 'Speed',
'map.ship.course': 'Heading',
'map.ship.latitude': 'Latitude',
'map.ship.longitude': 'Longitude',
'map.ship.state': 'State',
'map.ship.state.tooltip': 'Is the ship currently fishing?',
'map.ship.lastUpdate': 'Last Update',
'map.ship.name': 'Name',
'map.ship.imo': 'IMO',
'map.ship.mmsi': 'MMSI',
'map.ship.regNumber': 'Registration Number',
'map.ship.length': 'Length',
'map.ship.power': 'Power',
'map.ship.info': 'Ship Information',
'map.ship.status.online': 'Online',
'map.ship.status.offline': 'Offline',
'map.ship.status.fishing': 'Fishing',
'map.ship.state.notFishing': 'Not Fishing',
'map.ship.status.sailing': 'Sailing',
'map.ship.status.anchor': 'Anchored',
'map.alarm.title': 'Alarms',
'map.alarm.new': 'New Alarm',
'map.alarm.geofence': 'Geofence Violation',
'map.alarm.sos': 'SOS Emergency',
'map.alarm.power': 'Power Loss',
'map.alarm.gps': 'GPS Loss',
'map.zone.fishing': 'Fishing Zone',
'map.zone.restricted': 'Restricted Zone',
'map.zone.warning': 'Warning Zone',
'map.layer.ships': 'Ships',
'map.layer.alarms': 'Alarms',
'map.layer.zones': 'Zones',
'map.layer.tracks': 'Tracks',
// Trip Management
'trip.title': 'Trip Management',
'trip.create': 'Create New Trip',
'trip.edit': 'Edit Trip',
'trip.delete': 'Delete Trip',
'trip.list': 'Trip List',
'trip.detail': 'Trip Details',
'trip.startTime': 'Start Time',
'trip.endTime': 'End Time',
'trip.duration': 'Duration',
'trip.distance': 'Distance',
'trip.crew': 'Crew',
'trip.catch': 'Catch',
'trip.costs': 'Costs',
'trip.notes': 'Notes',
'trip.status.planning': 'Planning',
'trip.status.active': 'Active',
'trip.status.completed': 'Completed',
'trip.status.cancelled': 'Cancelled',
// Fishing Log
'fishing.title': 'Fishing Log',
'fishing.create': 'Create New Log',
'fishing.edit': 'Edit Log',
'fishing.delete': 'Delete Log',
'fishing.haul': 'Haul',
'fishing.species': 'Species',
'fishing.quantity': 'Quantity',
'fishing.weight': 'Weight',
'fishing.method': 'Method',
'fishing.location': 'Location',
'fishing.time': 'Time',
'fishing.depth': 'Depth',
'fishing.temperature': 'Temperature',
'fishing.weather': 'Weather',
// Alarms
'alarm.title': 'Alarm Management',
'alarm.active': 'Active Alarms',
'alarm.history': 'Alarm History',
'alarm.type.geofence': 'Geofence',
'alarm.type.sos': 'SOS',
'alarm.type.power': 'Power',
'alarm.type.gps': 'GPS',
'alarm.type.speed': 'Speed',
'alarm.type.stopped': 'Stopped',
'alarm.level.low': 'Low',
'alarm.level.medium': 'Medium',
'alarm.level.high': 'High',
'alarm.level.critical': 'Critical',
'alarm.acknowledge': 'Acknowledge',
'alarm.resolve': 'Resolve',
'alarm.ignore': 'Ignore',
'alarm.created': 'Created',
'alarm.resolved': 'Resolved',
// Settings
'settings.title': 'System Settings',
'settings.profile': 'User Profile',
'settings.language': 'Language',
'settings.theme': 'Theme',
'settings.notifications': 'Notifications',
'settings.privacy': 'Privacy',
'settings.security': 'Security',
'settings.about': 'About',
'settings.version': 'Version',
'settings.update': 'Update',
// Messages
'message.loading': 'Loading data...',
'message.noData': 'No data available',
'message.error.network': 'Network connection error',
'message.error.server': 'Server error',
'message.error.generic': 'An error occurred',
'message.success.saved': 'Saved successfully',
'message.success.deleted': 'Deleted successfully',
'message.success.updated': 'Updated successfully',
'message.confirm.delete': 'Are you sure you want to delete this item?',
'message.confirm.save': 'Are you sure you want to save these changes?',
'message.confirm.logout': 'Are you sure you want to logout?',
// Units
'unit.knots': 'knots',
'unit.km': 'km',
'unit.kmh': 'km/h',
'unit.meters': 'm',
'unit.kilometers': 'km',
'unit.nauticalMiles': 'nautical miles',
'unit.kilograms': 'kg',
'unit.tons': 'tons',
'unit.degrees': '°',
'unit.celsius': '°C',
'unit.hours': 'hours',
'unit.minutes': 'minutes',
'unit.seconds': 'seconds',
// Time
'time.now': 'Now',
'time.today': 'Today',
'time.yesterday': 'Yesterday',
'time.tomorrow': 'Tomorrow',
'time.thisWeek': 'This Week',
'time.thisMonth': 'This Month',
'time.thisYear': 'This Year',
'format.date': 'MM/DD/YYYY',
'format.datetime': 'MM/DD/YYYY HH:mm',
'format.time': 'HH:mm',
'format.relative': '{time} ago',
// Validation
'validation.required': 'This field is required',
'validation.username': 'Username cannot be empty',
'validation.password': 'Password cannot be empty',
'validation.minLength': 'Minimum {min} characters required',
'validation.maxLength': 'Maximum {max} characters allowed',
'validation.min': 'Minimum value is {min}',
'validation.max': 'Maximum value is {max}',
'validation.numeric': 'Must be a number',
'validation.phone': 'Invalid phone number',
'validation.url': 'Invalid URL',
// Trip Status Badge
'trip.badge.notApproved': 'Not Approved',
'trip.badge.waitingApproval': 'Waiting Approval',
'trip.badge.approved': 'Approved',
'trip.badge.active': 'Active',
'trip.badge.completed': 'Completed',
'trip.badge.cancelled': 'Cancelled',
'trip.badge.unknown': 'Unknown Status',
// Trip Actions
'trip.startTrip': 'Start Trip',
'trip.finishHaul': 'Finish Haul',
'trip.startHaul': 'Start Haul',
'trip.warning.haulNotFinished':
'Please finish the current haul before starting a new one',
'trip.warning.tripStarted': 'The trip has already been started or completed.',
'trip.cancelTrip.title': 'Confirm Cancel Trip',
'trip.cancelTrip.button': 'Cancel Trip',
'trip.cancelTrip.reason': 'Reason: ',
'trip.cancelTrip.placeholder': 'Enter reason for cancelling trip...',
'trip.cancelTrip.validation': 'Please enter reason for cancelling trip',
'trip.weather': 'Sunny',
// Haul Fish List
'trip.haulFishList.title': 'Fish List',
'trip.haulFishList.fishName': 'Fish Name',
'trip.haulFishList.fishCondition': 'Condition',
'trip.haulFishList.fishRarity': 'Rarity',
'trip.haulFishList.fishSize': 'Size (cm)',
'trip.haulFishList.weight': 'Weight (kg)',
'trip.haulFishList.gearUsage': 'Fishing Gear Used',
'trip.haulFishList.noData': 'No fish data in this haul',
// Haul Table
'trip.haulTable.no': 'Number',
'trip.haulTable.status': 'Status',
'trip.haulTable.fishing': 'Fishing',
'trip.haulTable.endFishing': 'Completed',
'trip.haulTable.cancelFishing': 'Cancelled',
'trip.haulTable.weather': 'Weather',
'trip.haulTable.startTime': 'Start Time',
'trip.haulTable.endTime': 'End Time',
'trip.haulTable.action': 'Action',
'trip.haulTable.updateSuccess': 'Haul updated successfully',
'trip.haulTable.updateError': 'Failed to update haul',
'trip.haulTable.haul': 'Haul',
// Main Trip Body
'trip.mainBody.tripCost': 'Trip Cost',
'trip.mainBody.fishingGear': 'Fishing Gear List',
'trip.mainBody.crew': 'Crew List',
'trip.mainBody.haulList': 'Haul List',
// Trip Cost
'trip.cost.type': 'Type',
'trip.cost.amount': 'Amount',
'trip.cost.unit': 'Unit',
'trip.cost.price': 'Cost',
'trip.cost.total': 'Total Cost',
'trip.cost.fuel': 'Fuel',
'trip.cost.crewSalary': 'Crew Salary',
'trip.cost.food': 'Food',
'trip.cost.iceSalt': 'Ice Salt',
'trip.cost.grandTotal': 'Grand Total',
// Trip Crews
'trip.crew.id': 'ID',
'trip.crew.name': 'Name',
'trip.crew.role': 'Role',
'trip.crew.email': 'Email',
'trip.crew.phone': 'Phone',
'trip.crew.birthDate': 'Birth Date',
'trip.crew.address': 'Address',
'trip.crew.captain': 'Captain',
'trip.crew.member': 'Crew Member',
// Trip Fishing Gear
'trip.gear.name': 'Name',
'trip.gear.quantity': 'Quantity',
// Trip Cancel Or Finish
'trip.finishButton.confirmTitle': 'Notification',
'trip.finishButton.confirmMessage':
'Are you sure you want to finish this trip?',
'trip.finishButton.confirmYes': 'Confirm',
'trip.finishButton.confirmNo': 'Cancel',
'trip.finishButton.end': 'Finish',
'trip.finishButton.updateSuccess': 'Trip status updated successfully',
'trip.finishButton.updateError': 'Failed to update trip status',
// Trip Detail Page
'trip.detail.title': 'Trip',
'trip.detail.defaultTitle': 'Trip',
'trip.detail.alarm': 'Alarms',
'trip.detail.createHaulSuccess': 'Haul created successfully',
'trip.detail.createHaulError': 'Failed to create haul',
'trip.detail.startTripSuccess': 'Trip started successfully',
'trip.detail.startTripError': 'Failed to start trip',
'trip.detail.updateHaulSuccess': 'Haul updated successfully',
'trip.detail.updateHaulError': 'Failed to update haul',
// Pagination
'pagination.total': '{start}-{end} of {total}',
// Home Page
'home.currentStatus': 'Current Status',
'home.tripInfo': 'Trip Information',
'home.shipInfo': 'Ship Information',
'home.errorDrawBanzones': 'Error drawing ban zones',
'home.errorBackground': 'Error calling background APIs',
'home.errorGpsUpdate': 'Error updating GPS',
'home.mapError': 'Error adding to map',
// SOS
'sos.title': 'Emergency Alert',
'sos.button': 'Emergency',
'sos.status': 'Emergency status active',
'sos.end': 'End',
'sos.label': 'Content:',
'sos.placeholder': 'Select or enter reason...',
'sos.other': 'Other',
'sos.otherLabel': 'Other Reason',
'sos.otherPlaceholder': 'Enter other reason...',
'sos.defaultMessage': 'Emergency situation, no time to choose!!!',
'sos.sendSuccess': 'SOS signal sent successfully!',
'sos.sendError': 'Failed to send SOS signal!',
'sos.cancelSuccess': 'SOS signal cancelled successfully!',
'sos.cancelError': 'Failed to cancel SOS signal!',
'sos.validationError': 'Please select or enter a reason!',
'sos.otherValidationError': 'Please enter another reason!',
};

359
src/locales/vi-VN.ts Normal file
View File

@@ -0,0 +1,359 @@
export default {
// Common
'common.save': 'Lưu',
'common.cancel': 'Hủy',
'common.delete': 'Xóa',
'common.edit': 'Chỉnh sửa',
'common.add': 'Thêm',
'common.search': 'Tìm kiếm',
'common.filter': 'Bộ lọc',
'common.loading': 'Đang tải...',
'common.confirm': 'Xác nhận',
'common.back': 'Quay lại',
'common.next': 'Tiếp theo',
'common.previous': 'Trước đó',
'common.submit': 'Gửi',
'common.reset': 'Đặt lại',
'common.refresh': 'Làm mới',
'common.export': 'Xuất',
'common.import': 'Nhập',
'common.view': 'Xem',
'common.detail': 'Chi tiết',
'common.list': 'Danh sách',
'common.settings': 'Cài đặt',
'common.logout': 'Đăng xuất',
'common.login': 'Đăng nhập',
'common.username': 'Tên người dùng',
'common.password': 'Mật khẩu',
'common.remember': 'Ghi nhớ',
'common.forgot': 'Quên mật khẩu?',
'common.required': 'Bắt buộc',
'common.optional': 'Tùy chọn',
'common.enabled': 'Đã bật',
'common.disabled': 'Đã tắt',
'common.active': 'Hoạt động',
'common.inactive': 'Không hoạt động',
'common.online': 'Trực tuyến',
'common.offline': 'Ngoại tuyến',
'common.yes': 'Có',
'common.no': 'Không',
'common.ok': 'OK',
'common.error': 'Lỗi',
'common.success': 'Thành công',
'common.warning': 'Cảnh báo',
'common.info': 'Thông tin',
"common.vietnamese": "Tiếng Việt",
"common.english": "Tiếng Anh",
'common.footer.copyright': '2025 Sản phẩm của Mobifone v1.2.2',
// Navigation
'menu.monitoring': 'Giám sát',
'menu.trips': 'Chuyến đi',
'menu.i18nTest': 'Kiểm tra i18n',
'menu.dashboard': 'Bảng điều khiển',
'menu.reports': 'Báo cáo',
'menu.settings': 'Cài đặt',
'menu.profile': 'Hồ sơ',
'menu.help': 'Trợ giúp',
'menu.about': 'Về chúng tôi',
// Authentication
'auth.login.title': 'Đăng nhập hệ thống',
'auth.login.subtitle': 'Hệ thống giám sát tàu cá',
'auth.login.welcome': 'Chào mừng trở lại',
'auth.login.description': 'Đăng nhập để tiếp tục giám sát tàu thuyền',
'auth.login.invalid': 'Tên người dùng hoặc mật khẩu không hợp lệ',
'auth.login.success': 'Đăng nhập thành công',
'auth.logout.title': 'Đăng xuất',
'auth.logout.confirm': 'Bạn có chắc chắn muốn đăng xuất?',
'auth.logout.success': 'Đăng xuất thành công',
// Map & Monitoring
'map.title': 'Bản đồ giám sát',
'map.ship.current': 'Vị trí hiện tại',
'map.ship.tracking': 'Theo dõi tàu',
'map.ship.speed': 'Tốc độ',
'map.ship.latitude': 'Vĩ độ',
'map.ship.longitude': 'Kinh độ',
'map.ship.course': 'Hướng',
'map.ship.state': 'Trạng thái',
'map.ship.state.tooltip': 'Thuyền có đang đánh bắt hay không',
'map.ship.lastUpdate': 'Cập nhật cuối cùng',
'map.ship.name': 'Tên',
'map.ship.imo': 'IMO',
'map.ship.mmsi': 'MMSI',
'map.ship.regNumber': 'Số đăng ký',
'map.ship.length': 'Chiều dài',
'map.ship.power': 'Công suất',
'map.ship.info': 'Thông tin tàu',
'map.ship.status.online': 'Trực tuyến',
'map.ship.status.offline': 'Ngoại tuyến',
'map.ship.status.fishing': 'Đang đánh cá',
'map.ship.state.notFishing': 'Không đánh bắt',
'map.ship.status.sailing': 'Đang di chuyển',
'map.ship.status.anchor': 'Đang thả neo',
'map.alarm.title': 'Cảnh báo',
'map.alarm.new': 'Cảnh báo mới',
'map.alarm.geofence': 'Vượt vùng cấm',
'map.alarm.sos': 'Khẩn cấp SOS',
'map.alarm.power': 'Mất nguồn',
'map.alarm.gps': 'Mất tín hiệu GPS',
'map.zone.fishing': 'Vùng đánh cá',
'map.zone.restricted': 'Vùng cấm',
'map.zone.warning': 'Vùng cảnh báo',
'map.layer.ships': 'Tàu thuyền',
'map.layer.alarms': 'Cảnh báo',
'map.layer.zones': 'Vùng',
'map.layer.tracks': 'Lộ trình',
// Trip Management
'trip.title': 'Quản lý chuyến đi',
'trip.create': 'Tạo chuyến đi mới',
'trip.edit': 'Chỉnh sửa chuyến đi',
'trip.delete': 'Xóa chuyến đi',
'trip.list': 'Danh sách chuyến đi',
'trip.detail': 'Chi tiết chuyến đi',
'trip.startTime': 'Thời gian bắt đầu',
'trip.endTime': 'Thời gian kết thúc',
'trip.duration': 'Thời lượng',
'trip.distance': 'Quãng đường',
'trip.crew': 'Thuyền viên',
'trip.catch': 'Sản lượng đánh bắt',
'trip.costs': 'Chi phí',
'trip.notes': 'Ghi chú',
'trip.status.planning': 'Lên kế hoạch',
'trip.status.active': 'Đang hoạt động',
'trip.status.completed': 'Hoàn thành',
'trip.status.cancelled': 'Đã hủy',
// Fishing Log
'fishing.title': 'Nhật ký đánh cá',
'fishing.create': 'Tạo nhật ký mới',
'fishing.edit': 'Chỉnh sửa nhật ký',
'fishing.delete': 'Xóa nhật ký',
'fishing.haul': 'Lượt kéo',
'fishing.species': 'Loài cá',
'fishing.quantity': 'Số lượng',
'fishing.weight': 'Trọng lượng',
'fishing.method': 'Phương pháp',
'fishing.location': 'Vị trí',
'fishing.time': 'Thời gian',
'fishing.depth': 'Độ sâu',
'fishing.temperature': 'Nhiệt độ',
'fishing.weather': 'Thời tiết',
// Alarms
'alarm.title': 'Quản lý cảnh báo',
'alarm.active': 'Cảnh báo hoạt động',
'alarm.history': 'Lịch sử cảnh báo',
'alarm.type.geofence': 'Vượt vùng',
'alarm.type.sos': 'Khẩn cấp SOS',
'alarm.type.power': 'Nguồn điện',
'alarm.type.gps': 'GPS',
'alarm.type.speed': 'Tốc độ',
'alarm.type.stopped': 'Dừng lại',
'alarm.level.low': 'Thấp',
'alarm.level.medium': 'Trung bình',
'alarm.level.high': 'Cao',
'alarm.level.critical': 'Khẩn cấp',
'alarm.acknowledge': 'Xác nhận',
'alarm.resolve': 'Giải quyết',
'alarm.ignore': 'Bỏ qua',
'alarm.created': 'Thời gian tạo',
'alarm.resolved': 'Thời gian giải quyết',
// Settings
'settings.title': 'Cài đặt hệ thống',
'settings.profile': 'Hồ sơ người dùng',
'settings.language': 'Ngôn ngữ',
'settings.theme': 'Giao diện',
'settings.notifications': 'Thông báo',
'settings.privacy': 'Riêng tư',
'settings.security': 'Bảo mật',
'settings.about': 'Về ứng dụng',
'settings.version': 'Phiên bản',
'settings.update': 'Cập nhật',
// Messages
'message.loading': 'Đang tải dữ liệu...',
'message.noData': 'Không có dữ liệu',
'message.error.network': 'Lỗi kết nối mạng',
'message.error.server': 'Lỗi máy chủ',
'message.error.generic': 'Đã xảy ra lỗi',
'message.success.saved': 'Lưu thành công',
'message.success.deleted': 'Xóa thành công',
'message.success.updated': 'Cập nhật thành công',
'message.confirm.delete': 'Bạn có chắc chắn muốn xóa mục này?',
'message.confirm.save': 'Bạn có chắc chắn muốn lưu các thay đổi?',
'message.confirm.logout': 'Bạn có chắc chắn muốn đăng xuất?',
// Units
'unit.knots': 'hải lý/giờ',
'unit.km': 'km',
'unit.kmh': 'km/h',
'unit.meters': 'm',
'unit.kilometers': 'km',
'unit.nauticalMiles': 'hải lý',
'unit.kilograms': 'kg',
'unit.tons': 'tấn',
'unit.degrees': '°',
'unit.celsius': '°C',
'unit.hours': 'giờ',
'unit.minutes': 'phút',
'unit.seconds': 'giây',
// Time
'time.now': 'Bây giờ',
'time.today': 'Hôm nay',
'time.yesterday': 'Hôm qua',
'time.tomorrow': 'Ngày mai',
'time.thisWeek': 'Tuần này',
'time.thisMonth': 'Tháng này',
'time.thisYear': 'Năm nay',
'format.date': 'DD/MM/YYYY',
'format.datetime': 'DD/MM/YYYY HH:mm',
'format.time': 'HH:mm',
'format.relative': '{time} trước',
// Validation
'validation.required': 'Trường này là bắt buộc',
'validation.username': 'Tài khoản không được để trống',
'validation.password': 'Mật khẩu không được để trống',
'validation.minLength': 'Tối thiểu {min} ký tự',
'validation.maxLength': 'Tối đa {max} ký tự',
'validation.min': 'Giá trị tối thiểu là {min}',
'validation.max': 'Giá trị tối đa là {max}',
'validation.numeric': 'Phải là số',
'validation.phone': 'Số điện thoại không hợp lệ',
'validation.url': 'URL không hợp lệ',
// Trip Status Badge
'trip.badge.notApproved': 'Chưa được phê duyệt',
'trip.badge.waitingApproval': 'Đang chờ duyệt',
'trip.badge.approved': 'Đã duyệt',
'trip.badge.active': 'Đang hoạt động',
'trip.badge.completed': 'Đã hoàn thành',
'trip.badge.cancelled': 'Đã huỷ',
'trip.badge.unknown': 'Trạng thái không xác định',
// Trip Actions
'trip.startTrip': 'Bắt đầu chuyến đi',
'trip.finishHaul': 'Kết thúc mẻ lưới',
'trip.startHaul': 'Bắt đầu mẻ lưới',
'trip.warning.haulNotFinished':
'Vui lòng kết thúc mẻ lưới hiện tại trước khi bắt đầu mẻ mới',
'trip.warning.tripStarted': 'Chuyến đi đã được bắt đầu hoặc hoàn thành.',
'trip.cancelTrip.title': 'Xác nhận huỷ chuyến đi',
'trip.cancelTrip.button': 'Huỷ chuyến đi',
'trip.cancelTrip.reason': 'Lý do: ',
'trip.cancelTrip.placeholder': 'Nhập lý do huỷ chuyến đi...',
'trip.cancelTrip.validation': 'Vui lòng nhập lý do huỷ chuyến đi',
'trip.weather': 'Nắng đẹp',
// Haul Fish List
'trip.haulFishList.title': 'Danh sách cá',
'trip.haulFishList.fishName': 'Tên cá',
'trip.haulFishList.fishCondition': 'Trạng thái',
'trip.haulFishList.fishRarity': 'Độ hiếm',
'trip.haulFishList.fishSize': 'Kích thước (cm)',
'trip.haulFishList.weight': 'Cân nặng (kg)',
'trip.haulFishList.gearUsage': 'Ngư cụ sử dụng',
'trip.haulFishList.noData': 'Không có dữ liệu cá trong mẻ lưới này',
// Haul Table
'trip.haulTable.no': 'STT',
'trip.haulTable.status': 'Trạng Thái',
'trip.haulTable.fishing': 'Đang đánh bắt',
'trip.haulTable.endFishing': 'Đã hoàn thành',
'trip.haulTable.cancelFishing': 'Đã huỷ',
'trip.haulTable.weather': 'Thời tiết',
'trip.haulTable.startTime': 'Thời điểm bắt đầu',
'trip.haulTable.endTime': 'Thời điểm kết thúc',
'trip.haulTable.action': 'Thao tác',
'trip.haulTable.updateSuccess': 'Cập nhật mẻ lưới thành công',
'trip.haulTable.updateError': 'Cập nhật mẻ lưới thất bại',
'trip.haulTable.haul': 'Mẻ',
// Main Trip Body
'trip.mainBody.tripCost': 'Chi phí chuyến đi',
'trip.mainBody.fishingGear': 'Danh sách ngư cụ',
'trip.mainBody.crew': 'Danh sách thuyền viên',
'trip.mainBody.haulList': 'Danh sách mẻ lưới',
// Trip Cost
'trip.cost.type': 'Loại',
'trip.cost.amount': 'Số lượng',
'trip.cost.unit': 'Đơn vị',
'trip.cost.price': 'Chi phí',
'trip.cost.total': 'Tổng chi phí',
'trip.cost.fuel': 'Nhiên liệu',
'trip.cost.crewSalary': 'Lương thuyền viên',
'trip.cost.food': 'Lương thực',
'trip.cost.iceSalt': 'Muối đá',
'trip.cost.grandTotal': 'Tổng cộng',
// Trip Crews
'trip.crew.id': 'Mã định danh',
'trip.crew.name': 'Tên',
'trip.crew.role': 'Chức vụ',
'trip.crew.email': 'Email',
'trip.crew.phone': 'Số điện thoại',
'trip.crew.birthDate': 'Ngày sinh',
'trip.crew.address': 'Địa chỉ',
'trip.crew.captain': 'Thuyền trưởng',
'trip.crew.member': 'Thuyền viên',
// Trip Fishing Gear
'trip.gear.name': 'Tên',
'trip.gear.quantity': 'Số lượng',
// Trip Cancel Or Finish
'trip.finishButton.confirmTitle': 'Thông báo',
'trip.finishButton.confirmMessage': 'Bạn chắc chắn muốn kết thúc chuyến đi?',
'trip.finishButton.confirmYes': 'Chắc chắn',
'trip.finishButton.confirmNo': 'Không',
'trip.finishButton.end': 'Kết thúc',
'trip.finishButton.updateSuccess': 'Cập nhật trạng thái thành công',
'trip.finishButton.updateError': 'Cập nhật trạng thái thất bại',
// Trip Detail Page
'trip.detail.title': 'Chuyến đi',
'trip.detail.defaultTitle': 'Chuyến đi',
'trip.detail.alarm': 'Cảnh báo',
'trip.detail.createHaulSuccess': 'Tạo mẻ lưới thành công',
'trip.detail.createHaulError': 'Tạo mẻ lưới thất bại',
'trip.detail.startTripSuccess': 'Bắt đầu chuyến đi thành công',
'trip.detail.startTripError': 'Bắt đầu chuyến đi thất bại',
'trip.detail.updateHaulSuccess': 'Cập nhật mẻ lưới thành công',
'trip.detail.updateHaulError': 'Cập nhật mẻ lưới thất bại',
// Pagination
'pagination.total': '{start}-{end} trên {total}',
// Home Page
'home.currentStatus': 'Trạng thái hiện tại',
'home.tripInfo': 'Thông tin chuyến đi',
'home.shipInfo': 'Thông tin tàu',
'home.errorDrawBanzones': 'Lỗi khi vẽ vùng cấm',
'home.errorBackground': 'Lỗi khi gọi API ở background',
'home.errorGpsUpdate': 'Lỗi khi cập nhật GPS',
'home.mapError': 'Lỗi khi thêm vào map',
// SOS
'sos.title': 'Thông báo khẩn cấp',
'sos.button': 'Khẩn cấp',
'sos.status': 'Đang trong trạng thái khẩn cấp',
'sos.end': 'Kết thúc',
'sos.label': 'Nội dung:',
'sos.placeholder': 'Chọn hoặc nhập lý do...',
'sos.other': 'Khác',
'sos.otherLabel': 'Lý do khác',
'sos.otherPlaceholder': 'Nhập lý do khác...',
'sos.defaultMessage': 'Tình huống khẩn cấp, không kịp chọn !!!',
'sos.sendSuccess': 'Gửi tín hiệu SOS thành công!',
'sos.sendError': 'Gửi tín hiệu SOS thất bại!',
'sos.cancelSuccess': 'Huỷ tín hiệu SOS thành công!',
'sos.cancelError': 'Huỷ tín hiệu SOS thất bại!',
'sos.validationError': 'Vui lòng chọn hoặc nhập lý do!',
'sos.otherValidationError': 'Vui lòng nhập lý do khác!',
};

View File

@@ -12,12 +12,12 @@ export default function useGetGpsModel() {
try { try {
// Bypass cache để GPS luôn realtime, gọi trực tiếp API // Bypass cache để GPS luôn realtime, gọi trực tiếp API
const data = await getGPS(); const data = await getGPS();
console.log('GPS fetched from API:', data); // console.log('GPS fetched from API:', data);
setGpsData(data); setGpsData(data);
// Luôn emit event GPS để đảm bảo realtime // Luôn emit event GPS để đảm bảo realtime
try { try {
eventBus.emit('gpsData:update', data); // eventBus.emit('gpsData:update', data);
} catch (e) { } catch (e) {
console.warn('Failed to emit gpsData:update event', e); console.warn('Failed to emit gpsData:update event', e);
} }
@@ -31,7 +31,7 @@ export default function useGetGpsModel() {
// ✅ Lắng nghe event cập nhật cache từ nơi khác (nếu có) // ✅ Lắng nghe event cập nhật cache từ nơi khác (nếu có)
useEffect(() => { useEffect(() => {
const handleUpdate = (data: API.GPSResonse) => { const handleUpdate = (data: API.GPSResonse) => {
console.log('GPS cache updated via eventBus'); // console.log('GPS cache updated via eventBus');
setGpsData(data); setGpsData(data);
}; };
eventBus.on('gpsData:update', handleUpdate); eventBus.on('gpsData:update', handleUpdate);

View File

@@ -5,27 +5,25 @@ import { parseJwt } from '@/utils/jwtTokenUtils';
import { getToken, removeToken, setToken } from '@/utils/localStorageUtils'; import { getToken, removeToken, setToken } from '@/utils/localStorageUtils';
import { LockOutlined, UserOutlined } from '@ant-design/icons'; import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginFormPage, ProFormText } from '@ant-design/pro-components'; import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
import { history } from '@umijs/max'; import { history, useIntl } from '@umijs/max';
import { Image, theme } from 'antd'; import { Image, Space, theme } from 'antd';
import { useEffect } from 'react'; import { useEffect } from 'react';
import logoImg from '../../../public/logo.png'; import logoImg from '../../../public/logo.png';
import mobifontImg from '../../../public/owner.png'; import mobifontImg from '../../../public/owner.png';
import LangSwitches from '@/components/switch/LangSwitches';
const LoginPage = () => { const LoginPage = () => {
const { token } = theme.useToken(); const { token } = theme.useToken();
const urlParams = new URL(window.location.href).searchParams; const urlParams = new URL(window.location.href).searchParams;
const redirect = urlParams.get('redirect'); const redirect = urlParams.get('redirect');
const intl = useIntl();
useEffect(() => {
checkLogin();
}, []);
const checkLogin = () => { const checkLogin = () => {
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
return; return;
} }
const parsed = parseJwt(token); const parsed = parseJwt(token);
const { sub, exp } = parsed; const { exp } = parsed;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60; const oneHour = 60 * 60;
if (exp - now < oneHour) { if (exp - now < oneHour) {
@@ -39,6 +37,10 @@ const LoginPage = () => {
} }
}; };
useEffect(() => {
checkLogin();
}, []);
const handleLogin = async (values: API.LoginRequestBody) => { const handleLogin = async (values: API.LoginRequestBody) => {
try { try {
const resp = await login(values); const resp = await login(values);
@@ -68,7 +70,10 @@ const LoginPage = () => {
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr" backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
title={ title={
<span style={{ color: token.colorBgContainer }}> <span style={{ color: token.colorBgContainer }}>
Hệ thống giám sát tàu {intl.formatMessage({
id: 'auth.login.subtitle',
defaultMessage: 'Sea Gateway',
})}
</span> </span>
} }
containerStyle={{ containerStyle={{
@@ -78,10 +83,18 @@ const LoginPage = () => {
subTitle={<Image preview={false} src={mobifontImg} />} subTitle={<Image preview={false} src={mobifontImg} />}
submitter={{ submitter={{
searchConfig: { searchConfig: {
submitText: 'Đăng nhập', submitText: intl.formatMessage({
id: 'common.login',
defaultMessage: 'Đăng nhập',
}),
}, },
}} }}
onFinish={async (values: API.LoginRequestBody) => handleLogin(values)} onFinish={async (values: API.LoginRequestBody) => handleLogin(values)}
actions={
<Space className='mt-3'>
<LangSwitches />
</Space>
}
> >
<> <>
<ProFormText <ProFormText
@@ -97,11 +110,17 @@ const LoginPage = () => {
/> />
), ),
}} }}
placeholder={'Tài khoản'} placeholder={intl.formatMessage({
id: 'common.username',
defaultMessage: 'Tài khoản',
})}
rules={[ rules={[
{ {
required: true, required: true,
message: 'Tài khoản không được để trống!', message: intl.formatMessage({
id: 'validation.username',
defaultMessage: 'Tài khoản không được để trống!',
}),
}, },
]} ]}
/> />
@@ -118,16 +137,24 @@ const LoginPage = () => {
/> />
), ),
}} }}
placeholder={'Mật khẩu'} placeholder={intl.formatMessage({
id: 'common.password',
defaultMessage: 'Mật khẩu',
})}
rules={[ rules={[
{ {
required: true, required: true,
message: 'Mật khẩu không được để trống!', message: intl.formatMessage({
id: 'validation.password',
defaultMessage: 'Mật khẩu không được để trống!',
}),
}, },
]} ]}
/> />
</> </>
</LoginFormPage> </LoginFormPage>
<LangSwitches />
<div <div
style={{ style={{
backgroundColor: 'transparent', backgroundColor: 'transparent',

View File

@@ -73,6 +73,7 @@ class MapManager {
private layers: BaseLayer[]; // Assuming layers is defined elsewhere 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 private isInitialized: boolean; // Thêm thuộc tính để theo dõi trạng thái khởi tạo
private eventKey: EventsKey | undefined; private eventKey: EventsKey | undefined;
shipFeature: any;
constructor( constructor(
mapRef: React.RefObject<HTMLDivElement>, mapRef: React.RefObject<HTMLDivElement>,

View File

@@ -1,7 +1,9 @@
import { convertToDMS } from '@/services/service/MapService'; import { convertToDMS } from '@/services/service/MapService';
import { ProDescriptions } from '@ant-design/pro-components'; import { ProDescriptions } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { GpsData } from '..'; import { GpsData } from '..';
const GpsInfo = ({ gpsData }: { gpsData: GpsData | null }) => { const GpsInfo = ({ gpsData }: { gpsData: GpsData | null }) => {
const intl = useIntl();
return ( return (
<ProDescriptions<GpsData> <ProDescriptions<GpsData>
dataSource={gpsData || undefined} dataSource={gpsData || undefined}
@@ -16,38 +18,40 @@ const GpsInfo = ({ gpsData }: { gpsData: GpsData | null }) => {
}} }}
columns={[ columns={[
{ {
title: 'Kinh độ', title: intl.formatMessage({ id: 'map.ship.longitude' }),
dataIndex: 'lat', dataIndex: 'lat',
render: (_, record) => render: (_, record) =>
record?.lat != null ? `${convertToDMS(record.lat, true)}°` : '--', record?.lat !== null ? `${convertToDMS(record.lat, true)}°` : '--',
}, },
{ {
title: 'Vĩ độ', title: intl.formatMessage({ id: 'map.ship.latitude' }),
dataIndex: 'lon', dataIndex: 'lon',
render: (_, record) => render: (_, record) =>
record?.lon != null ? `${convertToDMS(record.lon, false)}°` : '--', record?.lon !== null ? `${convertToDMS(record.lon, false)}°` : '--',
}, },
{ {
title: 'Tốc độ', title: intl.formatMessage({ id: 'map.ship.speed' }),
dataIndex: 's', dataIndex: 's',
valueType: 'digit', valueType: 'digit',
render: (_, record) => render: (_, record) =>
record?.s != null ? `${record.s} km/h` : '-- km/h', record?.s !== null
? `${record.s} ${intl.formatMessage({ id: 'unit.kmh' })}`
: `-- ${intl.formatMessage({ id: 'unit.kmh' })}`,
span: 1, span: 1,
}, },
{ {
title: 'Hướng', title: intl.formatMessage({ id: 'map.ship.course' }),
dataIndex: 'h', dataIndex: 'h',
valueType: 'digit', valueType: 'digit',
render: (_, record) => `${record.h}°`, render: (_, record) => `${record.h}°`,
span: 1, span: 1,
}, },
{ {
title: 'Trạng thái', title: intl.formatMessage({ id: 'map.ship.state' }),
tooltip: 'Thuyền có đang đánh bắt hay không', tooltip: intl.formatMessage({ id: 'map.ship.state.tooltip' }),
dataIndex: 'fishing', dataIndex: 'fishing',
render: (_, record) => render: (_, record) =>
record?.fishing ? 'Đang đánh bắt' : 'Không đánh bắt', record?.fishing ? `${intl.formatMessage({ id: 'map.ship.state.fishing' })}` : `${intl.formatMessage({ id: 'map.ship.state.notFishing' })}`,
}, },
]} ]}
/> />

View File

@@ -1,12 +1,38 @@
import { getShipInfo } from '@/services/controller/DeviceController'; import { getShipInfo } from '@/services/controller/DeviceController';
import { ProDescriptions } from '@ant-design/pro-components'; import { ProDescriptions } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Modal } from 'antd'; import { Modal } from 'antd';
import { useState, useEffect } from 'react';
interface ShipInfoProps { interface ShipInfoProps {
isOpen?: boolean; isOpen?: boolean;
setIsOpen: (open: boolean) => void; setIsOpen: (open: boolean) => void;
} }
const ShipInfo: React.FC<ShipInfoProps> = ({ isOpen, setIsOpen }) => { const ShipInfo: React.FC<ShipInfoProps> = ({ isOpen, setIsOpen }) => {
const intl = useIntl();
const [modalWidth, setModalWidth] = useState('90%');
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth;
if (width < 576) {
setModalWidth('95%');
} else if (width < 768) {
setModalWidth('90%');
} else if (width < 992) {
setModalWidth('80%');
} else if (width < 1200) {
setModalWidth('70%');
} else {
setModalWidth('800px');
}
};
window.addEventListener('resize', handleResize);
handleResize(); // Set initial width
return () => window.removeEventListener('resize', handleResize);
}, []);
const fetchShipData = async () => { const fetchShipData = async () => {
try { try {
const resp = await getShipInfo(); const resp = await getShipInfo();
@@ -20,10 +46,12 @@ const ShipInfo: React.FC<ShipInfoProps> = ({ isOpen, setIsOpen }) => {
return ( return (
<Modal <Modal
title="Thông tin tàu" centered
title={intl.formatMessage({ id: 'map.ship.info' })}
open={isOpen} open={isOpen}
onCancel={() => setIsOpen(false)} onCancel={() => setIsOpen(false)}
onOk={() => setIsOpen(false)} onOk={() => setIsOpen(false)}
width={modalWidth}
> >
<ProDescriptions<API.ShipDetail> <ProDescriptions<API.ShipDetail>
column={{ column={{
@@ -35,8 +63,6 @@ const ShipInfo: React.FC<ShipInfoProps> = ({ isOpen, setIsOpen }) => {
}} }}
request={async () => { request={async () => {
const resp = await fetchShipData(); const resp = await fetchShipData();
console.log('Fetched ship info:', resp);
return Promise.resolve({ return Promise.resolve({
data: resp, data: resp,
success: true, success: true,
@@ -44,29 +70,29 @@ const ShipInfo: React.FC<ShipInfoProps> = ({ isOpen, setIsOpen }) => {
}} }}
columns={[ columns={[
{ {
title: 'Tên', title: intl.formatMessage({ id: 'map.ship.name' }),
dataIndex: 'name', dataIndex: 'name',
}, },
{ {
title: 'IMO', title: intl.formatMessage({ id: 'map.ship.imo' }),
dataIndex: 'imo_number', dataIndex: 'imo_number',
}, },
{ {
title: 'MMSI', title: intl.formatMessage({ id: 'map.ship.mmsi' }),
dataIndex: 'mmsi_number', dataIndex: 'mmsi_number',
}, },
{ {
title: 'Số đăng ký', title: intl.formatMessage({ id: 'map.ship.regNumber' }),
dataIndex: 'reg_number', dataIndex: 'reg_number',
}, },
{ {
title: 'Chiều dài', title: intl.formatMessage({ id: 'map.ship.length' }),
dataIndex: 'ship_length', dataIndex: 'ship_length',
render: (_, record) => render: (_, record) =>
record?.ship_length ? `${record.ship_length} m` : '--', record?.ship_length ? `${record.ship_length} m` : '--',
}, },
{ {
title: 'Công suất', title: intl.formatMessage({ id: 'map.ship.power' }),
dataIndex: 'ship_power', dataIndex: 'ship_power',
render: (_, record) => render: (_, record) =>
record?.ship_power ? `${record.ship_power} kW` : '--', record?.ship_power ? `${record.ship_power} kW` : '--',

View File

@@ -1,3 +1,4 @@
import useTranslation from '@/hooks/useTranslation';
import { import {
deleteSos, deleteSos,
getSos, getSos,
@@ -20,23 +21,22 @@ interface SosButtonProps {
} }
const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => { const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
const { t } = useTranslation();
const [form] = Form.useForm<{ message: string; messageOther?: string }>(); const [form] = Form.useForm<{ message: string; messageOther?: string }>();
const [sosData, setSosData] = useState<API.SosResponse>(); const [sosData, setSosData] = useState<API.SosResponse>();
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
useEffect(() => {
getSosData();
}, []);
const getSosData = async () => { const getSosData = async () => {
try { try {
const sosData = await getSos(); const sosData = await getSos();
console.log('SOS Data: ', sosData);
setSosData(sosData); setSosData(sosData);
} catch (error) { } catch (error) {
console.error('Failed to fetch SOS data:', error); console.error('Failed to fetch SOS data:', error);
} }
}; };
useEffect(() => {
getSosData();
}, []);
// map width cho từng breakpoint // map width cho từng breakpoint
const getWidth = () => { const getWidth = () => {
if (screens.xs) return '95%'; // mobile if (screens.xs) return '95%'; // mobile
@@ -50,33 +50,33 @@ const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
if (messageData) { if (messageData) {
try { try {
await sendSosMessage(messageData); await sendSosMessage(messageData);
message.success('Gửi tín hiệu SOS thành công!'); message.success(t('sos.sendSuccess'));
getSosData(); // Cập nhật lại trạng thái SOS getSosData(); // Cập nhật lại trạng thái SOS
onRefresh?.(true); onRefresh?.(true);
} catch (error) { } catch (error) {
console.error('Failed to send SOS:', error); console.error('Failed to send SOS:', error);
message.error('Gửi tín hiệu SOS thất bại!'); message.error(t('sos.sendError'));
} }
} else { } else {
try { try {
await deleteSos(); await deleteSos();
message.success('Huỷ tín hiệu SOS thành công!'); message.success(t('sos.cancelSuccess'));
getSosData(); // Cập nhật lại trạng thái SOS getSosData(); // Cập nhật lại trạng thái SOS
onRefresh?.(true); onRefresh?.(true);
} catch (error) { } catch (error) {
console.error('Failed to delete SOS:', error); console.error('Failed to delete SOS:', error);
message.error('Huỷ tín hiệu SOS thất bại!'); message.error(t('sos.cancelError'));
} }
console.log('Sending SOS without message'); console.log('Sending SOS without message');
} }
}; };
return sosData && sosData.active == true ? ( 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 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"> <div className="flex items-center gap-2">
<WarningOutlined style={{ color: 'white', fontSize: '20px' }} /> <WarningOutlined style={{ color: 'white', fontSize: '20px' }} />
<Typography.Text className="text-white text-sm sm:text-base"> <Typography.Text className="text-white text-sm sm:text-base">
Đang trong trạng thái khẩn cấp {t('sos.status')}
</Typography.Text> </Typography.Text>
</div> </div>
@@ -86,13 +86,13 @@ const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
className="self-end sm:self-auto" className="self-end sm:self-auto"
onClick={async () => await handleSos()} onClick={async () => await handleSos()}
> >
Kết thúc {t('sos.end')}
</Button> </Button>
</div> </div>
) : ( ) : (
<ModalForm <ModalForm
title="Thông báo khẩn cấp" title={t('sos.title')}
initialValues={{ message: 'Tình huống khẩn cấp, không kịp chọn !!!' }} initialValues={{ message: t('sos.defaultMessage') }}
form={form} form={form}
width={getWidth()} width={getWidth()}
modalProps={{ modalProps={{
@@ -112,7 +112,7 @@ const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
danger danger
shape="round" shape="round"
> >
Khẩn cấp {t('sos.button')}
</Button> </Button>
} }
onFinish={async (values) => { onFinish={async (values) => {
@@ -129,16 +129,16 @@ const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
> >
<ProFormSelect <ProFormSelect
options={[ options={[
{ value: 'other', label: 'Khác' }, { value: 'other', label: t('sos.other') },
...sosMessage.map((item) => ({ ...sosMessage.map((item) => ({
value: item.moTa, value: item.moTa,
label: item.moTa, label: item.moTa,
})), })),
]} ]}
name="message" name="message"
rules={[{ required: true, message: 'Vui lòng chọn hoặc nhập lý do!' }]} rules={[{ required: true, message: t('sos.validationError') }]}
placeholder="Chọn hoặc nhập lý do..." placeholder={t('sos.placeholder')}
label="Nội dung:" label={t('sos.label')}
/> />
<ProFormDependency name={['message']}> <ProFormDependency name={['message']}>
@@ -146,9 +146,11 @@ const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
message === 'other' ? ( message === 'other' ? (
<ProFormText <ProFormText
name="messageOther" name="messageOther"
placeholder="Nhập lý do khác..." placeholder={t('sos.otherPlaceholder')}
rules={[{ required: true, message: 'Vui lòng nhập lý do khác!' }]} rules={[
label="Lý do khác" { required: true, message: t('sos.otherValidationError') },
]}
label={t('sos.otherLabel')}
/> />
) : null ) : null
} }

View File

@@ -12,6 +12,7 @@ import {
} from '@/services/controller/DeviceController'; } from '@/services/controller/DeviceController';
import { queryBanzones } from '@/services/controller/MapController'; import { queryBanzones } from '@/services/controller/MapController';
import { getShipIcon } from '@/services/service/MapService'; import { getShipIcon } from '@/services/service/MapService';
import { eventBus } from '@/utils/eventBus';
import { import {
convertWKTLineStringToLatLngArray, convertWKTLineStringToLatLngArray,
convertWKTtoLatLngString, convertWKTtoLatLngString,
@@ -22,17 +23,16 @@ import {
InfoCircleOutlined, InfoCircleOutlined,
InfoOutlined, InfoOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { history, useModel } from '@umijs/max'; import { history, useIntl, useModel } from '@umijs/max';
import { eventBus } from '@/utils/eventBus';
import { FloatButton, Popover } from 'antd'; import { FloatButton, Popover } from 'antd';
import LineString from 'ol/geom/LineString';
import Point from 'ol/geom/Point';
import { fromLonLat } from 'ol/proj';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import GpsInfo from './components/GpsInfo'; import GpsInfo from './components/GpsInfo';
import ShipInfo from './components/ShipInfo'; import ShipInfo from './components/ShipInfo';
import SosButton from './components/SosButton'; import SosButton from './components/SosButton';
import VietNamMap, { MapManager } from './components/VietNamMap'; import VietNamMap, { MapManager } from './components/VietNamMap';
import Point from 'ol/geom/Point';
import LineString from 'ol/geom/LineString';
import { fromLonLat } from 'ol/proj';
// // Define missing types locally for now // // Define missing types locally for now
// interface AlarmData { // interface AlarmData {
@@ -61,10 +61,6 @@ const HomePage: React.FC = () => {
const [isShipInfoOpen, setIsShipInfoOpen] = useState(false); const [isShipInfoOpen, setIsShipInfoOpen] = useState(false);
const { gpsData, getGPSData } = useModel('getSos'); const { gpsData, getGPSData } = useModel('getSos');
const hasCenteredRef = useRef(false); const hasCenteredRef = useRef(false);
const banzonesCacheRef = useRef<any[]>([]);
function isSameBanzones(newData: any[], oldData: any[]): boolean {
return JSON.stringify(newData) === JSON.stringify(oldData);
}
const onFeatureClick = useCallback((feature: any) => { const onFeatureClick = useCallback((feature: any) => {
console.log('OnClick Feature: ', feature); console.log('OnClick Feature: ', feature);
console.log( console.log(
@@ -74,19 +70,23 @@ const HomePage: React.FC = () => {
feature.getProperties(), feature.getProperties(),
); );
}, []); }, []);
const intl = useIntl();
const onFeaturesClick = useCallback((features: any[]) => { const onFeaturesClick = useCallback((features: any[]) => {
console.log('Multiple features clicked:', features); console.log('Multiple features clicked:', features);
}, []); }, []);
const onError = useCallback((error: any) => { const onError = useCallback(
console.error( (error: any) => {
'Lỗi khi thêm vào map at', console.error(
new Date().toLocaleTimeString(), intl.formatMessage({ id: 'home.mapError' }),
':', 'at',
error, new Date().toLocaleTimeString(),
); ':',
}, []); error,
);
},
[intl],
);
const mapManagerRef = useRef( const mapManagerRef = useRef(
new MapManager(mapRef, { new MapManager(mapRef, {
@@ -109,8 +109,14 @@ const HomePage: React.FC = () => {
}; };
// Helper function to add features to the map (kept above pollers to avoid use-before-define) // Helper function to add features to the map (kept above pollers to avoid use-before-define)
function addFeatureToMap(mapManager: MapManager, gpsData: GpsData, alarm: API.AlarmResponse,) { function addFeatureToMap(
const isSos = alarm?.alarms.find((a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING); mapManager: MapManager,
gpsData: GpsData,
alarm: API.AlarmResponse,
) {
const isSos = alarm?.alarms.find(
(a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING,
);
if (mapManager?.featureLayer && gpsData) { if (mapManager?.featureLayer && gpsData) {
mapManager.featureLayer.getSource()?.clear(); mapManager.featureLayer.getSource()?.clear();
@@ -124,142 +130,11 @@ const HomePage: React.FC = () => {
}, },
); );
} }
} }
const drawBanzones = async (mapManager: MapManager) => {
useEffect(() => {
// Chỉ poll GPS, các API còn lại sẽ được gọi khi có GPS mới
getGPSData();
const gpsInterval = setInterval(() => {
getGPSData();
}, 5000); // cập nhật mỗi 5s
// Vẽ marker tàu và polyline ngay khi vào trang nếu đã có dữ liệu
if (gpsData) {
eventBus.emit('ship:update', gpsData);
}
// Nếu có trackpoints, emit luôn (nếu bạn lưu trackpoints ở state, có thể emit ở đây)
// eventBus.emit('trackpoints:update', trackpoints);
return () => {
clearInterval(gpsInterval);
console.log('MapManager destroyed in HomePage cleanup');
};
}, []);
// Lắng nghe eventBus cho tất cả các loại dữ liệu
useEffect(() => {
// GPS cập nhật => emit ship update ngay lập tức, gọi API khác không đồng bộ
const onGpsUpdate = async (data: API.GPSResonse) => {
try {
if (data) {
// Emit ship update ngay lập tức để marker tàu cập nhật realtime
eventBus.emit('ship:update', data);
// Gọi các API khác trong background, không block ship update
setTimeout(async () => {
try {
const alarm = await queryAlarms();
eventBus.emit('alarm:update', alarm);
const trackpoints = await queryShipTrackPoints();
eventBus.emit('trackpoints:update', trackpoints);
const entities = await queryEntities();
eventBus.emit('entities:update', entities);
} catch (e) {
console.error('Background API calls error', e);
}
}, 0);
}
} catch (e) {
console.error('onGpsUpdate handler error', e);
}
};
// Cập nhật marker tàu khi có GPS mới
const onShipUpdate = (data: API.GPSResonse) => {
const mapManager = mapManagerRef.current;
if (!mapManager || !data) return;
// Cache feature trong mapManager
if (!mapManager.shipFeature) {
mapManager.shipFeature = mapManager.addPoint(
[data.lon, data.lat],
{ bearing: data.h || 0 },
{
icon: getShipIcon(0, data?.fishing || false),
scale: 0.1,
animate: false,
},
);
} else {
const geom = mapManager.shipFeature.getGeometry();
if (geom instanceof Point) {
geom.setCoordinates([data.lon, data.lat]);
}
}
// Set map center to current GPS position
if (!hasCenteredRef.current && mapManager.map && mapManager.map.getView) {
mapManager.map.getView().setCenter(fromLonLat([data.lon, data.lat]));
hasCenteredRef.current = true;
}
};
// Cập nhật alarm khi có event
const onAlarmUpdate = (alarm: API.AlarmResponse) => {
if (gpsData) addFeatureToMap(mapManagerRef.current, gpsData, alarm);
};
// Cập nhật trackpoints khi có event
const onTrackpointsUpdate = (trackpoints: API.ShipTrackPoint[]) => {
const source = mapManagerRef.current?.featureLayer?.getSource();
if (source && trackpoints.length > 0) {
const features = source.getFeatures();
// Tìm polyline theo id
const polyline = features.find(f => f.get('id') === MAP_TRACKPOINTS_ID);
const coordinates = trackpoints.map((point) => [point.lon, point.lat]);
if (polyline && polyline.getGeometry() instanceof LineString) {
polyline.getGeometry().setCoordinates(coordinates);
} else {
mapManagerRef.current.addLineString(
coordinates,
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
}
}
};
// Cập nhật entities/banzones khi có event
const onEntitiesUpdate = async (entities: API.TransformedEntity[]) => {
const mapManager = mapManagerRef.current;
if (mapManager) {
await drawBanzones(mapManager);
}
};
eventBus.on('gpsData:update', onGpsUpdate);
eventBus.on('ship:update', onShipUpdate);
eventBus.on('alarm:update', onAlarmUpdate);
eventBus.on('trackpoints:update', onTrackpointsUpdate);
eventBus.on('entities:update', onEntitiesUpdate);
return () => {
eventBus.off('gpsData:update', onGpsUpdate);
eventBus.off('ship:update', onShipUpdate);
eventBus.off('alarm:update', onAlarmUpdate);
eventBus.off('trackpoints:update', onTrackpointsUpdate);
eventBus.off('entities:update', onEntitiesUpdate);
};
}, [gpsData]);
// Vẽ lại banzones (polygon, polyline vùng cấm, vùng cá thử...)
async function drawBanzones(mapManager: MapManager) {
try { try {
const layer = mapManager.featureLayer?.getSource(); const layer = mapManager.featureLayer?.getSource();
layer?.getFeatures()?.forEach(f => { layer?.getFeatures()?.forEach((f) => {
if ([MAP_POLYGON_BAN, MAP_POLYLINE_BAN].includes(f.get('id'))) { if ([MAP_POLYGON_BAN, MAP_POLYLINE_BAN].includes(f.get('id'))) {
layer.removeFeature(f); layer.removeFeature(f);
} }
@@ -275,7 +150,9 @@ const HomePage: React.FC = () => {
if (geom) { if (geom) {
const { geom_type, geom_lines, geom_poly } = geom.geom || {}; const { geom_type, geom_lines, geom_poly } = geom.geom || {};
if (geom_type === 2) { if (geom_type === 2) {
const coordinates = convertWKTLineStringToLatLngArray(geom_lines || ''); const coordinates = convertWKTLineStringToLatLngArray(
geom_lines || '',
);
if (coordinates.length > 0) { if (coordinates.length > 0) {
mapManager.addLineString( mapManager.addLineString(
coordinates, coordinates,
@@ -310,9 +187,155 @@ const HomePage: React.FC = () => {
} }
} }
} catch (error) { } catch (error) {
console.error('Error drawing banzones:', error); console.error(
intl.formatMessage({ id: 'home.errorDrawBanzones' }),
error,
);
} }
} };
useEffect(() => {
// Chỉ poll GPS, các API còn lại sẽ được gọi khi có GPS mới
getGPSData();
const gpsInterval = setInterval(() => {
getGPSData();
}, 5000); // cập nhật mỗi 5s
// Vẽ marker tàu và polyline ngay khi vào trang nếu đã có dữ liệu
if (gpsData) {
eventBus.emit('ship:update', gpsData);
}
// Nếu có trackpoints, emit luôn (nếu bạn lưu trackpoints ở state, có thể emit ở đây)
// eventBus.emit('trackpoints:update', trackpoints);
return () => {
clearInterval(gpsInterval);
console.log('MapManager destroyed in HomePage cleanup');
};
}, []);
// Lắng nghe eventBus cho tất cả các loại dữ liệu
useEffect(() => {
// GPS cập nhật => emit ship update ngay lập tức, gọi API khác không đồng bộ
const onGpsUpdate = async (data: API.GPSResonse) => {
try {
if (data) {
// Emit ship update ngay lập tức để marker tàu cập nhật realtime
eventBus.emit('ship:update', data);
// Gọi các API khác trong background, không block ship update
setTimeout(async () => {
try {
const alarm = await queryAlarms();
eventBus.emit('alarm:update', alarm);
const trackpoints = await queryShipTrackPoints();
eventBus.emit('trackpoints:update', trackpoints);
const entities = await queryEntities();
eventBus.emit('entities:update', entities);
} catch (e) {
console.error(
intl.formatMessage({ id: 'home.errorBackground' }),
e,
);
}
}, 0);
}
} catch (e) {
console.error(intl.formatMessage({ id: 'home.errorGpsUpdate' }), e);
}
};
// Cập nhật marker tàu khi có GPS mới
const onShipUpdate = (data: API.GPSResonse) => {
const mapManager = mapManagerRef.current;
if (!mapManager || !data) return;
// Cache feature trong mapManager
if (!mapManager.shipFeature) {
mapManager.shipFeature = mapManager.addPoint(
[data.lon, data.lat],
{ bearing: data.h || 0 },
{
icon: getShipIcon(0, data?.fishing || false),
scale: 0.1,
animate: false,
},
);
} else {
const geom = mapManager.shipFeature.getGeometry();
if (geom instanceof Point) {
geom.setCoordinates([data.lon, data.lat]);
}
}
// Set map center to current GPS position
if (!hasCenteredRef.current && mapManager.map && mapManager.map.getView) {
mapManager.map.getView().setCenter(fromLonLat([data.lon, data.lat]));
hasCenteredRef.current = true;
}
};
// Cập nhật alarm khi có event
const onAlarmUpdate = (alarm: API.AlarmResponse) => {
if (gpsData) addFeatureToMap(mapManagerRef.current, gpsData, alarm);
};
// Cập nhật trackpoints khi có event
const onTrackpointsUpdate = (trackpoints: API.ShipTrackPoint[]) => {
const source = mapManagerRef.current?.featureLayer?.getSource();
if (source && trackpoints.length > 0) {
const features = source.getFeatures();
// Tìm polyline theo id
const polyline = features.find(
(f) => f.get('id') === MAP_TRACKPOINTS_ID,
);
const coordinates = trackpoints.map((point) => [point.lon, point.lat]);
if (polyline) {
const geom = polyline.getGeometry();
if (geom instanceof LineString) {
geom.setCoordinates(coordinates);
} else {
mapManagerRef.current?.addLineString(
coordinates,
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
}
} else {
mapManagerRef.current?.addLineString(
coordinates,
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
}
}
};
// Cập nhật entities/banzones khi có event
const onEntitiesUpdate = async () => {
const mapManager = mapManagerRef.current;
if (mapManager) {
await drawBanzones(mapManager);
}
};
eventBus.on('gpsData:update', onGpsUpdate);
eventBus.on('ship:update', onShipUpdate);
eventBus.on('alarm:update', onAlarmUpdate);
eventBus.on('trackpoints:update', onTrackpointsUpdate);
eventBus.on('entities:update', onEntitiesUpdate);
return () => {
eventBus.off('gpsData:update', onGpsUpdate);
eventBus.off('ship:update', onShipUpdate);
eventBus.off('alarm:update', onAlarmUpdate);
eventBus.off('trackpoints:update', onTrackpointsUpdate);
eventBus.off('entities:update', onEntitiesUpdate);
};
}, [gpsData]);
// Vẽ lại banzones (polygon, polyline vùng cấm, vùng cá thử...)
useEffect(() => { useEffect(() => {
// Vẽ lại banzones khi vào trang // Vẽ lại banzones khi vào trang
@@ -336,14 +359,11 @@ const HomePage: React.FC = () => {
/> />
<Popover <Popover
styles={{ styles={{
root: { width: '85%', maxWidth: 600, paddingLeft: 15 }, root: { width: '85%', maxWidth: 800, minWidth: 700, paddingLeft: 15 },
}} }}
placement="left" placement="left"
title="Trạng thái hiện tại" title={intl.formatMessage({ id: 'home.currentStatus' })}
content={ content={<GpsInfo gpsData={gpsData} />}
<GpsInfo gpsData={gpsData} />
// <ShipInfo />
}
open={isShowGPSData} open={isShowGPSData}
> >
<FloatButton.Group <FloatButton.Group
@@ -355,11 +375,11 @@ const HomePage: React.FC = () => {
<FloatButton <FloatButton
icon={<CommentOutlined />} icon={<CommentOutlined />}
onClick={() => history.push(ROUTE_TRIP)} onClick={() => history.push(ROUTE_TRIP)}
tooltip="Thông tin chuyến đi" tooltip={intl.formatMessage({ id: 'home.tripInfo' })}
/> />
<FloatButton <FloatButton
icon={<InfoOutlined />} icon={<InfoOutlined />}
tooltip="Thông tin tàu" tooltip={intl.formatMessage({ id: 'home.shipInfo' })}
onClick={() => setIsShipInfoOpen(true)} onClick={() => setIsShipInfoOpen(true)}
/> />
</FloatButton.Group> </FloatButton.Group>
@@ -378,4 +398,4 @@ const HomePage: React.FC = () => {
); );
}; };
export default HomePage; export default HomePage;

View File

@@ -1,3 +1,4 @@
import { useIntl } from '@umijs/max';
import type { BadgeProps } from 'antd'; import type { BadgeProps } from 'antd';
import { Badge } from 'antd'; import { Badge } from 'antd';
import React from 'react'; import React from 'react';
@@ -7,6 +8,7 @@ interface BadgeTripStatusProps {
} }
const BadgeTripStatus: React.FC<BadgeTripStatusProps> = ({ status }) => { const BadgeTripStatus: React.FC<BadgeTripStatusProps> = ({ status }) => {
const intl = useIntl();
// Khai báo kiểu cho map // Khai báo kiểu cho map
const statusBadgeMap: Record<number, BadgeProps> = { const statusBadgeMap: Record<number, BadgeProps> = {
0: { status: 'default' }, // Đã khởi tạo 0: { status: 'default' }, // Đã khởi tạo
@@ -20,19 +22,19 @@ const BadgeTripStatus: React.FC<BadgeTripStatusProps> = ({ status }) => {
const getBadgeProps = (status: number | undefined) => { const getBadgeProps = (status: number | undefined) => {
switch (status) { switch (status) {
case 0: case 0:
return 'Chưa được phê duyệt'; return intl.formatMessage({ id: 'trip.badge.notApproved' });
case 1: case 1:
return 'Đang chờ duyệt'; return intl.formatMessage({ id: 'trip.badge.waitingApproval' });
case 2: case 2:
return 'Đã duyệt'; return intl.formatMessage({ id: 'trip.badge.approved' });
case 3: case 3:
return 'Đang hoạt động'; return intl.formatMessage({ id: 'trip.badge.active' });
case 4: case 4:
return 'Đã hoàn thành'; return intl.formatMessage({ id: 'trip.badge.completed' });
case 5: case 5:
return 'Đã huỷ'; return intl.formatMessage({ id: 'trip.badge.cancelled' });
default: default:
return 'Trạng thái không xác định'; return intl.formatMessage({ id: 'trip.badge.unknown' });
} }
}; };

View File

@@ -1,13 +1,15 @@
import { ModalForm, ProFormTextArea } from '@ant-design/pro-components'; import { ModalForm, ProFormTextArea } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Button, Form } from 'antd'; import { Button, Form } from 'antd';
interface CancelTripProps { interface CancelTripProps {
onFinished?: (note: string) => void; onFinished?: (note: string) => void;
} }
const CancelTrip: React.FC<CancelTripProps> = ({ onFinished }) => { const CancelTrip: React.FC<CancelTripProps> = ({ onFinished }) => {
const intl = useIntl();
const [form] = Form.useForm<{ note: string }>(); const [form] = Form.useForm<{ note: string }>();
return ( return (
<ModalForm <ModalForm
title="Xác nhận huỷ chuyến đi" title={intl.formatMessage({ id: 'trip.cancelTrip.title' })}
form={form} form={form}
modalProps={{ modalProps={{
destroyOnHidden: true, destroyOnHidden: true,
@@ -15,7 +17,7 @@ const CancelTrip: React.FC<CancelTripProps> = ({ onFinished }) => {
}} }}
trigger={ trigger={
<Button color="danger" variant="solid"> <Button color="danger" variant="solid">
Huỷ chuyến đi {intl.formatMessage({ id: 'trip.cancelTrip.button' })}
</Button> </Button>
} }
onFinish={async (values) => { onFinish={async (values) => {
@@ -25,10 +27,13 @@ const CancelTrip: React.FC<CancelTripProps> = ({ onFinished }) => {
> >
<ProFormTextArea <ProFormTextArea
name="note" name="note"
label="Lý do: " label={intl.formatMessage({ id: 'trip.cancelTrip.reason' })}
placeholder={'Nhập lý do huỷ chuyến đi...'} placeholder={intl.formatMessage({ id: 'trip.cancelTrip.placeholder' })}
rules={[ rules={[
{ required: true, message: 'Vui lòng nhập lý do huỷ chuyến đi' }, {
required: true,
message: intl.formatMessage({ id: 'trip.cancelTrip.validation' }),
},
]} ]}
/> />
</ModalForm> </ModalForm>

View File

@@ -5,8 +5,8 @@ import {
updateTripState, updateTripState,
} from '@/services/controller/TripController'; } from '@/services/controller/TripController';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { useModel } from '@umijs/max'; import { useIntl, useModel } from '@umijs/max';
import { Button, Grid, message, theme } from 'antd'; import { Button, Grid, message } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
import CreateOrUpdateFishingLog from './CreateOrUpdateFishingLog'; import CreateOrUpdateFishingLog from './CreateOrUpdateFishingLog';
@@ -20,7 +20,7 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
onCallBack, onCallBack,
}) => { }) => {
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false); const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
const { token } = theme.useToken(); const intl = useIntl();
const { getApi } = useModel('getTrip'); const { getApi } = useModel('getTrip');
const checkHaulFinished = () => { const checkHaulFinished = () => {
return trips?.fishing_logs?.some((h) => h.status === 0); return trips?.fishing_logs?.some((h) => h.status === 0);
@@ -31,7 +31,7 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
const createNewHaul = async () => { const createNewHaul = async () => {
if (trips?.fishing_logs?.some((f) => f.status === 0)) { if (trips?.fishing_logs?.some((f) => f.status === 0)) {
message.warning( 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', intl.formatMessage({ id: 'trip.warning.haulNotFinished' }),
); );
return; return;
} }
@@ -45,10 +45,10 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
start_at: new Date(), start_at: new Date(),
start_lat: gpsData.lat, start_lat: gpsData.lat,
start_lon: gpsData.lon, start_lon: gpsData.lon,
weather_description: 'Nắng đẹp', weather_description: intl.formatMessage({ id: 'trip.weather' }),
}; };
const resp = await startNewHaul(body); await startNewHaul(body);
onCallBack?.(STATUS.CREATE_FISHING_LOG_SUCCESS); onCallBack?.(STATUS.CREATE_FISHING_LOG_SUCCESS);
getApi(); getApi();
} catch (error) { } catch (error) {
@@ -59,11 +59,11 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
const handleStartTrip = async (state: number, note?: string) => { const handleStartTrip = async (state: number, note?: string) => {
if (trips?.trip_status !== 2) { if (trips?.trip_status !== 2) {
message.warning('Chuyến đi đã được bắt đầu hoặc hoàn thành.'); message.warning(intl.formatMessage({ id: 'trip.warning.tripStarted' }));
return; return;
} }
try { try {
const resp = await updateTripState({ status: state, note: note || '' }); await updateTripState({ status: state, note: note || '' });
onCallBack?.(STATUS.START_TRIP_SUCCESS); onCallBack?.(STATUS.START_TRIP_SUCCESS);
getApi(); getApi();
} catch (error) { } catch (error) {
@@ -78,17 +78,19 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
} }
return ( return (
<div style={{ <div
padding: screens.sm ? '0px': '10px', style={{
marginRight: screens.sm ? '24px': '0px', padding: screens.sm ? '0px' : '10px',
}}> marginRight: screens.sm ? '24px' : '0px',
}}
>
{trips?.trip_status === 2 ? ( {trips?.trip_status === 2 ? (
<Button <Button
color="green" color="green"
variant="solid" variant="solid"
onClick={async () => handleStartTrip(3)} onClick={async () => handleStartTrip(3)}
> >
Bắt đu chuyến đi {intl.formatMessage({ id: 'trip.startTrip' })}
</Button> </Button>
) : checkHaulFinished() ? ( ) : checkHaulFinished() ? (
<Button <Button
@@ -100,7 +102,7 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
color="geekblue" color="geekblue"
variant="solid" variant="solid"
> >
Kết thúc mẻ lưới {intl.formatMessage({ id: 'trip.finishHaul' })}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -112,7 +114,7 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
color="cyan" color="cyan"
variant="solid" variant="solid"
> >
Bắt đu mẻ lưới {intl.formatMessage({ id: 'trip.startHaul' })}
</Button> </Button>
)} )}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
import { getGPS } from '@/services/controller/DeviceController'; import { getGPS } from '@/services/controller/DeviceController';
import { import {
getFishSpecies, getFishSpecies,
@@ -35,6 +36,15 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
>([]); >([]);
const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]); const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
const [fishDatas, setFishDatas] = useState<API.FishSpeciesResponse[]>([]); const [fishDatas, setFishDatas] = useState<API.FishSpeciesResponse[]>([]);
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);
}
};
useEffect(() => { useEffect(() => {
getAllFish(); getAllFish();
if (isOpen) { if (isOpen) {
@@ -57,15 +67,6 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
} }
}, [isOpen, fishingLogs]); }, [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>[] = [ const columns: ProColumns<FishingLogInfoWithKey>[] = [
{ {
title: 'Tên cá', title: 'Tên cá',
@@ -163,7 +164,7 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
try { try {
const gpsData = await getGPS(); const gpsData = await getGPS();
if (gpsData) { if (gpsData) {
if (isFinished == false) { if (isFinished === false) {
// Tạo mẻ mới // Tạo mẻ mới
const logStatus0 = trip.fishing_logs?.find((log) => log.status === 0); const logStatus0 = trip.fishing_logs?.find((log) => log.status === 0);
console.log('ok', logStatus0); console.log('ok', logStatus0);

View File

@@ -1,5 +1,6 @@
import { ProCard, ProColumns, ProTable } from '@ant-design/pro-components'; import { ProCard, ProColumns, ProTable } from '@ant-design/pro-components';
import { Form, Modal } from 'antd'; import { useIntl } from '@umijs/max';
import { Modal } from 'antd';
interface HaulFishListProp { interface HaulFishListProp {
open: boolean; open: boolean;
@@ -12,48 +13,48 @@ const HaulFishList: React.FC<HaulFishListProp> = ({
onOpenChange, onOpenChange,
fishList, fishList,
}) => { }) => {
const [form] = Form.useForm(); const intl = useIntl();
const fish_columns: ProColumns<API.FishingLogInfo>[] = [ const fish_columns: ProColumns<API.FishingLogInfo>[] = [
{ {
title: 'Tên cá', title: intl.formatMessage({ id: 'trip.haulFishList.fishName' }),
dataIndex: 'fish_name', dataIndex: 'fish_name',
key: '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 render: (value) => value,
}, },
{ {
title: 'Trạng thái', title: intl.formatMessage({ id: 'trip.haulFishList.fishCondition' }),
dataIndex: 'fish_condition', dataIndex: 'fish_condition',
key: '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 render: (value) => value,
}, },
{ {
title: 'Độ hiếm', title: intl.formatMessage({ id: 'trip.haulFishList.fishRarity' }),
dataIndex: 'fish_rarity', dataIndex: 'fish_rarity',
key: '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 render: (value) => value,
}, },
{ {
title: 'Kích thước (cm)', title: intl.formatMessage({ id: 'trip.haulFishList.fishSize' }),
dataIndex: 'fish_size', dataIndex: 'fish_size',
key: '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 render: (value) => value,
}, },
{ {
title: 'Cân nặng (kg)', title: intl.formatMessage({ id: 'trip.haulFishList.weight' }),
dataIndex: 'catch_number', dataIndex: 'catch_number',
key: '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 render: (value) => value,
}, },
{ {
title: 'Ngư cụ sử dụng', title: intl.formatMessage({ id: 'trip.haulFishList.gearUsage' }),
dataIndex: 'gear_usage', dataIndex: 'gear_usage',
key: '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 render: (value) => value,
}, },
]; ];
return ( return (
<Modal <Modal
title="Danh sách cá" title={intl.formatMessage({ id: 'trip.haulFishList.title' })}
open={open} open={open}
footer={null} footer={null}
closable closable

View File

@@ -11,7 +11,7 @@ export interface HaulTableProps {
trip?: API.Trip; trip?: API.Trip;
onReload?: (isTrue: boolean) => void; onReload?: (isTrue: boolean) => void;
} }
const HaulTable: React.FC<HaulTableProps> = ({ hauls, trip, onReload }) => { const HaulTable: React.FC<HaulTableProps> = ({ hauls, trip }) => {
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [editFishingLogOpen, setEditFishingLogOpen] = useState(false); const [editFishingLogOpen, setEditFishingLogOpen] = useState(false);
const [currentRow, setCurrentRow] = useState<API.FishingLogInfo[]>([]); const [currentRow, setCurrentRow] = useState<API.FishingLogInfo[]>([]);
@@ -19,53 +19,67 @@ const HaulTable: React.FC<HaulTableProps> = ({ hauls, trip, onReload }) => {
useState<API.FishingLog | null>(null); useState<API.FishingLog | null>(null);
const intl = useIntl(); const intl = useIntl();
const { getApi } = useModel('getTrip'); const { getApi } = useModel('getTrip');
console.log('HaulTable received hauls:', hauls);
const fishing_logs_columns: ProColumns<API.FishingLog>[] = [ const fishing_logs_columns: ProColumns<API.FishingLog>[] = [
{ {
title: <div style={{ textAlign: 'center' }}>STT</div>, title: (
<div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.haulTable.no' })}
</div>
),
dataIndex: 'fishing_log_id', dataIndex: 'fishing_log_id',
align: 'center', align: 'center',
render: (_, __, index, action) => { render: (_, __, index) => {
return `Mẻ ${hauls.length - index}`; return `${intl.formatMessage({ id: 'trip.haulTable.haul' })} ${
hauls.length - index
}`;
}, },
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Trạng Thái</div>, title: (
dataIndex: ['status'], // 👈 lấy từ status 1: đang <div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.haulTable.status' })}
</div>
),
dataIndex: ['status'],
align: 'center', align: 'center',
valueEnum: { valueEnum: {
0: { 0: {
text: intl.formatMessage({ text: intl.formatMessage({
id: 'pages.trips.status.fishing', id: 'trip.haulTable.fishing',
defaultMessage: 'Đang đánh bắt',
}), }),
status: 'Processing', status: 'Processing',
}, },
1: { 1: {
text: intl.formatMessage({ text: intl.formatMessage({
id: 'pages.trips.status.end_fishing', id: 'trip.haulTable.endFishing',
defaultMessage: 'Đã hoàn thành',
}), }),
status: 'Success', status: 'Success',
}, },
2: { 2: {
text: intl.formatMessage({ text: intl.formatMessage({
id: 'pages.trips.status.cancel_fishing', id: 'trip.haulTable.cancelFishing',
defaultMessage: 'Đã huỷ',
}), }),
status: 'default', status: 'default',
}, },
}, },
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Thời tiết</div>, title: (
dataIndex: ['weather_description'], // 👈 lấy từ weather <div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.haulTable.weather' })}
</div>
),
dataIndex: ['weather_description'],
align: 'center', align: 'center',
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Thời điểm bắt đu</div>, title: (
dataIndex: ['start_at'], // birth_date là date of birth <div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.haulTable.startTime' })}
</div>
),
dataIndex: ['start_at'],
align: 'center', align: 'center',
render: (start_at: any) => { render: (start_at: any) => {
if (!start_at) return '-'; if (!start_at) return '-';
@@ -82,12 +96,15 @@ const HaulTable: React.FC<HaulTableProps> = ({ hauls, trip, onReload }) => {
}, },
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Thời điểm kết thúc</div>, title: (
dataIndex: ['end_at'], // birth_date là date of birth <div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.haulTable.endTime' })}
</div>
),
dataIndex: ['end_at'],
align: 'center', align: 'center',
render: (end_at: any) => { render: (end_at: any) => {
// console.log('End at value:', end_at); if (end_at === '0001-01-01T00:00:00Z') return '-';
if (end_at == '0001-01-01T00:00:00Z') return '-';
const date = new Date(end_at); const date = new Date(end_at);
return date.toLocaleString('vi-VN', { return date.toLocaleString('vi-VN', {
day: '2-digit', day: '2-digit',
@@ -101,7 +118,7 @@ const HaulTable: React.FC<HaulTableProps> = ({ hauls, trip, onReload }) => {
}, },
}, },
{ {
title: 'Thao tác', title: intl.formatMessage({ id: 'trip.haulTable.action' }),
align: 'center', align: 'center',
hideInSearch: true, hideInSearch: true,
render: (_, record) => { render: (_, record) => {
@@ -118,7 +135,9 @@ const HaulTable: React.FC<HaulTableProps> = ({ hauls, trip, onReload }) => {
setCurrentRow(record.info!); // record là dòng hiện tại trong table setCurrentRow(record.info!); // record là dòng hiện tại trong table
setEditOpen(true); setEditOpen(true);
} else { } else {
message.warning('Không có dữ liệu cá trong mẻ lưới này'); message.warning(
intl.formatMessage({ id: 'trip.haulFishList.noData' }),
);
} }
}} }}
/> />
@@ -169,10 +188,14 @@ const HaulTable: React.FC<HaulTableProps> = ({ hauls, trip, onReload }) => {
onOpenChange={setEditFishingLogOpen} onOpenChange={setEditFishingLogOpen}
onFinished={(success) => { onFinished={(success) => {
if (success) { if (success) {
message.success('Cập nhật mẻ lưới thành công'); message.success(
intl.formatMessage({ id: 'trip.haulTable.updateSuccess' }),
);
getApi(); getApi();
} else { } else {
message.error('Cập nhật mẻ lưới thất bại'); message.error(
intl.formatMessage({ id: 'trip.haulTable.updateError' }),
);
} }
}} }}
/> />

View File

@@ -1,5 +1,5 @@
import { ProCard } from '@ant-design/pro-components'; import { ProCard } from '@ant-design/pro-components';
import { useModel } from '@umijs/max'; import { useIntl, useModel } from '@umijs/max';
import { Flex, Grid } from 'antd'; import { Flex, Grid } from 'antd';
import HaulTable from './HaulTable'; import HaulTable from './HaulTable';
import TripCostTable from './TripCost'; import TripCostTable from './TripCost';
@@ -12,52 +12,17 @@ interface MainTripBodyProps {
onReload?: (isTrue: boolean) => void; onReload?: (isTrue: boolean) => void;
} }
const MainTripBody: React.FC<MainTripBodyProps> = ({ const MainTripBody: React.FC<MainTripBodyProps> = ({ tripInfo }) => {
trip_id,
tripInfo,
onReload,
}) => {
// console.log('MainTripBody received:');
// console.log("trip_id:", trip_id);
// console.log('tripInfo:', tripInfo);
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
const screens = useBreakpoint(); const screens = useBreakpoint();
const { data, getApi } = useModel('getTrip'); const { getApi } = useModel('getTrip');
const tripCosts = Array.isArray(tripInfo?.trip_cost) const tripCosts = Array.isArray(tripInfo?.trip_cost)
? tripInfo.trip_cost ? tripInfo.trip_cost
: []; : [];
const fishingGears = Array.isArray(tripInfo?.fishing_gears) const fishingGears = Array.isArray(tripInfo?.fishing_gears)
? tripInfo.fishing_gears ? tripInfo.fishing_gears
: []; : [];
const intl = useIntl();
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 ( return (
<Flex gap={10}> <Flex gap={10}>
<ProCard <ProCard
@@ -93,9 +58,8 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
// borderBottom: '1px solid #f0f0f0',
}} }}
title="Chi phí chuyến đi" title={intl.formatMessage({ id: 'trip.mainBody.tripCost' })}
style={{ minHeight: 300 }} style={{ minHeight: 300 }}
> >
<TripCostTable tripCosts={tripCosts} /> <TripCostTable tripCosts={tripCosts} />
@@ -111,7 +75,7 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
alignItems: 'center', alignItems: 'center',
borderBottom: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0',
}} }}
title="Danh sách ngư cụ" title={intl.formatMessage({ id: 'trip.mainBody.fishingGear' })}
style={{ minHeight: 300 }} style={{ minHeight: 300 }}
> >
<TripFishingGearTable fishingGears={fishingGears} /> <TripFishingGearTable fishingGears={fishingGears} />
@@ -129,7 +93,7 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}} }}
title="Danh sách thuyền viên" title={intl.formatMessage({ id: 'trip.mainBody.crew' })}
> >
<TripCrews crew={tripInfo?.crews} /> <TripCrews crew={tripInfo?.crews} />
</ProCard> </ProCard>
@@ -145,14 +109,13 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}} }}
title="Danh sách mẻ lưới" title={intl.formatMessage({ id: 'trip.mainBody.haulList' })}
> >
<HaulTable <HaulTable
trip={tripInfo!} trip={tripInfo!}
hauls={tripInfo?.fishing_logs || []} hauls={tripInfo?.fishing_logs || []}
onReload={(isTrue) => { onReload={(isTrue) => {
if (isTrue) { if (isTrue) {
// onReload?.(true);
getApi(); getApi();
} }
}} }}

View File

@@ -1,5 +1,5 @@
import { updateTripState } from '@/services/controller/TripController'; import { updateTripState } from '@/services/controller/TripController';
import { useModel } from '@umijs/max'; import { useIntl, useModel } from '@umijs/max';
import { Button, message, Popconfirm } from 'antd'; import { Button, message, Popconfirm } from 'antd';
import React from 'react'; import React from 'react';
import CancelTrip from './CancelTrip'; import CancelTrip from './CancelTrip';
@@ -14,15 +14,20 @@ const TripCancleOrFinishedButton: React.FC<TripCancleOrFinishedButtonProps> = ({
onCallBack, onCallBack,
}) => { }) => {
const { getApi } = useModel('getTrip'); const { getApi } = useModel('getTrip');
const intl = useIntl();
const handleClickButton = async (state: number, note?: string) => { const handleClickButton = async (state: number, note?: string) => {
try { try {
const resp = await updateTripState({ status: state, note: note || '' }); await updateTripState({ status: state, note: note || '' });
message.success('Cập nhật trạng thái thành công'); message.success(
intl.formatMessage({ id: 'trip.finishButton.updateSuccess' }),
);
getApi(); getApi();
onCallBack?.(true); onCallBack?.(true);
} catch (error) { } catch (error) {
console.error('Error updating trip status:', error); console.error('Error updating trip status:', error);
message.error('Cập nhật trạng thái thất bại'); message.error(
intl.formatMessage({ id: 'trip.finishButton.updateError' }),
);
onCallBack?.(false); onCallBack?.(false);
} }
}; };
@@ -37,14 +42,22 @@ const TripCancleOrFinishedButton: React.FC<TripCancleOrFinishedButtonProps> = ({
}} }}
/> />
<Popconfirm <Popconfirm
title="Thông báo" title={intl.formatMessage({
description="Bạn chắc chắn muốn kết thúc chuyến đi?" id: 'trip.finishButton.confirmTitle',
})}
description={intl.formatMessage({
id: 'trip.finishButton.confirmMessage',
})}
onConfirm={async () => handleClickButton(4)} onConfirm={async () => handleClickButton(4)}
okText="Chắc chắn" okText={intl.formatMessage({
cancelText="Không" id: 'trip.finishButton.confirmYes',
})}
cancelText={intl.formatMessage({
id: 'trip.finishButton.confirmNo',
})}
> >
<Button color="orange" variant="solid"> <Button color="orange" variant="solid">
Kết thúc {intl.formatMessage({ id: 'trip.finishButton.end' })}
</Button> </Button>
</Popconfirm> </Popconfirm>
</div> </div>

View File

@@ -1,11 +1,13 @@
import { ProColumns, ProTable } from '@ant-design/pro-components'; import { ProColumns, ProTable } from '@ant-design/pro-components';
import { Grid, Typography } from 'antd'; import { useIntl } from '@umijs/max';
import { Typography } from 'antd';
interface TripCostTableProps { interface TripCostTableProps {
tripCosts: API.TripCost[]; tripCosts: API.TripCost[];
} }
const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => { const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
const intl = useIntl();
// Tính tổng chi phí // Tính tổng chi phí
const total_trip_cost = tripCosts.reduce( const total_trip_cost = tripCosts.reduce(
(sum, item) => sum + (Number(item.total_cost) || 0), (sum, item) => sum + (Number(item.total_cost) || 0),
@@ -13,34 +15,58 @@ const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
); );
const trip_cost_columns: ProColumns<API.TripCost>[] = [ const trip_cost_columns: ProColumns<API.TripCost>[] = [
{ {
title: <div style={{ textAlign: 'center' }}>Loại</div>, title: (
<div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.cost.type' })}
</div>
),
dataIndex: 'type', dataIndex: 'type',
valueEnum: { valueEnum: {
fuel: { text: 'Nhiên liệu' }, fuel: { text: intl.formatMessage({ id: 'trip.cost.fuel' }) },
crew_salary: { text: 'Lương thuyền viên' }, crew_salary: {
food: { text: 'Lương thực' }, text: intl.formatMessage({ id: 'trip.cost.crewSalary' }),
ice_salt_cost: { text: 'Muối đá' }, },
food: { text: intl.formatMessage({ id: 'trip.cost.food' }) },
ice_salt_cost: {
text: intl.formatMessage({ id: 'trip.cost.iceSalt' }),
},
}, },
align: 'center', align: 'center',
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Số lượng</div>, title: (
<div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.cost.amount' })}
</div>
),
dataIndex: 'amount', dataIndex: 'amount',
align: 'center', align: 'center',
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Đơn vị</div>, title: (
<div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.cost.unit' })}
</div>
),
dataIndex: 'unit', dataIndex: 'unit',
align: 'center', align: 'center',
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Chi phí</div>, title: (
<div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.cost.price' })}
</div>
),
dataIndex: 'cost_per_unit', dataIndex: 'cost_per_unit',
align: 'center', align: 'center',
render: (val: any) => (val ? Number(val).toLocaleString() : ''), render: (val: any) => (val ? Number(val).toLocaleString() : ''),
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Tổng chi phí</div>, title: (
<div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.cost.total' })}
</div>
),
dataIndex: 'total_cost', dataIndex: 'total_cost',
align: 'center', align: 'center',
render: (val: any) => render: (val: any) =>
@@ -51,8 +77,6 @@ const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
), ),
}, },
]; ];
const { useBreakpoint } = Grid;
const screens = useBreakpoint();
return ( return (
<ProTable<API.TripCost> <ProTable<API.TripCost>
columns={trip_cost_columns} columns={trip_cost_columns}
@@ -72,7 +96,7 @@ const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
<ProTable.Summary.Row> <ProTable.Summary.Row>
<ProTable.Summary.Cell index={0} colSpan={4} align="right"> <ProTable.Summary.Cell index={0} colSpan={4} align="right">
<Typography.Text strong style={{ color: '#1890ff' }}> <Typography.Text strong style={{ color: '#1890ff' }}>
Tổng cộng {intl.formatMessage({ id: 'trip.cost.grandTotal' })}
</Typography.Text> </Typography.Text>
</ProTable.Summary.Cell> </ProTable.Summary.Cell>
<ProTable.Summary.Cell index={4} align="center"> <ProTable.Summary.Cell index={4} align="center">

View File

@@ -1,4 +1,5 @@
import { ProColumns, ProTable } from '@ant-design/pro-components'; import { ProColumns, ProTable } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import React from 'react'; import React from 'react';
interface TripCrewsProps { interface TripCrewsProps {
@@ -6,57 +7,93 @@ interface TripCrewsProps {
} }
const TripCrews: React.FC<TripCrewsProps> = ({ crew }) => { const TripCrews: React.FC<TripCrewsProps> = ({ crew }) => {
console.log('TripCrews received crew:', crew); const intl = useIntl();
const crew_columns: ProColumns<API.TripCrews>[] = [ const crew_columns: ProColumns<API.TripCrews>[] = [
{ {
title: <div style={{ textAlign: 'center' }}> đnh danh</div>, title: (
dataIndex: ['Person', 'personal_id'], // 👈 lấy từ Person.personal_id <div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.crew.id' })}
</div>
),
dataIndex: ['Person', 'personal_id'],
align: 'center', align: 'center',
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Tên</div>, title: (
dataIndex: ['Person', 'name'], // 👈 lấy từ Person.name <div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.crew.name' })}
</div>
),
dataIndex: ['Person', 'name'],
align: 'center', align: 'center',
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Chức vụ</div>, title: (
<div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.crew.role' })}
</div>
),
dataIndex: 'role', dataIndex: 'role',
align: 'center', align: 'center',
render: (val: any) => { render: (val: any) => {
switch (val) { switch (val) {
case 'captain': case 'captain':
return <span style={{ color: 'blue' }}>Thuyền trưởng</span>; return (
<span style={{ color: 'blue' }}>
{intl.formatMessage({ id: 'trip.crew.captain' })}
</span>
);
case 'crew': case 'crew':
return <span style={{ color: 'green' }}>Thuyền viên</span>; return (
<span style={{ color: 'green' }}>
{intl.formatMessage({ id: 'trip.crew.member' })}
</span>
);
default: default:
return <span>{val}</span>; return <span>{val}</span>;
} }
}, },
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Email</div>, title: (
dataIndex: ['Person', 'email'], // 👈 lấy từ Person.email <div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.crew.email' })}
</div>
),
dataIndex: ['Person', 'email'],
align: 'center', align: 'center',
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Số điện thoại</div>, title: (
dataIndex: ['Person', 'phone'], // 👈 lấy từ Person.phone <div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.crew.phone' })}
</div>
),
dataIndex: ['Person', 'phone'],
align: 'center', align: 'center',
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Ngày sinh</div>, title: (
dataIndex: ['Person', 'birth_date'], // birth_date là date of birth <div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.crew.birthDate' })}
</div>
),
dataIndex: ['Person', 'birth_date'],
align: 'center', align: 'center',
render: (birth_date: any) => { render: (birth_date: any) => {
if (!birth_date) return '-'; if (!birth_date) return '-';
const date = new Date(birth_date); const date = new Date(birth_date);
return date.toLocaleDateString('vi-VN'); // 👉 tự động ra dd/mm/yyyy return date.toLocaleDateString('vi-VN');
}, },
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Đa chỉ</div>, title: (
dataIndex: ['Person', 'address'], // 👈 lấy từ Person.address <div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.crew.address' })}
</div>
),
dataIndex: ['Person', 'address'],
align: 'center', align: 'center',
}, },
]; ];
@@ -71,7 +108,11 @@ const TripCrews: React.FC<TripCrewsProps> = ({ crew }) => {
pagination={{ pagination={{
pageSize: 5, pageSize: 5,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`, showTotal: (total, range) =>
intl.formatMessage(
{ id: 'pagination.total' },
{ start: range[0], end: range[1], total },
),
}} }}
options={false} options={false}
bordered bordered

View File

@@ -1,4 +1,5 @@
import { ProColumns, ProTable } from '@ant-design/pro-components'; import { ProColumns, ProTable } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import React from 'react'; import React from 'react';
interface TripFishingGearTableProps { interface TripFishingGearTableProps {
@@ -8,15 +9,24 @@ interface TripFishingGearTableProps {
const TripFishingGearTable: React.FC<TripFishingGearTableProps> = ({ const TripFishingGearTable: React.FC<TripFishingGearTableProps> = ({
fishingGears, fishingGears,
}) => { }) => {
const intl = useIntl();
const fishing_gears_columns: ProColumns<API.FishingGear>[] = [ const fishing_gears_columns: ProColumns<API.FishingGear>[] = [
{ {
title: <div style={{ textAlign: 'center' }}>Tên</div>, title: (
<div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.gear.name' })}
</div>
),
dataIndex: 'name', dataIndex: 'name',
valueType: 'select', valueType: 'select',
align: 'center', align: 'center',
}, },
{ {
title: <div style={{ textAlign: 'center' }}>Số lượng</div>, title: (
<div style={{ textAlign: 'center' }}>
{intl.formatMessage({ id: 'trip.gear.quantity' })}
</div>
),
dataIndex: 'number', dataIndex: 'number',
align: 'center', align: 'center',
}, },
@@ -30,10 +40,14 @@ const TripFishingGearTable: React.FC<TripFishingGearTableProps> = ({
pagination={{ pagination={{
pageSize: 5, pageSize: 5,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`, showTotal: (total, range) =>
intl.formatMessage(
{ id: 'pagination.total' },
{ start: range[0], end: range[1], total },
),
}} }}
options={false} options={false}
// bordered // bordered
size="middle" size="middle"
scroll={{ x: '100%' }} scroll={{ x: '100%' }}
style={{ flex: 1 }} style={{ flex: 1 }}

View File

@@ -23,7 +23,7 @@ const DetailTrip = () => {
try { try {
setIsLoading(true); setIsLoading(true);
const resp: API.AlarmResponse = await queryAlarms(); const resp: API.AlarmResponse = await queryAlarms();
if (resp.alarms.length == 0) { if (resp.alarms.length === 0) {
setShowAlarmList(false); setShowAlarmList(false);
} else { } else {
setAlarmList(resp.alarms); setAlarmList(resp.alarms);
@@ -50,7 +50,9 @@ const DetailTrip = () => {
header={{ header={{
title: ( title: (
<div style={{ marginLeft: screens.md ? '24px' : '10px' }}> <div style={{ marginLeft: screens.md ? '24px' : '10px' }}>
{data ? data.name : 'Chuyến đi'} {data
? data.name
: intl.formatMessage({ id: 'trip.detail.defaultTitle' })}
</div> </div>
), ),
tags: <BadgeTripStatus status={data?.trip_status || 0} />, tags: <BadgeTripStatus status={data?.trip_status || 0} />,
@@ -60,29 +62,39 @@ const DetailTrip = () => {
ghost ghost
extra={[ extra={[
<CreateNewHaulOrTrip <CreateNewHaulOrTrip
key={'lkadjaskd'}
trips={data || undefined} trips={data || undefined}
onCallBack={async (success) => { onCallBack={async (success) => {
switch (success) { switch (success) {
case STATUS.CREATE_FISHING_LOG_SUCCESS: case STATUS.CREATE_FISHING_LOG_SUCCESS:
message.success('Tạo mẻ lưới thành công'); message.success(
// await fetchTrip(); intl.formatMessage({ id: 'trip.detail.createHaulSuccess' }),
);
break; break;
case STATUS.CREATE_FISHING_LOG_FAIL: case STATUS.CREATE_FISHING_LOG_FAIL:
message.error('Tạo mẻ lưới thất bại'); message.error(
intl.formatMessage({ id: 'trip.detail.createHaulError' }),
);
break; break;
case STATUS.START_TRIP_SUCCESS: case STATUS.START_TRIP_SUCCESS:
message.success('Bắt đầu chuyến đi thành công'); message.success(
// await fetchTrip(); intl.formatMessage({ id: 'trip.detail.startTripSuccess' }),
);
break; break;
case STATUS.START_TRIP_FAIL: case STATUS.START_TRIP_FAIL:
message.error('Bắt đầu chuyến đi thất bại'); message.error(
intl.formatMessage({ id: 'trip.detail.startTripError' }),
);
break; break;
case STATUS.UPDATE_FISHING_LOG_SUCCESS: case STATUS.UPDATE_FISHING_LOG_SUCCESS:
message.success('Cập nhật mẻ lưới thành công'); message.success(
// await fetchTrip(); intl.formatMessage({ id: 'trip.detail.updateHaulSuccess' }),
);
break; break;
case STATUS.UPDATE_FISHING_LOG_FAIL: case STATUS.UPDATE_FISHING_LOG_FAIL:
message.error('Cập nhật mẻ lưới thất bại'); message.error(
intl.formatMessage({ id: 'trip.detail.updateHaulError' }),
);
break; break;
default: default:
break; break;
@@ -100,13 +112,11 @@ const DetailTrip = () => {
{showAlarmList ? ( {showAlarmList ? (
<ProCard <ProCard
title={intl.formatMessage({ title={intl.formatMessage({
id: 'pages.gmsv5.alarm.list', id: 'trip.detail.alarm',
defaultMessage: 'Cảnh báo',
})} })}
colSpan={{ xs: 24, md: 24, lg: 5 }} colSpan={{ xs: 24, md: 24, lg: 5 }}
bodyStyle={{ paddingInline: 0, paddingBlock: 0 }} bodyStyle={{ paddingInline: 0, paddingBlock: 0 }}
bordered bordered
// style={{ borderBlockEnd: 'none'}}
> >
{data ? ( {data ? (
<AlarmTable alarmList={alarmList} isLoading={isLoading} /> <AlarmTable alarmList={alarmList} isLoading={isLoading} />

View File

@@ -178,7 +178,7 @@ declare namespace API {
} }
interface FishingLog { interface FishingLog {
fishing_log_id: string; fishing_log_id?: string;
trip_id: string; trip_id: string;
start_at: Date; // ISO datetime start_at: Date; // ISO datetime
end_at: Date; // ISO datetime end_at: Date; // ISO datetime

View File

@@ -41,7 +41,7 @@ export const tinhGeoLayer = createGeoJSONLayer({
}); });
export const getShipIcon = (type: number, isFishing: boolean) => { export const getShipIcon = (type: number, isFishing: boolean) => {
console.log('type, isFishing', type, isFishing); // console.log('type, isFishing', type, isFishing);
if (type === 1 && !isFishing) { if (type === 1 && !isFishing) {
return shipWarningIcon; return shipWarningIcon;

4
src/typings.d.ts vendored Normal file
View File

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

5
typings.d.ts vendored
View File

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