fix(mobile+backend): fix notification DNS, message page API bugs, add read-all

Android DNS fix (native_dio_adapter):
- Dart's getaddrinfo() fails on some Android 12+ devices when browser works
  fine — root cause is Dart native socket using different DNS path than
  Android Java stack; fix by using NativeAdapter (OkHttp) on Android
- Add native_dio_adapter ^1.5.1 to pubspec; enable in ApiClient for Android

Message page bugs (5 fixes):
- Fix offset->page: Flutter sent ?offset=0 but backend NotificationQueryDto
  uses page/limit (1-based); now sends ?page=1&limit=50
- Fix announcement tab API: Tab 2 now calls /api/v1/announcements (separate
  resource) instead of /api/v1/notifications?type=ANNOUNCEMENT
- Fix markAllAsRead routing: calls correct API based on active tab
- Fix markAsRead routing: announcement items use announcements API
- Fix detail navigation: passes NotificationItem as route argument

Backend notification-service:
- Add PUT /api/v1/notifications/read-all endpoint
- Add markAllReadByUserId() to repository interface + TypeORM implementation
- Add markAllRead() to NotificationService

i18n (4 languages): add time.justNow/minutesAgo/hoursAgo/daysAgo keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 11:15:03 -08:00
parent 918489e4e5
commit 9e368cbf37
12 changed files with 163 additions and 92 deletions

View File

@ -104,4 +104,8 @@ export class NotificationService {
async countUnread(userId: string): Promise<number> { async countUnread(userId: string): Promise<number> {
return this.notificationRepo.countByUserIdAndStatus(userId, NotificationStatus.SENT); return this.notificationRepo.countByUserIdAndStatus(userId, NotificationStatus.SENT);
} }
async markAllRead(userId: string): Promise<void> {
await this.notificationRepo.markAllReadByUserId(userId);
}
} }

View File

@ -8,6 +8,7 @@ export interface INotificationRepository {
create(data: Partial<Notification>): Promise<Notification>; create(data: Partial<Notification>): Promise<Notification>;
save(notification: Notification): Promise<Notification>; save(notification: Notification): Promise<Notification>;
updateStatus(id: string, status: NotificationStatus, extra?: Partial<Notification>): Promise<void>; updateStatus(id: string, status: NotificationStatus, extra?: Partial<Notification>): Promise<void>;
markAllReadByUserId(userId: string): Promise<void>;
getStatusCounts(): Promise<Array<{ status: string; count: string }>>; getStatusCounts(): Promise<Array<{ status: string; count: string }>>;
getChannelStatusCounts(): Promise<Array<{ channel: string; status: string; count: string }>>; getChannelStatusCounts(): Promise<Array<{ channel: string; status: string; count: string }>>;
countTodaySent(): Promise<number>; countTodaySent(): Promise<number>;

View File

@ -45,6 +45,13 @@ export class NotificationRepository implements INotificationRepository {
await this.repo.update(id, { status, ...extra }); await this.repo.update(id, { status, ...extra });
} }
async markAllReadByUserId(userId: string): Promise<void> {
await this.repo.update(
{ userId, status: NotificationStatus.SENT },
{ status: NotificationStatus.READ, readAt: new Date() },
);
}
async getStatusCounts(): Promise<Array<{ status: string; count: string }>> { async getStatusCounts(): Promise<Array<{ status: string; count: string }>> {
return this.repo return this.repo
.createQueryBuilder('n') .createQueryBuilder('n')

View File

@ -24,6 +24,13 @@ export class NotificationController {
return { code: 0, data: { count: await this.notificationService.countUnread(req.user.id) } }; return { code: 0, data: { count: await this.notificationService.countUnread(req.user.id) } };
} }
@Put('read-all')
@ApiOperation({ summary: 'Mark all notifications as read' })
async markAllAsRead(@Req() req: any) {
await this.notificationService.markAllRead(req.user.id);
return { code: 0, data: null };
}
@Put(':id/read') @Put(':id/read')
@ApiOperation({ summary: 'Mark notification as read' }) @ApiOperation({ summary: 'Mark notification as read' })
async markAsRead(@Param() params: MarkReadParamDto, @Req() req: any) { async markAsRead(@Param() params: MarkReadParamDto, @Req() req: any) {

View File

@ -835,4 +835,8 @@ const Map<String, String> en = {
'notification.empty': 'No notifications', 'notification.empty': 'No notifications',
'notification.loadFailed': 'Load failed', 'notification.loadFailed': 'Load failed',
'notification.retry': 'Retry', 'notification.retry': 'Retry',
'time.justNow': 'Just now',
'time.minutesAgo': '{n}m ago',
'time.hoursAgo': '{n}h ago',
'time.daysAgo': '{n}d ago',
}; };

View File

@ -836,4 +836,8 @@ const Map<String, String> ja = {
'notification.empty': '通知なし', 'notification.empty': '通知なし',
'notification.loadFailed': '読み込み失敗', 'notification.loadFailed': '読み込み失敗',
'notification.retry': 'リトライ', 'notification.retry': 'リトライ',
'time.justNow': 'たった今',
'time.minutesAgo': '{n}分前',
'time.hoursAgo': '{n}時間前',
'time.daysAgo': '{n}日前',
}; };

View File

@ -836,4 +836,10 @@ const Map<String, String> zhCN = {
'notification.empty': '暂无通知', 'notification.empty': '暂无通知',
'notification.loadFailed': '加载失败', 'notification.loadFailed': '加载失败',
'notification.retry': '重试', 'notification.retry': '重试',
// ============ Time formatting ============
'time.justNow': '刚刚',
'time.minutesAgo': '{n}分钟前',
'time.hoursAgo': '{n}小时前',
'time.daysAgo': '{n}天前',
}; };

View File

@ -836,4 +836,8 @@ const Map<String, String> zhTW = {
'notification.empty': '暫無通知', 'notification.empty': '暫無通知',
'notification.loadFailed': '載入失敗', 'notification.loadFailed': '載入失敗',
'notification.retry': '重試', 'notification.retry': '重試',
'time.justNow': '剛剛',
'time.minutesAgo': '{n}分鐘前',
'time.hoursAgo': '{n}小時前',
'time.daysAgo': '{n}天前',
}; };

View File

@ -21,8 +21,10 @@
// ============================================================ // ============================================================
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:native_dio_adapter/native_dio_adapter.dart';
import '../storage/session_storage.dart'; import '../storage/session_storage.dart';
/// Genex API /// Genex API
@ -56,6 +58,14 @@ class ApiClient {
'Accept': 'application/json', 'Accept': 'application/json',
}, },
)); ));
// Android 使 OkHttpJava Dart socket
// Dart getaddrinfo() Android 12+ DNS
// Android Java Dart native socket
if (Platform.isAndroid) {
_dio.httpClientAdapter = NativeAdapter();
}
_dio.interceptors.add(_buildAuthInterceptor()); _dio.interceptors.add(_buildAuthInterceptor());
} }

View File

@ -129,15 +129,17 @@ class NotificationService {
: _apiClient = apiClient ?? ApiClient.instance; : _apiClient = apiClient ?? ApiClient.instance;
/// ///
///
/// [page] 1 NotificationQueryDto 使 page/limit offset
Future<NotificationListResponse> getNotifications({ Future<NotificationListResponse> getNotifications({
NotificationType? type, NotificationType? type,
int page = 1,
int limit = 50, int limit = 50,
int offset = 0,
}) async { }) async {
try { try {
final queryParams = <String, dynamic>{ final queryParams = <String, dynamic>{
'page': page,
'limit': limit, 'limit': limit,
'offset': offset,
}; };
if (type != null) { if (type != null) {
queryParams['type'] = type.name.toUpperCase(); queryParams['type'] = type.name.toUpperCase();
@ -169,6 +171,17 @@ class NotificationService {
} }
} }
///
Future<bool> markAllNotificationsAsRead() async {
try {
await _apiClient.put('/api/v1/notifications/read-all');
return true;
} catch (e) {
debugPrint('[NotificationService] 全部通知标记已读失败: $e');
return false;
}
}
/// ///
Future<bool> markAsRead(String notificationId) async { Future<bool> markAsRead(String notificationId) async {
try { try {

View File

@ -6,10 +6,15 @@ import '../../../../shared/widgets/empty_state.dart';
import '../../../../core/services/notification_service.dart'; import '../../../../core/services/notification_service.dart';
import '../../../../core/providers/notification_badge_manager.dart'; import '../../../../core/providers/notification_badge_manager.dart';
/// A8. /// A8. +
/// ///
/// + Tab + /// Tab 0:
/// API /// Tab 1: type=SYSTEM
/// Tab 2: /api/v1/announcements
/// Tab 3: type=ACTIVITY
///
/// /
/// Tab
class MessagePage extends StatefulWidget { class MessagePage extends StatefulWidget {
const MessagePage({super.key}); const MessagePage({super.key});
@ -22,10 +27,20 @@ class _MessagePageState extends State<MessagePage>
late TabController _tabController; late TabController _tabController;
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
List<NotificationItem> _notifications = []; List<NotificationItem> _items = [];
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
NotificationType? _currentFilter;
// Tabindex=2使 API
bool get _isAnnouncementsTab => _tabController.index == 2;
// Tab index Tab
static const _tabTypeFilter = <int, NotificationType?>{
0: null,
1: NotificationType.system,
2: null, // Tab
3: NotificationType.activity,
};
@override @override
void initState() { void initState() {
@ -33,10 +48,10 @@ class _MessagePageState extends State<MessagePage>
_tabController = TabController(length: 4, vsync: this); _tabController = TabController(length: 4, vsync: this);
_tabController.addListener(() { _tabController.addListener(() {
if (!_tabController.indexIsChanging) { if (!_tabController.indexIsChanging) {
_onTabChanged(_tabController.index); _load();
} }
}); });
_loadNotifications(); _load();
} }
@override @override
@ -45,67 +60,72 @@ class _MessagePageState extends State<MessagePage>
super.dispose(); super.dispose();
} }
void _onTabChanged(int index) { //
switch (index) {
case 0:
_currentFilter = null;
break;
case 1:
_currentFilter = NotificationType.system;
break;
case 2:
_currentFilter = NotificationType.announcement;
break;
case 3:
_currentFilter = NotificationType.activity;
break;
}
_loadNotifications();
}
Future<void> _loadNotifications() async { Future<void> _load() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_error = null; _error = null;
}); });
try { try {
final response = await _notificationService.getNotifications( final List<NotificationItem> result;
type: _currentFilter, if (_isAnnouncementsTab) {
); // Tab 2使
if (mounted) { final resp = await _notificationService.getAnnouncements(page: 1, limit: 50);
setState(() { result = resp.notifications;
_notifications = response.notifications; } else {
_isLoading = false; // Tab 0/1/3
}); final resp = await _notificationService.getNotifications(
type: _tabTypeFilter[_tabController.index],
page: 1,
limit: 50,
);
result = resp.notifications;
} }
if (mounted) setState(() { _items = result; _isLoading = false; });
} catch (e) { } catch (e) {
if (mounted) { if (mounted) setState(() { _error = e.toString(); _isLoading = false; });
setState(() {
_error = e.toString();
_isLoading = false;
});
}
} }
} }
//
Future<void> _markAllAsRead() async { Future<void> _markAllAsRead() async {
final success = await _notificationService.markAllAnnouncementsAsRead(); final bool success;
if (_isAnnouncementsTab) {
success = await _notificationService.markAllAnnouncementsAsRead();
} else {
// PUT /api/v1/notifications/read-all
success = await _notificationService.markAllNotificationsAsRead();
}
if (success) { if (success) {
NotificationBadgeManager().clearCount(); NotificationBadgeManager().clearCount();
_loadNotifications(); _load();
} }
} }
Future<void> _markAsRead(NotificationItem item) async { Future<void> _markAsRead(NotificationItem item) async {
if (item.isRead) return; if (item.isRead) return;
final success = await _notificationService.markAsRead(item.id); final bool success;
if (_isAnnouncementsTab) {
success = await _notificationService.markAnnouncementAsRead(item.id);
} else {
success = await _notificationService.markAsRead(item.id);
}
if (success) { if (success) {
NotificationBadgeManager().decrementCount(); NotificationBadgeManager().decrementCount();
_loadNotifications(); _load();
} }
} }
void _openDetail(NotificationItem item) {
_markAsRead(item);
// NotificationItem
Navigator.pushNamed(context, '/message/detail', arguments: item);
}
// Build
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -115,8 +135,10 @@ class _MessagePageState extends State<MessagePage>
actions: [ actions: [
TextButton( TextButton(
onPressed: _markAllAsRead, onPressed: _markAllAsRead,
child: Text(context.t('notification.markAllRead'), child: Text(
style: AppTypography.labelSmall.copyWith(color: AppColors.primary)), context.t('notification.markAllRead'),
style: AppTypography.labelSmall.copyWith(color: AppColors.primary),
),
), ),
], ],
bottom: TabBar( bottom: TabBar(
@ -135,17 +157,15 @@ class _MessagePageState extends State<MessagePage>
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : _error != null
? _buildErrorView() ? _buildErrorView()
: _notifications.isEmpty : _items.isEmpty
? EmptyState.noMessages(context) ? EmptyState.noMessages(context)
: RefreshIndicator( : RefreshIndicator(
onRefresh: _loadNotifications, onRefresh: _load,
child: ListView.separated( child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _notifications.length, itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(indent: 76), separatorBuilder: (_, __) => const Divider(indent: 76),
itemBuilder: (context, index) { itemBuilder: (_, i) => _buildItem(_items[i]),
return _buildMessageItem(_notifications[index]);
},
), ),
), ),
); );
@ -156,11 +176,10 @@ class _MessagePageState extends State<MessagePage>
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text(context.t('notification.loadFailed'), Text(context.t('notification.loadFailed'), style: AppTypography.bodyMedium),
style: AppTypography.bodyMedium),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _loadNotifications, onPressed: _load,
child: Text(context.t('notification.retry')), child: Text(context.t('notification.retry')),
), ),
], ],
@ -168,7 +187,7 @@ class _MessagePageState extends State<MessagePage>
); );
} }
Widget _buildMessageItem(NotificationItem item) { Widget _buildItem(NotificationItem item) {
return ListTile( return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
leading: Container( leading: Container(
@ -183,13 +202,15 @@ class _MessagePageState extends State<MessagePage>
title: Row( title: Row(
children: [ children: [
Expanded( Expanded(
child: Text(item.title, child: Text(
style: AppTypography.labelMedium.copyWith( item.title,
fontWeight: item.isRead ? FontWeight.w400 : FontWeight.w600, style: AppTypography.labelMedium.copyWith(
)), fontWeight: item.isRead ? FontWeight.w400 : FontWeight.w600,
),
),
), ),
if (item.publishedAt != null) if (item.publishedAt != null)
Text(_formatTime(item.publishedAt!), style: AppTypography.caption), Text(_formatTime(context, item.publishedAt!), style: AppTypography.caption),
], ],
), ),
subtitle: Padding( subtitle: Padding(
@ -211,50 +232,39 @@ class _MessagePageState extends State<MessagePage>
), ),
) )
: null, : null,
onTap: () { onTap: () => _openDetail(item),
_markAsRead(item);
Navigator.pushNamed(context, '/message/detail');
},
); );
} }
String _formatTime(DateTime time) { //
String _formatTime(BuildContext context, DateTime time) {
final now = DateTime.now(); final now = DateTime.now();
final diff = now.difference(time); final diff = now.difference(time);
if (diff.inMinutes < 1) return '刚刚'; if (diff.inMinutes < 1) return context.t('time.justNow');
if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前'; if (diff.inMinutes < 60) return context.t('time.minutesAgo').replaceAll('{n}', '${diff.inMinutes}');
if (diff.inHours < 24) return '${diff.inHours}小时前'; if (diff.inHours < 24) return context.t('time.hoursAgo').replaceAll('{n}', '${diff.inHours}');
if (diff.inDays < 7) return '${diff.inDays}天前'; if (diff.inDays < 7) return context.t('time.daysAgo').replaceAll('{n}', '${diff.inDays}');
return '${time.month}/${time.day}'; return '${time.month}/${time.day}';
} }
IconData _iconData(NotificationType type) { IconData _iconData(NotificationType type) {
switch (type) { switch (type) {
case NotificationType.system: case NotificationType.system: return Icons.settings_rounded;
return Icons.settings_rounded; case NotificationType.activity: return Icons.swap_horiz_rounded;
case NotificationType.activity: case NotificationType.reward: return Icons.card_giftcard_rounded;
return Icons.swap_horiz_rounded; case NotificationType.upgrade: return Icons.system_update_rounded;
case NotificationType.reward: case NotificationType.announcement: return Icons.campaign_rounded;
return Icons.card_giftcard_rounded;
case NotificationType.upgrade:
return Icons.system_update_rounded;
case NotificationType.announcement:
return Icons.campaign_rounded;
} }
} }
Color _iconColor(NotificationType type) { Color _iconColor(NotificationType type) {
switch (type) { switch (type) {
case NotificationType.system: case NotificationType.system: return AppColors.primary;
return AppColors.primary; case NotificationType.activity: return AppColors.success;
case NotificationType.activity: case NotificationType.reward: return AppColors.warning;
return AppColors.success; case NotificationType.upgrade: return AppColors.info;
case NotificationType.reward: case NotificationType.announcement: return AppColors.info;
return AppColors.warning;
case NotificationType.upgrade:
return AppColors.info;
case NotificationType.announcement:
return AppColors.info;
} }
} }
} }

View File

@ -36,6 +36,7 @@ dependencies:
flutter_riverpod: ^2.5.1 flutter_riverpod: ^2.5.1
fpdart: ^1.1.0 fpdart: ^1.1.0
connectivity_plus: ^6.0.3 connectivity_plus: ^6.0.3
native_dio_adapter: ^1.5.1 # 让 Dio 在 Android 上走 OkHttpJava 网络栈),解决 Dart native socket DNS 解析失败问题
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: