diff --git a/lib/app/router.dart b/lib/app/router.dart index 1255bad..e97b3b6 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -7,6 +7,7 @@ import '../features/auth/application/session_controller.dart'; import '../features/auth/presentation/login_page.dart'; import '../features/customer/presentation/customer_detail_page.dart'; import '../features/customer/presentation/customer_edit_page.dart'; +import '../features/notice/presentation/notice_center_page.dart'; import '../features/project/presentation/project_detail_page.dart'; import '../features/survey/presentation/survey_form_page.dart'; import '../features/survey/presentation/survey_list_page.dart'; @@ -50,6 +51,12 @@ final routerProvider = Provider((ref) { GoRoute(path: '/login', builder: (context, state) => const LoginPage()), GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()), + // 消息中心(二级页) + GoRoute( + path: '/notice', + builder: (context, state) => const NoticeCenterPage(), + ), + // 客户模块 GoRoute( path: '/customer/detail/:id', diff --git a/lib/core/providers.dart b/lib/core/providers.dart index 5f5d5e6..3c1ad42 100644 --- a/lib/core/providers.dart +++ b/lib/core/providers.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../features/auth/application/session_controller.dart'; +import '../features/notice/data/notice_api.dart'; import 'env.dart'; import 'network/api_client.dart'; import 'storage/token_storage.dart'; @@ -52,4 +56,38 @@ final apiClientProvider = Provider((ref) { ); ref.onDispose(client.dispose); return client; -}); \ No newline at end of file +}); + +/// 站内消息未读数,供顶栏铃铛红点使用。 +/// +/// 仅在登录态下启用:登录后立即拉取一次,并按 60s 轮询(对齐 Web 端); +/// 登录态变化时自动重建,未登录返回 0 并停止轮询。 +class NoticeUnreadCountNotifier extends Notifier { + @override + int build() { + final loggedIn = ref.watch(sessionProvider.select((s) => s.isLoggedIn)); + if (!loggedIn) return 0; + + final timer = Timer.periodic(const Duration(seconds: 60), (_) => refresh()); + ref.onDispose(timer.cancel); + // 首帧后立即拉取一次,避免等满 60s 才出现红点。 + Future.microtask(refresh); + return 0; + } + + /// 立即刷新未读数;失败时保留上次结果(静默,不打扰用户)。 + Future refresh() async { + try { + final res = await ref.read(noticeApiProvider).unreadCount(); + if (!ref.mounted) return; + if (res.isOk && res.data != null) state = res.data!; + } catch (_) { + // 轮询失败:保留上次未读数 + } + } +} + +final noticeUnreadCountProvider = + NotifierProvider( + NoticeUnreadCountNotifier.new, + ); \ No newline at end of file diff --git a/lib/features/customer/presentation/customer_tab.dart b/lib/features/customer/presentation/customer_tab.dart index 39a910f..b32ae83 100644 --- a/lib/features/customer/presentation/customer_tab.dart +++ b/lib/features/customer/presentation/customer_tab.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/tab_index.dart'; +import '../../../core/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_typography.dart'; @@ -134,6 +135,8 @@ class _CustomerTabState extends ConsumerState { topBar: XTopBar.brand( title: '鑫潼智电', trailingColor: AppColors.primary, + onTrailingTap: () => context.push('/notice'), + trailingBadge: ref.watch(noticeUnreadCountProvider), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/features/mine/presentation/mine_tab.dart b/lib/features/mine/presentation/mine_tab.dart index a432309..307cd30 100644 --- a/lib/features/mine/presentation/mine_tab.dart +++ b/lib/features/mine/presentation/mine_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/tab_index.dart'; +import '../../../core/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../shared/widgets/app_feedback.dart'; import '../../../shared/widgets/x_card.dart'; @@ -138,7 +139,11 @@ class _MineTabState extends ConsumerState { ]; return XPageShell( - topBar: XTopBar.brand(title: '我的'), + topBar: XTopBar.brand( + title: '我的', + onTrailingTap: () => context.push('/notice'), + trailingBadge: ref.watch(noticeUnreadCountProvider), + ), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 130), child: Column( diff --git a/lib/features/notice/data/notice_api.dart b/lib/features/notice/data/notice_api.dart new file mode 100644 index 0000000..04c318d --- /dev/null +++ b/lib/features/notice/data/notice_api.dart @@ -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( + (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 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 list; + final int total; + final int page; + final int pageSize; + + factory NoticePage.fromJson(Map json, int fallbackPageSize) => + NoticePage( + list: (json['list'] as List? ?? []) + .map((e) => Notice.fromJson((e as Map).cast())) + .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> list({ + int page = 1, + int pageSize = 15, + int? isRead, + }) => _client.get( + '/notices', + query: {'page': page, 'pageSize': pageSize, 'isRead': isRead}, + parse: (d) => + NoticePage.fromJson((d as Map).cast(), pageSize), + ); + + /// 未读数量(顶栏红点)。 + Future> unreadCount() => _client.get( + '/notices/unread-count', + parse: (d) => toIntOr((d as Map)['count'], 0), + ); + + /// 单条标记已读。 + Future> markRead(int id) => + _client.put('/notices/$id/read'); + + /// 全部标记已读,返回本次更新条数。 + Future> markAllRead() => _client.put( + '/notices/read-all', + parse: (d) => toIntOr((d as Map)['updated'], 0), + ); +} diff --git a/lib/features/notice/presentation/notice_center_page.dart b/lib/features/notice/presentation/notice_center_page.dart new file mode 100644 index 0000000..24c8ee8 --- /dev/null +++ b/lib/features/notice/presentation/notice_center_page.dart @@ -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 createState() => _NoticeCenterPageState(); +} + +class _NoticeCenterPageState extends ConsumerState { + static const int _pageSize = 15; + + static const Map _typeLabels = { + 'cert_expire': '证照到期', + 'contract_expire': '合同到期', + 'worker_audit_result': '电工审核', + 'dispatch_assign': '派单通知', + 'visit_plan': '拜访计划', + 'order_timeout': '工单超时', + 'manual': '系统通知', + }; + + final ScrollController _scrollController = ScrollController(); + + List _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 _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 _refresh() async { + await _load(reset: true); + await ref.read(noticeUnreadCountProvider.notifier).refresh(); + } + + Future _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 _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 _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, + ), + ); + } +} diff --git a/lib/features/project/presentation/project_tab.dart b/lib/features/project/presentation/project_tab.dart index 77e109b..81312ad 100644 --- a/lib/features/project/presentation/project_tab.dart +++ b/lib/features/project/presentation/project_tab.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/tab_index.dart'; +import '../../../core/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_typography.dart'; @@ -131,7 +132,12 @@ class _ProjectTabState extends ConsumerState { }); return XPageShell( - topBar: XTopBar.brand(title: '鑫潼智电', trailingColor: AppColors.primary), + topBar: XTopBar.brand( + title: '鑫潼智电', + trailingColor: AppColors.primary, + onTrailingTap: () => context.push('/notice'), + trailingBadge: ref.watch(noticeUnreadCountProvider), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/features/workbench/presentation/workbench_tab.dart b/lib/features/workbench/presentation/workbench_tab.dart index 0903e84..746092a 100644 --- a/lib/features/workbench/presentation/workbench_tab.dart +++ b/lib/features/workbench/presentation/workbench_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../app/tab_index.dart'; +import '../../../core/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_typography.dart'; @@ -136,7 +137,12 @@ class _WorkbenchTabState extends ConsumerState { : '工号: ${user.username.toUpperCase()}'; return XPageShell( - topBar: XTopBar.brand(title: '鑫潼智电', trailingIcon: 'notification'), + topBar: XTopBar.brand( + title: '鑫潼智电', + trailingIcon: 'notification', + onTrailingTap: () => context.push('/notice'), + trailingBadge: ref.watch(noticeUnreadCountProvider), + ), child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), child: Column( diff --git a/lib/shared/widgets/x_top_bar.dart b/lib/shared/widgets/x_top_bar.dart index dfbefaf..693955c 100644 --- a/lib/shared/widgets/x_top_bar.dart +++ b/lib/shared/widgets/x_top_bar.dart @@ -26,6 +26,7 @@ class XTopBar extends StatelessWidget { String trailingIcon = 'notification', Color trailingColor = AppColors.onSurfaceVariant, VoidCallback? onTrailingTap, + int trailingBadge = 0, }) { return XTopBar._( title: title, @@ -46,7 +47,18 @@ class XTopBar extends StatelessWidget { trailing: GestureDetector( onTap: onTrailingTap, behavior: HitTestBehavior.opaque, - child: XIcon(trailingIcon, size: 22, color: trailingColor), + child: Stack( + clipBehavior: Clip.none, + children: [ + XIcon(trailingIcon, size: 22, color: trailingColor), + if (trailingBadge > 0) + Positioned( + top: -6, + right: -8, + child: _TrailingBadge(count: trailingBadge), + ), + ], + ), ), titleStyle: AppTypography.headlineMdMobile, titleAlign: TextAlign.left, @@ -135,4 +147,35 @@ class XTopBar extends StatelessWidget { ), ); } +} + +/// 顶栏图标右上角的未读角标:红底白字胶囊,超过 99 显示 `99+`。 +class _TrailingBadge extends StatelessWidget { + const _TrailingBadge({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minWidth: 16), + height: 16, + padding: const EdgeInsets.symmetric(horizontal: 4), + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.statusError, + borderRadius: BorderRadius.circular(9999), + border: Border.all(color: AppColors.surface, width: 1.5), + ), + child: Text( + count > 99 ? '99+' : '$count', + style: const TextStyle( + fontSize: 10, + height: 1, + fontWeight: FontWeight.w700, + color: AppColors.surface, + ), + ), + ); + } } \ No newline at end of file