1. 添加flutter_inappwebview依赖并配置各平台插件注册 2. 新增我的报告列表页、详情WebView页 3. 新增报告模块API和数据模型 4. 配置路由跳转,修复我的菜单跳转逻辑 5. 添加AGP9兼容补丁修复插件构建问题 6. 注释掉登录成功的SnackBar提示
613 lines
21 KiB
Dart
613 lines
21 KiB
Dart
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),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|