diff --git a/lib/core/api/auth/api.dart b/lib/core/api/auth/api.dart index 38d0108..3fbf6b2 100644 --- a/lib/core/api/auth/api.dart +++ b/lib/core/api/auth/api.dart @@ -89,6 +89,54 @@ 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'); + } + + // ==================== 注册 ==================== + + /// 注册 → POST /api/v1/User/Register + /// + /// 对齐 Vue api/login `register`: { account, password },密码同样 MD5 加密 + /// Vue RegisterResponse = { code, message }(业务响应壳),Flutter 端 DioClient + /// 已自动处理成功/失败分支,成功不关心 body,失败直接抛 ApiException。 + static Future register({ + required String account, + required String password, + }) async { + await DioClient.instance.post( + '/api/v1/User/Register', + data: {'account': account.trim(), 'password': _md5(password.trim())}, + ); + } + + // ==================== 审核模式 ==================== + + /// 获取审核模式(决定是否展示"去注册"入口)→ GET /api/v1/Public/GetIsAuditMode + /// + /// 对齐 Vue api/login: `getAuditMode()`,返回值本身就是 boolean + /// (request 拦截器已解包),接口不可用时安全回退 false(不展示注册入口)。 + static Future getAuditMode() async { + try { + final data = await DioClient.instance.get( + '/api/v1/Public/GetIsAuditMode', + ); + if (data is bool) return data; + if (data is Map) { + // 兼容 { isAuditMode: true } 包装 + final v = data['isAuditMode']; + if (v is bool) return v; + } + return false; + } catch (_) { + return false; + } + } + /// MD5 加密(UniApp stringToMd5 的等价实现) static String _md5(String text) { return md5.convert(utf8.encode(text)).toString(); diff --git a/lib/core/components/common_paged_list.dart b/lib/core/components/common_paged_list.dart index 779cd16..0663e7b 100644 --- a/lib/core/components/common_paged_list.dart +++ b/lib/core/components/common_paged_list.dart @@ -73,7 +73,7 @@ class CommonPagedList extends StatefulWidget { this.separatorBuilder, this.padding, this.emptyWidget, - this.noMoreText = '— 没有更多了 —', + this.noMoreText = '- AI 爱学蝶变提醒你 没有更多了 -', this.invisibleItemsThreshold = 0, this.enableRefresh = true, }); 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/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 4e71001..f70eef8 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -6,6 +6,7 @@ import 'package:new_learn/features/auth/legal_document_page.dart'; import 'package:new_learn/features/auth/legal_documents.dart'; import 'package:new_learn/features/auth/login_page.dart'; import 'package:new_learn/features/auth/privacy_consent_page.dart'; +import 'package:new_learn/features/auth/register_page.dart'; import 'package:new_learn/features/class/all_student_view.dart'; import 'package:new_learn/features/class/class_page.dart'; import 'package:new_learn/features/class/my_interact_page.dart'; @@ -14,6 +15,10 @@ import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/features/my/my_page.dart'; import 'package:new_learn/features/conquerPackage/captureList/index.dart'; import 'package:new_learn/features/my/my_point/my_point_page.dart'; +import 'package:new_learn/features/my/my_report/my_report_page.dart'; +import 'package:new_learn/features/my/my_report/report_detail_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'; @@ -96,34 +101,47 @@ final GoRouter appRouter = GoRouter( GoRoute(path: '/recite', builder: (_, _) => const RecitePage()), ], ), - // 班级(含班级模块子页:全部同学 / 我的互动 / 动态详情) + // 班级(只有主 tab 页在 ShellBranch 内,子页放顶层避免 tabbar 遮挡) StatefulShellBranch( routes: [ GoRoute(path: '/class', builder: (_, _) => const ClassPage()), - GoRoute( - path: '/class/all-student', - builder: (_, _) => const AllStudentPage(), - ), - GoRoute( - path: '/class/my-interact', - builder: (_, _) => const MyInteractPage(), - ), - GoRoute( - path: '/class/my-update-detail', - builder: (_, state) => - MyUpdateDetailPage(initialPost: state.extra as UserPostDto), - ), ], ), - // 我的(含我的积分页) + // 我的 StatefulShellBranch( - routes: [ - GoRoute(path: '/my', builder: (_, _) => const MyPage()), - GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), - ], + routes: [GoRoute(path: '/my', builder: (_, _) => const MyPage())], ), ], ), + // ---------- 二级业务页(不在 ShellBranch 内,push 时不显示 tabbar) ---------- + GoRoute( + path: '/class/all-student', + builder: (_, _) => const AllStudentPage(), + ), + GoRoute( + path: '/class/my-interact', + builder: (_, _) => const MyInteractPage(), + ), + GoRoute( + path: '/class/my-update-detail', + builder: (_, state) => + MyUpdateDetailPage(initialPost: state.extra as UserPostDto), + ), + GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), + GoRoute(path: '/myReport', builder: (_, _) => const MyReportPage()), + GoRoute( + path: '/report-detail', + builder: (_, state) { + // extra 格式:{'title': '...', 'url': '...'} + final extra = state.extra as Map? ?? {}; + return ReportDetailPage( + title: extra['title'] as String? ?? '我的报告', + url: extra['url'] as String? ?? '', + ); + }, + ), + GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()), + GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()), // ---------- 首次启动隐私协议页 ---------- GoRoute( path: '/privacy-consent', @@ -143,7 +161,7 @@ final GoRouter appRouter = GoRouter( content: kPrivacyPolicyContent, ), ), - // ---------- 登录页 ---------- + // ---------- 登录/注册页(未登录可访问) ---------- GoRoute(path: '/login', builder: (_, _) => const LoginPage()), // ---------- 攻克包 - 截图名单 ---------- GoRoute( @@ -151,6 +169,8 @@ final GoRouter appRouter = GoRouter( builder: (_, state) => CaptureListPage(courseId: state.extra as String), ), + GoRoute(path: '/register', builder: (_, _) => const RegisterPage()), + // app_router.dart 的 routes 里加,不用管跳转逻辑 // GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()), // ---------- 以后新业务页在这里加,一行一个 ---------- @@ -158,7 +178,7 @@ final GoRouter appRouter = GoRouter( ); /// 登录守卫(对标 vue-router beforeEach): -/// 没登录 → 强制去 /login;已登录还访问 /login → 踢回首页 +/// 没登录 → 强制去 /login;已登录还访问 /login 或 /register → 踢回首页 String? _guardLogin(BuildContext context, GoRouterState state) { final acceptedPrivacy = privacyAcceptedNotifier.value; final loggedIn = DioClient.instance.accessToken != null; @@ -167,11 +187,14 @@ String? _guardLogin(BuildContext context, GoRouterState state) { state.matchedLocation == '/user-agreement' || state.matchedLocation == '/privacy-policy'; final atLogin = state.matchedLocation == '/login'; + final atRegister = state.matchedLocation == '/register'; + // 未登录可白名单:隐私协议页 / 协议文档 / 登录页 / 注册页 + final atAuthFree = atLogin || atRegister || atLegalDocument; if (!acceptedPrivacy && !atPrivacyConsent && !atLegalDocument) { return '/privacy-consent'; } if (acceptedPrivacy && atPrivacyConsent) return loggedIn ? '/' : '/login'; - if (!loggedIn && !atLogin && !atLegalDocument) return '/login'; - if (loggedIn && atLogin) return '/'; + if (!loggedIn && !atAuthFree && !atPrivacyConsent) return '/login'; + if (loggedIn && (atLogin || atRegister)) return '/'; return null; } diff --git a/lib/core/storage/user_storage.dart b/lib/core/storage/user_storage.dart index e083104..26c61cc 100644 --- a/lib/core/storage/user_storage.dart +++ b/lib/core/storage/user_storage.dart @@ -4,6 +4,44 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:new_learn/core/models/user_model.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +/// ============================================================ +/// 【LoginHistoryItem】登录历史单条记录 —— 对齐 Vue loginHistory +/// +/// account → 登录账号(唯一标识,重复登录时去重用) +/// userName → 登录后拿到的昵称(展示用,可能为空) +/// password → 记住的密码(明文,存在 secure_storage 里一条 JSON) +/// 空串 "" 表示本次没勾选记住密码 +/// rememberPassword → 是否勾选了记住密码(决定密码是否回填) +/// ============================================================ +class LoginHistoryItem { + const LoginHistoryItem({ + required this.account, + required this.userName, + required this.password, + required this.rememberPassword, + }); + + final String account; + final String userName; + final String password; + final bool rememberPassword; + + Map toJson() => { + 'account': account, + 'userName': userName, + 'password': password, + 'rememberPassword': rememberPassword, + }; + + factory LoginHistoryItem.fromJson(Map json) => + LoginHistoryItem( + account: json['account'] as String? ?? '', + userName: json['userName'] as String? ?? '', + password: json['password'] as String? ?? '', + rememberPassword: json['rememberPassword'] as bool? ?? false, + ); +} + /// ============================================================ /// 【UserStorage】用户本地存储(token 安全存储 + 用户信息缓存) /// @@ -118,6 +156,36 @@ class UserStorage { Future clearRememberedPassword() => _secure.delete(key: _rememberedPasswordKey); + // ---------- 登录历史(多条,最多 3 条,对齐 Vue loginHistory) ---------- + + static const String _loginHistoryKey = 'login_history'; + static const int _maxHistoryCount = 3; + + /// 读取登录历史(没存过返回空列表) + Future> readLoginHistory() async { + final raw = await _secure.read(key: _loginHistoryKey); + if (raw == null) return []; + try { + final list = jsonDecode(raw) as List; + return list + .map((e) => LoginHistoryItem.fromJson(e as Map)) + .toList(); + } catch (_) { + return []; // 缓存损坏时当没历史处理 + } + } + + /// 保存登录历史(整体替换存,最多保留 3 条) + Future saveLoginHistory(List items) async { + final trimmed = items.length > _maxHistoryCount + ? items.sublist(0, _maxHistoryCount) + : items; + await _secure.write( + key: _loginHistoryKey, + value: jsonEncode(trimmed.map((e) => e.toJson()).toList()), + ); + } + /// 一键退出登录:清 Token + 用户信息 /// (记住的账号密码保留,方便用户下次登录) Future clearAll() async { diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart index 63cc32a..0e1788e 100644 --- a/lib/features/auth/login_page.dart +++ b/lib/features/auth/login_page.dart @@ -2,43 +2,39 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/storage/user_storage.dart'; import 'package:new_learn/core/api/auth/api.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; +import 'package:new_learn/core/components/confirm_box.dart'; +import 'package:new_learn/core/components/center_toast.dart'; /// ============================================================ -/// 【LoginPage】登录页 —— 对照 Vue 版 pages/index.vue 移植 +/// 【LoginPage】登录页 —— 对照 Vue 版 pages/login/index.vue /// -/// 一、布局结构(自上而下): -/// 1. 渐变头部 + 欢迎语(原 Vue 是背景图 + welcome 图) -/// 2. 账号输入框(圆角灰底 + 左侧图标 + 右侧历史下拉箭头) -/// 3. 密码输入框(左侧图标 + 右侧"眼睛"显隐切换) -/// 4. 记住密码 / 忘记密码 -/// 5. 立即登录按钮(loading 态防重复提交) -/// 6. 协议勾选 + 《用户协议》《隐私政策》入口 +/// 一、布局结构(自上而下,对齐 Vue 视觉): +/// 1. 全屏背景图(顶部 CachedNetworkImage + 底部白色圆角卡片盖上去) +/// 2. 欢迎图(品牌 welcome image,Pad/phone 尺寸不同) +/// 3. 白色圆角卡片容器: +/// - 账号输入框(右侧下拉箭头 → Overlay 弹出登录历史) +/// - 密码输入框 +/// - 记住密码 / 忘记密码 +/// - 立即登录按钮 +/// - 注册入口(仅 isAuditMode=true 时显示) +/// - 协议勾选 + 《用户协议》《隐私政策》 /// -/// 二、登录流程(对齐 Vue logInNow): -/// 校验账号/密码 → 校验协议 → 调 UserApi.login(密码 MD5 加密) -/// → 成功: -/// ① token 写进 UserStorage(安全存储)+ DioClient.accessToken(内存,拦截器自动带) -/// ② UserApi.getCurrentUserExtension 拉全量用户信息 → 缓存到 UserStorage -/// → 失败:ApiException.message 直接 Toast,永不接触 Dio 细节 +/// 二、登录历史(对齐 Vue loginHistory): +/// - 最多 3 条,存在 flutter_secure_storage 里一条 JSON +/// - 账号框右侧有历史才显示 ⌄ 箭头 +/// - 点击箭头 / 账号框聚焦 → Overlay 下拉列表(定位到输入框正下方) +/// - 选中某条 → 回填账号,rememberPassword=true 时回填密码 +/// - 登录成功 → 插头部 + 去重 + 截断 3 条 → 持久化 /// -/// 三、与 Vue 的对应关系: -/// uni.showToast → ScaffoldMessenger SnackBar -/// formLogin (ref 对象) → _accountController/_passwordController -/// stringToMd5(password) → UserApi.login 内部已处理(md5) -/// app.setToken / storage → UserStorage.saveToken(flutter_secure_storage) -/// app.fetchCurrentUserExtension → UserApi.getCurrentUserExtension -/// 用户协议/隐私政策 → 空方法(占位,本页先不做跳转) -/// -/// 四、高频坑: -/// 1. TextEditingController 必须在 dispose 里释放(防内存泄漏); -/// 2. 密码框 obscureText 切换要 setState 重建,否则不生效; -/// 3. 登录按钮 loading 时禁用,防止连点发多个请求; -/// 4. 页面里用了 .w/.h/.sp/.r 就不能加 const(运行期才知道尺寸)。 +/// 三、审核模式 / 记住密码 / 登录流程(不变)。 /// ============================================================ class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @@ -48,254 +44,494 @@ class LoginPage extends ConsumerStatefulWidget { } class _LoginPageState extends ConsumerState { - // ---------- 表单控制器(dispose 里必须释放) ---------- + // ---------- 表单控制器 ---------- final TextEditingController _accountController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); - bool _obscurePassword = true; // 密码是否打码 - bool _rememberPassword = true; // 记住密码 - bool _hasAgreed = false; // 已同意协议 - bool _logging = false; // 登录请求中(按钮 loading) + // ---------- 状态 ---------- + bool _obscurePassword = true; + bool _rememberPassword = true; + bool _hasAgreed = false; + bool _logging = false; + bool _isAuditMode = false; - /// 登录历史(对齐 Vue loginHistory,本版只存内存,不落盘) - final List _loginHistory = []; + // ---------- 登录历史 ---------- + final List _loginHistory = []; + bool _showHistory = false; + OverlayEntry? _historyOverlay; + + // 用来算账号框位置 → Overlay 定位 + final GlobalKey _accountFieldKey = GlobalKey(); @override void initState() { super.initState(); - // 回填"记住密码"的账号密码(异步读取,不在 initState 里 await/用 context) - _loadRemembered(); + _loadHistoryAndFill(); + _fetchAuditMode(); } - /// 启动时回填记住的账号密码(密码存在系统安全存储,勾选状态一并恢复) - Future _loadRemembered() async { - final account = await UserStorage.instance.readRememberedAccount(); - final pwd = await UserStorage.instance.readRememberedPassword(); - if (!mounted) return; // 页面已销毁就停止 setState(防泄漏) + /// 启动时:加载历史 → 第一条 rememberPassword=true 的回填账号+密码 → 有历史自动展开下拉 + Future _loadHistoryAndFill() async { + final history = await UserStorage.instance.readLoginHistory(); + if (!mounted) return; setState(() { - if (account != null) _accountController.text = account; - if (pwd != null) { - _passwordController.text = pwd; + _loginHistory + ..clear() + ..addAll(history); + if (history.isNotEmpty && history.first.rememberPassword) { + _accountController.text = history.first.account; + _passwordController.text = history.first.password; _rememberPassword = true; } }); + // 等账号框渲染完再展开(Overlay 定位需要 RenderBox) + if (history.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _showHistoryOverlay(); + }); + } + } + + /// 非阻塞获取审核模式 + Future _fetchAuditMode() async { + try { + final audit = await UserApi.getAuditMode(); + if (!mounted) return; + setState(() => _isAuditMode = audit); + } catch (_) { + // 接口失败保持 false + } } @override void dispose() { - // 内存泄漏红线:控制器必须释放 + _hideHistory(); _accountController.dispose(); _passwordController.dispose(); super.dispose(); } - // ==================== 空方法占位(后续再实现) ==================== + // ==================== 登录历史 Overlay ==================== - /// 《用户协议》跳转(Vue: uni.navigateTo /pages/login/userAgreement) - void _goToAgreement() { - context.push('/user-agreement'); + /// 账号框聚焦 / 点击箭头 → 切换下拉 + void _toggleHistory() { + if (_loginHistory.isEmpty) return; + if (_showHistory) { + _hideHistory(); + } else { + _showHistoryOverlay(); + } } - /// 《隐私政策》跳转(Vue: uni.navigateTo /pages/login/privacyPolicy) - void _goToPrivacy() { - context.push('/privacy-policy'); + void _showHistoryOverlay() { + if (_loginHistory.isEmpty) return; + _hideHistory(); + + final renderBox = + _accountFieldKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null || !mounted) return; + final overlay = Overlay.of(context); + final size = MediaQuery.sizeOf(context); + + final entry = OverlayEntry( + builder: (_) { + // 算账号框位置 + final box = + _accountFieldKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return const SizedBox.shrink(); + final topLeft = box.localToGlobal(Offset.zero); + final fieldWidth = box.size.width; + + // 列表高度:最多 3 条,每条 40.h,加边框圆角 + final itemHeight = 40.h; + final listHeight = itemHeight * _loginHistory.length + 2; // +2 边框 + + return Stack( + children: [ + // 全屏点击遮罩(点任何地方收起) + Positioned.fill( + child: GestureDetector( + onTap: _hideHistory, + behavior: HitTestBehavior.opaque, + child: const SizedBox.expand(), + ), + ), + // 下拉列表(定位到账号框正下方) + Positioned( + top: topLeft.dy + box.size.height + 4.h, + left: topLeft.dx, + width: fieldWidth, + child: Material( + color: Colors.transparent, + child: Container( + height: listHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8.r), + border: Border.all(color: const Color(0xFFEEEEEE)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + offset: Offset(0, 6.h), + blurRadius: 16, + ), + ], + ), + child: ListView.separated( + padding: EdgeInsets.zero, + itemCount: _loginHistory.length, + separatorBuilder: (_, __) => + const Divider(height: 1, color: Color(0xFFF0F0F0)), + itemBuilder: (context, index) { + final item = _loginHistory[index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _selectHistory(index), + child: Container( + height: itemHeight, + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.userName.isNotEmpty + ? item.userName + : item.account, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: const Color(0xFF323233), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (item.userName.isNotEmpty) + Text( + item.account, + style: TextStyle( + fontSize: 11.sp, + color: const Color(0xFF999999), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + GestureDetector( + onTap: () => _deleteHistory(index), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: EdgeInsets.all(8.r), + child: Icon( + Icons.close, + size: 14, + color: const Color(0xFFC0C4CC), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ), + ), + ], + ); + }, + ); + + setState(() => _showHistory = true); + _historyOverlay = entry; + overlay.insert(entry); } - /// 忘记密码(Vue: 弹窗提示"请联系学习官找回密码") - void _forgetPassword() { - _toast('请联系学习官找回密码'); + void _hideHistory() { + _historyOverlay?.remove(); + _historyOverlay = null; + if (mounted && _showHistory) { + setState(() => _showHistory = false); + } } + /// 选中某条历史 → 回填账号 + 可选回填密码 + Future _selectHistory(int index) async { + final item = _loginHistory[index]; + _hideHistory(); + setState(() { + _accountController.text = item.account; + if (item.rememberPassword) { + _passwordController.text = item.password; + _rememberPassword = true; + } else { + _passwordController.text = ''; + } + }); + } + + /// 删除某条历史 → 立即持久化 + Future _deleteHistory(int index) async { + _loginHistory.removeAt(index); + await UserStorage.instance.saveLoginHistory(_loginHistory); + if (!mounted) return; + // 如果删空了,直接收起列表 + if (_loginHistory.isEmpty) { + _hideHistory(); + } else { + // 重绘 Overlay(因为列表长度变了) + _hideHistory(); + _showHistoryOverlay(); + } + } + + /// 登录成功后把本次账号存入历史(插头部 + 去重 + 截断 3 条) + Future _updateLoginHistory({ + required String account, + required String userName, + required String password, + required bool rememberPassword, + }) async { + // 移除同账号的旧记录 + _loginHistory.removeWhere((e) => e.account == account); + // 插头部 + _loginHistory.insert( + 0, + LoginHistoryItem( + account: account, + userName: userName, + password: rememberPassword ? password : '', + rememberPassword: rememberPassword, + ), + ); + // 持久化(saveLoginHistory 内部自动截断到 3 条) + await UserStorage.instance.saveLoginHistory(_loginHistory); + } + + // ==================== 导航 ==================== + + void _goToAgreement() => context.push('/user-agreement'); + void _goToPrivacy() => context.push('/privacy-policy'); + + Future _forgetPassword() async { + await showConfirmBox( + context, + title: '忘记密码', + content: '请联系学习官找回密码!', + showCancel: false, + confirmText: '确定', + ); + } + + void _goToRegister() => context.push('/register'); + // ==================== 登录逻辑 ==================== - /// 点击「立即登录」→ 校验 → 调接口 Future _handleLogin() async { - if (_logging) return; // 防连点 + if (_logging) return; final account = _accountController.text.trim(); final password = _passwordController.text; - // ① 基础校验(对齐 Vue logInNow 的顺序) if (account.isEmpty) { - _toast('请填写账号'); + CenterToast.show(context, '请填写账号'); return; } if (password.isEmpty) { - _toast('请填写密码'); + CenterToast.show(context, '请填写密码'); return; } if (!_hasAgreed) { - _toast('请先阅读并同意《用户协议》和《隐私政策》'); - return; + final agreed = await showConfirmBox( + context, + title: '用户协议及隐私条款', + content: + '感谢您选择AI爱学蝶变!我们非常重视您的个人信息和隐私保护。' + '为了更好地保障您的个人权益,在您使用我们的产品前请务必审慎阅读' + '《隐私政策》和《用户协议》内的所有条款。同意并继续开始使用我们的产品和服务!', + cancelText: '不同意', + confirmText: '同意并继续', + ); + if (!agreed || !mounted) return; + setState(() => _hasAgreed = true); } - // ② 调接口(loading 态) setState(() => _logging = true); try { final res = await UserApi.login(account: account, password: password); - // ③ 成功:Token 进安全存储 + 内存(拦截器自动带 Authorization) - // 防御:token 为空 = 登录响应字段解析失败(比如后端把 token 包在 - // data 里,LoginResponse.fromJson 读的是顶层 json['token'])。 - // 此时不落盘不赋值,直接提示,避免发出不带 token 的请求。 if (res.token.isEmpty) { - if (mounted) { - _toast('登录响应缺少 token 字段,请检查后端返回结构'); - } + if (mounted) _toast('登录响应缺少 token 字段,请检查后端返回结构'); return; } await UserStorage.instance.saveToken(res.token); DioClient.instance.accessToken = res.token; - // ④ 登录后拉取完整用户信息并缓存(对齐 Vue: app.fetchCurrentUserExtension)。 - // - // 为什么登录接口成功后,还要再调一次"获取当前用户信息"接口: - // - /api/v1/User/Login 主要负责鉴权,核心产物是 token; - // - 真正给"我的"页、任务页头部使用的用户资料(头像、班级、积分等), - // 应该以 /api/v1/User/GetCurrentUserExtension 为准; - // - 这样后续页面只读 currentUserProvider,不需要每个页面自己再请求一次。 - // - // forceRefresh: true 的作用: - // - 登录刚成功时,必须拿一份服务器最新用户资料; - // - 不能继续沿用上次缓存,否则可能出现头像、积分、班级等信息还是旧的。 - // - // getCurrentUser(...) 内部职责: - // - 请求最新用户资料; - // - 成功后写入本地缓存(SharedPreferences); - // - 如果网络失败,会优先回退到旧缓存,尽量不打断登录后的进入流程。 try { final user = await UserApi.getCurrentUser(forceRefresh: true); - // ⑤ Riverpod 全局状态:把"完整用户信息"放进 currentUserProvider。 - // - // 这一步完成后: - // - 全 App 里所有 ref.watch(currentUserProvider) 的页面, - // 都能直接拿到当前用户; - // - "我的"页不需要再请求一次用户信息接口; - // - 后续如果头像局部更新,也是在这份全局状态上做 copyWith 即可。 - // - // 这里的 provider 负责"运行中的内存状态"; - // UserStorage.saveUserInfo(...) 负责"重启后还能恢复的本地缓存"; - // 两者配合起来,才是完整的用户信息链路。 ref.read(currentUserProvider.notifier).set(user); + // 存登录历史(userName 从拉到的完整信息里取) + await _updateLoginHistory( + account: account, + userName: user.userName, + password: password, + rememberPassword: _rememberPassword, + ); } catch (_) { debugPrint('⚠ 【用户信息】拉取失败,已跳过缓存'); - } - - // ⑤ 记住密码持久化(勾选才存:账号走 prefs,密码走安全存储) - if (_rememberPassword) { - await UserStorage.instance.saveRememberedAccount(account); - await UserStorage.instance.saveRememberedPassword(password); - } else { - await UserStorage.instance.clearRememberedAccount(); - await UserStorage.instance.clearRememberedPassword(); - } - - // 记录登录历史(内存) - _loginHistory - ..remove(account) - ..insert(0, account); - if (mounted) { - // 登录成功 → context.go('/') 替换为 4 tab 主框架(默认第 1 个"任务"页)。 - // go 是"替换不留历史":按返回键不会退回登录页。 - // 响应式守卫双保险:上面 set(user) 时 authTick++ 会触发守卫重算, - // 已登录访问 /login → 自动踢回 '/'。这里 go('/') 显式跳转, - // 两者目标一致、不冲突;就算哪天去掉守卫,登录后也能正常进主页。 - // 先取根 messenger,跳转后 SnackBar 挂在新页面 Scaffold 上正常显示。 - final messenger = ScaffoldMessenger.of(context); - context.go('/'); - messenger.showSnackBar( - SnackBar( - content: Text('登录成功,欢迎 ${res.info.userName.isEmpty ? account : res.info.userName}'), - duration: const Duration(seconds: 2), - ), + // 即使拉用户信息失败也存历史(userName 留空) + await _updateLoginHistory( + account: account, + userName: '', + password: password, + rememberPassword: _rememberPassword, ); } + + if (mounted) { + final messenger = ScaffoldMessenger.of(context); + context.go('/'); + // messenger.showSnackBar( + // SnackBar( + // content: Text( + // '登录成功,欢迎 ${res.info.userName.isEmpty ? account : res.info.userName}', + // ), + // duration: const Duration(seconds: 2), + // ), + // ); + } } on ApiException catch (e) { - // ④ 失败:只 catch 业务异常,直接展示中文提示 if (mounted) _toast(e.message); } catch (e) { - // 兜底:解析失败等意外 if (mounted) _toast('登录失败,请稍后重试'); } finally { if (mounted) setState(() => _logging = false); } } - /// 统一 Toast(对应 Vue uni.showToast,本版用 SnackBar) void _toast(String msg) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(msg), duration: const Duration(seconds: 2))); + ..showSnackBar( + SnackBar(content: Text(msg), duration: const Duration(seconds: 2)), + ); } // ==================== UI ==================== @override Widget build(BuildContext context) { + final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + return Scaffold( - backgroundColor: Colors.white, - body: SafeArea( - child: SingleChildScrollView( - padding: EdgeInsets.symmetric(horizontal: 25.w), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ---------- 顶部欢迎区 ---------- - SizedBox(height: 30.h), - _buildWelcome(), - SizedBox(height: 40.h), - - // ---------- 账号输入框 ---------- - _buildAccountField(), - SizedBox(height: 15.h), - - // ---------- 密码输入框 ---------- - _buildPasswordField(), - SizedBox(height: 15.h), - - // ---------- 记住密码 / 忘记密码 ---------- - _buildTipsRow(), - SizedBox(height: 30.h), - - // ---------- 立即登录按钮 ---------- - _buildLoginButton(), - SizedBox(height: 25.h), - - // ---------- 协议区 ---------- - _buildAgreementRow(), - SizedBox(height: 20.h), - ], + backgroundColor: const Color(0xFFF5F8FA), + body: Stack( + children: [ + // ① 底层:全屏背景图 + Positioned( + top: 0, + left: 0, + right: 0, + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + isPad + ? '/StaticResource/2026-06-02/welcome-bg-pad1780393110709.png' + : '/K12/materials/img/2026-03-27/779bc42523a24f919cb0d70de40fda51.png', + ), + fit: BoxFit.fitWidth, + alignment: Alignment.topCenter, + placeholder: (_, __) => const SizedBox.shrink(), + errorWidget: (_, __, ___) => + Container(height: 400.h, color: const Color(0xFFE8F0FE)), + ), ), - ), + + // ② 内容层 + SafeArea( + child: Column( + children: [ + SizedBox(height: 12.h), + + // 欢迎图 + Transform.translate( + offset: Offset(-7.5.w, 0), + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + isPad + ? '/StaticResource/2026-06-02/welcome-pad1780393110710.png' + : '/K12/materials/img/2026-03-27/a8b186d3055747ddaece6ad04d17399e.png', + ), + width: isPad ? 473.w : 334.w, + height: isPad ? 270.h : 196.h, + placeholder: (_, __) => + SizedBox(width: 334.w, height: 196.h), + errorWidget: (_, __, ___) => + SizedBox(width: 334.w, height: 196.h), + ), + ), + + // 白色圆角卡片(SingleChildScrollView 吸收键盘弹起时的溢出) + Expanded( + child: Container( + width: double.infinity, + padding: EdgeInsets.fromLTRB( + isPad ? 66.w : 25.w, + 50.h, + isPad ? 66.w : 25.w, + 40.h, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15.r), + topRight: Radius.circular(15.r), + ), + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 账号输入框(key 用来算 Overlay 位置 + 下拉箭头) + _buildAccountField(), + SizedBox(height: 25.h), + + // 密码输入框 + _buildPasswordField(), + SizedBox(height: 25.h), + + _buildTipsRow(), + SizedBox(height: 50.h), + + _buildLoginButton(), + + if (_isAuditMode) ...[ + SizedBox(height: 20.h), + _buildRegisterLink(), + ], + + SizedBox(height: 25.h), + _buildAgreementRow(), + ], + ), + ), + ), + ), + ], + ), + ), + ], ), ); } - /// 欢迎区:品牌名 + 欢迎语(Vue 里是欢迎图) - Widget _buildWelcome() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.school_rounded, size: 32.w, color: const Color(0xFF3B82F6)), - SizedBox(height: 12.h), - Text( - '爱学蝶变', - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w700, - color: const Color(0xFF323233), - ), - ), - SizedBox(height: 6.h), - Text( - '11欢迎回来,请登录后继续学习', - style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)), - ), - ], - ); - } - - /// 账号输入框(灰底圆角 + 左侧图标 + 右侧历史箭头) + /// 账号输入框(带 GlobalKey 给 Overlay 定位用,右侧有历史才显示下拉箭头) Widget _buildAccountField() { return Container( + key: _accountFieldKey, height: 48.h, padding: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( @@ -304,32 +540,51 @@ class _LoginPageState extends ConsumerState { ), child: Row( children: [ - const Icon(Icons.person_outline_rounded, color: Color(0xFF999999)), + CachedNetworkImage( + imageUrl: ImgUrl.build( + '/K12/materials/img/2026-03-27/b80f8d877d1e4cb3b077ca1cb77a6d3a.png', + ), + width: 24.w, + height: 24.h, + placeholder: (_, __) => + const Icon(Icons.person_outline_rounded, size: 24), + errorWidget: (_, __, ___) => + const Icon(Icons.person_outline_rounded, size: 24), + ), SizedBox(width: 10.w), Expanded( child: TextField( controller: _accountController, - style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)), + style: TextStyle(fontSize: 16.sp, color: const Color(0xFF323233)), decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, hintText: '请输入账号', - hintStyle: TextStyle(fontSize: 13.sp, color: const Color(0xFFC0C4CC)), + hintStyle: TextStyle( + fontSize: 13.sp, + color: const Color(0xFFC0C4CC), + ), ), ), ), - // 历史下拉箭头(有历史才显示,点击下拉选择) + // 历史下拉箭头(有历史才显示) if (_loginHistory.isNotEmpty) GestureDetector( - onTap: () => _showHistorySheet(), - child: const Icon(Icons.keyboard_arrow_down, color: Color(0xFFC0C4CC)), + onTap: _toggleHistory, + child: Icon( + _showHistory + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + color: const Color(0xFFC0C4CC), + size: 24, + ), ), ], ), ); } - /// 密码输入框(灰底圆角 + 左侧图标 + 右侧眼睛) + /// 密码输入框 Widget _buildPasswordField() { return Container( height: 48.h, @@ -340,38 +595,77 @@ class _LoginPageState extends ConsumerState { ), child: Row( children: [ - const Icon(Icons.lock_outline_rounded, color: Color(0xFF999999)), + CachedNetworkImage( + imageUrl: ImgUrl.build( + '/K12/materials/img/2026-03-27/07ee4c28ee1f42f8a021a227f56d81f4.png', + ), + width: 24.w, + height: 24.h, + placeholder: (_, __) => + const Icon(Icons.lock_outline_rounded, size: 24), + errorWidget: (_, __, ___) => + const Icon(Icons.lock_outline_rounded, size: 24), + ), SizedBox(width: 10.w), Expanded( child: TextField( controller: _passwordController, obscureText: _obscurePassword, - style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)), + style: TextStyle(fontSize: 16.sp, color: const Color(0xFF323233)), decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, hintText: '请输入密码', - hintStyle: TextStyle(fontSize: 13.sp, color: const Color(0xFFC0C4CC)), + hintStyle: TextStyle( + fontSize: 13.sp, + color: const Color(0xFFC0C4CC), + ), ), ), ), - // 密码显隐切换(setState 重建才生效) GestureDetector( onTap: () => setState(() => _obscurePassword = !_obscurePassword), - child: Icon( - _obscurePassword ? Icons.visibility_off_outlined : Icons.visibility_outlined, - color: const Color(0xFFC0C4CC), - ), + child: _obscurePassword + ? CachedNetworkImage( + imageUrl: ImgUrl.build( + '/K12/materials/img/2026-03-27/a2572338119745c2850e38f726db9f7f.png', + ), + width: 24.w, + height: 24.h, + placeholder: (_, __) => const Icon( + Icons.visibility_off_outlined, + size: 24, + color: Color(0xFFC0C4CC), + ), + errorWidget: (_, __, ___) => const Icon( + Icons.visibility_off_outlined, + size: 24, + color: Color(0xFFC0C4CC), + ), + ) + : CachedNetworkImage( + imageUrl: ImgUrl.build( + '/K12/materials/img/2026-03-27/02221c9e4c60409087f9ec51eed7b2d7.png', + ), + width: 24.w, + height: 24.h, + placeholder: (_, __) => const Icon( + Icons.visibility_outlined, + size: 24, + color: Color(0xFFC0C4CC), + ), + errorWidget: (_, __, ___) => const Icon( + Icons.visibility_outlined, + size: 24, + color: Color(0xFFC0C4CC), + ), + ), ), ], ), ); } - /// 记住密码(勾选) + 忘记密码 - /// 注意(测试抓到的真实 bug):375 宽下两组文字合计 277px > 可用 275px, - /// 会 Row overflow 3px。修复:左边 Expanded 占满 + 间距收窄到 8.w + - /// Flexible 兜底(更窄屏 ellipsis 截断,绝不溢出)。 Widget _buildTipsRow() { return Row( children: [ @@ -384,9 +678,9 @@ class _LoginPageState extends ConsumerState { _rememberPassword ? Icons.check_circle_rounded : Icons.radio_button_unchecked, - size: 15.w, + size: 16.w, color: _rememberPassword - ? const Color(0xFF3B82F6) + ? const Color(0xFF3D7DF5) : const Color(0xFFC0C4CC), ), SizedBox(width: 4.w), @@ -395,7 +689,10 @@ class _LoginPageState extends ConsumerState { '记住密码', maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 13.sp, color: const Color(0xFF323233)), + style: TextStyle( + fontSize: 14.sp, + color: const Color(0xFF323233), + ), ), ), ], @@ -406,14 +703,13 @@ class _LoginPageState extends ConsumerState { onTap: _forgetPassword, child: Text( '忘记密码?', - style: TextStyle(fontSize: 13.sp, color: const Color(0xFF3B82F6)), + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF3D7DF5)), ), ), ], ); } - /// 立即登录按钮(loading 时禁用) Widget _buildLoginButton() { return SizedBox( width: double.infinity, @@ -421,9 +717,13 @@ class _LoginPageState extends ConsumerState { child: FilledButton( onPressed: _logging ? null : _handleLogin, style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF3B82F6), - disabledBackgroundColor: const Color(0xFF3B82F6).withValues(alpha: 0.6), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24.r)), + backgroundColor: const Color(0xFF3D7DF5), + disabledBackgroundColor: const Color( + 0xFF3D7DF5, + ).withValues(alpha: 0.6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24.r), + ), ), child: _logging ? SizedBox( @@ -436,13 +736,32 @@ class _LoginPageState extends ConsumerState { ) : Text( '立即登录', - style: TextStyle(fontSize: 16.sp, color: Colors.white), + style: TextStyle(fontSize: 18.sp, color: Colors.white), ), ), ); } - /// 协议区:勾选同意 + 《用户协议》《隐私政策》 + Widget _buildRegisterLink() { + return Center( + child: GestureDetector( + onTap: _goToRegister, + child: Text.rich( + TextSpan( + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), + children: [ + const TextSpan(text: '没有账号?'), + TextSpan( + text: '去注册', + style: const TextStyle(color: Color(0xFF3D7DF5)), + ), + ], + ), + ), + ), + ); + } + Widget _buildAgreementRow() { return Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -450,27 +769,31 @@ class _LoginPageState extends ConsumerState { InkWell( onTap: () => setState(() => _hasAgreed = !_hasAgreed), child: Icon( - _hasAgreed ? Icons.check_circle_rounded : Icons.radio_button_unchecked, - size: 15.w, - color: _hasAgreed ? const Color(0xFF3B82F6) : const Color(0xFFC0C4CC), + _hasAgreed + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked, + size: 16.w, + color: _hasAgreed + ? const Color(0xFF3D7DF5) + : const Color(0xFFC0C4CC), ), ), SizedBox(width: 6.w), Flexible( child: RichText( text: TextSpan( - style: TextStyle(fontSize: 12.sp, color: const Color(0xFF323233)), + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)), children: [ const TextSpan(text: '我已阅读并同意'), TextSpan( text: '《用户协议》', - style: const TextStyle(color: Color(0xFF3B82F6)), + style: const TextStyle(color: Color(0xFF3D7DF5)), recognizer: TapGestureRecognizer()..onTap = _goToAgreement, ), const TextSpan(text: '和'), TextSpan( text: '《隐私政策》', - style: const TextStyle(color: Color(0xFF3B82F6)), + style: const TextStyle(color: Color(0xFF3D7DF5)), recognizer: TapGestureRecognizer()..onTap = _goToPrivacy, ), ], @@ -480,50 +803,4 @@ class _LoginPageState extends ConsumerState { ], ); } - - /// 登录历史下拉(本版用底部弹层,对应 Vue 的下拉列表) - void _showHistorySheet() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), - builder: (sheetContext) { - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: EdgeInsets.symmetric(vertical: 12.h), - child: Text( - '选择历史账号', - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: const Color(0xFF323233), - ), - ), - ), - for (final account in _loginHistory) - ListTile( - title: Text(account, style: TextStyle(fontSize: 14.sp)), - trailing: IconButton( - icon: const Icon(Icons.close, size: 10, color: Color(0xFFC0C4CC)), - onPressed: () { - setState(() => _loginHistory.remove(account)); - Navigator.of(sheetContext).pop(); - }, - ), - onTap: () { - _accountController.text = account; - Navigator.of(sheetContext).pop(); - }, - ), - ], - ), - ); - }, - ); - } } diff --git a/lib/features/class/class_update_view.dart b/lib/features/class/class_update_view.dart index 7fc4b0f..8d84157 100644 --- a/lib/features/class/class_update_view.dart +++ b/lib/features/class/class_update_view.dart @@ -286,7 +286,7 @@ class _ClassUpdateViewState extends ConsumerState { padding: EdgeInsets.symmetric(vertical: 20.h), child: Center( child: Text( - '没有更多了', + '- AI 爱学蝶变提醒你 没有更多了 -', style: TextStyle( fontSize: 12.sp, color: const Color(0xFFB4C2E0), diff --git a/lib/features/class/my_interact_page.dart b/lib/features/class/my_interact_page.dart index 7698fde..c02c978 100644 --- a/lib/features/class/my_interact_page.dart +++ b/lib/features/class/my_interact_page.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/api/class/api.dart'; import 'package:new_learn/core/api/class/model.dart'; +import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/features/class/widgets/post_list_body.dart'; /// ============================================================ @@ -45,8 +47,10 @@ class _MyInteractPageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF5F8FA), + extendBodyBehindAppBar: true, appBar: AppBar( - backgroundColor: const Color(0xFFF5F8FA), + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, elevation: 0, title: const Text( '我的互动', @@ -63,16 +67,45 @@ class _MyInteractPageState extends State { context.canPop() ? context.pop() : context.go('/class'), ), ), - body: Column( + // 对齐 Vue .activity-box:背景图 + #F5F8FA 底色 + // background-image: url(/StaticResource/2026-04-07/myInteract-bg.png) + // background-size: 100% 540rpx → height: 270.h + BoxFit.fitWidth + body: Stack( children: [ - _buildTabs(), - Expanded( - child: IndexedStack( - index: _currentTab, + Positioned( + top: 0, + left: 0, + right: 0, + child: SizedBox( + height: 270.h, // Vue 540rpx + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-04-07/myInteract-bg.png', + ), + fit: BoxFit.fitWidth, + placeholder: (_, __) => const SizedBox.shrink(), + errorWidget: (_, __, ___) => const SizedBox.shrink(), + ), + ), + ), + // 内容区:顶 padding = 系统状态栏 + AppBar 高度,对齐 Vue NavBar 后的起点 + Padding( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + kToolbarHeight, + ), + child: Column( children: [ - if (_loadedTabs[0]) _UpdateTab(onTapPost: _openDetail), - if (_loadedTabs[1]) _LikeTab(onTapPost: _openDetail), - if (_loadedTabs[2]) _CommentTab(onTapPost: _openDetail), + _buildTabs(), + Expanded( + child: IndexedStack( + index: _currentTab, + children: [ + if (_loadedTabs[0]) _UpdateTab(onTapPost: _openDetail), + if (_loadedTabs[1]) _LikeTab(onTapPost: _openDetail), + if (_loadedTabs[2]) _CommentTab(onTapPost: _openDetail), + ], + ), + ), ], ), ), @@ -127,13 +160,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 +181,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 +202,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/point_rank_view.dart b/lib/features/class/point_rank_view.dart index 92de141..6b8858a 100644 --- a/lib/features/class/point_rank_view.dart +++ b/lib/features/class/point_rank_view.dart @@ -174,7 +174,11 @@ class _PointRankViewState extends ConsumerState { ? Colors.white : Colors.white.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(8.r), - border: active ? null : Border.all(color: Colors.white, width: 1), + // 始终保留 1px border 占位,避免激活/未激活切换时 content 区域差 2px 抖动 + border: Border.all( + color: active ? Colors.transparent : Colors.white, + width: 1, + ), ), // Web 端回退字体行高偏大,固定高度可能溢出 → 内容放不下时自动缩小 child: FittedBox( @@ -190,7 +194,7 @@ class _PointRankViewState extends ConsumerState { color: active ? _primary : const Color(0xFF687FAD), ), ), - SizedBox(height: 4.h), + // SizedBox(height: 0.5.h), Text( '$countText人', style: TextStyle( @@ -202,7 +206,14 @@ class _PointRankViewState extends ConsumerState { ), ), ), - if (active) _buildTriangle(), + // 始终渲染三角形占位,visible 切换避免外层 Column 高度变化抖动 + Visibility( + visible: active, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: _buildTriangle(), + ), ], ), ); @@ -338,11 +349,7 @@ class _PointRankViewState extends ConsumerState { ), ), SizedBox(width: 5.w), - Icon( - _sortDesc ? Icons.arrow_downward : Icons.arrow_upward, - size: 14, - color: _primary, - ), + _buildSortIcons(), ], ), ), @@ -351,6 +358,32 @@ class _PointRankViewState extends ConsumerState { ); } + Widget _buildSortIcons() { + final up = _sortDesc + ? '/StaticResource/2026-07-29/reverseOrder_up_icon1785291938907.png' + : '/StaticResource/2026-07-29/properOrder_up_icon1785291938906.png'; + final down = _sortDesc + ? '/StaticResource/2026-07-29/reverseOrder_down_icon1785291938905.png' + : '/StaticResource/2026-07-29/properOrder_down_icon1785291938907.png'; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CachedNetworkImage( + imageUrl: ImgUrl.build(up), + width: 12.w, + height: 7.h, + fit: BoxFit.contain, + ), + CachedNetworkImage( + imageUrl: ImgUrl.build(down), + width: 12.w, + height: 7.h, + fit: BoxFit.contain, + ), + ], + ); + } + Widget _buildMyRankCard() { final rank = _myRank!; return Container( diff --git a/lib/features/class/widgets/class_update_widgets.dart b/lib/features/class/widgets/class_update_widgets.dart index 649c1cf..696fae0 100644 --- a/lib/features/class/widgets/class_update_widgets.dart +++ b/lib/features/class/widgets/class_update_widgets.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/core/components/image_preview_viewer.dart'; +import 'package:new_learn/core/components/resolve_problem_box.dart'; import 'package:new_learn/core/components/user_circle_avatar.dart'; import 'package:new_learn/core/utils/img_url.dart'; @@ -12,7 +13,8 @@ import 'package:new_learn/core/utils/img_url.dart'; /// 【班级动态组件】由 class_update_view.dart 抽离的展示组件 /// /// PostCard(动态卡片,按 type 分发内容 + 相册 + 点赞评论栏) -/// CardHeader / SubjectTag / ResolveProblemBox / AlbumDesc / AlbumGrid +/// CardHeader / SubjectTag / AlbumDesc / AlbumGrid / LikeCommentBar / ... +/// ResolveProblemBox 已抽离到 core/components/resolve_problem_box.dart /// LikeCommentBar / MetaButton / CommentPreview / CommentInput /// /// 原为 class_update_view.dart 内的私有组件,重命名去下划线后放到本文件。 @@ -263,65 +265,6 @@ class SubjectTag extends StatelessWidget { } } -/// 攻克任务卡(对标 ResolveProblem) -class ResolveProblemBox extends StatelessWidget { - const ResolveProblemBox({super.key, required this.desc}); - - final String desc; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: EdgeInsets.fromLTRB(19.w, 25.h, 19.w, 11.h), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFEAF4FF), Color(0xFFFFFFFF)], - ), - borderRadius: BorderRadius.circular(10.r), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '刚刚攻克了', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w900, - color: Color(0xFF1D2B47), - ), - ), - SizedBox(height: 3.h), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: '「$desc」', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Color(0xFF1D2B47), - ), - ), - const TextSpan( - text: ' 的所有高难考点!', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Color(0xFF45597E), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - /// 纯文字笔记(album-desc,最多 2 行) class AlbumDesc extends StatelessWidget { const AlbumDesc({super.key, required this.text}); @@ -441,15 +384,26 @@ class LikeCommentBar extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ MetaButton( - icon: post.userLikeStatus - ? Icons.favorite - : Icons.favorite_border, + icon: Image.asset( + post.userLikeStatus + ? 'assets/img/class/like-check-icon.png' + : 'assets/img/class/like-icon.png', + width: 13.w, + height: 13.w, + ), count: post.likeCount, active: post.userLikeStatus, onTap: onLike, ), SizedBox(width: 8.w), - MetaButton(icon: Icons.chat_bubble_outline, count: post.replyCount), + MetaButton( + icon: Image.asset( + 'assets/img/class/comment-icon.png', + width: 13.w, + height: 13.w, + ), + count: post.replyCount, + ), ], ), if (showComposer) ...[ @@ -474,7 +428,7 @@ class MetaButton extends StatelessWidget { this.onTap, }); - final IconData icon; + final Widget icon; final int count; final bool active; final VoidCallback? onTap; @@ -496,7 +450,7 @@ class MetaButton extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 13.w, color: color), + icon, SizedBox(width: 4.w), Text('$count', style: TextStyle(fontSize: 13, color: color)), ], diff --git a/lib/features/class/widgets/post_list_body.dart b/lib/features/class/widgets/post_list_body.dart index 9f87e2b..1395db8 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( + '- AI 爱学蝶变提醒你 没有更多了 -', + 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..193bdcc 100644 --- a/lib/features/my/my_page.dart +++ b/lib/features/my/my_page.dart @@ -1,16 +1,14 @@ -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/image_preview_viewer.dart'; +import 'package:new_learn/core/components/confirm_box.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 +45,152 @@ 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: () { + 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), + ], ), ), - // 菜单列表区域(可滚动) - 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,125 +216,49 @@ 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(); } 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'), - ), - const Divider(height: 1), - 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, maxCount: 1); + final picked = await PickerService.instance.showPickSheet( + context, + maxCount: 1, + ); 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, @@ -298,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', @@ -308,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, @@ -315,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(); } } @@ -349,45 +355,32 @@ 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(); } /// 默认头像:用户名首字圆形色块 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 +452,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 +475,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 +557,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 +570,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 +594,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/lib/features/my/my_point/my_point_page.dart b/lib/features/my/my_point/my_point_page.dart index c85028d..2e7379e 100644 --- a/lib/features/my/my_point/my_point_page.dart +++ b/lib/features/my/my_point/my_point_page.dart @@ -1,39 +1,35 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/api/my_point/api.dart'; import 'package:new_learn/core/api/my_point/model.dart'; import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/providers/user_provider.dart'; import 'package:new_learn/core/components/common_paged_list.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; /// ============================================================ -/// 【MyPointPage】我的积分页(点「我的」tab 的"我的积分"进入) +/// 【MyPointPage】我的积分页 —— 对标 Vue pages/myPackage/myPoint/index.vue /// -/// 一、数据来源(对齐 Vue3 端 api/userPoint/index.ts): -/// 1. 顶部积分卡:当前总积分来自 Riverpod 全局用户 -/// currentUser.userPoints(登录时已拉取,无需再请求接口) -/// 2. 积分明细列表:GetUserPointRecordPagedList(游标分页,见下方) +/// 一、数据来源(不动,保持现有实现): +/// 1. 顶部积分数字 → currentUser.userPoints(登录时已拉取) +/// 2. 积分记录分页 → UserPointApi.getRecordPage → CommonPagedList /// -/// 二、列表为什么用 CommonPagedList: -/// 积分记录可能几千条,必须懒加载(只渲染屏幕可见行)+ 上拉分页。 -/// 组件基于 infinite_scroll_pagination 封装(Flutter Favorite 认证), -/// 已内置:下拉刷新 / 触底加载 / 防重入 / 失败重试 / 空状态 / 没有更多。 -/// 页面只做两件事:load(调接口)+ buildItem(画每一行)。 +/// 二、UI 对齐 Vue(本次改动): +/// 1. 背景 #F5F8FA + AppBar 透明(extendBodyBehindAppBar) +/// 2. 顶部大号积分数字 + 装饰色带 + "积分" 小标签(代替原橙色渐变卡) +/// 3. 列表标题栏:左侧主题色竖条 + "积分获取记录" +/// 4. 列表项:SubjectTag("积分") + 消息 + 时间 + 积分值(去掉圆形加减图标) +/// 5. 加分 #1D2B47 深色 / 扣分 #687FAD 浅灰蓝(对齐 Vue CSS) /// -/// 三、页码分页: -/// CommonPagedList 的 load 只给 pageKey(首页 1,之后 +1),业务把 -/// pageKey 当 pageIndex 用。hasNext 用 total 判断:总条数 > 已请求条数。 -/// 对应 Vue3 端 type.ts 的 UserPointRecordRequest{pageIndex,pageSize}。 -/// -/// 四、高频坑: -/// 1. 列表要包在 Expanded 里(Column 里无限高度会报 unbounded height); -/// 2. 积分正负号决定颜色:points>0 绿色加分,<0 橙色扣分; -/// 3. bizTime 后端直接给字符串,不需要自己格式化; -/// 4. go_router ShellRoute push 出来的页面不自动显示返回箭头 -/// (issue #144687),必须手动加 leading,见 build 里 AppBar。 +/// 三、字段类型确认: +/// UserPointRecord.points = int(固定整数) +/// UserModel.userPoints = double(总积分可能有小数) /// ============================================================ + class MyPointPage extends ConsumerStatefulWidget { const MyPointPage({super.key}); @@ -42,8 +38,10 @@ class MyPointPage extends ConsumerStatefulWidget { } class _MyPointPageState extends ConsumerState { - /// 每页条数(CommonPagedList 无 pageSize 概念,由业务在 load 里自定) - static const int _pageSize = 10; + static const int _pageSize = 15; + + /// 展开/收起控制:key = 记录在当前页内的 index + final Set _expandedSet = {}; @override void initState() { @@ -53,42 +51,84 @@ class _MyPointPageState extends ConsumerState { @override Widget build(BuildContext context) { - // 全局用户:当前总积分(登录后才有值;未登录 0) final user = ref.watch(currentUserProvider).value; + // 对齐 Vue isPadDevice():宽度 >= 600dp 视为 Pad + final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + return Scaffold( + // Vue 端整体背景 #F5F8FA + 主题化背景图(Flutter 无图资源,用纯色) + backgroundColor: const Color(0xFFF5F8FA), + extendBodyBehindAppBar: true, appBar: AppBar( - title: const Text('我的积分'), - // go_router 已知 issue #144687:从 ShellRoute(4 tab)内 push 出来的 - // 页面,AppBar 不自动显示返回箭头。这里手动加: - // 能 pop(正常从"我的"进入)→ pop 回上一页; - // 不能 pop(如 Web 深链/刷新直达)→ 兜底回"我的"tab。 + // Vue 端 NavBar 是透明背景 + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, // 滚动时不叠加半透明色,背景保持不变 + elevation: 0, + title: const Text('我的积分', style: TextStyle(color: Color(0xFF1D2B47))), + iconTheme: const IconThemeData(color: Color(0xFF1D2B47)), leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => - context.canPop() ? context.pop() : context.go('/my'), + onPressed: () => context.canPop() ? context.pop() : context.go('/my'), ), ), - body: Column( + // 对齐 Vue .score-page:背景图 + #F5F8FA 底色 + // background-size: 100% auto → BoxFit.fitWidth + body: Stack( children: [ - // ---------- 顶部积分卡 ---------- - _PointHeaderCard(totalPoints: user?.userPoints ?? 0), - // ---------- 积分明细列表(分页) ---------- - Expanded( - child: CommonPagedList( - load: (page) async { - talker.debug('加载积分记录:pageIndex=$page pageSize=$_pageSize'); - // pageKey 当 pageIndex;CommonPagedList 从 1 开始递增 - final res = await UserPointApi.instance.getRecordPage( - pageIndex: page, - pageSize: _pageSize, - ); - return PagingResult( - data: res.data, - hasNext: res.total > page * _pageSize, - ); + // 底层:主题化背景图(Vue themes/default.json → backgroundImage / backgroundImage_pad) + // 用 CachedNetworkImage + ImgUrl.build 加载远端 CDN 图片 + Positioned( + top: 0, + left: 0, + right: 0, + child: SizedBox( + height: isPad + ? 345.h + : 260.h, // Vue topSpaceHeight: 690rpx/360rpx + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + isPad + ? '/StaticResource/2026-06-02/myScore-bg-pad1780381071330.png' + : '/StaticResource/2026-04-07/myScore-bg.png', + ), + fit: BoxFit.fitWidth, + alignment: Alignment.topCenter, + placeholder: (_, __) => const SizedBox.shrink(), + errorWidget: (_, __, ___) => const SizedBox.shrink(), + ), + ), + ), + // 积分数字行(大号数字 + 装饰色带 + "积分" 标签) + // Vue: score-summary-position top: 80rpx(pad: 260rpx) + Positioned( + top: isPad ? 145.h : 125.h, + left: 16.w, + right: 0, + child: _PointHeaderBlock( + totalPoints: user?.userPoints ?? 0.0, + isPad: isPad, + ), + ), + // 白色卡片(叠到渐变背景上 ~40.h) + // Vue: record-section-position margin-top: -100rpx(pad: -140rpx) + Positioned( + top: isPad ? 255.h : 230.h, + left: 14.w, + right: 14.w, + bottom: 0, + child: _RecordListSection( + pageSize: _pageSize, + expandedSet: _expandedSet, + onToggleExpand: (index) { + setState(() { + if (_expandedSet.contains(index)) { + _expandedSet.remove(index); + } else { + _expandedSet.add(index); + } + }); }, - buildItem: (context, item, index) => _RecordRow(record: item), ), ), ], @@ -97,49 +137,237 @@ class _MyPointPageState extends ConsumerState { } } -/// 顶部积分卡:当前总积分 -class _PointHeaderCard extends StatelessWidget { - const _PointHeaderCard({required this.totalPoints}); +/// 顶部积分数字行:大号数字 + 装饰色带 + "积分" 标签 +/// 对齐 Vue .score-summary(Pad/手机 双尺寸) +class _PointHeaderBlock extends StatelessWidget { + const _PointHeaderBlock({required this.totalPoints, required this.isPad}); final double totalPoints; + final bool isPad; + @override + Widget build(BuildContext context) { + // Vue 端字号:pad 132rpx/手机 88rpx → sp;label pad 42rpx/手机 28rpx → sp + final scoreFontSize = isPad ? 66.sp : 44.sp; + final scoreLineHeight = isPad ? 132 / 132 : 103 / 88; + final labelFontSize = isPad ? 21.sp : 14.sp; + final labelMarginBottom = isPad ? 3.h : 3.h; // margin-bottom: 6rpx + + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // 积分数字 + 装饰色带(Stack 叠) + Stack( + clipBehavior: Clip.none, + children: [ + // 装饰色带:Vue themes/default.json → score-summary__decor-background + // linear-gradient(90deg, rgba(61,125,245,0) 0%, rgba(29,156,255,0.2) 35.78%, rgba(138,236,253,0.5) 100%) + Positioned( + left: 0, + right: 0, + bottom: isPad ? -10.h : -5.h, // Vue bottom: -10rpx + child: Container( + height: isPad ? 12.h : 9.h, // height: 18rpx + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.r), + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Color(0x003D7DF5), // rgba(61,125,245,0) + Color(0x331D9CFF), // rgba(29,156,255,0.2) + Color(0x808AECFD), // rgba(138,236,253,0.5) + ], + stops: [0, 0.3578, 1], + ), + ), + ), + ), + // 积分数字 + Text( + _formatTotal(totalPoints), + style: TextStyle( + fontSize: scoreFontSize, + fontWeight: FontWeight.w500, + color: const Color(0xFF1D2B47), + height: scoreLineHeight, + ), + ), + ], + ), + SizedBox(width: 8.w), + // "积分" 小标签 + Padding( + padding: EdgeInsets.only(bottom: labelMarginBottom), + child: Text( + '积分', + style: TextStyle( + fontSize: labelFontSize, + fontWeight: FontWeight.w400, + color: const Color(0xFF687FAD), + ), + ), + ), + ], + ); + } + + /// 总积分格式化:整数不显示 .0,有小数才显示 + String _formatTotal(double v) { + if (v.truncateToDouble() == v) { + return v.toInt().toString(); + } + return v.toString(); + } +} + +/// 积分记录列表区(带标题栏 + 白色圆角卡片) +class _RecordListSection extends StatefulWidget { + const _RecordListSection({ + required this.pageSize, + required this.expandedSet, + required this.onToggleExpand, + }); + + final int pageSize; + final Set expandedSet; + final ValueChanged onToggleExpand; + + @override + State<_RecordListSection> createState() => _RecordListSectionState(); +} + +class _RecordListSectionState extends State<_RecordListSection> { + @override + Widget build(BuildContext context) { + // 对齐 Vue 叠层:header 有顶部圆角,list 有全圆角 + 向上叠 10.h + // list 的顶部圆角从 header 底部露出来(Vue margin-top: -18rpx) + // 整个卡片的底部圆角由 list Container 自己提供(撑到屏幕底) + return Column( + children: [ + // header:仅顶部圆角(Vue border-radius 32rpx 32rpx 0 0) + Container( + width: double.infinity, + height: 71.h, + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.r), + topRight: Radius.circular(16.r), + ), + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFFFFFF), Color(0xFFFFFFFF), Color(0xFFEDF3F7)], + stops: [0, 0.5, 1], + ), + ), + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 3.w, + height: 14.h, + decoration: BoxDecoration( + color: const Color(0xFF3D7DF5), + borderRadius: BorderRadius.circular(10.r), + ), + ), + SizedBox(width: 8.w), + Text( + '积分获取记录', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w400, + color: const Color(0xFF1D2B47), + ), + ), + ], + ), + ), + // list:向上叠 10.h,自己有全圆角(Vue record-list 全圆角 + margin-top: -18rpx) + Expanded( + child: Transform.translate( + offset: Offset(0, -10.h), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16.r), + ), + child: CommonPagedList( + padding: EdgeInsets.zero, + load: (page) async { + talker.debug( + '加载积分记录:pageIndex=$page pageSize=${widget.pageSize}', + ); + final res = await UserPointApi.instance.getRecordPage( + pageIndex: page, + pageSize: widget.pageSize, + ); + return PagingResult( + data: res.data, + hasNext: res.total > page * widget.pageSize, + ); + }, + separatorBuilder: (_, __) => Divider( + height: 1, + thickness: 1, + color: const Color(0xFFEDF3F7), + indent: 14.w, + endIndent: 14.w, + ), + buildItem: (context, item, index) => _RecordRow( + record: item, + expanded: widget.expandedSet.contains(index), + onToggle: () => widget.onToggleExpand(index), + ), + ), + ), + ), + ), + ], + ); + } +} + +/// 列表标题栏:左侧竖条 + "积分获取记录" +/// 对齐 Vue .record-header(渐变背景 + 顶部圆角 + 白色边框) +class _RecordHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Container( width: double.infinity, - margin: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 12.h), - padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 18.h), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFFFF9800), Color(0xFFFFB74D)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, + height: 71.h, + // 仅渐变背景,不设圆角 → 由外层 Container 的 clipBehavior 统一裁剪 + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFFFFFF), Color(0xFFFFFFFF), Color(0xFFEDF3F7)], + stops: [0, 0.5, 1], ), - borderRadius: BorderRadius.circular(16.r), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 16.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Text( - '当前积分', - style: TextStyle(fontSize: 13.sp, color: Colors.white.withValues(alpha: 0.9)), - ), - SizedBox(height: 6.h), - Text( - // 积分可能是小数(接口返回 105.5) - totalPoints.toString(), - style: TextStyle( - fontSize: 34.sp, - color: Colors.white, - fontWeight: FontWeight.bold, + // 左侧主题色竖条 + Container( + width: 3.w, + height: 14.h, + decoration: BoxDecoration( + color: const Color(0xFF3D7DF5), + borderRadius: BorderRadius.circular(10.r), ), ), - SizedBox(height: 8.h), + SizedBox(width: 8.w), Text( - '积分明细 · 变动随时记录', + '积分获取记录', style: TextStyle( - fontSize: 12.sp, - color: Colors.white70, + fontSize: 16.sp, + fontWeight: FontWeight.w400, + color: const Color(0xFF1D2B47), ), ), ], @@ -148,83 +376,135 @@ class _PointHeaderCard extends StatelessWidget { } } -/// 单条积分记录行 +/// 单条积分记录行(对标 Vue .record-item) class _RecordRow extends StatelessWidget { - const _RecordRow({required this.record}); + const _RecordRow({ + required this.record, + required this.expanded, + required this.onToggle, + }); final UserPointRecord record; + final bool expanded; + final VoidCallback onToggle; @override Widget build(BuildContext context) { - // 正负号决定颜色:加分绿 / 扣分橙 / 0 灰 - final isAdd = record.points > 0; - final isSub = record.points < 0; - final Color signColor = isAdd - ? const Color(0xFF2E7D32) - : (isSub ? const Color(0xFFE65100) : const Color(0xFF9E9E9E)); - final Color iconBg = isAdd - ? const Color(0xFFE8F5E9) - : (isSub ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5)); - final String sign = isAdd ? '+' : (isSub ? '-' : ''); + // Vue 端:加分 #1D2B47 深色 / 扣分 #687FAD 浅灰蓝 + final bool isAdd = record.points > 0; + final Color scoreColor = isAdd + ? const Color(0xFF1D2B47) + : const Color(0xFF687FAD); + // points 是 int:加分加 "+",扣分直接显示负数(带 "-") + final String scoreText = isAdd ? '+${record.points}' : '${record.points}'; + + // 简化溢出判断:Vue 用隐藏 DOM 精确测量,Flutter 用字符长度近似 + final bool overflow = record.pointMessage.length > 15; return Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), + padding: EdgeInsets.symmetric(vertical: 15.h, horizontal: 14.w), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 左侧圆形图标(加减符号) - Container( - width: 40.w, - height: 40.h, - decoration: BoxDecoration( - color: iconBg, - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - sign, - style: TextStyle( - fontSize: 22.sp, - color: signColor, - fontWeight: FontWeight.bold, - ), - ), - ), - SizedBox(width: 12.w), - // 中间:说明 + 时间 + // 左侧:SubjectTag + 消息 + 时间 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - record.pointMessage, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w500, - color: const Color(0xFF333333), - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // SubjectTag("积分"):对标 Vue + Container( + margin: EdgeInsets.only(top: 3.h), + padding: EdgeInsets.symmetric( + horizontal: 6.w, + vertical: 1.h, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFF6E3), + borderRadius: BorderRadius.circular(4.r), + ), + child: Text( + '积分', + style: TextStyle( + fontSize: 11.sp, + color: const Color(0xFFE5A301), + ), + ), + ), + SizedBox(width: 8.w), + // 消息文字(可展开/收起) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.pointMessage, + maxLines: expanded ? null : 1, + overflow: expanded + ? TextOverflow.visible + : TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + color: const Color(0xFF1D2B47), + height: 1.6, + ), + ), + if (overflow) + GestureDetector( + onTap: onToggle, + child: Padding( + padding: EdgeInsets.only(top: 5.h), + child: Text( + expanded ? '收起' : '全文', + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFF3D7DF5), + ), + ), + ), + ), + ], + ), + ), + ], ), - SizedBox(height: 4.h), + SizedBox(height: 10.h), + // 时间 Text( - record.bizTime, - style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)), + _formatBizTime(record.bizTime), + style: TextStyle( + fontSize: 12.sp, + color: const Color(0xFFB4C2E0), + ), ), ], ), ), - SizedBox(width: 8.w), + SizedBox(width: 9.w), // 右侧:积分变动值 - Text( - '$sign${record.points.abs()}', - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.bold, - color: signColor, + Padding( + padding: EdgeInsets.only(top: 9.h), + child: Text( + scoreText, + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w400, + color: scoreColor, + ), ), ), ], ), ); } + + /// Vue 端 formatBizTime:替换 T→空格、去掉 Z、截前 19 位 + String _formatBizTime(String bizTime) { + if (bizTime.isEmpty) return ''; + final s = bizTime.replaceAll('T', ' ').replaceAll('Z', ''); + // Dart String.substring 不越界,手动判断长度 + return s.length > 19 ? s.substring(0, 19) : s; + } } diff --git a/lib/main.dart b/lib/main.dart index 9bdb065..d36cab9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:new_learn/core/app.dart'; @@ -108,6 +109,9 @@ Future main() async { // 需要在启动 App 之前读取本地存储、调用原生插件,必须写在 runApp 之前! // 作用: 初始化 Flutter 和原生的绑定桥梁 WidgetsFlutterBinding.ensureInitialized(); + // flutter_inappwebview 平台实现注册(Chrome Web / Windows desktop 必须显式触发) + // 否则会报 InAppWebViewPlatform.instance != null 断言错误 + InAppWebViewPlatform.instance; // go_router 17.x 大坑:命令式导航(context.push/go)默认不写回浏览器 // 地址栏(optionURLReflectsImperativeAPIs 默认 false)。必须在使用 // appRouter(runApp)之前全局开启,否则 web 调试 URL 不随页面变化。 diff --git a/pubspec.lock b/pubspec.lock index 49dc887..5ba30c8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -483,6 +483,70 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.5+1" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.3.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.6.0" flutter_lints: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5101c6e..0180a91 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: new_learn description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -166,6 +166,14 @@ dependencies: # url_launcher:跳转应用商店 / 外部浏览器下载地址 url_launcher: ^6.3.1 + # WebView(内嵌 H5 报告详情页,支持 JS ↔ Flutter 双向通信) + # 为什么用 flutter_inappwebview 而不是 webview_flutter: + # - 需要 H5 内部按钮回调到 Flutter(JS bridge:addJavaScriptHandler) + # - 比官方 webview_flutter 功能强:支持 callAsyncJavaScript、cookie 管理等 + # - 6.1.5 stable 最低 Flutter 3.24 / Dart 3.5,项目 Flutter 3.44.8 完全兼容 + # ⚠ Android 端要求 minSdkVersion >= 19(flutter_inappwebview 6.x 默认) + flutter_inappwebview: ^6.1.5 + # 常驻底部面板(smooth_sheets:3 停靠点拖拽面板,支持内部 ListView 滚动联动) # 用法:lib/core/components/persistent_snap_sheet.dart 封装组件 # 版本:1.1.1(2026-07 发布,支持 persistent + modal 两种模式) @@ -222,7 +230,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. @@ -236,6 +243,8 @@ flutter: - assets/img/tabbar/ - 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: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 41feb02..632804e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); FlutterTimezonePluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ebfc32b..d6b57cc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus file_selector_windows + flutter_inappwebview_windows flutter_secure_storage_windows flutter_timezone gal