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:
parent
918489e4e5
commit
9e368cbf37
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}日前',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}天前',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}天前',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 上使用 OkHttp(Java 网络栈)替代 Dart 原生 socket
|
||||
// 解决 Dart 的 getaddrinfo() 在部分 Android 12+ 设备上 DNS 解析失败的问题
|
||||
// (浏览器走 Android Java 网络栈能正常解析,而 Dart native socket 不能)
|
||||
if (Platform.isAndroid) {
|
||||
_dio.httpClientAdapter = NativeAdapter();
|
||||
}
|
||||
|
||||
_dio.interceptors.add(_buildAuthInterceptor());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
// 当前是公告 Tab(index=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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 上走 OkHttp(Java 网络栈),解决 Dart native socket DNS 解析失败问题
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
Loading…
Reference in New Issue