From 216e865ca51a3d8fd242de45cc821bba7ed929de Mon Sep 17 00:00:00 2001 From: Tran Anh Tuan Date: Thu, 20 Nov 2025 16:21:17 +0700 Subject: [PATCH] feat(localization): Add en/vi language --- .gitignore | 3 +- .umirc.ts | 19 +- CLAUDE.md | 323 ++++++++++++++ I18N_USAGE_GUIDE.md | 400 ++++++++++++++++++ Task.md | 31 ++ config/Request.ts | 28 +- package-lock.json | 2 +- src/app.tsx | 72 ++-- src/components/Footer/Footer.tsx | 9 +- src/components/LanguageSwitcher/index.tsx | 93 ++++ src/components/switch/LangSwitches.tsx | 38 ++ src/hooks/useTranslation.ts | 76 ++++ src/locales/en-US.ts | 360 ++++++++++++++++ src/locales/vi-VN.ts | 359 ++++++++++++++++ src/models/getSos.ts | 6 +- src/pages/Auth/index.tsx | 53 ++- src/pages/Home/components/BaseMap.tsx | 1 + src/pages/Home/components/GpsInfo.tsx | 24 +- src/pages/Home/components/ShipInfo.tsx | 44 +- src/pages/Home/components/SosButton.tsx | 46 +- src/pages/Home/index.tsx | 352 +++++++-------- src/pages/Trip/components/BadgeTripStatus.tsx | 16 +- src/pages/Trip/components/CancelTrip.tsx | 15 +- .../Trip/components/CreateNewHaulOrTrip.tsx | 32 +- .../components/CreateOrUpdateFishingLog.tsx | 21 +- src/pages/Trip/components/HaulFishList.tsx | 31 +- src/pages/Trip/components/HaulTable.tsx | 73 ++-- src/pages/Trip/components/MainTripBody.tsx | 53 +-- .../components/TripCancelOrFinishButton.tsx | 31 +- src/pages/Trip/components/TripCost.tsx | 50 ++- src/pages/Trip/components/TripCrews.tsx | 77 +++- src/pages/Trip/components/TripFishingGear.tsx | 22 +- src/pages/Trip/index.tsx | 38 +- src/services/controller/typings.d.ts | 2 +- src/services/service/MapService.ts | 2 +- src/typings.d.ts | 4 + typings.d.ts | 5 + 37 files changed, 2356 insertions(+), 455 deletions(-) create mode 100644 CLAUDE.md create mode 100644 I18N_USAGE_GUIDE.md create mode 100644 Task.md create mode 100644 src/components/LanguageSwitcher/index.tsx create mode 100644 src/components/switch/LangSwitches.tsx create mode 100644 src/hooks/useTranslation.ts create mode 100644 src/locales/en-US.ts create mode 100644 src/locales/vi-VN.ts create mode 100644 src/typings.d.ts diff --git a/.gitignore b/.gitignore index ebe089d..4628588 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ /dist /.mfsu .swc -.DS_Store \ No newline at end of file +.DS_Store +claude_code_zai_env.sh \ No newline at end of file diff --git a/.umirc.ts b/.umirc.ts index 989c760..755bc67 100644 --- a/.umirc.ts +++ b/.umirc.ts @@ -12,8 +12,15 @@ export default defineConfig({ request: {}, locale: { default: 'vi-VN', - // baseNavigator: false, - // antd: true, + 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: { @@ -22,7 +29,7 @@ export default defineConfig({ proxy: proxy[REACT_APP_ENV], routes: [ { - name: 'Login', + title: 'Login', path: '/login', component: './Auth', layout: false, @@ -32,13 +39,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', @@ -46,5 +53,5 @@ export default defineConfig({ ], npmClient: 'pnpm', - tailwindcss: {}, + tailwindcss: {}, // Temporarily disabled }); diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8f2e07b --- /dev/null +++ b/CLAUDE.md @@ -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 +export async function queryAlarms(): Promise +export async function queryEntities(): Promise +``` + +### 4. UmiJS Model Pattern +Global state management using UmiJS Models: + +```typescript +// src/models/getTrip.ts +export default function useGetTripModel() { + const [data, setData] = useState(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 \ No newline at end of file diff --git a/I18N_USAGE_GUIDE.md b/I18N_USAGE_GUIDE.md new file mode 100644 index 0000000..ee0545f --- /dev/null +++ b/I18N_USAGE_GUIDE.md @@ -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: () => [ + , +], +``` + +## 📁 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 ( +
+ {/* Simple translation */} +

{intl.formatMessage({ id: 'common.save' })}

+ + {/* Translation with variables */} +

{intl.formatMessage( + { id: 'validation.minLength' }, + { min: 6 } + )}

+
+ ); +}; +``` + +### 2. Using `FormattedMessage` Component + +```typescript +import { FormattedMessage } from '@umijs/max'; +import React from 'react'; + +const MyComponent: React.FC = () => { + return ( +
+ {/* Simple translation */} + + + {/* Translation with variables */} + +
+ ); +}; +``` + +## 🎯 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 ( +
+ + + +
+ ); +}; +``` + +### 2. Conditional Rendering + +```typescript +import { useIntl } from '@umijs/max'; +import React from 'react'; + +const LocalizedContent: React.FC = () => { + const intl = useIntl(); + + return ( +
+ {intl.locale === 'vi-VN' ? ( +

Chào mừng đến với hệ thống!

+ ) : ( +

Welcome to the system!

+ )} +
+ ); +}; +``` + +## 🪝 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 ( +
+ {/* Simple translation */} +

{t('common.save')}

+ + {/* Translation with variables */} +

{t('validation.minLength', { min: 6 })}

+ + {/* Locale detection */} + {isLocale('vi-VN') &&

Xin chào!

} + + {/* Date formatting */} +

{formatDate(new Date(), { + year: 'numeric', + month: 'long', + day: 'numeric' + })}

+ + {/* Number formatting */} +

{formatNumber(1234.56, { + style: 'currency', + currency: 'VND' + })}

+
+ ); +}; +``` + +## 🌐 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 ( +
+ + {/* or */} + +
+ ); +}; +``` + +### 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 + + +// ✅ Correct - check if key exists in locale files + +``` + +#### 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
Check console for debug info
; +}; +``` + +## 🔄 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( + + + + ); + ``` + +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 \ No newline at end of file diff --git a/Task.md b/Task.md new file mode 100644 index 0000000..8bfc609 --- /dev/null +++ b/Task.md @@ -0,0 +1,31 @@ +- Goal: +Add new localization (i18n) to my website using the Internationalization plugin of Umi Max so that users can switch between Vietnamese and English. + +- Tasks for you: + +1. Create full base setup for i18n in Umi Max, including: + + + Required configuration in config/config.ts + + + Folder structure for locales + + + Base translation files (vi-VN.ts, en-US.ts) + + + Any needed runtime or app-level configuration + +2. Provide sample code for: + + + Using i18n inside components (useIntl, , etc.) + + + Creating a language switcher component + +3. Write a clear usage guide explaining: + + + Where to place all files + + + How to import and use translations + + + How to switch languages at runtime + +Make sure all examples are valid for Umi Max (Umi 4.5.0). Checked task you complete + diff --git a/config/Request.ts b/config/Request.ts index 548ca40..5f22c28 100644 --- a/config/Request.ts +++ b/config/Request.ts @@ -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 { - success: boolean; - data: T; - errorCode?: number; - errorMessage?: string; - showType?: ErrorShowType; -} +// interface ResponseStructure { +// success: boolean; +// data: T; +// errorCode?: number; +// errorMessage?: string; +// showType?: ErrorShowType; +// } const codeMessage = { 200: 'The server successfully returned the requested data。', diff --git a/package-lock.json b/package-lock.json index 5b21022..4b5b13c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "FE-DEVICE-SGW", + "name": "my-app", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/src/app.tsx b/src/app.tsx index 2543ebd..35546ff 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,12 +1,47 @@ 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 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 ( + , + label: intl.formatMessage({ id: 'common.logout' }), + onClick: () => { + removeToken(); + history.push(ROUTE_LOGIN); + }, + }, + ], + }} + > + avatar + + ); +}; // 全局初始化数据配置,用于 Layout 用户信息和权限初始化 // 更多信息见文档:https://umijs.org/docs/api/runtime-config#getinitialstate export async function getInitialState() { @@ -58,7 +93,7 @@ export const layout: RunTimeLayoutConfig = (initialState) => { console.log('initialState', initialState); return { - logo: logo, + logo, fixedHeader: true, contentWidth: 'Fluid', navTheme: 'light', @@ -66,39 +101,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: () => [ + , + ], avatarProps: { size: 'small', - src: '/avatar.svg', // 👈 ở đây dùng icon thay vì src - render: (_, dom) => { - return ( - , - label: 'Đăng xuất', - onClick: () => { - removeToken(); - history.push(ROUTE_LOGIN); - }, - }, - ], - }} - > - {dom} - - ); - }, + src: '/avatar.svg', + render: () => , }, - layout: 'mix', + layout: 'top', // Thay đổi từ 'mix' sang 'top' để đảm bảo header hiển thị đúng logout: () => { removeToken(); history.push(ROUTE_LOGIN); diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index fd7539a..846c8be 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -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 ( ); }; diff --git a/src/components/LanguageSwitcher/index.tsx b/src/components/LanguageSwitcher/index.tsx new file mode 100644 index 0000000..4789050 --- /dev/null +++ b/src/components/LanguageSwitcher/index.tsx @@ -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 = ({ + 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: ( + + {item.flag} + {item.label} + + ), + onClick: () => handleLanguageChange(item.key), + })); + + if (type === 'button') { + const currentLang = getCurrentLanguage(); + + return ( + + ); + } + + const currentLang = getCurrentLanguage(); + + return ( + + + + ); +}; + +export default LanguageSwitcher; diff --git a/src/components/switch/LangSwitches.tsx b/src/components/switch/LangSwitches.tsx new file mode 100644 index 0000000..8cda47d --- /dev/null +++ b/src/components/switch/LangSwitches.tsx @@ -0,0 +1,38 @@ +import { getLocale, setLocale } from '@umijs/max'; +import { useEffect, useState } from 'react'; + +const LangSwitches = () => { + const [isEnglish, setIsEnglish] = useState(false); + + // Keep checkbox in sync with current Umi locale without forcing a reload + useEffect(() => { + const syncLocale = () => setIsEnglish(getLocale() === 'en-US'); + + syncLocale(); + window.addEventListener('languagechange', syncLocale); + + return () => { + window.removeEventListener('languagechange', syncLocale); + }; + }, []); + + const handleLanguageChange = (e: React.ChangeEvent) => { + const newLocale = e.target.checked ? 'en-US' : 'vi-VN'; + setIsEnglish(e.target.checked); + setLocale(newLocale, false); // Update locale instantly without full page reload + }; + + return ( + + ); +}; + +export default LangSwitches; diff --git a/src/hooks/useTranslation.ts b/src/hooks/useTranslation.ts new file mode 100644 index 0000000..2a1b32c --- /dev/null +++ b/src/hooks/useTranslation.ts @@ -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 => { + 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; \ No newline at end of file diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts new file mode 100644 index 0000000..f7818eb --- /dev/null +++ b/src/locales/en-US.ts @@ -0,0 +1,360 @@ +export default { + // Common + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.delete': 'Delete', + 'common.edit': 'Edit', + 'common.add': 'Add', + 'common.search': 'Search', + 'common.filter': 'Filter', + 'common.loading': 'Loading...', + 'common.confirm': 'Confirm', + 'common.back': 'Back', + 'common.next': 'Next', + 'common.previous': 'Previous', + 'common.submit': 'Submit', + 'common.reset': 'Reset', + 'common.refresh': 'Refresh', + 'common.export': 'Export', + 'common.import': 'Import', + 'common.view': 'View', + 'common.detail': 'Detail', + 'common.list': 'List', + 'common.settings': 'Settings', + 'common.logout': 'Logout', + 'common.login': 'Login', + 'common.username': 'Username', + 'common.password': 'Password', + 'common.remember': 'Remember me', + 'common.forgot': 'Forgot password?', + 'common.required': 'Required', + 'common.optional': 'Optional', + 'common.enabled': 'Enabled', + 'common.disabled': 'Disabled', + 'common.active': 'Active', + 'common.inactive': 'Inactive', + 'common.online': 'Online', + 'common.offline': 'Offline', + 'common.yes': 'Yes', + 'common.no': 'No', + 'common.ok': 'OK', + 'common.error': 'Error', + 'common.success': 'Success', + 'common.warning': 'Warning', + 'common.info': 'Information', + 'common.vietnamese': 'Vietnamese', + 'common.english': 'English', + 'common.footer.copyright': '2025 Product of Mobifone v1.2.2', + + // Menu + 'menu.monitoring': 'Monitoring', + 'menu.trips': 'Trips', + 'menu.i18nTest': 'i18n Test', + 'menu.dashboard': 'Dashboard', + 'menu.reports': 'Reports', + 'menu.settings': 'Settings', + 'menu.profile': 'Profile', + 'menu.help': 'Help', + 'menu.about': 'About', + + // Authentication + 'auth.login.title': 'System Login', + 'auth.login.subtitle': 'Ship Monitoring System', + 'auth.login.welcome': 'Welcome Back', + 'auth.login.description': 'Login to continue monitoring vessels', + 'auth.login.invalid': 'Invalid username or password', + 'auth.login.success': 'Login successful', + 'auth.logout.title': 'Logout', + 'auth.logout.confirm': 'Are you sure you want to logout?', + 'auth.logout.success': 'Logout successful', + + // Map & Monitoring + 'map.title': 'Monitoring Map', + 'map.ship.current': 'Current Position', + 'map.ship.tracking': 'Ship Tracking', + 'map.ship.speed': 'Speed', + 'map.ship.course': 'Heading', + 'map.ship.latitude': 'Latitude', + 'map.ship.longitude': 'Longitude', + 'map.ship.state': 'State', + 'map.ship.state.tooltip': 'Is the ship currently fishing?', + 'map.ship.lastUpdate': 'Last Update', + 'map.ship.name': 'Name', + 'map.ship.imo': 'IMO', + 'map.ship.mmsi': 'MMSI', + 'map.ship.regNumber': 'Registration Number', + 'map.ship.length': 'Length', + 'map.ship.power': 'Power', + 'map.ship.info': 'Ship Information', + 'map.ship.status.online': 'Online', + 'map.ship.status.offline': 'Offline', + 'map.ship.status.fishing': 'Fishing', + 'map.ship.state.notFishing': 'Not Fishing', + 'map.ship.status.sailing': 'Sailing', + 'map.ship.status.anchor': 'Anchored', + 'map.alarm.title': 'Alarms', + 'map.alarm.new': 'New Alarm', + 'map.alarm.geofence': 'Geofence Violation', + 'map.alarm.sos': 'SOS Emergency', + 'map.alarm.power': 'Power Loss', + 'map.alarm.gps': 'GPS Loss', + 'map.zone.fishing': 'Fishing Zone', + 'map.zone.restricted': 'Restricted Zone', + 'map.zone.warning': 'Warning Zone', + 'map.layer.ships': 'Ships', + 'map.layer.alarms': 'Alarms', + 'map.layer.zones': 'Zones', + 'map.layer.tracks': 'Tracks', + + // Trip Management + 'trip.title': 'Trip Management', + 'trip.create': 'Create New Trip', + 'trip.edit': 'Edit Trip', + 'trip.delete': 'Delete Trip', + 'trip.list': 'Trip List', + 'trip.detail': 'Trip Details', + 'trip.startTime': 'Start Time', + 'trip.endTime': 'End Time', + 'trip.duration': 'Duration', + 'trip.distance': 'Distance', + 'trip.crew': 'Crew', + 'trip.catch': 'Catch', + 'trip.costs': 'Costs', + 'trip.notes': 'Notes', + 'trip.status.planning': 'Planning', + 'trip.status.active': 'Active', + 'trip.status.completed': 'Completed', + 'trip.status.cancelled': 'Cancelled', + + // Fishing Log + 'fishing.title': 'Fishing Log', + 'fishing.create': 'Create New Log', + 'fishing.edit': 'Edit Log', + 'fishing.delete': 'Delete Log', + 'fishing.haul': 'Haul', + 'fishing.species': 'Species', + 'fishing.quantity': 'Quantity', + 'fishing.weight': 'Weight', + 'fishing.method': 'Method', + 'fishing.location': 'Location', + 'fishing.time': 'Time', + 'fishing.depth': 'Depth', + 'fishing.temperature': 'Temperature', + 'fishing.weather': 'Weather', + + // Alarms + 'alarm.title': 'Alarm Management', + 'alarm.active': 'Active Alarms', + 'alarm.history': 'Alarm History', + 'alarm.type.geofence': 'Geofence', + 'alarm.type.sos': 'SOS', + 'alarm.type.power': 'Power', + 'alarm.type.gps': 'GPS', + 'alarm.type.speed': 'Speed', + 'alarm.type.stopped': 'Stopped', + 'alarm.level.low': 'Low', + 'alarm.level.medium': 'Medium', + 'alarm.level.high': 'High', + 'alarm.level.critical': 'Critical', + 'alarm.acknowledge': 'Acknowledge', + 'alarm.resolve': 'Resolve', + 'alarm.ignore': 'Ignore', + 'alarm.created': 'Created', + 'alarm.resolved': 'Resolved', + + // Settings + 'settings.title': 'System Settings', + 'settings.profile': 'User Profile', + 'settings.language': 'Language', + 'settings.theme': 'Theme', + 'settings.notifications': 'Notifications', + 'settings.privacy': 'Privacy', + 'settings.security': 'Security', + 'settings.about': 'About', + 'settings.version': 'Version', + 'settings.update': 'Update', + + // Messages + 'message.loading': 'Loading data...', + 'message.noData': 'No data available', + 'message.error.network': 'Network connection error', + 'message.error.server': 'Server error', + 'message.error.generic': 'An error occurred', + 'message.success.saved': 'Saved successfully', + 'message.success.deleted': 'Deleted successfully', + 'message.success.updated': 'Updated successfully', + 'message.confirm.delete': 'Are you sure you want to delete this item?', + 'message.confirm.save': 'Are you sure you want to save these changes?', + 'message.confirm.logout': 'Are you sure you want to logout?', + + // Units + 'unit.knots': 'knots', + 'unit.km': 'km', + 'unit.kmh': 'km/h', + 'unit.meters': 'm', + 'unit.kilometers': 'km', + 'unit.nauticalMiles': 'nautical miles', + 'unit.kilograms': 'kg', + 'unit.tons': 'tons', + 'unit.degrees': '°', + 'unit.celsius': '°C', + 'unit.hours': 'hours', + 'unit.minutes': 'minutes', + 'unit.seconds': 'seconds', + + // Time + 'time.now': 'Now', + 'time.today': 'Today', + 'time.yesterday': 'Yesterday', + 'time.tomorrow': 'Tomorrow', + 'time.thisWeek': 'This Week', + 'time.thisMonth': 'This Month', + 'time.thisYear': 'This Year', + 'format.date': 'MM/DD/YYYY', + 'format.datetime': 'MM/DD/YYYY HH:mm', + 'format.time': 'HH:mm', + 'format.relative': '{time} ago', + + // Validation + 'validation.required': 'This field is required', + 'validation.username': 'Username cannot be empty', + 'validation.password': 'Password cannot be empty', + 'validation.minLength': 'Minimum {min} characters required', + 'validation.maxLength': 'Maximum {max} characters allowed', + 'validation.min': 'Minimum value is {min}', + 'validation.max': 'Maximum value is {max}', + 'validation.numeric': 'Must be a number', + 'validation.phone': 'Invalid phone number', + 'validation.url': 'Invalid URL', + + // Trip Status Badge + 'trip.badge.notApproved': 'Not Approved', + 'trip.badge.waitingApproval': 'Waiting Approval', + 'trip.badge.approved': 'Approved', + 'trip.badge.active': 'Active', + 'trip.badge.completed': 'Completed', + 'trip.badge.cancelled': 'Cancelled', + 'trip.badge.unknown': 'Unknown Status', + + // Trip Actions + 'trip.startTrip': 'Start Trip', + 'trip.finishHaul': 'Finish Haul', + 'trip.startHaul': 'Start Haul', + 'trip.warning.haulNotFinished': + 'Please finish the current haul before starting a new one', + 'trip.warning.tripStarted': 'The trip has already been started or completed.', + 'trip.cancelTrip.title': 'Confirm Cancel Trip', + 'trip.cancelTrip.button': 'Cancel Trip', + 'trip.cancelTrip.reason': 'Reason: ', + 'trip.cancelTrip.placeholder': 'Enter reason for cancelling trip...', + 'trip.cancelTrip.validation': 'Please enter reason for cancelling trip', + 'trip.weather': 'Sunny', + + // Haul Fish List + 'trip.haulFishList.title': 'Fish List', + 'trip.haulFishList.fishName': 'Fish Name', + 'trip.haulFishList.fishCondition': 'Condition', + 'trip.haulFishList.fishRarity': 'Rarity', + 'trip.haulFishList.fishSize': 'Size (cm)', + 'trip.haulFishList.weight': 'Weight (kg)', + 'trip.haulFishList.gearUsage': 'Fishing Gear Used', + 'trip.haulFishList.noData': 'No fish data in this haul', + + // Haul Table + 'trip.haulTable.no': 'Number', + 'trip.haulTable.status': 'Status', + 'trip.haulTable.fishing': 'Fishing', + 'trip.haulTable.endFishing': 'Completed', + 'trip.haulTable.cancelFishing': 'Cancelled', + 'trip.haulTable.weather': 'Weather', + 'trip.haulTable.startTime': 'Start Time', + 'trip.haulTable.endTime': 'End Time', + 'trip.haulTable.action': 'Action', + 'trip.haulTable.updateSuccess': 'Haul updated successfully', + 'trip.haulTable.updateError': 'Failed to update haul', + 'trip.haulTable.haul': 'Haul', + + // Main Trip Body + 'trip.mainBody.tripCost': 'Trip Cost', + 'trip.mainBody.fishingGear': 'Fishing Gear List', + 'trip.mainBody.crew': 'Crew List', + 'trip.mainBody.haulList': 'Haul List', + + // Trip Cost + 'trip.cost.type': 'Type', + 'trip.cost.amount': 'Amount', + 'trip.cost.unit': 'Unit', + 'trip.cost.price': 'Cost', + 'trip.cost.total': 'Total Cost', + 'trip.cost.fuel': 'Fuel', + 'trip.cost.crewSalary': 'Crew Salary', + 'trip.cost.food': 'Food', + 'trip.cost.iceSalt': 'Ice Salt', + 'trip.cost.grandTotal': 'Grand Total', + + // Trip Crews + 'trip.crew.id': 'ID', + 'trip.crew.name': 'Name', + 'trip.crew.role': 'Role', + 'trip.crew.email': 'Email', + 'trip.crew.phone': 'Phone', + 'trip.crew.birthDate': 'Birth Date', + 'trip.crew.address': 'Address', + 'trip.crew.captain': 'Captain', + 'trip.crew.member': 'Crew Member', + + // Trip Fishing Gear + 'trip.gear.name': 'Name', + 'trip.gear.quantity': 'Quantity', + + // Trip Cancel Or Finish + 'trip.finishButton.confirmTitle': 'Notification', + 'trip.finishButton.confirmMessage': + 'Are you sure you want to finish this trip?', + 'trip.finishButton.confirmYes': 'Confirm', + 'trip.finishButton.confirmNo': 'Cancel', + 'trip.finishButton.end': 'Finish', + 'trip.finishButton.updateSuccess': 'Trip status updated successfully', + 'trip.finishButton.updateError': 'Failed to update trip status', + + // Trip Detail Page + 'trip.detail.title': 'Trip', + 'trip.detail.defaultTitle': 'Trip', + 'trip.detail.alarm': 'Alarms', + 'trip.detail.createHaulSuccess': 'Haul created successfully', + 'trip.detail.createHaulError': 'Failed to create haul', + 'trip.detail.startTripSuccess': 'Trip started successfully', + 'trip.detail.startTripError': 'Failed to start trip', + 'trip.detail.updateHaulSuccess': 'Haul updated successfully', + 'trip.detail.updateHaulError': 'Failed to update haul', + + // Pagination + 'pagination.total': '{start}-{end} of {total}', + + // Home Page + 'home.currentStatus': 'Current Status', + 'home.tripInfo': 'Trip Information', + 'home.shipInfo': 'Ship Information', + 'home.errorDrawBanzones': 'Error drawing ban zones', + 'home.errorBackground': 'Error calling background APIs', + 'home.errorGpsUpdate': 'Error updating GPS', + 'home.mapError': 'Error adding to map', + + // SOS + 'sos.title': 'Emergency Alert', + 'sos.button': 'Emergency', + 'sos.status': 'Emergency status active', + 'sos.end': 'End', + 'sos.label': 'Content:', + 'sos.placeholder': 'Select or enter reason...', + 'sos.other': 'Other', + 'sos.otherLabel': 'Other Reason', + 'sos.otherPlaceholder': 'Enter other reason...', + 'sos.defaultMessage': 'Emergency situation, no time to choose!!!', + 'sos.sendSuccess': 'SOS signal sent successfully!', + 'sos.sendError': 'Failed to send SOS signal!', + 'sos.cancelSuccess': 'SOS signal cancelled successfully!', + 'sos.cancelError': 'Failed to cancel SOS signal!', + 'sos.validationError': 'Please select or enter a reason!', + 'sos.otherValidationError': 'Please enter another reason!', +}; diff --git a/src/locales/vi-VN.ts b/src/locales/vi-VN.ts new file mode 100644 index 0000000..fb5bbba --- /dev/null +++ b/src/locales/vi-VN.ts @@ -0,0 +1,359 @@ +export default { + // Common + 'common.save': 'Lưu', + 'common.cancel': 'Hủy', + 'common.delete': 'Xóa', + 'common.edit': 'Chỉnh sửa', + 'common.add': 'Thêm', + 'common.search': 'Tìm kiếm', + 'common.filter': 'Bộ lọc', + 'common.loading': 'Đang tải...', + 'common.confirm': 'Xác nhận', + 'common.back': 'Quay lại', + 'common.next': 'Tiếp theo', + 'common.previous': 'Trước đó', + 'common.submit': 'Gửi', + 'common.reset': 'Đặt lại', + 'common.refresh': 'Làm mới', + 'common.export': 'Xuất', + 'common.import': 'Nhập', + 'common.view': 'Xem', + 'common.detail': 'Chi tiết', + 'common.list': 'Danh sách', + 'common.settings': 'Cài đặt', + 'common.logout': 'Đăng xuất', + 'common.login': 'Đăng nhập', + 'common.username': 'Tên người dùng', + 'common.password': 'Mật khẩu', + 'common.remember': 'Ghi nhớ', + 'common.forgot': 'Quên mật khẩu?', + 'common.required': 'Bắt buộc', + 'common.optional': 'Tùy chọn', + 'common.enabled': 'Đã bật', + 'common.disabled': 'Đã tắt', + 'common.active': 'Hoạt động', + 'common.inactive': 'Không hoạt động', + 'common.online': 'Trực tuyến', + 'common.offline': 'Ngoại tuyến', + 'common.yes': 'Có', + 'common.no': 'Không', + 'common.ok': 'OK', + 'common.error': 'Lỗi', + 'common.success': 'Thành công', + 'common.warning': 'Cảnh báo', + 'common.info': 'Thông tin', + "common.vietnamese": "Tiếng Việt", + "common.english": "Tiếng Anh", + 'common.footer.copyright': '2025 Sản phẩm của Mobifone v1.2.2', + + // Navigation + 'menu.monitoring': 'Giám sát', + 'menu.trips': 'Chuyến đi', + 'menu.i18nTest': 'Kiểm tra i18n', + 'menu.dashboard': 'Bảng điều khiển', + 'menu.reports': 'Báo cáo', + 'menu.settings': 'Cài đặt', + 'menu.profile': 'Hồ sơ', + 'menu.help': 'Trợ giúp', + 'menu.about': 'Về chúng tôi', + + // Authentication + 'auth.login.title': 'Đăng nhập hệ thống', + 'auth.login.subtitle': 'Hệ thống giám sát tàu cá', + 'auth.login.welcome': 'Chào mừng trở lại', + 'auth.login.description': 'Đăng nhập để tiếp tục giám sát tàu thuyền', + 'auth.login.invalid': 'Tên người dùng hoặc mật khẩu không hợp lệ', + 'auth.login.success': 'Đăng nhập thành công', + 'auth.logout.title': 'Đăng xuất', + 'auth.logout.confirm': 'Bạn có chắc chắn muốn đăng xuất?', + 'auth.logout.success': 'Đăng xuất thành công', + + // Map & Monitoring + 'map.title': 'Bản đồ giám sát', + 'map.ship.current': 'Vị trí hiện tại', + 'map.ship.tracking': 'Theo dõi tàu', + 'map.ship.speed': 'Tốc độ', + 'map.ship.latitude': 'Vĩ độ', + 'map.ship.longitude': 'Kinh độ', + 'map.ship.course': 'Hướng', + 'map.ship.state': 'Trạng thái', + 'map.ship.state.tooltip': 'Thuyền có đang đánh bắt hay không', + 'map.ship.lastUpdate': 'Cập nhật cuối cùng', + 'map.ship.name': 'Tên', + 'map.ship.imo': 'IMO', + 'map.ship.mmsi': 'MMSI', + 'map.ship.regNumber': 'Số đăng ký', + 'map.ship.length': 'Chiều dài', + 'map.ship.power': 'Công suất', + 'map.ship.info': 'Thông tin tàu', + 'map.ship.status.online': 'Trực tuyến', + 'map.ship.status.offline': 'Ngoại tuyến', + 'map.ship.status.fishing': 'Đang đánh cá', + 'map.ship.state.notFishing': 'Không đánh bắt', + 'map.ship.status.sailing': 'Đang di chuyển', + 'map.ship.status.anchor': 'Đang thả neo', + 'map.alarm.title': 'Cảnh báo', + 'map.alarm.new': 'Cảnh báo mới', + 'map.alarm.geofence': 'Vượt vùng cấm', + 'map.alarm.sos': 'Khẩn cấp SOS', + 'map.alarm.power': 'Mất nguồn', + 'map.alarm.gps': 'Mất tín hiệu GPS', + 'map.zone.fishing': 'Vùng đánh cá', + 'map.zone.restricted': 'Vùng cấm', + 'map.zone.warning': 'Vùng cảnh báo', + 'map.layer.ships': 'Tàu thuyền', + 'map.layer.alarms': 'Cảnh báo', + 'map.layer.zones': 'Vùng', + 'map.layer.tracks': 'Lộ trình', + + // Trip Management + 'trip.title': 'Quản lý chuyến đi', + 'trip.create': 'Tạo chuyến đi mới', + 'trip.edit': 'Chỉnh sửa chuyến đi', + 'trip.delete': 'Xóa chuyến đi', + 'trip.list': 'Danh sách chuyến đi', + 'trip.detail': 'Chi tiết chuyến đi', + 'trip.startTime': 'Thời gian bắt đầu', + 'trip.endTime': 'Thời gian kết thúc', + 'trip.duration': 'Thời lượng', + 'trip.distance': 'Quãng đường', + 'trip.crew': 'Thuyền viên', + 'trip.catch': 'Sản lượng đánh bắt', + 'trip.costs': 'Chi phí', + 'trip.notes': 'Ghi chú', + 'trip.status.planning': 'Lên kế hoạch', + 'trip.status.active': 'Đang hoạt động', + 'trip.status.completed': 'Hoàn thành', + 'trip.status.cancelled': 'Đã hủy', + + // Fishing Log + 'fishing.title': 'Nhật ký đánh cá', + 'fishing.create': 'Tạo nhật ký mới', + 'fishing.edit': 'Chỉnh sửa nhật ký', + 'fishing.delete': 'Xóa nhật ký', + 'fishing.haul': 'Lượt kéo', + 'fishing.species': 'Loài cá', + 'fishing.quantity': 'Số lượng', + 'fishing.weight': 'Trọng lượng', + 'fishing.method': 'Phương pháp', + 'fishing.location': 'Vị trí', + 'fishing.time': 'Thời gian', + 'fishing.depth': 'Độ sâu', + 'fishing.temperature': 'Nhiệt độ', + 'fishing.weather': 'Thời tiết', + + // Alarms + 'alarm.title': 'Quản lý cảnh báo', + 'alarm.active': 'Cảnh báo hoạt động', + 'alarm.history': 'Lịch sử cảnh báo', + 'alarm.type.geofence': 'Vượt vùng', + 'alarm.type.sos': 'Khẩn cấp SOS', + 'alarm.type.power': 'Nguồn điện', + 'alarm.type.gps': 'GPS', + 'alarm.type.speed': 'Tốc độ', + 'alarm.type.stopped': 'Dừng lại', + 'alarm.level.low': 'Thấp', + 'alarm.level.medium': 'Trung bình', + 'alarm.level.high': 'Cao', + 'alarm.level.critical': 'Khẩn cấp', + 'alarm.acknowledge': 'Xác nhận', + 'alarm.resolve': 'Giải quyết', + 'alarm.ignore': 'Bỏ qua', + 'alarm.created': 'Thời gian tạo', + 'alarm.resolved': 'Thời gian giải quyết', + + // Settings + 'settings.title': 'Cài đặt hệ thống', + 'settings.profile': 'Hồ sơ người dùng', + 'settings.language': 'Ngôn ngữ', + 'settings.theme': 'Giao diện', + 'settings.notifications': 'Thông báo', + 'settings.privacy': 'Riêng tư', + 'settings.security': 'Bảo mật', + 'settings.about': 'Về ứng dụng', + 'settings.version': 'Phiên bản', + 'settings.update': 'Cập nhật', + + // Messages + 'message.loading': 'Đang tải dữ liệu...', + 'message.noData': 'Không có dữ liệu', + 'message.error.network': 'Lỗi kết nối mạng', + 'message.error.server': 'Lỗi máy chủ', + 'message.error.generic': 'Đã xảy ra lỗi', + 'message.success.saved': 'Lưu thành công', + 'message.success.deleted': 'Xóa thành công', + 'message.success.updated': 'Cập nhật thành công', + 'message.confirm.delete': 'Bạn có chắc chắn muốn xóa mục này?', + 'message.confirm.save': 'Bạn có chắc chắn muốn lưu các thay đổi?', + 'message.confirm.logout': 'Bạn có chắc chắn muốn đăng xuất?', + + // Units + 'unit.knots': 'hải lý/giờ', + 'unit.km': 'km', + 'unit.kmh': 'km/h', + 'unit.meters': 'm', + 'unit.kilometers': 'km', + 'unit.nauticalMiles': 'hải lý', + 'unit.kilograms': 'kg', + 'unit.tons': 'tấn', + 'unit.degrees': '°', + 'unit.celsius': '°C', + 'unit.hours': 'giờ', + 'unit.minutes': 'phút', + 'unit.seconds': 'giây', + + // Time + 'time.now': 'Bây giờ', + 'time.today': 'Hôm nay', + 'time.yesterday': 'Hôm qua', + 'time.tomorrow': 'Ngày mai', + 'time.thisWeek': 'Tuần này', + 'time.thisMonth': 'Tháng này', + 'time.thisYear': 'Năm nay', + 'format.date': 'DD/MM/YYYY', + 'format.datetime': 'DD/MM/YYYY HH:mm', + 'format.time': 'HH:mm', + 'format.relative': '{time} trước', + + // Validation + 'validation.required': 'Trường này là bắt buộc', + 'validation.username': 'Tài khoản không được để trống', + 'validation.password': 'Mật khẩu không được để trống', + 'validation.minLength': 'Tối thiểu {min} ký tự', + 'validation.maxLength': 'Tối đa {max} ký tự', + 'validation.min': 'Giá trị tối thiểu là {min}', + 'validation.max': 'Giá trị tối đa là {max}', + 'validation.numeric': 'Phải là số', + 'validation.phone': 'Số điện thoại không hợp lệ', + 'validation.url': 'URL không hợp lệ', + + // Trip Status Badge + 'trip.badge.notApproved': 'Chưa được phê duyệt', + 'trip.badge.waitingApproval': 'Đang chờ duyệt', + 'trip.badge.approved': 'Đã duyệt', + 'trip.badge.active': 'Đang hoạt động', + 'trip.badge.completed': 'Đã hoàn thành', + 'trip.badge.cancelled': 'Đã huỷ', + 'trip.badge.unknown': 'Trạng thái không xác định', + + // Trip Actions + 'trip.startTrip': 'Bắt đầu chuyến đi', + 'trip.finishHaul': 'Kết thúc mẻ lưới', + 'trip.startHaul': 'Bắt đầu mẻ lưới', + 'trip.warning.haulNotFinished': + 'Vui lòng kết thúc mẻ lưới hiện tại trước khi bắt đầu mẻ mới', + 'trip.warning.tripStarted': 'Chuyến đi đã được bắt đầu hoặc hoàn thành.', + 'trip.cancelTrip.title': 'Xác nhận huỷ chuyến đi', + 'trip.cancelTrip.button': 'Huỷ chuyến đi', + 'trip.cancelTrip.reason': 'Lý do: ', + 'trip.cancelTrip.placeholder': 'Nhập lý do huỷ chuyến đi...', + 'trip.cancelTrip.validation': 'Vui lòng nhập lý do huỷ chuyến đi', + 'trip.weather': 'Nắng đẹp', + + // Haul Fish List + 'trip.haulFishList.title': 'Danh sách cá', + 'trip.haulFishList.fishName': 'Tên cá', + 'trip.haulFishList.fishCondition': 'Trạng thái', + 'trip.haulFishList.fishRarity': 'Độ hiếm', + 'trip.haulFishList.fishSize': 'Kích thước (cm)', + 'trip.haulFishList.weight': 'Cân nặng (kg)', + 'trip.haulFishList.gearUsage': 'Ngư cụ sử dụng', + 'trip.haulFishList.noData': 'Không có dữ liệu cá trong mẻ lưới này', + + // Haul Table + 'trip.haulTable.no': 'STT', + 'trip.haulTable.status': 'Trạng Thái', + 'trip.haulTable.fishing': 'Đang đánh bắt', + 'trip.haulTable.endFishing': 'Đã hoàn thành', + 'trip.haulTable.cancelFishing': 'Đã huỷ', + 'trip.haulTable.weather': 'Thời tiết', + 'trip.haulTable.startTime': 'Thời điểm bắt đầu', + 'trip.haulTable.endTime': 'Thời điểm kết thúc', + 'trip.haulTable.action': 'Thao tác', + 'trip.haulTable.updateSuccess': 'Cập nhật mẻ lưới thành công', + 'trip.haulTable.updateError': 'Cập nhật mẻ lưới thất bại', + 'trip.haulTable.haul': 'Mẻ', + + // Main Trip Body + 'trip.mainBody.tripCost': 'Chi phí chuyến đi', + 'trip.mainBody.fishingGear': 'Danh sách ngư cụ', + 'trip.mainBody.crew': 'Danh sách thuyền viên', + 'trip.mainBody.haulList': 'Danh sách mẻ lưới', + + // Trip Cost + 'trip.cost.type': 'Loại', + 'trip.cost.amount': 'Số lượng', + 'trip.cost.unit': 'Đơn vị', + 'trip.cost.price': 'Chi phí', + 'trip.cost.total': 'Tổng chi phí', + 'trip.cost.fuel': 'Nhiên liệu', + 'trip.cost.crewSalary': 'Lương thuyền viên', + 'trip.cost.food': 'Lương thực', + 'trip.cost.iceSalt': 'Muối đá', + 'trip.cost.grandTotal': 'Tổng cộng', + + // Trip Crews + 'trip.crew.id': 'Mã định danh', + 'trip.crew.name': 'Tên', + 'trip.crew.role': 'Chức vụ', + 'trip.crew.email': 'Email', + 'trip.crew.phone': 'Số điện thoại', + 'trip.crew.birthDate': 'Ngày sinh', + 'trip.crew.address': 'Địa chỉ', + 'trip.crew.captain': 'Thuyền trưởng', + 'trip.crew.member': 'Thuyền viên', + + // Trip Fishing Gear + 'trip.gear.name': 'Tên', + 'trip.gear.quantity': 'Số lượng', + + // Trip Cancel Or Finish + 'trip.finishButton.confirmTitle': 'Thông báo', + 'trip.finishButton.confirmMessage': 'Bạn chắc chắn muốn kết thúc chuyến đi?', + 'trip.finishButton.confirmYes': 'Chắc chắn', + 'trip.finishButton.confirmNo': 'Không', + 'trip.finishButton.end': 'Kết thúc', + 'trip.finishButton.updateSuccess': 'Cập nhật trạng thái thành công', + 'trip.finishButton.updateError': 'Cập nhật trạng thái thất bại', + + // Trip Detail Page + 'trip.detail.title': 'Chuyến đi', + 'trip.detail.defaultTitle': 'Chuyến đi', + 'trip.detail.alarm': 'Cảnh báo', + 'trip.detail.createHaulSuccess': 'Tạo mẻ lưới thành công', + 'trip.detail.createHaulError': 'Tạo mẻ lưới thất bại', + 'trip.detail.startTripSuccess': 'Bắt đầu chuyến đi thành công', + 'trip.detail.startTripError': 'Bắt đầu chuyến đi thất bại', + 'trip.detail.updateHaulSuccess': 'Cập nhật mẻ lưới thành công', + 'trip.detail.updateHaulError': 'Cập nhật mẻ lưới thất bại', + + // Pagination + 'pagination.total': '{start}-{end} trên {total}', + + // Home Page + 'home.currentStatus': 'Trạng thái hiện tại', + 'home.tripInfo': 'Thông tin chuyến đi', + 'home.shipInfo': 'Thông tin tàu', + 'home.errorDrawBanzones': 'Lỗi khi vẽ vùng cấm', + 'home.errorBackground': 'Lỗi khi gọi API ở background', + 'home.errorGpsUpdate': 'Lỗi khi cập nhật GPS', + 'home.mapError': 'Lỗi khi thêm vào map', + + // SOS + 'sos.title': 'Thông báo khẩn cấp', + 'sos.button': 'Khẩn cấp', + 'sos.status': 'Đang trong trạng thái khẩn cấp', + 'sos.end': 'Kết thúc', + 'sos.label': 'Nội dung:', + 'sos.placeholder': 'Chọn hoặc nhập lý do...', + 'sos.other': 'Khác', + 'sos.otherLabel': 'Lý do khác', + 'sos.otherPlaceholder': 'Nhập lý do khác...', + 'sos.defaultMessage': 'Tình huống khẩn cấp, không kịp chọn !!!', + 'sos.sendSuccess': 'Gửi tín hiệu SOS thành công!', + 'sos.sendError': 'Gửi tín hiệu SOS thất bại!', + 'sos.cancelSuccess': 'Huỷ tín hiệu SOS thành công!', + 'sos.cancelError': 'Huỷ tín hiệu SOS thất bại!', + 'sos.validationError': 'Vui lòng chọn hoặc nhập lý do!', + 'sos.otherValidationError': 'Vui lòng nhập lý do khác!', +}; diff --git a/src/models/getSos.ts b/src/models/getSos.ts index bf228e2..c7a6845 100644 --- a/src/models/getSos.ts +++ b/src/models/getSos.ts @@ -12,12 +12,12 @@ export default function useGetGpsModel() { try { // Bypass cache để GPS luôn realtime, gọi trực tiếp API const data = await getGPS(); - console.log('GPS fetched from API:', data); + // console.log('GPS fetched from API:', data); setGpsData(data); // Luôn emit event GPS để đảm bảo realtime try { - eventBus.emit('gpsData:update', data); + // eventBus.emit('gpsData:update', data); } catch (e) { console.warn('Failed to emit gpsData:update event', e); } @@ -31,7 +31,7 @@ export default function useGetGpsModel() { // ✅ Lắng nghe event cập nhật cache từ nơi khác (nếu có) useEffect(() => { const handleUpdate = (data: API.GPSResonse) => { - console.log('GPS cache updated via eventBus'); + // console.log('GPS cache updated via eventBus'); setGpsData(data); }; eventBus.on('gpsData:update', handleUpdate); diff --git a/src/pages/Auth/index.tsx b/src/pages/Auth/index.tsx index cf9863b..6ebcd38 100644 --- a/src/pages/Auth/index.tsx +++ b/src/pages/Auth/index.tsx @@ -5,27 +5,25 @@ 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 { Image, theme } from 'antd'; +import { history, useIntl } from '@umijs/max'; +import { Image, Space, theme } from 'antd'; import { useEffect } from 'react'; import logoImg from '../../../public/logo.png'; import mobifontImg from '../../../public/owner.png'; +import LangSwitches from '@/components/switch/LangSwitches'; 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={ - Hệ thống giám sát tàu cá + {intl.formatMessage({ + id: 'auth.login.subtitle', + defaultMessage: 'Sea Gateway', + })} } containerStyle={{ @@ -78,10 +83,18 @@ const LoginPage = () => { subTitle={} submitter={{ searchConfig: { - submitText: 'Đăng nhập', + submitText: intl.formatMessage({ + id: 'common.login', + defaultMessage: 'Đăng nhập', + }), }, }} onFinish={async (values: API.LoginRequestBody) => handleLogin(values)} + actions={ + + + + } > <> { /> ), }} - 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 +137,24 @@ 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!', + }), }, ]} /> + +
, diff --git a/src/pages/Home/components/GpsInfo.tsx b/src/pages/Home/components/GpsInfo.tsx index 2ef2ab6..16ea2ed 100644 --- a/src/pages/Home/components/GpsInfo.tsx +++ b/src/pages/Home/components/GpsInfo.tsx @@ -1,7 +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 ( dataSource={gpsData || undefined} @@ -16,38 +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 ? `${convertToDMS(record.lat, true)}°` : '--', + record?.lat !== null ? `${convertToDMS(record.lat, true)}°` : '--', }, { - title: 'Vĩ độ', + title: intl.formatMessage({ id: 'map.ship.latitude' }), dataIndex: 'lon', render: (_, record) => - record?.lon != null ? `${convertToDMS(record.lon, false)}°` : '--', + record?.lon !== null ? `${convertToDMS(record.lon, false)}°` : '--', }, { - title: 'Tốc độ', + title: intl.formatMessage({ id: 'map.ship.speed' }), dataIndex: 's', valueType: 'digit', render: (_, record) => - record?.s != null ? `${record.s} km/h` : '-- km/h', + record?.s !== null + ? `${record.s} ${intl.formatMessage({ id: 'unit.kmh' })}` + : `-- ${intl.formatMessage({ id: 'unit.kmh' })}`, span: 1, }, { - 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' })}`, }, ]} /> diff --git a/src/pages/Home/components/ShipInfo.tsx b/src/pages/Home/components/ShipInfo.tsx index caff1ab..9f4d2ed 100644 --- a/src/pages/Home/components/ShipInfo.tsx +++ b/src/pages/Home/components/ShipInfo.tsx @@ -1,12 +1,38 @@ 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 = ({ 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(); @@ -20,10 +46,12 @@ const ShipInfo: React.FC = ({ isOpen, setIsOpen }) => { return ( setIsOpen(false)} onOk={() => setIsOpen(false)} + width={modalWidth} > column={{ @@ -35,8 +63,6 @@ const ShipInfo: React.FC = ({ 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 = ({ 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` : '--', diff --git a/src/pages/Home/components/SosButton.tsx b/src/pages/Home/components/SosButton.tsx index 9422715..4b2456d 100644 --- a/src/pages/Home/components/SosButton.tsx +++ b/src/pages/Home/components/SosButton.tsx @@ -1,3 +1,4 @@ +import useTranslation from '@/hooks/useTranslation'; import { deleteSos, getSos, @@ -20,23 +21,22 @@ interface SosButtonProps { } const SosButton: React.FC = ({ onRefresh }) => { + const { t } = useTranslation(); const [form] = Form.useForm<{ message: string; messageOther?: string }>(); const [sosData, setSosData] = useState(); 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 = ({ 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'); } }; - return sosData && sosData.active == true ? ( + return sosData && sosData.active === true ? (
- Đang trong trạng thái khẩn cấp + {t('sos.status')}
@@ -86,13 +86,13 @@ const SosButton: React.FC = ({ onRefresh }) => { className="self-end sm:self-auto" onClick={async () => await handleSos()} > - Kết thúc + {t('sos.end')}
) : ( = ({ onRefresh }) => { danger shape="round" > - Khẩn cấp + {t('sos.button')} } onFinish={async (values) => { @@ -129,16 +129,16 @@ const SosButton: React.FC = ({ onRefresh }) => { > ({ 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')} /> @@ -146,9 +146,11 @@ const SosButton: React.FC = ({ onRefresh }) => { message === 'other' ? ( ) : null } diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index f2ac07a..79aec35 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -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,17 +23,16 @@ import { InfoCircleOutlined, InfoOutlined, } from '@ant-design/icons'; -import { history, useModel } from '@umijs/max'; -import { eventBus } from '@/utils/eventBus'; +import { history, useIntl, useModel } from '@umijs/max'; import { FloatButton, Popover } from 'antd'; +import LineString from 'ol/geom/LineString'; +import Point from 'ol/geom/Point'; +import { fromLonLat } from 'ol/proj'; import { useCallback, useEffect, useRef, useState } from 'react'; import GpsInfo from './components/GpsInfo'; import ShipInfo from './components/ShipInfo'; import SosButton from './components/SosButton'; import VietNamMap, { MapManager } from './components/VietNamMap'; -import Point from 'ol/geom/Point'; -import LineString from 'ol/geom/LineString'; -import { fromLonLat } from 'ol/proj'; // // Define missing types locally for now // interface AlarmData { @@ -61,10 +61,6 @@ const HomePage: React.FC = () => { const [isShipInfoOpen, setIsShipInfoOpen] = useState(false); const { gpsData, getGPSData } = useModel('getSos'); const hasCenteredRef = useRef(false); - const banzonesCacheRef = useRef([]); - function isSameBanzones(newData: any[], oldData: any[]): boolean { - return JSON.stringify(newData) === JSON.stringify(oldData); - } const onFeatureClick = useCallback((feature: any) => { console.log('OnClick Feature: ', feature); console.log( @@ -74,19 +70,23 @@ const HomePage: React.FC = () => { 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, { @@ -109,8 +109,14 @@ const HomePage: React.FC = () => { }; // Helper function to add features to the map (kept above pollers to avoid use-before-define) - function addFeatureToMap(mapManager: MapManager, gpsData: GpsData, alarm: API.AlarmResponse,) { - const isSos = alarm?.alarms.find((a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING); + function addFeatureToMap( + mapManager: MapManager, + gpsData: GpsData, + alarm: API.AlarmResponse, + ) { + const isSos = alarm?.alarms.find( + (a) => a.id === ENTITY_TYPE_ENUM.SOS_WARNING, + ); if (mapManager?.featureLayer && gpsData) { mapManager.featureLayer.getSource()?.clear(); @@ -124,142 +130,11 @@ const HomePage: React.FC = () => { }, ); } - } - - useEffect(() => { - // Chỉ poll GPS, các API còn lại sẽ được gọi khi có GPS mới - getGPSData(); - const gpsInterval = setInterval(() => { - getGPSData(); - }, 5000); // cập nhật mỗi 5s - - // Vẽ marker tàu và polyline ngay khi vào trang nếu đã có dữ liệu - if (gpsData) { - eventBus.emit('ship:update', gpsData); - } - // Nếu có trackpoints, emit luôn (nếu bạn lưu trackpoints ở state, có thể emit ở đây) - // eventBus.emit('trackpoints:update', trackpoints); - - return () => { - clearInterval(gpsInterval); - console.log('MapManager destroyed in HomePage cleanup'); - }; - }, []); - - // Lắng nghe eventBus cho tất cả các loại dữ liệu - useEffect(() => { - // GPS cập nhật => emit ship update ngay lập tức, gọi API khác không đồng bộ - const onGpsUpdate = async (data: API.GPSResonse) => { - try { - if (data) { - // Emit ship update ngay lập tức để marker tàu cập nhật realtime - eventBus.emit('ship:update', data); - - // Gọi các API khác trong background, không block ship update - setTimeout(async () => { - try { - const alarm = await queryAlarms(); - eventBus.emit('alarm:update', alarm); - const trackpoints = await queryShipTrackPoints(); - eventBus.emit('trackpoints:update', trackpoints); - const entities = await queryEntities(); - eventBus.emit('entities:update', entities); - } catch (e) { - console.error('Background API calls error', e); - } - }, 0); - } - } catch (e) { - console.error('onGpsUpdate handler error', e); - } - }; - - - // Cập nhật marker tàu khi có GPS mới - const onShipUpdate = (data: API.GPSResonse) => { - const mapManager = mapManagerRef.current; - if (!mapManager || !data) return; - - // Cache feature trong mapManager - if (!mapManager.shipFeature) { - mapManager.shipFeature = mapManager.addPoint( - [data.lon, data.lat], - { bearing: data.h || 0 }, - { - icon: getShipIcon(0, data?.fishing || false), - scale: 0.1, - animate: false, - }, - ); - } else { - const geom = mapManager.shipFeature.getGeometry(); - if (geom instanceof Point) { - geom.setCoordinates([data.lon, data.lat]); - } - } - - // Set map center to current GPS position - if (!hasCenteredRef.current && mapManager.map && mapManager.map.getView) { - mapManager.map.getView().setCenter(fromLonLat([data.lon, data.lat])); - hasCenteredRef.current = true; - } - - }; - - - // Cập nhật alarm khi có event - const onAlarmUpdate = (alarm: API.AlarmResponse) => { - if (gpsData) addFeatureToMap(mapManagerRef.current, gpsData, alarm); - }; - - // Cập nhật trackpoints khi có event - const onTrackpointsUpdate = (trackpoints: API.ShipTrackPoint[]) => { - const source = mapManagerRef.current?.featureLayer?.getSource(); - if (source && trackpoints.length > 0) { - const features = source.getFeatures(); - // Tìm polyline theo id - const polyline = features.find(f => f.get('id') === MAP_TRACKPOINTS_ID); - const coordinates = trackpoints.map((point) => [point.lon, point.lat]); - if (polyline && polyline.getGeometry() instanceof LineString) { - polyline.getGeometry().setCoordinates(coordinates); - } else { - mapManagerRef.current.addLineString( - coordinates, - { id: MAP_TRACKPOINTS_ID }, - { strokeColor: 'blue', strokeWidth: 3 }, - ); - } - } - }; - - // Cập nhật entities/banzones khi có event - const onEntitiesUpdate = async (entities: API.TransformedEntity[]) => { - const mapManager = mapManagerRef.current; - if (mapManager) { - await drawBanzones(mapManager); - } - }; - - eventBus.on('gpsData:update', onGpsUpdate); - eventBus.on('ship:update', onShipUpdate); - eventBus.on('alarm:update', onAlarmUpdate); - eventBus.on('trackpoints:update', onTrackpointsUpdate); - eventBus.on('entities:update', onEntitiesUpdate); - - return () => { - eventBus.off('gpsData:update', onGpsUpdate); - eventBus.off('ship:update', onShipUpdate); - eventBus.off('alarm:update', onAlarmUpdate); - eventBus.off('trackpoints:update', onTrackpointsUpdate); - eventBus.off('entities:update', onEntitiesUpdate); - }; - }, [gpsData]); - - // Vẽ lại banzones (polygon, polyline vùng cấm, vùng cá thử...) - async function drawBanzones(mapManager: MapManager) { + } + const drawBanzones = async (mapManager: MapManager) => { try { const layer = mapManager.featureLayer?.getSource(); - layer?.getFeatures()?.forEach(f => { + layer?.getFeatures()?.forEach((f) => { if ([MAP_POLYGON_BAN, MAP_POLYLINE_BAN].includes(f.get('id'))) { layer.removeFeature(f); } @@ -275,7 +150,9 @@ const HomePage: React.FC = () => { if (geom) { const { geom_type, geom_lines, geom_poly } = geom.geom || {}; if (geom_type === 2) { - const coordinates = convertWKTLineStringToLatLngArray(geom_lines || ''); + const coordinates = convertWKTLineStringToLatLngArray( + geom_lines || '', + ); if (coordinates.length > 0) { mapManager.addLineString( coordinates, @@ -310,9 +187,155 @@ const HomePage: React.FC = () => { } } } catch (error) { - console.error('Error drawing banzones:', error); + console.error( + intl.formatMessage({ id: 'home.errorDrawBanzones' }), + error, + ); } - } + }; + + useEffect(() => { + // Chỉ poll GPS, các API còn lại sẽ được gọi khi có GPS mới + getGPSData(); + const gpsInterval = setInterval(() => { + getGPSData(); + }, 5000); // cập nhật mỗi 5s + + // Vẽ marker tàu và polyline ngay khi vào trang nếu đã có dữ liệu + if (gpsData) { + eventBus.emit('ship:update', gpsData); + } + // Nếu có trackpoints, emit luôn (nếu bạn lưu trackpoints ở state, có thể emit ở đây) + // eventBus.emit('trackpoints:update', trackpoints); + + return () => { + clearInterval(gpsInterval); + console.log('MapManager destroyed in HomePage cleanup'); + }; + }, []); + + // Lắng nghe eventBus cho tất cả các loại dữ liệu + useEffect(() => { + // GPS cập nhật => emit ship update ngay lập tức, gọi API khác không đồng bộ + const onGpsUpdate = async (data: API.GPSResonse) => { + try { + if (data) { + // Emit ship update ngay lập tức để marker tàu cập nhật realtime + eventBus.emit('ship:update', data); + + // Gọi các API khác trong background, không block ship update + setTimeout(async () => { + try { + const alarm = await queryAlarms(); + eventBus.emit('alarm:update', alarm); + const trackpoints = await queryShipTrackPoints(); + eventBus.emit('trackpoints:update', trackpoints); + const entities = await queryEntities(); + eventBus.emit('entities:update', entities); + } catch (e) { + console.error( + intl.formatMessage({ id: 'home.errorBackground' }), + e, + ); + } + }, 0); + } + } catch (e) { + console.error(intl.formatMessage({ id: 'home.errorGpsUpdate' }), e); + } + }; + + // Cập nhật marker tàu khi có GPS mới + const onShipUpdate = (data: API.GPSResonse) => { + const mapManager = mapManagerRef.current; + if (!mapManager || !data) return; + + // Cache feature trong mapManager + if (!mapManager.shipFeature) { + mapManager.shipFeature = mapManager.addPoint( + [data.lon, data.lat], + { bearing: data.h || 0 }, + { + icon: getShipIcon(0, data?.fishing || false), + scale: 0.1, + animate: false, + }, + ); + } else { + const geom = mapManager.shipFeature.getGeometry(); + if (geom instanceof Point) { + geom.setCoordinates([data.lon, data.lat]); + } + } + + // Set map center to current GPS position + if (!hasCenteredRef.current && mapManager.map && mapManager.map.getView) { + mapManager.map.getView().setCenter(fromLonLat([data.lon, data.lat])); + hasCenteredRef.current = true; + } + }; + + // Cập nhật alarm khi có event + const onAlarmUpdate = (alarm: API.AlarmResponse) => { + if (gpsData) addFeatureToMap(mapManagerRef.current, gpsData, alarm); + }; + + // Cập nhật trackpoints khi có event + const onTrackpointsUpdate = (trackpoints: API.ShipTrackPoint[]) => { + const source = mapManagerRef.current?.featureLayer?.getSource(); + if (source && trackpoints.length > 0) { + const features = source.getFeatures(); + // Tìm polyline theo id + const polyline = features.find( + (f) => f.get('id') === MAP_TRACKPOINTS_ID, + ); + const coordinates = trackpoints.map((point) => [point.lon, point.lat]); + + if (polyline) { + const geom = polyline.getGeometry(); + if (geom instanceof LineString) { + geom.setCoordinates(coordinates); + } else { + mapManagerRef.current?.addLineString( + coordinates, + { id: MAP_TRACKPOINTS_ID }, + { strokeColor: 'blue', strokeWidth: 3 }, + ); + } + } else { + mapManagerRef.current?.addLineString( + coordinates, + { id: MAP_TRACKPOINTS_ID }, + { strokeColor: 'blue', strokeWidth: 3 }, + ); + } + } + }; + + // Cập nhật entities/banzones khi có event + const onEntitiesUpdate = async () => { + const mapManager = mapManagerRef.current; + if (mapManager) { + await drawBanzones(mapManager); + } + }; + + eventBus.on('gpsData:update', onGpsUpdate); + eventBus.on('ship:update', onShipUpdate); + eventBus.on('alarm:update', onAlarmUpdate); + eventBus.on('trackpoints:update', onTrackpointsUpdate); + eventBus.on('entities:update', onEntitiesUpdate); + + return () => { + eventBus.off('gpsData:update', onGpsUpdate); + eventBus.off('ship:update', onShipUpdate); + eventBus.off('alarm:update', onAlarmUpdate); + eventBus.off('trackpoints:update', onTrackpointsUpdate); + eventBus.off('entities:update', onEntitiesUpdate); + }; + }, [gpsData]); + + // Vẽ lại banzones (polygon, polyline vùng cấm, vùng cá thử...) useEffect(() => { // Vẽ lại banzones khi vào trang @@ -336,14 +359,11 @@ const HomePage: React.FC = () => { /> - // - } + title={intl.formatMessage({ id: 'home.currentStatus' })} + content={} open={isShowGPSData} > { } onClick={() => history.push(ROUTE_TRIP)} - tooltip="Thông tin chuyến đi" + tooltip={intl.formatMessage({ id: 'home.tripInfo' })} /> } - tooltip="Thông tin tàu" + tooltip={intl.formatMessage({ id: 'home.shipInfo' })} onClick={() => setIsShipInfoOpen(true)} /> @@ -378,4 +398,4 @@ const HomePage: React.FC = () => { ); }; -export default HomePage; \ No newline at end of file +export default HomePage; diff --git a/src/pages/Trip/components/BadgeTripStatus.tsx b/src/pages/Trip/components/BadgeTripStatus.tsx index 41eba26..da06ab2 100644 --- a/src/pages/Trip/components/BadgeTripStatus.tsx +++ b/src/pages/Trip/components/BadgeTripStatus.tsx @@ -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 = ({ status }) => { + const intl = useIntl(); // Khai báo kiểu cho map const statusBadgeMap: Record = { 0: { status: 'default' }, // Đã khởi tạo @@ -20,19 +22,19 @@ const BadgeTripStatus: React.FC = ({ 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' }); } }; diff --git a/src/pages/Trip/components/CancelTrip.tsx b/src/pages/Trip/components/CancelTrip.tsx index 6de6bd2..14129a3 100644 --- a/src/pages/Trip/components/CancelTrip.tsx +++ b/src/pages/Trip/components/CancelTrip.tsx @@ -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 = ({ onFinished }) => { + const intl = useIntl(); const [form] = Form.useForm<{ note: string }>(); return ( = ({ onFinished }) => { }} trigger={ } onFinish={async (values) => { @@ -25,10 +27,13 @@ const CancelTrip: React.FC = ({ onFinished }) => { > diff --git a/src/pages/Trip/components/CreateNewHaulOrTrip.tsx b/src/pages/Trip/components/CreateNewHaulOrTrip.tsx index 424c1ed..10a14d3 100644 --- a/src/pages/Trip/components/CreateNewHaulOrTrip.tsx +++ b/src/pages/Trip/components/CreateNewHaulOrTrip.tsx @@ -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 = ({ 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,7 +31,7 @@ const CreateNewHaulOrTrip: React.FC = ({ 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; } @@ -45,10 +45,10 @@ const CreateNewHaulOrTrip: React.FC = ({ 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 = ({ 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 = ({ } return ( -
+
{trips?.trip_status === 2 ? ( ) : checkHaulFinished() ? ( ) : ( )} diff --git a/src/pages/Trip/components/CreateOrUpdateFishingLog.tsx b/src/pages/Trip/components/CreateOrUpdateFishingLog.tsx index e132d13..0e288fb 100644 --- a/src/pages/Trip/components/CreateOrUpdateFishingLog.tsx +++ b/src/pages/Trip/components/CreateOrUpdateFishingLog.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ import { getGPS } from '@/services/controller/DeviceController'; import { getFishSpecies, @@ -35,6 +36,15 @@ const CreateOrUpdateFishingLog: React.FC = ({ >([]); const [editableKeys, setEditableRowKeys] = useState([]); const [fishDatas, setFishDatas] = useState([]); + 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) { @@ -57,15 +67,6 @@ const CreateOrUpdateFishingLog: React.FC = ({ } }, [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[] = [ { title: 'Tên cá', @@ -163,7 +164,7 @@ const CreateOrUpdateFishingLog: React.FC = ({ 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); diff --git a/src/pages/Trip/components/HaulFishList.tsx b/src/pages/Trip/components/HaulFishList.tsx index d537767..0b1e244 100644 --- a/src/pages/Trip/components/HaulFishList.tsx +++ b/src/pages/Trip/components/HaulFishList.tsx @@ -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 = ({ onOpenChange, fishList, }) => { - const [form] = Form.useForm(); + const intl = useIntl(); const fish_columns: ProColumns[] = [ { - 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 ( void; } -const HaulTable: React.FC = ({ hauls, trip, onReload }) => { +const HaulTable: React.FC = ({ hauls, trip }) => { const [editOpen, setEditOpen] = useState(false); const [editFishingLogOpen, setEditFishingLogOpen] = useState(false); const [currentRow, setCurrentRow] = useState([]); @@ -19,53 +19,67 @@ const HaulTable: React.FC = ({ hauls, trip, onReload }) => { useState(null); const intl = useIntl(); const { getApi } = useModel('getTrip'); - console.log('HaulTable received hauls:', hauls); const fishing_logs_columns: ProColumns[] = [ { - title:
STT
, + title: ( +
+ {intl.formatMessage({ id: 'trip.haulTable.no' })} +
+ ), 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:
Trạng Thái
, - dataIndex: ['status'], // 👈 lấy từ status 1: đang + title: ( +
+ {intl.formatMessage({ id: 'trip.haulTable.status' })} +
+ ), + 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:
Thời tiết
, - dataIndex: ['weather_description'], // 👈 lấy từ weather + title: ( +
+ {intl.formatMessage({ id: 'trip.haulTable.weather' })} +
+ ), + dataIndex: ['weather_description'], align: 'center', }, { - title:
Thời điểm bắt đầu
, - dataIndex: ['start_at'], // birth_date là date of birth + title: ( +
+ {intl.formatMessage({ id: 'trip.haulTable.startTime' })} +
+ ), + dataIndex: ['start_at'], align: 'center', render: (start_at: any) => { if (!start_at) return '-'; @@ -82,12 +96,15 @@ const HaulTable: React.FC = ({ hauls, trip, onReload }) => { }, }, { - title:
Thời điểm kết thúc
, - dataIndex: ['end_at'], // birth_date là date of birth + title: ( +
+ {intl.formatMessage({ id: 'trip.haulTable.endTime' })} +
+ ), + 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,7 +118,7 @@ const HaulTable: React.FC = ({ hauls, trip, onReload }) => { }, }, { - title: 'Thao tác', + title: intl.formatMessage({ id: 'trip.haulTable.action' }), align: 'center', hideInSearch: true, render: (_, record) => { @@ -118,7 +135,9 @@ const HaulTable: React.FC = ({ 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 = ({ 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' }), + ); } }} /> diff --git a/src/pages/Trip/components/MainTripBody.tsx b/src/pages/Trip/components/MainTripBody.tsx index 538b17f..f013e93 100644 --- a/src/pages/Trip/components/MainTripBody.tsx +++ b/src/pages/Trip/components/MainTripBody.tsx @@ -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 = ({ - trip_id, - tripInfo, - onReload, -}) => { - // console.log('MainTripBody received:'); - // console.log("trip_id:", trip_id); - // console.log('tripInfo:', tripInfo); +const MainTripBody: React.FC = ({ 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:
Tên
, - dataIndex: 'name', - valueType: 'select', - align: 'center', - }, - { - title:
Số lượng
, - dataIndex: 'number', - align: 'center', - }, - ]; - - const tranship_columns = [ - { - title:
Tên
, - dataIndex: 'name', - valueType: 'select', - align: 'center', - }, - { - title:
Chức vụ
, - dataIndex: 'role', - align: 'center', - }, - ]; - + const intl = useIntl(); return ( = ({ 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 }} > @@ -111,7 +75,7 @@ const MainTripBody: React.FC = ({ alignItems: 'center', borderBottom: '1px solid #f0f0f0', }} - title="Danh sách ngư cụ" + title={intl.formatMessage({ id: 'trip.mainBody.fishingGear' })} style={{ minHeight: 300 }} > @@ -129,7 +93,7 @@ const MainTripBody: React.FC = ({ justifyContent: 'center', alignItems: 'center', }} - title="Danh sách thuyền viên" + title={intl.formatMessage({ id: 'trip.mainBody.crew' })} > @@ -145,14 +109,13 @@ const MainTripBody: React.FC = ({ justifyContent: 'center', alignItems: 'center', }} - title="Danh sách mẻ lưới" + title={intl.formatMessage({ id: 'trip.mainBody.haulList' })} > { if (isTrue) { - // onReload?.(true); getApi(); } }} diff --git a/src/pages/Trip/components/TripCancelOrFinishButton.tsx b/src/pages/Trip/components/TripCancelOrFinishButton.tsx index 8de30ec..5113ba1 100644 --- a/src/pages/Trip/components/TripCancelOrFinishButton.tsx +++ b/src/pages/Trip/components/TripCancelOrFinishButton.tsx @@ -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 = ({ 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 = ({ }} /> handleClickButton(4)} - okText="Chắc chắn" - cancelText="Không" + okText={intl.formatMessage({ + id: 'trip.finishButton.confirmYes', + })} + cancelText={intl.formatMessage({ + id: 'trip.finishButton.confirmNo', + })} >
diff --git a/src/pages/Trip/components/TripCost.tsx b/src/pages/Trip/components/TripCost.tsx index 98bb162..f8c3113 100644 --- a/src/pages/Trip/components/TripCost.tsx +++ b/src/pages/Trip/components/TripCost.tsx @@ -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 = ({ 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,58 @@ const TripCostTable: React.FC = ({ tripCosts }) => { ); const trip_cost_columns: ProColumns[] = [ { - title:
Loại
, + title: ( +
+ {intl.formatMessage({ id: 'trip.cost.type' })} +
+ ), 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:
Số lượng
, + title: ( +
+ {intl.formatMessage({ id: 'trip.cost.amount' })} +
+ ), dataIndex: 'amount', align: 'center', }, { - title:
Đơn vị
, + title: ( +
+ {intl.formatMessage({ id: 'trip.cost.unit' })} +
+ ), dataIndex: 'unit', align: 'center', }, { - title:
Chi phí
, + title: ( +
+ {intl.formatMessage({ id: 'trip.cost.price' })} +
+ ), dataIndex: 'cost_per_unit', align: 'center', render: (val: any) => (val ? Number(val).toLocaleString() : ''), }, { - title:
Tổng chi phí
, + title: ( +
+ {intl.formatMessage({ id: 'trip.cost.total' })} +
+ ), dataIndex: 'total_cost', align: 'center', render: (val: any) => @@ -51,8 +77,6 @@ const TripCostTable: React.FC = ({ tripCosts }) => { ), }, ]; - const { useBreakpoint } = Grid; - const screens = useBreakpoint(); return ( columns={trip_cost_columns} @@ -72,7 +96,7 @@ const TripCostTable: React.FC = ({ tripCosts }) => { - Tổng cộng + {intl.formatMessage({ id: 'trip.cost.grandTotal' })} diff --git a/src/pages/Trip/components/TripCrews.tsx b/src/pages/Trip/components/TripCrews.tsx index 5c68c2f..d7d44d7 100644 --- a/src/pages/Trip/components/TripCrews.tsx +++ b/src/pages/Trip/components/TripCrews.tsx @@ -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 = ({ crew }) => { - console.log('TripCrews received crew:', crew); + const intl = useIntl(); const crew_columns: ProColumns[] = [ { - title:
Mã định danh
, - dataIndex: ['Person', 'personal_id'], // 👈 lấy từ Person.personal_id + title: ( +
+ {intl.formatMessage({ id: 'trip.crew.id' })} +
+ ), + dataIndex: ['Person', 'personal_id'], align: 'center', }, { - title:
Tên
, - dataIndex: ['Person', 'name'], // 👈 lấy từ Person.name + title: ( +
+ {intl.formatMessage({ id: 'trip.crew.name' })} +
+ ), + dataIndex: ['Person', 'name'], align: 'center', }, { - title:
Chức vụ
, + title: ( +
+ {intl.formatMessage({ id: 'trip.crew.role' })} +
+ ), dataIndex: 'role', align: 'center', render: (val: any) => { switch (val) { case 'captain': - return Thuyền trưởng; + return ( + + {intl.formatMessage({ id: 'trip.crew.captain' })} + + ); case 'crew': - return Thuyền viên; + return ( + + {intl.formatMessage({ id: 'trip.crew.member' })} + + ); default: return {val}; } }, }, { - title:
Email
, - dataIndex: ['Person', 'email'], // 👈 lấy từ Person.email + title: ( +
+ {intl.formatMessage({ id: 'trip.crew.email' })} +
+ ), + dataIndex: ['Person', 'email'], align: 'center', }, { - title:
Số điện thoại
, - dataIndex: ['Person', 'phone'], // 👈 lấy từ Person.phone + title: ( +
+ {intl.formatMessage({ id: 'trip.crew.phone' })} +
+ ), + dataIndex: ['Person', 'phone'], align: 'center', }, { - title:
Ngày sinh
, - dataIndex: ['Person', 'birth_date'], // birth_date là date of birth + title: ( +
+ {intl.formatMessage({ id: 'trip.crew.birthDate' })} +
+ ), + 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:
Địa chỉ
, - dataIndex: ['Person', 'address'], // 👈 lấy từ Person.address + title: ( +
+ {intl.formatMessage({ id: 'trip.crew.address' })} +
+ ), + dataIndex: ['Person', 'address'], align: 'center', }, ]; @@ -71,7 +108,11 @@ const TripCrews: React.FC = ({ 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 diff --git a/src/pages/Trip/components/TripFishingGear.tsx b/src/pages/Trip/components/TripFishingGear.tsx index 377d2c3..ca26344 100644 --- a/src/pages/Trip/components/TripFishingGear.tsx +++ b/src/pages/Trip/components/TripFishingGear.tsx @@ -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 = ({ fishingGears, }) => { + const intl = useIntl(); const fishing_gears_columns: ProColumns[] = [ { - title:
Tên
, + title: ( +
+ {intl.formatMessage({ id: 'trip.gear.name' })} +
+ ), dataIndex: 'name', valueType: 'select', align: 'center', }, { - title:
Số lượng
, + title: ( +
+ {intl.formatMessage({ id: 'trip.gear.quantity' })} +
+ ), dataIndex: 'number', align: 'center', }, @@ -30,10 +40,14 @@ const TripFishingGearTable: React.FC = ({ 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 + // bordered size="middle" scroll={{ x: '100%' }} style={{ flex: 1 }} diff --git a/src/pages/Trip/index.tsx b/src/pages/Trip/index.tsx index ed416c8..45fc127 100644 --- a/src/pages/Trip/index.tsx +++ b/src/pages/Trip/index.tsx @@ -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); @@ -50,7 +50,9 @@ const DetailTrip = () => { header={{ title: (
- {data ? data.name : 'Chuyến đi'} + {data + ? data.name + : intl.formatMessage({ id: 'trip.detail.defaultTitle' })}
), tags: , @@ -60,29 +62,39 @@ const DetailTrip = () => { ghost extra={[ { 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 ? ( {data ? ( diff --git a/src/services/controller/typings.d.ts b/src/services/controller/typings.d.ts index e6bc058..69f8341 100644 --- a/src/services/controller/typings.d.ts +++ b/src/services/controller/typings.d.ts @@ -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 diff --git a/src/services/service/MapService.ts b/src/services/service/MapService.ts index a165a90..e48af41 100644 --- a/src/services/service/MapService.ts +++ b/src/services/service/MapService.ts @@ -41,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; diff --git a/src/typings.d.ts b/src/typings.d.ts new file mode 100644 index 0000000..934cae4 --- /dev/null +++ b/src/typings.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const src: string; + export default src; +} \ No newline at end of file diff --git a/typings.d.ts b/typings.d.ts index 74cffc3..33f846f 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -1 +1,6 @@ import '@umijs/max/typings'; + +declare module '*.png' { + const src: string; + export default src; +}