16 Commits

Author SHA1 Message Date
Tran Anh Tuan
2957d7f04d chore(deploy): Add file deploy.sh 2025-12-11 12:12:01 +07:00
Tran Anh Tuan
d722a89d54 chore(deploy): Add file deploy.sh 2025-12-11 12:08:02 +07:00
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
54 changed files with 26926 additions and 444 deletions

1
.gitignore vendored
View File

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

View File

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

323
CLAUDE.md Normal file
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
`@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.

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

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

105
config/request_prod.ts Normal file
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;
// },
// ],
};

38
deploy.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# ====== CONFIG =======
REMOTE_HOST="root@192.168.30.102"
REMOTE_DIR="/var/lib/nginx"
ZIP_FILE="dist.zip"
TMP_REMOTE_ZIP="/tmp/build.zip"
# =====================
echo "🔄 Copy file zip lên remote..."
scp "$ZIP_FILE" "$REMOTE_HOST:$TMP_REMOTE_ZIP"
echo "📦 Giải nén & deploy trên remote..."
ssh "$REMOTE_HOST" << EOF
set -e
# echo "👉 Xoá thư mục cũ (nếu muốn)..."
# rm -rf $REMOTE_DIR/*
# echo "👉 Tạo thư mục nếu chưa có..."
# mkdir -p $REMOTE_DIR
echo "👉 Giải nén file build.zip..."
unzip -o $TMP_REMOTE_ZIP -d $REMOTE_DIR
echo " Move..."
cd $REMOTE_DIR
mv dist web
echo "👉 Xoá file zip tạm..."
rm $TMP_REMOTE_ZIP
echo "🔁 Restart nginx..."
sudo systemctl restart nginx
echo "✅ Deploy thành công!"
EOF

22439
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

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

View File

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

View File

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

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

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);
try {
const res = await queryAlarms(); // đổi URL cho phù hợp
console.log('Alarm Data fetched:', res);
// console.log('Alarm Data fetched:', res);
setAlarmData(res || []);
} catch (err) {

View File

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

View File

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

View File

@@ -59,11 +59,6 @@ interface FeatureData {
text?: string;
}
// Interface for layer object
interface Layer {
layer: VectorLayer<VectorSource>;
}
class MapManager {
mapRef: React.RefObject<HTMLDivElement>;
map: Map | null;
@@ -78,6 +73,7 @@ class MapManager {
private layers: BaseLayer[]; // Assuming layers is defined elsewhere
private isInitialized: boolean; // Thêm thuộc tính để theo dõi trạng thái khởi tạo
private eventKey: EventsKey | undefined;
shipFeature: any;
constructor(
mapRef: React.RefObject<HTMLDivElement>,
@@ -112,10 +108,15 @@ class MapManager {
for (const layerMeta of listLayers) {
try {
const data = await getLayer(layerMeta); // lấy GeoJSON từ server
let style = {};
if (layerMeta === 'base-countries') {
style = createLabelAndFillStyle('#fafaf8', '#E5BEB5');
} else {
style = createLabelAndFillStyle('#77BEF0', '#000000');
}
const vectorLayer = createGeoJSONLayer({
data,
style: createLabelAndFillStyle('#77BEF0', '#000000'),
style: style,
});
dynamicLayers.push(vectorLayer);
@@ -162,10 +163,10 @@ class MapManager {
this.initZoomListener();
this.isInitialized = true;
console.log(
'Map initialized successfully at',
new Date().toLocaleTimeString(),
);
// console.log(
// 'Map initialized successfully at',
// new Date().toLocaleTimeString(),
// );
this.map.on('singleclick', (evt: any) => {
const featuresAtPixel: {
@@ -187,12 +188,12 @@ class MapManager {
if (this.features.includes(feature)) {
this.onFeatureClick(feature);
this.onFeatureSelect(feature, evt.pixel);
console.log(
'Feature clicked at',
new Date().toLocaleTimeString(),
':',
feature.getProperties(),
);
// console.log(
// 'Feature clicked at',
// new Date().toLocaleTimeString(),
// ':',
// feature.getProperties(),
// );
}
} else {
this.onFeaturesClick(featuresAtPixel.map((f) => f.feature));
@@ -365,7 +366,7 @@ class MapManager {
strokeColor = 'rgba(255, 0, 0, 0.8)',
strokeWidthBase = 0.25,
} = config;
console.log('Starting animatedMarker with config:', config);
// console.log('Starting animatedMarker with config:', config);
const flashGeom = feature.getGeometry()?.clone() as Point | undefined;
if (!flashGeom || !this.featureLayer) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import {
} from '@/services/controller/DeviceController';
import { queryBanzones } from '@/services/controller/MapController';
import { getShipIcon } from '@/services/service/MapService';
import { eventBus } from '@/utils/eventBus';
import {
convertWKTLineStringToLatLngArray,
convertWKTtoLatLngString,
@@ -22,8 +23,11 @@ import {
InfoCircleOutlined,
InfoOutlined,
} from '@ant-design/icons';
import { history, useModel } from '@umijs/max';
import { history, useIntl, useModel } from '@umijs/max';
import { FloatButton, Popover } from 'antd';
import LineString from 'ol/geom/LineString';
import Point from 'ol/geom/Point';
import { fromLonLat } from 'ol/proj';
import { useCallback, useEffect, useRef, useState } from 'react';
import GpsInfo from './components/GpsInfo';
import ShipInfo from './components/ShipInfo';
@@ -56,29 +60,33 @@ const HomePage: React.FC = () => {
const mapRef = useRef<HTMLDivElement>(null);
const [isShipInfoOpen, setIsShipInfoOpen] = useState(false);
const { gpsData, getGPSData } = useModel('getSos');
const hasCenteredRef = useRef(false);
const onFeatureClick = useCallback((feature: any) => {
console.log('OnClick Feature: ', feature);
console.log(
'Clicked ship at',
new Date().toLocaleTimeString(),
'Properties:',
feature.getProperties(),
);
// console.log(
// 'Clicked ship at',
// new Date().toLocaleTimeString(),
// 'Properties:',
// feature.getProperties(),
// );
}, []);
const intl = useIntl();
const onFeaturesClick = useCallback((features: any[]) => {
console.log('Multiple features clicked:', features);
}, []);
const onError = useCallback((error: any) => {
console.error(
'Lỗi khi thêm vào map at',
new Date().toLocaleTimeString(),
':',
error,
);
}, []);
const onError = useCallback(
(error: any) => {
console.error(
intl.formatMessage({ id: 'home.mapError' }),
'at',
new Date().toLocaleTimeString(),
':',
error,
);
},
[intl],
);
const mapManagerRef = useRef(
new MapManager(mapRef, {
@@ -90,46 +98,25 @@ const HomePage: React.FC = () => {
const [isShowGPSData, setIsShowGPSData] = useState(true);
useEffect(() => {
getGPSData();
const interval = setInterval(() => {
getGPSData();
getEntitiesData(mapManagerRef.current, gpsData!);
fetchAndAddTrackPoints(mapManagerRef.current);
fetchAndProcessEntities(mapManagerRef.current);
}, 5000);
return () => {
clearInterval(interval);
console.log('MapManager destroyed in HomePage cleanup');
};
}, []);
// Hàm tổng hợp: gọi GPS trước, sau đó gọi các API còn lại
const refreshAllData = async () => {
try {
// Gọi GPS, khi xong sẽ emit event gpsData:update và các API khác sẽ tự động được gọi qua event handler
await getGPSData();
} catch (e) {
console.error('refreshAllData error', e);
}
};
useEffect(() => {
const timer = setTimeout(() => {
// getGPSData();
getEntitiesData(mapManagerRef.current, gpsData!);
fetchAndAddTrackPoints(mapManagerRef.current);
fetchAndProcessEntities(mapManagerRef.current);
}, 1000); // delay 1s
return () => clearTimeout(timer);
}, [gpsData]);
// Helper function to add features to the map
const addFeatureToMap = (
// Helper function to add features to the map (kept above pollers to avoid use-before-define)
function addFeatureToMap(
mapManager: MapManager,
gpsData: GpsData,
alarm: API.AlarmResponse,
) => {
console.log(
'Adding feature to map with GPS data:',
gpsData,
'and alarm:',
alarm,
);
) {
const isSos = alarm?.alarms.find(
(a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING,
);
console.log('Is SOS Alarm Present:', isSos);
if (mapManager?.featureLayer && gpsData) {
mapManager.featureLayer.getSource()?.clear();
@@ -143,18 +130,21 @@ const HomePage: React.FC = () => {
},
);
}
};
const fetchAndProcessEntities = async (mapManager: MapManager) => {
}
const drawBanzones = async (mapManager: MapManager) => {
try {
const entities: API.TransformedEntity[] = await queryEntities();
console.log('Fetched entities:', entities.length);
const layer = mapManager.featureLayer?.getSource();
layer?.getFeatures()?.forEach((f) => {
if ([MAP_POLYGON_BAN, MAP_POLYLINE_BAN].includes(f.get('id'))) {
layer.removeFeature(f);
}
});
const entities = await queryEntities();
for (const entity of entities) {
if (entity.id === '50:2' && entity.valueString !== '[]') {
const banzones = await queryBanzones();
const zones: any[] = JSON.parse(entity.valueString);
zones.forEach((zone: any) => {
const geom = banzones.find((b) => b.id === zone.zone_id);
if (geom) {
@@ -197,33 +187,163 @@ const HomePage: React.FC = () => {
}
}
} catch (error) {
console.error('Error fetching entities:', error);
console.error(
intl.formatMessage({ id: 'home.errorDrawBanzones' }),
error,
);
}
};
const fetchAndAddTrackPoints = async (mapManager: MapManager) => {
try {
const trackpoints: API.ShipTrackPoint[] = await queryShipTrackPoints();
if (trackpoints.length > 0) {
mapManager.addLineString(
trackpoints.map((point) => [point.lon, point.lat]),
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
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);
}
} catch (error) {
console.error('Error fetching ship track points:', error);
}
};
};
const getEntitiesData = async (mapManager: MapManager, gpsData: GpsData) => {
try {
const alarm: API.AlarmResponse = await queryAlarms();
addFeatureToMap(mapManager, gpsData, alarm);
} catch (error) {
console.error('Error fetching entities:', error);
// Cập nhật marker tàu khi có GPS mới
const onShipUpdate = (data: API.GPSResonse) => {
const mapManager = mapManagerRef.current;
if (!mapManager || !data) return;
// Cache feature trong mapManager
if (!mapManager.shipFeature) {
mapManager.shipFeature = mapManager.addPoint(
[data.lon, data.lat],
{ bearing: data.h || 0 },
{
icon: getShipIcon(0, data?.fishing || false),
scale: 0.1,
animate: false,
},
);
} else {
const geom = mapManager.shipFeature.getGeometry();
if (geom instanceof Point) {
geom.setCoordinates([data.lon, data.lat]);
}
}
// Set map center to current GPS position
if (!hasCenteredRef.current && mapManager.map && mapManager.map.getView) {
mapManager.map.getView().setCenter(fromLonLat([data.lon, data.lat]));
hasCenteredRef.current = true;
}
};
// Cập nhật alarm khi có event
const onAlarmUpdate = (alarm: API.AlarmResponse) => {
if (gpsData) addFeatureToMap(mapManagerRef.current, gpsData, alarm);
};
// Cập nhật trackpoints khi có event
const onTrackpointsUpdate = (trackpoints: API.ShipTrackPoint[]) => {
const source = mapManagerRef.current?.featureLayer?.getSource();
if (source && trackpoints.length > 0) {
const features = source.getFeatures();
// Tìm polyline theo id
const polyline = features.find(
(f) => f.get('id') === MAP_TRACKPOINTS_ID,
);
const coordinates = trackpoints.map((point) => [point.lon, point.lat]);
if (polyline) {
const geom = polyline.getGeometry();
if (geom instanceof LineString) {
geom.setCoordinates(coordinates);
} else {
mapManagerRef.current?.addLineString(
coordinates,
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
}
} else {
mapManagerRef.current?.addLineString(
coordinates,
{ id: MAP_TRACKPOINTS_ID },
{ strokeColor: 'blue', strokeWidth: 3 },
);
}
}
};
// Cập nhật entities/banzones khi có event
const onEntitiesUpdate = async () => {
const mapManager = mapManagerRef.current;
if (mapManager) {
await drawBanzones(mapManager);
}
};
eventBus.on('gpsData:update', onGpsUpdate);
eventBus.on('ship:update', onShipUpdate);
eventBus.on('alarm:update', onAlarmUpdate);
eventBus.on('trackpoints:update', onTrackpointsUpdate);
eventBus.on('entities:update', onEntitiesUpdate);
return () => {
eventBus.off('gpsData:update', onGpsUpdate);
eventBus.off('ship:update', onShipUpdate);
eventBus.off('alarm:update', onAlarmUpdate);
eventBus.off('trackpoints:update', onTrackpointsUpdate);
eventBus.off('entities:update', onEntitiesUpdate);
};
}, [gpsData]);
// Vẽ lại banzones (polygon, polyline vùng cấm, vùng cá thử...)
useEffect(() => {
// Vẽ lại banzones khi vào trang
const mapManager = mapManagerRef.current;
if (mapManager) {
drawBanzones(mapManager);
}
};
}, []);
return (
<div>
@@ -239,14 +359,11 @@ const HomePage: React.FC = () => {
/>
<Popover
styles={{
root: { width: '85%', maxWidth: 500, paddingLeft: 15 },
root: { width: '85%', maxWidth: 800, minWidth: 700, paddingLeft: 15 },
}}
placement="left"
title="Trạng thái hiện tại"
content={
<GpsInfo gpsData={gpsData} />
// <ShipInfo />
}
title={intl.formatMessage({ id: 'home.currentStatus' })}
content={<GpsInfo gpsData={gpsData} />}
open={isShowGPSData}
>
<FloatButton.Group
@@ -258,25 +375,20 @@ const HomePage: React.FC = () => {
<FloatButton
icon={<CommentOutlined />}
onClick={() => history.push(ROUTE_TRIP)}
tooltip="Thông tin chuyến đi"
tooltip={intl.formatMessage({ id: 'home.tripInfo' })}
/>
<FloatButton
icon={<InfoOutlined />}
tooltip="Thông tin tàu"
tooltip={intl.formatMessage({ id: 'home.shipInfo' })}
onClick={() => setIsShipInfoOpen(true)}
/>
</FloatButton.Group>
</Popover>
<div className="absolute top-3 right-3 ">
<SosButton
onRefresh={(value) => {
onRefresh={async (value) => {
if (value) {
// Ensure null check for gpsData in all usages
if (gpsData) {
getEntitiesData(mapManagerRef.current, gpsData);
} else {
console.warn('GPS data is null, skipping getEntitiesData call');
}
await refreshAllData();
}
}}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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';
export async function login(body: API.LoginRequestBody) {
console.log('Login request body:', body);
// console.log('Login request body:', body);
return request<API.LoginResponse>(API_PATH_LOGIN, {
method: 'POST',

View File

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

View File

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

4
src/typings.d.ts vendored Normal file
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';
declare module '*.png' {
const src: string;
export default src;
}