diff --git a/lib/core/api/auth/api.dart b/lib/core/api/auth/api.dart index 38d0108..74aa562 100644 --- a/lib/core/api/auth/api.dart +++ b/lib/core/api/auth/api.dart @@ -89,6 +89,14 @@ class UserApi { ); } + /// 注销账号 → POST /api/v1/User/CloseAccount + /// + /// 对齐 Vue api/login: `closeAccount = () => request.post('/api/v1/User/CloseAccount')` + /// 调用方负责弹确认框 + 注销后清理本地状态。 + static Future closeAccount() async { + await DioClient.instance.post('/api/v1/User/CloseAccount'); + } + /// MD5 加密(UniApp stringToMd5 的等价实现) static String _md5(String text) { return md5.convert(utf8.encode(text)).toString(); diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index f928674..ec68d7e 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -38,7 +38,7 @@ import 'package:talker_dio_logger/talker_dio_logger.dart'; /// ============================================================ class DioClient { /// 网络日志总开关:true = 控制台打印请求/响应数据,false = 静默 - static const bool enableNetworkLog = false; + static const bool enableNetworkLog = true; // ---------- 1. 单例:整个 App 只有一个 Dio ---------- DioClient._() diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index ea8f947..ba2eff3 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -13,6 +13,8 @@ import 'package:new_learn/features/class/my_update_detail_page.dart'; import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/features/my/my_page.dart'; import 'package:new_learn/features/my/my_point/my_point_page.dart'; +import 'package:new_learn/features/my/settings/settings_page.dart'; +import 'package:new_learn/features/my/settings/about_app_page.dart'; import 'package:new_learn/features/recite/recite_page.dart'; import 'package:new_learn/features/task/task_page.dart'; import 'package:new_learn/core/router/main_tab_page.dart'; @@ -122,6 +124,8 @@ final GoRouter appRouter = GoRouter( MyUpdateDetailPage(initialPost: state.extra as UserPostDto), ), GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), + GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()), + GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()), // ---------- 首次启动隐私协议页 ---------- GoRoute( path: '/privacy-consent', diff --git a/lib/features/my/my_page.dart b/lib/features/my/my_page.dart index 7789a42..853bb0d 100644 --- a/lib/features/my/my_page.dart +++ b/lib/features/my/my_page.dart @@ -2,7 +2,6 @@ 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/config/app_env.dart'; import 'package:new_learn/core/network/dio_client.dart'; @@ -158,6 +157,18 @@ class MyPage extends ConsumerWidget { ), ], ), + 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), ], ), @@ -218,95 +229,19 @@ class _UserHeader extends ConsumerStatefulWidget { } class _UserHeaderState extends ConsumerState<_UserHeader> { - static const String _avatarHeroTag = 'my-page-avatar'; - - /// 刚选中的本地新头像(null = 还没选) - PickedImage? _pickedAvatar; - - /// 点击头像: - /// 1. 没头像 → 直接更换; - /// 2. 有头像 → 先弹动作表,支持"预览头像 / 更换头像"。 + /// 点击头像 → 直接触发选图上传(对齐 Vue my.vue changeAvatar 逻辑,无预览中间步骤) Future _onAvatarTap() async { - if (!_hasPreviewableAvatar) { - await _pickAndUploadAvatar(); - return; - } - - final action = await showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (dialogContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.visibility_outlined), - title: const Text('预览头像'), - onTap: () => Navigator.of(dialogContext).pop('preview'), - ), - ListTile( - leading: const Icon(Icons.photo_camera_outlined), - title: const Text('更换头像'), - onTap: () => Navigator.of(dialogContext).pop('change'), - ), - const SizedBox(height: 8), - ], - ), - ), - ); - - if (!mounted) return; - if (action == 'preview') { - await _previewAvatar(); - return; - } - if (action == 'change') { - await _pickAndUploadAvatar(); - } + await _pickAndUploadAvatar(); } - bool get _hasPreviewableAvatar { - return _pickedAvatar != null || widget.avatar.isNotEmpty; - } - - Future _previewAvatar() async { - final picked = _pickedAvatar; - if (picked?.bytes != null) { - await ImagePreviewViewer.showSingle( - context, - imageProvider: MemoryImage(picked!.bytes!), - heroTag: _avatarHeroTag, - label: '头像预览', - ); - return; - } - - if (widget.avatar.isNotEmpty) { - await ImagePreviewViewer.showSingle( - context, - imageProvider: NetworkImage(widget.avatar), - heroTag: _avatarHeroTag, - label: '头像预览', - ); - } - } - - /// 选择头像:拍照/相册 → OSS 直传 → 调业务接口更新头像字段 → 更新全局用户 - /// - /// 这里和 UniApp 旧项目保持一致,完整链路是: - /// 1. 先选图片; - /// 2. 本地压缩/方向修正; - /// 3. 上传到 OSS,拿到最终图片 url; - /// 4. 调 /api/v1/User/UpdateUserAvatar,把服务器里的 avatar 字段改掉; - /// 5. 更新 currentUserProvider + 本地缓存,让界面立刻刷新。 - /// - /// 为什么"我的"页这里不再重新请求用户信息接口: - /// 登录成功时已经把完整用户信息拉下来放进了 currentUserProvider; - /// 头像上传成功后,本次只有 avatar 这一个字段发生变化, - /// 所以前端直接做一次局部更新就够了,没必要再额外请求整份用户资料。 + /// 选择头像并上传(对齐 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, @@ -315,19 +250,15 @@ class _UserHeaderState extends ConsumerState<_UserHeader> { if (picked == null || picked.isEmpty) return; final image = picked.first; - // 先显示本地临时预览,让用户选完图后有即时反馈。 - setState(() => _pickedAvatar = image); - // ① 读图片字节:Web 走 bytes;原生走 photo_manager 纯 Dart 的 originBytes - // (不 import dart:io,Web 才能编译) + // ① 读图片字节(Web 走 bytes;原生走 originBytes) final bytes = image.bytes ?? await image.entity?.originBytes; if (bytes == null || bytes.isEmpty) { _toast('读取图片失败,请重试'); return; } - // ② 压缩 + EXIF 方向修正(后台 isolate 跑,不卡页面) - // 头像只需 512 长边;null = 尺寸超出 14~4000 像素硬限制 + // ② 压缩 + EXIF 方向修正 final processed = await ImageProcessor.process( bytes: bytes, fileName: image.name, @@ -338,7 +269,10 @@ class _UserHeaderState extends ConsumerState<_UserHeader> { return; } - // ③ OSS 直传:申请路径 → 拿 STS → V4 签名 → PUT(拦截器自动带 Token) + // ③ 显示 loading 遮罩(对应 Vue uni.showLoading) + if (!mounted) return; + _showLoading('上传中...'); + try { final fileName = _replaceExtension( image.name ?? 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg', @@ -348,6 +282,8 @@ class _UserHeaderState extends ConsumerState<_UserHeader> { '头像处理:${processed.originalBytes}B -> ${processed.processedBytes}B ' '(${processed.contentType}, $fileName)', ); + + // ④ OSS 直传 final url = await OssUploader.uploadImage( bytes: processed.bytes, fileName: fileName, @@ -355,30 +291,60 @@ class _UserHeaderState extends ConsumerState<_UserHeader> { scene: UploadScene.avatar, ); - // ④ 通知业务接口"当前用户头像已改成这个 OSS 地址"。 - // 这一步对应 UniApp 的 UpdateUserAvatar({ avatar: url }), - // 目的是把服务器里当前用户资料的 avatar 字段真正更新掉。 + // ⑤ 业务接口更新头像字段(对应 Vue UpdateUserAvatar({ avatar: url })) await UserApi.updateUserAvatar(url); - // ⑤ 头像字段只改本地当前用户信息即可,不需要"我的"页再重新请求接口。 - // updateAvatar(url) 内部会做两件事: - // - 更新 currentUserProvider 的内存状态; - // - 把更新后的用户对象写回 SharedPreferences。 - // 因此所有 ref.watch(currentUserProvider) 的页面都会一起刷新。 - await ref.read(currentUserProvider.notifier).updateAvatar(url); - - // 上传成功后清掉本地临时预览,改为展示服务器返回的头像 URL。 - // 否则界面会一直优先显示 _pickedAvatar,看起来像"方向没修正", - // 实际上你看到的是本地原图/相册缩略图,不是处理后的上传结果。 - if (mounted) { - setState(() => _pickedAvatar = null); - } - + // ⑥ 关闭 loading,弹审核确认框(对应 Vue proxy.$confirm) if (!mounted) return; - _toast('头像上传成功'); + _hideLoading(); + + await showConfirmBox( + context, + title: '上传成功,等待审核完成会自动生效', + confirmText: '确定', + cancelText: '取消', + ); } catch (e) { if (!mounted) return; - _toast('头像上传失败:$e'); + _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(); } } @@ -389,38 +355,24 @@ class _UserHeaderState extends ConsumerState<_UserHeader> { ); } - /// 头像主体:按「本地新图 → 网络图 → 默认首字圆」三级渲染 + /// 头像主体:网络头像 → 默认首字圆 二级渲染 Widget _buildAvatar() { final size = 62.w; - final picked = _pickedAvatar; - // 一级:刚选中的本地头像 - if (picked != null) { - return Hero( - tag: _avatarHeroTag, - child: ClipOval( - child: PickerService.buildThumb(picked, width: size, height: size), - ), - ); - } - - // 二级:服务器返回的网络头像(加载失败自动降级到默认头像) + // 一级:服务器返回的网络头像(加载失败自动降级到默认头像) if (widget.avatar.isNotEmpty) { - return Hero( - tag: _avatarHeroTag, - child: ClipOval( - child: Image.network( - widget.avatar, - width: size, - height: size, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _buildDefaultAvatar(), - ), + return ClipOval( + child: Image.network( + widget.avatar, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => _buildDefaultAvatar(), ), ); } - // 三级:默认头像(首字圆) + // 二级:默认头像(首字圆) return _buildDefaultAvatar(); } diff --git a/lib/features/my/settings/about_app_page.dart b/lib/features/my/settings/about_app_page.dart new file mode 100644 index 0000000..0ee8bb6 --- /dev/null +++ b/lib/features/my/settings/about_app_page.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/version/app_version_service.dart'; + +/// ============================================================ +/// 【AboutAppPage】关于APP页 —— 对齐 Vue pages/myPackage/aboutApp/index.vue +/// +/// 菜单:隐私政策 / 用户协议 / 版本号(只读) +/// 底部:APP备案号 +/// ============================================================ +class AboutAppPage extends ConsumerStatefulWidget { + const AboutAppPage({super.key}); + + @override + ConsumerState createState() => _AboutAppPageState(); +} + +class _AboutAppPageState extends ConsumerState { + String _version = ''; + static const _recordNumber = '京ICP备2026019420号-2A'; + + @override + void initState() { + super.initState(); + _loadVersion(); + } + + Future _loadVersion() async { + final service = AppVersionService.instance; + if (service.versionName.isEmpty) { + await service.init(); + } + if (mounted) setState(() => _version = service.versionName); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F8FA), + appBar: AppBar( + title: const Text('关于APP'), + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/my'), + ), + ), + body: ListView( + padding: EdgeInsets.all(12.w), + children: [ + // 隐私政策 + _buildRow( + title: '隐私政策', + trailing: const Icon( + Icons.chevron_right, + size: 22, + color: Color(0xFF7FB0F2), + ), + onTap: () => context.push('/privacy-policy'), + showBorder: true, + ), + // 用户协议 + _buildRow( + title: '用户协议', + trailing: const Icon( + Icons.chevron_right, + size: 22, + color: Color(0xFF7FB0F2), + ), + onTap: () => context.push('/user-agreement'), + showBorder: true, + ), + // 版本号(只读,无跳转) + _buildRow( + title: '版本号', + trailing: Text( + _version, + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF687FAD)), + ), + onTap: null, + showBorder: false, + ), + SizedBox(height: 48.h), + // 备案号 + Center( + child: Text( + 'APP备案编号:$_recordNumber', + style: TextStyle(fontSize: 12.sp, color: const Color(0xFF687FAD)), + ), + ), + ], + ), + ); + } + + Widget _buildRow({ + required String title, + required Widget trailing, + required VoidCallback? onTap, + required bool showBorder, + }) { + return InkWell( + onTap: onTap, + child: Column( + children: [ + Container( + height: 52.h, + padding: EdgeInsets.symmetric(horizontal: 16.w), + color: Colors.white, + child: Row( + children: [ + Text( + title, + style: TextStyle( + fontSize: 16.sp, + color: const Color(0xFF1D2B47), + ), + ), + const Spacer(), + trailing, + ], + ), + ), + if (showBorder) + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Container(height: 1, color: const Color(0xFFEDF3F7)), + ), + ], + ), + ); + } +} diff --git a/lib/features/my/settings/settings_page.dart b/lib/features/my/settings/settings_page.dart new file mode 100644 index 0000000..f9e88f8 --- /dev/null +++ b/lib/features/my/settings/settings_page.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/api/auth/api.dart'; +import 'package:new_learn/core/components/app_update_dialog.dart'; +import 'package:new_learn/core/components/confirm_box.dart'; +import 'package:new_learn/core/network/dio_client.dart'; +import 'package:new_learn/core/providers/user_provider.dart'; +import 'package:new_learn/core/storage/user_storage.dart'; +import 'package:new_learn/core/version/app_version_service.dart'; + +/// ============================================================ +/// 【SettingsPage】设置页 —— 对齐 Vue pages/myPackage/settings/index.vue +/// +/// 三栏菜单: +/// 检查更新(右侧显示当前版本号,手动触发版本比对弹窗) +/// 关于APP (TODO: 跳转关于页) +/// 注销账号 (Vue 端受 isAuditMode 控制,Flutter 暂先显示) +/// +/// 样式:白色圆角卡片 + 分隔线,和 my_page 的 _MenuCard 风格一致 +/// ============================================================ +class SettingsPage extends ConsumerStatefulWidget { + const SettingsPage({super.key}); + + @override + ConsumerState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + String _version = ''; + + @override + void initState() { + super.initState(); + _loadVersion(); + } + + Future _loadVersion() async { + final service = AppVersionService.instance; + if (service.versionName.isEmpty) { + await service.init(); + } + if (mounted) { + setState(() => _version = service.versionName); + } + } + + Future _handleCheckUpdate() async { + // 手动检查更新:复用 AppVersionService + AppUpdateDialog + try { + final service = AppVersionService.instance; + await service.init(); + final update = await service.checkForUpdate(); + if (!mounted) return; + if (update == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('已是最新版本'))); + return; + } + await AppUpdateDialog.show( + context, + update, + isForce: service.isForceUpdate(), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('检查更新失败:$e'))); + } + } + + Future _handleCloseAccount() async { + final ok = await showConfirmBox( + context, + title: '注销提醒', + content: '账号注销后,将会删除账号的全部数据,不可恢复。请确认是否注销账号?', + confirmText: '确定注销', + cancelText: '我再想想', + ); + if (!ok) return; + try { + await UserApi.closeAccount(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('注销失败:$e'))); + return; + } + // 清理本地登录态 → 路由守卫自动踢回登录页 + DioClient.instance.accessToken = null; + await UserStorage.instance.clearAll(); + if (!mounted) return; + ref.read(currentUserProvider.notifier).set(null); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F8FA), + appBar: AppBar( + title: const Text('设置'), + backgroundColor: Colors.white, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.canPop() ? context.pop() : context.go('/my'), + ), + ), + body: ListView( + padding: EdgeInsets.all(12.w), + children: [ + // 检查更新 + _buildRow( + title: '检查更新', + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_version.isNotEmpty) + Text( + 'v$_version', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF687FAD), + ), + ), + SizedBox(width: 4.w), + const Icon( + Icons.chevron_right, + size: 22, + color: Color(0xFF7FB0F2), + ), + ], + ), + onTap: _handleCheckUpdate, + ), + _buildDivider(), + // 关于APP + _buildRow( + title: '关于APP', + trailing: const Icon( + Icons.chevron_right, + size: 22, + color: Color(0xFF7FB0F2), + ), + onTap: () => context.push('/about-app'), + ), + _buildDivider(), + // 注销账号 + _buildRow( + title: '注销账号', + trailing: const Icon( + Icons.chevron_right, + size: 22, + color: Color(0xFF7FB0F2), + ), + onTap: _handleCloseAccount, + ), + ], + ), + ); + } + + Widget _buildDivider() { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Container(height: 1, color: const Color(0xFFEDF3F7)), + ); + } + + Widget _buildRow({ + required String title, + required Widget trailing, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: Container( + height: 52.h, + padding: EdgeInsets.symmetric(horizontal: 16.w), + color: Colors.white, + child: Row( + children: [ + Text( + title, + style: TextStyle(fontSize: 16.sp, color: const Color(0xFF1D2B47)), + ), + const Spacer(), + trailing, + ], + ), + ), + ); + } +}