diff --git a/assets/img/my/close-icon.png b/assets/img/my/close-icon.png new file mode 100644 index 0000000..4a8f422 Binary files /dev/null and b/assets/img/my/close-icon.png differ diff --git a/assets/img/my/correct-active-icon.png b/assets/img/my/correct-active-icon.png new file mode 100644 index 0000000..8ab986a Binary files /dev/null and b/assets/img/my/correct-active-icon.png differ diff --git a/assets/img/my/correct-inactive-icon.png b/assets/img/my/correct-inactive-icon.png new file mode 100644 index 0000000..af6b041 Binary files /dev/null and b/assets/img/my/correct-inactive-icon.png differ diff --git a/assets/img/my/download-icon.png b/assets/img/my/download-icon.png new file mode 100644 index 0000000..d819877 Binary files /dev/null and b/assets/img/my/download-icon.png differ diff --git a/assets/img/my/fork-icon.png b/assets/img/my/fork-icon.png new file mode 100644 index 0000000..1249f8c Binary files /dev/null and b/assets/img/my/fork-icon.png differ diff --git a/assets/img/my/remove-icon.png b/assets/img/my/remove-icon.png new file mode 100644 index 0000000..cd25c7b Binary files /dev/null and b/assets/img/my/remove-icon.png differ diff --git a/assets/img/my/star-icon.png b/assets/img/my/star-icon.png new file mode 100644 index 0000000..8a9ecd5 Binary files /dev/null and b/assets/img/my/star-icon.png differ diff --git a/assets/img/my/wrong-active-icon.png b/assets/img/my/wrong-active-icon.png new file mode 100644 index 0000000..a05cdd3 Binary files /dev/null and b/assets/img/my/wrong-active-icon.png differ diff --git a/assets/img/my/wrong-inactive-icon.png b/assets/img/my/wrong-inactive-icon.png new file mode 100644 index 0000000..50cdc61 Binary files /dev/null and b/assets/img/my/wrong-inactive-icon.png differ diff --git a/lib/core/config/app_env.dart b/lib/core/config/app_env.dart index 8053391..f10ef84 100644 --- a/lib/core/config/app_env.dart +++ b/lib/core/config/app_env.dart @@ -22,8 +22,10 @@ class AppEnv { /// 品牌标识:aixue / chucai / yule / ...(默认 aixue=爱学蝶变) /// 品牌表见 app_brand.dart;主题/接口/App 名都随它走(AppConfig 聚合) - static const String brand = - String.fromEnvironment('BRAND', defaultValue: 'aixue'); + static const String brand = String.fromEnvironment( + 'BRAND', + defaultValue: 'aixue', + ); /// 三套环境地址表(与 uni-app envList 完全对齐) static const Map> _envTable = { @@ -58,6 +60,9 @@ class AppEnv { /// 当前环境学习报告地址(预留,对标 envList.reportUrl) static String get reportUrl => value('reportUrl'); + /// 静态资源 CDN 域名(对标 uni-app utils/image.js 的 IMG_URL) + static const String imgBaseUrl = 'https://hx.cnzhyx.cn'; + /// 是否正式环境(组合规则里判断用:prod 才走品牌专属地址) static bool get isProd => env == 'prod'; } diff --git a/lib/features/class/my_interact_page.dart b/lib/features/class/my_interact_page.dart index 7698fde..0d6cad9 100644 --- a/lib/features/class/my_interact_page.dart +++ b/lib/features/class/my_interact_page.dart @@ -47,6 +47,7 @@ class _MyInteractPageState extends State { backgroundColor: const Color(0xFFF5F8FA), appBar: AppBar( backgroundColor: const Color(0xFFF5F8FA), + surfaceTintColor: Colors.transparent, // 滚动时不叠加半透明色,背景保持不变 elevation: 0, title: const Text( '我的互动', @@ -127,13 +128,14 @@ class _UpdateTab extends StatelessWidget { @override Widget build(BuildContext context) { return PostListBody( - fetchPage: ({required pageIndex, required pageSize, required prevPageId}) => - ClassApi.instance.getUserClassPost( - queryType: 0, - pageIndex: pageIndex, - pageSize: pageSize, - prevPageId: prevPageId, - ), + fetchPage: + ({required pageIndex, required pageSize, required prevPageId}) => + ClassApi.instance.getUserClassPost( + queryType: 0, + pageIndex: pageIndex, + pageSize: pageSize, + prevPageId: prevPageId, + ), emptyText: '暂无动态', onTapPost: onTapPost, ); @@ -147,12 +149,13 @@ class _LikeTab extends StatelessWidget { @override Widget build(BuildContext context) { return PostListBody( - fetchPage: ({required pageIndex, required pageSize, required prevPageId}) => - ClassApi.instance.getMyLikePage( - pageIndex: pageIndex, - pageSize: pageSize, - prevPageId: prevPageId, - ), + fetchPage: + ({required pageIndex, required pageSize, required prevPageId}) => + ClassApi.instance.getMyLikePage( + pageIndex: pageIndex, + pageSize: pageSize, + prevPageId: prevPageId, + ), emptyText: '暂无点赞', removeOnUnlike: true, onTapPost: onTapPost, @@ -167,14 +170,15 @@ class _CommentTab extends StatelessWidget { @override Widget build(BuildContext context) { return PostListBody( - fetchPage: ({required pageIndex, required pageSize, required prevPageId}) => - ClassApi.instance.getMyCommentPage( - pageIndex: pageIndex, - pageSize: pageSize, - prevPageId: prevPageId, - ), + fetchPage: + ({required pageIndex, required pageSize, required prevPageId}) => + ClassApi.instance.getMyCommentPage( + pageIndex: pageIndex, + pageSize: pageSize, + prevPageId: prevPageId, + ), emptyText: '暂无留言', onTapPost: onTapPost, ); } -} \ No newline at end of file +} diff --git a/lib/features/class/widgets/post_list_body.dart b/lib/features/class/widgets/post_list_body.dart index 9f87e2b..564c906 100644 --- a/lib/features/class/widgets/post_list_body.dart +++ b/lib/features/class/widgets/post_list_body.dart @@ -34,7 +34,8 @@ class PostListBody extends ConsumerStatefulWidget { required int pageIndex, required int pageSize, required int prevPageId, - }) fetchPage; + }) + fetchPage; final String emptyText; final bool removeOnUnlike; @@ -233,16 +234,30 @@ class _PostListBodyState extends ConsumerState { controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.symmetric(horizontal: 14.w), - itemCount: _posts.length + (_hasMore ? 1 : 0), + itemCount: _posts.length + 1, itemBuilder: (context, index) { if (index >= _posts.length) { + if (_hasMore) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 16.h), + child: const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + ), + ); + } return Padding( - padding: EdgeInsets.symmetric(vertical: 16.h), - child: const Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2.5), + padding: EdgeInsets.symmetric(vertical: 20.h), + child: Center( + child: Text( + '没有更多了', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFFB4C2E0), + ), ), ), ); @@ -262,4 +277,4 @@ class _PostListBodyState extends ConsumerState { ), ); } -} \ No newline at end of file +} diff --git a/lib/features/my/my_page.dart b/lib/features/my/my_page.dart index 30f441f..7789a42 100644 --- a/lib/features/my/my_page.dart +++ b/lib/features/my/my_page.dart @@ -1,16 +1,15 @@ -import 'package:flutter/foundation.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:new_learn/core/components/image_preview_viewer.dart'; import 'package:go_router/go_router.dart'; -import 'package:new_learn/core/logger/app_talker.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/local_notification_service.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'; @@ -47,103 +46,140 @@ class MyPage extends ConsumerWidget { final user = ref.watch(currentUserProvider).value; return Scaffold( - body: SafeArea( - child: Column( - children: [ - // 顶部渐变背景区域(用户信息头部) - Container( - width: double.infinity, - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFFFE2C2), Color(0xFFFFFFFF)], - ), - ), - child: Padding( - padding: EdgeInsets.only(bottom: 20.h), - child: _UserHeader( - name: user?.userName ?? '未登录', - className: user?.className ?? '-', - avatar: user?.avatar ?? '', - ), + 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: () { + // TODO: 跳转到我的报告页 + }, + ), + _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: 24.h), + ], ), ), - // 菜单列表区域(可滚动) - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - SizedBox(height: 12.h), - // 功能菜单(先只留"我的积分",其余后补) - _MenuCard( - items: [ - _MenuItem( - color: const Color(0xFFFF9800), - title: '我的积分', - iconData: Icons.stars_rounded, - trailingText: '${user?.userPoints ?? 0}积分', - onTap: () => context.push('/myPoint'), - ), - if (!kReleaseMode) - _MenuItem( - color: const Color(0xFF2563EB), - title: '测试通知(15秒)', - iconData: Icons.notifications_active_outlined, - onTap: () async { - talker.debug('触发测试通知:15 秒后弹出,点击进入我的积分'); - await LocalNotificationService.instance - .showMyPointNow(); - await LocalNotificationService.instance - .scheduleMyPointAfter15Seconds(); - }, - ), - ], - ), - SizedBox(height: 24.h), - // 退出登录(守卫自动踢回登录页,见 app_router.dart) - _MenuCard( - items: [ - _MenuItem( - color: const Color(0xFFEF4444), - title: '退出登录', - iconData: Icons.logout_rounded, - onTap: () => _confirmLogout(context, ref), - ), - ], - ), - SizedBox(height: 24.h), - ], - ), - ), - ), - ], - ), + ); + }, ), ); } } -/// 退出登录:确认弹窗 → 清 token → 清全局登录态 → 守卫自动踢回登录页 +/// 退出登录:用公共 ConfirmBox 二次确认 → 清 token → 清全局登录态 → 守卫自动踢回登录页 +/// +/// 对齐 Vue my.vue: `proxy.$confirm({ title: '是否退出登录', confirmText: '确定', cancelText: '取消' })` Future _confirmLogout(BuildContext context, WidgetRef ref) async { - final ok = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('退出登录'), - content: const Text('确定要退出当前账号吗?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('取消'), - ), - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('退出'), - ), - ], - ), + final ok = await showConfirmBox( + context, + title: '是否退出登录', + confirmText: '确定', + cancelText: '取消', ); - if (ok != true) return; + if (!ok) return; // ① 清内存 Token:Dio 拦截器立刻停止携带 Authorization DioClient.instance.accessToken = null; @@ -169,11 +205,13 @@ class _UserHeader extends ConsumerStatefulWidget { 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(); @@ -209,7 +247,6 @@ class _UserHeaderState extends ConsumerState<_UserHeader> { title: const Text('预览头像'), onTap: () => Navigator.of(dialogContext).pop('preview'), ), - const Divider(height: 1), ListTile( leading: const Icon(Icons.photo_camera_outlined), title: const Text('更换头像'), @@ -271,7 +308,10 @@ class _UserHeaderState extends ConsumerState<_UserHeader> { /// 头像上传成功后,本次只有 avatar 这一个字段发生变化, /// 所以前端直接做一次局部更新就够了,没必要再额外请求整份用户资料。 Future _pickAndUploadAvatar() async { - final picked = await PickerService.instance.showPickSheet(context, maxCount: 1); + final picked = await PickerService.instance.showPickSheet( + context, + maxCount: 1, + ); if (picked == null || picked.isEmpty) return; final image = picked.first; @@ -386,8 +426,9 @@ class _UserHeaderState extends ConsumerState<_UserHeader> { /// 默认头像:用户名首字圆形色块 Widget _buildDefaultAvatar() { - final avatarChar = - widget.name.isNotEmpty ? widget.name.characters.first : '?'; + final avatarChar = widget.name.isNotEmpty + ? widget.name.characters.first + : '?'; return Container( width: 62.w, height: 62.h, @@ -459,11 +500,16 @@ class _UserHeaderState extends ConsumerState<_UserHeader> { SizedBox(height: 4.h), Text( widget.className, - style: TextStyle(fontSize: 13.sp, color: const Color(0xFF8A8A8A)), + style: TextStyle( + fontSize: 13.sp, + color: const Color(0xFF8A8A8A), + ), ), ], ), ), + // 右上角退出按钮(和 Vue my.vue 的 logout-btn 对齐) + _LogoutButton(onTap: widget.onLogout), ], ), ); @@ -477,20 +523,70 @@ String _replaceExtension(String fileName, String extension) { 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.color, + required this.imageUrl, required this.title, required this.onTap, - this.iconData, this.trailingText, }); - final Color color; + final String imageUrl; // 图标(CDN 图片 URL,和 Vue 端保持一致) final String title; final VoidCallback onTap; // 点击跳转 - final IconData? iconData; // 普通图标 final String? trailingText; // 右侧文字,如 "382积分" } @@ -509,19 +605,7 @@ class _MenuCard extends StatelessWidget { borderRadius: BorderRadius.circular(16.r), ), child: Column( - children: [ - for (int i = 0; i < items.length; i++) ...[ - if (i > 0) - Divider( - height: 1, - thickness: 1, - indent: 64.w, - endIndent: 16.w, - color: const Color(0xFFF0F2F5), - ), - _buildRow(items[i]), - ], - ], + children: [for (int i = 0; i < items.length; i++) _buildRow(items[i])], ), ); } @@ -534,16 +618,13 @@ class _MenuCard extends StatelessWidget { child: Row( children: [ SizedBox(width: 16.w), - // 左侧彩色方形图标 - Container( - width: 36.w, - height: 36.h, - decoration: BoxDecoration( - color: item.color, - borderRadius: BorderRadius.circular(10.r), - ), - alignment: Alignment.center, - child: Icon(item.iconData, color: Colors.white, size: 20.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), // 中间标题 @@ -561,7 +642,10 @@ class _MenuCard extends StatelessWidget { if (item.trailingText != null) ...[ Text( item.trailingText!, - style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)), + style: TextStyle( + fontSize: 13.sp, + color: const Color(0xFF999999), + ), ), SizedBox(width: 2.w), ], diff --git a/pubspec.yaml b/pubspec.yaml index 292c5b5..a39e3d6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -236,6 +236,7 @@ flutter: - assets/img/tabbar/default/ - assets/audio/ - assets/img/class/ + - assets/img/my/ # To add assets to your application, add an assets section, like this: # assets: