feat: 添加设置页、关于页与账号注销功能,开启网络日志

1.  新增账号注销API接口与设置页菜单,实现完整注销流程
2.  新增关于APP页面,展示隐私政策、用户协议与版本信息
3.  在我的页面添加设置入口,跳转设置页
4.  优化头像上传逻辑,移除预览弹窗步骤并简化流程
5.  开启网络日志打印,方便调试网络请求
This commit is contained in:
崔宝新
2026-09-08 15:35:56 +08:00
parent 2aa1927694
commit 7f5d160212
6 changed files with 436 additions and 138 deletions
+89 -137
View File
@@ -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<void> _onAvatarTap() async {
if (!_hasPreviewableAvatar) {
await _pickAndUploadAvatar();
return;
}
final action = await showModalBottomSheet<String>(
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<void> _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<void> _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();
}