feat(notification): 新增强制阅读弹窗功能(管理员可配置 requiresForceRead)

## 功能概述

在不影响任何现有业务的前提下,新增"强制阅读弹窗"功能:
- 管理员创建通知时可勾选「需要强制弹窗阅读」
- 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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-27 19:33:51 -08:00
parent 2684a81383
commit 1d1c60e2a2
13 changed files with 451 additions and 2 deletions

View File

@ -0,0 +1,5 @@
-- Migration: add requiresForceRead to notifications
-- 为通知表添加"是否需要强制弹窗阅读"字段
-- 管理员可在创建通知时配置此字段,标记为 true 的通知将在用户打开 App 时强制弹窗展示
ALTER TABLE "notifications" ADD COLUMN "requiresForceRead" BOOLEAN NOT NULL DEFAULT false;

View File

@ -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())

View File

@ -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)

View File

@ -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;

View File

@ -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,
};
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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})</>
)}
</span>
{notification.requiresForceRead && (
<span
className={styles.notifications__tag}
style={{ backgroundColor: '#fee2e2', color: '#b91c1c', borderColor: '#fca5a5' }}
>
</span>
)}
{!notification.isEnabled && (
<span className={styles.notifications__disabled}></span>
)}
@ -614,6 +626,24 @@ export default function NotificationsPage() {
placeholder="https://..."
/>
</div>
<div className={styles.notifications__formGroup}>
<label
style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}
onClick={() => setFormData({ ...formData, requiresForceRead: !formData.requiresForceRead })}
>
<input
type="checkbox"
checked={formData.requiresForceRead}
onChange={(e) => setFormData({ ...formData, requiresForceRead: e.target.checked })}
style={{ width: '16px', height: '16px', cursor: 'pointer' }}
/>
<span></span>
</label>
<span className={styles.notifications__formHint}>
App "我已经阅读并知晓"
</span>
</div>
</div>
</Modal>

View File

@ -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;
}

View File

@ -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<String, dynamic> 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,
);
}

View File

@ -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<HomeShellPage>
///
bool _isShowingDialog = false;
///
bool _isShowingForceReadDialog = false;
/// 60
static DateTime? _lastForceReadDialogShownAt;
@override
void initState() {
super.initState();
@ -49,6 +58,7 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
_checkMaintenanceStatus();
_checkForUpdateIfNeeded();
_checkContractsAndKyc();
_checkAndShowForceReadDialog();
//
_startBackgroundContractCheck();
//
@ -74,6 +84,8 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
_scheduleNextContractCheck();
//
_startMaintenanceCheck();
//
_checkAndShowForceReadDialog();
} else if (state == AppLifecycleState.paused) {
//
_contractCheckTimer?.cancel();
@ -364,9 +376,90 @@ class _HomeShellPageState extends ConsumerState<HomeShellPage>
_isShowingDialog = false;
}
///
///
/// [requiresForceRead]
/// "我已经阅读并知晓"
/// markAsRead ID
Future<void> _checkAndShowForceReadDialog() async {
// 1
if (_isShowingForceReadDialog) return;
// 260
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<NotificationItem> 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) {

View File

@ -104,6 +104,7 @@ class _NotificationInboxPageState extends ConsumerState<NotificationInboxPage> {
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<NotificationInboxPage> {
publishedAt: n.publishedAt,
isRead: true,
readAt: DateTime.now(),
requiresForceRead: n.requiresForceRead,
);
}
return n;

View File

@ -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<void> show({
required BuildContext context,
required NotificationItem notification,
required int currentIndex,
required int totalCount,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: const Color(0x99000000),
builder: (context) => ForceReadNotificationDialog._(
notification: notification,
currentIndex: currentIndex,
totalCount: totalCount,
),
);
}
@override
State<ForceReadNotificationDialog> createState() =>
_ForceReadNotificationDialogState();
}
class _ForceReadNotificationDialogState
extends State<ForceReadNotificationDialog> {
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')}';
}
}