15 Commits

Author SHA1 Message Date
95702b8370 Update Task.md 2025-12-08 16:07:04 +07:00
Tran Anh Tuan
fa43e72b48 chore: release v.1.2.3 2025-11-21 14:49:14 +07:00
Tran Anh Tuan
4d4463a517 style(login): change LangSwitches's position to top-left 2025-11-21 14:44:02 +07:00
Tran Anh Tuan
38c9639fcc perf(logs): remove console.log 2025-11-21 14:27:00 +07:00
Tran Anh Tuan
46aaf67a71 refactor(.umirc.ts): change proxy and request in dev mode and production mode 2025-11-20 17:16:24 +07:00
Tran Anh Tuan
216e865ca5 feat(localization): Add en/vi language 2025-11-20 16:21:17 +07:00
Lê Tuấn Anh
dea435a4ec feat(map): add event-driven GPS update 2025-11-20 09:05:07 +07:00
eed98f7c29 feat(core): add file cauTrucDuAn 2025-10-30 14:41:39 +07:00
Tran Anh Tuan
92eb505cfc chore(version): update version to 1.2.2 - dynamic api 2025-10-30 13:45:34 +07:00
Tran Anh Tuan
ff66a95bc5 chore(version): update version to 1.2.1 2025-10-09 11:24:16 +07:00
Tran Anh Tuan
cd8332a7ef chore(proxy): change proxy to request 2025-10-09 11:21:13 +07:00
Tran Anh Tuan
65f9468bbd chore(maps): display position in DMS 2025-10-08 09:38:54 +07:00
Tran Anh Tuan
28739ddcd9 style(maps): update base color for map's layer 2025-10-01 09:30:15 +07:00
Tran Anh Tuan
53dfb861dd chore(release): bump version to 1.2 2025-09-30 14:13:19 +07:00
Tran Anh Tuan
076d0460cb chore(maps): update animation marker when sos_alarm 2025-09-30 14:04:02 +07:00
55 changed files with 28579 additions and 523 deletions

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@
/.mfsu /.mfsu
.swc .swc
.DS_Store .DS_Store
claude_code_zai_env.sh

View File

@@ -1,8 +1,11 @@
import { defineConfig } from '@umijs/max'; import { defineConfig } from '@umijs/max';
import proxy from './config/proxy'; import proxyDev from './config/proxy_dev';
const { REACT_APP_ENV = 'dev' } = process.env as { import proxyProd from './config/proxy_prod';
REACT_APP_ENV: 'dev' | 'test' | 'prod'; const envConfig = process.env as { REACT_APP_ENV?: 'dev' | 'test' | 'prod' };
}; const rawEnv = envConfig.REACT_APP_ENV;
const isProdBuild = process.env.NODE_ENV === 'production';
const resolvedEnv = isProdBuild && !rawEnv ? 'prod' : rawEnv || 'dev';
const proxyConfig = isProdBuild ? proxyProd : proxyDev;
export default defineConfig({ export default defineConfig({
antd: {}, antd: {},
@@ -12,15 +15,24 @@ export default defineConfig({
request: {}, request: {},
locale: { locale: {
default: 'vi-VN', default: 'vi-VN',
baseNavigator: false,
antd: true,
title: false,
baseSeparator: '-',
// support for Vietnamese and English
// locales: [
// ['vi-VN', 'Tiếng Việt'],
// ['en-US', 'English'],
// ],
}, },
favicons: ['/logo.png'], favicons: ['/logo.png'],
layout: { layout: {
title: '2025 Sản phẩm của Mobifone v1.0', title: '2025 Sản phẩm của Mobifone v1.0',
}, },
proxy: proxy[REACT_APP_ENV], proxy: proxyConfig[resolvedEnv],
routes: [ routes: [
{ {
name: 'Login', title: 'Login',
path: '/login', path: '/login',
component: './Auth', component: './Auth',
layout: false, layout: false,
@@ -30,13 +42,13 @@ export default defineConfig({
redirect: '/map', redirect: '/map',
}, },
{ {
name: 'Giám sát', name: 'monitoring',
path: '/map', path: '/map',
component: './Home', component: './Home',
icon: 'icon-Map', icon: 'icon-Map',
}, },
{ {
name: 'Chuyến đi', name: 'trips',
path: '/trip', path: '/trip',
component: './Trip', component: './Trip',
icon: 'icon-specification', icon: 'icon-specification',
@@ -44,5 +56,5 @@ export default defineConfig({
], ],
npmClient: 'pnpm', npmClient: 'pnpm',
tailwindcss: {}, tailwindcss: {}, // Temporarily disabled
}); });

323
CLAUDE.md Normal file
View File

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

39
DYNAMIC_API_CONFIG.md Normal file
View 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
View File

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

View File

@@ -1,3 +1,38 @@
# README # README
`@umijs/max` 模板项目,更多功能参考 [Umi Max 简介](https://umijs.org/docs/max/introduce) `@umijs/max` 模板项目,更多功能参考 [Umi Max 简介](https://umijs.org/docs/max/introduce)
## Language Switching
- Lang toggle components (`src/components/switch/LangSwitches.tsx`, `src/components/LanguageSwitcher/index.tsx`) call Umi `setLocale(newLocale, false)` so content re-renders instantly without refreshing the page.
- Both components listen for the browser `languagechange` event to keep their UI state in sync when another part of the app switches languages.
- Strings are translated via `useTranslation`/`useIntl`; ensure new labels have entries in `src/locales/en-US.ts` and `src/locales/vi-VN.ts`.
## Proxy & Request Profiles
- Project uses two sets of configs under `config/`:
- `proxy.ts` + `Request.ts` for local development API targets.
- `proxy_prod.ts` + `request_prod.ts` for production-like targets.
- The runtime selects configs automatically:
- `pnpm run dev` / any dev server run ⇒ `NODE_ENV=development` ⇒ uses dev proxy/request.
- `pnpm run build` (and other production builds) ⇒ `NODE_ENV=production` ⇒ uses prod proxy/request.
- Override the environment by exporting `REACT_APP_ENV` before running commands (values: `dev`, `test`, `prod`). Example:
```bash
# Use test upstreams in a dev session
REACT_APP_ENV=test pnpm run dev
# Build with testing endpoints
NODE_ENV=production REACT_APP_ENV=test pnpm run build
```
- `config/proxy_prod.ts` resolves targets dynamically based on the current hostname; adjust rules there if deployment hosts change.
- Request pipelines differ per mode:
- `config/request_dev.ts` keeps relative URLs so calls pass through the dev proxy setup (use when mocking/pointing to LAN services).
- `config/request_prod.ts` builds absolute URLs from `ApiConfigService` so static builds call the correct host without relying on the proxy.
## Verifying Builds
- After switching language, confirm UI updates immediately and check console for `languagechange` events.
- For proxy changes, inspect network requests in devtools to ensure they hit the expected environment.
- Run `pnpm run build && pnpm serve` (or your hosting preview) to validate prod settings before deploying.

1494
Task.md Normal file

File diff suppressed because it is too large Load Diff

1600
cauTrucDuAn.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
const proxy: Record<string, any> = { const proxyDev: Record<string, any> = {
dev: { dev: {
'/api': { '/api': {
target: 'http://192.168.30.102:81', target: 'http://192.168.30.103:81',
changeOrigin: true, changeOrigin: true,
}, },
}, },
@@ -21,4 +21,4 @@ const proxy: Record<string, any> = {
}, },
}; };
export default proxy; export default proxyDev;

59
config/proxy_prod.ts Normal file
View 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;

View File

@@ -4,21 +4,21 @@ import { history, RequestConfig } from '@umijs/max';
import { message } from 'antd'; import { message } from 'antd';
// Error handling scheme: Error types // Error handling scheme: Error types
enum ErrorShowType { // enum ErrorShowType {
SILENT = 0, // SILENT = 0,
WARN_MESSAGE = 1, // WARN_MESSAGE = 1,
ERROR_MESSAGE = 2, // ERROR_MESSAGE = 2,
NOTIFICATION = 3, // NOTIFICATION = 3,
REDIRECT = 9, // REDIRECT = 9,
} // }
// Response data structure agreed with the backend // Response data structure agreed with the backend
interface ResponseStructure<T = any> { // interface ResponseStructure<T = any> {
success: boolean; // success: boolean;
data: T; // data: T;
errorCode?: number; // errorCode?: number;
errorMessage?: string; // errorMessage?: string;
showType?: ErrorShowType; // showType?: ErrorShowType;
} // }
const codeMessage = { const codeMessage = {
200: 'The server successfully returned the requested data。', 200: 'The server successfully returned the requested data。',
@@ -47,7 +47,7 @@ export const handleRequestConfig: RequestConfig = {
errorConfig: { errorConfig: {
// Error throwing // Error throwing
errorThrower: (res: any) => { errorThrower: (res: any) => {
console.log('Response from backend:', res); // console.log('Response from backend:', res);
const { success, data, errorCode, errorMessage, showType } = res; const { success, data, errorCode, errorMessage, showType } = res;
if (!success) { if (!success) {
const error: any = new Error(errorMessage); const error: any = new Error(errorMessage);

105
config/request_prod.ts Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
.ant-pro-global-footer-copyright {
color: white !important; /* hoặc mã màu bạn muốn */
}

View File

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

View File

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

View File

@@ -6,3 +6,7 @@ export enum STATUS {
UPDATE_FISHING_LOG_SUCCESS = 'UPDATE_FISHING_LOG_SUCCESS', UPDATE_FISHING_LOG_SUCCESS = 'UPDATE_FISHING_LOG_SUCCESS',
UPDATE_FISHING_LOG_FAIL = 'UPDATE_FISHING_LOG_FAIL', UPDATE_FISHING_LOG_FAIL = 'UPDATE_FISHING_LOG_FAIL',
} }
export enum ENTITY_TYPE_ENUM {
SOS_WARNING = '50:15',
}

View File

@@ -14,12 +14,12 @@ export const ROUTE_TRIP = '/trip';
// API Path Constants // API Path Constants
export const API_PATH_LOGIN = '/api/agent/login'; export const API_PATH_LOGIN = '/api/agent/login';
export const API_PATH_ENTITIES = '/api/agent/entities'; export const API_PATH_ENTITIES = '/api/io/entities';
export const API_PATH_SHIP_INFO = '/api/sgw/shipinfo'; export const API_PATH_SHIP_INFO = '/api/sgw/shipinfo';
export const API_GET_ALL_LAYER = '/api/sgw/geojsonlist'; export const API_GET_ALL_LAYER = '/api/sgw/geojsonlist';
export const API_GET_LAYER_INFO = '/api/sgw/geojson'; export const API_GET_LAYER_INFO = '/api/sgw/geojson';
export const API_GET_TRIP = '/api/sgw/trip'; export const API_GET_TRIP = '/api/sgw/trip';
export const API_GET_ALARMS = '/api/agent/alarms'; export const API_GET_ALARMS = '/api/io/alarms';
export const API_UPDATE_TRIP_STATUS = '/api/sgw/tripState'; export const API_UPDATE_TRIP_STATUS = '/api/sgw/tripState';
export const API_HAUL_HANDLE = '/api/sgw/fishingLog'; export const API_HAUL_HANDLE = '/api/sgw/fishingLog';
export const API_GET_GPS = '/api/sgw/gps'; export const API_GET_GPS = '/api/sgw/gps';

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

View File

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

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

@@ -0,0 +1,360 @@
export default {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
'common.search': 'Search',
'common.filter': 'Filter',
'common.loading': 'Loading...',
'common.confirm': 'Confirm',
'common.back': 'Back',
'common.next': 'Next',
'common.previous': 'Previous',
'common.submit': 'Submit',
'common.reset': 'Reset',
'common.refresh': 'Refresh',
'common.export': 'Export',
'common.import': 'Import',
'common.view': 'View',
'common.detail': 'Detail',
'common.list': 'List',
'common.settings': 'Settings',
'common.logout': 'Logout',
'common.login': 'Login',
'common.username': 'Username',
'common.password': 'Password',
'common.remember': 'Remember me',
'common.forgot': 'Forgot password?',
'common.required': 'Required',
'common.optional': 'Optional',
'common.enabled': 'Enabled',
'common.disabled': 'Disabled',
'common.active': 'Active',
'common.inactive': 'Inactive',
'common.online': 'Online',
'common.offline': 'Offline',
'common.yes': 'Yes',
'common.no': 'No',
'common.ok': 'OK',
'common.error': 'Error',
'common.success': 'Success',
'common.warning': 'Warning',
'common.info': 'Information',
'common.vietnamese': 'Vietnamese',
'common.english': 'English',
'common.footer.copyright': '2025 Product of Mobifone v1.2.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
View File

@@ -0,0 +1,359 @@
export default {
// Common
'common.save': 'Lưu',
'common.cancel': 'Hủy',
'common.delete': 'Xóa',
'common.edit': 'Chỉnh sửa',
'common.add': 'Thêm',
'common.search': 'Tìm kiếm',
'common.filter': 'Bộ lọc',
'common.loading': 'Đang tải...',
'common.confirm': 'Xác nhận',
'common.back': 'Quay lại',
'common.next': 'Tiếp theo',
'common.previous': 'Trước đó',
'common.submit': 'Gửi',
'common.reset': 'Đặt lại',
'common.refresh': 'Làm mới',
'common.export': 'Xuất',
'common.import': 'Nhập',
'common.view': 'Xem',
'common.detail': 'Chi tiết',
'common.list': 'Danh sách',
'common.settings': 'Cài đặt',
'common.logout': 'Đăng xuất',
'common.login': 'Đăng nhập',
'common.username': 'Tên người dùng',
'common.password': 'Mật khẩu',
'common.remember': 'Ghi nhớ',
'common.forgot': 'Quên mật khẩu?',
'common.required': 'Bắt buộc',
'common.optional': 'Tùy chọn',
'common.enabled': 'Đã bật',
'common.disabled': 'Đã tắt',
'common.active': 'Hoạt động',
'common.inactive': 'Không hoạt động',
'common.online': 'Trực tuyến',
'common.offline': 'Ngoại tuyến',
'common.yes': 'Có',
'common.no': 'Không',
'common.ok': 'OK',
'common.error': 'Lỗi',
'common.success': 'Thành công',
'common.warning': 'Cảnh báo',
'common.info': 'Thông tin',
"common.vietnamese": "Tiếng Việt",
"common.english": "Tiếng Anh",
'common.footer.copyright': '2025 Sản phẩm của Mobifone v1.2.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!',
};

View File

@@ -8,7 +8,7 @@ export default function useAlarmModel() {
setLoading(true); setLoading(true);
try { try {
const res = await queryAlarms(); // đổi URL cho phù hợp const res = await queryAlarms(); // đổi URL cho phù hợp
console.log('Alarm Data fetched:', res); // console.log('Alarm Data fetched:', res);
setAlarmData(res || []); setAlarmData(res || []);
} catch (err) { } catch (err) {

View File

@@ -1,22 +1,43 @@
// src/models/useGetGpsModel.ts
import { getGPS } from '@/services/controller/DeviceController'; import { getGPS } from '@/services/controller/DeviceController';
import { useCallback, useState } from 'react'; import { useCallback, useState, useEffect } from 'react';
import { eventBus } from '@/utils/eventBus';
export default function useGetGpsModel() { export default function useGetGpsModel() {
const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null); const [gpsData, setGpsData] = useState<API.GPSResonse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const getGPSData = useCallback(async () => { const getGPSData = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await getGPS(); // đổi URL cho phù hợp // Bypass cache để GPS luôn realtime, gọi trực tiếp API
console.log('GPS Data fetched:', res); const data = await getGPS();
// console.log('GPS fetched from API:', data);
setGpsData(res || []); setGpsData(data);
// Luôn emit event GPS để đảm bảo realtime
try {
// eventBus.emit('gpsData:update', data);
} catch (e) {
console.warn('Failed to emit gpsData:update event', e);
}
} catch (err) { } catch (err) {
console.error('Fetch gps data failed', err); console.error('Fetch gps data failed', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, []);
// ✅ Lắng nghe event cập nhật cache từ nơi khác (nếu có)
useEffect(() => {
const handleUpdate = (data: API.GPSResonse) => {
// console.log('GPS cache updated via eventBus');
setGpsData(data);
};
eventBus.on('gpsData:update', handleUpdate);
return () => eventBus.off('gpsData:update', handleUpdate);
}, []);
return { return {
gpsData, gpsData,
loading, loading,

View File

@@ -1,10 +1,12 @@
import Footer from '@/components/Footer/Footer';
import LangSwitches from '@/components/switch/LangSwitches';
import { ROUTE_HOME } from '@/constants'; import { ROUTE_HOME } from '@/constants';
import { login } from '@/services/controller/AuthController'; import { login } from '@/services/controller/AuthController';
import { parseJwt } from '@/utils/jwtTokenUtils'; import { parseJwt } from '@/utils/jwtTokenUtils';
import { getToken, removeToken, setToken } from '@/utils/localStorageUtils'; import { getToken, removeToken, setToken } from '@/utils/localStorageUtils';
import { LockOutlined, UserOutlined } from '@ant-design/icons'; import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginFormPage, ProFormText } from '@ant-design/pro-components'; import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
import { history } from '@umijs/max'; import { history, useIntl } from '@umijs/max';
import { Image, theme } from 'antd'; import { Image, theme } from 'antd';
import { useEffect } from 'react'; import { useEffect } from 'react';
import logoImg from '../../../public/logo.png'; import logoImg from '../../../public/logo.png';
@@ -14,17 +16,14 @@ const LoginPage = () => {
const { token } = theme.useToken(); const { token } = theme.useToken();
const urlParams = new URL(window.location.href).searchParams; const urlParams = new URL(window.location.href).searchParams;
const redirect = urlParams.get('redirect'); const redirect = urlParams.get('redirect');
const intl = useIntl();
useEffect(() => {
checkLogin();
}, []);
const checkLogin = () => { const checkLogin = () => {
const token = getToken(); const token = getToken();
if (!token) { if (!token) {
return; return;
} }
const parsed = parseJwt(token); const parsed = parseJwt(token);
const { sub, exp } = parsed; const { exp } = parsed;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60; const oneHour = 60 * 60;
if (exp - now < oneHour) { if (exp - now < oneHour) {
@@ -38,6 +37,10 @@ const LoginPage = () => {
} }
}; };
useEffect(() => {
checkLogin();
}, []);
const handleLogin = async (values: API.LoginRequestBody) => { const handleLogin = async (values: API.LoginRequestBody) => {
try { try {
const resp = await login(values); const resp = await login(values);
@@ -67,7 +70,10 @@ const LoginPage = () => {
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr" backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
title={ title={
<span style={{ color: token.colorBgContainer }}> <span style={{ color: token.colorBgContainer }}>
Hệ thống giám sát tàu {intl.formatMessage({
id: 'auth.login.subtitle',
defaultMessage: 'Sea Gateway',
})}
</span> </span>
} }
containerStyle={{ containerStyle={{
@@ -77,7 +83,10 @@ const LoginPage = () => {
subTitle={<Image preview={false} src={mobifontImg} />} subTitle={<Image preview={false} src={mobifontImg} />}
submitter={{ submitter={{
searchConfig: { searchConfig: {
submitText: 'Đăng nhập', submitText: intl.formatMessage({
id: 'common.login',
defaultMessage: 'Đăng nhập',
}),
}, },
}} }}
onFinish={async (values: API.LoginRequestBody) => handleLogin(values)} onFinish={async (values: API.LoginRequestBody) => handleLogin(values)}
@@ -96,11 +105,17 @@ const LoginPage = () => {
/> />
), ),
}} }}
placeholder={'Tài khoản'} placeholder={intl.formatMessage({
id: 'common.username',
defaultMessage: 'Tài khoản',
})}
rules={[ rules={[
{ {
required: true, required: true,
message: 'Tài khoản không được để trống!', message: intl.formatMessage({
id: 'validation.username',
defaultMessage: 'Tài khoản không được để trống!',
}),
}, },
]} ]}
/> />
@@ -117,16 +132,36 @@ const LoginPage = () => {
/> />
), ),
}} }}
placeholder={'Mật khẩu'} placeholder={intl.formatMessage({
id: 'common.password',
defaultMessage: 'Mật khẩu',
})}
rules={[ rules={[
{ {
required: true, required: true,
message: 'Mật khẩu không được để trống!', message: intl.formatMessage({
id: 'validation.password',
defaultMessage: 'Mật khẩu không được để trống!',
}),
}, },
]} ]}
/> />
</> </>
</LoginFormPage> </LoginFormPage>
<div className="absolute top-5 right-5 z-50">
<LangSwitches />
</div>
<div
style={{
backgroundColor: 'transparent',
position: 'absolute',
bottom: 0,
zIndex: 99,
width: '100%',
}}
>
<Footer />
</div>
</div> </div>
); );
}; };

View File

@@ -7,16 +7,21 @@ import {
} from '@/utils/mapUtils'; } from '@/utils/mapUtils';
import { Feature, Map, View } from 'ol'; import { Feature, Map, View } from 'ol';
import { Coordinate } from 'ol/coordinate'; import { Coordinate } from 'ol/coordinate';
import { easeOut } from 'ol/easing';
import { EventsKey } from 'ol/events';
import { createEmpty, extend, Extent, isEmpty } from 'ol/extent'; import { createEmpty, extend, Extent, isEmpty } from 'ol/extent';
import { LineString, MultiPolygon, Point, Polygon } from 'ol/geom'; import { LineString, MultiPolygon, Point, Polygon } from 'ol/geom';
import BaseLayer from 'ol/layer/Base'; import BaseLayer from 'ol/layer/Base';
import VectorLayer from 'ol/layer/Vector'; import VectorLayer from 'ol/layer/Vector';
import { unByKey } from 'ol/Observable';
import { fromLonLat } from 'ol/proj'; import { fromLonLat } from 'ol/proj';
import { getVectorContext } from 'ol/render';
import RenderEvent from 'ol/render/Event';
import VectorSource from 'ol/source/Vector'; import VectorSource from 'ol/source/Vector';
import { Circle as CircleStyle, Style } from 'ol/style';
import Fill from 'ol/style/Fill'; import Fill from 'ol/style/Fill';
import Icon from 'ol/style/Icon'; import Icon from 'ol/style/Icon';
import Stroke from 'ol/style/Stroke'; import Stroke from 'ol/style/Stroke';
import Style from 'ol/style/Style';
import Text from 'ol/style/Text'; import Text from 'ol/style/Text';
interface MapManagerConfig { interface MapManagerConfig {
@@ -25,6 +30,12 @@ interface MapManagerConfig {
onFeaturesClick?: (features: any[]) => void; onFeaturesClick?: (features: any[]) => void;
onError?: (error: string[]) => void; onError?: (error: string[]) => void;
} }
interface AnimationConfig {
duration?: number;
maxRadius?: number;
strokeColor?: string;
strokeWidthBase?: number;
}
interface StyleConfig { interface StyleConfig {
icon?: string; // URL của icon icon?: string; // URL của icon
@@ -48,11 +59,6 @@ interface FeatureData {
text?: string; text?: string;
} }
// Interface for layer object
interface Layer {
layer: VectorLayer<VectorSource>;
}
class MapManager { class MapManager {
mapRef: React.RefObject<HTMLDivElement>; mapRef: React.RefObject<HTMLDivElement>;
map: Map | null; map: Map | null;
@@ -66,6 +72,8 @@ class MapManager {
private errors: string[]; private errors: string[];
private layers: BaseLayer[]; // Assuming layers is defined elsewhere private layers: BaseLayer[]; // Assuming layers is defined elsewhere
private isInitialized: boolean; // Thêm thuộc tính để theo dõi trạng thái khởi tạo private isInitialized: boolean; // Thêm thuộc tính để theo dõi trạng thái khởi tạo
private eventKey: EventsKey | undefined;
shipFeature: any;
constructor( constructor(
mapRef: React.RefObject<HTMLDivElement>, mapRef: React.RefObject<HTMLDivElement>,
@@ -83,11 +91,12 @@ class MapManager {
this.errors = []; this.errors = [];
this.layers = []; // Initialize layers (adjust based on actual usage) this.layers = []; // Initialize layers (adjust based on actual usage)
this.isInitialized = false; // Khởi tạo là false this.isInitialized = false; // Khởi tạo là false
this.eventKey = undefined; // Khởi tạo eventKey
} }
async getListLayers(): Promise<[]> { async getListLayers(): Promise<[]> {
const resp: [] = await getAllLayer(); const resp: [] = await getAllLayer();
console.log('resp', resp); // console.log('resp', resp);
return resp; return resp;
} }
@@ -99,10 +108,15 @@ class MapManager {
for (const layerMeta of listLayers) { for (const layerMeta of listLayers) {
try { try {
const data = await getLayer(layerMeta); // lấy GeoJSON từ server const data = await getLayer(layerMeta); // lấy GeoJSON từ server
let style = {};
if (layerMeta === 'base-countries') {
style = createLabelAndFillStyle('#fafaf8', '#E5BEB5');
} else {
style = createLabelAndFillStyle('#77BEF0', '#000000');
}
const vectorLayer = createGeoJSONLayer({ const vectorLayer = createGeoJSONLayer({
data, data,
style: createLabelAndFillStyle('#77BEF0', '#000000'), style: style,
}); });
dynamicLayers.push(vectorLayer); dynamicLayers.push(vectorLayer);
@@ -149,10 +163,10 @@ class MapManager {
this.initZoomListener(); this.initZoomListener();
this.isInitialized = true; this.isInitialized = true;
console.log( // console.log(
'Map initialized successfully at', // 'Map initialized successfully at',
new Date().toLocaleTimeString(), // new Date().toLocaleTimeString(),
); // );
this.map.on('singleclick', (evt: any) => { this.map.on('singleclick', (evt: any) => {
const featuresAtPixel: { const featuresAtPixel: {
@@ -174,12 +188,12 @@ class MapManager {
if (this.features.includes(feature)) { if (this.features.includes(feature)) {
this.onFeatureClick(feature); this.onFeatureClick(feature);
this.onFeatureSelect(feature, evt.pixel); this.onFeatureSelect(feature, evt.pixel);
console.log( // console.log(
'Feature clicked at', // 'Feature clicked at',
new Date().toLocaleTimeString(), // new Date().toLocaleTimeString(),
':', // ':',
feature.getProperties(), // feature.getProperties(),
); // );
} }
} else { } else {
this.onFeaturesClick(featuresAtPixel.map((f) => f.feature)); this.onFeaturesClick(featuresAtPixel.map((f) => f.feature));
@@ -328,8 +342,10 @@ class MapManager {
// Lưu feature // Lưu feature
this.features.push(feature); this.features.push(feature);
this.featureLayer?.getSource()?.addFeature(feature); this.featureLayer?.getSource()?.addFeature(feature);
if (styleConfig.animate) {
console.log('Point added successfully:', { coord, data, styleConfig }); this.animatedMarker(feature, styleConfig.animationConfig);
}
// console.log('Point added successfully:', { coord, data, styleConfig });
return feature; return feature;
} catch (error: any) { } catch (error: any) {
@@ -340,6 +356,74 @@ class MapManager {
return null; return null;
} }
} }
animatedMarker = (
feature: Feature<Point>,
config: AnimationConfig = {},
): void => {
const {
duration = 3000,
maxRadius = 20,
strokeColor = 'rgba(255, 0, 0, 0.8)',
strokeWidthBase = 0.25,
} = config;
// console.log('Starting animatedMarker with config:', config);
const flashGeom = feature.getGeometry()?.clone() as Point | undefined;
if (!flashGeom || !this.featureLayer) {
console.error('Invalid geometry or featureLayer for animation');
return;
}
// Tạo 2 "pha" sóng, mỗi sóng bắt đầu cách nhau duration/2
const waveCount = 1;
const waveOffsets = Array.from(
{ length: waveCount },
(_, i) => (duration / waveCount) * i,
);
const start = Date.now();
const listenerKey: EventsKey = this.featureLayer.on(
'postrender',
(event: RenderEvent) => {
const frameState = event.frameState;
if (!frameState) return;
const elapsedTotal = frameState.time - start;
if (elapsedTotal >= duration * 3) {
// chạy 2 chu kỳ rồi dừng (bạn có thể bỏ điều kiện này để chạy mãi)
unByKey(listenerKey);
return;
}
const vectorContext = getVectorContext(event);
waveOffsets.forEach((offset) => {
const elapsed =
(frameState.time - start - offset + duration) % duration;
const elapsedRatio = elapsed / duration;
const radius = easeOut(elapsedRatio) * (maxRadius - 5) + 5;
const opacity = easeOut(1 - elapsedRatio);
const style = new Style({
image: new CircleStyle({
radius,
stroke: new Stroke({
color: strokeColor.replace('0.8', opacity.toString()),
width: strokeWidthBase + opacity,
}),
}),
});
vectorContext.setStyle(style);
vectorContext.drawGeometry(flashGeom);
});
this.map!.render();
},
);
this.eventKey = listenerKey;
};
addPolygon( addPolygon(
coords: Coordinate[][], coords: Coordinate[][],
@@ -510,12 +594,12 @@ class MapManager {
if (this.featureLayer && this.isInitialized) { if (this.featureLayer && this.isInitialized) {
this.features.push(feature); this.features.push(feature);
this.featureLayer.getSource()?.addFeature(feature); this.featureLayer.getSource()?.addFeature(feature);
console.log( // console.log(
'LineString added successfully at', // 'LineString added successfully at',
new Date().toLocaleTimeString(), // new Date().toLocaleTimeString(),
':', // ':',
{ coords, data, styleConfig }, // { coords, data, styleConfig },
); // );
// Đảm bảo bản đồ được render lại // Đảm bảo bản đồ được render lại
this.map?.render(); this.map?.render();
return feature; return feature;
@@ -540,10 +624,10 @@ class MapManager {
styleConfig: Record<string, any> = {}, styleConfig: Record<string, any> = {},
zoom: number, zoom: number,
) => { ) => {
console.log( // console.log(
`createPolygonStyle called with zoom: ${zoom}, styleConfig:`, // `createPolygonStyle called with zoom: ${zoom}, styleConfig:`,
styleConfig, // styleConfig,
); // Debug // ); // Debug
const textContent = `Tên: ${data?.name ?? 'Chưa có'}\n\n Loại: ${ const textContent = `Tên: ${data?.name ?? 'Chưa có'}\n\n Loại: ${
data?.type ?? 0 data?.type ?? 0
}\n\nThông tin: ${data?.description ?? 'Chưa có'}`; }\n\nThông tin: ${data?.description ?? 'Chưa có'}`;
@@ -581,7 +665,7 @@ class MapManager {
text: textStyle, text: textStyle,
}); });
console.log(`Polygon style created:`, style); // Debug // console.log(`Polygon style created:`, style); // Debug
return [style]; return [style];
}; };

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ const VietNamMap: React.FC<VietNamMapProps> = React.memo(
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
console.log('useEffect in VietNamMap triggered'); // console.log('useEffect in VietNamMap triggered');
let manager = mapManager; let manager = mapManager;
if (!manager) { if (!manager) {
@@ -38,10 +38,10 @@ const VietNamMap: React.FC<VietNamMapProps> = React.memo(
manager!.mapRef = mapRef; manager!.mapRef = mapRef;
if (!manager!.map) { if (!manager!.map) {
manager!.initializeMap(); manager!.initializeMap();
console.log( // console.log(
'Initialized new MapManager instance at', // 'Initialized new MapManager instance at',
new Date().toLocaleTimeString(), // new Date().toLocaleTimeString(),
); // );
} }
} else if (retryCount < maxRetries) { } else if (retryCount < maxRetries) {
console.error('mapRef.current is not ready, retrying...'); console.error('mapRef.current is not ready, retrying...');
@@ -55,7 +55,7 @@ const VietNamMap: React.FC<VietNamMapProps> = React.memo(
initialize(); initialize();
return () => { return () => {
console.log('Cleanup in VietNamMap triggered'); // console.log('Cleanup in VietNamMap triggered');
// Không gọi destroy ở đây để tránh phá hủy bản đồ không cần thiết // Không gọi destroy ở đây để tránh phá hủy bản đồ không cần thiết
}; };
}, [mapManager, onFeatureClick, onFeatureSelect, onFeaturesClick, onError]); }, [mapManager, onFeatureClick, onFeatureSelect, onFeaturesClick, onError]);

View File

@@ -4,6 +4,7 @@ import {
MAP_TRACKPOINTS_ID, MAP_TRACKPOINTS_ID,
ROUTE_TRIP, ROUTE_TRIP,
} from '@/constants'; } from '@/constants';
import { ENTITY_TYPE_ENUM } from '@/constants/enums';
import { import {
queryAlarms, queryAlarms,
queryEntities, queryEntities,
@@ -11,6 +12,7 @@ import {
} from '@/services/controller/DeviceController'; } from '@/services/controller/DeviceController';
import { queryBanzones } from '@/services/controller/MapController'; import { queryBanzones } from '@/services/controller/MapController';
import { getShipIcon } from '@/services/service/MapService'; import { getShipIcon } from '@/services/service/MapService';
import { eventBus } from '@/utils/eventBus';
import { import {
convertWKTLineStringToLatLngArray, convertWKTLineStringToLatLngArray,
convertWKTtoLatLngString, convertWKTtoLatLngString,
@@ -21,14 +23,32 @@ import {
InfoCircleOutlined, InfoCircleOutlined,
InfoOutlined, InfoOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { history, useModel } from '@umijs/max'; import { history, useIntl, useModel } from '@umijs/max';
import { FloatButton, Popover } from 'antd'; import { FloatButton, Popover } from 'antd';
import LineString from 'ol/geom/LineString';
import Point from 'ol/geom/Point';
import { fromLonLat } from 'ol/proj';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import GpsInfo from './components/GpsInfo'; import GpsInfo from './components/GpsInfo';
import ShipInfo from './components/ShipInfo'; import ShipInfo from './components/ShipInfo';
import SosButton from './components/SosButton'; import SosButton from './components/SosButton';
import VietNamMap, { MapManager } from './components/VietNamMap'; import VietNamMap, { MapManager } from './components/VietNamMap';
// // Define missing types locally for now
// interface AlarmData {
// level: number;
// }
// interface EntityData {
// id: string;
// valueString: string;
// }
// interface TrackPoint {
// lat: number;
// lon: number;
// }
export interface GpsData { export interface GpsData {
lat: number; lat: number;
lon: number; lon: number;
@@ -40,29 +60,33 @@ const HomePage: React.FC = () => {
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
const [isShipInfoOpen, setIsShipInfoOpen] = useState(false); const [isShipInfoOpen, setIsShipInfoOpen] = useState(false);
const { gpsData, getGPSData } = useModel('getSos'); const { gpsData, getGPSData } = useModel('getSos');
const hasCenteredRef = useRef(false);
const onFeatureClick = useCallback((feature: any) => { const onFeatureClick = useCallback((feature: any) => {
console.log('OnClick Feature: ', feature); console.log('OnClick Feature: ', feature);
console.log( // console.log(
'Clicked ship at', // 'Clicked ship at',
new Date().toLocaleTimeString(), // new Date().toLocaleTimeString(),
'Properties:', // 'Properties:',
feature.getProperties(), // feature.getProperties(),
); // );
}, []); }, []);
const intl = useIntl();
const onFeaturesClick = useCallback((features: any[]) => { const onFeaturesClick = useCallback((features: any[]) => {
console.log('Multiple features clicked:', features); console.log('Multiple features clicked:', features);
}, []); }, []);
const onError = useCallback((error: any) => { const onError = useCallback(
(error: any) => {
console.error( console.error(
'Lỗi khi thêm vào map at', intl.formatMessage({ id: 'home.mapError' }),
'at',
new Date().toLocaleTimeString(), new Date().toLocaleTimeString(),
':', ':',
error, error,
); );
}, []); },
[intl],
);
const mapManagerRef = useRef( const mapManagerRef = useRef(
new MapManager(mapRef, { new MapManager(mapRef, {
@@ -74,124 +98,75 @@ const HomePage: React.FC = () => {
const [isShowGPSData, setIsShowGPSData] = useState(true); const [isShowGPSData, setIsShowGPSData] = useState(true);
useEffect(() => { // Hàm tổng hợp: gọi GPS trước, sau đó gọi các API còn lại
getGPSData(); const refreshAllData = async () => {
const interval = setInterval(() => { try {
getGPSData(); // Gọi GPS, khi xong sẽ emit event gpsData:update và các API khác sẽ tự động được gọi qua event handler
getEntitiesData(); await getGPSData();
getShipTrackPoints(); } catch (e) {
getEntityData(); console.error('refreshAllData error', e);
}, 5000); }
return () => {
clearInterval(interval);
console.log('MapManager destroyed in HomePage cleanup');
}; };
}, []);
useEffect(() => { // Helper function to add features to the map (kept above pollers to avoid use-before-define)
const timer = setTimeout(() => { function addFeatureToMap(
// getGPSData(); mapManager: MapManager,
getEntitiesData(); gpsData: GpsData,
getShipTrackPoints(); alarm: API.AlarmResponse,
getEntityData(); ) {
}, 1000); // delay 1s const isSos = alarm?.alarms.find(
return () => clearTimeout(timer); (a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING,
}, [gpsData]); );
const getEntitiesData = async () => { if (mapManager?.featureLayer && gpsData) {
try { mapManager.featureLayer.getSource()?.clear();
const alarm = await queryAlarms(); mapManager.addPoint(
// console.log('Fetched alarm data:', alarm);
// console.log('GPS Data:', gpsData);
if (mapManagerRef.current?.featureLayer && gpsData) {
// Clear trước
mapManagerRef.current.featureLayer.getSource()?.clear();
try {
// Tạo feature mới
const feature = mapManagerRef.current.addPoint(
[gpsData.lon, gpsData.lat], [gpsData.lon, gpsData.lat],
{ { bearing: gpsData.h || 0 },
bearing: gpsData.h || 0,
},
{ {
icon: getShipIcon(alarm.level || 0, gpsData?.fishing || false), icon: getShipIcon(alarm.level || 0, gpsData?.fishing || false),
scale: 0.1, scale: 0.1,
animate: isSos ? true : false,
}, },
); );
} catch (parseError) {
console.error(
`Error parsing valueString for entity: ${parseError}`,
parseError,
);
} }
} }
} catch (error) { const drawBanzones = async (mapManager: MapManager) => {
console.error('Error fetching entities:', error);
}
};
const getShipTrackPoints = async () => {
try { try {
const trackpoints = await queryShipTrackPoints(); const layer = mapManager.featureLayer?.getSource();
// console.log('Fetched ship track points:', trackpoints.length); layer?.getFeatures()?.forEach((f) => {
if (trackpoints.length > 0) { if ([MAP_POLYGON_BAN, MAP_POLYLINE_BAN].includes(f.get('id'))) {
mapManagerRef.current?.addLineString( layer.removeFeature(f);
trackpoints.map((point) => [point.lon, point.lat]),
{
id: MAP_TRACKPOINTS_ID,
// You can add more properties here if needed
},
{
strokeColor: 'blue',
strokeWidth: 3,
},
);
} }
} catch (error) { });
console.error('Error fetching ship track points:', error);
}
};
const getEntityData = async () => {
try {
const entities = await queryEntities(); const entities = await queryEntities();
console.log('Fetched entities:', entities.length); for (const entity of entities) {
if (entities.length > 0) { if (entity.id === '50:2' && entity.valueString !== '[]') {
entities.forEach(async (entity) => {
if (entity.id == '50:2') {
const banzones = await queryBanzones(); const banzones = await queryBanzones();
console.log('Fetched banzones:', banzones.length); const zones: any[] = JSON.parse(entity.valueString);
if (entity.valueString != '[]') { zones.forEach((zone: any) => {
const zones = JSON.parse(entity.valueString);
console.log('Parsed zones:', zones);
for (const zone of zones) {
const geom = banzones.find((b) => b.id === zone.zone_id); const geom = banzones.find((b) => b.id === zone.zone_id);
if (geom) { if (geom) {
if (geom.geom?.geom_type === 2) { const { geom_type, geom_lines, geom_poly } = geom.geom || {};
// Polyline if (geom_type === 2) {
const coordinates = convertWKTLineStringToLatLngArray( const coordinates = convertWKTLineStringToLatLngArray(
geom?.geom?.geom_lines || '', geom_lines || '',
); );
if (coordinates.length > 0) { if (coordinates.length > 0) {
mapManagerRef.current.addLineString( mapManager.addLineString(
coordinates, coordinates,
{ {
id: MAP_POLYLINE_BAN, id: MAP_POLYLINE_BAN,
text: `${geom.name} - ${zone.message}`, text: `${geom.name} - ${zone.message}`,
}, },
{ { strokeColor: 'red', strokeWidth: 4 },
strokeColor: 'red',
strokeWidth: 4,
},
); );
} }
} else if (geom.geom?.geom_type === 1) { } else if (geom_type === 1) {
const coordinates = convertWKTtoLatLngString( const coordinates = convertWKTtoLatLngString(geom_poly || '');
geom?.geom?.geom_poly || '',
);
if (coordinates.length > 0) { if (coordinates.length > 0) {
mapManagerRef.current.addPolygon( mapManager.addPolygon(
coordinates, coordinates,
{ {
id: MAP_POLYGON_BAN, id: MAP_POLYGON_BAN,
@@ -200,28 +175,176 @@ const HomePage: React.FC = () => {
description: zone.message, description: zone.message,
}, },
{ {
strokeColor: 'yellow', strokeColor: '#FCC61D',
strokeWidth: 2, strokeWidth: 2,
fillColor: 'rgba(255, 255, 0, 0.3)', // Màu vàng với độ trong suốt fillColor: '#FCC61D',
}, },
); );
} }
} }
} else {
console.log('No geometry found for zone:', zone.id);
}
}
} else {
console.log('Zone rỗng');
}
} }
}); });
} }
}
} catch (error) { } catch (error) {
console.error('Error fetching entities:', error); console.error(
intl.formatMessage({ id: 'home.errorDrawBanzones' }),
error,
);
} }
}; };
useEffect(() => {
// Chỉ poll GPS, các API còn lại sẽ được gọi khi có GPS mới
getGPSData();
const gpsInterval = setInterval(() => {
getGPSData();
}, 5000); // cập nhật mỗi 5s
// Vẽ marker tàu và polyline ngay khi vào trang nếu đã có dữ liệu
if (gpsData) {
eventBus.emit('ship:update', gpsData);
}
// Nếu có trackpoints, emit luôn (nếu bạn lưu trackpoints ở state, có thể emit ở đây)
// eventBus.emit('trackpoints:update', trackpoints);
return () => {
clearInterval(gpsInterval);
console.log('MapManager destroyed in HomePage cleanup');
};
}, []);
// Lắng nghe eventBus cho tất cả các loại dữ liệu
useEffect(() => {
// GPS cập nhật => emit ship update ngay lập tức, gọi API khác không đồng bộ
const onGpsUpdate = async (data: API.GPSResonse) => {
try {
if (data) {
// Emit ship update ngay lập tức để marker tàu cập nhật realtime
eventBus.emit('ship:update', data);
// Gọi các API khác trong background, không block ship update
setTimeout(async () => {
try {
const alarm = await queryAlarms();
eventBus.emit('alarm:update', alarm);
const trackpoints = await queryShipTrackPoints();
eventBus.emit('trackpoints:update', trackpoints);
const entities = await queryEntities();
eventBus.emit('entities:update', entities);
} catch (e) {
console.error(
intl.formatMessage({ id: 'home.errorBackground' }),
e,
);
}
}, 0);
}
} catch (e) {
console.error(intl.formatMessage({ id: 'home.errorGpsUpdate' }), e);
}
};
// Cập nhật marker tàu khi có GPS mới
const onShipUpdate = (data: API.GPSResonse) => {
const mapManager = mapManagerRef.current;
if (!mapManager || !data) return;
// Cache feature trong mapManager
if (!mapManager.shipFeature) {
mapManager.shipFeature = mapManager.addPoint(
[data.lon, data.lat],
{ bearing: data.h || 0 },
{
icon: getShipIcon(0, data?.fishing || false),
scale: 0.1,
animate: false,
},
);
} else {
const geom = mapManager.shipFeature.getGeometry();
if (geom instanceof Point) {
geom.setCoordinates([data.lon, data.lat]);
}
}
// Set map center to current GPS position
if (!hasCenteredRef.current && mapManager.map && mapManager.map.getView) {
mapManager.map.getView().setCenter(fromLonLat([data.lon, data.lat]));
hasCenteredRef.current = true;
}
};
// Cập nhật alarm khi có event
const onAlarmUpdate = (alarm: API.AlarmResponse) => {
if (gpsData) addFeatureToMap(mapManagerRef.current, gpsData, alarm);
};
// Cập nhật trackpoints khi có event
const onTrackpointsUpdate = (trackpoints: API.ShipTrackPoint[]) => {
const source = mapManagerRef.current?.featureLayer?.getSource();
if (source && trackpoints.length > 0) {
const features = source.getFeatures();
// Tìm polyline theo id
const polyline = features.find(
(f) => f.get('id') === MAP_TRACKPOINTS_ID,
);
const coordinates = trackpoints.map((point) => [point.lon, point.lat]);
if (polyline) {
const geom = polyline.getGeometry();
if (geom instanceof LineString) {
geom.setCoordinates(coordinates);
} else {
mapManagerRef.current?.addLineString(
coordinates,
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
}
} else {
mapManagerRef.current?.addLineString(
coordinates,
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
}
}
};
// Cập nhật entities/banzones khi có event
const onEntitiesUpdate = async () => {
const mapManager = mapManagerRef.current;
if (mapManager) {
await drawBanzones(mapManager);
}
};
eventBus.on('gpsData:update', onGpsUpdate);
eventBus.on('ship:update', onShipUpdate);
eventBus.on('alarm:update', onAlarmUpdate);
eventBus.on('trackpoints:update', onTrackpointsUpdate);
eventBus.on('entities:update', onEntitiesUpdate);
return () => {
eventBus.off('gpsData:update', onGpsUpdate);
eventBus.off('ship:update', onShipUpdate);
eventBus.off('alarm:update', onAlarmUpdate);
eventBus.off('trackpoints:update', onTrackpointsUpdate);
eventBus.off('entities:update', onEntitiesUpdate);
};
}, [gpsData]);
// Vẽ lại banzones (polygon, polyline vùng cấm, vùng cá thử...)
useEffect(() => {
// Vẽ lại banzones khi vào trang
const mapManager = mapManagerRef.current;
if (mapManager) {
drawBanzones(mapManager);
}
}, []);
return ( return (
<div> <div>
<VietNamMap <VietNamMap
@@ -236,14 +359,11 @@ const HomePage: React.FC = () => {
/> />
<Popover <Popover
styles={{ styles={{
root: { width: '85%', maxWidth: 500, paddingLeft: 15 }, root: { width: '85%', maxWidth: 800, minWidth: 700, paddingLeft: 15 },
}} }}
placement="left" placement="left"
title="Trạng thái hiện tại" title={intl.formatMessage({ id: 'home.currentStatus' })}
content={ content={<GpsInfo gpsData={gpsData} />}
<GpsInfo gpsData={gpsData} />
// <ShipInfo />
}
open={isShowGPSData} open={isShowGPSData}
> >
<FloatButton.Group <FloatButton.Group
@@ -255,20 +375,20 @@ const HomePage: React.FC = () => {
<FloatButton <FloatButton
icon={<CommentOutlined />} icon={<CommentOutlined />}
onClick={() => history.push(ROUTE_TRIP)} onClick={() => history.push(ROUTE_TRIP)}
tooltip="Thông tin chuyến đi" tooltip={intl.formatMessage({ id: 'home.tripInfo' })}
/> />
<FloatButton <FloatButton
icon={<InfoOutlined />} icon={<InfoOutlined />}
tooltip="Thông tin tàu" tooltip={intl.formatMessage({ id: 'home.shipInfo' })}
onClick={() => setIsShipInfoOpen(true)} onClick={() => setIsShipInfoOpen(true)}
/> />
</FloatButton.Group> </FloatButton.Group>
</Popover> </Popover>
<div className="absolute top-3 right-3 "> <div className="absolute top-3 right-3 ">
<SosButton <SosButton
onRefresh={(value) => { onRefresh={async (value) => {
if (value) { if (value) {
getEntitiesData(); await refreshAllData();
} }
}} }}
/> />

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
import { getGPS } from '@/services/controller/DeviceController'; import { getGPS } from '@/services/controller/DeviceController';
import { import {
getFishSpecies, getFishSpecies,
@@ -35,10 +36,19 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
>([]); >([]);
const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]); const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
const [fishDatas, setFishDatas] = useState<API.FishSpeciesResponse[]>([]); const [fishDatas, setFishDatas] = useState<API.FishSpeciesResponse[]>([]);
const getAllFish = async () => {
try {
const resp = await getFishSpecies();
setFishDatas(resp);
// console.log('Fetched fish species:', resp);
} catch (error) {
console.error('Error fetching fish species:', error);
}
};
useEffect(() => { useEffect(() => {
getAllFish(); getAllFish();
if (isOpen) { if (isOpen) {
console.log('Modal opened with fishingLogs:', fishingLogs); // console.log('Modal opened with fishingLogs:', fishingLogs);
if (fishingLogs?.info && fishingLogs.info.length > 0) { if (fishingLogs?.info && fishingLogs.info.length > 0) {
const dataWithKeys: FishingLogInfoWithKey[] = fishingLogs.info.map( const dataWithKeys: FishingLogInfoWithKey[] = fishingLogs.info.map(
@@ -57,15 +67,6 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
} }
}, [isOpen, fishingLogs]); }, [isOpen, fishingLogs]);
const getAllFish = async () => {
try {
const resp = await getFishSpecies();
setFishDatas(resp);
console.log('Fetched fish species:', resp);
} catch (error) {
console.error('Error fetching fish species:', error);
}
};
const columns: ProColumns<FishingLogInfoWithKey>[] = [ const columns: ProColumns<FishingLogInfoWithKey>[] = [
{ {
title: 'Tên cá', title: 'Tên cá',
@@ -157,17 +158,17 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
async function createOrCreateOrUpdateFishingLog( async function createOrCreateOrUpdateFishingLog(
fishingLog: FishingLogInfoWithKey[], fishingLog: FishingLogInfoWithKey[],
) { ) {
console.log('Is finished:', isFinished); // console.log('Is finished:', isFinished);
console.log('Trip:', trip); // console.log('Trip:', trip);
try { try {
const gpsData = await getGPS(); const gpsData = await getGPS();
if (gpsData) { if (gpsData) {
if (isFinished == false) { if (isFinished === false) {
// Tạo mẻ mới // Tạo mẻ mới
const logStatus0 = trip.fishing_logs?.find((log) => log.status === 0); const logStatus0 = trip.fishing_logs?.find((log) => log.status === 0);
console.log('ok', logStatus0); // console.log('ok', logStatus0);
console.log('ok', fishingLog); // console.log('ok', fishingLog);
const body: API.FishingLog = { const body: API.FishingLog = {
fishing_log_id: logStatus0?.fishing_log_id || '', fishing_log_id: logStatus0?.fishing_log_id || '',
trip_id: trip.id, trip_id: trip.id,
@@ -191,8 +192,8 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
})), })),
sync: true, sync: true,
}; };
const resp = await updateFishingLogs(body); await updateFishingLogs(body);
console.log('Resp', resp); // console.log('Resp', resp);
onFinished?.(true); onFinished?.(true);
onOpenChange(false); onOpenChange(false);
@@ -222,8 +223,8 @@ const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
}; };
// console.log('Update body:', body); // console.log('Update body:', body);
const resp = await updateFishingLogs(body); await updateFishingLogs(body);
console.log('Resp', resp); // console.log('Resp', resp);
onFinished?.(true); onFinished?.(true);
onOpenChange(false); onOpenChange(false);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -2,7 +2,7 @@ import { API_PATH_LOGIN } from '@/constants';
import { request } from '@umijs/max'; import { request } from '@umijs/max';
export async function login(body: API.LoginRequestBody) { export async function login(body: API.LoginRequestBody) {
console.log('Login request body:', body); // console.log('Login request body:', body);
return request<API.LoginResponse>(API_PATH_LOGIN, { return request<API.LoginResponse>(API_PATH_LOGIN, {
method: 'POST', method: 'POST',

View File

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

View File

@@ -15,8 +15,8 @@ export const BASEMAP_ATTRIBUTIONS = '© OpenStreetMap contributors, © CartoDB';
export const INITIAL_VIEW_CONFIG = { export const INITIAL_VIEW_CONFIG = {
// Dịch tâm bản đồ ra phía biển và zoom ra xa hơn để thấy bao quát // Dịch tâm bản đồ ra phía biển và zoom ra xa hơn để thấy bao quát
center: [109.5, 16.0], center: [116.152685, 15.70581],
zoom: 5.5, zoom: 6.5,
minZoom: 5, minZoom: 5,
maxZoom: 12, maxZoom: 12,
minScale: 0.1, minScale: 0.1,
@@ -31,7 +31,6 @@ export const osmLayer = new TileLayer({
}); });
// const layerVN = await getLayer('base-vn'); // const layerVN = await getLayer('base-vn');
export const tinhGeoLayer = createGeoJSONLayer({ export const tinhGeoLayer = createGeoJSONLayer({
data: '', data: '',
style: createLabelAndFillStyle('#77BEF0', '#000000'), // vừa màu vừa label style: createLabelAndFillStyle('#77BEF0', '#000000'), // vừa màu vừa label
@@ -42,7 +41,7 @@ export const tinhGeoLayer = createGeoJSONLayer({
}); });
export const getShipIcon = (type: number, isFishing: boolean) => { export const getShipIcon = (type: number, isFishing: boolean) => {
console.log('type, isFishing', type, isFishing); // console.log('type, isFishing', type, isFishing);
if (type === 1 && !isFishing) { if (type === 1 && !isFishing) {
return shipWarningIcon; return shipWarningIcon;
@@ -62,3 +61,14 @@ export const getShipIcon = (type: number, isFishing: boolean) => {
return shipUndefineIcon; return shipUndefineIcon;
} }
}; };
export const convertToDMS = (value: number, isLat: boolean): string => {
const deg = Math.floor(Math.abs(value));
const minFloat = (Math.abs(value) - deg) * 60;
const min = Math.floor(minFloat);
const sec = (minFloat - min) * 60;
const direction = value >= 0 ? (isLat ? 'N' : 'E') : isLat ? 'S' : 'W';
return `${deg}°${min}'${sec.toFixed(2)}"${direction}`;
};

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

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

30
src/utils/cacheStore.ts Normal file
View 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
View 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();

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

View File

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

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