From 99db8e5cb04cb997917b7786d1cf02a4ea2fc4d5 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 3 Jun 2026 10:25:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E5=AE=B6=E9=95=BF=E5=90=8C?= =?UTF-8?q?=E6=84=8F=E9=AA=8C=E8=AF=81=E6=B5=81=E7=A8=8B=20=E2=80=94=20PIP?= =?UTF-8?q?L=20=E7=AC=AC28=E6=9D=A1=E5=90=88=E8=A7=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ParentalConsentPage: 显示隐私政策要点 + 双重确认复选框 - 角色选择流程: 学生 → 家长同意确认 → 班级码输入 - Authenticated 状态: 添加 needsParentalConsent/parentalConsentAt/selectedRole - ParentalConsentAccepted 事件: 记录同意时间戳 - 路由守卫: 注册 /parental-consent 路径和重定向逻辑 - 非学生角色(老师/家长/独立用户)不需要经过同意流程 审计 ID: S-03 --- app/lib/core/routing/app_router.dart | 16 +- app/lib/features/auth/bloc/auth_bloc.dart | 27 +- app/lib/features/auth/bloc/auth_event.dart | 6 + app/lib/features/auth/bloc/auth_state.dart | 19 ++ .../auth/views/parental_consent_page.dart | 254 ++++++++++++++++++ 5 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 app/lib/features/auth/views/parental_consent_page.dart diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index 75cf447..294953b 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -28,6 +28,7 @@ import '../../features/profile/views/profile_page.dart'; import '../../features/editor/views/editor_page.dart'; import '../../features/auth/views/login_page.dart'; import '../../features/auth/views/role_selection_page.dart'; +import '../../features/auth/views/parental_consent_page.dart'; import '../../features/auth/views/class_code_join_page.dart'; import '../../features/onboarding/views/splash_page.dart'; import '../../features/onboarding/views/onboarding_page.dart'; @@ -49,7 +50,7 @@ final _rootNavigatorKey = GlobalKey(); final _shellNavigatorKey = GlobalKey(); /// 不需要认证的白名单路径 -const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/class-code']; +const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/parental-consent', '/class-code']; /// 创建路由配置 — 需要注入 AuthBloc GoRouter createAppRouter(AuthBloc authBloc) { @@ -74,6 +75,7 @@ GoRouter createAppRouter(AuthBloc authBloc) { // 已认证 + 访问公开页面 → 根据状态重定向 if (isAuthenticated && isPublicPath) { if (authState.needsRoleSelection) return '/role-selection'; + if (authState.needsParentalConsent) return '/parental-consent'; if (authState.needsClassCode) return '/class-code'; return '/home'; } @@ -83,9 +85,14 @@ GoRouter createAppRouter(AuthBloc authBloc) { if (authState.needsRoleSelection && currentPath != '/role-selection') { return '/role-selection'; } + if (authState.needsParentalConsent && + currentPath != '/parental-consent') { + return '/parental-consent'; + } if (authState.needsClassCode && currentPath != '/class-code' && - currentPath != '/role-selection') { + currentPath != '/role-selection' && + currentPath != '/parental-consent') { return '/class-code'; } return null; @@ -125,6 +132,11 @@ GoRouter createAppRouter(AuthBloc authBloc) { name: 'roleSelection', builder: (context, state) => const RoleSelectionPage(), ), + GoRoute( + path: '/parental-consent', + name: 'parentalConsent', + builder: (context, state) => const ParentalConsentPage(), + ), GoRoute( path: '/class-code', name: 'classCode', diff --git a/app/lib/features/auth/bloc/auth_bloc.dart b/app/lib/features/auth/bloc/auth_bloc.dart index 389c077..8c105fe 100644 --- a/app/lib/features/auth/bloc/auth_bloc.dart +++ b/app/lib/features/auth/bloc/auth_bloc.dart @@ -34,6 +34,7 @@ class AuthBloc extends Bloc { on(_onLoginRequested); on(_onRegisterRequested); on(_onRoleSelected); + on(_onParentalConsentAccepted); on(_onClassCodeSubmitted); on(_onLogoutRequested); on(_onTokenRefreshed); @@ -124,16 +125,38 @@ class AuthBloc extends Bloc { final currentState = state; if (currentState is! Authenticated) return; + // 学生角色需要先经过家长同意确认(PIPL 第28条) + final needsParentalConsent = event.role == UserRoleType.student; + // 根据角色决定下一步 final needsClassCode = event.role == UserRoleType.student || event.role == UserRoleType.parent; emit(currentState.copyWith( needsRoleSelection: false, - needsClassCode: needsClassCode, + needsParentalConsent: needsParentalConsent, + needsClassCode: needsClassCode && !needsParentalConsent, + selectedRole: event.role, )); - _logger.i('角色选择: ${event.role.name}, 需要班级码: $needsClassCode'); + _logger.i('角色选择: ${event.role.name}, 需要家长同意: $needsParentalConsent'); + } + + /// 家长/监护人同意信息收集(PIPL 合规) + Future _onParentalConsentAccepted( + ParentalConsentAccepted event, + Emitter emit, + ) async { + final currentState = state; + if (currentState is! Authenticated) return; + + _logger.i('家长同意已确认: ${event.consentAt}'); + + emit(currentState.copyWith( + needsParentalConsent: false, + needsClassCode: true, + parentalConsentAt: event.consentAt, + )); } /// 班级码加入 diff --git a/app/lib/features/auth/bloc/auth_event.dart b/app/lib/features/auth/bloc/auth_event.dart index 0556cfe..e7a62c2 100644 --- a/app/lib/features/auth/bloc/auth_event.dart +++ b/app/lib/features/auth/bloc/auth_event.dart @@ -43,6 +43,12 @@ final class RoleSelected extends AuthEvent { const RoleSelected(this.role); } +/// 家长/监护人同意 PIPL 信息收集(审计 S-03) +final class ParentalConsentAccepted extends AuthEvent { + final DateTime consentAt; + const ParentalConsentAccepted(this.consentAt); +} + /// 班级码加入(学生/家长加入班级) final class ClassCodeSubmitted extends AuthEvent { final String classCode; diff --git a/app/lib/features/auth/bloc/auth_state.dart b/app/lib/features/auth/bloc/auth_state.dart index 7b2191e..41dcc68 100644 --- a/app/lib/features/auth/bloc/auth_state.dart +++ b/app/lib/features/auth/bloc/auth_state.dart @@ -37,6 +37,9 @@ final class Authenticated extends AuthState { /// 是否需要角色选择(新注册用户还没有角色) final bool needsRoleSelection; + /// 是否需要家长/监护人同意(PIPL 第28条 — 学生角色) + final bool needsParentalConsent; + /// 是否需要班级码加入(学生/家长角色) final bool needsClassCode; @@ -46,27 +49,43 @@ final class Authenticated extends AuthState { /// 班级码验证错误信息 final String? classCodeError; + /// 已选择的角色(角色选择后暂存) + final UserRoleType? selectedRole; + + /// 家长同意时间戳 + final DateTime? parentalConsentAt; + const Authenticated({ required this.user, this.needsRoleSelection = false, + this.needsParentalConsent = false, this.needsClassCode = false, this.isLoading = false, this.classCodeError, + this.selectedRole, + this.parentalConsentAt, }); Authenticated copyWith({ User? user, bool? needsRoleSelection, + bool? needsParentalConsent, bool? needsClassCode, bool? isLoading, String? classCodeError, + UserRoleType? selectedRole, + DateTime? parentalConsentAt, }) => Authenticated( user: user ?? this.user, needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection, + needsParentalConsent: + needsParentalConsent ?? this.needsParentalConsent, needsClassCode: needsClassCode ?? this.needsClassCode, isLoading: isLoading ?? this.isLoading, classCodeError: classCodeError, + selectedRole: selectedRole ?? this.selectedRole, + parentalConsentAt: parentalConsentAt ?? this.parentalConsentAt, ); } diff --git a/app/lib/features/auth/views/parental_consent_page.dart b/app/lib/features/auth/views/parental_consent_page.dart new file mode 100644 index 0000000..14b63f1 --- /dev/null +++ b/app/lib/features/auth/views/parental_consent_page.dart @@ -0,0 +1,254 @@ +// 家长同意确认页面 — PIPL 第28条合规 +// +// 未满 14 岁用户选择"学生"角色后,必须经过家长/监护人确认。 +// 页面展示隐私政策要点,要求家长勾选同意并确认。 + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/constants/design_tokens.dart'; +import '../../../core/theme/app_radius.dart'; +import '../bloc/auth_bloc.dart'; + +/// 家长同意确认页面 +class ParentalConsentPage extends StatefulWidget { + const ParentalConsentPage({super.key}); + + @override + State createState() => _ParentalConsentPageState(); +} + +class _ParentalConsentPageState extends State { + bool _consentGiven = false; + bool _privacyPolicyAccepted = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final canProceed = _consentGiven && _privacyPolicyAccepted; + + return Scaffold( + backgroundColor: theme.colorScheme.surface, + appBar: AppBar( + title: const Text('家长/监护人确认'), + backgroundColor: theme.colorScheme.surface, + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(DesignTokens.spacing16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Icon( + Icons.shield_rounded, + size: 48, + color: theme.colorScheme.primary, + ), + const SizedBox(height: DesignTokens.spacing12), + Text( + '儿童个人信息保护', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: DesignTokens.spacing8), + Text( + '根据《中华人民共和国个人信息保护法》第28条,' + '未满14周岁未成年人的个人信息处理需要取得父母或监护人的同意。', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: DesignTokens.spacing24), + + // 信息收集说明卡片 + _buildInfoCard( + context, + icon: Icons.info_outline_rounded, + title: '我们会收集哪些信息', + items: const [ + '昵称和年级(不收集真实姓名和身份证号)', + '日记内容和手写笔画', + '心情标签和照片', + ], + ), + const SizedBox(height: DesignTokens.spacing12), + + // 用途说明卡片 + _buildInfoCard( + context, + icon: Icons.security_rounded, + title: '信息如何保护', + items: const [ + '所有数据加密存储和传输', + '仅用于日记记录和班级互动', + '不会用于商业广告或分享给第三方', + '您可以随时查阅、更正或删除孩子数据', + ], + ), + const SizedBox(height: DesignTokens.spacing24), + + // 同意复选框 + _buildCheckbox( + value: _privacyPolicyAccepted, + onChanged: (v) => + setState(() => _privacyPolicyAccepted = v ?? false), + text: '我已阅读并同意《暖记隐私政策》和《儿童个人信息保护规则》', + ), + const SizedBox(height: DesignTokens.spacing4), + + _buildCheckbox( + value: _consentGiven, + onChanged: (v) => setState(() => _consentGiven = v ?? false), + text: '我是该用户的家长/监护人,同意暖记收集和处理上述信息', + ), + const SizedBox(height: DesignTokens.spacing32), + + // 确认按钮 + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: canProceed ? _onConfirm : null, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.spacing12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.pill), + ), + ), + child: const Text('确认同意,继续'), + ), + ), + const SizedBox(height: DesignTokens.spacing8), + + // 拒绝按钮 + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => context.pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.spacing12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.pill), + ), + ), + child: const Text('不同意,返回'), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildInfoCard( + BuildContext context, { + required IconData icon, + required String title, + required List items, + }) { + final theme = Theme.of(context); + return Card( + elevation: 0, + color: theme.colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Padding( + padding: const EdgeInsets.all(DesignTokens.spacing12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: DesignTokens.spacing8), + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.spacing8), + ...items.map( + (item) => Padding( + padding: const EdgeInsets.only( + bottom: DesignTokens.spacing4, + left: DesignTokens.spacing12, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '• ', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + Expanded( + child: Text( + item, + style: theme.textTheme.bodySmall, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildCheckbox({ + required bool value, + required ValueChanged onChanged, + required String text, + }) { + final theme = Theme.of(context); + return InkWell( + onTap: () => onChanged(!value), + borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.spacing4, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: value, + onChanged: onChanged, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + const SizedBox(width: DesignTokens.spacing4), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + text, + style: theme.textTheme.bodySmall, + ), + ), + ), + ], + ), + ), + ); + } + + /// 确认同意 — 发出事件继续注册流程 + void _onConfirm() { + final consentAt = DateTime.now(); + context.read().add(ParentalConsentAccepted(consentAt)); + } +}