Compare commits
16 Commits
904028649c
...
device_sgw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa43e72b48 | ||
|
|
4d4463a517 | ||
|
|
38c9639fcc | ||
|
|
46aaf67a71 | ||
|
|
216e865ca5 | ||
|
|
dea435a4ec | ||
| eed98f7c29 | |||
|
|
92eb505cfc | ||
|
|
ff66a95bc5 | ||
|
|
cd8332a7ef | ||
|
|
65f9468bbd | ||
|
|
28739ddcd9 | ||
|
|
53dfb861dd | ||
|
|
076d0460cb | ||
|
|
b44f1a9b29 | ||
|
|
ef353862e4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@
|
||||
/.mfsu
|
||||
.swc
|
||||
.DS_Store
|
||||
claude_code_zai_env.sh
|
||||
32
.umirc.ts
32
.umirc.ts
@@ -1,9 +1,11 @@
|
||||
import { defineConfig } from '@umijs/max';
|
||||
import proxy from './config/proxy';
|
||||
|
||||
const { REACT_APP_ENV = 'dev' } = process.env as {
|
||||
REACT_APP_ENV: 'dev' | 'test' | 'prod';
|
||||
};
|
||||
import proxyDev from './config/proxy_dev';
|
||||
import proxyProd from './config/proxy_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({
|
||||
antd: {},
|
||||
@@ -13,14 +15,24 @@ export default defineConfig({
|
||||
request: {},
|
||||
locale: {
|
||||
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'],
|
||||
layout: {
|
||||
title: '2025 Sản phẩm của Mobifone v1.0',
|
||||
},
|
||||
proxy: proxy[REACT_APP_ENV],
|
||||
proxy: proxyConfig[resolvedEnv],
|
||||
routes: [
|
||||
{
|
||||
name: 'Login',
|
||||
title: 'Login',
|
||||
path: '/login',
|
||||
component: './Auth',
|
||||
layout: false,
|
||||
@@ -30,13 +42,13 @@ export default defineConfig({
|
||||
redirect: '/map',
|
||||
},
|
||||
{
|
||||
name: 'Giám sát',
|
||||
name: 'monitoring',
|
||||
path: '/map',
|
||||
component: './Home',
|
||||
icon: 'icon-Map',
|
||||
},
|
||||
{
|
||||
name: 'Chuyến đi',
|
||||
name: 'trips',
|
||||
path: '/trip',
|
||||
component: './Trip',
|
||||
icon: 'icon-specification',
|
||||
@@ -44,5 +56,5 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
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
|
||||
|
||||
`@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: {
|
||||
'/api': {
|
||||
target: 'http://192.168.30.102:81',
|
||||
target: 'http://192.168.30.103:81',
|
||||
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';
|
||||
|
||||
// Error handling scheme: Error types
|
||||
enum ErrorShowType {
|
||||
SILENT = 0,
|
||||
WARN_MESSAGE = 1,
|
||||
ERROR_MESSAGE = 2,
|
||||
NOTIFICATION = 3,
|
||||
REDIRECT = 9,
|
||||
}
|
||||
// enum ErrorShowType {
|
||||
// SILENT = 0,
|
||||
// WARN_MESSAGE = 1,
|
||||
// ERROR_MESSAGE = 2,
|
||||
// NOTIFICATION = 3,
|
||||
// REDIRECT = 9,
|
||||
// }
|
||||
// Response data structure agreed with the backend
|
||||
interface ResponseStructure<T = any> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
errorCode?: number;
|
||||
errorMessage?: string;
|
||||
showType?: ErrorShowType;
|
||||
}
|
||||
// interface ResponseStructure<T = any> {
|
||||
// success: boolean;
|
||||
// data: T;
|
||||
// errorCode?: number;
|
||||
// errorMessage?: string;
|
||||
// showType?: ErrorShowType;
|
||||
// }
|
||||
|
||||
const codeMessage = {
|
||||
200: 'The server successfully returned the requested data。',
|
||||
@@ -47,7 +47,7 @@ export const handleRequestConfig: RequestConfig = {
|
||||
errorConfig: {
|
||||
// Error throwing
|
||||
errorThrower: (res: any) => {
|
||||
console.log('Response from backend:', res);
|
||||
// console.log('Response from backend:', res);
|
||||
const { success, data, errorCode, errorMessage, showType } = res;
|
||||
if (!success) {
|
||||
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
1
public/avatar.svg
Normal file
1
public/avatar.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1679201365371" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1897" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M509.31257004 540.79389452h4.91415801c44.99525866-0.76783723 81.39074114-16.58528313 108.26504222-46.83806846 59.12346278-66.6482671 49.29514673-180.9024399 48.2201749-191.80572705-3.83918618-81.85144307-42.53817965-121.01113989-74.48020676-139.28566479C572.42878628 149.19693275 544.63307997 141.82569571 513.61245812 141.21142578H511.00181181c-17.04598575 0-50.52368662 2.76421361-82.61928101 21.03873851-32.24916171 18.27452489-71.56242513 57.43422101-75.40161058 139.89993473-1.07497185 10.90328787-10.90328787 125.15746067 48.22017491 191.80572704 26.72073376 30.2527846 63.11621625 46.07023049 108.11147491 46.83806846z m-115.32914464-234.80461006c0-0.46070263 0.15356731-0.92140454 0.15356731-1.22853914 5.06772532-110.10785129 83.23355022-121.93254442 116.7112518-121.93254443H512.69105358c41.4632078 0.92140454 111.95066108 17.81382227 116.71125108 121.93254443 0 0.46070263 0 0.92140454 0.15356731 1.22853914 0.15356731 1.07497185 10.90328787 105.50082861-37.93115624 160.47797059-19.34949674 21.80657575-45.14882595 32.55629632-79.08722946 32.86343166h-1.53567446c-33.78483618-0.30713461-59.73773271-11.05685517-78.93366214-32.86343165-48.68087753-54.67000739-38.23829157-159.55656606-38.08472427-160.4779706z" p-id="1898"></path><path d="M827.3507296 730.29611058v-0.46070263c0-1.22853915-0.15356731-2.457079-0.15356731-3.83918618-0.92140454-30.40635263-2.91778164-101.50807511-69.56604874-124.23605539-0.46070263-0.15356731-1.07497185-0.30713461-1.53567375-0.46070264-69.25891341-17.66025497-126.84670245-57.5877883-127.46097237-58.04849021-9.3676134-6.60339979-22.26727838-4.29988808-28.87067744 5.06772532-6.60339979 9.3676134-4.29988808 22.26727838 5.0677253 28.87067743 2.6106463 1.84280907 63.73048619 44.38098873 140.20706862 64.03762079 35.78121256 12.74609694 39.77396603 50.98438852 40.84893787 85.99776458 0 1.38210717 0 2.6106463 0.15356802 3.83918545 0.15356731 13.82106951-0.76783723 35.16694264-3.22491624 47.45233838-24.87792469 14.12820412-122.39324633 62.96264895-270.73938991 62.96264823-147.73187366 0-245.86146522-48.98801213-270.89295721-63.11621625-2.457079-12.28539504-3.53205084-33.63126816-3.22491624-47.45233767 0-1.22853915 0.15356731-2.457079 0.15356802-3.83918545 1.07497185-35.01337533 5.06772532-73.25166689 40.84893786-85.99776457 76.47658314-19.65663206 137.596423-62.34837901 140.20706862-64.03762079 9.3676134-6.60339979 11.67112511-19.50306404 5.06772531-28.87067744-6.60339979-9.3676134-19.50306404-11.67112511-28.87067745-5.06772605-0.61426993 0.46070263-57.89492363 40.38823598-127.46097236 58.04849094-0.61426993 0.15356731-1.07497185 0.30713461-1.53567375 0.46070264-66.6482671 22.88154832-68.64464421 93.9832708-69.56604874 124.2360554 0 1.38210717 0 2.6106463-0.15356731 3.83918617v0.46070191c-0.15356731 7.98550697-0.30713461 48.98801213 7.83193893 69.56604875 1.53567446 3.99275348 4.29988808 7.37123702 7.98550697 9.67474873 4.60702342 3.07134893 115.02200931 73.4052342 299.76363465 73.40523419s295.15661197-70.48745329 299.76363538-73.40523419c3.53205084-2.3035117 6.44983249-5.68199525 7.98550624-9.67474873 7.67837163-20.4244693 7.52480433-61.42697448 7.37123703-69.41248072z" p-id="1899"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
72
src/app.tsx
72
src/app.tsx
@@ -1,11 +1,48 @@
|
||||
import { history, RunTimeLayoutConfig } from '@umijs/max';
|
||||
import { handleRequestConfig } from '../config/Request';
|
||||
import logo from '../public/logo.png';
|
||||
import { LogoutOutlined } from '@ant-design/icons';
|
||||
import { history, RunTimeLayoutConfig, useIntl } from '@umijs/max';
|
||||
import { Dropdown } from 'antd';
|
||||
import { handleRequestConfig as devRequestConfig } from '../config/request_dev';
|
||||
import { handleRequestConfig as prodRequestConfig } from '../config/request_prod';
|
||||
import UnAccessPage from './components/403/403Page';
|
||||
import Footer from './components/Footer/Footer';
|
||||
import LanguageSwitcher from './components/LanguageSwitcher';
|
||||
import { ROUTE_LOGIN } from './constants';
|
||||
import { parseJwt } from './utils/jwtTokenUtils';
|
||||
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 用户信息和权限初始化
|
||||
// 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate
|
||||
export async function getInitialState() {
|
||||
@@ -57,7 +94,7 @@ export const layout: RunTimeLayoutConfig = (initialState) => {
|
||||
console.log('initialState', initialState);
|
||||
|
||||
return {
|
||||
logo: logo,
|
||||
logo,
|
||||
fixedHeader: true,
|
||||
contentWidth: 'Fluid',
|
||||
navTheme: 'light',
|
||||
@@ -65,17 +102,27 @@ export const layout: RunTimeLayoutConfig = (initialState) => {
|
||||
title: 'SGW',
|
||||
iconfontUrl: '//at.alicdn.com/t/c/font_1970684_76mmjhln75w.js',
|
||||
menu: {
|
||||
locale: false,
|
||||
locale: true,
|
||||
},
|
||||
contentStyle: {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
paddingInline: 0,
|
||||
},
|
||||
layout: 'mix',
|
||||
actionsRender: () => [
|
||||
<LanguageSwitcher key="lang-switcher" type="dropdown" />,
|
||||
],
|
||||
avatarProps: {
|
||||
size: 'small',
|
||||
src: '/avatar.svg',
|
||||
render: () => <AvatarDropdown />,
|
||||
},
|
||||
layout: 'top', // Thay đổi từ 'mix' sang 'top' để đảm bảo header hiển thị đúng
|
||||
logout: () => {
|
||||
removeToken();
|
||||
history.push(ROUTE_LOGIN);
|
||||
},
|
||||
footerRender: () => <Footer />,
|
||||
// footerRender: () => <Footer />,
|
||||
onPageChange: () => {
|
||||
if (!initialState.initialState) {
|
||||
history.push(ROUTE_LOGIN);
|
||||
@@ -103,7 +150,14 @@ export const layout: RunTimeLayoutConfig = (initialState) => {
|
||||
},
|
||||
],
|
||||
unAccessible: <UnAccessPage />,
|
||||
token: {
|
||||
pageContainer: {
|
||||
paddingInlinePageContainerContent: 0,
|
||||
paddingBlockPageContainerContent: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
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;
|
||||
|
||||
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_FAIL = 'UPDATE_FISHING_LOG_FAIL',
|
||||
}
|
||||
|
||||
export enum ENTITY_TYPE_ENUM {
|
||||
SOS_WARNING = '50:15',
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export const DEFAULT_NAME = 'Umi Max';
|
||||
export const TOKEN = 'token';
|
||||
export const BASE_URL = 'https://sgw-device.gms.vn';
|
||||
export const MAP_TRACKPOINTS_ID = 'ship-trackpoints';
|
||||
export const MAP_POLYLINE_BAN = 'ban-polyline';
|
||||
export const MAP_POLYGON_BAN = 'ban-polygon';
|
||||
|
||||
// Global Constants
|
||||
|
||||
// Route Constants
|
||||
@@ -10,15 +14,17 @@ export const ROUTE_TRIP = '/trip';
|
||||
|
||||
// API Path Constants
|
||||
export const API_PATH_LOGIN = '/api/agent/login';
|
||||
export const API_PATH_ENTITIES = '/api/agent/entities';
|
||||
export const API_PATH_ENTITIES = '/api/io/entities';
|
||||
export const API_PATH_SHIP_INFO = '/api/sgw/shipinfo';
|
||||
export const API_GET_ALL_LAYER = '/api/sgw/geojsonlist';
|
||||
export const API_GET_LAYER_INFO = '/api/sgw/geojson';
|
||||
export const API_GET_TRIP = '/api/sgw/trip';
|
||||
export const API_GET_ALARMS = '/api/agent/alarms';
|
||||
export const API_GET_ALARMS = '/api/io/alarms';
|
||||
export const API_UPDATE_TRIP_STATUS = '/api/sgw/tripState';
|
||||
export const API_HAUL_HANDLE = '/api/sgw/fishingLog';
|
||||
export const API_GET_GPS = '/api/sgw/gps';
|
||||
export const API_GET_FISH = '/api/sgw/fishspecies';
|
||||
export const API_UPDATE_FISHING_LOGS = '/api/sgw/fishingLog';
|
||||
export const API_SOS = '/api/sgw/sos';
|
||||
export const API_PATH_SHIP_TRACK_POINTS = '/api/sgw/trackpoints';
|
||||
export const API_GET_ALL_BANZONES = '/api/sgw/banzones';
|
||||
|
||||
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);
|
||||
try {
|
||||
const res = await queryAlarms(); // đổi URL cho phù hợp
|
||||
console.log('Alarm Data fetched:', res);
|
||||
// console.log('Alarm Data fetched:', res);
|
||||
|
||||
setAlarmData(res || []);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
// src/models/useGetGpsModel.ts
|
||||
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() {
|
||||
const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getGPSData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getGPS(); // đổi URL cho phù hợp
|
||||
console.log('GPS Data fetched:', res);
|
||||
// Bypass cache để GPS luôn realtime, gọi trực tiếp API
|
||||
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) {
|
||||
console.error('Fetch gps data failed', err);
|
||||
} finally {
|
||||
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 {
|
||||
gpsData,
|
||||
loading,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Footer from '@/components/Footer/Footer';
|
||||
import LangSwitches from '@/components/switch/LangSwitches';
|
||||
import { ROUTE_HOME } from '@/constants';
|
||||
import { login } from '@/services/controller/AuthController';
|
||||
import { parseJwt } from '@/utils/jwtTokenUtils';
|
||||
import { getToken, removeToken, setToken } from '@/utils/localStorageUtils';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
|
||||
import { history } from '@umijs/max';
|
||||
import { history, useIntl } from '@umijs/max';
|
||||
import { Image, theme } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
import logoImg from '../../../public/logo.png';
|
||||
@@ -14,17 +16,14 @@ const LoginPage = () => {
|
||||
const { token } = theme.useToken();
|
||||
const urlParams = new URL(window.location.href).searchParams;
|
||||
const redirect = urlParams.get('redirect');
|
||||
|
||||
useEffect(() => {
|
||||
checkLogin();
|
||||
}, []);
|
||||
const intl = useIntl();
|
||||
const checkLogin = () => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const parsed = parseJwt(token);
|
||||
const { sub, exp } = parsed;
|
||||
const { exp } = parsed;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oneHour = 60 * 60;
|
||||
if (exp - now < oneHour) {
|
||||
@@ -38,6 +37,10 @@ const LoginPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkLogin();
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (values: API.LoginRequestBody) => {
|
||||
try {
|
||||
const resp = await login(values);
|
||||
@@ -67,7 +70,10 @@ const LoginPage = () => {
|
||||
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
|
||||
title={
|
||||
<span style={{ color: token.colorBgContainer }}>
|
||||
Hệ thống giám sát tàu cá
|
||||
{intl.formatMessage({
|
||||
id: 'auth.login.subtitle',
|
||||
defaultMessage: 'Sea Gateway',
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
containerStyle={{
|
||||
@@ -77,7 +83,10 @@ const LoginPage = () => {
|
||||
subTitle={<Image preview={false} src={mobifontImg} />}
|
||||
submitter={{
|
||||
searchConfig: {
|
||||
submitText: 'Đăng nhập',
|
||||
submitText: intl.formatMessage({
|
||||
id: 'common.login',
|
||||
defaultMessage: 'Đăng nhập',
|
||||
}),
|
||||
},
|
||||
}}
|
||||
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={[
|
||||
{
|
||||
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={[
|
||||
{
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,16 +7,21 @@ import {
|
||||
} from '@/utils/mapUtils';
|
||||
import { Feature, Map, View } from 'ol';
|
||||
import { Coordinate } from 'ol/coordinate';
|
||||
import { easeOut } from 'ol/easing';
|
||||
import { EventsKey } from 'ol/events';
|
||||
import { createEmpty, extend, Extent, isEmpty } from 'ol/extent';
|
||||
import { MultiPolygon, Point, Polygon } from 'ol/geom';
|
||||
import { LineString, MultiPolygon, Point, Polygon } from 'ol/geom';
|
||||
import BaseLayer from 'ol/layer/Base';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import { unByKey } from 'ol/Observable';
|
||||
import { fromLonLat } from 'ol/proj';
|
||||
import { getVectorContext } from 'ol/render';
|
||||
import RenderEvent from 'ol/render/Event';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import { Circle as CircleStyle, Style } from 'ol/style';
|
||||
import Fill from 'ol/style/Fill';
|
||||
import Icon from 'ol/style/Icon';
|
||||
import Stroke from 'ol/style/Stroke';
|
||||
import Style from 'ol/style/Style';
|
||||
import Text from 'ol/style/Text';
|
||||
|
||||
interface MapManagerConfig {
|
||||
@@ -25,6 +30,12 @@ interface MapManagerConfig {
|
||||
onFeaturesClick?: (features: any[]) => void;
|
||||
onError?: (error: string[]) => void;
|
||||
}
|
||||
interface AnimationConfig {
|
||||
duration?: number;
|
||||
maxRadius?: number;
|
||||
strokeColor?: string;
|
||||
strokeWidthBase?: number;
|
||||
}
|
||||
|
||||
interface StyleConfig {
|
||||
icon?: string; // URL của icon
|
||||
@@ -32,19 +43,22 @@ interface StyleConfig {
|
||||
textColor?: string; // màu chữ
|
||||
textStrokeColor?: string; // màu viền chữ
|
||||
textOffsetY?: number; // độ lệch theo trục Y
|
||||
strokeColor?: string; // màu đường kẻ cho LineString
|
||||
strokeWidth?: number; // độ dày đường kẻ cho LineString
|
||||
fillColor?: string; // màu fill cho Polygon
|
||||
borderColor?: string; // màu viền cho Polygon
|
||||
borderWidth?: number; // độ dày viền cho Polygon
|
||||
minScale?: number; // tỷ lệ nhỏ nhất
|
||||
maxScale?: number; // tỷ lệ lớn nhất
|
||||
}
|
||||
|
||||
// Interface for feature data
|
||||
interface FeatureData {
|
||||
id?: string | number;
|
||||
bearing?: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// Interface for layer object
|
||||
interface Layer {
|
||||
layer: VectorLayer<VectorSource>;
|
||||
}
|
||||
|
||||
class MapManager {
|
||||
mapRef: React.RefObject<HTMLDivElement>;
|
||||
map: Map | null;
|
||||
@@ -58,6 +72,8 @@ class MapManager {
|
||||
private errors: string[];
|
||||
private layers: BaseLayer[]; // Assuming layers is defined elsewhere
|
||||
private isInitialized: boolean; // Thêm thuộc tính để theo dõi trạng thái khởi tạo
|
||||
private eventKey: EventsKey | undefined;
|
||||
shipFeature: any;
|
||||
|
||||
constructor(
|
||||
mapRef: React.RefObject<HTMLDivElement>,
|
||||
@@ -75,11 +91,12 @@ class MapManager {
|
||||
this.errors = [];
|
||||
this.layers = []; // Initialize layers (adjust based on actual usage)
|
||||
this.isInitialized = false; // Khởi tạo là false
|
||||
this.eventKey = undefined; // Khởi tạo eventKey
|
||||
}
|
||||
|
||||
async getListLayers(): Promise<[]> {
|
||||
const resp: [] = await getAllLayer();
|
||||
console.log('resp', resp);
|
||||
// console.log('resp', resp);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@@ -91,10 +108,15 @@ class MapManager {
|
||||
for (const layerMeta of listLayers) {
|
||||
try {
|
||||
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({
|
||||
data,
|
||||
style: createLabelAndFillStyle('#77BEF0', '#000000'),
|
||||
style: style,
|
||||
});
|
||||
|
||||
dynamicLayers.push(vectorLayer);
|
||||
@@ -141,10 +163,10 @@ class MapManager {
|
||||
this.initZoomListener();
|
||||
this.isInitialized = true;
|
||||
|
||||
console.log(
|
||||
'Map initialized successfully at',
|
||||
new Date().toLocaleTimeString(),
|
||||
);
|
||||
// console.log(
|
||||
// 'Map initialized successfully at',
|
||||
// new Date().toLocaleTimeString(),
|
||||
// );
|
||||
|
||||
this.map.on('singleclick', (evt: any) => {
|
||||
const featuresAtPixel: {
|
||||
@@ -166,12 +188,12 @@ class MapManager {
|
||||
if (this.features.includes(feature)) {
|
||||
this.onFeatureClick(feature);
|
||||
this.onFeatureSelect(feature, evt.pixel);
|
||||
console.log(
|
||||
'Feature clicked at',
|
||||
new Date().toLocaleTimeString(),
|
||||
':',
|
||||
feature.getProperties(),
|
||||
);
|
||||
// console.log(
|
||||
// 'Feature clicked at',
|
||||
// new Date().toLocaleTimeString(),
|
||||
// ':',
|
||||
// feature.getProperties(),
|
||||
// );
|
||||
}
|
||||
} else {
|
||||
this.onFeaturesClick(featuresAtPixel.map((f) => f.feature));
|
||||
@@ -319,10 +341,11 @@ class MapManager {
|
||||
|
||||
// Lưu feature
|
||||
this.features.push(feature);
|
||||
this.flyToFeature(feature);
|
||||
this.featureLayer?.getSource()?.addFeature(feature);
|
||||
|
||||
console.log('Point added successfully:', { coord, data, styleConfig });
|
||||
if (styleConfig.animate) {
|
||||
this.animatedMarker(feature, styleConfig.animationConfig);
|
||||
}
|
||||
// console.log('Point added successfully:', { coord, data, styleConfig });
|
||||
|
||||
return feature;
|
||||
} catch (error: any) {
|
||||
@@ -333,6 +356,74 @@ class MapManager {
|
||||
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(
|
||||
coords: Coordinate[][],
|
||||
@@ -444,15 +535,99 @@ class MapManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Hàm addLineString đã được sửa đổi
|
||||
addLineString(
|
||||
coords: Coordinate[],
|
||||
data: FeatureData = {},
|
||||
styleConfig: StyleConfig = {},
|
||||
): Feature | null {
|
||||
try {
|
||||
// Kiểm tra coords là mảng hợp lệ và có ít nhất 2 tọa độ
|
||||
if (
|
||||
!Array.isArray(coords) ||
|
||||
coords.length < 2 ||
|
||||
!coords.every(
|
||||
(coord) =>
|
||||
Array.isArray(coord) &&
|
||||
coord.length === 2 &&
|
||||
typeof coord[0] === 'number' &&
|
||||
typeof coord[1] === 'number',
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid coordinates for LineString: ${JSON.stringify(coords)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Chuyển đổi tọa độ từ [lon, lat] sang hệ tọa độ của OpenLayers
|
||||
const transformedCoords = coords.map((coord) => fromLonLat(coord));
|
||||
const geometry = new LineString(transformedCoords);
|
||||
const feature = new Feature({
|
||||
geometry,
|
||||
...data,
|
||||
});
|
||||
|
||||
// Tạo style cho LineString
|
||||
const style = new Style({
|
||||
text: new Text({
|
||||
text: data.text || '',
|
||||
font: styleConfig.font || '16px Arial', // Tăng kích thước font
|
||||
fill: new Fill({ color: styleConfig.textColor || 'black' }), // Đổi màu thành đen để nổi bật
|
||||
stroke: new Stroke({
|
||||
color: styleConfig.textStrokeColor || 'white',
|
||||
width: 2, // Tăng độ rộng viền
|
||||
}),
|
||||
placement: 'line',
|
||||
textAlign: 'center',
|
||||
textBaseline: 'middle', // Đổi thành 'middle' để căn giữa theo chiều dọc
|
||||
offsetY: styleConfig.textOffsetY || -10, // Dịch text lên trên một chút
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: styleConfig.strokeColor || 'blue',
|
||||
width: styleConfig.strokeWidth || 5,
|
||||
}),
|
||||
});
|
||||
|
||||
feature.setStyle(style);
|
||||
|
||||
// Thêm feature vào features và featureLayer
|
||||
if (this.featureLayer && this.isInitialized) {
|
||||
this.features.push(feature);
|
||||
this.featureLayer.getSource()?.addFeature(feature);
|
||||
// console.log(
|
||||
// 'LineString added successfully at',
|
||||
// new Date().toLocaleTimeString(),
|
||||
// ':',
|
||||
// { coords, data, styleConfig },
|
||||
// );
|
||||
// Đảm bảo bản đồ được render lại
|
||||
this.map?.render();
|
||||
return feature;
|
||||
} else {
|
||||
throw new Error('featureLayer or map is not initialized');
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.errors.push(error.message);
|
||||
this.onError(this.errors);
|
||||
console.error(
|
||||
'Error adding LineString at',
|
||||
new Date().toLocaleTimeString(),
|
||||
':',
|
||||
error.message,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
createPolygonStyle = (
|
||||
data: Record<string, any> = {},
|
||||
styleConfig: Record<string, any> = {},
|
||||
zoom: number,
|
||||
) => {
|
||||
console.log(
|
||||
`createPolygonStyle called with zoom: ${zoom}, styleConfig:`,
|
||||
styleConfig,
|
||||
); // Debug
|
||||
// console.log(
|
||||
// `createPolygonStyle called with zoom: ${zoom}, styleConfig:`,
|
||||
// styleConfig,
|
||||
// ); // Debug
|
||||
const textContent = `Tên: ${data?.name ?? 'Chưa có'}\n\n Loại: ${
|
||||
data?.type ?? 0
|
||||
}\n\nThông tin: ${data?.description ?? 'Chưa có'}`;
|
||||
@@ -490,7 +665,7 @@ class MapManager {
|
||||
text: textStyle,
|
||||
});
|
||||
|
||||
console.log(`Polygon style created:`, style); // Debug
|
||||
// console.log(`Polygon style created:`, style); // Debug
|
||||
return [style];
|
||||
};
|
||||
|
||||
@@ -635,7 +810,7 @@ class MapManager {
|
||||
|
||||
view.fit(extent, {
|
||||
padding: [300, 300, 300, 300],
|
||||
maxZoom: features.length === 1 ? 10 : 12,
|
||||
maxZoom: features.length === 1 ? 8 : 10,
|
||||
duration,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { convertToDMS } from '@/services/service/MapService';
|
||||
import { ProDescriptions } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { GpsData } from '..';
|
||||
const GpsInfo = ({ gpsData }: { gpsData: GpsData | null }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<ProDescriptions<GpsData>
|
||||
dataSource={gpsData || undefined}
|
||||
@@ -15,37 +18,40 @@ const GpsInfo = ({ gpsData }: { gpsData: GpsData | null }) => {
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: 'Kinh độ',
|
||||
title: intl.formatMessage({ id: 'map.ship.longitude' }),
|
||||
dataIndex: 'lat',
|
||||
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',
|
||||
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',
|
||||
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,
|
||||
},
|
||||
{
|
||||
title: 'Hướng',
|
||||
title: intl.formatMessage({ id: 'map.ship.course' }),
|
||||
dataIndex: 'h',
|
||||
valueType: 'digit',
|
||||
render: (_, record) => `${record.h}°`,
|
||||
span: 1,
|
||||
},
|
||||
{
|
||||
title: 'Trạng thái',
|
||||
tooltip: 'Thuyền có đang đánh bắt hay không',
|
||||
title: intl.formatMessage({ id: 'map.ship.state' }),
|
||||
tooltip: intl.formatMessage({ id: 'map.ship.state.tooltip' }),
|
||||
dataIndex: 'fishing',
|
||||
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 { ProDescriptions } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Modal } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ShipInfoProps {
|
||||
isOpen?: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
}
|
||||
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 () => {
|
||||
try {
|
||||
const resp = await getShipInfo();
|
||||
console.log('Ship Info Response:', resp);
|
||||
// console.log('Ship Info Response:', resp);
|
||||
return resp;
|
||||
} catch (error) {
|
||||
console.error('Error fetching ship data:', error);
|
||||
@@ -20,10 +46,12 @@ const ShipInfo: React.FC<ShipInfoProps> = ({ isOpen, setIsOpen }) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Thông tin tàu"
|
||||
centered
|
||||
title={intl.formatMessage({ id: 'map.ship.info' })}
|
||||
open={isOpen}
|
||||
onCancel={() => setIsOpen(false)}
|
||||
onOk={() => setIsOpen(false)}
|
||||
width={modalWidth}
|
||||
>
|
||||
<ProDescriptions<API.ShipDetail>
|
||||
column={{
|
||||
@@ -35,8 +63,6 @@ const ShipInfo: React.FC<ShipInfoProps> = ({ isOpen, setIsOpen }) => {
|
||||
}}
|
||||
request={async () => {
|
||||
const resp = await fetchShipData();
|
||||
console.log('Fetched ship info:', resp);
|
||||
|
||||
return Promise.resolve({
|
||||
data: resp,
|
||||
success: true,
|
||||
@@ -44,29 +70,29 @@ const ShipInfo: React.FC<ShipInfoProps> = ({ isOpen, setIsOpen }) => {
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: 'Tên',
|
||||
title: intl.formatMessage({ id: 'map.ship.name' }),
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: 'IMO',
|
||||
title: intl.formatMessage({ id: 'map.ship.imo' }),
|
||||
dataIndex: 'imo_number',
|
||||
},
|
||||
{
|
||||
title: 'MMSI',
|
||||
title: intl.formatMessage({ id: 'map.ship.mmsi' }),
|
||||
dataIndex: 'mmsi_number',
|
||||
},
|
||||
{
|
||||
title: 'Số đăng ký',
|
||||
title: intl.formatMessage({ id: 'map.ship.regNumber' }),
|
||||
dataIndex: 'reg_number',
|
||||
},
|
||||
{
|
||||
title: 'Chiều dài',
|
||||
title: intl.formatMessage({ id: 'map.ship.length' }),
|
||||
dataIndex: 'ship_length',
|
||||
render: (_, record) =>
|
||||
record?.ship_length ? `${record.ship_length} m` : '--',
|
||||
},
|
||||
{
|
||||
title: 'Công suất',
|
||||
title: intl.formatMessage({ id: 'map.ship.power' }),
|
||||
dataIndex: 'ship_power',
|
||||
render: (_, record) =>
|
||||
record?.ship_power ? `${record.ship_power} kW` : '--',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import useTranslation from '@/hooks/useTranslation';
|
||||
import {
|
||||
deleteSos,
|
||||
getSos,
|
||||
@@ -20,23 +21,22 @@ interface SosButtonProps {
|
||||
}
|
||||
|
||||
const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm<{ message: string; messageOther?: string }>();
|
||||
const [sosData, setSosData] = useState<API.SosResponse>();
|
||||
const screens = Grid.useBreakpoint();
|
||||
useEffect(() => {
|
||||
getSosData();
|
||||
}, []);
|
||||
|
||||
const getSosData = async () => {
|
||||
try {
|
||||
const sosData = await getSos();
|
||||
console.log('SOS Data: ', sosData);
|
||||
|
||||
setSosData(sosData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch SOS data:', error);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
getSosData();
|
||||
}, []);
|
||||
// map width cho từng breakpoint
|
||||
const getWidth = () => {
|
||||
if (screens.xs) return '95%'; // mobile
|
||||
@@ -50,33 +50,33 @@ const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
|
||||
if (messageData) {
|
||||
try {
|
||||
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
|
||||
onRefresh?.(true);
|
||||
} catch (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 {
|
||||
try {
|
||||
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
|
||||
onRefresh?.(true);
|
||||
} catch (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 items-center gap-2">
|
||||
<WarningOutlined style={{ color: 'white', fontSize: '20px' }} />
|
||||
<Typography.Text className="text-white text-sm sm:text-base">
|
||||
Đang trong trạng thái khẩn cấp
|
||||
{t('sos.status')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -86,13 +86,13 @@ const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
|
||||
className="self-end sm:self-auto"
|
||||
onClick={async () => await handleSos()}
|
||||
>
|
||||
Kết thúc
|
||||
{t('sos.end')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ModalForm
|
||||
title="Thông báo khẩn cấp"
|
||||
initialValues={{ message: 'Tình huống khẩn cấp, không kịp chọn !!!' }}
|
||||
title={t('sos.title')}
|
||||
initialValues={{ message: t('sos.defaultMessage') }}
|
||||
form={form}
|
||||
width={getWidth()}
|
||||
modalProps={{
|
||||
@@ -112,11 +112,11 @@ const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
|
||||
danger
|
||||
shape="round"
|
||||
>
|
||||
Khẩn cấp
|
||||
{t('sos.button')}
|
||||
</Button>
|
||||
}
|
||||
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
|
||||
const finalMessage =
|
||||
@@ -129,16 +129,16 @@ const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
|
||||
>
|
||||
<ProFormSelect
|
||||
options={[
|
||||
{ value: 'other', label: 'Khác' },
|
||||
{ value: 'other', label: t('sos.other') },
|
||||
...sosMessage.map((item) => ({
|
||||
value: item.moTa,
|
||||
label: item.moTa,
|
||||
})),
|
||||
]}
|
||||
name="message"
|
||||
rules={[{ required: true, message: 'Vui lòng chọn hoặc nhập lý do!' }]}
|
||||
placeholder="Chọn hoặc nhập lý do..."
|
||||
label="Nội dung:"
|
||||
rules={[{ required: true, message: t('sos.validationError') }]}
|
||||
placeholder={t('sos.placeholder')}
|
||||
label={t('sos.label')}
|
||||
/>
|
||||
|
||||
<ProFormDependency name={['message']}>
|
||||
@@ -146,9 +146,11 @@ const SosButton: React.FC<SosButtonProps> = ({ onRefresh }) => {
|
||||
message === 'other' ? (
|
||||
<ProFormText
|
||||
name="messageOther"
|
||||
placeholder="Nhập lý do khác..."
|
||||
rules={[{ required: true, message: 'Vui lòng nhập lý do khác!' }]}
|
||||
label="Lý do khác"
|
||||
placeholder={t('sos.otherPlaceholder')}
|
||||
rules={[
|
||||
{ required: true, message: t('sos.otherValidationError') },
|
||||
]}
|
||||
label={t('sos.otherLabel')}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const VietNamMap: React.FC<VietNamMapProps> = React.memo(
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('useEffect in VietNamMap triggered');
|
||||
// console.log('useEffect in VietNamMap triggered');
|
||||
let manager = mapManager;
|
||||
|
||||
if (!manager) {
|
||||
@@ -38,10 +38,10 @@ const VietNamMap: React.FC<VietNamMapProps> = React.memo(
|
||||
manager!.mapRef = mapRef;
|
||||
if (!manager!.map) {
|
||||
manager!.initializeMap();
|
||||
console.log(
|
||||
'Initialized new MapManager instance at',
|
||||
new Date().toLocaleTimeString(),
|
||||
);
|
||||
// console.log(
|
||||
// 'Initialized new MapManager instance at',
|
||||
// new Date().toLocaleTimeString(),
|
||||
// );
|
||||
}
|
||||
} else if (retryCount < maxRetries) {
|
||||
console.error('mapRef.current is not ready, retrying...');
|
||||
@@ -55,7 +55,7 @@ const VietNamMap: React.FC<VietNamMapProps> = React.memo(
|
||||
initialize();
|
||||
|
||||
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
|
||||
};
|
||||
}, [mapManager, onFeatureClick, onFeatureSelect, onFeaturesClick, onError]);
|
||||
|
||||
@@ -1,19 +1,54 @@
|
||||
import { ROUTE_TRIP } from '@/constants';
|
||||
import { queryAlarms } from '@/services/controller/DeviceController';
|
||||
import {
|
||||
MAP_POLYGON_BAN,
|
||||
MAP_POLYLINE_BAN,
|
||||
MAP_TRACKPOINTS_ID,
|
||||
ROUTE_TRIP,
|
||||
} from '@/constants';
|
||||
import { ENTITY_TYPE_ENUM } from '@/constants/enums';
|
||||
import {
|
||||
queryAlarms,
|
||||
queryEntities,
|
||||
queryShipTrackPoints,
|
||||
} from '@/services/controller/DeviceController';
|
||||
import { queryBanzones } from '@/services/controller/MapController';
|
||||
import { getShipIcon } from '@/services/service/MapService';
|
||||
import { eventBus } from '@/utils/eventBus';
|
||||
import {
|
||||
convertWKTLineStringToLatLngArray,
|
||||
convertWKTtoLatLngString,
|
||||
getBanzoneNameByType,
|
||||
} from '@/utils/geomUtils';
|
||||
import {
|
||||
CommentOutlined,
|
||||
InfoCircleOutlined,
|
||||
InfoOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { history, useModel } from '@umijs/max';
|
||||
import { history, useIntl, useModel } from '@umijs/max';
|
||||
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 GpsInfo from './components/GpsInfo';
|
||||
import ShipInfo from './components/ShipInfo';
|
||||
import SosButton from './components/SosButton';
|
||||
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 {
|
||||
lat: number;
|
||||
lon: number;
|
||||
@@ -25,29 +60,33 @@ const HomePage: React.FC = () => {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const [isShipInfoOpen, setIsShipInfoOpen] = useState(false);
|
||||
const { gpsData, getGPSData } = useModel('getSos');
|
||||
|
||||
const hasCenteredRef = useRef(false);
|
||||
const onFeatureClick = useCallback((feature: any) => {
|
||||
console.log('OnClick Feature: ', feature);
|
||||
console.log(
|
||||
'Clicked ship at',
|
||||
new Date().toLocaleTimeString(),
|
||||
'Properties:',
|
||||
feature.getProperties(),
|
||||
);
|
||||
// console.log(
|
||||
// 'Clicked ship at',
|
||||
// new Date().toLocaleTimeString(),
|
||||
// 'Properties:',
|
||||
// feature.getProperties(),
|
||||
// );
|
||||
}, []);
|
||||
|
||||
const intl = useIntl();
|
||||
const onFeaturesClick = useCallback((features: any[]) => {
|
||||
console.log('Multiple features clicked:', features);
|
||||
}, []);
|
||||
|
||||
const onError = useCallback((error: any) => {
|
||||
const onError = useCallback(
|
||||
(error: any) => {
|
||||
console.error(
|
||||
'Lỗi khi thêm vào map at',
|
||||
intl.formatMessage({ id: 'home.mapError' }),
|
||||
'at',
|
||||
new Date().toLocaleTimeString(),
|
||||
':',
|
||||
error,
|
||||
);
|
||||
}, []);
|
||||
},
|
||||
[intl],
|
||||
);
|
||||
|
||||
const mapManagerRef = useRef(
|
||||
new MapManager(mapRef, {
|
||||
@@ -59,51 +98,253 @@ const HomePage: React.FC = () => {
|
||||
|
||||
const [isShowGPSData, setIsShowGPSData] = useState(true);
|
||||
|
||||
// Hàm tổng hợp: gọi GPS trước, sau đó gọi các API còn lại
|
||||
const refreshAllData = async () => {
|
||||
try {
|
||||
// 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
|
||||
await getGPSData();
|
||||
} catch (e) {
|
||||
console.error('refreshAllData error', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to add features to the map (kept above pollers to avoid use-before-define)
|
||||
function addFeatureToMap(
|
||||
mapManager: MapManager,
|
||||
gpsData: GpsData,
|
||||
alarm: API.AlarmResponse,
|
||||
) {
|
||||
const isSos = alarm?.alarms.find(
|
||||
(a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING,
|
||||
);
|
||||
|
||||
if (mapManager?.featureLayer && gpsData) {
|
||||
mapManager.featureLayer.getSource()?.clear();
|
||||
mapManager.addPoint(
|
||||
[gpsData.lon, gpsData.lat],
|
||||
{ bearing: gpsData.h || 0 },
|
||||
{
|
||||
icon: getShipIcon(alarm.level || 0, gpsData?.fishing || false),
|
||||
scale: 0.1,
|
||||
animate: isSos ? true : false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
const drawBanzones = async (mapManager: MapManager) => {
|
||||
try {
|
||||
const layer = mapManager.featureLayer?.getSource();
|
||||
layer?.getFeatures()?.forEach((f) => {
|
||||
if ([MAP_POLYGON_BAN, MAP_POLYLINE_BAN].includes(f.get('id'))) {
|
||||
layer.removeFeature(f);
|
||||
}
|
||||
});
|
||||
|
||||
const entities = await queryEntities();
|
||||
for (const entity of entities) {
|
||||
if (entity.id === '50:2' && entity.valueString !== '[]') {
|
||||
const banzones = await queryBanzones();
|
||||
const zones: any[] = JSON.parse(entity.valueString);
|
||||
zones.forEach((zone: any) => {
|
||||
const geom = banzones.find((b) => b.id === zone.zone_id);
|
||||
if (geom) {
|
||||
const { geom_type, geom_lines, geom_poly } = geom.geom || {};
|
||||
if (geom_type === 2) {
|
||||
const coordinates = convertWKTLineStringToLatLngArray(
|
||||
geom_lines || '',
|
||||
);
|
||||
if (coordinates.length > 0) {
|
||||
mapManager.addLineString(
|
||||
coordinates,
|
||||
{
|
||||
id: MAP_POLYLINE_BAN,
|
||||
text: `${geom.name} - ${zone.message}`,
|
||||
},
|
||||
{ strokeColor: 'red', strokeWidth: 4 },
|
||||
);
|
||||
}
|
||||
} else if (geom_type === 1) {
|
||||
const coordinates = convertWKTtoLatLngString(geom_poly || '');
|
||||
if (coordinates.length > 0) {
|
||||
mapManager.addPolygon(
|
||||
coordinates,
|
||||
{
|
||||
id: MAP_POLYGON_BAN,
|
||||
name: geom.name,
|
||||
type: getBanzoneNameByType(geom.type || 4),
|
||||
description: zone.message,
|
||||
},
|
||||
{
|
||||
strokeColor: '#FCC61D',
|
||||
strokeWidth: 2,
|
||||
fillColor: '#FCC61D',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (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 () => {
|
||||
mapManagerRef.current?.destroy();
|
||||
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(() => {
|
||||
const timer = setTimeout(() => {
|
||||
getEntitiesData();
|
||||
}, 500); // delay 1s
|
||||
return () => clearTimeout(timer);
|
||||
}, [gpsData]);
|
||||
// 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);
|
||||
|
||||
const getEntitiesData = async () => {
|
||||
// Gọi các API khác trong background, không block ship update
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const alarm = await queryAlarms();
|
||||
console.log('GPS Data:', gpsData);
|
||||
|
||||
if (mapManagerRef.current?.featureLayer) {
|
||||
mapManagerRef.current.featureLayer.getSource()?.clear();
|
||||
try {
|
||||
mapManagerRef.current.addPoint(
|
||||
[gpsData!.lon, gpsData!.lat],
|
||||
{
|
||||
bearing: gpsData!.h || 0,
|
||||
},
|
||||
{
|
||||
icon: getShipIcon(alarm.level || 1, gpsData?.fishing || false),
|
||||
scale: 0.1,
|
||||
},
|
||||
);
|
||||
} catch (parseError) {
|
||||
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(
|
||||
`Error parsing valueString for entity: ${parseError}`,
|
||||
parseError,
|
||||
intl.formatMessage({ id: 'home.errorBackground' }),
|
||||
e,
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching entities:', error);
|
||||
} 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 (
|
||||
<div>
|
||||
<VietNamMap
|
||||
@@ -118,14 +359,11 @@ const HomePage: React.FC = () => {
|
||||
/>
|
||||
<Popover
|
||||
styles={{
|
||||
root: { width: '85%', maxWidth: 500, paddingLeft: 15 },
|
||||
root: { width: '85%', maxWidth: 800, minWidth: 700, paddingLeft: 15 },
|
||||
}}
|
||||
placement="left"
|
||||
title="Trạng thái hiện tại"
|
||||
content={
|
||||
<GpsInfo gpsData={gpsData} />
|
||||
// <ShipInfo />
|
||||
}
|
||||
title={intl.formatMessage({ id: 'home.currentStatus' })}
|
||||
content={<GpsInfo gpsData={gpsData} />}
|
||||
open={isShowGPSData}
|
||||
>
|
||||
<FloatButton.Group
|
||||
@@ -137,20 +375,20 @@ const HomePage: React.FC = () => {
|
||||
<FloatButton
|
||||
icon={<CommentOutlined />}
|
||||
onClick={() => history.push(ROUTE_TRIP)}
|
||||
tooltip="Thông tin chuyến đi"
|
||||
tooltip={intl.formatMessage({ id: 'home.tripInfo' })}
|
||||
/>
|
||||
<FloatButton
|
||||
icon={<InfoOutlined />}
|
||||
tooltip="Thông tin tàu"
|
||||
tooltip={intl.formatMessage({ id: 'home.shipInfo' })}
|
||||
onClick={() => setIsShipInfoOpen(true)}
|
||||
/>
|
||||
</FloatButton.Group>
|
||||
</Popover>
|
||||
<div className="absolute top-3 right-3 ">
|
||||
<SosButton
|
||||
onRefresh={(value) => {
|
||||
onRefresh={async (value) => {
|
||||
if (value) {
|
||||
getEntitiesData();
|
||||
await refreshAllData();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -86,7 +86,7 @@ export const AlarmTable: React.FC<AlarmTableProps> = ({
|
||||
return (
|
||||
<>
|
||||
<ProList<API.Alarm>
|
||||
// bordered
|
||||
bordered
|
||||
actionRef={actionRef}
|
||||
metas={columns}
|
||||
polling={DURATION_POLLING_PRESENTATIONS}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useIntl } from '@umijs/max';
|
||||
import type { BadgeProps } from 'antd';
|
||||
import { Badge } from 'antd';
|
||||
import React from 'react';
|
||||
@@ -7,6 +8,7 @@ interface BadgeTripStatusProps {
|
||||
}
|
||||
|
||||
const BadgeTripStatus: React.FC<BadgeTripStatusProps> = ({ status }) => {
|
||||
const intl = useIntl();
|
||||
// Khai báo kiểu cho map
|
||||
const statusBadgeMap: Record<number, BadgeProps> = {
|
||||
0: { status: 'default' }, // Đã khởi tạo
|
||||
@@ -20,19 +22,19 @@ const BadgeTripStatus: React.FC<BadgeTripStatusProps> = ({ status }) => {
|
||||
const getBadgeProps = (status: number | undefined) => {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return 'Chưa được phê duyệt';
|
||||
return intl.formatMessage({ id: 'trip.badge.notApproved' });
|
||||
case 1:
|
||||
return 'Đang chờ duyệt';
|
||||
return intl.formatMessage({ id: 'trip.badge.waitingApproval' });
|
||||
case 2:
|
||||
return 'Đã duyệt';
|
||||
return intl.formatMessage({ id: 'trip.badge.approved' });
|
||||
case 3:
|
||||
return 'Đang hoạt động';
|
||||
return intl.formatMessage({ id: 'trip.badge.active' });
|
||||
case 4:
|
||||
return 'Đã hoàn thành';
|
||||
return intl.formatMessage({ id: 'trip.badge.completed' });
|
||||
case 5:
|
||||
return 'Đã huỷ';
|
||||
return intl.formatMessage({ id: 'trip.badge.cancelled' });
|
||||
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 { useIntl } from '@umijs/max';
|
||||
import { Button, Form } from 'antd';
|
||||
interface CancelTripProps {
|
||||
onFinished?: (note: string) => void;
|
||||
}
|
||||
const CancelTrip: React.FC<CancelTripProps> = ({ onFinished }) => {
|
||||
const intl = useIntl();
|
||||
const [form] = Form.useForm<{ note: string }>();
|
||||
return (
|
||||
<ModalForm
|
||||
title="Xác nhận huỷ chuyến đi"
|
||||
title={intl.formatMessage({ id: 'trip.cancelTrip.title' })}
|
||||
form={form}
|
||||
modalProps={{
|
||||
destroyOnHidden: true,
|
||||
@@ -15,7 +17,7 @@ const CancelTrip: React.FC<CancelTripProps> = ({ onFinished }) => {
|
||||
}}
|
||||
trigger={
|
||||
<Button color="danger" variant="solid">
|
||||
Huỷ chuyến đi
|
||||
{intl.formatMessage({ id: 'trip.cancelTrip.button' })}
|
||||
</Button>
|
||||
}
|
||||
onFinish={async (values) => {
|
||||
@@ -25,10 +27,13 @@ const CancelTrip: React.FC<CancelTripProps> = ({ onFinished }) => {
|
||||
>
|
||||
<ProFormTextArea
|
||||
name="note"
|
||||
label="Lý do: "
|
||||
placeholder={'Nhập lý do huỷ chuyến đi...'}
|
||||
label={intl.formatMessage({ id: 'trip.cancelTrip.reason' })}
|
||||
placeholder={intl.formatMessage({ id: 'trip.cancelTrip.placeholder' })}
|
||||
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>
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
updateTripState,
|
||||
} from '@/services/controller/TripController';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { Button, message, theme } from 'antd';
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Button, Grid, message } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import CreateOrUpdateFishingLog from './CreateOrUpdateFishingLog';
|
||||
|
||||
@@ -20,33 +20,35 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
|
||||
onCallBack,
|
||||
}) => {
|
||||
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
|
||||
const { token } = theme.useToken();
|
||||
const intl = useIntl();
|
||||
const { getApi } = useModel('getTrip');
|
||||
const checkHaulFinished = () => {
|
||||
return trips?.fishing_logs?.some((h) => h.status === 0);
|
||||
};
|
||||
const { useBreakpoint } = Grid;
|
||||
const screens = useBreakpoint();
|
||||
|
||||
const createNewHaul = async () => {
|
||||
if (trips?.fishing_logs?.some((f) => f.status === 0)) {
|
||||
message.warning(
|
||||
'Vui lòng kết thúc mẻ lưới hiện tại trước khi bắt đầu mẻ mới',
|
||||
intl.formatMessage({ id: 'trip.warning.haulNotFinished' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const gpsData = await getGPS();
|
||||
console.log('GPS Data:', gpsData);
|
||||
// console.log('GPS Data:', gpsData);
|
||||
|
||||
const body: API.NewFishingLogRequest = {
|
||||
trip_id: trips?.id || '',
|
||||
start_at: new Date(),
|
||||
start_lat: gpsData.lat,
|
||||
start_lon: gpsData.lon,
|
||||
weather_description: 'Nắng đẹp',
|
||||
weather_description: intl.formatMessage({ id: 'trip.weather' }),
|
||||
};
|
||||
|
||||
const resp = await startNewHaul(body);
|
||||
await startNewHaul(body);
|
||||
onCallBack?.(STATUS.CREATE_FISHING_LOG_SUCCESS);
|
||||
getApi();
|
||||
} catch (error) {
|
||||
@@ -57,11 +59,11 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
|
||||
|
||||
const handleStartTrip = async (state: number, note?: string) => {
|
||||
if (trips?.trip_status !== 2) {
|
||||
message.warning('Chuyến đi đã được bắt đầu hoặc hoàn thành.');
|
||||
message.warning(intl.formatMessage({ id: 'trip.warning.tripStarted' }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await updateTripState({ status: state, note: note || '' });
|
||||
await updateTripState({ status: state, note: note || '' });
|
||||
onCallBack?.(STATUS.START_TRIP_SUCCESS);
|
||||
getApi();
|
||||
} catch (error) {
|
||||
@@ -76,14 +78,19 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: screens.sm ? '0px' : '10px',
|
||||
marginRight: screens.sm ? '24px' : '0px',
|
||||
}}
|
||||
>
|
||||
{trips?.trip_status === 2 ? (
|
||||
<Button
|
||||
color="green"
|
||||
variant="solid"
|
||||
onClick={async () => handleStartTrip(3)}
|
||||
>
|
||||
Bắt đầu chuyến đi
|
||||
{intl.formatMessage({ id: 'trip.startTrip' })}
|
||||
</Button>
|
||||
) : checkHaulFinished() ? (
|
||||
<Button
|
||||
@@ -95,7 +102,7 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
|
||||
color="geekblue"
|
||||
variant="solid"
|
||||
>
|
||||
Kết thúc mẻ lưới
|
||||
{intl.formatMessage({ id: 'trip.finishHaul' })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -107,7 +114,7 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
|
||||
color="cyan"
|
||||
variant="solid"
|
||||
>
|
||||
Bắt đầu mẻ lưới
|
||||
{intl.formatMessage({ id: 'trip.startHaul' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -126,7 +133,7 @@ const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
|
||||
import { getGPS } from '@/services/controller/DeviceController';
|
||||
import {
|
||||
getFishSpecies,
|
||||
@@ -35,10 +36,19 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
|
||||
>([]);
|
||||
const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
|
||||
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(() => {
|
||||
getAllFish();
|
||||
if (isOpen) {
|
||||
console.log('Modal opened with fishingLogs:', fishingLogs);
|
||||
// console.log('Modal opened with fishingLogs:', fishingLogs);
|
||||
|
||||
if (fishingLogs?.info && fishingLogs.info.length > 0) {
|
||||
const dataWithKeys: FishingLogInfoWithKey[] = fishingLogs.info.map(
|
||||
@@ -57,15 +67,6 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
|
||||
}
|
||||
}, [isOpen, fishingLogs]);
|
||||
|
||||
const getAllFish = async () => {
|
||||
try {
|
||||
const resp = await getFishSpecies();
|
||||
setFishDatas(resp);
|
||||
console.log('Fetched fish species:', resp);
|
||||
} catch (error) {
|
||||
console.error('Error fetching fish species:', error);
|
||||
}
|
||||
};
|
||||
const columns: ProColumns<FishingLogInfoWithKey>[] = [
|
||||
{
|
||||
title: 'Tên cá',
|
||||
@@ -157,17 +158,17 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
|
||||
async function createOrCreateOrUpdateFishingLog(
|
||||
fishingLog: FishingLogInfoWithKey[],
|
||||
) {
|
||||
console.log('Is finished:', isFinished);
|
||||
console.log('Trip:', trip);
|
||||
// console.log('Is finished:', isFinished);
|
||||
// console.log('Trip:', trip);
|
||||
|
||||
try {
|
||||
const gpsData = await getGPS();
|
||||
if (gpsData) {
|
||||
if (isFinished == false) {
|
||||
if (isFinished === false) {
|
||||
// Tạo mẻ mới
|
||||
const logStatus0 = trip.fishing_logs?.find((log) => log.status === 0);
|
||||
console.log('ok', logStatus0);
|
||||
console.log('ok', fishingLog);
|
||||
// console.log('ok', logStatus0);
|
||||
// console.log('ok', fishingLog);
|
||||
const body: API.FishingLog = {
|
||||
fishing_log_id: logStatus0?.fishing_log_id || '',
|
||||
trip_id: trip.id,
|
||||
@@ -191,8 +192,8 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
|
||||
})),
|
||||
sync: true,
|
||||
};
|
||||
const resp = await updateFishingLogs(body);
|
||||
console.log('Resp', resp);
|
||||
await updateFishingLogs(body);
|
||||
// console.log('Resp', resp);
|
||||
|
||||
onFinished?.(true);
|
||||
onOpenChange(false);
|
||||
@@ -222,8 +223,8 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
|
||||
};
|
||||
// console.log('Update body:', body);
|
||||
|
||||
const resp = await updateFishingLogs(body);
|
||||
console.log('Resp', resp);
|
||||
await updateFishingLogs(body);
|
||||
// console.log('Resp', resp);
|
||||
|
||||
onFinished?.(true);
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
open: boolean;
|
||||
@@ -12,48 +13,48 @@ const HaulFishList: React.FC<HaulFishListProp> = ({
|
||||
onOpenChange,
|
||||
fishList,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const intl = useIntl();
|
||||
const fish_columns: ProColumns<API.FishingLogInfo>[] = [
|
||||
{
|
||||
title: 'Tên cá',
|
||||
title: intl.formatMessage({ id: 'trip.haulFishList.fishName' }),
|
||||
dataIndex: 'fish_name',
|
||||
key: 'fish_name',
|
||||
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||
render: (value) => value,
|
||||
},
|
||||
{
|
||||
title: 'Trạng thái',
|
||||
title: intl.formatMessage({ id: 'trip.haulFishList.fishCondition' }),
|
||||
dataIndex: 'fish_condition',
|
||||
key: 'fish_condition',
|
||||
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||
render: (value) => value,
|
||||
},
|
||||
{
|
||||
title: 'Độ hiếm',
|
||||
title: intl.formatMessage({ id: 'trip.haulFishList.fishRarity' }),
|
||||
dataIndex: 'fish_rarity',
|
||||
key: 'fish_rarity',
|
||||
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||
render: (value) => value,
|
||||
},
|
||||
{
|
||||
title: 'Kích thước (cm)',
|
||||
title: intl.formatMessage({ id: 'trip.haulFishList.fishSize' }),
|
||||
dataIndex: 'fish_size',
|
||||
key: 'fish_size',
|
||||
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||
render: (value) => value,
|
||||
},
|
||||
{
|
||||
title: 'Cân nặng (kg)',
|
||||
title: intl.formatMessage({ id: 'trip.haulFishList.weight' }),
|
||||
dataIndex: 'catch_number',
|
||||
key: 'catch_number',
|
||||
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||
render: (value) => value,
|
||||
},
|
||||
{
|
||||
title: 'Ngư cụ sử dụng',
|
||||
title: intl.formatMessage({ id: 'trip.haulFishList.gearUsage' }),
|
||||
dataIndex: 'gear_usage',
|
||||
key: 'gear_usage',
|
||||
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
|
||||
render: (value) => value,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Modal
|
||||
title="Danh sách cá"
|
||||
title={intl.formatMessage({ id: 'trip.haulFishList.title' })}
|
||||
open={open}
|
||||
footer={null}
|
||||
closable
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface HaulTableProps {
|
||||
trip?: API.Trip;
|
||||
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 [editFishingLogOpen, setEditFishingLogOpen] = useState(false);
|
||||
const [currentRow, setCurrentRow] = useState<API.FishingLogInfo[]>([]);
|
||||
@@ -19,53 +19,67 @@ const HaulTable: React.FC<HaulTableProps> = ({ hauls, trip, onReload }) => {
|
||||
useState<API.FishingLog | null>(null);
|
||||
const intl = useIntl();
|
||||
const { getApi } = useModel('getTrip');
|
||||
console.log('HaulTable received hauls:', hauls);
|
||||
|
||||
const fishing_logs_columns: ProColumns<API.FishingLog>[] = [
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>STT</div>,
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.haulTable.no' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'fishing_log_id',
|
||||
align: 'center',
|
||||
render: (_, __, index, action) => {
|
||||
return `Mẻ ${hauls.length - index}`;
|
||||
render: (_, __, index) => {
|
||||
return `${intl.formatMessage({ id: 'trip.haulTable.haul' })} ${
|
||||
hauls.length - index
|
||||
}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Trạng Thái</div>,
|
||||
dataIndex: ['status'], // 👈 lấy từ status 1: đang
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.haulTable.status' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: ['status'],
|
||||
align: 'center',
|
||||
valueEnum: {
|
||||
0: {
|
||||
text: intl.formatMessage({
|
||||
id: 'pages.trips.status.fishing',
|
||||
defaultMessage: 'Đang đánh bắt',
|
||||
id: 'trip.haulTable.fishing',
|
||||
}),
|
||||
status: 'Processing',
|
||||
},
|
||||
1: {
|
||||
text: intl.formatMessage({
|
||||
id: 'pages.trips.status.end_fishing',
|
||||
defaultMessage: 'Đã hoàn thành',
|
||||
id: 'trip.haulTable.endFishing',
|
||||
}),
|
||||
status: 'Success',
|
||||
},
|
||||
2: {
|
||||
text: intl.formatMessage({
|
||||
id: 'pages.trips.status.cancel_fishing',
|
||||
defaultMessage: 'Đã huỷ',
|
||||
id: 'trip.haulTable.cancelFishing',
|
||||
}),
|
||||
status: 'default',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Thời tiết</div>,
|
||||
dataIndex: ['weather_description'], // 👈 lấy từ weather
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.haulTable.weather' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: ['weather_description'],
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Thời điểm bắt đầu</div>,
|
||||
dataIndex: ['start_at'], // birth_date là date of birth
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.haulTable.startTime' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: ['start_at'],
|
||||
align: 'center',
|
||||
render: (start_at: any) => {
|
||||
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>,
|
||||
dataIndex: ['end_at'], // birth_date là date of birth
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.haulTable.endTime' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: ['end_at'],
|
||||
align: 'center',
|
||||
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);
|
||||
return date.toLocaleString('vi-VN', {
|
||||
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',
|
||||
hideInSearch: true,
|
||||
render: (_, record) => {
|
||||
console.log('Rendering action column for record:', record);
|
||||
// console.log('Rendering action column for record:', record);
|
||||
return (
|
||||
<Flex align="center" justify="center" gap={5}>
|
||||
{/* 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
|
||||
setEditOpen(true);
|
||||
} 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}
|
||||
onFinished={(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();
|
||||
} else {
|
||||
message.error('Cập nhật mẻ lưới thất bại');
|
||||
message.error(
|
||||
intl.formatMessage({ id: 'trip.haulTable.updateError' }),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ProCard } from '@ant-design/pro-components';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Flex, Grid } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import HaulTable from './HaulTable';
|
||||
import TripCostTable from './TripCost';
|
||||
import TripCrews from './TripCrews';
|
||||
@@ -13,53 +12,17 @@ interface MainTripBodyProps {
|
||||
onReload?: (isTrue: boolean) => void;
|
||||
}
|
||||
|
||||
const MainTripBody: React.FC<MainTripBodyProps> = ({
|
||||
trip_id,
|
||||
tripInfo,
|
||||
onReload,
|
||||
}) => {
|
||||
// console.log('MainTripBody received:');
|
||||
// console.log("trip_id:", trip_id);
|
||||
// console.log('tripInfo:', tripInfo);
|
||||
const MainTripBody: React.FC<MainTripBodyProps> = ({ tripInfo }) => {
|
||||
const { useBreakpoint } = Grid;
|
||||
const screens = useBreakpoint();
|
||||
const [isResponsive, setIsResponsive] = useState(false);
|
||||
const { data, getApi } = useModel('getTrip');
|
||||
const { getApi } = useModel('getTrip');
|
||||
const tripCosts = Array.isArray(tripInfo?.trip_cost)
|
||||
? tripInfo.trip_cost
|
||||
: [];
|
||||
const fishingGears = Array.isArray(tripInfo?.fishing_gears)
|
||||
? tripInfo.fishing_gears
|
||||
: [];
|
||||
|
||||
const fishing_logs_columns = [
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Tên</div>,
|
||||
dataIndex: 'name',
|
||||
valueType: 'select',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Số lượng</div>,
|
||||
dataIndex: 'number',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const tranship_columns = [
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Tên</div>,
|
||||
dataIndex: 'name',
|
||||
valueType: 'select',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Chức vụ</div>,
|
||||
dataIndex: 'role',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Flex gap={10}>
|
||||
<ProCard
|
||||
@@ -77,11 +40,14 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
|
||||
padding: 0,
|
||||
paddingInline: 0,
|
||||
gap: 10,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<ProCard
|
||||
split={screens.lg ? 'vertical' : 'horizontal'}
|
||||
bodyStyle={{ padding: 0, gap: 5 }}
|
||||
gutter={16} // tạo khoảng cách thay vì split (không có border)
|
||||
direction={screens.lg ? 'row' : 'column'} // responsive: ngang/dọc
|
||||
bodyStyle={{ padding: 0, gap: 10 }}
|
||||
style={{ color: 'transparent' }}
|
||||
>
|
||||
<ProCard
|
||||
colSpan={{ xs: 24, sm: 24, lg: 12, xl: 12 }}
|
||||
@@ -92,9 +58,8 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
title="Chi phí chuyến đi"
|
||||
title={intl.formatMessage({ id: 'trip.mainBody.tripCost' })}
|
||||
style={{ minHeight: 300 }}
|
||||
>
|
||||
<TripCostTable tripCosts={tripCosts} />
|
||||
@@ -110,7 +75,7 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
title="Danh sách ngư cụ"
|
||||
title={intl.formatMessage({ id: 'trip.mainBody.fishingGear' })}
|
||||
style={{ minHeight: 300 }}
|
||||
>
|
||||
<TripFishingGearTable fishingGears={fishingGears} />
|
||||
@@ -128,7 +93,7 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
title="Danh sách thuyền viên"
|
||||
title={intl.formatMessage({ id: 'trip.mainBody.crew' })}
|
||||
>
|
||||
<TripCrews crew={tripInfo?.crews} />
|
||||
</ProCard>
|
||||
@@ -144,14 +109,13 @@ const MainTripBody: React.FC<MainTripBodyProps> = ({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
title="Danh sách mẻ lưới"
|
||||
title={intl.formatMessage({ id: 'trip.mainBody.haulList' })}
|
||||
>
|
||||
<HaulTable
|
||||
trip={tripInfo!}
|
||||
hauls={tripInfo?.fishing_logs || []}
|
||||
onReload={(isTrue) => {
|
||||
if (isTrue) {
|
||||
// onReload?.(true);
|
||||
getApi();
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { updateTripState } from '@/services/controller/TripController';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Button, message, Popconfirm } from 'antd';
|
||||
import React from 'react';
|
||||
import CancelTrip from './CancelTrip';
|
||||
@@ -14,15 +14,20 @@ const TripCancleOrFinishedButton: React.FC<TripCancleOrFinishedButtonProps> = ({
|
||||
onCallBack,
|
||||
}) => {
|
||||
const { getApi } = useModel('getTrip');
|
||||
const intl = useIntl();
|
||||
const handleClickButton = async (state: number, note?: string) => {
|
||||
try {
|
||||
const resp = await updateTripState({ status: state, note: note || '' });
|
||||
message.success('Cập nhật trạng thái thành công');
|
||||
await updateTripState({ status: state, note: note || '' });
|
||||
message.success(
|
||||
intl.formatMessage({ id: 'trip.finishButton.updateSuccess' }),
|
||||
);
|
||||
getApi();
|
||||
onCallBack?.(true);
|
||||
} catch (error) {
|
||||
console.error('Error updating trip status:', error);
|
||||
message.error('Cập nhật trạng thái thất bại');
|
||||
message.error(
|
||||
intl.formatMessage({ id: 'trip.finishButton.updateError' }),
|
||||
);
|
||||
onCallBack?.(false);
|
||||
}
|
||||
};
|
||||
@@ -37,14 +42,22 @@ const TripCancleOrFinishedButton: React.FC<TripCancleOrFinishedButtonProps> = ({
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="Thông báo"
|
||||
description="Bạn chắc chắn muốn kết thúc chuyến đi?"
|
||||
title={intl.formatMessage({
|
||||
id: 'trip.finishButton.confirmTitle',
|
||||
})}
|
||||
description={intl.formatMessage({
|
||||
id: 'trip.finishButton.confirmMessage',
|
||||
})}
|
||||
onConfirm={async () => handleClickButton(4)}
|
||||
okText="Chắc chắn"
|
||||
cancelText="Không"
|
||||
okText={intl.formatMessage({
|
||||
id: 'trip.finishButton.confirmYes',
|
||||
})}
|
||||
cancelText={intl.formatMessage({
|
||||
id: 'trip.finishButton.confirmNo',
|
||||
})}
|
||||
>
|
||||
<Button color="orange" variant="solid">
|
||||
Kết thúc
|
||||
{intl.formatMessage({ id: 'trip.finishButton.end' })}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { Grid, Typography } from 'antd';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
interface TripCostTableProps {
|
||||
tripCosts: API.TripCost[];
|
||||
}
|
||||
|
||||
const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
|
||||
const intl = useIntl();
|
||||
// Tính tổng chi phí
|
||||
const total_trip_cost = tripCosts.reduce(
|
||||
(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>[] = [
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Loại</div>,
|
||||
key: 'type',
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.cost.type' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'type',
|
||||
valueEnum: {
|
||||
fuel: { text: 'Nhiên liệu' },
|
||||
crew_salary: { text: 'Lương thuyền viên' },
|
||||
food: { text: 'Lương thực' },
|
||||
ice_salt_cost: { text: 'Muối đá' },
|
||||
fuel: { text: intl.formatMessage({ id: 'trip.cost.fuel' }) },
|
||||
crew_salary: {
|
||||
text: intl.formatMessage({ id: 'trip.cost.crewSalary' }),
|
||||
},
|
||||
food: { text: intl.formatMessage({ id: 'trip.cost.food' }) },
|
||||
ice_salt_cost: {
|
||||
text: intl.formatMessage({ id: 'trip.cost.iceSalt' }),
|
||||
},
|
||||
},
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
align: 'center',
|
||||
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',
|
||||
align: 'center',
|
||||
render: (val: any) =>
|
||||
@@ -51,10 +82,9 @@ const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
|
||||
),
|
||||
},
|
||||
];
|
||||
const { useBreakpoint } = Grid;
|
||||
const screens = useBreakpoint();
|
||||
return (
|
||||
<ProTable<API.TripCost>
|
||||
rowKey="trip-cost-table"
|
||||
columns={trip_cost_columns}
|
||||
dataSource={tripCosts}
|
||||
search={false}
|
||||
@@ -72,7 +102,7 @@ const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
|
||||
<ProTable.Summary.Row>
|
||||
<ProTable.Summary.Cell index={0} colSpan={4} align="right">
|
||||
<Typography.Text strong style={{ color: '#1890ff' }}>
|
||||
Tổng cộng
|
||||
{intl.formatMessage({ id: 'trip.cost.grandTotal' })}
|
||||
</Typography.Text>
|
||||
</ProTable.Summary.Cell>
|
||||
<ProTable.Summary.Cell index={4} align="center">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import React from 'react';
|
||||
|
||||
interface TripCrewsProps {
|
||||
@@ -6,57 +7,93 @@ interface TripCrewsProps {
|
||||
}
|
||||
|
||||
const TripCrews: React.FC<TripCrewsProps> = ({ crew }) => {
|
||||
console.log('TripCrews received crew:', crew);
|
||||
const intl = useIntl();
|
||||
|
||||
const crew_columns: ProColumns<API.TripCrews>[] = [
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Mã định danh</div>,
|
||||
dataIndex: ['Person', 'personal_id'], // 👈 lấy từ Person.personal_id
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.crew.id' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: ['Person', 'personal_id'],
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Tên</div>,
|
||||
dataIndex: ['Person', 'name'], // 👈 lấy từ Person.name
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.crew.name' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: ['Person', 'name'],
|
||||
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',
|
||||
align: 'center',
|
||||
render: (val: any) => {
|
||||
switch (val) {
|
||||
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':
|
||||
return <span style={{ color: 'green' }}>Thuyền viên</span>;
|
||||
return (
|
||||
<span style={{ color: 'green' }}>
|
||||
{intl.formatMessage({ id: 'trip.crew.member' })}
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return <span>{val}</span>;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Email</div>,
|
||||
dataIndex: ['Person', 'email'], // 👈 lấy từ Person.email
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.crew.email' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: ['Person', 'email'],
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Số điện thoại</div>,
|
||||
dataIndex: ['Person', 'phone'], // 👈 lấy từ Person.phone
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.crew.phone' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: ['Person', 'phone'],
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Ngày sinh</div>,
|
||||
dataIndex: ['Person', 'birth_date'], // birth_date là date of birth
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.crew.birthDate' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: ['Person', 'birth_date'],
|
||||
align: 'center',
|
||||
render: (birth_date: any) => {
|
||||
if (!birth_date) return '-';
|
||||
const date = new Date(birth_date);
|
||||
return date.toLocaleDateString('vi-VN'); // 👉 tự động ra dd/mm/yyyy
|
||||
return date.toLocaleDateString('vi-VN');
|
||||
},
|
||||
},
|
||||
{
|
||||
title: <div style={{ textAlign: 'center' }}>Địa chỉ</div>,
|
||||
dataIndex: ['Person', 'address'], // 👈 lấy từ Person.address
|
||||
title: (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{intl.formatMessage({ id: 'trip.crew.address' })}
|
||||
</div>
|
||||
),
|
||||
dataIndex: ['Person', 'address'],
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
@@ -71,7 +108,11 @@ const TripCrews: React.FC<TripCrewsProps> = ({ crew }) => {
|
||||
pagination={{
|
||||
pageSize: 5,
|
||||
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}
|
||||
bordered
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import React from 'react';
|
||||
|
||||
interface TripFishingGearTableProps {
|
||||
@@ -8,15 +9,24 @@ interface TripFishingGearTableProps {
|
||||
const TripFishingGearTable: React.FC<TripFishingGearTableProps> = ({
|
||||
fishingGears,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
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',
|
||||
valueType: 'select',
|
||||
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',
|
||||
align: 'center',
|
||||
},
|
||||
@@ -30,7 +40,11 @@ const TripFishingGearTable: React.FC<TripFishingGearTableProps> = ({
|
||||
pagination={{
|
||||
pageSize: 5,
|
||||
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}
|
||||
// bordered
|
||||
|
||||
@@ -12,7 +12,6 @@ import MainTripBody from './components/MainTripBody';
|
||||
import TripCancleOrFinishedButton from './components/TripCancelOrFinishButton';
|
||||
const DetailTrip = () => {
|
||||
const intl = useIntl();
|
||||
const [responsive, setResponsive] = useState(false);
|
||||
const [showAlarmList, setShowAlarmList] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [alarmList, setAlarmList] = useState<API.Alarm[]>([]);
|
||||
@@ -24,7 +23,7 @@ const DetailTrip = () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const resp: API.AlarmResponse = await queryAlarms();
|
||||
if (resp.alarms.length == 0) {
|
||||
if (resp.alarms.length === 0) {
|
||||
setShowAlarmList(false);
|
||||
} else {
|
||||
setAlarmList(resp.alarms);
|
||||
@@ -45,39 +44,57 @@ const DetailTrip = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
console.log('Rendering with tripInfo:', data),
|
||||
// console.log('Rendering with tripInfo:', data),
|
||||
(
|
||||
<PageContainer
|
||||
header={{
|
||||
title: data ? data.name : 'Chuyến đi',
|
||||
title: (
|
||||
<div style={{ marginLeft: screens.md ? '24px' : '10px' }}>
|
||||
{data
|
||||
? data.name
|
||||
: intl.formatMessage({ id: 'trip.detail.defaultTitle' })}
|
||||
</div>
|
||||
),
|
||||
tags: <BadgeTripStatus status={data?.trip_status || 0} />,
|
||||
}}
|
||||
content={null}
|
||||
loading={isLoading}
|
||||
ghost
|
||||
extra={[
|
||||
<CreateNewHaulOrTrip
|
||||
key={'lkadjaskd'}
|
||||
trips={data || undefined}
|
||||
onCallBack={async (success) => {
|
||||
switch (success) {
|
||||
case STATUS.CREATE_FISHING_LOG_SUCCESS:
|
||||
message.success('Tạo mẻ lưới thành công');
|
||||
// await fetchTrip();
|
||||
message.success(
|
||||
intl.formatMessage({ id: 'trip.detail.createHaulSuccess' }),
|
||||
);
|
||||
break;
|
||||
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;
|
||||
case STATUS.START_TRIP_SUCCESS:
|
||||
message.success('Bắt đầu chuyến đi thành công');
|
||||
// await fetchTrip();
|
||||
message.success(
|
||||
intl.formatMessage({ id: 'trip.detail.startTripSuccess' }),
|
||||
);
|
||||
break;
|
||||
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;
|
||||
case STATUS.UPDATE_FISHING_LOG_SUCCESS:
|
||||
message.success('Cập nhật mẻ lưới thành công');
|
||||
// await fetchTrip();
|
||||
message.success(
|
||||
intl.formatMessage({ id: 'trip.detail.updateHaulSuccess' }),
|
||||
);
|
||||
break;
|
||||
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;
|
||||
default:
|
||||
break;
|
||||
@@ -95,11 +112,10 @@ const DetailTrip = () => {
|
||||
{showAlarmList ? (
|
||||
<ProCard
|
||||
title={intl.formatMessage({
|
||||
id: 'pages.gmsv5.alarm.list',
|
||||
defaultMessage: 'Cảnh báo',
|
||||
id: 'trip.detail.alarm',
|
||||
})}
|
||||
colSpan={{ xs: 24, md: 24, lg: 5 }}
|
||||
bodyStyle={{ paddingInline: 0, paddingBlock: 8 }}
|
||||
bodyStyle={{ paddingInline: 0, paddingBlock: 0 }}
|
||||
bordered
|
||||
>
|
||||
{data ? (
|
||||
@@ -124,13 +140,6 @@ const DetailTrip = () => {
|
||||
<MainTripBody
|
||||
trip_id={data?.id}
|
||||
tripInfo={data || null}
|
||||
onReload={(isReload) => {
|
||||
console.log('Nhanaj dduowcj hàm, onReload:', isReload);
|
||||
|
||||
// if (isReload) {
|
||||
// fetchTrip();
|
||||
// }
|
||||
}}
|
||||
/>
|
||||
</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';
|
||||
|
||||
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, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
API_GET_GPS,
|
||||
API_PATH_ENTITIES,
|
||||
API_PATH_SHIP_INFO,
|
||||
API_PATH_SHIP_TRACK_POINTS,
|
||||
API_SOS,
|
||||
} from '@/constants';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
function transformEntityResponse(
|
||||
export function transformEntityResponse(
|
||||
raw: API.EntityResponse,
|
||||
): API.TransformedEntity {
|
||||
return {
|
||||
@@ -19,7 +20,7 @@ function transformEntityResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEntities(): Promise<API.TransformedEntity[]> {
|
||||
export async function queryEntities(): Promise<API.TransformedEntity[]> {
|
||||
const rawList = await request<API.EntityResponse[]>(API_PATH_ENTITIES);
|
||||
return rawList.map(transformEntityResponse);
|
||||
}
|
||||
@@ -52,3 +53,7 @@ export async function sendSosMessage(message: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function queryShipTrackPoints() {
|
||||
return await request<API.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { API_GET_ALL_LAYER, API_GET_LAYER_INFO } from '@/constants';
|
||||
import { API_GET_ALL_BANZONES, API_GET_ALL_LAYER, API_GET_LAYER_INFO } from '@/constants';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
export async function getLayer(name: string) {
|
||||
@@ -8,3 +8,7 @@ export async function getLayer(name: string) {
|
||||
export async function getAllLayer() {
|
||||
return request(API_GET_ALL_LAYER);
|
||||
}
|
||||
|
||||
export async function queryBanzones() {
|
||||
return request<API.Zone[]>(API_GET_ALL_BANZONES);
|
||||
}
|
||||
42
src/services/controller/typings.d.ts
vendored
42
src/services/controller/typings.d.ts
vendored
@@ -124,6 +124,14 @@ declare namespace API {
|
||||
fishing: boolean;
|
||||
}
|
||||
|
||||
interface ShipTrackPoint {
|
||||
time: number;
|
||||
lon: number;
|
||||
lat: number;
|
||||
s: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// Trips
|
||||
interface FishingGear {
|
||||
name: string;
|
||||
@@ -170,7 +178,7 @@ declare namespace API {
|
||||
}
|
||||
|
||||
interface FishingLog {
|
||||
fishing_log_id: string;
|
||||
fishing_log_id?: string;
|
||||
trip_id: string;
|
||||
start_at: Date; // ISO datetime
|
||||
end_at: Date; // ISO datetime
|
||||
@@ -328,4 +336,36 @@ declare namespace API {
|
||||
evtid?: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// Banzone
|
||||
export interface Zone {
|
||||
id?: string;
|
||||
name?: string;
|
||||
type?: number;
|
||||
conditions?: Condition[];
|
||||
enabled?: boolean;
|
||||
updated_at?: Date;
|
||||
geom?: Geom;
|
||||
}
|
||||
|
||||
export interface Condition {
|
||||
max?: number;
|
||||
min?: number;
|
||||
type?: Type;
|
||||
to?: number;
|
||||
from?: number;
|
||||
}
|
||||
|
||||
export enum Type {
|
||||
LengthLimit = 'length_limit',
|
||||
MonthRange = 'month_range',
|
||||
}
|
||||
|
||||
export interface Geom {
|
||||
geom_type?: number;
|
||||
geom_poly?: string;
|
||||
geom_lines?: string;
|
||||
geom_point?: string;
|
||||
geom_radius?: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ export const BASEMAP_ATTRIBUTIONS = '© OpenStreetMap contributors, © CartoDB';
|
||||
|
||||
export const INITIAL_VIEW_CONFIG = {
|
||||
// Dịch tâm bản đồ ra phía biển và zoom ra xa hơn để thấy bao quát
|
||||
center: [109.5, 16.0],
|
||||
zoom: 5.5,
|
||||
center: [116.152685, 15.70581],
|
||||
zoom: 6.5,
|
||||
minZoom: 5,
|
||||
maxZoom: 12,
|
||||
minScale: 0.1,
|
||||
@@ -31,7 +31,6 @@ export const osmLayer = new TileLayer({
|
||||
});
|
||||
|
||||
// const layerVN = await getLayer('base-vn');
|
||||
|
||||
export const tinhGeoLayer = createGeoJSONLayer({
|
||||
data: '',
|
||||
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) => {
|
||||
console.log('type, isFishing', type, isFishing);
|
||||
// console.log('type, isFishing', type, isFishing);
|
||||
|
||||
if (type === 1 && !isFishing) {
|
||||
return shipWarningIcon;
|
||||
@@ -62,3 +61,14 @@ export const getShipIcon = (type: number, isFishing: boolean) => {
|
||||
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();
|
||||
76
src/utils/geomUtils.ts
Normal file
76
src/utils/geomUtils.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export const convertWKTPointToLatLng = (wktString: string) => {
|
||||
if (
|
||||
!wktString ||
|
||||
typeof wktString !== 'string' ||
|
||||
!wktString.startsWith('POINT')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matched = wktString.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
|
||||
if (!matched) return null;
|
||||
|
||||
const lng = parseFloat(matched[1]);
|
||||
const lat = parseFloat(matched[2]);
|
||||
|
||||
return [lng, lat]; // [longitude, latitude]
|
||||
};
|
||||
export const convertWKTLineStringToLatLngArray = (wktString: string) => {
|
||||
if (
|
||||
!wktString ||
|
||||
typeof wktString !== 'string' ||
|
||||
!wktString.startsWith('LINESTRING')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matched = wktString.match(/LINESTRING\s*\((.*)\)/);
|
||||
if (!matched) return [];
|
||||
|
||||
const coordinates = matched[1].split(',').map((coordStr) => {
|
||||
const [x, y] = coordStr.trim().split(' ').map(Number);
|
||||
return [x, y]; // [lng, lat]
|
||||
});
|
||||
|
||||
return coordinates;
|
||||
};
|
||||
|
||||
export const convertWKTtoLatLngString = (wktString: string) => {
|
||||
if (
|
||||
!wktString ||
|
||||
typeof wktString !== 'string' ||
|
||||
!wktString.startsWith('MULTIPOLYGON')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matched = wktString.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
|
||||
if (!matched) return [];
|
||||
|
||||
const polygons = matched[1]
|
||||
.split(')),((') // chia các polygon
|
||||
.map((polygonStr) =>
|
||||
polygonStr
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((coordStr) => {
|
||||
const [x, y] = coordStr.trim().split(' ').map(Number);
|
||||
return [x, y];
|
||||
}),
|
||||
);
|
||||
|
||||
return polygons;
|
||||
};
|
||||
|
||||
export const getBanzoneNameByType = (type: number) => {
|
||||
switch (type) {
|
||||
case 1:
|
||||
return 'Cấm đánh bắt';
|
||||
case 2:
|
||||
return 'Cấm di chuyển';
|
||||
case 3:
|
||||
return 'Vùng an toàn';
|
||||
default:
|
||||
return 'Chưa có';
|
||||
}
|
||||
};
|
||||
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';
|
||||
|
||||
declare module '*.png' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user