import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/components/confirm_box.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/config/app_env.dart'; import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/network/oss_api.dart'; import 'package:new_learn/core/network/oss_uploader.dart'; import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/services/image_processor.dart'; import 'package:new_learn/core/services/picker_service.dart'; import 'package:new_learn/core/storage/user_storage.dart'; import 'package:new_learn/core/api/auth/api.dart'; /// ============================================================ /// 【MyPage】「我的」tab 页 /// /// 一、本页结构(Column): /// 渐变头部(用户信息)→ Expanded(SingleChildScrollView(菜单)) /// 固定头部 + 菜单可滚动,是「我的」页经典布局。 /// /// 二、当前菜单: /// 暂时只保留【我的积分】入口(点击 context.push('/myPoint') 进积分页), /// 后续菜单项一个个加回来,点击各自 push 对应页面。 /// /// 三、Riverpod 全局用户: /// 头部头像/用户名/班级/积分全部来自 currentUserProvider(登录后 /// login_page 已 set),本页 ref.watch 自动响应更新,不再写死。 /// /// 四、屏幕适配(flutter_screenutil): /// 所有尺寸按设计稿 375x812 适配:.w 宽/.h 高/.sp 字号/.r 圆角。 /// /// 五、高频坑: /// 1. SingleChildScrollView 里不要嵌套无界高度的 ListView; /// 2. 菜单项统一 64.h 高,点击区用 InkWell 包满整行; /// 3. 加了 .w/.h/.sp 的 widget 不能再用 const(值是运行时计算)。 /// ============================================================ class MyPage extends ConsumerWidget { const MyPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // 全局用户(登录后自动有值) final user = ref.watch(currentUserProvider).value; return Scaffold( backgroundColor: const Color(0xFFF5F8FA), // 整个 body 用 SingleChildScrollView 包 —— 完全对齐 Vue 的 scroll-view flex:1 结构 // Vue: 内部先放 HeaderBac 再放卡片区 body: LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight), child: Column( children: [ // 头部背景:和 Vue HeaderBac 完全一致 // Stack 高度由内容决定,Positioned 背景图溢出不裁(Clip.none) // → 渐变尾巴"飘"到下方,卡片紧贴 Stack 底部 = 浮在渐变上 Stack( clipBehavior: Clip.none, children: [ // 底层:skyBlue 主题渐变背景图(Positioned 不绑 bottom,不撑 Stack 高度) Positioned( top: 0, left: 0, right: 0, child: Image.network( '${AppEnv.imgBaseUrl}/StaticResource/2026-05-26/my-header-bg-main-skyBlue1779778426425.png', width: double.infinity, fit: BoxFit.fitWidth, errorBuilder: (_, __, ___) => const SizedBox.shrink(), ), ), // 上层:用户信息(SafeArea 避开状态栏),决定 Stack 高度 SafeArea( bottom: false, child: Column( children: [ _UserHeader( name: user?.userName ?? '未登录', className: user?.className ?? '-', avatar: user?.avatar ?? '', onLogout: () => _confirmLogout(context, ref), ), // Vue my.vue 第 30 行: SizedBox(height: 40.h), ], ), ), ], ), // 菜单卡片区:紧贴头部(Stack clipBehavior:none 让渐变尾巴飘到这里) SizedBox(height: 8.h), // 第一组:学习功能菜单 _MenuCard( items: [ _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png', title: '我的考试', onTap: () { context.push('/myExam'); }, ), _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png', title: '我的报告', onTap: () { context.push('/myReport'); }, ), _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/note.png', title: '我的笔记', onTap: () { // TODO: 跳转到我的笔记页 }, ), _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/error.png', title: '错题本', onTap: () { // TODO: 跳转到错题本页 }, ), _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/collect.png', title: '我的收藏', onTap: () { // TODO: 跳转到我的收藏页 }, ), _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/word.png', title: '生词本', onTap: () { // TODO: 跳转到生词本页 }, ), ], ), SizedBox(height: 16.h), // 第二组:个人数据菜单 _MenuCard( items: [ _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-04-10/point.png', title: '我的积分', trailingText: '${user?.userPoints ?? 0}积分', onTap: () => context.push('/myPoint'), ), _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-04-10/interact.png', title: '我的互动', onTap: () => context.push('/class/my-interact'), ), ], ), SizedBox(height: 16.h), // 第三组:设置(Vue my.vue #ifdef APP-PLUS 专属,Flutter 始终显示) _MenuCard( items: [ _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-08-20/setting1787192171875.png', title: '设置', onTap: () => context.push('/settings'), ), ], ), SizedBox(height: 24.h), ], ), ), ); }, ), ); } } /// 退出登录:用公共 ConfirmBox 二次确认 → 清 token → 清全局登录态 → 守卫自动踢回登录页 /// /// 对齐 Vue my.vue: `proxy.$confirm({ title: '是否退出登录', confirmText: '确定', cancelText: '取消' })` Future _confirmLogout(BuildContext context, WidgetRef ref) async { final ok = await showConfirmBox( context, title: '是否退出登录', confirmText: '确定', cancelText: '取消', ); if (!ok) return; // ① 清内存 Token:Dio 拦截器立刻停止携带 Authorization DioClient.instance.accessToken = null; // ② 清磁盘缓存:安全存储的 token + prefs 里的用户信息(记住密码保留) await UserStorage.instance.clearAll(); // ③ 清全局登录态 → set(null) 内部 authTick.value++ // → GoRouter.refresh() → 守卫 redirect 把当前页踢回 /login。 // 这里不需要手动 context.go('/login'),守卫会自动接管。 ref.read(currentUserProvider.notifier).set(null); } /// 用户信息头部:圆形头像 + 用户名 + 班级 /// /// 头像显示三级优先: /// 1. 刚选中的本地新头像(上传成功后替换为服务器 URL) /// 2. 服务器返回的 avatar 网络图(登录/缓存里有 URL) /// 3. 默认头像:用户名首字圆(还没上传过头像时兜底) /// /// 点击头像 → 弹出「拍照/从相册」→ 选完 OSS 直传(场景 avatar) /// → 上传成功更新全局用户头像(currentUserProvider + 本地缓存)。 class _UserHeader extends ConsumerStatefulWidget { const _UserHeader({ required this.name, required this.className, required this.avatar, required this.onLogout, }); final String name; final String className; final String avatar; final VoidCallback onLogout; @override ConsumerState<_UserHeader> createState() => _UserHeaderState(); } class _UserHeaderState extends ConsumerState<_UserHeader> { /// 点击头像 → 直接触发选图上传(对齐 Vue my.vue changeAvatar 逻辑,无预览中间步骤) Future _onAvatarTap() async { await _pickAndUploadAvatar(); } /// 选择头像并上传(对齐 Vue my.vue 的 changeAvatar 完整链路): /// 1. showPickSheet 选图(相册+拍照,内部已含权限请求) /// 2. ImageProcessor 压缩 + EXIF 方向修正 /// 3. 显示 loading 遮罩(对应 Vue uni.showLoading '上传中...') /// 4. OssUploader.uploadImage 直传 OSS /// 5. UserApi.updateUserAvatar 调业务接口更新头像字段 /// 6. 弹审核确认框(对应 Vue proxy.$confirm) /// 注意:不上传后立刻刷新用户信息,头像保持旧的,等后端审核通过后自然生效 Future _pickAndUploadAvatar() async { final picked = await PickerService.instance.showPickSheet( context, maxCount: 1, ); if (picked == null || picked.isEmpty) return; final image = picked.first; // ① 读图片字节(Web 走 bytes;原生走 originBytes) final bytes = image.bytes ?? await image.entity?.originBytes; if (bytes == null || bytes.isEmpty) { _toast('读取图片失败,请重试'); return; } // ② 压缩 + EXIF 方向修正 final processed = await ImageProcessor.process( bytes: bytes, fileName: image.name, maxDimension: 512, ); if (processed == null) { _toast('图片尺寸不符合要求,请重新选择'); return; } // ③ 显示 loading 遮罩(对应 Vue uni.showLoading) if (!mounted) return; _showLoading('上传中...'); try { final fileName = _replaceExtension( image.name ?? 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg', processed.extension, ); debugPrint( '头像处理:${processed.originalBytes}B -> ${processed.processedBytes}B ' '(${processed.contentType}, $fileName)', ); // ④ OSS 直传 final url = await OssUploader.uploadImage( bytes: processed.bytes, fileName: fileName, contentType: processed.contentType, scene: UploadScene.avatar, ); // ⑤ 业务接口更新头像字段(对应 Vue UpdateUserAvatar({ avatar: url })) await UserApi.updateUserAvatar(url); // ⑥ 关闭 loading,弹审核确认框(对应 Vue proxy.$confirm) if (!mounted) return; _hideLoading(); await showConfirmBox( context, title: '上传成功,等待审核完成会自动生效', confirmText: '确定', cancelText: '取消', ); } catch (e) { if (!mounted) return; _hideLoading(); _toast('笔记上传失败,请检查网络后重试'); } } /// 显示全局 loading 遮罩(对应 Vue uni.showLoading) void _showLoading(String message) { showDialog( context: context, barrierDismissible: false, builder: (_) => PopScope( canPop: false, child: Center( child: Container( padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 24.h), decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius.circular(12.r), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(color: Colors.white), SizedBox(height: 16.h), Text( message, style: TextStyle(color: Colors.white, fontSize: 14.sp), ), ], ), ), ), ), ); } void _hideLoading() { if (Navigator.of(context, rootNavigator: true).canPop()) { Navigator.of(context, rootNavigator: true).pop(); } } /// 轻提示(统一用 SnackBar) void _toast(String message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message), duration: const Duration(seconds: 2)), ); } /// 头像主体:网络头像 → 默认首字圆 二级渲染 Widget _buildAvatar() { final size = 62.w; // 一级:服务器返回的网络头像(加载失败自动降级到默认头像) if (widget.avatar.isNotEmpty) { return ClipOval( child: Image.network( widget.avatar, width: size, height: size, fit: BoxFit.cover, errorBuilder: (_, _, _) => _buildDefaultAvatar(), ), ); } // 二级:默认头像(首字圆) return _buildDefaultAvatar(); } /// 默认头像:用户名首字圆形色块 Widget _buildDefaultAvatar() { final avatarChar = widget.name.isNotEmpty ? widget.name.characters.first : '?'; return Container( width: 62.w, height: 62.h, decoration: const BoxDecoration( shape: BoxShape.circle, color: Color(0xFF4A90E2), ), alignment: Alignment.center, child: Text( avatarChar, style: TextStyle( fontSize: 26.sp, color: Colors.white, fontWeight: FontWeight.bold, ), ), ); } @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 0), child: Row( children: [ // 头像(点击可更换) GestureDetector( onTap: _onAvatarTap, child: Stack( children: [ _buildAvatar(), // 右下角小相机角标,暗示可点击换头像 Positioned( right: 0, bottom: 0, child: Container( width: 20.w, height: 20.h, decoration: const BoxDecoration( shape: BoxShape.circle, color: Color(0xFF4A90E2), border: Border.fromBorderSide( BorderSide(color: Colors.white, width: 2), ), ), child: Icon( Icons.photo_camera, size: 11.w, color: Colors.white, ), ), ), ], ), ), SizedBox(width: 12.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.name, style: TextStyle( fontSize: 20.sp, fontWeight: FontWeight.bold, color: const Color(0xFF1A1A1A), ), ), SizedBox(height: 4.h), Text( widget.className, style: TextStyle( fontSize: 13.sp, color: const Color(0xFF8A8A8A), ), ), ], ), ), // 右上角退出按钮(和 Vue my.vue 的 logout-btn 对齐) _LogoutButton(onTap: widget.onLogout), ], ), ); } } String _replaceExtension(String fileName, String extension) { final dotIndex = fileName.lastIndexOf('.'); final safeExtension = extension.toLowerCase(); if (dotIndex <= 0) return '$fileName.$safeExtension'; return '${fileName.substring(0, dotIndex)}.$safeExtension'; } /// 头部右上角的退出按钮(和 Vue my.vue 的 logout-btn 对齐) /// /// 样式:半透明白色渐变 + 白色边框 + 圆角胶囊,内含"退出"文字+logout 图标。 class _LogoutButton extends StatelessWidget { const _LogoutButton({required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { const iconUrl = '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/logout.png'; return GestureDetector( onTap: onTap, child: Container( width: 72.w, height: 32.h, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16.r), border: Border.all(color: Colors.white, width: 1), gradient: const LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [Color(0x00FFFFFF), Color(0x80FFFFFF)], ), boxShadow: const [ BoxShadow( color: Color(0x0D000000), blurRadius: 10, offset: Offset(0, 5), ), ], ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '退出', style: TextStyle(fontSize: 14.sp, color: const Color(0xFF687FAD)), ), SizedBox(width: 3.w), Image.network( iconUrl, width: 18.w, height: 18.h, errorBuilder: (_, __, ___) => const SizedBox.shrink(), ), ], ), ), ); } } /// 菜单列表项数据 class _MenuItem { const _MenuItem({ required this.imageUrl, required this.title, required this.onTap, this.trailingText, }); final String imageUrl; // 图标(CDN 图片 URL,和 Vue 端保持一致) final String title; final VoidCallback onTap; // 点击跳转 final String? trailingText; // 右侧文字,如 "382积分" } /// 圆角白色卡片菜单容器 class _MenuCard extends StatelessWidget { const _MenuCard({required this.items}); final List<_MenuItem> items; @override Widget build(BuildContext context) { return Container( margin: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16.r), ), child: Column( children: [for (int i = 0; i < items.length; i++) _buildRow(items[i])], ), ); } Widget _buildRow(_MenuItem item) { return InkWell( onTap: item.onTap, child: SizedBox( height: 64.h, child: Row( children: [ SizedBox(width: 16.w), // 左侧图标(和 Vue 端一致的 CDN 图片) Image.network( item.imageUrl, width: 34.w, height: 34.h, fit: BoxFit.contain, errorBuilder: (_, __, ___) => SizedBox(width: 34.w, height: 34.h), ), SizedBox(width: 12.w), // 中间标题 Expanded( child: Text( item.title, style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w500, color: const Color(0xFF333333), ), ), ), // 右侧文字(如积分) if (item.trailingText != null) ...[ Text( item.trailingText!, style: TextStyle( fontSize: 13.sp, color: const Color(0xFF999999), ), ), SizedBox(width: 2.w), ], // 右侧浅蓝色箭头 const Icon(Icons.chevron_right, size: 22, color: Color(0xFF7FB0F2)), SizedBox(width: 10.w), ], ), ), ); } }