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')}';
+ }
+}