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> {
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>;
save(notification: Notification): Promise<Notification>;
updateStatus(id: string, status: NotificationStatus, extra?: Partial<Notification>): Promise<void>;
markAllReadByUserId(userId: string): Promise<void>;
getStatusCounts(): Promise<Array<{ status: string; count: string }>>;
getChannelStatusCounts(): Promise<Array<{ channel: string; status: string; count: string }>>;
countTodaySent(): Promise<number>;

View File

@ -45,6 +45,13 @@ export class NotificationRepository implements INotificationRepository {
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 }>> {
return this.repo
.createQueryBuilder('n')

View File

@ -24,6 +24,13 @@ export class NotificationController {
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')
@ApiOperation({ summary: 'Mark notification as read' })
async markAsRead(@Param() params: MarkReadParamDto, @Req() req: any) {

View File

@ -835,4 +835,8 @@ const Map<String, String> en = {
'notification.empty': 'No notifications',
'notification.loadFailed': 'Load failed',
'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.loadFailed': '読み込み失敗',
'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.loadFailed': '加载失败',
'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.loadFailed': '載入失敗',
'notification.retry': '重試',
'time.justNow': '剛剛',
'time.minutesAgo': '{n}分鐘前',
'time.hoursAgo': '{n}小時前',
'time.daysAgo': '{n}天前',
};

View File

@ -21,8 +21,10 @@
// ============================================================
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:native_dio_adapter/native_dio_adapter.dart';
import '../storage/session_storage.dart';
/// Genex API
@ -56,6 +58,14 @@ class ApiClient {
'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());
}

View File

@ -129,15 +129,17 @@ class NotificationService {
: _apiClient = apiClient ?? ApiClient.instance;
///
///
/// [page] 1 NotificationQueryDto 使 page/limit offset
Future<NotificationListResponse> getNotifications({
NotificationType? type,
int page = 1,
int limit = 50,
int offset = 0,
}) async {
try {
final queryParams = <String, dynamic>{
'page': page,
'limit': limit,
'offset': offset,
};
if (type != null) {
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 {
try {

View File

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