feat: 实现站内通知系统
- 后端: 新增 notifications/user_notifications 表迁移及 ORM 模型
- 后端: 实现 schema/repository/service/router 全套通知 API
- GET /api/v1/notifications (列表+游标分页)
- GET /api/v1/notifications/unread-count
- PATCH /api/v1/notifications/{id}/read (幂等)
- PATCH /api/v1/notifications/mark-all-read (幂等)
- 后端: payload 使用 Pydantic discriminated union (none/open_route/open_url)
- 后端: 19 个单元测试全部通过
- Flutter: 通知 feature 完整实现 (models/apis/repositories/bloc/UI)
- Flutter: Home 页通知按钮接入真实页面,显示未读 badge
- Flutter: 14 个测试全部通过
- 协议文档: notification-inbox-protocol.md 及错误码注册
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../data/models/notification_item.dart';
|
||||
import '../../data/repositories/notification_repository.dart';
|
||||
|
||||
enum NotificationStatus { initial, loading, loaded, error }
|
||||
|
||||
class NotificationState {
|
||||
const NotificationState({
|
||||
this.status = NotificationStatus.initial,
|
||||
this.items = const [],
|
||||
this.unreadCount = 0,
|
||||
this.hasMore = false,
|
||||
this.nextCursor,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final NotificationStatus status;
|
||||
final List<NotificationItem> items;
|
||||
final int unreadCount;
|
||||
final bool hasMore;
|
||||
final String? nextCursor;
|
||||
final String? errorMessage;
|
||||
|
||||
NotificationState copyWith({
|
||||
NotificationStatus? status,
|
||||
List<NotificationItem>? items,
|
||||
int? unreadCount,
|
||||
bool? hasMore,
|
||||
String? nextCursor,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return NotificationState(
|
||||
status: status ?? this.status,
|
||||
items: items ?? this.items,
|
||||
unreadCount: unreadCount ?? this.unreadCount,
|
||||
hasMore: hasMore ?? this.hasMore,
|
||||
nextCursor: nextCursor ?? this.nextCursor,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
sealed class NotificationEvent {}
|
||||
|
||||
final class LoadNotifications extends NotificationEvent {}
|
||||
|
||||
final class RefreshNotifications extends NotificationEvent {}
|
||||
|
||||
final class LoadMoreNotifications extends NotificationEvent {}
|
||||
|
||||
final class MarkNotificationRead extends NotificationEvent {
|
||||
MarkNotificationRead({required this.notificationId});
|
||||
final String notificationId;
|
||||
}
|
||||
|
||||
final class MarkAllNotificationsRead extends NotificationEvent {}
|
||||
|
||||
final class RefreshUnreadCount extends NotificationEvent {}
|
||||
|
||||
final class NotificationCreatedEvent extends NotificationEvent {
|
||||
NotificationCreatedEvent({required this.item});
|
||||
final NotificationItem item;
|
||||
}
|
||||
|
||||
final class NotificationReadUpdatedEvent extends NotificationEvent {
|
||||
NotificationReadUpdatedEvent({
|
||||
required this.notificationId,
|
||||
required this.isRead,
|
||||
});
|
||||
final String notificationId;
|
||||
final bool isRead;
|
||||
}
|
||||
|
||||
final class NotificationRevokedEvent extends NotificationEvent {
|
||||
NotificationRevokedEvent({required this.notificationId});
|
||||
final String notificationId;
|
||||
}
|
||||
|
||||
class NotificationBloc extends ChangeNotifier {
|
||||
NotificationBloc({required NotificationRepository repository})
|
||||
: _repository = repository;
|
||||
|
||||
final NotificationRepository _repository;
|
||||
final Logger _logger = getLogger('features.notifications.bloc');
|
||||
NotificationState _state = const NotificationState();
|
||||
|
||||
NotificationState get state => _state;
|
||||
|
||||
Future<void> handleEvent(NotificationEvent event) async {
|
||||
switch (event) {
|
||||
case LoadNotifications():
|
||||
await _loadNotifications();
|
||||
case RefreshNotifications():
|
||||
await _refreshNotifications();
|
||||
case LoadMoreNotifications():
|
||||
await _loadMore();
|
||||
case MarkNotificationRead():
|
||||
await _markRead(event.notificationId);
|
||||
case MarkAllNotificationsRead():
|
||||
await _markAllRead();
|
||||
case RefreshUnreadCount():
|
||||
await _refreshUnreadCount();
|
||||
case NotificationCreatedEvent():
|
||||
_handleCreated(event.item);
|
||||
case NotificationReadUpdatedEvent():
|
||||
_handleReadUpdated(event.notificationId, event.isRead);
|
||||
case NotificationRevokedEvent():
|
||||
_handleRevoked(event.notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNotifications() async {
|
||||
if (_state.status == NotificationStatus.loading) return;
|
||||
_state = _state.copyWith(status: NotificationStatus.loading);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final result = await _repository.listNotifications(limit: 20);
|
||||
_state = _state.copyWith(
|
||||
status: NotificationStatus.loaded,
|
||||
items: result.items,
|
||||
hasMore: result.hasMore,
|
||||
nextCursor: result.nextCursor,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Load notifications failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_state = _state.copyWith(
|
||||
status: NotificationStatus.error,
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshNotifications() async {
|
||||
try {
|
||||
final result = await _repository.listNotifications(limit: 20);
|
||||
_state = _state.copyWith(
|
||||
status: NotificationStatus.loaded,
|
||||
items: result.items,
|
||||
hasMore: result.hasMore,
|
||||
nextCursor: result.nextCursor,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Refresh notifications failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (!_state.hasMore || _state.nextCursor == null) return;
|
||||
|
||||
try {
|
||||
final result = await _repository.listNotifications(
|
||||
limit: 20,
|
||||
cursor: _state.nextCursor,
|
||||
);
|
||||
final allItems = [..._state.items, ...result.items];
|
||||
_state = _state.copyWith(
|
||||
items: allItems,
|
||||
hasMore: result.hasMore,
|
||||
nextCursor: result.nextCursor,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Load more notifications failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markRead(String notificationId) async {
|
||||
final previousItems = _state.items;
|
||||
final previousCount = _state.unreadCount;
|
||||
final idx = _state.items.indexWhere((item) => item.id == notificationId);
|
||||
if (idx == -1) return;
|
||||
|
||||
final wasUnread = !_state.items[idx].isRead;
|
||||
_state = _state.copyWith(
|
||||
items: [
|
||||
..._state.items.sublist(0, idx),
|
||||
_state.items[idx].copyWith(isRead: true),
|
||||
..._state.items.sublist(idx + 1),
|
||||
],
|
||||
unreadCount: wasUnread
|
||||
? (_state.unreadCount > 0 ? _state.unreadCount - 1 : 0)
|
||||
: _state.unreadCount,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _repository.markRead(notificationId: notificationId);
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Mark read failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_state = _state.copyWith(
|
||||
items: previousItems,
|
||||
unreadCount: previousCount,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
final previousItems = _state.items;
|
||||
_state = _state.copyWith(
|
||||
items: _state.items.map((item) => item.copyWith(isRead: true)).toList(),
|
||||
unreadCount: 0,
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _repository.markAllRead();
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Mark all read failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_state = _state.copyWith(items: previousItems);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshUnreadCount() async {
|
||||
try {
|
||||
final count = await _repository.getUnreadCount();
|
||||
_state = _state.copyWith(unreadCount: count);
|
||||
notifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Refresh unread count failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleCreated(NotificationItem item) {
|
||||
final exists = _state.items.any((i) => i.id == item.id);
|
||||
if (exists) return;
|
||||
_state = _state.copyWith(
|
||||
items: [item, ..._state.items],
|
||||
unreadCount: _state.unreadCount + (item.isRead ? 0 : 1),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleReadUpdated(String notificationId, bool isRead) {
|
||||
final idx = _state.items.indexWhere((item) => item.id == notificationId);
|
||||
if (idx == -1) return;
|
||||
final wasUnread = !_state.items[idx].isRead;
|
||||
final nowRead = isRead;
|
||||
|
||||
_state = _state.copyWith(
|
||||
items: [
|
||||
..._state.items.sublist(0, idx),
|
||||
_state.items[idx].copyWith(isRead: nowRead),
|
||||
..._state.items.sublist(idx + 1),
|
||||
],
|
||||
);
|
||||
|
||||
if (wasUnread && nowRead) {
|
||||
_state = _state.copyWith(
|
||||
unreadCount: _state.unreadCount > 0 ? _state.unreadCount - 1 : 0,
|
||||
);
|
||||
} else if (!wasUnread && !nowRead) {
|
||||
_state = _state.copyWith(unreadCount: _state.unreadCount + 1);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleRevoked(String notificationId) {
|
||||
final matchingItems = _state.items.where(
|
||||
(i) => i.notificationId == notificationId,
|
||||
);
|
||||
if (matchingItems.isEmpty) return;
|
||||
|
||||
final item = matchingItems.first;
|
||||
final wasUnread = !item.isRead;
|
||||
_state = _state.copyWith(
|
||||
items: _state.items
|
||||
.where((i) => i.notificationId != notificationId)
|
||||
.toList(),
|
||||
);
|
||||
if (wasUnread) {
|
||||
_state = _state.copyWith(
|
||||
unreadCount: _state.unreadCount > 0 ? _state.unreadCount - 1 : 0,
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../data/models/notification_item.dart';
|
||||
import '../../data/models/notification_payload.dart';
|
||||
import '../../data/repositories/notification_repository.dart';
|
||||
import '../bloc/notification_bloc.dart';
|
||||
import '../widgets/notification_list_item.dart';
|
||||
|
||||
class NotificationCenterScreen extends StatefulWidget {
|
||||
const NotificationCenterScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
this.onNavigateToRoute,
|
||||
this.onOpenUrl,
|
||||
});
|
||||
|
||||
final NotificationRepository repository;
|
||||
final void Function(String route, {String? entityId, String? tab})?
|
||||
onNavigateToRoute;
|
||||
final void Function(String url)? onOpenUrl;
|
||||
|
||||
@override
|
||||
State<NotificationCenterScreen> createState() =>
|
||||
_NotificationCenterScreenState();
|
||||
}
|
||||
|
||||
class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
late NotificationBloc _bloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc = NotificationBloc(repository: widget.repository);
|
||||
_bloc.handleEvent(LoadNotifications());
|
||||
_bloc.addListener(_onStateChanged);
|
||||
}
|
||||
|
||||
void _onStateChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.removeListener(_onStateChanged);
|
||||
_bloc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final state = _bloc.state;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('通知'),
|
||||
actions: [
|
||||
if (state.items.any((item) => !item.isRead))
|
||||
TextButton(
|
||||
onPressed: _onMarkAllRead,
|
||||
child: Text('全部已读', style: TextStyle(color: colors.primary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => _bloc.handleEvent(RefreshNotifications()),
|
||||
child: _buildBody(state, colors),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(NotificationState state, ColorScheme colors) {
|
||||
if (state.status == NotificationStatus.loading && state.items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state.status == NotificationStatus.error && state.items.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: colors.error),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text('加载失败', style: TextStyle(color: colors.onSurfaceVariant)),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
FilledButton(
|
||||
onPressed: () => _bloc.handleEvent(LoadNotifications()),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.items.isEmpty) {
|
||||
return ListView(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notifications_none_outlined,
|
||||
size: 64,
|
||||
color: colors.outline,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
'暂无通知',
|
||||
style: TextStyle(
|
||||
color: colors.onSurfaceVariant,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: state.items.length + (state.hasMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.items.length && state.hasMore) {
|
||||
_bloc.handleEvent(LoadMoreNotifications());
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.lg),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final item = state.items[index];
|
||||
return NotificationListItem(
|
||||
item: item,
|
||||
onTap: () => _handleNotificationTap(item),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleNotificationTap(NotificationItem item) {
|
||||
if (!item.isRead) {
|
||||
_bloc.handleEvent(MarkNotificationRead(notificationId: item.id));
|
||||
}
|
||||
_executePayload(item.payload);
|
||||
}
|
||||
|
||||
void _executePayload(NotificationPayload payload) {
|
||||
switch (payload) {
|
||||
case NotificationPayloadNone():
|
||||
break;
|
||||
case NotificationPayloadRoute(:final route, :final entityId, :final tab):
|
||||
widget.onNavigateToRoute?.call(route, entityId: entityId, tab: tab);
|
||||
case NotificationPayloadUrl(:final url):
|
||||
widget.onOpenUrl?.call(url);
|
||||
}
|
||||
}
|
||||
|
||||
void _onMarkAllRead() {
|
||||
_bloc.handleEvent(MarkAllNotificationsRead());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../data/models/notification_item.dart';
|
||||
|
||||
class NotificationListItem extends StatelessWidget {
|
||||
const NotificationListItem({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final NotificationItem item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.isRead ? colors.surface : colors.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!item.isRead)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: AppSpacing.sm,
|
||||
right: AppSpacing.sm,
|
||||
),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.normal
|
||||
: FontWeight.w600,
|
||||
color: colors.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
item.body,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
if (diff.inMinutes < 1) return '刚刚';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
|
||||
if (diff.inDays < 1) return '${diff.inHours}小时前';
|
||||
if (diff.inDays < 30) return '${diff.inDays}天前';
|
||||
return '${dt.month}/${dt.day}';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user