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:
qzl
2026-04-10 18:50:08 +08:00
parent 17ef460391
commit 3f3d613d99
28 changed files with 3481 additions and 651 deletions
@@ -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());
}
}