Files
App.K12.Student.Miniapp_Flu…/lib/features/my/my_page.dart
T
崔宝新 c0c673e2d2 feat: 新增我的报告模块,集成inappwebview支持H5详情页
1.  添加flutter_inappwebview依赖并配置各平台插件注册
2.  新增我的报告列表页、详情WebView页
3.  新增报告模块API和数据模型
4.  配置路由跳转,修复我的菜单跳转逻辑
5.  添加AGP9兼容补丁修复插件构建问题
6.  注释掉登录成功的SnackBar提示
2026-09-09 11:08:47 +08:00

613 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: <scroll-view scroll-y style="flex:1"> 内部先放 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 行:<view style="height: 80rpx;"></view>
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('/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<void> _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<void> _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<void> _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),
],
),
),
);
}
}