Compare commits
2
Commits
9017ec669f
...
f718749ad7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f718749ad7 | ||
|
|
7f5d160212 |
@@ -89,6 +89,14 @@ class UserApi {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 注销账号 → POST /api/v1/User/CloseAccount
|
||||||
|
///
|
||||||
|
/// 对齐 Vue api/login: `closeAccount = () => request.post('/api/v1/User/CloseAccount')`
|
||||||
|
/// 调用方负责弹确认框 + 注销后清理本地状态。
|
||||||
|
static Future<void> closeAccount() async {
|
||||||
|
await DioClient.instance.post('/api/v1/User/CloseAccount');
|
||||||
|
}
|
||||||
|
|
||||||
/// MD5 加密(UniApp stringToMd5 的等价实现)
|
/// MD5 加密(UniApp stringToMd5 的等价实现)
|
||||||
static String _md5(String text) {
|
static String _md5(String text) {
|
||||||
return md5.convert(utf8.encode(text)).toString();
|
return md5.convert(utf8.encode(text)).toString();
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import 'package:talker_dio_logger/talker_dio_logger.dart';
|
|||||||
/// ============================================================
|
/// ============================================================
|
||||||
class DioClient {
|
class DioClient {
|
||||||
/// 网络日志总开关:true = 控制台打印请求/响应数据,false = 静默
|
/// 网络日志总开关:true = 控制台打印请求/响应数据,false = 静默
|
||||||
static const bool enableNetworkLog = false;
|
static const bool enableNetworkLog = true;
|
||||||
|
|
||||||
// ---------- 1. 单例:整个 App 只有一个 Dio ----------
|
// ---------- 1. 单例:整个 App 只有一个 Dio ----------
|
||||||
DioClient._()
|
DioClient._()
|
||||||
|
|||||||
@@ -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/core/api/class/model.dart';
|
||||||
import 'package:new_learn/features/my/my_page.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/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/recite/recite_page.dart';
|
||||||
import 'package:new_learn/features/task/task_page.dart';
|
import 'package:new_learn/features/task/task_page.dart';
|
||||||
import 'package:new_learn/core/router/main_tab_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),
|
MyUpdateDetailPage(initialPost: state.extra as UserPostDto),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
|
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
|
||||||
|
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
|
||||||
|
GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()),
|
||||||
// ---------- 首次启动隐私协议页 ----------
|
// ---------- 首次启动隐私协议页 ----------
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/privacy-consent',
|
path: '/privacy-consent',
|
||||||
|
|||||||
+89
-137
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:new_learn/core/components/confirm_box.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:go_router/go_router.dart';
|
||||||
import 'package:new_learn/core/config/app_env.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/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),
|
SizedBox(height: 24.h),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -218,95 +229,19 @@ class _UserHeader extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UserHeaderState extends ConsumerState<_UserHeader> {
|
class _UserHeaderState extends ConsumerState<_UserHeader> {
|
||||||
static const String _avatarHeroTag = 'my-page-avatar';
|
/// 点击头像 → 直接触发选图上传(对齐 Vue my.vue changeAvatar 逻辑,无预览中间步骤)
|
||||||
|
|
||||||
/// 刚选中的本地新头像(null = 还没选)
|
|
||||||
PickedImage? _pickedAvatar;
|
|
||||||
|
|
||||||
/// 点击头像:
|
|
||||||
/// 1. 没头像 → 直接更换;
|
|
||||||
/// 2. 有头像 → 先弹动作表,支持"预览头像 / 更换头像"。
|
|
||||||
Future<void> _onAvatarTap() async {
|
Future<void> _onAvatarTap() async {
|
||||||
if (!_hasPreviewableAvatar) {
|
await _pickAndUploadAvatar();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _hasPreviewableAvatar {
|
/// 选择头像并上传(对齐 Vue my.vue 的 changeAvatar 完整链路):
|
||||||
return _pickedAvatar != null || widget.avatar.isNotEmpty;
|
/// 1. showPickSheet 选图(相册+拍照,内部已含权限请求)
|
||||||
}
|
/// 2. ImageProcessor 压缩 + EXIF 方向修正
|
||||||
|
/// 3. 显示 loading 遮罩(对应 Vue uni.showLoading '上传中...')
|
||||||
Future<void> _previewAvatar() async {
|
/// 4. OssUploader.uploadImage 直传 OSS
|
||||||
final picked = _pickedAvatar;
|
/// 5. UserApi.updateUserAvatar 调业务接口更新头像字段
|
||||||
if (picked?.bytes != null) {
|
/// 6. 弹审核确认框(对应 Vue proxy.$confirm)
|
||||||
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 这一个字段发生变化,
|
|
||||||
/// 所以前端直接做一次局部更新就够了,没必要再额外请求整份用户资料。
|
|
||||||
Future<void> _pickAndUploadAvatar() async {
|
Future<void> _pickAndUploadAvatar() async {
|
||||||
final picked = await PickerService.instance.showPickSheet(
|
final picked = await PickerService.instance.showPickSheet(
|
||||||
context,
|
context,
|
||||||
@@ -315,19 +250,15 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
|
|||||||
if (picked == null || picked.isEmpty) return;
|
if (picked == null || picked.isEmpty) return;
|
||||||
|
|
||||||
final image = picked.first;
|
final image = picked.first;
|
||||||
// 先显示本地临时预览,让用户选完图后有即时反馈。
|
|
||||||
setState(() => _pickedAvatar = image);
|
|
||||||
|
|
||||||
// ① 读图片字节:Web 走 bytes;原生走 photo_manager 纯 Dart 的 originBytes
|
// ① 读图片字节(Web 走 bytes;原生走 originBytes)
|
||||||
// (不 import dart:io,Web 才能编译)
|
|
||||||
final bytes = image.bytes ?? await image.entity?.originBytes;
|
final bytes = image.bytes ?? await image.entity?.originBytes;
|
||||||
if (bytes == null || bytes.isEmpty) {
|
if (bytes == null || bytes.isEmpty) {
|
||||||
_toast('读取图片失败,请重试');
|
_toast('读取图片失败,请重试');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ② 压缩 + EXIF 方向修正(后台 isolate 跑,不卡页面)
|
// ② 压缩 + EXIF 方向修正
|
||||||
// 头像只需 512 长边;null = 尺寸超出 14~4000 像素硬限制
|
|
||||||
final processed = await ImageProcessor.process(
|
final processed = await ImageProcessor.process(
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
fileName: image.name,
|
fileName: image.name,
|
||||||
@@ -338,7 +269,10 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ③ OSS 直传:申请路径 → 拿 STS → V4 签名 → PUT(拦截器自动带 Token)
|
// ③ 显示 loading 遮罩(对应 Vue uni.showLoading)
|
||||||
|
if (!mounted) return;
|
||||||
|
_showLoading('上传中...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final fileName = _replaceExtension(
|
final fileName = _replaceExtension(
|
||||||
image.name ?? 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
image.name ?? 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||||
@@ -348,6 +282,8 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
|
|||||||
'头像处理:${processed.originalBytes}B -> ${processed.processedBytes}B '
|
'头像处理:${processed.originalBytes}B -> ${processed.processedBytes}B '
|
||||||
'(${processed.contentType}, $fileName)',
|
'(${processed.contentType}, $fileName)',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ④ OSS 直传
|
||||||
final url = await OssUploader.uploadImage(
|
final url = await OssUploader.uploadImage(
|
||||||
bytes: processed.bytes,
|
bytes: processed.bytes,
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
@@ -355,30 +291,60 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
|
|||||||
scene: UploadScene.avatar,
|
scene: UploadScene.avatar,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ④ 通知业务接口"当前用户头像已改成这个 OSS 地址"。
|
// ⑤ 业务接口更新头像字段(对应 Vue UpdateUserAvatar({ avatar: url }))
|
||||||
// 这一步对应 UniApp 的 UpdateUserAvatar({ avatar: url }),
|
|
||||||
// 目的是把服务器里当前用户资料的 avatar 字段真正更新掉。
|
|
||||||
await UserApi.updateUserAvatar(url);
|
await UserApi.updateUserAvatar(url);
|
||||||
|
|
||||||
// ⑤ 头像字段只改本地当前用户信息即可,不需要"我的"页再重新请求接口。
|
// ⑥ 关闭 loading,弹审核确认框(对应 Vue proxy.$confirm)
|
||||||
// updateAvatar(url) 内部会做两件事:
|
|
||||||
// - 更新 currentUserProvider 的内存状态;
|
|
||||||
// - 把更新后的用户对象写回 SharedPreferences。
|
|
||||||
// 因此所有 ref.watch(currentUserProvider) 的页面都会一起刷新。
|
|
||||||
await ref.read(currentUserProvider.notifier).updateAvatar(url);
|
|
||||||
|
|
||||||
// 上传成功后清掉本地临时预览,改为展示服务器返回的头像 URL。
|
|
||||||
// 否则界面会一直优先显示 _pickedAvatar,看起来像"方向没修正",
|
|
||||||
// 实际上你看到的是本地原图/相册缩略图,不是处理后的上传结果。
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _pickedAvatar = null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_toast('头像上传成功');
|
_hideLoading();
|
||||||
|
|
||||||
|
await showConfirmBox(
|
||||||
|
context,
|
||||||
|
title: '上传成功,等待审核完成会自动生效',
|
||||||
|
confirmText: '确定',
|
||||||
|
cancelText: '取消',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
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() {
|
Widget _buildAvatar() {
|
||||||
final size = 62.w;
|
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) {
|
if (widget.avatar.isNotEmpty) {
|
||||||
return Hero(
|
return ClipOval(
|
||||||
tag: _avatarHeroTag,
|
child: Image.network(
|
||||||
child: ClipOval(
|
widget.avatar,
|
||||||
child: Image.network(
|
width: size,
|
||||||
widget.avatar,
|
height: size,
|
||||||
width: size,
|
fit: BoxFit.cover,
|
||||||
height: size,
|
errorBuilder: (_, _, _) => _buildDefaultAvatar(),
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (_, _, _) => _buildDefaultAvatar(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 三级:默认头像(首字圆)
|
// 二级:默认头像(首字圆)
|
||||||
return _buildDefaultAvatar();
|
return _buildDefaultAvatar();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<AboutAppPage> createState() => _AboutAppPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AboutAppPageState extends ConsumerState<AboutAppPage> {
|
||||||
|
String _version = '';
|
||||||
|
static const _recordNumber = '京ICP备2026019420号-2A';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SettingsPage> createState() => _SettingsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||||
|
String _version = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadVersion() async {
|
||||||
|
final service = AppVersionService.instance;
|
||||||
|
if (service.versionName.isEmpty) {
|
||||||
|
await service.init();
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _version = service.versionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user