From 1d1c60e2a2f7482bdabdb242df6a83b77f521d08 Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 27 Feb 2026 19:33:51 -0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E9=98=85=E8=AF=BB=E5=BC=B9=E7=AA=97=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=88=E7=AE=A1=E7=90=86=E5=91=98=E5=8F=AF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20requiresForceRead=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能概述 在不影响任何现有业务的前提下,新增"强制阅读弹窗"功能: - 管理员创建通知时可勾选「需要强制弹窗阅读」 - App 冷启动进入主页 或 从后台切回前台时自动触发检查 - 存在未读且标记 requiresForceRead=true 的通知时,依次逐条弹窗 - 用户无法通过点击背景或返回键关闭弹窗(强制阅读) - 最后一条通知弹窗底部显示 checkbox「我已经阅读并知晓」 - 未勾选时"确定"按钮置灰禁用 - 勾选后"确定"变为金色可点击,点击后所有弹窗消失 - 全部看完后仅对已展示的强制阅读通知按 ID 逐一标记已读 (不影响普通未读通知的 badge 计数) ## 涉及改动 ### 后端 admin-service - `prisma/schema.prisma` - Notification 模型新增字段 `requiresForceRead Boolean @default(false)` - `prisma/migrations/20260227100000_add_requires_force_read_to_notifications/migration.sql` - 手动创建 SQL migration(本地无 DATABASE_URL 环境) - 部署时需在服务器执行 `npx prisma migrate deploy` - `src/domain/entities/notification.entity.ts` - 实体类构造器新增 `requiresForceRead` - create() / update() 方法均支持该字段,默认值 false - `src/infrastructure/persistence/mappers/notification.mapper.ts` - toDomain() 从 Prisma 记录读取 requiresForceRead - toPersistence() 写入 requiresForceRead - `src/api/dto/request/notification.dto.ts` - CreateNotificationDto / UpdateNotificationDto 各新增可选字段 requiresForceRead - `src/api/dto/response/notification.dto.ts` - NotificationResponseDto(管理端)新增 requiresForceRead - UserNotificationResponseDto(移动端)新增 requiresForceRead - `src/api/controllers/notification.controller.ts` - create() / update() 透传 requiresForceRead 到 entity ### 前端 admin-web - `src/services/notificationService.ts` - NotificationItem / CreateNotificationRequest / UpdateNotificationRequest 新增 requiresForceRead - `src/app/(dashboard)/notifications/page.tsx` - 通知列表:requiresForceRead=true 时显示红色「强制阅读」标签 - 创建/编辑表单:新增 checkbox「需要强制弹窗阅读」及说明文字 - form state / submit payload / edit 初始化均包含 requiresForceRead ### 移动端 mobile-app - `lib/core/services/notification_service.dart` - NotificationItem 新增字段 requiresForceRead(默认 false,fromJson 安全读取) - `lib/features/notification/presentation/pages/notification_inbox_page.dart` - markAsRead / markAllAsRead 重建 NotificationItem 时保留 requiresForceRead - `lib/features/notification/presentation/widgets/force_read_notification_dialog.dart`(新建) - 单条强制阅读弹窗组件 - 顶部显示通知类型图标 + 进度「1/3」 - 可滚动内容区展示完整通知 - 非最后条:「下一条 ▶」按钮(始终可点) - 最后一条:checkbox + 「确定」(勾选后才可点) - barrierDismissible: false + PopScope(canPop: false),无法逃出 - `lib/features/home/presentation/pages/home_shell_page.dart` - 新增状态:_isShowingForceReadDialog(实例,防重入) _lastForceReadDialogShownAt(静态,60秒冷却) - 新增方法 _checkAndShowForceReadDialog(): Guard 1: 防重入锁 Guard 2: 60秒冷却(防回前台闪弹) Guard 3: 检查用户已登录 Guard 4: 检查无其他弹窗在显示 弹窗期间同时设置 _isShowingDialog=true,阻止后台合同/KYC检查并发 全部看完后仅标记 forceReadList 中的通知为已读,再 refresh() 刷新 badge - initState addPostFrameCallback 中新增调用 - didChangeAppLifecycleState resumed 分支中新增调用 - resetContractCheckState() 中重置 _lastForceReadDialogShownAt(账号切换隔离) ## 安全与兼容性 - API 调用失败时静默返回,不阻断用户进入 App - 仅对 requiresForceRead=true 的通知弹窗,普通通知完全不受影响 - 与现有合同弹窗、KYC弹窗、维护弹窗、更新弹窗无冲突 - 静态冷却变量在账号切换时重置,避免新账号被旧账号冷却影响 - badge 准确:仅标记已展示的强制通知,不动其他未读通知计数 Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 5 + .../admin-service/prisma/schema.prisma | 3 +- .../controllers/notification.controller.ts | 2 + .../src/api/dto/request/notification.dto.ts | 8 + .../src/api/dto/response/notification.dto.ts | 4 + .../domain/entities/notification.entity.ts | 6 + .../mappers/notification.mapper.ts | 2 + .../app/(dashboard)/notifications/page.tsx | 30 ++ .../src/services/notificationService.ts | 3 + .../core/services/notification_service.dart | 5 + .../presentation/pages/home_shell_page.dart | 95 +++++- .../pages/notification_inbox_page.dart | 2 + .../force_read_notification_dialog.dart | 288 ++++++++++++++++++ 13 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 backend/services/admin-service/prisma/migrations/20260227100000_add_requires_force_read_to_notifications/migration.sql create mode 100644 frontend/mobile-app/lib/features/notification/presentation/widgets/force_read_notification_dialog.dart diff --git a/backend/services/admin-service/prisma/migrations/20260227100000_add_requires_force_read_to_notifications/migration.sql b/backend/services/admin-service/prisma/migrations/20260227100000_add_requires_force_read_to_notifications/migration.sql new file mode 100644 index 00000000..9511e493 --- /dev/null +++ b/backend/services/admin-service/prisma/migrations/20260227100000_add_requires_force_read_to_notifications/migration.sql @@ -0,0 +1,5 @@ +-- Migration: add requiresForceRead to notifications +-- 为通知表添加"是否需要强制弹窗阅读"字段 +-- 管理员可在创建通知时配置此字段,标记为 true 的通知将在用户打开 App 时强制弹窗展示 + +ALTER TABLE "notifications" ADD COLUMN "requiresForceRead" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/services/admin-service/prisma/schema.prisma b/backend/services/admin-service/prisma/schema.prisma index 98c333cf..d3f43fc0 100644 --- a/backend/services/admin-service/prisma/schema.prisma +++ b/backend/services/admin-service/prisma/schema.prisma @@ -59,7 +59,8 @@ model Notification { targetLogic TargetLogic @default(ANY) @map("target_logic") // 多标签匹配逻辑 imageUrl String? // 可选的图片URL linkUrl String? // 可选的跳转链接 - isEnabled Boolean @default(true) // 是否启用 + isEnabled Boolean @default(true) // 是否启用 + requiresForceRead Boolean @default(false) // 是否需要强制弹窗阅读(管理员可配置) publishedAt DateTime? // 发布时间(null表示草稿) expiresAt DateTime? // 过期时间(null表示永不过期) createdAt DateTime @default(now()) diff --git a/backend/services/admin-service/src/api/controllers/notification.controller.ts b/backend/services/admin-service/src/api/controllers/notification.controller.ts index 594486eb..194fae75 100644 --- a/backend/services/admin-service/src/api/controllers/notification.controller.ts +++ b/backend/services/admin-service/src/api/controllers/notification.controller.ts @@ -74,6 +74,7 @@ export class AdminNotificationController { targetConfig, imageUrl: dto.imageUrl, linkUrl: dto.linkUrl, + requiresForceRead: dto.requiresForceRead, publishedAt: dto.publishedAt ? new Date(dto.publishedAt) : null, expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, createdBy: 'admin', // TODO: 从认证信息获取 @@ -149,6 +150,7 @@ export class AdminNotificationController { imageUrl: dto.imageUrl, linkUrl: dto.linkUrl, isEnabled: dto.isEnabled, + requiresForceRead: dto.requiresForceRead, publishedAt: dto.publishedAt !== undefined ? dto.publishedAt ? new Date(dto.publishedAt) diff --git a/backend/services/admin-service/src/api/dto/request/notification.dto.ts b/backend/services/admin-service/src/api/dto/request/notification.dto.ts index aa10124a..64a9b5ea 100644 --- a/backend/services/admin-service/src/api/dto/request/notification.dto.ts +++ b/backend/services/admin-service/src/api/dto/request/notification.dto.ts @@ -70,6 +70,10 @@ export class CreateNotificationDto { @IsString() linkUrl?: string; + @IsOptional() + @IsBoolean() + requiresForceRead?: boolean; + @IsOptional() @IsDateString() publishedAt?: string; @@ -120,6 +124,10 @@ export class UpdateNotificationDto { @IsBoolean() isEnabled?: boolean; + @IsOptional() + @IsBoolean() + requiresForceRead?: boolean; + @IsOptional() @IsDateString() publishedAt?: string; diff --git a/backend/services/admin-service/src/api/dto/response/notification.dto.ts b/backend/services/admin-service/src/api/dto/response/notification.dto.ts index 48e3cefe..33565e0e 100644 --- a/backend/services/admin-service/src/api/dto/response/notification.dto.ts +++ b/backend/services/admin-service/src/api/dto/response/notification.dto.ts @@ -31,6 +31,7 @@ export class NotificationResponseDto { imageUrl: string | null; linkUrl: string | null; isEnabled: boolean; + requiresForceRead: boolean; publishedAt: string | null; expiresAt: string | null; createdAt: string; @@ -47,6 +48,7 @@ export class NotificationResponseDto { imageUrl: entity.imageUrl, linkUrl: entity.linkUrl, isEnabled: entity.isEnabled, + requiresForceRead: entity.requiresForceRead, publishedAt: entity.publishedAt?.toISOString() ?? null, expiresAt: entity.expiresAt?.toISOString() ?? null, createdAt: entity.createdAt.toISOString(), @@ -68,6 +70,7 @@ export class UserNotificationResponseDto { publishedAt: string | null; isRead: boolean; readAt: string | null; + requiresForceRead: boolean; static fromEntity(item: NotificationWithReadStatus): UserNotificationResponseDto { return { @@ -81,6 +84,7 @@ export class UserNotificationResponseDto { publishedAt: item.notification.publishedAt?.toISOString() ?? null, isRead: item.isRead, readAt: item.readAt?.toISOString() ?? null, + requiresForceRead: item.notification.requiresForceRead, }; } } diff --git a/backend/services/admin-service/src/domain/entities/notification.entity.ts b/backend/services/admin-service/src/domain/entities/notification.entity.ts index 192021fe..35e988cf 100644 --- a/backend/services/admin-service/src/domain/entities/notification.entity.ts +++ b/backend/services/admin-service/src/domain/entities/notification.entity.ts @@ -23,6 +23,8 @@ export class NotificationEntity { public readonly imageUrl: string | null, public readonly linkUrl: string | null, public readonly isEnabled: boolean, + /** 是否需要强制弹窗阅读(由管理员创建时配置) */ + public readonly requiresForceRead: boolean, public readonly publishedAt: Date | null, public readonly expiresAt: Date | null, public readonly createdAt: Date, @@ -99,6 +101,7 @@ export class NotificationEntity { targetConfig?: NotificationTarget | null; imageUrl?: string | null; linkUrl?: string | null; + requiresForceRead?: boolean; publishedAt?: Date | null; expiresAt?: Date | null; createdBy: string; @@ -128,6 +131,7 @@ export class NotificationEntity { params.imageUrl ?? null, params.linkUrl ?? null, true, + params.requiresForceRead ?? false, params.publishedAt ?? null, params.expiresAt ?? null, now, @@ -149,6 +153,7 @@ export class NotificationEntity { imageUrl?: string | null; linkUrl?: string | null; isEnabled?: boolean; + requiresForceRead?: boolean; publishedAt?: Date | null; expiresAt?: Date | null; }): NotificationEntity { @@ -163,6 +168,7 @@ export class NotificationEntity { params.imageUrl !== undefined ? params.imageUrl : this.imageUrl, params.linkUrl !== undefined ? params.linkUrl : this.linkUrl, params.isEnabled ?? this.isEnabled, + params.requiresForceRead ?? this.requiresForceRead, params.publishedAt !== undefined ? params.publishedAt : this.publishedAt, params.expiresAt !== undefined ? params.expiresAt : this.expiresAt, this.createdAt, diff --git a/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts b/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts index 56938bdb..ef68ea89 100644 --- a/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts +++ b/backend/services/admin-service/src/infrastructure/persistence/mappers/notification.mapper.ts @@ -56,6 +56,7 @@ export class NotificationMapper { prisma.imageUrl, prisma.linkUrl, prisma.isEnabled, + prisma.requiresForceRead, prisma.publishedAt, prisma.expiresAt, prisma.createdAt, @@ -78,6 +79,7 @@ export class NotificationMapper { imageUrl: entity.imageUrl, linkUrl: entity.linkUrl, isEnabled: entity.isEnabled, + requiresForceRead: entity.requiresForceRead, publishedAt: entity.publishedAt, expiresAt: entity.expiresAt, createdAt: entity.createdAt, diff --git a/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx b/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx index b4f32446..8965bfd8 100644 --- a/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx +++ b/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx @@ -84,6 +84,7 @@ export default function NotificationsPage() { userIds: '', imageUrl: '', linkUrl: '', + requiresForceRead: false, publishedAt: '', expiresAt: '', }); @@ -135,6 +136,7 @@ export default function NotificationsPage() { userIds: '', imageUrl: '', linkUrl: '', + requiresForceRead: false, publishedAt: '', expiresAt: '', }); @@ -156,6 +158,7 @@ export default function NotificationsPage() { userIds: notification.targetConfig?.accountSequences?.join('\n') || '', imageUrl: notification.imageUrl || '', linkUrl: notification.linkUrl || '', + requiresForceRead: notification.requiresForceRead ?? false, publishedAt: notification.publishedAt ? notification.publishedAt.slice(0, 16) : '', expiresAt: notification.expiresAt ? notification.expiresAt.slice(0, 16) : '', }); @@ -210,6 +213,7 @@ export default function NotificationsPage() { targetConfig, imageUrl: formData.imageUrl.trim() || undefined, linkUrl: formData.linkUrl.trim() || undefined, + requiresForceRead: formData.requiresForceRead, publishedAt: formData.publishedAt ? new Date(formData.publishedAt).toISOString() : undefined, expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined, }; @@ -360,6 +364,14 @@ export default function NotificationsPage() { <> ({notification.targetConfig.accountSequences.length}个用户) )} + {notification.requiresForceRead && ( + + 强制阅读 + + )} {!notification.isEnabled && ( 已禁用 )} @@ -614,6 +626,24 @@ export default function NotificationsPage() { placeholder="https://..." /> + +
+ + + 勾选后,用户打开 App 时会强制弹窗展示此消息,用户必须勾选"我已经阅读并知晓"后才能关闭 + +
diff --git a/frontend/admin-web/src/services/notificationService.ts b/frontend/admin-web/src/services/notificationService.ts index 83ae555c..8237343c 100644 --- a/frontend/admin-web/src/services/notificationService.ts +++ b/frontend/admin-web/src/services/notificationService.ts @@ -34,6 +34,7 @@ export interface NotificationItem { imageUrl: string | null; linkUrl: string | null; isEnabled: boolean; + requiresForceRead: boolean; publishedAt: string | null; expiresAt: string | null; createdAt: string; @@ -52,6 +53,7 @@ export interface CreateNotificationRequest { }; imageUrl?: string; linkUrl?: string; + requiresForceRead?: boolean; publishedAt?: string; expiresAt?: string; } @@ -70,6 +72,7 @@ export interface UpdateNotificationRequest { imageUrl?: string | null; linkUrl?: string | null; isEnabled?: boolean; + requiresForceRead?: boolean; publishedAt?: string | null; expiresAt?: string | null; } diff --git a/frontend/mobile-app/lib/core/services/notification_service.dart b/frontend/mobile-app/lib/core/services/notification_service.dart index 0e76768d..6faa58dd 100644 --- a/frontend/mobile-app/lib/core/services/notification_service.dart +++ b/frontend/mobile-app/lib/core/services/notification_service.dart @@ -31,6 +31,9 @@ class NotificationItem { final bool isRead; final DateTime? readAt; + /// 是否需要强制弹窗阅读(由管理员创建通知时配置) + final bool requiresForceRead; + NotificationItem({ required this.id, required this.title, @@ -42,6 +45,7 @@ class NotificationItem { this.publishedAt, required this.isRead, this.readAt, + this.requiresForceRead = false, }); factory NotificationItem.fromJson(Map json) { @@ -58,6 +62,7 @@ class NotificationItem { : null, isRead: json['isRead'] ?? false, readAt: json['readAt'] != null ? DateTime.tryParse(json['readAt']) : null, + requiresForceRead: json['requiresForceRead'] ?? false, ); } diff --git a/frontend/mobile-app/lib/features/home/presentation/pages/home_shell_page.dart b/frontend/mobile-app/lib/features/home/presentation/pages/home_shell_page.dart index 014c4783..d5e46013 100644 --- a/frontend/mobile-app/lib/features/home/presentation/pages/home_shell_page.dart +++ b/frontend/mobile-app/lib/features/home/presentation/pages/home_shell_page.dart @@ -6,12 +6,15 @@ import 'package:go_router/go_router.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/di/injection_container.dart'; import '../../../../core/services/contract_check_service.dart'; +import '../../../../core/services/notification_service.dart'; import '../../../../core/updater/update_service.dart'; import '../../../../core/providers/maintenance_provider.dart'; +import '../../../../core/providers/notification_badge_provider.dart'; import '../../../../routes/route_paths.dart'; import '../../../../routes/app_router.dart'; import '../../../../bootstrap.dart'; -import '../../../../core/providers/notification_badge_provider.dart'; +import '../../../auth/presentation/providers/auth_provider.dart'; +import '../../../notification/presentation/widgets/force_read_notification_dialog.dart'; import '../widgets/bottom_nav_bar.dart'; class HomeShellPage extends ConsumerStatefulWidget { @@ -40,6 +43,12 @@ class _HomeShellPageState extends ConsumerState /// 是否正在显示弹窗(防止重复弹窗) bool _isShowingDialog = false; + /// 是否正在显示强制阅读通知弹窗 + bool _isShowingForceReadDialog = false; + + /// 上次显示强制阅读弹窗的时间(60 秒内不重复触发) + static DateTime? _lastForceReadDialogShownAt; + @override void initState() { super.initState(); @@ -49,6 +58,7 @@ class _HomeShellPageState extends ConsumerState _checkMaintenanceStatus(); _checkForUpdateIfNeeded(); _checkContractsAndKyc(); + _checkAndShowForceReadDialog(); // 启动后台定时检查 _startBackgroundContractCheck(); // 启动维护状态定时检查 @@ -74,6 +84,8 @@ class _HomeShellPageState extends ConsumerState _scheduleNextContractCheck(); // 恢复维护检查定时器 _startMaintenanceCheck(); + // 从后台恢复时检查是否有需要强制阅读的通知 + _checkAndShowForceReadDialog(); } else if (state == AppLifecycleState.paused) { // 进入后台时取消定时器 _contractCheckTimer?.cancel(); @@ -364,9 +376,90 @@ class _HomeShellPageState extends ConsumerState _isShowingDialog = false; } + /// 检查并展示需要强制阅读的未读通知(逐条弹出) + /// + /// 仅展示标记了 [requiresForceRead] 且尚未读的通知。 + /// 用户必须逐条查看,最后一条勾选"我已经阅读并知晓"后方可关闭。 + /// 全部确认后逐条调用 markAsRead(按 ID)并刷新真实未读数量角标。 + Future _checkAndShowForceReadDialog() async { + // 防护 1:已有强制阅读弹窗正在展示 + if (_isShowingForceReadDialog) return; + + // 防护 2:60 秒内不重复触发(防止快速切后台后立刻重弹) + if (_lastForceReadDialogShownAt != null && + DateTime.now().difference(_lastForceReadDialogShownAt!) < + const Duration(seconds: 60)) return; + + // 防护 3:用户未登录 + final authState = ref.read(authProvider); + final userSerialNum = authState.userSerialNum; + if (userSerialNum == null) return; + + // 防护 4:其他系统弹窗(合同/KYC/升级)正在展示 + if (_isShowingDialog) return; + + // 获取未读且需要强制阅读的通知 + List forceReadList; + try { + final notifService = ref.read(notificationServiceProvider); + final response = await notifService.getNotifications( + userSerialNum: userSerialNum, + limit: 20, + ); + forceReadList = response.notifications + .where((n) => !n.isRead && n.requiresForceRead) + .toList(); + } catch (_) { + // API 失败时静默处理,不阻断用户使用 + return; + } + + if (forceReadList.isEmpty || !mounted) return; + + _isShowingForceReadDialog = true; + _isShowingDialog = true; // 防止后台合同/KYC 检查在此期间叠加弹窗 + _lastForceReadDialogShownAt = DateTime.now(); + + // 逐条展示强制阅读弹窗 + for (int i = 0; i < forceReadList.length; i++) { + if (!mounted) break; + await ForceReadNotificationDialog.show( + context: context, + notification: forceReadList[i], + currentIndex: i + 1, + totalCount: forceReadList.length, + ); + } + + _isShowingDialog = false; + _isShowingForceReadDialog = false; + + // 全部通知看完后,仅标记本次展示的通知为已读(不影响其他未读普通通知) + if (mounted) { + try { + final notifService = ref.read(notificationServiceProvider); + final currentUserSerialNum = ref.read(authProvider).userSerialNum; + if (currentUserSerialNum != null) { + for (final n in forceReadList) { + await notifService.markAsRead( + userSerialNum: currentUserSerialNum, + notificationId: n.id, + ); + } + // 刷新真实未读数量,而非直接清零(避免误清其他未读普通通知的 badge) + ref.read(notificationBadgeProvider.notifier).refresh(); + } + } catch (_) { + // 标记失败时静默处理;下次打开 App 仍会重新检查 + } + } + } + /// 重置合同检查状态(用于用户切换账号时) static void resetContractCheckState() { _hasCheckedContracts = false; + // 同时重置强制阅读弹窗冷却时间,确保新账号可以立即检查 + _lastForceReadDialogShownAt = null; } int _getCurrentIndex(BuildContext context) { diff --git a/frontend/mobile-app/lib/features/notification/presentation/pages/notification_inbox_page.dart b/frontend/mobile-app/lib/features/notification/presentation/pages/notification_inbox_page.dart index 7a29045d..c34e6c99 100644 --- a/frontend/mobile-app/lib/features/notification/presentation/pages/notification_inbox_page.dart +++ b/frontend/mobile-app/lib/features/notification/presentation/pages/notification_inbox_page.dart @@ -104,6 +104,7 @@ class _NotificationInboxPageState extends ConsumerState { publishedAt: notification.publishedAt, isRead: true, readAt: DateTime.now(), + requiresForceRead: notification.requiresForceRead, ); _unreadCount = (_unreadCount - 1).clamp(0, _unreadCount); } @@ -141,6 +142,7 @@ class _NotificationInboxPageState extends ConsumerState { publishedAt: n.publishedAt, isRead: true, readAt: DateTime.now(), + requiresForceRead: n.requiresForceRead, ); } return n; diff --git a/frontend/mobile-app/lib/features/notification/presentation/widgets/force_read_notification_dialog.dart b/frontend/mobile-app/lib/features/notification/presentation/widgets/force_read_notification_dialog.dart new file mode 100644 index 00000000..c387e11b --- /dev/null +++ b/frontend/mobile-app/lib/features/notification/presentation/widgets/force_read_notification_dialog.dart @@ -0,0 +1,288 @@ +import 'package:flutter/material.dart'; +import '../../../../core/services/notification_service.dart'; + +/// 强制阅读通知弹窗 +/// +/// 用于在用户打开 App 时强制展示标记了 [requiresForceRead] 的未读通知。 +/// 用户必须逐条查看,并在最后一条勾选"我已经阅读并知晓"后才能关闭。 +class ForceReadNotificationDialog extends StatefulWidget { + final NotificationItem notification; + final int currentIndex; + final int totalCount; + + const ForceReadNotificationDialog._({ + super.key, + required this.notification, + required this.currentIndex, + required this.totalCount, + }); + + /// 显示单条强制阅读弹窗。 + /// + /// [currentIndex] 和 [totalCount] 用于显示进度(如 "2/5")。 + /// 当 [currentIndex] == [totalCount] 时为最后一条,显示 checkbox + 确定按钮。 + static Future show({ + required BuildContext context, + required NotificationItem notification, + required int currentIndex, + required int totalCount, + }) { + return showDialog( + context: context, + barrierDismissible: false, + barrierColor: const Color(0x99000000), + builder: (context) => ForceReadNotificationDialog._( + notification: notification, + currentIndex: currentIndex, + totalCount: totalCount, + ), + ); + } + + @override + State createState() => + _ForceReadNotificationDialogState(); +} + +class _ForceReadNotificationDialogState + extends State { + bool _isAcknowledged = false; + + bool get _isLast => widget.currentIndex == widget.totalCount; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420, maxHeight: 580), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── 顶部标题栏 ── + _buildHeader(), + const Divider(height: 1, color: Color(0xFFEEEEEE)), + + // ── 通知标题 ── + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 6), + child: Text( + widget.notification.title, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: Color(0xFF3E2723), + height: 1.4, + ), + ), + ), + + // ── 可滚动内容区 ── + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.notification.content, + style: const TextStyle( + fontSize: 14, + color: Color(0xFF5D4037), + height: 1.7, + ), + ), + if (widget.notification.publishedAt != null) ...[ + const SizedBox(height: 12), + Text( + _formatTime(widget.notification.publishedAt!), + style: const TextStyle( + fontSize: 12, + color: Color(0xFFBDBDBD), + ), + ), + ], + ], + ), + ), + ), + + const Divider(height: 1, color: Color(0xFFEEEEEE)), + + // ── 底部操作区 ── + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 最后一条才显示 checkbox + if (_isLast) ...[ + _buildAcknowledgeCheckbox(), + const SizedBox(height: 12), + ], + _buildActionButton(context), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 16, 14), + child: Row( + children: [ + // 类型图标圆圈 + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: const Color(0xFFFFF8E1), + borderRadius: BorderRadius.circular(19), + ), + child: Center( + child: Text( + widget.notification.typeIcon, + style: const TextStyle(fontSize: 18), + ), + ), + ), + const SizedBox(width: 10), + + // 类型名称 + Expanded( + child: Text( + widget.notification.typeName, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ), + + // 进度指示器(多条时才显示) + if (widget.totalCount > 1) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFFFF8E1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFD4AF37).withValues(alpha: 0.5), + ), + ), + child: Text( + '${widget.currentIndex}/${widget.totalCount}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Color(0xFFD4AF37), + ), + ), + ), + ], + ), + ); + } + + Widget _buildAcknowledgeCheckbox() { + return InkWell( + onTap: () => setState(() => _isAcknowledged = !_isAcknowledged), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: _isAcknowledged, + onChanged: (v) => + setState(() => _isAcknowledged = v ?? false), + activeColor: const Color(0xFFD4AF37), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + '我已经阅读并知晓', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF5D4037), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildActionButton(BuildContext context) { + // 非最后一条:始终可点"下一条";最后一条:需要勾选 checkbox + final isEnabled = !_isLast || _isAcknowledged; + + return SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: isEnabled ? () => Navigator.of(context).pop() : null, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4AF37), + foregroundColor: Colors.white, + disabledBackgroundColor: + const Color(0xFFD4AF37).withValues(alpha: 0.35), + disabledForegroundColor: Colors.white.withValues(alpha: 0.6), + elevation: isEnabled ? 2 : 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + _isLast ? '确定' : '下一条 ▶', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + String _formatTime(DateTime time) { + return '${time.year}-' + '${time.month.toString().padLeft(2, '0')}-' + '${time.day.toString().padLeft(2, '0')} ' + '${time.hour.toString().padLeft(2, '0')}:' + '${time.minute.toString().padLeft(2, '0')}'; + } +}