Compare commits
14 Commits
b44f1a9b29
...
device_sgw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa43e72b48 | ||
|
|
4d4463a517 | ||
|
|
38c9639fcc | ||
|
|
46aaf67a71 | ||
|
|
216e865ca5 | ||
|
|
dea435a4ec | ||
| eed98f7c29 | |||
|
|
92eb505cfc | ||
|
|
ff66a95bc5 | ||
|
|
cd8332a7ef | ||
|
|
65f9468bbd | ||
|
|
28739ddcd9 | ||
|
|
53dfb861dd | ||
|
|
076d0460cb |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@
|
|||||||
/.mfsu
|
/.mfsu
|
||||||
.swc
|
.swc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
claude_code_zai_env.sh
|
||||||
30
.umirc.ts
30
.umirc.ts
@@ -1,8 +1,11 @@
|
|||||||
import { defineConfig } from '@umijs/max';
|
import { defineConfig } from '@umijs/max';
|
||||||
import proxy from './config/proxy';
|
import proxyDev from './config/proxy_dev';
|
||||||
const { REACT_APP_ENV = 'dev' } = process.env as {
|
import proxyProd from './config/proxy_prod';
|
||||||
REACT_APP_ENV: 'dev' | 'test' | 'prod';
|
const envConfig = process.env as { REACT_APP_ENV?: 'dev' | 'test' | 'prod' };
|
||||||
};
|
const rawEnv = envConfig.REACT_APP_ENV;
|
||||||
|
const isProdBuild = process.env.NODE_ENV === 'production';
|
||||||
|
const resolvedEnv = isProdBuild && !rawEnv ? 'prod' : rawEnv || 'dev';
|
||||||
|
const proxyConfig = isProdBuild ? proxyProd : proxyDev;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
antd: {},
|
antd: {},
|
||||||
@@ -12,15 +15,24 @@ export default defineConfig({
|
|||||||
request: {},
|
request: {},
|
||||||
locale: {
|
locale: {
|
||||||
default: 'vi-VN',
|
default: 'vi-VN',
|
||||||
|
baseNavigator: false,
|
||||||
|
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: {
|
||||||
title: '2025 Sản phẩm của Mobifone v1.0',
|
title: '2025 Sản phẩm của Mobifone v1.0',
|
||||||
},
|
},
|
||||||
proxy: proxy[REACT_APP_ENV],
|
proxy: proxyConfig[resolvedEnv],
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
name: 'Login',
|
title: 'Login',
|
||||||
path: '/login',
|
path: '/login',
|
||||||
component: './Auth',
|
component: './Auth',
|
||||||
layout: false,
|
layout: false,
|
||||||
@@ -30,13 +42,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',
|
||||||
@@ -44,5 +56,5 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
|
|
||||||
npmClient: 'pnpm',
|
npmClient: 'pnpm',
|
||||||
tailwindcss: {},
|
tailwindcss: {}, // Temporarily disabled
|
||||||
});
|
});
|
||||||
|
|||||||
323
CLAUDE.md
Normal file
323
CLAUDE.md
Normal 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
|
||||||
39
DYNAMIC_API_CONFIG.md
Normal file
39
DYNAMIC_API_CONFIG.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Cấu hình API động theo IP thiết bị
|
||||||
|
|
||||||
|
## Cách hoạt động
|
||||||
|
|
||||||
|
1. **ApiConfigService** tự động detect IP từ `window.location.hostname`
|
||||||
|
2. **Request interceptor** tự động thêm base URL vào mọi request
|
||||||
|
3. Không cần cấu hình proxy phức tạp nữa
|
||||||
|
|
||||||
|
## Cách deploy trên nhiều thiết bị
|
||||||
|
|
||||||
|
### 1. Build ứng dụng
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Deploy lên từng thiết bị
|
||||||
|
|
||||||
|
- Copy folder `dist` lên thiết bị
|
||||||
|
- Serve static files (nginx, apache, hoặc simple HTTP server)
|
||||||
|
|
||||||
|
### 3. Truy cập từ thiết bị
|
||||||
|
|
||||||
|
- Nếu thiết bị có IP `192.168.1.100`, truy cập: `http://192.168.1.100`
|
||||||
|
- Ứng dụng sẽ tự động gọi API đến `http://192.168.1.100:81`
|
||||||
|
|
||||||
|
## Ví dụ các thiết bị
|
||||||
|
|
||||||
|
| Thiết bị | IP | URL truy cập | API endpoint |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Device 1 | 192.168.1.100 | http://192.168.1.100 | http://192.168.1.100:81/api/* |
|
||||||
|
| Device 2 | 192.168.1.101 | http://192.168.1.101 | http://192.168.1.101:81/api/* |
|
||||||
|
| Device 3 | 10.0.0.50 | http://10.0.0.50 | http://10.0.0.50:81/api/* |
|
||||||
|
|
||||||
|
## Lưu ý
|
||||||
|
|
||||||
|
- Backend API cần chạy trên port 81 của mỗi thiết bị
|
||||||
|
- Đảm bảo CORS được cấu hình đúng trên backend
|
||||||
|
- Nếu dùng domain, cần cấu hình HTTPS tương ứng
|
||||||
400
I18N_USAGE_GUIDE.md
Normal file
400
I18N_USAGE_GUIDE.md
Normal 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
|
||||||
35
README.md
35
README.md
@@ -1,3 +1,38 @@
|
|||||||
# README
|
# README
|
||||||
|
|
||||||
`@umijs/max` 模板项目,更多功能参考 [Umi Max 简介](https://umijs.org/docs/max/introduce)
|
`@umijs/max` 模板项目,更多功能参考 [Umi Max 简介](https://umijs.org/docs/max/introduce)
|
||||||
|
|
||||||
|
## Language Switching
|
||||||
|
|
||||||
|
- Lang toggle components (`src/components/switch/LangSwitches.tsx`, `src/components/LanguageSwitcher/index.tsx`) call Umi `setLocale(newLocale, false)` so content re-renders instantly without refreshing the page.
|
||||||
|
- Both components listen for the browser `languagechange` event to keep their UI state in sync when another part of the app switches languages.
|
||||||
|
- Strings are translated via `useTranslation`/`useIntl`; ensure new labels have entries in `src/locales/en-US.ts` and `src/locales/vi-VN.ts`.
|
||||||
|
|
||||||
|
## Proxy & Request Profiles
|
||||||
|
|
||||||
|
- Project uses two sets of configs under `config/`:
|
||||||
|
- `proxy.ts` + `Request.ts` for local development API targets.
|
||||||
|
- `proxy_prod.ts` + `request_prod.ts` for production-like targets.
|
||||||
|
- The runtime selects configs automatically:
|
||||||
|
- `pnpm run dev` / any dev server run ⇒ `NODE_ENV=development` ⇒ uses dev proxy/request.
|
||||||
|
- `pnpm run build` (and other production builds) ⇒ `NODE_ENV=production` ⇒ uses prod proxy/request.
|
||||||
|
- Override the environment by exporting `REACT_APP_ENV` before running commands (values: `dev`, `test`, `prod`). Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use test upstreams in a dev session
|
||||||
|
REACT_APP_ENV=test pnpm run dev
|
||||||
|
|
||||||
|
# Build with testing endpoints
|
||||||
|
NODE_ENV=production REACT_APP_ENV=test pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- `config/proxy_prod.ts` resolves targets dynamically based on the current hostname; adjust rules there if deployment hosts change.
|
||||||
|
- Request pipelines differ per mode:
|
||||||
|
- `config/request_dev.ts` keeps relative URLs so calls pass through the dev proxy setup (use when mocking/pointing to LAN services).
|
||||||
|
- `config/request_prod.ts` builds absolute URLs from `ApiConfigService` so static builds call the correct host without relying on the proxy.
|
||||||
|
|
||||||
|
## Verifying Builds
|
||||||
|
|
||||||
|
- After switching language, confirm UI updates immediately and check console for `languagechange` events.
|
||||||
|
- For proxy changes, inspect network requests in devtools to ensure they hit the expected environment.
|
||||||
|
- Run `pnpm run build && pnpm serve` (or your hosting preview) to validate prod settings before deploying.
|
||||||
|
|||||||
31
Task.md
Normal file
31
Task.md
Normal 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
|
||||||
|
|
||||||
1600
cauTrucDuAn.md
Normal file
1600
cauTrucDuAn.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
const proxy: Record<string, any> = {
|
const proxyDev: Record<string, any> = {
|
||||||
dev: {
|
dev: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://192.168.30.102:81',
|
target: 'http://192.168.30.103:81',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -21,4 +21,4 @@ const proxy: Record<string, any> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default proxy;
|
export default proxyDev;
|
||||||
59
config/proxy_prod.ts
Normal file
59
config/proxy_prod.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// Hàm lấy IP từ hostname hiện tại (chỉ hoạt động runtime)
|
||||||
|
const getCurrentIP = () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
// Nếu là localhost hoặc IP local, trả về IP mặc định
|
||||||
|
if (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname.startsWith('192.168.') ||
|
||||||
|
hostname.startsWith('10.')
|
||||||
|
) {
|
||||||
|
// console.log('Host name: ', hostname);
|
||||||
|
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
// Nếu là domain, có thể cần map sang IP tương ứng
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
return process.env.REACT_APP_API_HOST || '192.168.30.102'; // fallback từ env
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hàm tạo proxy config động
|
||||||
|
const createDynamicProxy = () => {
|
||||||
|
const currentIP = getCurrentIP();
|
||||||
|
const isLocalIP =
|
||||||
|
currentIP.startsWith('192.168.') ||
|
||||||
|
currentIP.startsWith('10.') ||
|
||||||
|
currentIP === 'localhost';
|
||||||
|
|
||||||
|
return {
|
||||||
|
dev: {
|
||||||
|
'/api': {
|
||||||
|
target: `http://${currentIP}:81`,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
'/test': {
|
||||||
|
target: isLocalIP
|
||||||
|
? `http://${currentIP}:81`
|
||||||
|
: 'https://test-sgw-device.gms.vn',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: !isLocalIP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prod: {
|
||||||
|
'/test': {
|
||||||
|
target: isLocalIP
|
||||||
|
? `http://${currentIP}:81`
|
||||||
|
: 'https://prod-sgw-device.gms.vn',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: !isLocalIP,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyProduct: Record<string, any> = createDynamicProxy();
|
||||||
|
|
||||||
|
export default proxyProduct;
|
||||||
@@ -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。',
|
||||||
@@ -47,7 +47,7 @@ export const handleRequestConfig: RequestConfig = {
|
|||||||
errorConfig: {
|
errorConfig: {
|
||||||
// Error throwing
|
// Error throwing
|
||||||
errorThrower: (res: any) => {
|
errorThrower: (res: any) => {
|
||||||
console.log('Response from backend:', res);
|
// console.log('Response from backend:', res);
|
||||||
const { success, data, errorCode, errorMessage, showType } = res;
|
const { success, data, errorCode, errorMessage, showType } = res;
|
||||||
if (!success) {
|
if (!success) {
|
||||||
const error: any = new Error(errorMessage);
|
const error: any = new Error(errorMessage);
|
||||||
105
config/request_prod.ts
Normal file
105
config/request_prod.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { ROUTE_LOGIN } from '@/constants';
|
||||||
|
import { apiConfig } from '@/services/ApiConfigService';
|
||||||
|
import { getToken, removeToken } from '@/utils/localStorageUtils';
|
||||||
|
import { history, RequestConfig } from '@umijs/max';
|
||||||
|
import { message } from 'antd';
|
||||||
|
|
||||||
|
const codeMessage = {
|
||||||
|
200: 'The server successfully returned the requested data。',
|
||||||
|
201: 'New or modified data succeeded。',
|
||||||
|
202: 'A request has been queued in the background (asynchronous task)。',
|
||||||
|
204: 'Data deleted successfully。',
|
||||||
|
400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
|
||||||
|
401: 'The user does not have permission (token, username, password is wrong) 。',
|
||||||
|
403: 'User is authorized, but access is prohibited。',
|
||||||
|
404: 'The request issued was for a non-existent record, the server did not operate。',
|
||||||
|
406: 'The requested format is not available。',
|
||||||
|
410: 'The requested resource is permanently deleted and will no longer be available。',
|
||||||
|
422: 'When creating an object, a validation error occurred。',
|
||||||
|
500: 'Server error, please check the server。',
|
||||||
|
502: 'Gateway error。',
|
||||||
|
503: 'Service unavailable, server temporarily overloaded or maintained。',
|
||||||
|
504: 'Gateway timeout。',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runtime configuration
|
||||||
|
export const handleRequestConfig: RequestConfig = {
|
||||||
|
// Unified request settings
|
||||||
|
timeout: 20000,
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
// Error handling: umi@3's error handling scheme.
|
||||||
|
errorConfig: {
|
||||||
|
// Error throwing
|
||||||
|
errorThrower: (res: any) => {
|
||||||
|
// console.log('Response from backend:', res);
|
||||||
|
const { success, data, errorCode, errorMessage, showType } = res;
|
||||||
|
if (!success) {
|
||||||
|
const error: any = new Error(errorMessage);
|
||||||
|
error.name = 'BizError';
|
||||||
|
error.info = { errorCode, errorMessage, showType, data };
|
||||||
|
throw error; // Throw custom error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Error catching and handling
|
||||||
|
errorHandler: (error: any) => {
|
||||||
|
if (error.response) {
|
||||||
|
const { status, statusText, data } = error.response;
|
||||||
|
|
||||||
|
// Ưu tiên: codeMessage → backend message → statusText
|
||||||
|
const errMsg =
|
||||||
|
codeMessage[status as keyof typeof codeMessage] ||
|
||||||
|
data?.message ||
|
||||||
|
statusText ||
|
||||||
|
'Unknown error';
|
||||||
|
|
||||||
|
message.error(`❌ ${status}: ${errMsg}`);
|
||||||
|
if (status === 401) {
|
||||||
|
removeToken();
|
||||||
|
history.push(ROUTE_LOGIN);
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
message.error('🚨 No response from server!');
|
||||||
|
} else {
|
||||||
|
message.error(`⚠️ Request setup error: ${error.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Request interceptors
|
||||||
|
requestInterceptors: [
|
||||||
|
(url: string, options: any) => {
|
||||||
|
// console.log('URL Request:', url, options);
|
||||||
|
|
||||||
|
// Nếu URL không phải absolute URL, thêm base URL
|
||||||
|
let finalUrl = url;
|
||||||
|
if (!url.startsWith('http')) {
|
||||||
|
const baseUrl = apiConfig.getApiBaseUrl();
|
||||||
|
finalUrl = `${baseUrl}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getToken();
|
||||||
|
return {
|
||||||
|
url: finalUrl,
|
||||||
|
options: {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
...(token ? { Authorization: `${token}` } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Unwrap data from backend response
|
||||||
|
// responseInterceptors: [
|
||||||
|
// (response) => {
|
||||||
|
// const res = response.data as ResponseStructure<any>;
|
||||||
|
// if (res && res.success) {
|
||||||
|
// // ✅ Trả ra data luôn thay vì cả object
|
||||||
|
// return res.data;
|
||||||
|
// }
|
||||||
|
// return response.data;
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
};
|
||||||
22439
package-lock.json
generated
Normal file
22439
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
78
src/app.tsx
78
src/app.tsx
@@ -1,12 +1,48 @@
|
|||||||
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 as devRequestConfig } from '../config/request_dev';
|
||||||
import logo from '../public/logo.png';
|
import { handleRequestConfig as prodRequestConfig } from '../config/request_prod';
|
||||||
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 +94,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 +102,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);
|
|
||||||
},
|
},
|
||||||
},
|
layout: 'top', // Thay đổi từ 'mix' sang 'top' để đảm bảo header hiển thị đúng
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dom}
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layout: 'mix',
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
removeToken();
|
removeToken();
|
||||||
history.push(ROUTE_LOGIN);
|
history.push(ROUTE_LOGIN);
|
||||||
@@ -140,4 +159,5 @@ export const layout: RunTimeLayoutConfig = (initialState) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const request = handleRequestConfig;
|
const isProdBuild = process.env.NODE_ENV === 'production';
|
||||||
|
export const request = isProdBuild ? prodRequestConfig : devRequestConfig;
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { DefaultFooter } from '@ant-design/pro-components';
|
|
||||||
|
|
||||||
|
import { DefaultFooter } from '@ant-design/pro-components';
|
||||||
|
import './style.less';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
return <DefaultFooter copyright="2025 Sản phẩm của Mobifone v1.0" />;
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DefaultFooter
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
copyright={intl.formatMessage({
|
||||||
|
id: 'common.footer.copyright',
|
||||||
|
defaultMessage: '2025 Sản phẩm của Mobifone v1.2.2',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Footer;
|
export default Footer;
|
||||||
|
|||||||
3
src/components/Footer/style.less
Normal file
3
src/components/Footer/style.less
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.ant-pro-global-footer-copyright {
|
||||||
|
color: white !important; /* hoặc mã màu bạn muốn */
|
||||||
|
}
|
||||||
93
src/components/LanguageSwitcher/index.tsx
Normal file
93
src/components/LanguageSwitcher/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { getLocale, setLocale, useIntl } from '@umijs/max';
|
||||||
|
import { Button, Dropdown, Space } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface LanguageSwitcherProps {
|
||||||
|
className?: string;
|
||||||
|
type?: 'dropdown' | 'button';
|
||||||
|
size?: 'small' | 'middle' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({
|
||||||
|
className,
|
||||||
|
type = 'dropdown',
|
||||||
|
size = 'middle',
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const languageItems = [
|
||||||
|
{
|
||||||
|
key: 'vi-VN',
|
||||||
|
label: intl.formatMessage({ id: 'common.vietnamese' }),
|
||||||
|
flag: '🇻🇳',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'en-US',
|
||||||
|
label: intl.formatMessage({ id: 'common.english' }),
|
||||||
|
flag: '🇺🇸',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleLanguageChange = (locale: string) => {
|
||||||
|
setLocale(locale, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentLanguage = () => {
|
||||||
|
const currentLocale = intl.locale || getLocale() || 'vi-VN';
|
||||||
|
return (
|
||||||
|
languageItems.find((item) => item.key === currentLocale) ||
|
||||||
|
languageItems[0]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownItems = languageItems.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<span>{item.flag}</span>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
onClick: () => handleLanguageChange(item.key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (type === 'button') {
|
||||||
|
const currentLang = getCurrentLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
onClick={() => {
|
||||||
|
const nextLocale = currentLang.key === 'vi-VN' ? 'en-US' : 'vi-VN';
|
||||||
|
handleLanguageChange(nextLocale);
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<span>{currentLang.flag}</span>
|
||||||
|
<span>{currentLang.label}</span>
|
||||||
|
</Space>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLang = getCurrentLanguage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: dropdownItems }}
|
||||||
|
placement="bottomRight"
|
||||||
|
trigger={['click']}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<Button size={size} type="text">
|
||||||
|
<Space>
|
||||||
|
<span>{currentLang.flag}</span>
|
||||||
|
<span>{currentLang.label}</span>
|
||||||
|
</Space>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LanguageSwitcher;
|
||||||
42
src/components/switch/LangSwitches.tsx
Normal file
42
src/components/switch/LangSwitches.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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-2xl"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LangSwitches;
|
||||||
@@ -6,3 +6,7 @@ export enum STATUS {
|
|||||||
UPDATE_FISHING_LOG_SUCCESS = 'UPDATE_FISHING_LOG_SUCCESS',
|
UPDATE_FISHING_LOG_SUCCESS = 'UPDATE_FISHING_LOG_SUCCESS',
|
||||||
UPDATE_FISHING_LOG_FAIL = 'UPDATE_FISHING_LOG_FAIL',
|
UPDATE_FISHING_LOG_FAIL = 'UPDATE_FISHING_LOG_FAIL',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ENTITY_TYPE_ENUM {
|
||||||
|
SOS_WARNING = '50:15',
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ export const ROUTE_TRIP = '/trip';
|
|||||||
|
|
||||||
// API Path Constants
|
// API Path Constants
|
||||||
export const API_PATH_LOGIN = '/api/agent/login';
|
export const API_PATH_LOGIN = '/api/agent/login';
|
||||||
export const API_PATH_ENTITIES = '/api/agent/entities';
|
export const API_PATH_ENTITIES = '/api/io/entities';
|
||||||
export const API_PATH_SHIP_INFO = '/api/sgw/shipinfo';
|
export const API_PATH_SHIP_INFO = '/api/sgw/shipinfo';
|
||||||
export const API_GET_ALL_LAYER = '/api/sgw/geojsonlist';
|
export const API_GET_ALL_LAYER = '/api/sgw/geojsonlist';
|
||||||
export const API_GET_LAYER_INFO = '/api/sgw/geojson';
|
export const API_GET_LAYER_INFO = '/api/sgw/geojson';
|
||||||
export const API_GET_TRIP = '/api/sgw/trip';
|
export const API_GET_TRIP = '/api/sgw/trip';
|
||||||
export const API_GET_ALARMS = '/api/agent/alarms';
|
export const API_GET_ALARMS = '/api/io/alarms';
|
||||||
export const API_UPDATE_TRIP_STATUS = '/api/sgw/tripState';
|
export const API_UPDATE_TRIP_STATUS = '/api/sgw/tripState';
|
||||||
export const API_HAUL_HANDLE = '/api/sgw/fishingLog';
|
export const API_HAUL_HANDLE = '/api/sgw/fishingLog';
|
||||||
export const API_GET_GPS = '/api/sgw/gps';
|
export const API_GET_GPS = '/api/sgw/gps';
|
||||||
|
|||||||
19
src/hooks/useRealtimeGps.ts
Normal file
19
src/hooks/useRealtimeGps.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// src/hooks/useRealtimeGps.ts
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { eventBus } from '@/utils/eventBus';
|
||||||
|
|
||||||
|
export default function useRealtimeGps() {
|
||||||
|
const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGpsUpdate = (data: API.GPSResonse) => {
|
||||||
|
setGpsData(data);
|
||||||
|
};
|
||||||
|
eventBus.on('gpsData:update', handleGpsUpdate);
|
||||||
|
|
||||||
|
// cleanup khi unmount
|
||||||
|
return () => eventBus.off('gpsData:update', handleGpsUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return gpsData;
|
||||||
|
}
|
||||||
76
src/hooks/useTranslation.ts
Normal file
76
src/hooks/useTranslation.ts
Normal 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
360
src/locales/en-US.ts
Normal 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.3',
|
||||||
|
|
||||||
|
// 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
359
src/locales/vi-VN.ts
Normal 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.3',
|
||||||
|
|
||||||
|
// 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!',
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@ export default function useAlarmModel() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await queryAlarms(); // đổi URL cho phù hợp
|
const res = await queryAlarms(); // đổi URL cho phù hợp
|
||||||
console.log('Alarm Data fetched:', res);
|
// console.log('Alarm Data fetched:', res);
|
||||||
|
|
||||||
setAlarmData(res || []);
|
setAlarmData(res || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,22 +1,43 @@
|
|||||||
|
// src/models/useGetGpsModel.ts
|
||||||
import { getGPS } from '@/services/controller/DeviceController';
|
import { getGPS } from '@/services/controller/DeviceController';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
|
import { eventBus } from '@/utils/eventBus';
|
||||||
|
|
||||||
export default function useGetGpsModel() {
|
export default function useGetGpsModel() {
|
||||||
const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null);
|
const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const getGPSData = useCallback(async () => {
|
const getGPSData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getGPS(); // đổi URL cho phù hợp
|
// Bypass cache để GPS luôn realtime, gọi trực tiếp API
|
||||||
console.log('GPS Data fetched:', res);
|
const data = await getGPS();
|
||||||
|
// console.log('GPS fetched from API:', data);
|
||||||
|
|
||||||
setGpsData(res || []);
|
setGpsData(data);
|
||||||
|
// Luôn emit event GPS để đảm bảo realtime
|
||||||
|
try {
|
||||||
|
// eventBus.emit('gpsData:update', data);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to emit gpsData:update event', e);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fetch gps data failed', err);
|
console.error('Fetch gps data failed', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ✅ Lắng nghe event cập nhật cache từ nơi khác (nếu có)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdate = (data: API.GPSResonse) => {
|
||||||
|
// console.log('GPS cache updated via eventBus');
|
||||||
|
setGpsData(data);
|
||||||
|
};
|
||||||
|
eventBus.on('gpsData:update', handleUpdate);
|
||||||
|
return () => eventBus.off('gpsData:update', handleUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gpsData,
|
gpsData,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import Footer from '@/components/Footer/Footer';
|
||||||
|
import LangSwitches from '@/components/switch/LangSwitches';
|
||||||
import { ROUTE_HOME } from '@/constants';
|
import { ROUTE_HOME } from '@/constants';
|
||||||
import { login } from '@/services/controller/AuthController';
|
import { login } from '@/services/controller/AuthController';
|
||||||
import { parseJwt } from '@/utils/jwtTokenUtils';
|
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, theme } from 'antd';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import logoImg from '../../../public/logo.png';
|
import logoImg from '../../../public/logo.png';
|
||||||
@@ -14,17 +16,14 @@ 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) {
|
||||||
@@ -38,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);
|
||||||
@@ -67,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 cá
|
{intl.formatMessage({
|
||||||
|
id: 'auth.login.subtitle',
|
||||||
|
defaultMessage: 'Sea Gateway',
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
containerStyle={{
|
containerStyle={{
|
||||||
@@ -77,7 +83,10 @@ 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)}
|
||||||
@@ -96,11 +105,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!',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -117,16 +132,36 @@ 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>
|
||||||
|
<div className="absolute top-5 right-5 z-50">
|
||||||
|
<LangSwitches />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 99,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,16 +7,21 @@ import {
|
|||||||
} from '@/utils/mapUtils';
|
} from '@/utils/mapUtils';
|
||||||
import { Feature, Map, View } from 'ol';
|
import { Feature, Map, View } from 'ol';
|
||||||
import { Coordinate } from 'ol/coordinate';
|
import { Coordinate } from 'ol/coordinate';
|
||||||
|
import { easeOut } from 'ol/easing';
|
||||||
|
import { EventsKey } from 'ol/events';
|
||||||
import { createEmpty, extend, Extent, isEmpty } from 'ol/extent';
|
import { createEmpty, extend, Extent, isEmpty } from 'ol/extent';
|
||||||
import { LineString, MultiPolygon, Point, Polygon } from 'ol/geom';
|
import { LineString, MultiPolygon, Point, Polygon } from 'ol/geom';
|
||||||
import BaseLayer from 'ol/layer/Base';
|
import BaseLayer from 'ol/layer/Base';
|
||||||
import VectorLayer from 'ol/layer/Vector';
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
|
import { unByKey } from 'ol/Observable';
|
||||||
import { fromLonLat } from 'ol/proj';
|
import { fromLonLat } from 'ol/proj';
|
||||||
|
import { getVectorContext } from 'ol/render';
|
||||||
|
import RenderEvent from 'ol/render/Event';
|
||||||
import VectorSource from 'ol/source/Vector';
|
import VectorSource from 'ol/source/Vector';
|
||||||
|
import { Circle as CircleStyle, Style } from 'ol/style';
|
||||||
import Fill from 'ol/style/Fill';
|
import Fill from 'ol/style/Fill';
|
||||||
import Icon from 'ol/style/Icon';
|
import Icon from 'ol/style/Icon';
|
||||||
import Stroke from 'ol/style/Stroke';
|
import Stroke from 'ol/style/Stroke';
|
||||||
import Style from 'ol/style/Style';
|
|
||||||
import Text from 'ol/style/Text';
|
import Text from 'ol/style/Text';
|
||||||
|
|
||||||
interface MapManagerConfig {
|
interface MapManagerConfig {
|
||||||
@@ -25,6 +30,12 @@ interface MapManagerConfig {
|
|||||||
onFeaturesClick?: (features: any[]) => void;
|
onFeaturesClick?: (features: any[]) => void;
|
||||||
onError?: (error: string[]) => void;
|
onError?: (error: string[]) => void;
|
||||||
}
|
}
|
||||||
|
interface AnimationConfig {
|
||||||
|
duration?: number;
|
||||||
|
maxRadius?: number;
|
||||||
|
strokeColor?: string;
|
||||||
|
strokeWidthBase?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface StyleConfig {
|
interface StyleConfig {
|
||||||
icon?: string; // URL của icon
|
icon?: string; // URL của icon
|
||||||
@@ -48,11 +59,6 @@ interface FeatureData {
|
|||||||
text?: string;
|
text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interface for layer object
|
|
||||||
interface Layer {
|
|
||||||
layer: VectorLayer<VectorSource>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MapManager {
|
class MapManager {
|
||||||
mapRef: React.RefObject<HTMLDivElement>;
|
mapRef: React.RefObject<HTMLDivElement>;
|
||||||
map: Map | null;
|
map: Map | null;
|
||||||
@@ -66,6 +72,8 @@ class MapManager {
|
|||||||
private errors: string[];
|
private errors: string[];
|
||||||
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;
|
||||||
|
shipFeature: any;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
mapRef: React.RefObject<HTMLDivElement>,
|
mapRef: React.RefObject<HTMLDivElement>,
|
||||||
@@ -83,11 +91,12 @@ class MapManager {
|
|||||||
this.errors = [];
|
this.errors = [];
|
||||||
this.layers = []; // Initialize layers (adjust based on actual usage)
|
this.layers = []; // Initialize layers (adjust based on actual usage)
|
||||||
this.isInitialized = false; // Khởi tạo là false
|
this.isInitialized = false; // Khởi tạo là false
|
||||||
|
this.eventKey = undefined; // Khởi tạo eventKey
|
||||||
}
|
}
|
||||||
|
|
||||||
async getListLayers(): Promise<[]> {
|
async getListLayers(): Promise<[]> {
|
||||||
const resp: [] = await getAllLayer();
|
const resp: [] = await getAllLayer();
|
||||||
console.log('resp', resp);
|
// console.log('resp', resp);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +108,15 @@ class MapManager {
|
|||||||
for (const layerMeta of listLayers) {
|
for (const layerMeta of listLayers) {
|
||||||
try {
|
try {
|
||||||
const data = await getLayer(layerMeta); // lấy GeoJSON từ server
|
const data = await getLayer(layerMeta); // lấy GeoJSON từ server
|
||||||
|
let style = {};
|
||||||
|
if (layerMeta === 'base-countries') {
|
||||||
|
style = createLabelAndFillStyle('#fafaf8', '#E5BEB5');
|
||||||
|
} else {
|
||||||
|
style = createLabelAndFillStyle('#77BEF0', '#000000');
|
||||||
|
}
|
||||||
const vectorLayer = createGeoJSONLayer({
|
const vectorLayer = createGeoJSONLayer({
|
||||||
data,
|
data,
|
||||||
style: createLabelAndFillStyle('#77BEF0', '#000000'),
|
style: style,
|
||||||
});
|
});
|
||||||
|
|
||||||
dynamicLayers.push(vectorLayer);
|
dynamicLayers.push(vectorLayer);
|
||||||
@@ -149,10 +163,10 @@ class MapManager {
|
|||||||
this.initZoomListener();
|
this.initZoomListener();
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
|
|
||||||
console.log(
|
// console.log(
|
||||||
'Map initialized successfully at',
|
// 'Map initialized successfully at',
|
||||||
new Date().toLocaleTimeString(),
|
// new Date().toLocaleTimeString(),
|
||||||
);
|
// );
|
||||||
|
|
||||||
this.map.on('singleclick', (evt: any) => {
|
this.map.on('singleclick', (evt: any) => {
|
||||||
const featuresAtPixel: {
|
const featuresAtPixel: {
|
||||||
@@ -174,12 +188,12 @@ class MapManager {
|
|||||||
if (this.features.includes(feature)) {
|
if (this.features.includes(feature)) {
|
||||||
this.onFeatureClick(feature);
|
this.onFeatureClick(feature);
|
||||||
this.onFeatureSelect(feature, evt.pixel);
|
this.onFeatureSelect(feature, evt.pixel);
|
||||||
console.log(
|
// console.log(
|
||||||
'Feature clicked at',
|
// 'Feature clicked at',
|
||||||
new Date().toLocaleTimeString(),
|
// new Date().toLocaleTimeString(),
|
||||||
':',
|
// ':',
|
||||||
feature.getProperties(),
|
// feature.getProperties(),
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.onFeaturesClick(featuresAtPixel.map((f) => f.feature));
|
this.onFeaturesClick(featuresAtPixel.map((f) => f.feature));
|
||||||
@@ -328,8 +342,10 @@ class MapManager {
|
|||||||
// Lưu feature
|
// Lưu feature
|
||||||
this.features.push(feature);
|
this.features.push(feature);
|
||||||
this.featureLayer?.getSource()?.addFeature(feature);
|
this.featureLayer?.getSource()?.addFeature(feature);
|
||||||
|
if (styleConfig.animate) {
|
||||||
console.log('Point added successfully:', { coord, data, styleConfig });
|
this.animatedMarker(feature, styleConfig.animationConfig);
|
||||||
|
}
|
||||||
|
// console.log('Point added successfully:', { coord, data, styleConfig });
|
||||||
|
|
||||||
return feature;
|
return feature;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -340,6 +356,74 @@ class MapManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
animatedMarker = (
|
||||||
|
feature: Feature<Point>,
|
||||||
|
config: AnimationConfig = {},
|
||||||
|
): void => {
|
||||||
|
const {
|
||||||
|
duration = 3000,
|
||||||
|
maxRadius = 20,
|
||||||
|
strokeColor = 'rgba(255, 0, 0, 0.8)',
|
||||||
|
strokeWidthBase = 0.25,
|
||||||
|
} = config;
|
||||||
|
// console.log('Starting animatedMarker with config:', config);
|
||||||
|
|
||||||
|
const flashGeom = feature.getGeometry()?.clone() as Point | undefined;
|
||||||
|
if (!flashGeom || !this.featureLayer) {
|
||||||
|
console.error('Invalid geometry or featureLayer for animation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo 2 "pha" sóng, mỗi sóng bắt đầu cách nhau duration/2
|
||||||
|
const waveCount = 1;
|
||||||
|
const waveOffsets = Array.from(
|
||||||
|
{ length: waveCount },
|
||||||
|
(_, i) => (duration / waveCount) * i,
|
||||||
|
);
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
const listenerKey: EventsKey = this.featureLayer.on(
|
||||||
|
'postrender',
|
||||||
|
(event: RenderEvent) => {
|
||||||
|
const frameState = event.frameState;
|
||||||
|
if (!frameState) return;
|
||||||
|
|
||||||
|
const elapsedTotal = frameState.time - start;
|
||||||
|
if (elapsedTotal >= duration * 3) {
|
||||||
|
// chạy 2 chu kỳ rồi dừng (bạn có thể bỏ điều kiện này để chạy mãi)
|
||||||
|
unByKey(listenerKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vectorContext = getVectorContext(event);
|
||||||
|
|
||||||
|
waveOffsets.forEach((offset) => {
|
||||||
|
const elapsed =
|
||||||
|
(frameState.time - start - offset + duration) % duration;
|
||||||
|
const elapsedRatio = elapsed / duration;
|
||||||
|
|
||||||
|
const radius = easeOut(elapsedRatio) * (maxRadius - 5) + 5;
|
||||||
|
const opacity = easeOut(1 - elapsedRatio);
|
||||||
|
|
||||||
|
const style = new Style({
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius,
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: strokeColor.replace('0.8', opacity.toString()),
|
||||||
|
width: strokeWidthBase + opacity,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vectorContext.setStyle(style);
|
||||||
|
vectorContext.drawGeometry(flashGeom);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.map!.render();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.eventKey = listenerKey;
|
||||||
|
};
|
||||||
|
|
||||||
addPolygon(
|
addPolygon(
|
||||||
coords: Coordinate[][],
|
coords: Coordinate[][],
|
||||||
@@ -510,12 +594,12 @@ class MapManager {
|
|||||||
if (this.featureLayer && this.isInitialized) {
|
if (this.featureLayer && this.isInitialized) {
|
||||||
this.features.push(feature);
|
this.features.push(feature);
|
||||||
this.featureLayer.getSource()?.addFeature(feature);
|
this.featureLayer.getSource()?.addFeature(feature);
|
||||||
console.log(
|
// console.log(
|
||||||
'LineString added successfully at',
|
// 'LineString added successfully at',
|
||||||
new Date().toLocaleTimeString(),
|
// new Date().toLocaleTimeString(),
|
||||||
':',
|
// ':',
|
||||||
{ coords, data, styleConfig },
|
// { coords, data, styleConfig },
|
||||||
);
|
// );
|
||||||
// Đảm bảo bản đồ được render lại
|
// Đảm bảo bản đồ được render lại
|
||||||
this.map?.render();
|
this.map?.render();
|
||||||
return feature;
|
return feature;
|
||||||
@@ -540,10 +624,10 @@ class MapManager {
|
|||||||
styleConfig: Record<string, any> = {},
|
styleConfig: Record<string, any> = {},
|
||||||
zoom: number,
|
zoom: number,
|
||||||
) => {
|
) => {
|
||||||
console.log(
|
// console.log(
|
||||||
`createPolygonStyle called with zoom: ${zoom}, styleConfig:`,
|
// `createPolygonStyle called with zoom: ${zoom}, styleConfig:`,
|
||||||
styleConfig,
|
// styleConfig,
|
||||||
); // Debug
|
// ); // Debug
|
||||||
const textContent = `Tên: ${data?.name ?? 'Chưa có'}\n\n Loại: ${
|
const textContent = `Tên: ${data?.name ?? 'Chưa có'}\n\n Loại: ${
|
||||||
data?.type ?? 0
|
data?.type ?? 0
|
||||||
}\n\nThông tin: ${data?.description ?? 'Chưa có'}`;
|
}\n\nThông tin: ${data?.description ?? 'Chưa có'}`;
|
||||||
@@ -581,7 +665,7 @@ class MapManager {
|
|||||||
text: textStyle,
|
text: textStyle,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Polygon style created:`, style); // Debug
|
// console.log(`Polygon style created:`, style); // Debug
|
||||||
return [style];
|
return [style];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
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}
|
||||||
@@ -15,37 +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 ? `${Number(record.lat).toFixed(5)}°` : '--',
|
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 ? `${Number(record.lon).toFixed(5)}°` : '--',
|
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) => `${record.s} km/h`,
|
render: (_, record) =>
|
||||||
|
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' })}`,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,42 @@
|
|||||||
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();
|
||||||
console.log('Ship Info Response:', resp);
|
// console.log('Ship Info Response:', resp);
|
||||||
return resp;
|
return resp;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching ship data:', error);
|
console.error('Error fetching ship data:', error);
|
||||||
@@ -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` : '--',
|
||||||
|
|||||||
@@ -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,11 +112,11 @@ 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) => {
|
||||||
console.log('Form Values: ', values);
|
// console.log('Form Values: ', values);
|
||||||
|
|
||||||
// Nếu chọn "Khác" thì lấy messageOther, ngược lại lấy message
|
// Nếu chọn "Khác" thì lấy messageOther, ngược lại lấy message
|
||||||
const finalMessage =
|
const finalMessage =
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const VietNamMap: React.FC<VietNamMapProps> = React.memo(
|
|||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('useEffect in VietNamMap triggered');
|
// console.log('useEffect in VietNamMap triggered');
|
||||||
let manager = mapManager;
|
let manager = mapManager;
|
||||||
|
|
||||||
if (!manager) {
|
if (!manager) {
|
||||||
@@ -38,10 +38,10 @@ const VietNamMap: React.FC<VietNamMapProps> = React.memo(
|
|||||||
manager!.mapRef = mapRef;
|
manager!.mapRef = mapRef;
|
||||||
if (!manager!.map) {
|
if (!manager!.map) {
|
||||||
manager!.initializeMap();
|
manager!.initializeMap();
|
||||||
console.log(
|
// console.log(
|
||||||
'Initialized new MapManager instance at',
|
// 'Initialized new MapManager instance at',
|
||||||
new Date().toLocaleTimeString(),
|
// new Date().toLocaleTimeString(),
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
} else if (retryCount < maxRetries) {
|
} else if (retryCount < maxRetries) {
|
||||||
console.error('mapRef.current is not ready, retrying...');
|
console.error('mapRef.current is not ready, retrying...');
|
||||||
@@ -55,7 +55,7 @@ const VietNamMap: React.FC<VietNamMapProps> = React.memo(
|
|||||||
initialize();
|
initialize();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('Cleanup in VietNamMap triggered');
|
// console.log('Cleanup in VietNamMap triggered');
|
||||||
// Không gọi destroy ở đây để tránh phá hủy bản đồ không cần thiết
|
// Không gọi destroy ở đây để tránh phá hủy bản đồ không cần thiết
|
||||||
};
|
};
|
||||||
}, [mapManager, onFeatureClick, onFeatureSelect, onFeaturesClick, onError]);
|
}, [mapManager, onFeatureClick, onFeatureSelect, onFeaturesClick, onError]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
MAP_TRACKPOINTS_ID,
|
MAP_TRACKPOINTS_ID,
|
||||||
ROUTE_TRIP,
|
ROUTE_TRIP,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
import { ENTITY_TYPE_ENUM } from '@/constants/enums';
|
||||||
import {
|
import {
|
||||||
queryAlarms,
|
queryAlarms,
|
||||||
queryEntities,
|
queryEntities,
|
||||||
@@ -11,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,
|
||||||
@@ -21,14 +23,32 @@ 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 { 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';
|
||||||
|
|
||||||
|
// // Define missing types locally for now
|
||||||
|
// interface AlarmData {
|
||||||
|
// level: number;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// interface EntityData {
|
||||||
|
// id: string;
|
||||||
|
// valueString: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// interface TrackPoint {
|
||||||
|
// lat: number;
|
||||||
|
// lon: number;
|
||||||
|
// }
|
||||||
|
|
||||||
export interface GpsData {
|
export interface GpsData {
|
||||||
lat: number;
|
lat: number;
|
||||||
lon: number;
|
lon: number;
|
||||||
@@ -40,29 +60,33 @@ const HomePage: React.FC = () => {
|
|||||||
const mapRef = useRef<HTMLDivElement>(null);
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
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 onFeatureClick = useCallback((feature: any) => {
|
const onFeatureClick = useCallback((feature: any) => {
|
||||||
console.log('OnClick Feature: ', feature);
|
console.log('OnClick Feature: ', feature);
|
||||||
console.log(
|
// console.log(
|
||||||
'Clicked ship at',
|
// 'Clicked ship at',
|
||||||
new Date().toLocaleTimeString(),
|
// new Date().toLocaleTimeString(),
|
||||||
'Properties:',
|
// 'Properties:',
|
||||||
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(
|
||||||
|
(error: any) => {
|
||||||
console.error(
|
console.error(
|
||||||
'Lỗi khi thêm vào map at',
|
intl.formatMessage({ id: 'home.mapError' }),
|
||||||
|
'at',
|
||||||
new Date().toLocaleTimeString(),
|
new Date().toLocaleTimeString(),
|
||||||
':',
|
':',
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
}, []);
|
},
|
||||||
|
[intl],
|
||||||
|
);
|
||||||
|
|
||||||
const mapManagerRef = useRef(
|
const mapManagerRef = useRef(
|
||||||
new MapManager(mapRef, {
|
new MapManager(mapRef, {
|
||||||
@@ -74,124 +98,75 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
const [isShowGPSData, setIsShowGPSData] = useState(true);
|
const [isShowGPSData, setIsShowGPSData] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
// Hàm tổng hợp: gọi GPS trước, sau đó gọi các API còn lại
|
||||||
getGPSData();
|
const refreshAllData = async () => {
|
||||||
const interval = setInterval(() => {
|
try {
|
||||||
getGPSData();
|
// Gọi GPS, khi xong sẽ emit event gpsData:update và các API khác sẽ tự động được gọi qua event handler
|
||||||
getEntitiesData();
|
await getGPSData();
|
||||||
getShipTrackPoints();
|
} catch (e) {
|
||||||
getEntityData();
|
console.error('refreshAllData error', e);
|
||||||
}, 5000);
|
}
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
console.log('MapManager destroyed in HomePage cleanup');
|
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Helper function to add features to the map (kept above pollers to avoid use-before-define)
|
||||||
const timer = setTimeout(() => {
|
function addFeatureToMap(
|
||||||
// getGPSData();
|
mapManager: MapManager,
|
||||||
getEntitiesData();
|
gpsData: GpsData,
|
||||||
getShipTrackPoints();
|
alarm: API.AlarmResponse,
|
||||||
getEntityData();
|
) {
|
||||||
}, 1000); // delay 1s
|
const isSos = alarm?.alarms.find(
|
||||||
return () => clearTimeout(timer);
|
(a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING,
|
||||||
}, [gpsData]);
|
);
|
||||||
|
|
||||||
const getEntitiesData = async () => {
|
if (mapManager?.featureLayer && gpsData) {
|
||||||
try {
|
mapManager.featureLayer.getSource()?.clear();
|
||||||
const alarm = await queryAlarms();
|
mapManager.addPoint(
|
||||||
// console.log('Fetched alarm data:', alarm);
|
|
||||||
// console.log('GPS Data:', gpsData);
|
|
||||||
|
|
||||||
if (mapManagerRef.current?.featureLayer && gpsData) {
|
|
||||||
// Clear trước
|
|
||||||
mapManagerRef.current.featureLayer.getSource()?.clear();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Tạo feature mới
|
|
||||||
const feature = mapManagerRef.current.addPoint(
|
|
||||||
[gpsData.lon, gpsData.lat],
|
[gpsData.lon, gpsData.lat],
|
||||||
{
|
{ bearing: gpsData.h || 0 },
|
||||||
bearing: gpsData.h || 0,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: getShipIcon(alarm.level || 0, gpsData?.fishing || false),
|
icon: getShipIcon(alarm.level || 0, gpsData?.fishing || false),
|
||||||
scale: 0.1,
|
scale: 0.1,
|
||||||
|
animate: isSos ? true : false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (parseError) {
|
|
||||||
console.error(
|
|
||||||
`Error parsing valueString for entity: ${parseError}`,
|
|
||||||
parseError,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
const drawBanzones = async (mapManager: MapManager) => {
|
||||||
console.error('Error fetching entities:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const getShipTrackPoints = async () => {
|
|
||||||
try {
|
try {
|
||||||
const trackpoints = await queryShipTrackPoints();
|
const layer = mapManager.featureLayer?.getSource();
|
||||||
// console.log('Fetched ship track points:', trackpoints.length);
|
layer?.getFeatures()?.forEach((f) => {
|
||||||
if (trackpoints.length > 0) {
|
if ([MAP_POLYGON_BAN, MAP_POLYLINE_BAN].includes(f.get('id'))) {
|
||||||
mapManagerRef.current?.addLineString(
|
layer.removeFeature(f);
|
||||||
trackpoints.map((point) => [point.lon, point.lat]),
|
|
||||||
{
|
|
||||||
id: MAP_TRACKPOINTS_ID,
|
|
||||||
// You can add more properties here if needed
|
|
||||||
},
|
|
||||||
{
|
|
||||||
strokeColor: 'blue',
|
|
||||||
strokeWidth: 3,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Error fetching ship track points:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getEntityData = async () => {
|
|
||||||
try {
|
|
||||||
const entities = await queryEntities();
|
const entities = await queryEntities();
|
||||||
console.log('Fetched entities:', entities.length);
|
for (const entity of entities) {
|
||||||
if (entities.length > 0) {
|
if (entity.id === '50:2' && entity.valueString !== '[]') {
|
||||||
entities.forEach(async (entity) => {
|
|
||||||
if (entity.id == '50:2') {
|
|
||||||
const banzones = await queryBanzones();
|
const banzones = await queryBanzones();
|
||||||
console.log('Fetched banzones:', banzones.length);
|
const zones: any[] = JSON.parse(entity.valueString);
|
||||||
if (entity.valueString != '[]') {
|
zones.forEach((zone: any) => {
|
||||||
const zones = JSON.parse(entity.valueString);
|
|
||||||
console.log('Parsed zones:', zones);
|
|
||||||
for (const zone of zones) {
|
|
||||||
const geom = banzones.find((b) => b.id === zone.zone_id);
|
const geom = banzones.find((b) => b.id === zone.zone_id);
|
||||||
if (geom) {
|
if (geom) {
|
||||||
if (geom.geom?.geom_type === 2) {
|
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
|
||||||
// Polyline
|
if (geom_type === 2) {
|
||||||
const coordinates = convertWKTLineStringToLatLngArray(
|
const coordinates = convertWKTLineStringToLatLngArray(
|
||||||
geom?.geom?.geom_lines || '',
|
geom_lines || '',
|
||||||
);
|
);
|
||||||
if (coordinates.length > 0) {
|
if (coordinates.length > 0) {
|
||||||
mapManagerRef.current.addLineString(
|
mapManager.addLineString(
|
||||||
coordinates,
|
coordinates,
|
||||||
{
|
{
|
||||||
id: MAP_POLYLINE_BAN,
|
id: MAP_POLYLINE_BAN,
|
||||||
text: `${geom.name} - ${zone.message}`,
|
text: `${geom.name} - ${zone.message}`,
|
||||||
},
|
},
|
||||||
{
|
{ strokeColor: 'red', strokeWidth: 4 },
|
||||||
strokeColor: 'red',
|
|
||||||
strokeWidth: 4,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (geom.geom?.geom_type === 1) {
|
} else if (geom_type === 1) {
|
||||||
const coordinates = convertWKTtoLatLngString(
|
const coordinates = convertWKTtoLatLngString(geom_poly || '');
|
||||||
geom?.geom?.geom_poly || '',
|
|
||||||
);
|
|
||||||
if (coordinates.length > 0) {
|
if (coordinates.length > 0) {
|
||||||
mapManagerRef.current.addPolygon(
|
mapManager.addPolygon(
|
||||||
coordinates,
|
coordinates,
|
||||||
{
|
{
|
||||||
id: MAP_POLYGON_BAN,
|
id: MAP_POLYGON_BAN,
|
||||||
@@ -200,28 +175,176 @@ const HomePage: React.FC = () => {
|
|||||||
description: zone.message,
|
description: zone.message,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
strokeColor: 'yellow',
|
strokeColor: '#FCC61D',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
fillColor: 'rgba(255, 255, 0, 0.3)', // Màu vàng với độ trong suốt
|
fillColor: '#FCC61D',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log('No geometry found for zone:', zone.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('Zone rỗng');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching entities:', 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(() => {
|
||||||
|
// Vẽ lại banzones khi vào trang
|
||||||
|
const mapManager = mapManagerRef.current;
|
||||||
|
if (mapManager) {
|
||||||
|
drawBanzones(mapManager);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<VietNamMap
|
<VietNamMap
|
||||||
@@ -236,14 +359,11 @@ const HomePage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<Popover
|
<Popover
|
||||||
styles={{
|
styles={{
|
||||||
root: { width: '85%', maxWidth: 500, 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
|
||||||
@@ -255,20 +375,20 @@ 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>
|
||||||
</Popover>
|
</Popover>
|
||||||
<div className="absolute top-3 right-3 ">
|
<div className="absolute top-3 right-3 ">
|
||||||
<SosButton
|
<SosButton
|
||||||
onRefresh={(value) => {
|
onRefresh={async (value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
getEntitiesData();
|
await refreshAllData();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,24 +31,24 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gpsData = await getGPS();
|
const gpsData = await getGPS();
|
||||||
console.log('GPS Data:', gpsData);
|
// console.log('GPS Data:', gpsData);
|
||||||
|
|
||||||
const body: API.NewFishingLogRequest = {
|
const body: API.NewFishingLogRequest = {
|
||||||
trip_id: trips?.id || '',
|
trip_id: trips?.id || '',
|
||||||
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
|
||||||
|
style={{
|
||||||
padding: screens.sm ? '0px' : '10px',
|
padding: screens.sm ? '0px' : '10px',
|
||||||
marginRight: screens.sm ? '24px' : '0px',
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +36,19 @@ 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) {
|
||||||
console.log('Modal opened with fishingLogs:', fishingLogs);
|
// console.log('Modal opened with fishingLogs:', fishingLogs);
|
||||||
|
|
||||||
if (fishingLogs?.info && fishingLogs.info.length > 0) {
|
if (fishingLogs?.info && fishingLogs.info.length > 0) {
|
||||||
const dataWithKeys: FishingLogInfoWithKey[] = fishingLogs.info.map(
|
const dataWithKeys: FishingLogInfoWithKey[] = fishingLogs.info.map(
|
||||||
@@ -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á',
|
||||||
@@ -157,17 +158,17 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
|
|||||||
async function createOrCreateOrUpdateFishingLog(
|
async function createOrCreateOrUpdateFishingLog(
|
||||||
fishingLog: FishingLogInfoWithKey[],
|
fishingLog: FishingLogInfoWithKey[],
|
||||||
) {
|
) {
|
||||||
console.log('Is finished:', isFinished);
|
// console.log('Is finished:', isFinished);
|
||||||
console.log('Trip:', trip);
|
// console.log('Trip:', trip);
|
||||||
|
|
||||||
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);
|
||||||
console.log('ok', fishingLog);
|
// console.log('ok', fishingLog);
|
||||||
const body: API.FishingLog = {
|
const body: API.FishingLog = {
|
||||||
fishing_log_id: logStatus0?.fishing_log_id || '',
|
fishing_log_id: logStatus0?.fishing_log_id || '',
|
||||||
trip_id: trip.id,
|
trip_id: trip.id,
|
||||||
@@ -191,8 +192,8 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
|
|||||||
})),
|
})),
|
||||||
sync: true,
|
sync: true,
|
||||||
};
|
};
|
||||||
const resp = await updateFishingLogs(body);
|
await updateFishingLogs(body);
|
||||||
console.log('Resp', resp);
|
// console.log('Resp', resp);
|
||||||
|
|
||||||
onFinished?.(true);
|
onFinished?.(true);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -222,8 +223,8 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
|
|||||||
};
|
};
|
||||||
// console.log('Update body:', body);
|
// console.log('Update body:', body);
|
||||||
|
|
||||||
const resp = await updateFishingLogs(body);
|
await updateFishingLogs(body);
|
||||||
console.log('Resp', resp);
|
// console.log('Resp', resp);
|
||||||
|
|
||||||
onFinished?.(true);
|
onFinished?.(true);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,11 +118,11 @@ 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) => {
|
||||||
console.log('Rendering action column for record:', record);
|
// console.log('Rendering action column for record:', record);
|
||||||
return (
|
return (
|
||||||
<Flex align="center" justify="center" gap={5}>
|
<Flex align="center" justify="center" gap={5}>
|
||||||
{/* Nút Edit */}
|
{/* Nút Edit */}
|
||||||
@@ -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' }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,63 @@ 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>,
|
key: 'type',
|
||||||
|
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>,
|
key: 'amount',
|
||||||
|
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>,
|
key: 'unit',
|
||||||
|
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>,
|
key: 'cost_per_unit',
|
||||||
|
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>,
|
key: 'total_cost',
|
||||||
|
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,10 +82,9 @@ const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const { useBreakpoint } = Grid;
|
|
||||||
const screens = useBreakpoint();
|
|
||||||
return (
|
return (
|
||||||
<ProTable<API.TripCost>
|
<ProTable<API.TripCost>
|
||||||
|
rowKey="trip-cost-table"
|
||||||
columns={trip_cost_columns}
|
columns={trip_cost_columns}
|
||||||
dataSource={tripCosts}
|
dataSource={tripCosts}
|
||||||
search={false}
|
search={false}
|
||||||
@@ -72,7 +102,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">
|
||||||
|
|||||||
@@ -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' }}>Mã đị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
|
||||||
|
|||||||
@@ -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,7 +40,11 @@ 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
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -44,13 +44,15 @@ const DetailTrip = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
console.log('Rendering with tripInfo:', data),
|
// console.log('Rendering with tripInfo:', data),
|
||||||
(
|
(
|
||||||
<PageContainer
|
<PageContainer
|
||||||
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} />
|
||||||
@@ -130,13 +140,6 @@ const DetailTrip = () => {
|
|||||||
<MainTripBody
|
<MainTripBody
|
||||||
trip_id={data?.id}
|
trip_id={data?.id}
|
||||||
tripInfo={data || null}
|
tripInfo={data || null}
|
||||||
onReload={(isReload) => {
|
|
||||||
console.log('Nhanaj dduowcj hàm, onReload:', isReload);
|
|
||||||
|
|
||||||
// if (isReload) {
|
|
||||||
// fetchTrip();
|
|
||||||
// }
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</ProCard>
|
</ProCard>
|
||||||
</ProCard>
|
</ProCard>
|
||||||
|
|||||||
54
src/services/ApiConfigService.ts
Normal file
54
src/services/ApiConfigService.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Service để quản lý API endpoint động
|
||||||
|
class ApiConfigService {
|
||||||
|
private static instance: ApiConfigService;
|
||||||
|
private currentIP: string = '';
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): ApiConfigService {
|
||||||
|
if (!ApiConfigService.instance) {
|
||||||
|
ApiConfigService.instance = new ApiConfigService();
|
||||||
|
}
|
||||||
|
return ApiConfigService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy IP từ URL hiện tại
|
||||||
|
getCurrentIP(): string {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
// Nếu là localhost hoặc IP local
|
||||||
|
if (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname.startsWith('192.168.') ||
|
||||||
|
hostname.startsWith('10.')
|
||||||
|
) {
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
// Nếu là domain, trả về hostname
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
return '192.168.30.102'; // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy base URL cho API calls
|
||||||
|
getApiBaseUrl(): string {
|
||||||
|
const ip = this.getCurrentIP();
|
||||||
|
const isLocal =
|
||||||
|
ip.startsWith('192.168.') || ip.startsWith('10.') || ip === 'localhost';
|
||||||
|
|
||||||
|
if (isLocal) {
|
||||||
|
return `http://${ip}:81`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu là domain, có thể cần HTTPS
|
||||||
|
return `https://${ip}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lấy full API URL
|
||||||
|
getApiUrl(endpoint: string): string {
|
||||||
|
const baseUrl = this.getApiBaseUrl();
|
||||||
|
return `${baseUrl}${endpoint}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiConfig = ApiConfigService.getInstance();
|
||||||
@@ -2,7 +2,7 @@ import { API_PATH_LOGIN } from '@/constants';
|
|||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
export async function login(body: API.LoginRequestBody) {
|
export async function login(body: API.LoginRequestBody) {
|
||||||
console.log('Login request body:', body);
|
// console.log('Login request body:', body);
|
||||||
|
|
||||||
return request<API.LoginResponse>(API_PATH_LOGIN, {
|
return request<API.LoginResponse>(API_PATH_LOGIN, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
2
src/services/controller/typings.d.ts
vendored
2
src/services/controller/typings.d.ts
vendored
@@ -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
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export const BASEMAP_ATTRIBUTIONS = '© OpenStreetMap contributors, © CartoDB';
|
|||||||
|
|
||||||
export const INITIAL_VIEW_CONFIG = {
|
export const INITIAL_VIEW_CONFIG = {
|
||||||
// Dịch tâm bản đồ ra phía biển và zoom ra xa hơn để thấy bao quát
|
// Dịch tâm bản đồ ra phía biển và zoom ra xa hơn để thấy bao quát
|
||||||
center: [109.5, 16.0],
|
center: [116.152685, 15.70581],
|
||||||
zoom: 5.5,
|
zoom: 6.5,
|
||||||
minZoom: 5,
|
minZoom: 5,
|
||||||
maxZoom: 12,
|
maxZoom: 12,
|
||||||
minScale: 0.1,
|
minScale: 0.1,
|
||||||
@@ -31,7 +31,6 @@ export const osmLayer = new TileLayer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// const layerVN = await getLayer('base-vn');
|
// const layerVN = await getLayer('base-vn');
|
||||||
|
|
||||||
export const tinhGeoLayer = createGeoJSONLayer({
|
export const tinhGeoLayer = createGeoJSONLayer({
|
||||||
data: '',
|
data: '',
|
||||||
style: createLabelAndFillStyle('#77BEF0', '#000000'), // vừa màu vừa label
|
style: createLabelAndFillStyle('#77BEF0', '#000000'), // vừa màu vừa label
|
||||||
@@ -42,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;
|
||||||
@@ -62,3 +61,14 @@ export const getShipIcon = (type: number, isFishing: boolean) => {
|
|||||||
return shipUndefineIcon;
|
return shipUndefineIcon;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const convertToDMS = (value: number, isLat: boolean): string => {
|
||||||
|
const deg = Math.floor(Math.abs(value));
|
||||||
|
const minFloat = (Math.abs(value) - deg) * 60;
|
||||||
|
const min = Math.floor(minFloat);
|
||||||
|
const sec = (minFloat - min) * 60;
|
||||||
|
|
||||||
|
const direction = value >= 0 ? (isLat ? 'N' : 'E') : isLat ? 'S' : 'W';
|
||||||
|
|
||||||
|
return `${deg}°${min}'${sec.toFixed(2)}"${direction}`;
|
||||||
|
};
|
||||||
|
|||||||
4
src/typings.d.ts
vendored
Normal file
4
src/typings.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.png' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
30
src/utils/cacheStore.ts
Normal file
30
src/utils/cacheStore.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// src/utils/cacheStore.ts
|
||||||
|
interface CacheItem<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL = 1 * 1000; // dữ liệu có hiệu lực trong 1 giây để GPS realtime
|
||||||
|
const cache: Record<string, CacheItem<any>> = {};
|
||||||
|
|
||||||
|
export function getCache<T>(key: string): T | null {
|
||||||
|
const item = cache[key];
|
||||||
|
if (!item) return null;
|
||||||
|
if (Date.now() - item.timestamp > TTL) {
|
||||||
|
delete cache[key];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return item.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCache<T>(key: string, data: T) {
|
||||||
|
cache[key] = { data, timestamp: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidate(key: string) {
|
||||||
|
delete cache[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllCacheKeys(): string[] {
|
||||||
|
return Object.keys(cache);
|
||||||
|
}
|
||||||
24
src/utils/eventBus.ts
Normal file
24
src/utils/eventBus.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// src/utils/eventBus.ts
|
||||||
|
type EventHandler<T = any> = (data: T) => void;
|
||||||
|
|
||||||
|
class EventBus {
|
||||||
|
private listeners: Record<string, EventHandler[]> = {};
|
||||||
|
|
||||||
|
on(event: string, handler: EventHandler) {
|
||||||
|
if (!this.listeners[event]) this.listeners[event] = [];
|
||||||
|
this.listeners[event].push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, handler: EventHandler) {
|
||||||
|
this.listeners[event] = (this.listeners[event] || []).filter(
|
||||||
|
(h) => h !== handler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, data?: any) {
|
||||||
|
(this.listeners[event] || []).forEach((h) => h(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ phải có dòng này
|
||||||
|
export const eventBus = new EventBus();
|
||||||
27
src/utils/queryWithCache.ts
Normal file
27
src/utils/queryWithCache.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// utils/queryWithCache.ts
|
||||||
|
import { getCache, setCache, invalidate, getAllCacheKeys } from './cacheStore';
|
||||||
|
import { eventBus } from './eventBus';
|
||||||
|
|
||||||
|
export async function queryWithCache<T>(
|
||||||
|
key: string,
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const cached = getCache<T>(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const data = await fetcher();
|
||||||
|
setCache(key, data);
|
||||||
|
|
||||||
|
// Phát sự kiện để các component khác biết có data mới
|
||||||
|
eventBus.emit(`${key}:update`, data);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Xóa cache theo key hoặc prefix
|
||||||
|
queryWithCache.clear = (prefix = '') => {
|
||||||
|
const keys = getAllCacheKeys();
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key.startsWith(prefix)) invalidate(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "./src/.umi/tsconfig.json"
|
"extends": "./src/.umi/tsconfig.json",
|
||||||
|
// "compilerOptions": {
|
||||||
|
// "target": "es2017",
|
||||||
|
// "lib": ["dom", "es2017"],
|
||||||
|
// "module": "esnext",
|
||||||
|
// "moduleResolution": "node",
|
||||||
|
// "jsx": "react-jsx",
|
||||||
|
// "esModuleInterop": true,
|
||||||
|
// "skipLibCheck": true,
|
||||||
|
// "strict": true,
|
||||||
|
// "forceConsistentCasingInFileNames": true,
|
||||||
|
// },
|
||||||
|
// "include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
5
typings.d.ts
vendored
5
typings.d.ts
vendored
@@ -1 +1,6 @@
|
|||||||
import '@umijs/max/typings';
|
import '@umijs/max/typings';
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const src: string;
|
||||||
|
export default src;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user