feat: 添加消息中心及顶栏未读角标功能

新增消息中心页面,实现未读消息轮询与展示
为各 Tab 页面顶栏添加通知图标及未读数角标
新增通知 API 接口与未读数状态管理
实现消息标记已读、全部已读及下拉刷新功能
This commit is contained in:
andyesfly committed 2026-10-06 13:16:55 +08:00
1 parent 66013ee069
commit 7ad604ff8b
9 files changed
+575 -5

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../../../core/utils/id_util.dart';
final noticeApiProvider = Provider<NoticeApi>(
(ref) => NoticeApi(ref.watch(apiClientProvider)),
);
/// 站内通知,与后端 `sys_notice` / `/api/notices` 契约对齐。
///
/// 管理端接口固定 `receiver_type='user'`,字段序列化为驼峰(主键 BIGINT 为字符串)。
class Notice {
const Notice({
required this.id,
required this.noticeType,
required this.title,
required this.content,
required this.bizType,
required this.bizId,
required this.isRead,
required this.receiverType,
required this.ruleId,
required this.batchId,
required this.createdAt,
});
final int id;
final String noticeType;
final String title;
final String content;
final String bizType;
final int? bizId;
/// 0 未读 / 1 已读
final int isRead;
final String receiverType;
final int? ruleId;
final int? batchId;
final String? createdAt;
bool get unread => isRead == 0;
factory Notice.fromJson(Map<String, dynamic> json) => Notice(
id: toIntOr(json['id'], 0),
noticeType: json['noticeType'] as String? ?? '',
title: json['title'] as String? ?? '',
content: json['content'] as String? ?? '',
bizType: json['bizType'] as String? ?? '',
bizId: toIntOrNull(json['bizId']),
isRead: toIntOr(json['isRead'], 0),
receiverType: json['receiverType'] as String? ?? 'user',
ruleId: toIntOrNull(json['ruleId']),
batchId: toIntOrNull(json['batchId']),
createdAt: (json['created_at'] ?? json['createdAt']) as String?,
);
Notice copyWith({int? isRead}) => Notice(
id: id,
noticeType: noticeType,
title: title,
content: content,
bizType: bizType,
bizId: bizId,
isRead: isRead ?? this.isRead,
receiverType: receiverType,
ruleId: ruleId,
batchId: batchId,
createdAt: createdAt,
);
}
/// `/notices` 分页结果。
class NoticePage {
const NoticePage({
required this.list,
required this.total,
required this.page,
required this.pageSize,
});
final List<Notice> list;
final int total;
final int page;
final int pageSize;
factory NoticePage.fromJson(Map<String, dynamic> json, int fallbackPageSize) =>
NoticePage(
list: (json['list'] as List? ?? [])
.map((e) => Notice.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
total: toIntOr(json['total'], 0),
page: toIntOr(json['page'], 1),
pageSize: toIntOr(json['pageSize'], fallbackPageSize),
);
}
/// 站内消息接口,对应后端 `notice.controller.ts`(管理端)。
class NoticeApi {
NoticeApi(this._client);
final ApiClient _client;
Future<ApiResponse<NoticePage>> list({
int page = 1,
int pageSize = 15,
int? isRead,
}) => _client.get<NoticePage>(
'/notices',
query: {'page': page, 'pageSize': pageSize, 'isRead': isRead},
parse: (d) =>
NoticePage.fromJson((d as Map).cast<String, dynamic>(), pageSize),
);
/// 未读数量(顶栏红点)。
Future<ApiResponse<int>> unreadCount() => _client.get<int>(
'/notices/unread-count',
parse: (d) => toIntOr((d as Map)['count'], 0),
);
/// 单条标记已读。
Future<ApiResponse<void>> markRead(int id) =>
_client.put<void>('/notices/$id/read');
/// 全部标记已读,返回本次更新条数。
Future<ApiResponse<int>> markAllRead() => _client.put<int>(
'/notices/read-all',
parse: (d) => toIntOr((d as Map)['updated'], 0),
);
}
@@ -0,0 +1,330 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../data/notice_api.dart';
/// 消息中心(二级页):列表 + 未读高亮 + 全部已读 + 下拉刷新 + 分页加载。
class NoticeCenterPage extends ConsumerStatefulWidget {
const NoticeCenterPage({super.key});
@override
ConsumerState<NoticeCenterPage> createState() => _NoticeCenterPageState();
}
class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
static const int _pageSize = 15;
static const Map<String, String> _typeLabels = {
'cert_expire': '证照到期',
'contract_expire': '合同到期',
'worker_audit_result': '电工审核',
'dispatch_assign': '派单通知',
'visit_plan': '拜访计划',
'order_timeout': '工单超时',
'manual': '系统通知',
};
final ScrollController _scrollController = ScrollController();
List<Notice> _list = const [];
int _total = 0;
int _page = 1;
bool _hasMore = true;
bool _loading = false;
bool _markingAll = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_load(reset: true);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
}
Future<void> _load({bool reset = false}) async {
if (_loading) return;
setState(() => _loading = true);
if (reset) {
_page = 1;
_hasMore = true;
}
try {
final res = await ref
.read(noticeApiProvider)
.list(page: _page, pageSize: _pageSize);
if (res.isOk && res.data != null && mounted) {
final data = res.data!;
setState(() {
_list = reset ? data.list : [..._list, ...data.list];
_total = data.total;
_hasMore = _list.length < data.total;
if (_hasMore) _page += 1;
});
}
} catch (_) {
if (mounted) showAppToast('加载消息失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _loadMore() {
if (_hasMore && !_loading) _load();
}
Future<void> _refresh() async {
await _load(reset: true);
await ref.read(noticeUnreadCountProvider.notifier).refresh();
}
Future<void> _markRead(Notice notice) async {
try {
final res = await ref.read(noticeApiProvider).markRead(notice.id);
if (!mounted) return;
if (!res.isOk) {
showAppToast(res.message.isEmpty ? '标记已读失败' : res.message);
return;
}
setState(() {
_list = [
for (final e in _list) e.id == notice.id ? e.copyWith(isRead: 1) : e,
];
});
unawaited(ref.read(noticeUnreadCountProvider.notifier).refresh());
} catch (_) {
if (mounted) showAppToast('标记已读失败');
}
}
Future<void> _markAllRead() async {
if (_markingAll) return;
final ok = await showAppConfirm(
title: '全部已读',
content: '确定将全部消息标记为已读吗?',
);
if (!ok || !mounted) return;
_markingAll = true;
try {
final res = await ref.read(noticeApiProvider).markAllRead();
if (!mounted) return;
if (res.isOk) {
setState(() {
_list = [for (final e in _list) e.copyWith(isRead: 1)];
});
unawaited(ref.read(noticeUnreadCountProvider.notifier).refresh());
showAppToast('已全部标记为已读', success: true);
} else {
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
}
} catch (_) {
if (mounted) showAppToast('操作失败');
} finally {
_markingAll = false;
}
}
String _typeLabel(String type) =>
_typeLabels[type] ?? (type.isEmpty ? '通知' : type);
ChipTone _typeTone(String type) => switch (type) {
'cert_expire' || 'contract_expire' => ChipTone.warn,
'order_timeout' => ChipTone.error,
'manual' => ChipTone.primary,
_ => ChipTone.muted,
};
@override
Widget build(BuildContext context) {
final unread = ref.watch(noticeUnreadCountProvider);
return XPageShell(
topBar: XTopBar.nav(
title: '消息中心',
onBack: () => context.pop(),
trailing: unread > 0
? GestureDetector(
onTap: _markAllRead,
behavior: HitTestBehavior.opaque,
child: Text(
'全部已读',
style: AppTypography.labelBold.copyWith(
color: AppColors.primary,
),
),
)
: null,
),
child: RefreshIndicator(
onRefresh: _refresh,
color: AppColors.primary,
child: ListView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 4, 16, 32),
children: _buildList(),
),
),
);
}
List<Widget> _buildList() {
if (_loading && _list.isEmpty) {
return const [XPlaceholder(text: '加载中...')];
}
if (_list.isEmpty) {
return const [XPlaceholder(text: '暂无消息')];
}
return [
for (final notice in _list) _buildCard(notice),
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 4),
child: Center(
child: Text(
_hasMore ? '加载中...' : '共 $_total 条',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
),
];
}
Widget _buildCard(Notice notice) {
return XCard(
clip: true,
margin: const EdgeInsets.only(top: 12),
onTap: notice.unread ? () => _markRead(notice) : null,
child: Stack(
children: [
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(
width: 4,
color: notice.unread
? AppColors.primary
: Colors.transparent,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (notice.unread) ...[
const Padding(
padding: EdgeInsets.only(top: 6),
child: _UnreadDot(),
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
notice.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 8),
XChip(
_typeLabel(notice.noticeType),
tone: _typeTone(notice.noticeType),
),
],
),
if (notice.content.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
notice.content,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
height: 1.4,
color: AppColors.onSurfaceVariant,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
children: [
const XIcon(
'clock',
size: 14,
color: AppColors.textMuted,
),
const SizedBox(width: 4),
Text(
formatDateTime(notice.createdAt),
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
const Spacer(),
if (notice.unread)
Text(
'未读',
style: AppTypography.captionSm.copyWith(
color: AppColors.primary,
),
),
],
),
),
],
),
),
],
),
);
}
}
/// 未读消息左侧的实心圆点。
class _UnreadDot extends StatelessWidget {
const _UnreadDot();
@override
Widget build(BuildContext context) {
return Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.statusError,
),
);
}
}