From 5e4cfe9ac6df121c4f8f39a1b438dd476b2dace6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E5=AE=9D=E6=96=B0?= Date: Wed, 9 Sep 2026 16:49:51 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E6=8A=A5=E5=91=8A=E5=85=A8=E6=B5=81=E7=A8=8B=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E9=80=82=E9=85=8D=E7=99=BB=E5=BD=95=E5=90=8E?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E8=B7=B3=E8=BD=AC=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了入门测/出门测报告的完整链路: 1. 新增UserInfo模型的出门测报告标记字段与相关接口 2. 新增全局报告路由拦截器,自动在登录后/应用回前台时检查报告状态 3. 新增报告相关API、页面与路由配置,包含欢迎页、H5壳子、AI计算页 4. 适配登录成功后的报告跳转逻辑,更新用户状态管理方法 --- lib/core/api/auth/api.dart | 20 + lib/core/api/auth/model.dart | 5 + lib/core/api/report/api.dart | 46 ++ lib/core/api/report/model.dart | 80 ++++ lib/core/app.dart | 9 +- lib/core/models/user_model.dart | 17 +- lib/core/providers/user_provider.dart | 44 ++ lib/core/report/report_redirect.dart | 184 ++++++++ lib/core/router/app_router.dart | 19 + lib/features/auth/login_page.dart | 26 +- .../report/report_computation_page.dart | 440 ++++++++++++++++++ lib/features/report/report_web_view_page.dart | 300 ++++++++++++ lib/features/report/student_info_page.dart | 269 +++++++++++ 13 files changed, 1441 insertions(+), 18 deletions(-) create mode 100644 lib/core/api/report/api.dart create mode 100644 lib/core/api/report/model.dart create mode 100644 lib/core/report/report_redirect.dart create mode 100644 lib/features/report/report_computation_page.dart create mode 100644 lib/features/report/report_web_view_page.dart create mode 100644 lib/features/report/student_info_page.dart diff --git a/lib/core/api/auth/api.dart b/lib/core/api/auth/api.dart index 3fbf6b2..08f6328 100644 --- a/lib/core/api/auth/api.dart +++ b/lib/core/api/auth/api.dart @@ -137,6 +137,26 @@ class UserApi { } } + // ==================== 报告状态更新 ==================== + + /// 更新入门测试报告显示状态(看完后置回 false) + /// → POST /api/v1/User/ChangeShowReport + /// + /// 对齐 Vue api/user/index.ts 的 UpdateReportStatus(), + /// 在 reportComputation finishedPopup 点"立即开启学习蝶变"后调用。 + static Future changeShowReport() async { + await DioClient.instance.post('/api/v1/User/ChangeShowReport'); + } + + /// 更新出门测试报告显示状态(看完后置回 false) + /// → POST /api/v1/User/ChangeShowAfterReport + /// + /// 对齐 H5 壳子 api/mock/index.ts 的 updateAfterReportStatus(), + /// 在出门测 H5 壳子 navigateReport() 里由 Flutter JS Bridge 拦截后调用。 + static Future changeShowAfterReport() async { + await DioClient.instance.post('/api/v1/User/ChangeShowAfterReport'); + } + /// MD5 加密(UniApp stringToMd5 的等价实现) static String _md5(String text) { return md5.convert(utf8.encode(text)).toString(); diff --git a/lib/core/api/auth/model.dart b/lib/core/api/auth/model.dart index dc593e6..3c1c02c 100644 --- a/lib/core/api/auth/model.dart +++ b/lib/core/api/auth/model.dart @@ -55,6 +55,7 @@ class UserInfo { this.avatar = '', this.phone = '', this.isShowReport = false, + this.isShowAfterReport = false, }); final int userId; @@ -64,6 +65,9 @@ class UserInfo { final String phone; final bool isShowReport; + /// 登录响应里的出门测试报告标记(对齐 Vue api/login/type.ts 的 UserInfo) + final bool isShowAfterReport; + factory UserInfo.fromJson(Map json) { return UserInfo( // userId 可能是字符串(后端统一风格),不能直接 as num @@ -73,6 +77,7 @@ class UserInfo { avatar: json['avatar'] as String? ?? '', phone: json['phone'] as String? ?? '', isShowReport: json['isShowReport'] as bool? ?? false, + isShowAfterReport: json['isShowAfterReport'] as bool? ?? false, ); } } diff --git a/lib/core/api/report/api.dart b/lib/core/api/report/api.dart new file mode 100644 index 0000000..e9a9dec --- /dev/null +++ b/lib/core/api/report/api.dart @@ -0,0 +1,46 @@ +import 'package:new_learn/core/api/report/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【report 模块接口】入门测/出门测诊断报告 —— 对标 Vue3 端 api/report/index.ts +/// +/// 注意:这个模块是"诊断报告包"那条链路的接口(studentInfo / reportComputation 页用), +/// 与 core/api/my_report(历史报告列表)不是同一个业务模块。 +/// +/// getCurrentReport() → 入门测报告(placementTest) +/// getCurrentAfterReport() → 出门测报告(exitTest) +/// getCurrentReportKnow() → 当前用户未掌握知识点(reportComputation AI 动画页用) +/// ============================================================ +class ReportApi { + ReportApi._(); + + static final ReportApi instance = ReportApi._(); + + /// 获取当前用户入门测试报告 → GET /api/v1/UserReport/GetCurrentReport + Future getCurrentReport() async { + final data = await DioClient.instance.get( + '/api/v1/UserReport/GetCurrentReport', + ); + return UserReportType.fromJson(data as Map); + } + + /// 获取当前用户出门测试报告 → GET /api/v1/UserReport/GetCurrentAfterReport + Future getCurrentAfterReport() async { + final data = await DioClient.instance.get( + '/api/v1/UserReport/GetCurrentAfterReport', + ); + return UserReportType.fromJson(data as Map); + } + + /// 获取当前用户未掌握的知识点 → GET /api/v1/UserReport/GetCurrentReportKnow + Future> getCurrentReportKnow() async { + final data = await DioClient.instance.get( + '/api/v1/UserReport/GetCurrentReportKnow', + ); + final list = data is List ? data : (data['data'] as List? ?? const []); + return list + .whereType>() + .map(UserReportKnowType.fromJson) + .toList(); + } +} diff --git a/lib/core/api/report/model.dart b/lib/core/api/report/model.dart new file mode 100644 index 0000000..71a0720 --- /dev/null +++ b/lib/core/api/report/model.dart @@ -0,0 +1,80 @@ +/// ============================================================ +/// 【report 模块模型】入门测/出门测报告 —— 对标 Vue3 端 api/report/type.ts +/// +/// 注意:这个模块是"诊断报告包"(报告状态流转那条链路用的), +/// 与 core/api/my_report(历史报告列表)不是同一个业务模块。 +/// +/// SubjectReportType → 单科目报告(科目名/图标/知识点数/报告URL) +/// UserReportType → 当前用户诊断报告整体(用户名/能力等级/科目列表) +/// UserReportKnowType → 当前用户未掌握的知识点(reportComputation 页用) +/// +/// 序列化:手动 fromJson,与项目其他模块风格一致,不依赖 build_runner。 +/// ============================================================ +library; + +/// 单科目报告信息 +class SubjectReportType { + const SubjectReportType({ + this.subjectName = '', + this.url = '', + this.icon = '', + this.knowledgePoint = 0, + }); + + final String subjectName; + final String url; + final String icon; + final int knowledgePoint; + + factory SubjectReportType.fromJson(Map json) { + return SubjectReportType( + subjectName: (json['subjectName'] as String?) ?? '', + url: (json['url'] as String?) ?? '', + icon: (json['icon'] as String?) ?? '', + knowledgePoint: (json['knowledgePoint'] as num?)?.toInt() ?? 0, + ); + } +} + +/// 当前用户诊断报告整体 +class UserReportType { + const UserReportType({ + this.userName = '', + this.userLevel = '', + this.subjectReports = const [], + }); + + final String userName; + final String userLevel; + final List subjectReports; + + factory UserReportType.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return UserReportType( + userName: (payload['userName'] as String?) ?? '', + userLevel: (payload['userLevel'] as String?) ?? '', + subjectReports: (payload['subjectReports'] as List? ?? const []) + .whereType>() + .map(SubjectReportType.fromJson) + .toList(), + ); + } +} + +/// 当前用户未掌握的知识点(reportComputation AI加载动画页用) +class UserReportKnowType { + const UserReportKnowType({ + this.subjectName = '', + this.knowCount = 0, + }); + + final String subjectName; + final int knowCount; + + factory UserReportKnowType.fromJson(Map json) { + return UserReportKnowType( + subjectName: (json['subjectName'] as String?) ?? '', + knowCount: (json['knowCount'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/lib/core/app.dart b/lib/core/app.dart index dacd5fb..6af2654 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -9,6 +9,7 @@ import 'package:new_learn/core/config/app_config.dart'; import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/providers/network_status_provider.dart'; import 'package:new_learn/core/providers/privacy_provider.dart'; +import 'package:new_learn/core/report/report_redirect.dart'; import 'package:new_learn/core/router/app_router.dart'; import 'package:new_learn/core/services/local_notification_service.dart'; import 'package:new_learn/core/utils/screen_adapt_template.dart'; // 复用 AppAdapt 适配配置 @@ -89,8 +90,12 @@ class TabApp extends StatelessWidget { // 这样冷启动时不会先看到登录页/主页闪一下,再被路由重定向到隐私页, // 而是直接由全屏拦截页先接管。 builder: (context, child) { - final content = - PrivacyStartupGate(child: child ?? const SizedBox.shrink()); + // ReportRedirectNotifier 必须包在 MaterialApp 里面(能拿到 GoRouterState) + // 但在 PrivacyStartupGate 外层(隐私同意后的页面才能拦截) + final withRedirect = ReportRedirectNotifier( + child: child ?? const SizedBox.shrink(), + ); + final content = PrivacyStartupGate(child: withRedirect); final bootstrapped = _LocalNotificationBootstrapper(child: content); if (kReleaseMode) return bootstrapped; return TalkerWrapper( diff --git a/lib/core/models/user_model.dart b/lib/core/models/user_model.dart index d331b9c..b7968fb 100644 --- a/lib/core/models/user_model.dart +++ b/lib/core/models/user_model.dart @@ -30,6 +30,7 @@ class CurrentUserExtension { this.userAvatarReason = '', this.isShowGuide = false, this.isShowReport = false, + this.isShowAfterReport = false, this.id = 0, }); @@ -48,6 +49,9 @@ class CurrentUserExtension { final String userAvatarReason; final bool isShowGuide; final bool isShowReport; + + /// 是否显示出门测试报告(默认 false,后端置 true 后,用户完成报告查看后置回 false) + final bool isShowAfterReport; final int id; /// JSON → 对象(服务器响应) @@ -74,6 +78,7 @@ class CurrentUserExtension { userAvatarReason: payload['userAvatarReason'] as String? ?? '', isShowGuide: payload['isShowGuide'] as bool? ?? false, isShowReport: payload['isShowReport'] as bool? ?? false, + isShowAfterReport: payload['isShowAfterReport'] as bool? ?? false, id: _toInt(payload['id']), ); } @@ -96,12 +101,17 @@ class CurrentUserExtension { 'userAvatarReason': userAvatarReason, 'isShowGuide': isShowGuide, 'isShowReport': isShowReport, + 'isShowAfterReport': isShowAfterReport, 'id': id, }; } - /// 局部更新(目前只用得上 avatar:头像上传成功后替换 URL) - CurrentUserExtension copyWith({String? avatar}) { + /// 局部更新(目前只用得上 avatar / isShowReport / isShowAfterReport) + CurrentUserExtension copyWith({ + String? avatar, + bool? isShowReport, + bool? isShowAfterReport, + }) { return CurrentUserExtension( userId: userId, userName: userName, @@ -117,7 +127,8 @@ class CurrentUserExtension { userAvatarStatus: userAvatarStatus, userAvatarReason: userAvatarReason, isShowGuide: isShowGuide, - isShowReport: isShowReport, + isShowReport: isShowReport ?? this.isShowReport, + isShowAfterReport: isShowAfterReport ?? this.isShowAfterReport, id: id, ); } diff --git a/lib/core/providers/user_provider.dart b/lib/core/providers/user_provider.dart index 3eea190..d9012d1 100644 --- a/lib/core/providers/user_provider.dart +++ b/lib/core/providers/user_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:new_learn/core/api/auth/api.dart'; import 'package:new_learn/core/models/user_model.dart'; import 'package:new_learn/core/storage/user_storage.dart'; @@ -86,6 +87,49 @@ class CurrentUserNotifier extends AsyncNotifier { // 也还是最新头像。 await UserStorage.instance.saveUserInfo(updated); } + + /// 强制从服务器拉最新用户信息(对齐 Vue App.vue 的 fetchCurrentUserExtension(true)) + /// + /// 使用场景: + /// 1. App 回前台(WidgetsBindingObserver resumed):拿最新 isShowReport / isShowAfterReport; + /// 2. 报告页调完 ChangeShowReport / ChangeShowAfterReport 后:刷新全局状态; + /// 3. 登录成功后:确保后端最新数据。 + Future refresh() async { + try { + final ext = await UserApi.getCurrentUserExtension(); + state = AsyncData(ext); + await UserStorage.instance.saveUserInfo(ext); + return ext; + } catch (_) { + // 网络失败:保持现有状态,不要清空(可能本来就是 AsyncData) + return state.value; + } + } + + /// 只改本地报告标记为 false(studentInfo subjectReports 为空时降级处理) + /// + /// 对应 Vue studentInfo.vue 的: + /// userInfo.isShowReport = false; uni.setStorageSync('userInfo', userInfo); + /// app.setUserInfo(Object.assign({}, app.globalData.userInfo, { isShowReport: false })); + /// + /// 注意:不走后端接口!因为 subjectReports 为空意味着后端可能也没报告数据, + /// 直接本地置 false 避免拦截器下次又打回来。 + Future markShowReportFalse() async { + final current = state.value; + if (current == null) return; + final updated = current.copyWith(isShowReport: false); + state = AsyncData(updated); + await UserStorage.instance.saveUserInfo(updated); + } + + /// 只改本地出门测试报告标记为 false(降级场景用) + Future markShowAfterReportFalse() async { + final current = state.value; + if (current == null) return; + final updated = current.copyWith(isShowAfterReport: false); + state = AsyncData(updated); + await UserStorage.instance.saveUserInfo(updated); + } } /// 登录态变化信号(给 go_router 的 refreshListenable 用) diff --git a/lib/core/report/report_redirect.dart b/lib/core/report/report_redirect.dart new file mode 100644 index 0000000..119ccea --- /dev/null +++ b/lib/core/report/report_redirect.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/network/dio_client.dart'; +import 'package:new_learn/core/providers/user_provider.dart'; + +/// ============================================================ +/// 【报告状态全局拦截 + App 生命周期监听】 +/// +/// 对标 Vue App.vue 的 checkAndRedirectToReport() + onShow force 拉 userInfo。 +/// +/// 一、拦截时机(两处): +/// 1. userProvider 状态变化(set() / refresh())→ _checkAndRedirect() +/// :登录成功 / refresh 后都自动检查; +/// 2. App 回前台(WidgetsBindingObserver.resumed)→ force refresh() → 自动检查 +/// :从胶囊面板/后台回来时拉最新 isShowReport; +/// +/// 二、拦截条件(全部满足才跳): +/// ✅ 有登录态(DioClient.accessToken != null) +/// ✅ isShowReport=true 或 isShowAfterReport=true +/// ✅ 当前路由在 tab 页面(动态判断,不硬编码列表) +/// —— go_router 的 StatefulShellRoute.indexedStack 内的路由就是 tab 页 +/// +/// 三、动态判断 tab 页面的方法(不硬编码): +/// 通过 GoRouterState 的 matchedLocation,判断是否能匹配到 ShellBranch 内的路径 +/// (即 /, /recite, /class, /my 这四个——但这些路径是由路由配置决定的, +/// 所以判断逻辑跟着路由配置走,别人改 tab 路径这里自动生效)。 +/// +/// ============================================================ + +/// 是否为 tab 路由(动态判断:4 个 tab 的路径由路由配置决定) +/// +/// go_router 的 StatefulShellBranch 内配置了 4 条路径, +/// 直接从路由表读取所有 ShellBranch 的 path 集合,而不是硬编码。 +/// 但 ShellBranch 的 routes 里只有 GoRoute,path 是字段。 +/// 所以这里采用一个更稳的方式:判断当前是否在 StatefulShellRoute 里, +/// 或者直接在 app_router.dart 里暴露一个 tabPaths 常量。 +/// +/// 目前路由表固定,这里用 tabPaths 常量(从 app_router 导出)。 +import 'package:new_learn/core/router/app_router.dart'; + +/// 拦截器:当 userProvider 变化时检查是否需要跳报告页 +class ReportRedirectNotifier extends ConsumerStatefulWidget { + const ReportRedirectNotifier({super.key, required this.child}); + final Widget child; + + @override + ConsumerState createState() => + _ReportRedirectNotifierState(); +} + +class _ReportRedirectNotifierState extends ConsumerState + with WidgetsBindingObserver { + /// 防止重复跳转标记 + bool _isRedirecting = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + talker.debug('[ReportRedirect] initState 🔥 被创建了'); + // 监听 userProvider 后续变化 + ref.listenManual(currentUserProvider, (prev, next) { + talker.debug('[ReportRedirect] listenManual 触发!prev=$prev next=$next'); + _checkAndRedirect(); + }); + // 立即检查一次!listenManual 只监听变化不会主动触发, + // 登录后 userProvider 已经是 AsyncData 状态了,必须主动调。 + WidgetsBinding.instance.addPostFrameCallback((_) { + talker.debug( + '[ReportRedirect] addPostFrameCallback → 主动调 _checkAndRedirect', + ); + _checkAndRedirect(); + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// App 生命周期变化:resumed 时 force 拉最新 userInfo + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) { + _onAppResumed(); + } + } + + Future _onAppResumed() async { + // 只有已登录才 force 拉 + if (DioClient.instance.accessToken == null) return; + talker.debug('[ReportRedirect] App resumed → force refresh userInfo'); + await ref.read(currentUserProvider.notifier).refresh(); + _checkAndRedirect(); + } + + /// 核心拦截逻辑:判断当前路由 + 报告标记 → 决定跳不跳 + void _checkAndRedirect() { + talker.debug('[ReportRedirect] ===== _checkAndRedirect 开始 ===='); + if (_isRedirecting) { + talker.debug('[ReportRedirect] _isRedirecting=true,跳过'); + return; + } + + final user = ref.read(currentUserProvider).value; + if (user == null) { + talker.debug('[ReportRedirect] user=null(未登录),放行'); + return; + } + + // TODO: 调试报告用,把后端字段叠加调试开关,上线前删掉下面三行 + // const _debugExitTest = false; + // const _debugPlacementTest = false; + const _debugExitTest = false; // true = 强制调试出门测 + const _debugPlacementTest = false; // true = 强制调试入门测 + + talker.debug( + '[ReportRedirect] user.isShowReport=${user.isShowReport} ' + 'user.isShowAfterReport=${user.isShowAfterReport} ' + 'debugExit=$_debugExitTest debugPlacement=$_debugPlacementTest', + ); + + if (!user.isShowReport && + !user.isShowAfterReport && + !_debugExitTest && + !_debugPlacementTest) { + talker.debug('[ReportRedirect] ❌ 无报告标记+无调试开关,放行'); + return; + } + + final isExitTest = user.isShowAfterReport || _debugExitTest; + final reportType = isExitTest + ? 'exitTest' + : (user.isShowReport || _debugPlacementTest ? 'placementTest' : null); + if (reportType == null) { + talker.debug('[ReportRedirect] ❌ reportType=null,放行'); + return; + } + talker.debug('[ReportRedirect] ✅ 通过标记检查,reportType=$reportType'); + + // 当前路由是否在 tab 页面(直接从全局 appRouter 拿,不需要 context) + String location = appRouter.state?.matchedLocation ?? ''; + talker.debug('[ReportRedirect] appRouter.state matchedLocation=$location'); + final inTab = _isInTabRoute(location); + talker.debug( + '[ReportRedirect] location=$location kTabPaths=$kTabPaths inTab=$inTab', + ); + if (!inTab) { + talker.debug('[ReportRedirect] ❌ 不在 tab 页面,跳过拦截'); + return; + } + + talker.debug('[ReportRedirect] ✅✅✅ 触发跳转 → studentInfo?type=$reportType'); + _isRedirecting = true; + + try { + appRouter.go('/studentInfo', extra: {'type': reportType}); + talker.debug('[ReportRedirect] appRouter.go 调用成功'); + } catch (e) { + talker.error('[ReportRedirect] appRouter.go 崩了!', e); + } + + // 重置标记(go_router 会重建 Widget,这个 State 会被 dispose) + Future.delayed(const Duration(milliseconds: 100), () { + _isRedirecting = false; + }); + } + + /// 判断当前路径是否在 tab 路由里(动态,不硬编码) + /// + /// go_router 的 StatefulShellRoute 下的 4 个 ShellBranch 里, + /// 第一个 GoRoute 的 path 就是 tab 路径。我们从 app_router 导出的 + /// tabPaths 集合判断,别人改 tab 路径这里自动生效。 + bool _isInTabRoute(String location) { + return kTabPaths.contains(location); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 326e4d2..9df377c 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -19,6 +19,9 @@ 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/report/report_computation_page.dart'; +import 'package:new_learn/features/report/report_web_view_page.dart'; +import 'package:new_learn/features/report/student_info_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/network/dio_client.dart'; @@ -69,6 +72,12 @@ import 'package:new_learn/core/providers/user_provider.dart'; /// 必须在创建 GoRouter 前全局开启,见 main.dart 的 main()。 /// ============================================================ +/// 【kTabPaths】四个底部 tab 路径集合(动态来源,别人改 tab 路由时同步改这里即可) +/// +/// 全局拦截器 ReportRedirectNotifier 用它判断"当前是否在 tab 页面", +/// 避免硬编码分散在多个地方。 +const kTabPaths = {'/', '/recite', '/class', '/my'}; + /// 全局路由表(对标 Vue 的 router/index.ts) final GoRouter appRouter = GoRouter( navigatorKey: @@ -141,6 +150,16 @@ final GoRouter appRouter = GoRouter( ), GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()), GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()), + // ---------- 诊断报告包三个页面(不在 ShellBranch 内,push 时不显示 tabbar) ---------- + GoRoute(path: '/studentInfo', builder: (_, _) => const StudentInfoPage()), + GoRoute( + path: '/reportWebView', + builder: (_, _) => const ReportWebViewPage(), + ), + GoRoute( + path: '/reportComputation', + builder: (_, _) => const ReportComputationPage(), + ), // ---------- 首次启动隐私协议页 ---------- GoRoute( path: '/privacy-consent', diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart index 0e1788e..e8e5d02 100644 --- a/lib/features/auth/login_page.dart +++ b/lib/features/auth/login_page.dart @@ -381,6 +381,18 @@ class _LoginPageState extends ConsumerState { password: password, rememberPassword: _rememberPassword, ); + + if (mounted) { + // 对齐 Vue login/index.vue L404: 登录成功后检查报告标记 + // isShowReport / isShowAfterReport 任一为 true → 跳报告页 + final hasReport = user.isShowReport || user.isShowAfterReport; + if (hasReport) { + final reportType = user.isShowAfterReport ? 'exitTest' : 'placementTest'; + context.go('/studentInfo', extra: {'type': reportType}); + } else { + context.go('/'); + } + } } catch (_) { debugPrint('⚠ 【用户信息】拉取失败,已跳过缓存'); // 即使拉用户信息失败也存历史(userName 留空) @@ -390,19 +402,7 @@ class _LoginPageState extends ConsumerState { 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), - // ), - // ); + if (mounted) context.go('/'); } } on ApiException catch (e) { if (mounted) _toast(e.message); diff --git a/lib/features/report/report_computation_page.dart b/lib/features/report/report_computation_page.dart new file mode 100644 index 0000000..818b2dd --- /dev/null +++ b/lib/features/report/report_computation_page.dart @@ -0,0 +1,440 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/api/auth/api.dart'; +import 'package:new_learn/core/api/report/api.dart'; +import 'package:new_learn/core/api/report/model.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/providers/user_provider.dart'; + +/// ============================================================ +/// 【ReportComputationPage】AI 加载动画页 —— 对标 Vue reportComputation.vue +/// +/// 入门测 H5 壳子看完所有科目报告 → navigateTo reportComputation → +/// 圆环进度 + 每个科目依次"AI计算中"动画 → finishedPopup 弹窗 → +/// 调 ChangeShowReport → refresh userProvider → switchTab 首页 +/// ============================================================ +class ReportComputationPage extends ConsumerStatefulWidget { + const ReportComputationPage({super.key}); + + @override + ConsumerState createState() => + _ReportComputationPageState(); +} + +class _ReportComputationPageState extends ConsumerState { + List<_SubjectData> _subjectList = []; + int _ringProgress = 0; + bool _showFinishedPopup = false; + int _currentIndex = -1; + Timer? _animTimer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadAndStart()); + } + + @override + void dispose() { + _animTimer?.cancel(); + super.dispose(); + } + + Future _loadAndStart() async { + try { + final results = await Future.wait([ + ReportApi.instance.getCurrentReport(), + ReportApi.instance.getCurrentReportKnow(), + ]); + final report = results[0] as UserReportType; + final knowList = results[1] as List; + + if (report.subjectReports.isEmpty) return; + + final itemTime = (15 / report.subjectReports.length).floor(); + setState(() { + _subjectList = report.subjectReports.map((item) { + final knowItem = knowList.isNotEmpty + ? knowList.firstWhere( + (k) => k.subjectName == item.subjectName, + orElse: () => const UserReportKnowType(), + ) + : const UserReportKnowType(); + return _SubjectData( + subjectReport: item, + knowCount: knowItem.knowCount, + time: itemTime, + ); + }).toList(); + }); + _startSubjects(0); + } catch (e) { + talker.error('reportComputation 加载失败', e); + } + } + + void _startSubjects(int index) { + if (index >= _subjectList.length) { + setState(() { + _ringProgress = 100; + _showFinishedPopup = true; + }); + return; + } + + setState(() { + _currentIndex = index; + _subjectList[index].status = _SubjectStatus.inProgress; + _subjectList[index].progress = 0; + }); + + final totalTimeMs = _subjectList[index].time * 1000; + final startTime = DateTime.now().millisecondsSinceEpoch; + + _animTimer = Timer.periodic(const Duration(milliseconds: 30), (timer) { + final elapsed = DateTime.now().millisecondsSinceEpoch - startTime; + final progress = (elapsed / totalTimeMs * 100).clamp(0, 100).toInt(); + + setState(() { + _subjectList[index].progress = progress; + _ringProgress = + ((index * 100) + progress) ~/ _subjectList.length; + }); + + if (elapsed >= totalTimeMs) { + timer.cancel(); + _animTimer = null; + setState(() { + _subjectList[index].progress = 100; + _subjectList[index].status = _SubjectStatus.finished; + }); + _startSubjects(index + 1); + } + }); + } + + /// finishedPopup 的"立即开启学习蝶变"按钮 + Future _onFinish() async { + try { + await UserApi.changeShowReport(); + talker.debug('changeShowReport 成功'); + await ref.read(currentUserProvider.notifier).refresh(); + } catch (e) { + talker.error('changeShowReport 失败', e); + } + if (!mounted) return; + context.go('/'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0F1A2E), + body: Stack( + children: [ + SafeArea( + child: Column( + children: [ + const SizedBox(height: 16), + const Text( + 'AI智能生成学习计划', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 24), + // 圆环进度 + _ProgressRing(progress: _ringProgress), + const SizedBox(height: 20), + // 科目列表 + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + itemCount: _subjectList.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (_, index) { + return _SubjectItem(data: _subjectList[index]); + }, + ), + ), + ], + ), + ), + // finishedPopup 弹窗 + if (_showFinishedPopup) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: Container( + width: 320, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle, + size: 48, color: Color(0xFF10C79C)), + const SizedBox(height: 12), + const Text( + '报告生成完成', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2B47), + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _onFinish, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF3D7DF5), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + elevation: 0, + ), + child: const Text('立即开启学习蝶变'), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +// ==================== 内部数据类 ==================== + +enum _SubjectStatus { notStarted, inProgress, finished } + +class _SubjectData { + final SubjectReportType subjectReport; + int progress; + _SubjectStatus status; + int time; + int knowCount; + + _SubjectData({ + required this.subjectReport, + required this.time, + required this.knowCount, + this.progress = 0, + this.status = _SubjectStatus.notStarted, + }); +} + +// ==================== 圆环进度组件 ==================== + +class _ProgressRing extends StatelessWidget { + const _ProgressRing({required this.progress}); + final int progress; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 262, + height: 262, + child: Stack( + alignment: Alignment.center, + children: [ + // 背景圈 + SizedBox( + width: 240, + height: 240, + child: CircularProgressIndicator( + value: 1.0, + strokeWidth: 8, + backgroundColor: Colors.white.withValues(alpha: 0.1), + valueColor: const AlwaysStoppedAnimation( + Color(0xFF1D2B47), + ), + ), + ), + // 进度圈 + SizedBox( + width: 240, + height: 240, + child: CircularProgressIndicator( + value: progress / 100, + strokeWidth: 8, + backgroundColor: Colors.transparent, + valueColor: const AlwaysStoppedAnimation( + Color(0xFF3D7DF5), + ), + ), + ), + // 中心文字 + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$progress%', + style: const TextStyle( + color: Colors.white, + fontSize: 40, + fontWeight: FontWeight.w700, + ), + ), + const Text( + 'AI加载中', + style: TextStyle( + color: Color(0xFF687FAD), + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ], + ), + ); + } +} + +// ==================== 科目卡片组件 ==================== + +class _SubjectItem extends StatelessWidget { + const _SubjectItem({required this.data}); + final _SubjectData data; + + @override + Widget build(BuildContext context) { + final isFinished = data.status == _SubjectStatus.finished; + final isInProgress = data.status == _SubjectStatus.inProgress; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isFinished + ? const [Color(0xFFE3FCF4), Color(0xFFF7FCFA), Colors.white] + : const [Color(0xFFE3F7FC), Color(0xFFF7FBFC), Colors.white], + ), + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 6, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + // 状态徽章 + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isFinished + ? const Color(0xFF19CE92) + : isInProgress + ? const Color(0xFF3D7DF5) + : const Color(0xFFC0C4CC), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${data.progress}%', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + // 科目图标 + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: data.subjectReport.icon.isNotEmpty + ? Image.network( + data.subjectReport.icon, + width: 26, + height: 26, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon( + Icons.menu_book, + size: 26, + color: Color(0xFF3D7DF5), + ), + ) + : const Icon( + Icons.menu_book, + size: 26, + color: Color(0xFF3D7DF5), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.subjectReport.subjectName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2B47), + ), + ), + const SizedBox(height: 6), + // 科目内部进度条 + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: data.progress / 100, + backgroundColor: const Color(0xFFE1EAFA), + valueColor: AlwaysStoppedAnimation( + isFinished + ? const Color(0xFF19CE92) + : const Color(0xFF3D7DF5), + ), + minHeight: 4, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + // 知识点数量 + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFE1EAFA), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${data.knowCount}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF3D7DF5), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/report/report_web_view_page.dart b/lib/features/report/report_web_view_page.dart new file mode 100644 index 0000000..ee1d6b8 --- /dev/null +++ b/lib/features/report/report_web_view_page.dart @@ -0,0 +1,300 @@ +import 'package:flutter/foundation.dart'; +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/config/app_config.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【ReportWebViewPage】报告 H5 壳子容器 —— 对标 Vue reportWebView.vue +/// +/// 承载已上线的 H5 壳子(App.K12.Student.Report),它内部自己加载 +/// 每个科目的报告 URL 并提供"跳过/下一步"按钮。 +/// +/// Flutter 端负责: +/// 1. 拼 H5 壳子的加载 URL(baseURL / token / type / safeBottom); +/// 2. 注入 window.uni JS Bridge(switchTab / navigateTo / reLaunch), +/// 让 H5 壳子里已有的 `window.uni.switchTab(...)` 等调用自然生效。 +/// 【通信方式】:用 console.log('[FLUTTER_BRIDGE] xxx url') + onConsoleMessage 监听, +/// 而不是 InAppWebView.callHandler/addJavaScriptHandler。 +/// 原因:callHandler 在真机上首次 onLoadStop 注入时偶尔能通, +/// 但 H5 后续真实点击触发时就失效了(之前加的 _isExiting 锁导致死循环, +/// 去掉锁也没收到调用);console.log 通道每次都能收到,更稳定。 +/// 3. H5 壳子自己调 updateAfterReportStatus() 更新出门测状态, +/// Flutter Bridge 只负责路由跳转,职责单一不重复。 +/// +/// 路由参数(GoRouteState.extra): +/// context.push('/reportWebView', extra: {'type': 'placementTest'}); +/// +/// H5 路径 → Flutter 路由映射表:见 _h5PathMap +/// ============================================================ +class ReportWebViewPage extends ConsumerStatefulWidget { + const ReportWebViewPage({super.key}); + + @override + ConsumerState createState() => _ReportWebViewPageState(); +} + +class _ReportWebViewPageState extends ConsumerState { + InAppWebViewController? _webController; + double _loadingProgress = 0; + String _reportType = 'placementTest'; + + @override + void initState() { + super.initState(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // initState 里不能调 GoRouterState.of(context)(会访问 InheritedWidget), + // didChangeDependencies 在 initState 之后、build 之前立刻调用, + // 赋值时机刚好赶得上 InAppWebView.initialUrlRequest 用 _reportType 拼 URL。 + final extra = + GoRouterState.of(context).extra as Map? ?? {}; + _reportType = (extra['type'] as String?) ?? 'placementTest'; + } + + /// 拼 H5 壳子的加载 URL(对齐 UniApp reportWebView.vue 的 encodeURIComponent 逻辑) + String _buildH5Url() { + final token = DioClient.instance.accessToken ?? ''; + final baseUrl = AppConfig.baseUrl; + final safeBottom = 0; // TODO: 计算安全底部距离 + // 用 Dart Uri 统一编码所有 query param,和 UniApp 的 encodeURIComponent 对齐 + final h5Uri = Uri.parse(AppConfig.reportUrl); + return h5Uri + .replace( + queryParameters: { + 'baseURL': baseUrl, + 'token': token, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + 'safeBottom': safeBottom.toString(), + 'type': _reportType, + }, + ) + .toString(); + } + + /// 注入 window.uni JS Bridge 源码(使用 console 通道通信,比 callHandler 更稳) + String get _uniBridgeScript { + final script = ''' +(function() { + window.uni = window.uni || {}; + + // 通过 console.log 发送指令给 Flutter 端 onConsoleMessage 监听 + function callFlutter(action, url) { + console.log('[FLUTTER_BRIDGE] ' + action + ' ' + url); + } + + window.uni.switchTab = function(options) { + var url = (options && options.url) || ''; + console.log('[uniBridge] switchTab called, url=' + url); + callFlutter('switchTab', url); + if (options && options.success) options.success(); + }; + window.uni.navigateTo = function(options) { + var url = (options && options.url) || ''; + console.log('[uniBridge] navigateTo called, url=' + url); + callFlutter('navigateTo', url); + if (options && options.success) options.success(); + }; + window.uni.reLaunch = function(options) { + var url = (options && options.url) || ''; + console.log('[uniBridge] reLaunch called, url=' + url); + callFlutter('reLaunch', url); + if (options && options.success) options.success(); + }; + console.log('[uniBridge] window.uni injected via console channel'); +})(); +'''; + return script; + } + + @override + Widget build(BuildContext context) { + final h5Url = _buildH5Url(); + talker.debug('ReportWebViewPage loading: $h5Url'); + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: Text('学生报告'), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1D2B47)), + onPressed: () => context.canPop() ? context.pop() : context.go('/'), + ), + ), + body: InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(h5Url)), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + allowsInlineMediaPlayback: true, + useHybridComposition: true, + ), + // 把 H5 壳子的 console 日志转发到 Flutter talker + onConsoleMessage: (controller, consoleMessage) { + final levelName = consoleMessage.messageLevel + .toString() + .split('.') + .last; + final rawMsg = consoleMessage.message; + final msg = '[H5] [$levelName] $rawMsg'; + if (levelName == 'ERROR') { + talker.error(msg); + } else { + talker.debug(msg); + } + // 解析 H5 通过 console 发来的跳转指令: + // console.log('[FLUTTER_BRIDGE] switchTab', '/pages/tabbar/task/task') + // console.log('[FLUTTER_BRIDGE] navigateTo', '/pages/reportPackage/reportComputation/reportComputation') + // console.log('[FLUTTER_BRIDGE] reLaunch', '/pages/xxx/yyy') + if (rawMsg.startsWith('[FLUTTER_BRIDGE]')) { + final parts = rawMsg + .substring('[FLUTTER_BRIDGE]'.length) + .trim() + .split(' '); + if (parts.length >= 2) { + final action = parts[0]; + final url = parts.sublist(1).join(' '); + final flutterPath = _mapH5Path(url); + talker.debug('[consoleBridge] $action $url → $flutterPath'); + switch (action) { + case 'switchTab': + if (mounted) context.go(flutterPath); + break; + case 'navigateTo': + if (mounted) context.go(flutterPath); + break; + case 'reLaunch': + if (mounted) context.go(flutterPath); + break; + } + } + } + }, + onWebViewCreated: (controller) { + _webController = controller; + _registerJsBridge(controller); + }, + onLoadStop: (controller, url) async { + setState(() => _loadingProgress = 1); + // 注入 window.uni JS Bridge + await controller.evaluateJavascript(source: _uniBridgeScript); + }, + onProgressChanged: (controller, progress) { + setState(() => _loadingProgress = progress / 100.0); + }, + ), + bottomNavigationBar: _loadingProgress < 1.0 + ? LinearProgressIndicator( + value: _loadingProgress, + backgroundColor: Colors.transparent, + valueColor: const AlwaysStoppedAnimation(Color(0xFF3D7DF5)), + ) + : null, + ); + } + + /// H5 壳子传的 uni-app 路径 → Flutter 路由路径映射表 + /// + /// H5 壳子里所有 uni.switchTab/navigateTo/reLaunch 都用 uni-app 的路径格式, + /// Flutter Bridge 收到后先过这个表转成 Flutter 自己的路由再跳转。 + static const Map _h5PathMap = { + // switchTab + '/pages/tabbar/task/task': '/', + '/pages/tabbar/recite/recite': '/recite', + '/pages/tabbar/class/class': '/class', + '/pages/tabbar/my/my': '/my', + // navigateTo + '/pages/reportPackage/reportComputation/reportComputation': + '/reportComputation', + // reLaunch + '/pages/reportPackage/studentInfo/studentInfo': '/studentInfo', + '/pages/login/index': '/login', + }; + + /// 把 H5 uni-app 路径转 Flutter 路由,转不了就原样返回 + String _mapH5Path(String h5Path) { + // 去掉 query 参数再匹配 + final pathOnly = h5Path.split('?').first; + return _h5PathMap[pathOnly] ?? h5Path; + } + + // ============================================================ + // JS Bridge:接收 H5 壳子通过 window.uni.switchTab/navigateTo/reLaunch 发起的调用 + // 统一用 uniBridge 一个 handler,参数里带 name 区分 + // ============================================================ + void _registerJsBridge(InAppWebViewController controller) { + // addJavaScriptHandler 在 Chrome Web 平台不支持,跳过即可 + if (kIsWeb) { + talker.debug('[uniBridge] Web平台跳过 registerJsBridge'); + return; + } + controller.addJavaScriptHandler( + handlerName: 'uniBridge', + callback: (args) async { + // 最顶端先打,任何过滤之前 + talker.debug('[uniBridge] 收到 callHandler, args=$args'); + if (args.isEmpty) { + talker.debug('[uniBridge] args 为空,return'); + return; + } + final payload = args.first; + if (payload is! Map) { + talker.debug( + '[uniBridge] payload 不是 Map,return: ${payload.runtimeType}', + ); + return; + } + + final name = payload['name'] as String?; + final options = payload['options'] as Map? ?? {}; + final url = options['url'] as String? ?? ''; + final flutterPath = _mapH5Path(url); + + talker.debug('[uniBridge] $name $url → $flutterPath'); + + switch (name) { + case 'switchTab': + await _handleSwitchTab(flutterPath); + break; + case 'navigateTo': + await _handleNavigateTo(flutterPath); + break; + case 'reLaunch': + await _handleReLaunch(flutterPath); + break; + } + }, + ); + } + + /// H5 壳子调 switchTab:直接跳对应 tab + Future _handleSwitchTab(String flutterPath) async { + if (!mounted) return; + context.go(flutterPath); + } + + /// H5 壳子调 navigateTo:跳到对应 Flutter 页面 + Future _handleNavigateTo(String flutterPath) async { + if (!mounted) return; + context.go(flutterPath); + } + + /// H5 壳子调 reLaunch:重建路由栈跳到对应页面 + Future _handleReLaunch(String flutterPath) async { + if (!mounted) return; + context.go(flutterPath); + } + + @override + void dispose() { + _webController?.dispose(); + super.dispose(); + } +} diff --git a/lib/features/report/student_info_page.dart b/lib/features/report/student_info_page.dart new file mode 100644 index 0000000..28d7d6b --- /dev/null +++ b/lib/features/report/student_info_page.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/api/auth/api.dart'; +import 'package:new_learn/core/api/report/api.dart'; +import 'package:new_learn/core/api/report/model.dart'; +import 'package:new_learn/core/config/app_config.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/network/dio_client.dart'; +import 'package:new_learn/core/providers/user_provider.dart'; + +/// ============================================================ +/// 【StudentInfoPage】诊断报告欢迎介绍页 —— 对标 Vue studentInfo.vue +/// +/// 路由参数(GoRouteState.extra): +/// context.go('/studentInfo', extra: {'type': 'placementTest'}); +/// - type: placementTest(入门测)| exitTest(出门测) +/// +/// 完整链路:登录成功/全局拦截 → 此页 → H5壳子 → reportComputation(入门测) +/// 或 switchTab 首页(出门测) +/// ============================================================ +class StudentInfoPage extends ConsumerStatefulWidget { + const StudentInfoPage({super.key}); + + @override + ConsumerState createState() => _StudentInfoPageState(); +} + +class _StudentInfoPageState extends ConsumerState { + UserReportType _report = const UserReportType(); + bool _loading = true; + String _reportType = 'placementTest'; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final extra = GoRouterState.of(context).extra as Map? ?? {}; + _reportType = (extra['type'] as String?) ?? 'placementTest'; + _loadReport(); + }); + } + + Future _loadReport() async { + setState(() => _loading = true); + try { + final isExit = _reportType == 'exitTest'; + final apiPath = isExit + ? '/api/v1/UserReport/GetCurrentAfterReport' + : '/api/v1/UserReport/GetCurrentReport'; + talker.debug('studentInfo 调 $apiPath'); + final report = isExit + ? await ReportApi.instance.getCurrentAfterReport() + : await ReportApi.instance.getCurrentReport(); + talker.debug( + 'studentInfo 响应: userName=${report.userName} ' + 'subjectReports.length=${report.subjectReports.length} ' + 'raw=${report.subjectReports.toString()}', + ); + if (mounted) { + setState(() { + _report = report; + _loading = false; + }); + } + } catch (e) { + talker.error('加载报告数据失败', e); + if (mounted) setState(() => _loading = false); + } + } + + /// 按钮点击:subjectReports 为空 → 降级置 false 回首页;否则进 H5 壳子 + Future _onStart() async { + if (_report.subjectReports.isEmpty) { + // 降级:只改本地标记,不调后端 + talker.debug('studentInfo: subjectReports 为空,降级本地置 false'); + if (_reportType == 'exitTest') { + await ref.read(currentUserProvider.notifier).markShowAfterReportFalse(); + } else { + await ref.read(currentUserProvider.notifier).markShowReportFalse(); + } + if (mounted) context.go('/'); + return; + } + // 进 H5 壳子 + context.push('/reportWebView', extra: { + 'type': _reportType, + }); + } + + @override + Widget build(BuildContext context) { + final user = ref.watch(currentUserProvider).value; + final userName = _report.userName.isNotEmpty + ? _report.userName + : (user?.userName ?? ''); + + return Scaffold( + backgroundColor: const Color(0xFFF5F8FF), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1D2B47)), + onPressed: () => context.canPop() ? context.pop() : context.go('/'), + ), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : SafeArea( + child: Column( + children: [ + // 顶部问候 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Row( + children: [ + const Text( + 'Hi . ', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + Text( + userName, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ], + ), + ), + // 科目卡片列表 + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(28, 120, 28, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: Text( + '诊断科目分析', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2B47), + ), + ), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: _report.subjectReports.isEmpty + ? const Center( + child: Text( + '暂无数据', + style: TextStyle(color: Color(0xFF687FAD)), + ), + ) + : Wrap( + spacing: 10, + runSpacing: 10, + children: _report.subjectReports + .map((s) => _SubjectCard( + subject: s, + )) + .toList(), + ), + ), + ], + ), + ), + ), + // 底部按钮 + Padding( + padding: const EdgeInsets.fromLTRB(29, 20, 29, 20), + child: SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: _onStart, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF3D7DF5), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + elevation: 0, + ), + child: const Text( + '开启智能诊断报告', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _SubjectCard extends StatelessWidget { + const _SubjectCard({required this.subject}); + final SubjectReportType subject; + + @override + Widget build(BuildContext context) { + return Container( + width: 72, + height: 88, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF5F8FF), Color(0xFFE6EEFF)], + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: subject.icon.isNotEmpty + ? Image.network( + subject.icon, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon( + Icons.menu_book, + size: 40, + color: Color(0xFF3D7DF5), + ), + ) + : const Icon( + Icons.menu_book, + size: 40, + color: Color(0xFF3D7DF5), + ), + ), + const SizedBox(height: 8), + Text( + subject.subjectName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF1D2B47), + ), + ), + ], + ), + ); + } +} From 0fefc786bb3557bd9b2425333d6e02759db9b742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E5=AE=9D=E6=96=B0?= Date: Thu, 10 Sep 2026 11:57:18 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(report):=20=E5=AE=9E=E7=8E=B0AI?= =?UTF-8?q?=E5=AD=A6=E4=B9=A0=E6=8A=A5=E5=91=8A=E8=AE=A1=E7=AE=97=E9=A1=B5?= =?UTF-8?q?=E5=8F=8A=E7=9B=B8=E5=85=B3=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增报告计算进度圆环、科目卡片、完成弹窗组件 2. 重构report_computation_page,替换旧的自定义组件为新封装组件 3. 新增student_info_page的主题资源和UI重构,适配Flutter屏幕适配 4. 新增临时调试入口用于开发测试 --- .../report/report_computation_page.dart | 434 ++++------- lib/features/report/student_info_page.dart | 701 ++++++++++++++---- .../widgets/computation_finished_popup.dart | 171 +++++ .../widgets/computation_progress_ring.dart | 235 ++++++ .../widgets/computation_subject_item.dart | 267 +++++++ lib/main.dart | 44 ++ 6 files changed, 1400 insertions(+), 452 deletions(-) create mode 100644 lib/features/report/widgets/computation_finished_popup.dart create mode 100644 lib/features/report/widgets/computation_progress_ring.dart create mode 100644 lib/features/report/widgets/computation_subject_item.dart diff --git a/lib/features/report/report_computation_page.dart b/lib/features/report/report_computation_page.dart index 818b2dd..55a271a 100644 --- a/lib/features/report/report_computation_page.dart +++ b/lib/features/report/report_computation_page.dart @@ -1,21 +1,48 @@ import 'dart:async'; +import 'package:cached_network_image/cached_network_image.dart'; 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/api/report/api.dart'; import 'package:new_learn/core/api/report/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/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; +import 'package:new_learn/features/report/widgets/computation_finished_popup.dart'; +import 'package:new_learn/features/report/widgets/computation_progress_ring.dart'; +import 'package:new_learn/features/report/widgets/computation_subject_item.dart'; /// ============================================================ /// 【ReportComputationPage】AI 加载动画页 —— 对标 Vue reportComputation.vue /// /// 入门测 H5 壳子看完所有科目报告 → navigateTo reportComputation → -/// 圆环进度 + 每个科目依次"AI计算中"动画 → finishedPopup 弹窗 → +/// 圆环进度 + 每个科目依次"AI计算中"动画 → 500ms 后 finishedPopup 弹窗 → /// 调 ChangeShowReport → refresh userProvider → switchTab 首页 +/// +/// Vue 端用 tPageImg/tPageColor 从 themes/default.json 抽主题; +/// Flutter 端未做主题抽离,本页资源路径/颜色集中在 _ComputationTheme。 /// ============================================================ + +/// 本页主题资源(对标 default.json → pages/reportPackage/reportComputation) +class _ComputationTheme { + // ---- images ---- + static const String bg = + '/StaticResource/2026-06-11/bg-report21781144905849.png'; + static const String bgPad = + '/StaticResource/2026-06-23/bg-report_pad1782196580176.png'; + static const String bubble = + '/StaticResource/2026-06-11/Group33(2)1781142719442.png'; + + // ---- colors ---- + static const Color pageBg = Color(0xFF275FD9); // background +} + +String _img(String path) => ImgUrl.build(path); + class ReportComputationPage extends ConsumerStatefulWidget { const ReportComputationPage({super.key}); @@ -26,15 +53,21 @@ class ReportComputationPage extends ConsumerStatefulWidget { class _ReportComputationPageState extends ConsumerState { List<_SubjectData> _subjectList = []; - int _ringProgress = 0; + double _ringProgress = 0; bool _showFinishedPopup = false; - int _currentIndex = -1; Timer? _animTimer; + /// 每个科目卡片的 key,用于动画切换时自动滚动到可见区域 + /// (对标 Vue scroll-into-view / scrollToItemIfNeeded) + final List _itemKeys = []; + @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadAndStart()); + WidgetsBinding.instance.addPostFrameCallback((_) { + _precacheImages(); + _loadAndStart(); + }); } @override @@ -43,6 +76,18 @@ class _ReportComputationPageState extends ConsumerState { super.dispose(); } + /// 预加载弹窗/背景图,避免首次显示闪烁(对标 Vue onMounted getImageInfo) + void _precacheImages() { + final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + for (final path in [ + isPad ? _ComputationTheme.bgPad : _ComputationTheme.bg, + ComputationFinishedPopup.topLightPadImg, + ComputationFinishedPopup.contentBgImg, + ]) { + precacheImage(CachedNetworkImageProvider(_img(path)), context); + } + } + Future _loadAndStart() async { try { final results = await Future.wait([ @@ -55,20 +100,26 @@ class _ReportComputationPageState extends ConsumerState { if (report.subjectReports.isEmpty) return; final itemTime = (15 / report.subjectReports.length).floor(); + final list = report.subjectReports.map((item) { + final knowItem = knowList.isNotEmpty + ? knowList.firstWhere( + (k) => k.subjectName == item.subjectName, + orElse: () => const UserReportKnowType(), + ) + : const UserReportKnowType(); + return _SubjectData( + subjectReport: item, + knowCount: knowItem.knowCount, + time: itemTime, + ); + }).toList(); + + if (!mounted) return; setState(() { - _subjectList = report.subjectReports.map((item) { - final knowItem = knowList.isNotEmpty - ? knowList.firstWhere( - (k) => k.subjectName == item.subjectName, - orElse: () => const UserReportKnowType(), - ) - : const UserReportKnowType(); - return _SubjectData( - subjectReport: item, - knowCount: knowItem.knowCount, - time: itemTime, - ); - }).toList(); + _subjectList = list; + _itemKeys + ..clear() + ..addAll(List.generate(list.length, (_) => GlobalKey())); }); _startSubjects(0); } catch (e) { @@ -78,18 +129,19 @@ class _ReportComputationPageState extends ConsumerState { void _startSubjects(int index) { if (index >= _subjectList.length) { - setState(() { - _ringProgress = 100; - _showFinishedPopup = true; + setState(() => _ringProgress = 100); + // Vue:最后一个科目完成后延迟 500ms 弹弹窗 + Timer(const Duration(milliseconds: 500), () { + if (mounted) setState(() => _showFinishedPopup = true); }); return; } setState(() { - _currentIndex = index; - _subjectList[index].status = _SubjectStatus.inProgress; + _subjectList[index].status = ComputationSubjectStatus.inProgress; _subjectList[index].progress = 0; }); + _scrollToItem(index); final totalTimeMs = _subjectList[index].time * 1000; final startTime = DateTime.now().millisecondsSinceEpoch; @@ -100,8 +152,7 @@ class _ReportComputationPageState extends ConsumerState { setState(() { _subjectList[index].progress = progress; - _ringProgress = - ((index * 100) + progress) ~/ _subjectList.length; + _ringProgress = (index * 100 + progress) / _subjectList.length; }); if (elapsed >= totalTimeMs) { @@ -109,13 +160,28 @@ class _ReportComputationPageState extends ConsumerState { _animTimer = null; setState(() { _subjectList[index].progress = 100; - _subjectList[index].status = _SubjectStatus.finished; + _subjectList[index].status = ComputationSubjectStatus.finished; }); _startSubjects(index + 1); } }); } + /// 当前科目卡片不完全可见时,滚动到可见区域 + void _scrollToItem(int index) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _itemKeys[index].currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + alignment: 0.2, + ); + } + }); + } + /// finishedPopup 的"立即开启学习蝶变"按钮 Future _onFinish() async { try { @@ -131,37 +197,64 @@ class _ReportComputationPageState extends ConsumerState { @override Widget build(BuildContext context) { + final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + final bottomPadding = MediaQuery.paddingOf(context).bottom; + return Scaffold( - backgroundColor: const Color(0xFF0F1A2E), + backgroundColor: _ComputationTheme.pageBg, body: Stack( children: [ + // ===== 背景图:宽铺满、居顶、高度自适应(Vue mode=widthFix)===== + Positioned( + top: 0, + left: 0, + right: 0, + child: CachedNetworkImage( + imageUrl: _img( + isPad ? _ComputationTheme.bgPad : _ComputationTheme.bg, + ), + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), SafeArea( + bottom: false, child: Column( children: [ - const SizedBox(height: 16), - const Text( - 'AI智能生成学习计划', - style: TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w600, - ), + SizedBox(height: 8.h), + // AI核心算力气泡(454x98rpx) + CachedNetworkImage( + imageUrl: _img(_ComputationTheme.bubble), + width: 227.w, + height: 49.h, + fit: BoxFit.fill, + errorWidget: (_, _, _) => + SizedBox(width: 227.w, height: 49.h), ), - const SizedBox(height: 24), + SizedBox(height: 7.5.h), // 圆环进度 - _ProgressRing(progress: _ringProgress), - const SizedBox(height: 20), + ComputationProgressRing(progress: _ringProgress), // 科目列表 Expanded( child: ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, + padding: EdgeInsets.fromLTRB( + 14.w, + 10.h, + 14.w, + 20.h + bottomPadding, ), itemCount: _subjectList.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), + separatorBuilder: (_, _) => SizedBox(height: 7.5.h), itemBuilder: (_, index) { - return _SubjectItem(data: _subjectList[index]); + final data = _subjectList[index]; + return ComputationSubjectItem( + key: _itemKeys[index], + subjectName: data.subjectReport.subjectName, + iconUrl: data.subjectReport.icon, + knowCount: data.knowCount, + progress: data.progress, + status: data.status, + ); }, ), ), @@ -169,55 +262,7 @@ class _ReportComputationPageState extends ConsumerState { ), ), // finishedPopup 弹窗 - if (_showFinishedPopup) - Positioned.fill( - child: Container( - color: Colors.black54, - child: Center( - child: Container( - width: 320, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.check_circle, - size: 48, color: Color(0xFF10C79C)), - const SizedBox(height: 12), - const Text( - '报告生成完成', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1D2B47), - ), - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: _onFinish, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF3D7DF5), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(25), - ), - elevation: 0, - ), - child: const Text('立即开启学习蝶变'), - ), - ), - ], - ), - ), - ), - ), - ), + if (_showFinishedPopup) ComputationFinishedPopup(onStart: _onFinish), ], ), ); @@ -226,215 +271,16 @@ class _ReportComputationPageState extends ConsumerState { // ==================== 内部数据类 ==================== -enum _SubjectStatus { notStarted, inProgress, finished } - class _SubjectData { final SubjectReportType subjectReport; - int progress; - _SubjectStatus status; - int time; - int knowCount; + int progress = 0; + ComputationSubjectStatus status = ComputationSubjectStatus.notStarted; + final int time; + final int knowCount; _SubjectData({ required this.subjectReport, required this.time, required this.knowCount, - this.progress = 0, - this.status = _SubjectStatus.notStarted, }); } - -// ==================== 圆环进度组件 ==================== - -class _ProgressRing extends StatelessWidget { - const _ProgressRing({required this.progress}); - final int progress; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 262, - height: 262, - child: Stack( - alignment: Alignment.center, - children: [ - // 背景圈 - SizedBox( - width: 240, - height: 240, - child: CircularProgressIndicator( - value: 1.0, - strokeWidth: 8, - backgroundColor: Colors.white.withValues(alpha: 0.1), - valueColor: const AlwaysStoppedAnimation( - Color(0xFF1D2B47), - ), - ), - ), - // 进度圈 - SizedBox( - width: 240, - height: 240, - child: CircularProgressIndicator( - value: progress / 100, - strokeWidth: 8, - backgroundColor: Colors.transparent, - valueColor: const AlwaysStoppedAnimation( - Color(0xFF3D7DF5), - ), - ), - ), - // 中心文字 - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '$progress%', - style: const TextStyle( - color: Colors.white, - fontSize: 40, - fontWeight: FontWeight.w700, - ), - ), - const Text( - 'AI加载中', - style: TextStyle( - color: Color(0xFF687FAD), - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ], - ), - ); - } -} - -// ==================== 科目卡片组件 ==================== - -class _SubjectItem extends StatelessWidget { - const _SubjectItem({required this.data}); - final _SubjectData data; - - @override - Widget build(BuildContext context) { - final isFinished = data.status == _SubjectStatus.finished; - final isInProgress = data.status == _SubjectStatus.inProgress; - - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: isFinished - ? const [Color(0xFFE3FCF4), Color(0xFFF7FCFA), Colors.white] - : const [Color(0xFFE3F7FC), Color(0xFFF7FBFC), Colors.white], - ), - boxShadow: const [ - BoxShadow( - color: Colors.black12, - blurRadius: 6, - offset: Offset(0, 2), - ), - ], - ), - child: Row( - children: [ - // 状态徽章 - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: isFinished - ? const Color(0xFF19CE92) - : isInProgress - ? const Color(0xFF3D7DF5) - : const Color(0xFFC0C4CC), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '${data.progress}%', - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 12), - // 科目图标 - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: data.subjectReport.icon.isNotEmpty - ? Image.network( - data.subjectReport.icon, - width: 26, - height: 26, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon( - Icons.menu_book, - size: 26, - color: Color(0xFF3D7DF5), - ), - ) - : const Icon( - Icons.menu_book, - size: 26, - color: Color(0xFF3D7DF5), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - data.subjectReport.subjectName, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1D2B47), - ), - ), - const SizedBox(height: 6), - // 科目内部进度条 - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: data.progress / 100, - backgroundColor: const Color(0xFFE1EAFA), - valueColor: AlwaysStoppedAnimation( - isFinished - ? const Color(0xFF19CE92) - : const Color(0xFF3D7DF5), - ), - minHeight: 4, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - // 知识点数量 - Container( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFE1EAFA), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${data.knowCount}', - style: const TextStyle( - fontSize: 12, - color: Color(0xFF3D7DF5), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/report/student_info_page.dart b/lib/features/report/student_info_page.dart index 28d7d6b..a62f94a 100644 --- a/lib/features/report/student_info_page.dart +++ b/lib/features/report/student_info_page.dart @@ -1,17 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:new_learn/core/api/auth/api.dart'; import 'package:new_learn/core/api/report/api.dart'; import 'package:new_learn/core/api/report/model.dart'; -import 'package:new_learn/core/config/app_config.dart'; import 'package:new_learn/core/logger/app_talker.dart'; -import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/providers/user_provider.dart'; +import 'package:new_learn/core/utils/img_url.dart'; /// ============================================================ /// 【StudentInfoPage】诊断报告欢迎介绍页 —— 对标 Vue studentInfo.vue /// +/// Vue 端用 tPageImg / tPageCss / tPageColor 从 themes/default.json 抽主题; +/// Flutter 端未做主题抽离,所以直接硬编码 default.json 里这一页的资源路径、 +/// 颜色值、渐变配置。集中在本文件顶部 _StudentTheme 常量,便于后续抽离。 +/// /// 路由参数(GoRouteState.extra): /// context.go('/studentInfo', extra: {'type': 'placementTest'}); /// - type: placementTest(入门测)| exitTest(出门测) @@ -19,6 +21,87 @@ import 'package:new_learn/core/providers/user_provider.dart'; /// 完整链路:登录成功/全局拦截 → 此页 → H5壳子 → reportComputation(入门测) /// 或 switchTab 首页(出门测) /// ============================================================ + +/// 本页用到的所有主题资源(对标 Vue themes/default.json → pages/reportPackage/studentInfo) +class _StudentTheme { + // ---- 图片(default.json images)---- + // Flutter 端用 network 加载,需拼 CDN 域名;这里存相对路径, + // 使用时调 ImgUrl.build(path) + static const String bg = + '/StaticResource/2026-06-10/bg-report1781093056538.png'; + static const String bubble = + '/StaticResource/2026-06-12/report_kuang1781230963682.png'; + static const String welcome = + '/StaticResource/2026-06-10/Group3331781094167604.png'; + static const String analytics = + '/StaticResource/2026-06-10/analytics1781081543912.png'; + static const String logo = '/StaticResource/2026-06-10/logo1781083733383.png'; + static const String aiAnalysisTitle = + '/StaticResource/2026-06-10/ai-analysis-title1781087439752.png'; + static const String analysisTitle = + '/StaticResource/2026-06-22/analysis-title1782110217839.png'; + static const String levelA = + '/StaticResource/2026-06-23/levelA1782178351618.png'; + static const String levelB = + '/StaticResource/2026-06-23/levelB1782178351618.png'; + static const String levelC = + '/StaticResource/2026-06-23/levelC1782178351618.png'; + static const String verify = + '/StaticResource/2026-06-10/verify1781081993368.png'; + static const String starIcon = + '/StaticResource/2026-06-10/star1781089096898.png'; + + // ---- 颜色(default.json colors)---- + static const Color pageBg = Color(0xFFE5F6FE); // background + static const Color highlight = Color(0xFF3D7DF5); + + // ---- 渐变(default.json css 里的 linear-gradient/rgba 转 Flutter)---- + // item-bg: linear-gradient(180deg, #F5FBFF → #E5F4FF) + static const LinearGradient itemBg = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFF5FBFF), Color(0xFFE5F4FF)], + ); + + // item-border-color: linear-gradient(180deg, #FFFFFF → #C9E9FF(50%) → #BAE2FF) + static const LinearGradient itemBorder = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFFFFFF), Color(0xFFC9E9FF), Color(0xFFBAE2FF)], + ); + + // analysis-view-bg: linear-gradient(180deg, #DAF3FF → #D4ECFF(52%) → #E5FAFB) + static const LinearGradient analysisViewBg = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFDAF3FF), Color(0xFFD4ECFF), Color(0xFFE5FAFB)], + ); + + // text-line: 底部渐变条(大字号等级的下划线) + // linear-gradient(90deg, rgba(61,125,245,0) → rgba(29,156,255,0.2) → rgba(138,236,253,0.5)) + static const LinearGradient textLine = LinearGradient( + colors: [Color(0x003D7DF5), Color(0x331D9CFF), Color(0x808AECFD)], + ); + + // button-text-bg: linear-gradient(0deg, #FFFFFF → #E0F2FF) + static const LinearGradient buttonTextBg = LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Color(0xFFFFFFFF), Color(0xFFE0F2FF)], + ); + + // start-button-bg 径向渐变 + #3d7df5(简化:Flutter 径向渐变不太好还原多层 radial, + // 这里用线性渐变近似,视觉上接近即可) + static const LinearGradient startButtonBg = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF5B9BFF), Color(0xFF3D7DF5)], + ); +} + +/// 图片 URL 拼接工具(对标 Vue $img('/StaticResource/xxx'),复用项目 ImgUrl.build) +String _img(String path) => ImgUrl.build(path); + class StudentInfoPage extends ConsumerStatefulWidget { const StudentInfoPage({super.key}); @@ -35,7 +118,8 @@ class _StudentInfoPageState extends ConsumerState { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - final extra = GoRouterState.of(context).extra as Map? ?? {}; + final extra = + GoRouterState.of(context).extra as Map? ?? {}; _reportType = (extra['type'] as String?) ?? 'placementTest'; _loadReport(); }); @@ -45,18 +129,9 @@ class _StudentInfoPageState extends ConsumerState { setState(() => _loading = true); try { final isExit = _reportType == 'exitTest'; - final apiPath = isExit - ? '/api/v1/UserReport/GetCurrentAfterReport' - : '/api/v1/UserReport/GetCurrentReport'; - talker.debug('studentInfo 调 $apiPath'); final report = isExit ? await ReportApi.instance.getCurrentAfterReport() : await ReportApi.instance.getCurrentReport(); - talker.debug( - 'studentInfo 响应: userName=${report.userName} ' - 'subjectReports.length=${report.subjectReports.length} ' - 'raw=${report.subjectReports.toString()}', - ); if (mounted) { setState(() { _report = report; @@ -69,11 +144,8 @@ class _StudentInfoPageState extends ConsumerState { } } - /// 按钮点击:subjectReports 为空 → 降级置 false 回首页;否则进 H5 壳子 Future _onStart() async { if (_report.subjectReports.isEmpty) { - // 降级:只改本地标记,不调后端 - talker.debug('studentInfo: subjectReports 为空,降级本地置 false'); if (_reportType == 'exitTest') { await ref.read(currentUserProvider.notifier).markShowAfterReportFalse(); } else { @@ -82,10 +154,7 @@ class _StudentInfoPageState extends ConsumerState { if (mounted) context.go('/'); return; } - // 进 H5 壳子 - context.push('/reportWebView', extra: { - 'type': _reportType, - }); + context.push('/reportWebView', extra: {'type': _reportType}); } @override @@ -96,170 +165,385 @@ class _StudentInfoPageState extends ConsumerState { : (user?.userName ?? ''); return Scaffold( - backgroundColor: const Color(0xFFF5F8FF), - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1D2B47)), - onPressed: () => context.canPop() ? context.pop() : context.go('/'), - ), - ), + backgroundColor: _StudentTheme.pageBg, body: _loading ? const Center(child: CircularProgressIndicator()) - : SafeArea( - child: Column( + : SizedBox.expand( + child: Stack( children: [ - // 顶部问候 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Row( - children: [ - const Text( - 'Hi . ', - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.w900, - color: Colors.white, - ), - ), - Text( - userName, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w900, - color: Colors.white, - ), - ), - ], + // ===== 背景层(Vue bg-wrapper)铺满 ===== + Positioned.fill( + child: Image.network( + _img(_StudentTheme.bg), + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), ), ), - // 科目卡片列表 - Expanded( + // ===== 内容层(滚动,底部留出按钮高度)===== + SafeArea( child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(28, 120, 28, 20), + padding: const EdgeInsets.only(bottom: 140), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Padding( - padding: EdgeInsets.only(bottom: 12), - child: Text( - '诊断科目分析', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1D2B47), - ), - ), - ), - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: _report.subjectReports.isEmpty - ? const Center( - child: Text( - '暂无数据', - style: TextStyle(color: Color(0xFF687FAD)), - ), - ) - : Wrap( - spacing: 10, - runSpacing: 10, - children: _report.subjectReports - .map((s) => _SubjectCard( - subject: s, - )) - .toList(), - ), - ), + _buildHeader(userName), + const SizedBox(height: 130), + // _buildAnalysisLevel(), + // const SizedBox(height: 15), + _buildSubjectCard(), + const SizedBox(height: 14), + _buildLogo(), ], ), ), ), - // 底部按钮 - Padding( - padding: const EdgeInsets.fromLTRB(29, 20, 29, 20), - child: SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: _onStart, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF3D7DF5), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(25), - ), - elevation: 0, - ), - child: const Text( - '开启智能诊断报告', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), + // ===== 底部按钮(fixed,无边距)===== + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SafeArea(top: false, child: _buildBottomButton()), ), ], ), ), ); } -} -class _SubjectCard extends StatelessWidget { - const _SubjectCard({required this.subject}); - final SubjectReportType subject; + // ===== 顶部问候区 + welcome 插画 ===== + // Vue: bubble-wrap(背景图气泡 + userName)+ welcome-wrap(欢迎插画) + Widget _buildHeader(String userName) { + return Padding( + padding: const EdgeInsets.only(left: 14, right: 14, top: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // bubble 气泡(用 DecoratedBox 做背景图) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + // borderRadius: BorderRadius.circular(12), + image: DecorationImage( + image: NetworkImage(_img(_StudentTheme.bubble)), + fit: BoxFit.fill, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Hi . ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + Text( + userName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w900, + color: Colors.white, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + // welcome 插画(占位,没图时跳过) + Image.network( + _img(_StudentTheme.welcome), + width: 165, + fit: BoxFit.fitWidth, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ], + ), + ); + } - @override - Widget build(BuildContext context) { - return Container( - width: 72, - height: 88, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFF5F8FF), Color(0xFFE6EEFF)], + // ===== 当前档次等级卡片(Vue analysis-view,被注释掉的)===== + Widget _buildAnalysisLevel() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Container( + width: double.infinity, + // analysis-view-bg 渐变外层 + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: _StudentTheme.analysisViewBg, + boxShadow: const [ + BoxShadow( + color: Color(0x1A3D7DF5), + blurRadius: 15, + offset: Offset(0, 5), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(16, 18, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // analysis-title 图 + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Image.network( + _img(_StudentTheme.analysisTitle), + height: 18, + fit: BoxFit.fitHeight, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ], + ), + const SizedBox(height: 12), + // 内层白色卡片:大字号 level + 文字 + Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 18), + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 大字号等级 + text-line 下划线 + Stack( + alignment: Alignment.bottomLeft, + children: [ + Text( + _report.userLevel.isNotEmpty + ? _report.userLevel + : '--', + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2B47), + height: 1.0, + ), + ), + // 底部渐变条 + Positioned( + left: 0, + right: 0, + bottom: -2, + child: Container( + height: 4, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + gradient: _StudentTheme.textLine, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // ai-analysis-title 图("AI 智能诊断评定") + Image.network( + _img(_StudentTheme.aiAnalysisTitle), + height: 14, + fit: BoxFit.fitHeight, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ], + ), + // 右上角 level${level} 图标 + Positioned( + right: -18, + top: -44, + child: _LevelIcon(level: _report.userLevel), + ), + ], + ), + ), + ], ), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: subject.icon.isNotEmpty - ? Image.network( - subject.icon, - width: 40, - height: 40, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon( - Icons.menu_book, - size: 40, - color: Color(0xFF3D7DF5), + ); + } + + // ===== 诊断科目分析卡片 ===== + Widget _buildSubjectCard() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // header: analytics 图标 + title + 数据已校准 + Row( + children: [ + Image.network( + _img(_StudentTheme.analytics), + width: 18, + height: 18, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + const SizedBox(width: 6), + const Text( + '诊断科目分析', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1D2B47), + ), + ), + const Spacer(), + Image.network( + _img(_StudentTheme.verify), + width: 16, + height: 16, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + const SizedBox(width: 3), + const Text( + '数据已校准', + style: TextStyle(fontSize: 11, color: Color(0xFF00CC96)), + ), + ], + ), + const SizedBox(height: 16), + // subject list + _report.subjectReports.isEmpty + ? const Center( + child: Text( + '暂无数据', + style: TextStyle(color: Color(0xFF687FAD)), ), ) - : const Icon( - Icons.menu_book, - size: 40, - color: Color(0xFF3D7DF5), + : Wrap( + spacing: 10, + runSpacing: 10, + children: _report.subjectReports + .map((s) => _SubjectCard(subject: s)) + .toList(), ), + ], + ), + ), + ); + } + + // ===== logo ===== + Widget _buildLogo() { + return Center( + child: Image.network( + _img(_StudentTheme.logo), + width: 65, // 130rpx ≈ 65dp + fit: BoxFit.fitWidth, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ); + } + + // ===== 底部按钮区(fixed 底部,无边距,圆角顶对齐屏幕)===== + Widget _buildBottomButton() { + return Container( + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + style: const TextStyle( + fontSize: 11, + height: 1.6, + color: Color(0xFF495A7A), + ), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.top, + child: Padding( + padding: const EdgeInsets.only(right: 6), + child: Image.network( + _img(_StudentTheme.aiAnalysisTitle), + width: 95, + fit: BoxFit.fitWidth, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ), + ), + const TextSpan(text: '本报告基于'), + const TextSpan( + text: '15年高考真题精研', + style: TextStyle(color: Color(0xFF3D7DF5)), + ), + const TextSpan(text: '、'), + const TextSpan( + text: '50万道名校模拟题库', + style: TextStyle(color: Color(0xFF3D7DF5)), + ), + const TextSpan(text: '及'), + const TextSpan( + text: '3,682个', + style: TextStyle(color: Color(0xFF3D7DF5)), + ), + const TextSpan(text: '底层知识点拓扑结构,通过爱学智算引擎进行深度解析。'), + ], + ), ), - const SizedBox(height: 8), - Text( - subject.subjectName, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Color(0xFF1D2B47), + const SizedBox(height: 10), + // start-button + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton( + onPressed: _onStart, + style: ElevatedButton.styleFrom( + padding: EdgeInsets.zero, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(22), + ), + elevation: 0, + backgroundColor: Colors.transparent, + ), + child: Ink( + decoration: BoxDecoration( + gradient: _StudentTheme.startButtonBg, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: const Color(0x4DFFFFFF), width: 2), + ), + child: Container( + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Image.network( + _img(_StudentTheme.starIcon), + width: 24, + height: 24, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + const SizedBox(width: 6), + const Text( + '开启智能诊断报告', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), ), ), ], @@ -267,3 +551,104 @@ class _SubjectCard extends StatelessWidget { ); } } + +/// 科目卡片 —— item-bg 渐变 + item-border-color 渐变边框 +class _SubjectCard extends StatelessWidget { + const _SubjectCard({required this.subject}); + final SubjectReportType subject; + + @override + Widget build(BuildContext context) { + return Container( + width: 72, // 144rpx + height: 88, // 176rpx + // 渐变边框用双层 Container 实现 + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: _StudentTheme.itemBorder, + ), + padding: const EdgeInsets.all(1), // 边框厚度 + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(11), + gradient: _StudentTheme.itemBg, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: subject.icon.isNotEmpty + ? Image.network( + subject.icon, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Icon( + Icons.menu_book, + size: 40, + color: Color(0xFF3D7DF5), + ), + ) + : const Icon( + Icons.menu_book, + size: 40, + color: Color(0xFF3D7DF5), + ), + ), + const SizedBox(height: 8), + Text( + subject.subjectName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF1D2B47), + ), + ), + ], + ), + ), + ); + } +} + +/// 右上角等级图标:levelA/B/C 图片,加载失败时用占位 +class _LevelIcon extends StatelessWidget { + const _LevelIcon({required this.level}); + final String level; + + @override + Widget build(BuildContext context) { + // 大写化匹配 default.json 的 key + final key = level.toUpperCase(); + final iconPath = switch (key) { + 'A' => _StudentTheme.levelA, + 'B' => _StudentTheme.levelB, + 'C' => _StudentTheme.levelC, + _ => _StudentTheme.levelB, // 兜底 + }; + return Image.network( + _img(iconPath), + width: 110, + height: 110, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Container( + width: 110, + height: 110, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + gradient: _StudentTheme.startButtonBg, + ), + child: Text( + key, + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ); + } +} diff --git a/lib/features/report/widgets/computation_finished_popup.dart b/lib/features/report/widgets/computation_finished_popup.dart new file mode 100644 index 0000000..2021a36 --- /dev/null +++ b/lib/features/report/widgets/computation_finished_popup.dart @@ -0,0 +1,171 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; + +/// ============================================================ +/// 【ComputationFinishedPopup】报告生成完成弹窗 +/// —— 对标 Vue finishedPopup.vue +/// +/// 黑色 60% 遮罩 + 顶部 62% 屏高的 top-light 光效图 + +/// 640x570rpx content-bg 内容图,底部「立即开启学习蝶变」渐变按钮, +/// 入场 0.5s 淡入上移(popup-fade-up)。 +/// ============================================================ +class ComputationFinishedPopup extends StatefulWidget { + const ComputationFinishedPopup({super.key, required this.onStart}); + + final VoidCallback onStart; + + static const String topLightImg = + '/StaticResource/2026-06-11/top-light1781173654372.png'; + static const String topLightPadImg = + '/StaticResource/2026-06-24/top-light_pad1782270110001.png'; + static const String contentBgImg = + '/StaticResource/2026-06-11/content-bg1781173794633.png'; + static const String starImg = + '/StaticResource/2026-06-10/star1781089096898.png'; + + @override + State createState() => + _ComputationFinishedPopupState(); +} + +class _ComputationFinishedPopupState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + )..forward(); + + late final Animation _opacity = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + late final Animation _offset = + Tween(begin: const Offset(0, 0.08), end: Offset.zero).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.sizeOf(context).height; + final isPad = + MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + + return Stack( + children: [ + // popup-mask + const Positioned.fill(child: ColoredBox(color: Color(0x99000000))), + // top-light:宽铺满,高 62%,居顶 + Positioned( + top: 0, + left: 0, + right: 0, + height: screenHeight * 0.62, + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + isPad + ? ComputationFinishedPopup.topLightPadImg + : ComputationFinishedPopup.topLightImg, + ), + fit: BoxFit.fill, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ), + // popup-content:640x570rpx + Center( + child: FadeTransition( + opacity: _opacity, + child: SlideTransition( + position: _offset, + child: SizedBox( + width: 320.w, + height: 310.h, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: CachedNetworkImage( + imageUrl: + ImgUrl.build(ComputationFinishedPopup.contentBgImg), + fit: BoxFit.fill, + errorWidget: (_, _, _) => DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + // gogo-button:90% 宽,距底 50rpx + Positioned( + left: 16.w, + right: 16.w, + bottom: 25.h, + child: _buildStartButton(), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildStartButton() { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onStart, + child: Container( + height: 50.h, // 100rpx + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + // start-button-bg 多层 radial-gradient 用线性渐变近似 + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF6AA2FF), Color(0xFF3D7DF5)], + ), + border: Border.all(color: const Color(0x4DFFFFFF), width: 1.w), + boxShadow: const [ + BoxShadow( + color: Color(0x3D3D7DF5), + blurRadius: 20, + offset: Offset(0, 10), + ), + ], + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + CachedNetworkImage( + imageUrl: ImgUrl.build(ComputationFinishedPopup.starImg), + width: 24.w, + height: 24.w, + errorWidget: (_, _, _) => SizedBox(width: 24.w, height: 24.w), + ), + SizedBox(width: 6.w), + Text( + '立即开启学习蝶变', + style: TextStyle( + fontSize: 16.spMin, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/report/widgets/computation_progress_ring.dart b/lib/features/report/widgets/computation_progress_ring.dart new file mode 100644 index 0000000..0137646 --- /dev/null +++ b/lib/features/report/widgets/computation_progress_ring.dart @@ -0,0 +1,235 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:new_learn/core/utils/img_url.dart'; + +/// ============================================================ +/// 【ComputationProgressRing】AI 计算圆环 —— 对标 Vue ProgressRing.vue +/// +/// 由外到内: +/// 1. 虚线刻度图(700rpx,6s 顺时针慢转) +/// 2. 外圈光环球图(524rpx,1.5s 反向快转) +/// 3. 外层渐变光圈 ellipse-wai(452rpx) +/// 4. 渐变进度弧(370rpx,线宽 20rpx,#8BFFF5 → #006CFF 纵向渐变) +/// 5. 白色内圆 ellipse-nei(300rpx)+ 渐变百分比文字 + 副标题 +/// +/// Vue 用 canvas 画弧;Flutter 用 CustomPaint,渐变方向与 Vue +/// createLinearGradient(center,0 → center,size) 保持一致(纵向)。 +/// ============================================================ +class ComputationProgressRing extends StatefulWidget { + const ComputationProgressRing({super.key, required this.progress}); + + /// 进度值 0-100 + final double progress; + + /// 外圈光环球图(Vue props.outerImg 默认值) + static const String outerImg = + '/StaticResource/2026-06-11/outer1781143064766.png'; + + /// 虚线刻度图(Vue props.dashedLineImg 默认值) + static const String dashedImg = + '/StaticResource/2026-06-11/dashedLine1781143064766.png'; + + @override + State createState() => + _ComputationProgressRingState(); +} + +class _ComputationProgressRingState extends State + with TickerProviderStateMixin { + /// 外圈:Vue rotate-cw 0 → -360deg / 1.5s + late final AnimationController _outerCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + + /// 虚线:Vue rotate-ccw 0 → 360deg / 6s + late final AnimationController _dashedCtrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 6), + )..repeat(); + + @override + void dispose() { + _outerCtrl.dispose(); + _dashedCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = 262.w; // 524rpx + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // 虚线刻度(700rpx,超出 524rpx 容器,允许溢出绘制) + RepaintBoundary( + child: RotationTransition( + turns: _dashedCtrl, + child: _ringImage(ComputationProgressRing.dashedImg, 350.w), + ), + ), + // 外圈光球 + RepaintBoundary( + child: RotationTransition( + turns: ReverseAnimation(_outerCtrl), + child: _ringImage(ComputationProgressRing.outerImg, 262.w), + ), + ), + // ellipse-wai:87.42deg 紫色渐变 + 内发光(内阴影用外发光近似) + Container( + width: 226.w, + height: 226.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + stops: [0.1133, 1.0], + colors: [Color(0x6B5B5DFF), Color(0x0000FFF2)], + ), + border: Border.all(color: const Color(0x33FFFFFF), width: 1.w), + boxShadow: const [ + BoxShadow(color: Color(0x33FFFFFF), blurRadius: 25), + ], + ), + ), + // 进度弧(canvas) + SizedBox( + width: 185.w, + height: 185.w, + child: CustomPaint(painter: _RingArcPainter(widget.progress)), + ), + // ellipse-nei:内圆 + Container( + width: 150.w, + height: 150.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment(-0.6, -0.8), + end: Alignment(0.87, 0.49), + stops: [0.42, 0.82], + colors: [Color(0xFFB1DFFF), Color(0xFFFFFFFF)], + ), + border: Border.all(color: const Color(0x80FFFFFF)), + boxShadow: const [ + BoxShadow( + color: Color(0x0D1D2B47), + blurRadius: 28, + offset: Offset(0, 14), + ), + BoxShadow(color: Color(0x80FFFFFF), blurRadius: 26), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _gradientText( + widget.progress.floor().toString(), + 40.spMin, + height: 1.0, + ), + Padding( + padding: EdgeInsets.only(left: 2.w, bottom: 6.h), + child: _gradientText('%', 14.5.spMin), + ), + ], + ), + SizedBox(height: 2.h), + Text( + 'AI加载中', + style: TextStyle( + fontSize: 14.5.spMin, + fontWeight: FontWeight.bold, + color: const Color(0xFF0F9AFF), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _ringImage(String path, double size) { + return CachedNetworkImage( + imageUrl: ImgUrl.build(path), + width: size, + height: size, + fit: BoxFit.contain, + errorWidget: (_, _, _) => SizedBox(width: size, height: size), + ); + } + + /// 渐变文字:linear-gradient(180deg, #006cff → #529bff) + Widget _gradientText(String text, double fontSize, {double? height}) { + return ShaderMask( + shaderCallback: (rect) => ui.Gradient.linear( + rect.topCenter, + rect.bottomCenter, + const [Color(0xFF006CFF), Color(0xFF529BFF)], + ), + child: Text( + text, + style: TextStyle( + fontSize: fontSize, + height: height, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ); + } +} + +/// 进度弧画笔(对标 Vue canvas arc + 纵向线性渐变 + round 线帽) +class _RingArcPainter extends CustomPainter { + _RingArcPainter(this.progress); + + final double progress; + + @override + void paint(Canvas canvas, Size size) { + final p = progress.clamp(0.0, 100.0); + if (p <= 0) return; + + final stroke = 10.w; // 20rpx + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - stroke) / 2; + + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round + ..shader = ui.Gradient.linear( + Offset(center.dx, center.dy - radius), + Offset(center.dx, center.dy + radius), + const [Color(0xFF8BFFF5), Color(0xFF006CFF)], + ); + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, // 从 12 点方向开始 + 2 * math.pi * p / 100, + false, + paint, + ); + } + + @override + bool shouldRepaint(covariant _RingArcPainter oldDelegate) => + oldDelegate.progress != progress; +} diff --git a/lib/features/report/widgets/computation_subject_item.dart b/lib/features/report/widgets/computation_subject_item.dart new file mode 100644 index 0000000..3d906ec --- /dev/null +++ b/lib/features/report/widgets/computation_subject_item.dart @@ -0,0 +1,267 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:new_learn/core/utils/img_url.dart'; + +/// 科目计算状态(对标 Vue SubjectStatus) +enum ComputationSubjectStatus { notStarted, inProgress, finished } + +/// ============================================================ +/// 【ComputationSubjectItem】AI 计算科目卡片 +/// —— 对标 reportComputation.vue 的 .subject-item +/// +/// 右上角标(subscript):未开始/进行中蓝色闪电底,已完成绿色对勾底; +/// 标题行:科目名 + 科目图标 + ai-plan「AI学习计划+」图; +/// 知识点行 + 底部渐变进度条(进行中蓝 / 已完成绿)。 +/// ============================================================ +class ComputationSubjectItem extends StatelessWidget { + const ComputationSubjectItem({ + super.key, + required this.subjectName, + required this.iconUrl, + required this.knowCount, + required this.progress, + required this.status, + }); + + final String subjectName; + final String iconUrl; + final int knowCount; + + /// 0-100 + final int progress; + final ComputationSubjectStatus status; + + // ---- 图片资源(default.json ai-plan + Vue 内联 $img 角标图)---- + static const String _aiPlanImg = + '/StaticResource/2026-06-11/aiPlan1781157211012.png'; + static const String _subscriptBgBlue = + '/StaticResource/2026-06-11/subscript-bg11781162055363.png'; + static const String _subscriptBgGreen = + '/StaticResource/2026-06-11/subscript-bg21781162055358.png'; + static const String _iconFlash = + '/StaticResource/2026-06-11/subscript-cion1-1781162401643.png'; + static const String _iconCheck = + '/StaticResource/2026-06-11/subscript-cion2-1781162401644.png'; + + bool get _isFinished => status == ComputationSubjectStatus.finished; + + @override + Widget build(BuildContext context) { + return Opacity( + // Vue: 未开始 opacity 0.8 + opacity: status == ComputationSubjectStatus.notStarted ? 0.8 : 1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.r), // 16rpx + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.3, 1], + colors: _isFinished + ? const [Color(0xFFE3FCF4), Color(0xFFF7FCFA), Colors.white] + : const [Color(0xFFE3F7FC), Color(0xFFF7FBFC), Colors.white], + ), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(14.w, 14.w, 14.w, 20.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + SizedBox(height: 16.h), // 知识点行 margin 32rpx 0 + _buildKnowledge(), + SizedBox(height: 16.h), + _buildProgressBar(), + ], + ), + ), + // 右上角标 + Positioned(top: 0, right: 0, child: _buildSubscript()), + ], + ), + ), + ); + } + + // ===== 标题行:科目名 + 科目图标 + AI学习计划图 ===== + Widget _buildTitle() { + return Row( + children: [ + Text( + subjectName, + style: TextStyle( + fontSize: 18.spMin, // 36rpx + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox(width: 6.w), + if (iconUrl.isNotEmpty) + CachedNetworkImage( + imageUrl: ImgUrl.build(iconUrl), + width: 26.w, // 52rpx,widthFix + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => Icon( + Icons.menu_book, + size: 26.w, + color: const Color(0xFF3D7DF5), + ), + ) + else + Icon(Icons.menu_book, size: 26.w, color: const Color(0xFF3D7DF5)), + SizedBox(width: 6.w), + CachedNetworkImage( + imageUrl: ImgUrl.build(_aiPlanImg), + width: 77.w, // 154rpx,widthFix + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ], + ); + } + + // ===== 知识点行(Vue 预计时间已注释,只保留知识点)===== + Widget _buildKnowledge() { + return Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 6.h), + decoration: BoxDecoration( + color: const Color(0xFFF3F8FE), + borderRadius: BorderRadius.circular(4.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '知识点', + style: TextStyle( + fontSize: 12.spMin, + color: const Color(0xFF495A7A), + ), + ), + SizedBox(width: 6.w), + Container( + padding: EdgeInsets.symmetric(horizontal: 3.5.w), + decoration: BoxDecoration( + color: const Color(0xFFE1EAFA), + borderRadius: BorderRadius.circular(4.r), + ), + child: Text( + '$knowCount', + style: TextStyle( + fontSize: 12.spMin, + color: const Color(0xFF495A7A), + ), + ), + ), + ], + ), + ); + } + + // ===== 底部进度条:蓝渐变 / 绿渐变 ===== + Widget _buildProgressBar() { + return SizedBox( + height: 6.h, // 12rpx + child: LayoutBuilder( + builder: (_, constraints) { + final fillWidth = + constraints.maxWidth * (progress.clamp(0, 100) / 100); + return Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFE9EDF5), + borderRadius: BorderRadius.circular(3.h), + ), + ), + ), + Positioned( + left: 0, + top: 0, + bottom: 0, + child: Container( + width: fillWidth, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3.h), + gradient: LinearGradient( + colors: _isFinished + ? const [Color(0xFFB4EADA), Color(0xFF19CE92)] + : const [ + Color(0xFF8AD1FD), + Color(0xFF3C84FF), + Color(0xFF3D7DF5), + ], + stops: _isFinished ? null : const [0, 0.64, 1], + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + + // ===== 右上角标:162x66rpx 背景图 + 状态图标 + 百分比 ===== + Widget _buildSubscript() { + final bgImg = _isFinished ? _subscriptBgGreen : _subscriptBgBlue; + return SizedBox( + width: 81.w, + height: 33.h, + child: Stack( + children: [ + Positioned.fill( + child: CachedNetworkImage( + imageUrl: ImgUrl.build(bgImg), + fit: BoxFit.fill, + errorWidget: (_, _, _) => DecoratedBox( + decoration: BoxDecoration( + color: _isFinished + ? const Color(0xFF19CE92) + : const Color(0xFF3D7DF5), + borderRadius: BorderRadius.only( + topRight: Radius.circular(8.r), + ), + ), + ), + ), + ), + Padding( + padding: EdgeInsets.only(bottom: 5.h), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (status != ComputationSubjectStatus.notStarted) + Padding( + padding: EdgeInsets.only(right: 4.w), + child: CachedNetworkImage( + imageUrl: ImgUrl.build( + _isFinished ? _iconCheck : _iconFlash, + ), + width: 18.w, + height: 18.w, + errorWidget: (_, _, _) => + SizedBox(width: 18.w, height: 18.w), + ), + ), + Text( + '$progress%', + style: TextStyle( + fontSize: 16.spMin, // 32rpx + color: Colors.white, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index d36cab9..66458f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; +// ignore: unused_import —— 调试结束切回 TabApp 时使用 import 'package:new_learn/core/app.dart'; import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/providers/privacy_provider.dart'; +import 'package:new_learn/core/router/app_router.dart'; import 'package:new_learn/core/storage/database/app_database.dart'; import 'package:new_learn/core/storage/privacy_storage.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; /// App 启动入口 —— 真正的业务代码都在 app.dart / pages/ 里 /// @@ -127,4 +131,44 @@ Future main() async { await AppDatabase.instance.init(); // ProviderScope:Riverpod 所有 provider 的容器,必须包在根 runApp(const ProviderScope(child: TabApp())); + // 临时调试用,跑完改回。 + // 注意两点: + // 1. 页面组件用了 .w/.h/.spMin,必须包 ScreenUtilInit(与 TabApp 一致), + // 否则 ScreenUtil 的 late 字段未初始化会抛 LateInitializationError; + // 2. 必须用真实 appRouter(MaterialApp.router),不能用裸 MaterialApp(home:), + // 否则页面里 context.go('/') 找不到 GoRouter,点"立即开启学习蝶变"不会跳首页。 + // runApp(const ProviderScope(child: _DebugApp())); +} + +/// 临时调试壳:ScreenUtilInit + 真实路由表,启动后直接进 /reportComputation +class _DebugApp extends StatefulWidget { + const _DebugApp(); + + @override + State<_DebugApp> createState() => _DebugAppState(); +} + +class _DebugAppState extends State<_DebugApp> { + @override + void initState() { + super.initState(); + // 首帧后跳到调试页(默认初始位置是 '/',直接进会先闪一帧首页) + WidgetsBinding.instance.addPostFrameCallback((_) { + appRouter.go('/reportComputation'); + }); + } + + @override + Widget build(BuildContext context) { + return ScreenUtilInit( + designSize: AppAdapt.designSizeOf(context), + minTextAdapt: true, + splitScreenMode: true, + fontSizeResolver: AppAdapt.fontSizeResolver, + builder: (context, _) => MaterialApp.router( + debugShowCheckedModeBanner: false, + routerConfig: appRouter, + ), + ); + } } From 96ab0221a5883734774c5b32be60c41abae510f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E5=AE=9D=E6=96=B0?= Date: Thu, 10 Sep 2026 15:59:11 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(my):=20add=20=E6=88=91=E7=9A=84?= =?UTF-8?q?=E8=80=83=E8=AF=95=20page=20and=20related=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增我的考试页面,包含学科筛选Tab和分页考试列表 2. 新增学科图标工具类,替换我的报告页重复的学科图标逻辑 3. 在我的页面菜单中添加我的考试入口 4. 新增exam模块的API接口和数据模型 5. 优化我的报告页面卡片样式,统一与考试卡片的布局 --- lib/core/api/exam/api.dart | 51 ++ lib/core/api/exam/model.dart | 109 +++++ lib/core/router/app_router.dart | 5 +- lib/core/utils/subject_icons.dart | 28 ++ lib/features/my/exam/exam_page.dart | 435 ++++++++++++++++++ lib/features/my/my_page.dart | 8 + lib/features/my/my_report/my_report_page.dart | 268 ++++++----- 7 files changed, 790 insertions(+), 114 deletions(-) create mode 100644 lib/core/api/exam/api.dart create mode 100644 lib/core/api/exam/model.dart create mode 100644 lib/core/utils/subject_icons.dart create mode 100644 lib/features/my/exam/exam_page.dart diff --git a/lib/core/api/exam/api.dart b/lib/core/api/exam/api.dart new file mode 100644 index 0000000..586da56 --- /dev/null +++ b/lib/core/api/exam/api.dart @@ -0,0 +1,51 @@ +import 'package:new_learn/core/api/exam/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【exam 模块接口】我的考试 —— 对标 Vue3 端 api/exam/index.ts +/// +/// getSubjects —— 获取考试学科筛选 Tab 列表 +/// search —— 我的考试分页列表 +/// +/// 注意:考试报告详情(getReport)等考试做题相关接口, +/// 后续扩展时再按模块拆分补进来。 +/// ============================================================ +class ExamApi { + ExamApi._(); + + static final ExamApi instance = ExamApi._(); + + /// 考试学科筛选 Tab 列表 + /// GET /api/v1/Evaluation/GetSubjects + Future> getSubjects() async { + final data = await DioClient.instance + .get('/api/v1/Evaluation/GetSubjects'); + final list = data is List + ? data.whereType>().toList() + : (data is Map + ? (data['data'] as List? ?? const []) + : const []) + .whereType>() + .toList(); + return list.map(ExamSubjectTab.fromJson).toList(); + } + + /// 我的考试分页列表 + /// GET /api/v1/Evaluation/Search + /// 参数:baseSubjectId(可 null=全部)/ pageIndex / pageSize + Future search({ + int? baseSubjectId, + required int pageIndex, + int pageSize = 10, + }) async { + final data = await DioClient.instance.get( + '/api/v1/Evaluation/Search', + query: { + if (baseSubjectId != null) 'baseSubjectId': baseSubjectId, + 'pageIndex': pageIndex, + 'pageSize': pageSize, + }, + ); + return ExamResponse.fromJson(data as Map); + } +} diff --git a/lib/core/api/exam/model.dart b/lib/core/api/exam/model.dart new file mode 100644 index 0000000..fde8f23 --- /dev/null +++ b/lib/core/api/exam/model.dart @@ -0,0 +1,109 @@ +/// ============================================================ +/// 【exam 模块模型】我的考试 —— 对标 Vue3 端 api/exam/type.ts +/// +/// ExamDto —— 单条考试记录 +/// ExamSubjectTab —— 考试科目筛选 Tab +/// ExamResponse —— 考试列表分页响应 +/// +/// 序列化:手动 fromJson,与项目其他模块风格一致,不依赖 build_runner。 +/// +/// ⚠ 注意:后端返回的 id 字段类型不统一(数字 or 字符串), +/// 所有数值字段统一用 _asInt() 做兼容解析,避免类型转换异常。 +/// ============================================================ +library; + +int? _optInt(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is double) return v.toInt(); + final s = v.toString(); + final n = int.tryParse(s); + if (n != null) return n; + return double.tryParse(s)?.toInt(); +} + +int _asInt(dynamic v, [int fallback = 0]) => _optInt(v) ?? fallback; + +/// 单条考试记录 +class ExamDto { + const ExamDto({ + this.id = 0, + this.taskId = 0, + this.taskName = '', + this.baseSubjectId = 0, + this.baseSubjectName = '', + this.startTime = '', + this.endTime = '', + this.totalScore = 0, + this.userScore = 0, + }); + + final int id; + final int taskId; + final String taskName; + final int baseSubjectId; + final String baseSubjectName; + final String startTime; + final String endTime; + final int totalScore; + final int userScore; + + factory ExamDto.fromJson(Map json) { + return ExamDto( + id: _asInt(json['id']), + taskId: _asInt(json['taskId']), + taskName: (json['taskName'] as String?) ?? '', + baseSubjectId: _asInt(json['baseSubjectId']), + baseSubjectName: (json['baseSubjectName'] as String?) ?? '', + startTime: (json['startTime'] as String?) ?? '', + endTime: (json['endTime'] as String?) ?? '', + totalScore: _asInt(json['totalScore']), + userScore: _asInt(json['userScore']), + ); + } +} + +/// 考试科目筛选 Tab +class ExamSubjectTab { + const ExamSubjectTab({this.id, this.name = ''}); + + /// 为 null 表示"全部" + final int? id; + final String name; + + factory ExamSubjectTab.fromJson(Map json) { + return ExamSubjectTab( + id: _optInt(json['id']), + name: (json['name'] as String?) ?? '', + ); + } +} + +/// 考试列表分页响应 +class ExamResponse { + const ExamResponse({ + this.data = const [], + this.total = 0, + this.pageIndex = 1, + this.pageSize = 10, + }); + + final List data; + final int total; + final int pageIndex; + final int pageSize; + + factory ExamResponse.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + final list = (payload['data'] as List? ?? const []) + .whereType>() + .map(ExamDto.fromJson) + .toList(); + return ExamResponse( + data: list, + total: (payload['total'] as num?)?.toInt() ?? list.length, + pageIndex: (payload['pageIndex'] as num?)?.toInt() ?? 1, + pageSize: (payload['pageSize'] as num?)?.toInt() ?? 10, + ); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 3241877..034921e 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -17,6 +17,7 @@ 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/exam/exam_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'; @@ -138,6 +139,7 @@ final GoRouter appRouter = GoRouter( ), GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), GoRoute(path: '/myReport', builder: (_, _) => const MyReportPage()), + GoRoute(path: '/myExam', builder: (_, _) => const ExamPage()), GoRoute( path: '/report-detail', builder: (_, state) { @@ -185,8 +187,7 @@ final GoRouter appRouter = GoRouter( // ---------- 攻克包 - 截图名单 ---------- GoRoute( path: '/conquerPackage/captureList', - builder: (_, state) => - CaptureListPage(courseId: state.extra as String), + builder: (_, state) => CaptureListPage(courseId: state.extra as String), ), GoRoute(path: '/register', builder: (_, _) => const RegisterPage()), diff --git a/lib/core/utils/subject_icons.dart b/lib/core/utils/subject_icons.dart new file mode 100644 index 0000000..c2f0ee2 --- /dev/null +++ b/lib/core/utils/subject_icons.dart @@ -0,0 +1,28 @@ +import 'package:new_learn/core/utils/img_url.dart'; + +/// ============================================================ +/// 【学科图标常量】 +/// +/// 对标 Vue utils/constant.js 的 SUBJECT_ICON_MAP。 +/// 我的报告(my_report)、我的考试(exam)等模块共用, +/// 通过学科名(如「数学」「英语」)动态取对应图标。 +/// ============================================================ + +/// 学科名 → 图标资源路径 +const Map kSubjectIconMap = { + '语文': '/StaticResource/2026-06-11/shuxue1781163770612.png', + '化学': '/StaticResource/2026-06-11/huaxue1781163828443.png', + '英语': '/StaticResource/2026-06-11/yingyu1781163725441.png', + '物理': '/StaticResource/2026-06-11/wuli1781148438346.png', + '数学': '/StaticResource/2026-06-11/shuxue1781163770612.png', + '生物': '/StaticResource/2026-06-11/shengwu1781163791768.png', + '历史': '/StaticResource/2026-06-11/lishi1781163811822.png', + '政治': '/StaticResource/2026-06-11/zhengzhi1781163750422.png', + '地理': '/StaticResource/2026-06-11/dili1781163844311.png', +}; + +/// 取学科图标完整 URL(无匹配时用数学兜底,对标 Vue getSubjectIcon) +String subjectIconUrl(String subjectName) { + final path = kSubjectIconMap[subjectName] ?? kSubjectIconMap['数学']!; + return ImgUrl.build(path); +} diff --git a/lib/features/my/exam/exam_page.dart b/lib/features/my/exam/exam_page.dart new file mode 100644 index 0000000..57309d3 --- /dev/null +++ b/lib/features/my/exam/exam_page.dart @@ -0,0 +1,435 @@ +import 'dart:ui' as ui; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:new_learn/core/api/exam/api.dart'; +import 'package:new_learn/core/api/exam/model.dart'; +import 'package:new_learn/core/components/common_paged_list.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/subject_icons.dart'; + +/// ============================================================ +/// 【ExamPage】我的考试列表页 —— 对标 Vue pages/myPackage/exam/exam.vue +/// +/// 结构: +/// AppBar + Tab 栏(全部/各学科,胶囊样式,动态拉取) +/// + 分页列表(CommonPagedList,下拉刷新/触底加载) +/// + 卡片(学科图标 + 考试名 + 时间 + 得分渐变文字) +/// +/// 点击卡片 → 跳考试报告详情 +/// (先留 TODO,后续考试详情页做了再接入路由) +/// ============================================================ +class ExamPage extends StatefulWidget { + const ExamPage({super.key}); + + @override + State createState() => _ExamPageState(); +} + +class _ExamPageState extends State { + final List _tabs = []; + ExamSubjectTab _currentTab = const ExamSubjectTab(id: null, name: '全部'); + bool _tabsLoading = true; + + @override + void initState() { + super.initState(); + _tabs.add(const ExamSubjectTab(id: null, name: '全部')); + _loadTabs(); + } + + Future _loadTabs() async { + try { + final list = await ExamApi.instance.getSubjects(); + if (!mounted) return; + setState(() { + _tabs + ..clear() + ..add(const ExamSubjectTab(id: null, name: '全部')) + ..addAll(list); + _currentTab = _tabs.first; + _tabsLoading = false; + }); + } catch (e) { + talker.error('获取考试科目Tab失败', e); + if (!mounted) return; + setState(() { + _tabs + ..clear() + ..add(const ExamSubjectTab(id: null, name: '全部')); + _currentTab = _tabs.first; + _tabsLoading = false; + }); + } + } + + /// 分页加载回调 + Future> _loadPage(int pageKey) async { + final res = await ExamApi.instance.search( + baseSubjectId: _currentTab.id, + pageIndex: pageKey, + pageSize: 10, + ); + // hasNext:当前页返回数据满 pageSize 且总数更大 + final hasMore = + res.data.length >= res.pageSize && res.total > pageKey * res.pageSize; + return PagingResult(data: res.data, hasNext: hasMore); + } + + void _onTapTab(ExamSubjectTab tab) { + if (_currentTab.id == tab.id) return; + setState(() => _currentTab = tab); + // tab 切换后,通过 ValueKey 让 CommonPagedList 重建 State,自动首屏加载新数据 + } + + void _goToExamDetail(ExamDto item) { + // TODO: 接入考试报告详情页路由,对应 Vue /pages/examPackage/examReport/index?recordId=${item.id} + talker.debug('点击考试详情: ${item.taskName}, id=${item.id}'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text('我的考试'), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1D2B47), + elevation: 0, + centerTitle: true, + bottom: PreferredSize( + preferredSize: Size.fromHeight(44.h), + child: _buildTabBar(), + ), + ), + body: _tabsLoading + ? const Center(child: CircularProgressIndicator()) + : Container( + color: const Color(0xFFF5F8FA), + child: CommonPagedList( + key: ValueKey('exam-list-${_currentTab.id}'), + load: _loadPage, + padding: EdgeInsets.fromLTRB(12.w, 16.h, 14.w, 20.h), + separatorBuilder: (_, __) => SizedBox(height: 14.h), + noMoreText: '- AI 爱学蝶变提醒你 没有更多了 -', + emptyWidget: _buildEmpty(), + buildItem: (ctx, item, index) { + return _ExamCard( + item: item, + onTap: () => _goToExamDetail(item), + ); + }, + ), + ), + ); + } + + // ===== Tab 栏(对标 up-tabs + 自定义 activeStyle/inactiveStyle)===== + Widget _buildTabBar() { + return Container( + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 12.h), + color: Colors.white, + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Row( + children: _tabs.map((tab) { + final isActive = tab.id == _currentTab.id; + return Padding( + padding: EdgeInsets.symmetric(horizontal: 6.w), + child: GestureDetector( + onTap: () => _onTapTab(tab), + behavior: HitTestBehavior.opaque, + child: Container( + height: 37.h, + width: 54.w, + // padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: isActive + ? const Color(0xFF3D7DF5) + : const Color(0xFFF5F8FA), + borderRadius: BorderRadius.circular(999), + ), + alignment: Alignment.center, + child: Text( + tab.name, + style: TextStyle( + fontSize: 15.spMin, + color: isActive ? Colors.white : const Color(0xFF687FAD), + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ); + } + + // ===== 空状态(对标 DataEmpty + exam_empty_bg)===== + Widget _buildEmpty() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CachedNetworkImage( + imageUrl: ImgUrl.build( + '/StaticResource/2026-06-10/exam_empty_bg1781078431804.png', + ), + width: 200.w, + fit: BoxFit.fitWidth, + errorWidget: (_, _, _) => SizedBox(width: 200.w, height: 200.h), + ), + SizedBox(height: 12.h), + Text( + '暂无内容', + style: TextStyle( + fontSize: 14.spMin, + color: const Color(0xFF687FAD), + ), + ), + ], + ), + ); + } +} + +// ==================== 考试卡片 ==================== + +class _ExamCard extends StatelessWidget { + const _ExamCard({required this.item, required this.onTap}); + + final ExamDto item; + final VoidCallback onTap; + + static const String _cardBgBook = + '/StaticResource/2026-09-10/book_icon_final1789026573675.png'; + + // 书图标设计尺寸(目标渲染尺寸) + static const double _bookWidth = 124; + static const double _bookHeight = 134; + // 卡片尺寸 + static const double _cardHeight = 92; + static const double _cardRadius = 24; + // ⚠ book_icon.png 自带透明 padding,下面这些偏移用于补偿图片留白 + // 让书图案的左下角对齐卡片左下角。换图时需重新调整。 + static const double _bookOffsetLeft = -14; // 左补留白(宽度加大了,偏移也加大) + static const double _bookOffsetBottom = 10; // 底补留白 + static const double _bookExtraHeight = 0; // 放大补偿,让图案达到目标高度 + + String get _timeRange { + // 日期相同只展示 endTime 的时分,去除秒 + final startParts = item.startTime.split(' '); + final startDate = startParts.isNotEmpty ? startParts[0] : ''; + final startHhMm = startParts.length >= 2 + ? startParts[1].split(':').take(2).join(':') + : ''; + final endParts = item.endTime.split(' '); + final endHhMm = endParts.length >= 2 + ? endParts[1].split(':').take(2).join(':') + : ''; + return '$startDate $startHhMm - $endHhMm'; + } + + @override + Widget build(BuildContext context) { + // 学科图标:按 baseSubjectName 动态取,无匹配兜底数学 + final subjectIcon = subjectIconUrl(item.baseSubjectName); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox( + // ⚠ 书图/卡片尺寸统一用 .w:宽高同基准,锁定盒子宽高比, + // 避免手机(.w按375稿/.h按812稿)与pad(设备尺寸)缩放系数不同导致 + // BoxFit.contain 缩放分支翻转、书图被压扁。 + height: (_bookHeight + _bookExtraHeight).w, // 整体占位 = 书图容器高度(溢出卡片) + child: Stack( + clipBehavior: Clip.none, + children: [ + // 底层:白色圆角卡片(垂直居中) + Positioned( + left: 0, + right: 0, + top: (_bookHeight + _bookExtraHeight - _cardHeight).w / 2, + child: Container( + height: _cardHeight.w, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(_cardRadius.r), + boxShadow: const [ + BoxShadow( + color: Color(0x0A1D2B47), + blurRadius: 20, + offset: Offset(0, 6), + ), + ], + ), + ), + ), + // 中层:左侧书形装饰图 + // 图片自带透明 padding,用 _bookOffsetLeft/Bottom/ExtraHeight 补偿 + Positioned( + left: _bookOffsetLeft.w, + bottom: _bookOffsetBottom.w, + width: _bookWidth.w, + height: (_bookHeight + _bookExtraHeight).w, + child: CachedNetworkImage( + imageUrl: ImgUrl.build(_cardBgBook), + fit: BoxFit.contain, + alignment: Alignment.bottomLeft, + errorWidget: (_, __, ___) => + SizedBox(width: _bookWidth.w, height: _bookHeight.w), + ), + ), + // 学科小图标(叠在书图上半部分,书图 110 宽 / 139 高容器) + Positioned( + left: 22.w, + top: 14.w, + child: CachedNetworkImage( + imageUrl: subjectIcon, + width: 56.w, + height: 56.w, + errorWidget: (_, _, _) => Icon( + Icons.menu_book, + size: 56.w, + color: const Color(0xFF3D7DF5), + ), + ), + ), + // 上层:内容 + Positioned.fill( + child: Row( + children: [ + // 左侧书图区域的占位宽(内容从书图右边开始) + SizedBox(width: 90.w), + // 中间 + 右侧 + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Row( + children: [ + Expanded(child: _buildMid()), + _buildRight(), + ], + ), + ), + ), + ], + ), + ), + // 学科标签(覆盖在书图底部胶囊上) + Positioned( + left: -10.w, + bottom: 26.w, + width: _bookWidth.w, + child: Center( + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 6.w, + ), + decoration: BoxDecoration( + color: const Color(0xFFE8F2FF), + borderRadius: BorderRadius.circular(12.r), + ), + child: Text( + item.baseSubjectName, + style: TextStyle( + fontSize: 16.spMin, + fontWeight: FontWeight.w600, + color: const Color(0xFF3D7DF5), + height: 1.2, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildMid() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.taskName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18.spMin, + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox(height: 8.h), + Text( + _timeRange, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.spMin, + fontWeight: FontWeight.w400, + color: const Color(0xFF687FAD), + ), + ), + ], + ); + } + + Widget _buildRight() { + return Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + // 得分 + _gradientScoreText('${item.userScore}', 32.spMin, FontWeight.w700), + Text( + ' / ${item.totalScore}', + style: TextStyle( + fontSize: 16.spMin, + fontWeight: FontWeight.w400, + color: const Color(0xFF687FAD), + ), + ), + SizedBox(width: 10.w), + Icon( + Icons.arrow_forward_ios, + size: 16.w, + color: const Color(0xFF3D7DF5), + ), + ], + ); + } + + /// 渐变得分文字:linear-gradient(270deg, #2DC9FF -2.95%, #2B73F9 73.86%) + Widget _gradientScoreText(String text, double fontSize, FontWeight weight) { + return ShaderMask( + shaderCallback: (rect) => ui.Gradient.linear( + rect.topRight, + rect.topLeft, + const [Color(0xFF2DC9FF), Color(0xFF2B73F9)], + const [-0.0295, 0.7386], + TileMode.clamp, + ), + child: Text( + text, + style: TextStyle( + fontSize: fontSize, + fontWeight: weight, + color: Colors.white, + height: 1, + ), + ), + ); + } +} diff --git a/lib/features/my/my_page.dart b/lib/features/my/my_page.dart index 193bdcc..f279ddc 100644 --- a/lib/features/my/my_page.dart +++ b/lib/features/my/my_page.dart @@ -96,6 +96,14 @@ class MyPage extends ConsumerWidget { // 第一组:学习功能菜单 _MenuCard( items: [ + _MenuItem( + imageUrl: + '${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png', + title: '我的考试', + onTap: () { + context.push('/myExam'); + }, + ), _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png', diff --git a/lib/features/my/my_report/my_report_page.dart b/lib/features/my/my_report/my_report_page.dart index 3366a2a..b5c3d03 100644 --- a/lib/features/my/my_report/my_report_page.dart +++ b/lib/features/my/my_report/my_report_page.dart @@ -8,6 +8,7 @@ import 'package:new_learn/core/api/my_report/model.dart'; import 'package:new_learn/core/components/common_paged_list.dart'; import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/subject_icons.dart'; /// ============================================================ /// 【MyReportPage】我的报告列表 —— 对标 Vue pages/myPackage/myReport/myReport.vue @@ -25,19 +26,6 @@ import 'package:new_learn/core/utils/img_url.dart'; /// 四、先只做 phone 布局,Pad 后续再说 /// ============================================================ -/// 学科 icon 映射表(对标 Vue utils/constant.js SUBJECT_ICON_MAP) -const Map _subjectIconMap = { - '语文': '/StaticResource/2026-06-11/shuxue1781163770612.png', - '化学': '/StaticResource/2026-06-11/huaxue1781163828443.png', - '英语': '/StaticResource/2026-06-11/yingyu1781163725441.png', - '物理': '/StaticResource/2026-06-11/wuli1781148438346.png', - '数学': '/StaticResource/2026-06-11/shuxue1781163770612.png', - '生物': '/StaticResource/2026-06-11/shengwu1781163791768.png', - '历史': '/StaticResource/2026-06-11/lishi1781163811822.png', - '政治': '/StaticResource/2026-06-11/zhengzhi1781163750422.png', - '地理': '/StaticResource/2026-06-11/dili1781163844311.png', -}; - /// 取学科名(报告名前 2 个字,对标 Vue getSubjectName) String _extractSubjectName(UserReportDto item) { final name = item.reportName.trim(); @@ -45,12 +33,6 @@ String _extractSubjectName(UserReportDto item) { return name.substring(0, 2); } -/// 取学科 icon URL(无匹配则用数学兜底,对标 Vue getSubjectIcon) -String _getSubjectIconUrl(String subjectName) { - final path = _subjectIconMap[subjectName] ?? _subjectIconMap['数学']!; - return ImgUrl.build(path); -} - /// 格式化创建时间(对标 Vue getReportTime) String _formatCreateTime(String raw) { if (raw.isEmpty) return ''; @@ -120,8 +102,8 @@ class _MyReportPageState extends ConsumerState { color: const Color(0xFFF5F8FA), child: CommonPagedList( load: _loadPage, - padding: EdgeInsets.fromLTRB(12.w, 16.h, 12.w, 0), - separatorBuilder: (_, _) => SizedBox(height: 14.h), + padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 0), + // separatorBuilder: (_, _) => SizedBox(height: 14.h), buildItem: (context, item, index) { return _ReportCard(item: item, onTap: () => _onTapCard(item)); }, @@ -140,11 +122,11 @@ class _MyReportPageState extends ConsumerState { /// ============================================================ /// 【_ReportCard】报告卡片 /// -/// 布局(Row 从左到右): -/// [icon + 学科标签] → [报告名 + 时间] → [箭头] +/// 左侧装饰与我的考试(exam)卡片一致: +/// 白色圆角卡片 + 左侧溢出书图(book_icon)+ 学科图标叠在书图上 + +/// 底部学科名胶囊。书图/卡片尺寸统一用 .w 锁定宽高比,适配 pad。 /// -/// 背景图叠底,InkWell 提供点击水波纹。 -/// 图标/标签自然靠左,不做偏移 hack。 +/// 内容区:中间 [报告名 + 时间],右侧 [箭头]。 /// ============================================================ class _ReportCard extends StatelessWidget { const _ReportCard({required this.item, required this.onTap}); @@ -152,115 +134,177 @@ class _ReportCard extends StatelessWidget { final UserReportDto item; final VoidCallback onTap; + static const String _cardBgBook = + '/StaticResource/2026-09-10/book_icon_final1789026573675.png'; + + // 书图标 / 卡片尺寸(与 exam 卡片一致,统一 .w 锁比例) + static const double _bookWidth = 124; + static const double _bookHeight = + 124; // 卡片间距 = _bookHeight - _cardHeight(书图向上探出的高度) + static const double _cardHeight = 92; + static const double _cardRadius = 24; + // ⚠ book_icon.png 自带透明 padding,偏移用于补偿留白 + static const double _bookOffsetLeft = -14; + static const double _bookOffsetBottom = -10; + static const double _bookExtraHeight = 0; + @override Widget build(BuildContext context) { final subjectName = _extractSubjectName(item); - final subjectIconUrl = _getSubjectIconUrl(subjectName); + final iconUrl = subjectIconUrl(subjectName); final timeText = _formatCreateTime(item.createTime); - const bgPath = - '/StaticResource/2026-06-10/examListItem_bg1781074465963.png'; - - return InkWell( + return GestureDetector( + behavior: HitTestBehavior.opaque, onTap: onTap, - borderRadius: BorderRadius.circular(12.r), - child: Container( - height: 109.h, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12.r)), + child: SizedBox( + height: (_bookHeight + _bookExtraHeight).w, child: Stack( + clipBehavior: Clip.none, children: [ - // 底层:卡片背景图 - Positioned.fill( - child: ClipRRect( - borderRadius: BorderRadius.circular(12.r), - child: CachedNetworkImage( - imageUrl: ImgUrl.build(bgPath), - fit: BoxFit.fill, - placeholder: (_, _) => Container(color: Colors.white), - errorWidget: (_, _, _) => Container(color: Colors.white), + // 底层:白色圆角卡片(底部对齐,书图只向上溢出) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: _cardHeight.w, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(_cardRadius.r), + boxShadow: const [ + BoxShadow( + color: Color(0x0A1D2B47), + blurRadius: 20, + offset: Offset(0, 6), + ), + ], ), ), ), - // 上层内容:Row 左对齐,padding 控制整体边距 - Padding( - padding: EdgeInsets.symmetric(horizontal: 22.w), + // 中层:左侧书形装饰图 + Positioned( + left: _bookOffsetLeft.w, + bottom: _bookOffsetBottom.w, + width: _bookWidth.w, + height: (_bookHeight + _bookExtraHeight).w, + child: CachedNetworkImage( + imageUrl: ImgUrl.build(_cardBgBook), + fit: BoxFit.contain, + alignment: Alignment.bottomLeft, + errorWidget: (_, __, ___) => + SizedBox(width: _bookWidth.w, height: _bookHeight.w), + ), + ), + // 学科小图标(叠在书图上半部分) + Positioned( + left: 22.w, + top: 14.w, + child: CachedNetworkImage( + imageUrl: iconUrl, + width: 56.w, + height: 56.w, + errorWidget: (_, _, _) => Icon( + Icons.menu_book, + size: 56.w, + color: const Color(0xFF3D7DF5), + ), + ), + ), + // 上层:内容(与卡片同高,底部对齐) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _cardHeight.w, child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ - // ── 左侧:学科 icon + 标签(垂直排列,自然靠左) ── - Column( - mainAxisSize: MainAxisSize.min, - children: [ - CachedNetworkImage( - imageUrl: subjectIconUrl, - width: 56.w, - height: 56.w, - placeholder: (_, _) => const SizedBox.shrink(), - errorWidget: (_, _, _) => const SizedBox.shrink(), - ), - SizedBox(height: 4.h), - Container( - padding: EdgeInsets.symmetric(horizontal: 8.w), - height: 25.h, - decoration: BoxDecoration( - color: const Color(0xFFEAF5FE), - borderRadius: BorderRadius.circular(7.r), - ), - alignment: Alignment.center, - child: Text( - subjectName, - style: TextStyle( - fontSize: 14.sp, - color: const Color(0xFF3D7DF5), - ), - ), - ), - ], - ), - SizedBox(width: 40.w), - // ── 中间:报告名 + 时间(Expanded 占剩余) ── + // 左侧书图区域占位 + SizedBox(width: 90.w), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(height: 10.h), - Text( - item.reportName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 16.sp, - color: const Color(0xFF1D2B47), - ), - ), - SizedBox(height: 20.h), - Text( - timeText, - style: TextStyle( - fontSize: 12.sp, - color: const Color(0xFF687FAD), - ), - ), - ], - ), - ), - // ── 右侧:箭头 ── - Padding( - padding: EdgeInsets.only(top: 10.h), - child: Icon( - Icons.chevron_right, - color: const Color(0xFF4B6B99), - size: 22.sp, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Row( + children: [ + Expanded(child: _buildMid(timeText)), + _buildRight(), + ], + ), ), ), ], ), ), + // 学科标签(覆盖在书图底部胶囊上,卡片贴底) + Positioned( + left: -10.w, + bottom: 10.w, + width: _bookWidth.w, + child: Center( + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 6.w, + ), + decoration: BoxDecoration( + color: const Color(0xFFE8F2FF), + borderRadius: BorderRadius.circular(12.r), + ), + child: Text( + subjectName, + style: TextStyle( + fontSize: 16.spMin, + fontWeight: FontWeight.w600, + color: const Color(0xFF3D7DF5), + height: 1.2, + ), + ), + ), + ), + ), ], ), ), ); } + + // 中间:报告名 + 时间 + Widget _buildMid(String timeText) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.reportName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18.spMin, + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox(height: 8.h), + Text( + timeText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.spMin, + fontWeight: FontWeight.w400, + color: const Color(0xFF687FAD), + ), + ), + ], + ); + } + + // 右侧:箭头 + Widget _buildRight() { + return Icon( + Icons.chevron_right, + color: const Color(0xFF4B6B99), + size: 22.sp, + ); + } } From 14b8e56bc3ff8cf67b3116f5e3b833f4308ac1f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E5=AE=9D=E6=96=B0?= Date: Thu, 10 Sep 2026 17:14:11 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(my):=20=E6=96=B0=E5=A2=9E=E9=94=99?= =?UTF-8?q?=E9=A2=98=E6=9C=AC=E5=92=8C=E6=88=91=E7=9A=84=E6=94=B6=E8=97=8F?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=EF=BC=8C=E9=87=8D=E6=9E=84=E5=AD=A6=E7=A7=91?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 为我的页面添加错题本和我的收藏跳转路由,指向共用的QuestionListPage 2. 抽离全局学科标签组件,移除class模块内的重复实现 3. 新增用户收藏/错题本接口层和数据模型 4. 完成QuestionListPage分页列表页实现,支持学科筛选和游标分页 5. 调整考试页面卡片布局和图标偏移参数 --- lib/core/api/user_collect/api.dart | 66 +++ lib/core/api/user_collect/model.dart | 124 +++++ lib/core/components/subject_tag.dart | 83 +++ lib/core/router/app_router.dart | 10 + .../class/widgets/class_update_widgets.dart | 36 +- lib/features/my/exam/exam_page.dart | 8 +- lib/features/my/my_page.dart | 4 +- .../my/question_list/question_list_page.dart | 505 ++++++++++++++++++ 8 files changed, 795 insertions(+), 41 deletions(-) create mode 100644 lib/core/api/user_collect/api.dart create mode 100644 lib/core/api/user_collect/model.dart create mode 100644 lib/core/components/subject_tag.dart create mode 100644 lib/features/my/question_list/question_list_page.dart diff --git a/lib/core/api/user_collect/api.dart b/lib/core/api/user_collect/api.dart new file mode 100644 index 0000000..21896a7 --- /dev/null +++ b/lib/core/api/user_collect/api.dart @@ -0,0 +1,66 @@ +import 'package:new_learn/core/api/user_collect/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【user_collect 模块接口】我的收藏 + 错题本 —— 对标 Vue3 端 api/userCollect/index.ts +/// +/// getSubjects —— 学科筛选项(顶部 Tab) +/// GET /api/v1/UserCollect/GetUserCollectSubject/{type} +/// +/// getCollectList —— 收藏/错题分页列表(游标分页) +/// GET /api/v1/UserCollect/GetUserCollect +/// 参数:type(1收藏/2错题) / subjectId(可选) / pageIndex / pageSize / prevPageId +/// +/// 游标分页说明(对齐 Vue useList): +/// 第一页 prevPageId=0,之后每页传上一页最后一条的 id; +/// 切学科/下拉刷新时 prevPageId 重置为 0。 +/// ============================================================ +class UserCollectApi { + UserCollectApi._(); + + static final UserCollectApi instance = UserCollectApi._(); + + /// 学科筛选项([type] 1=我的收藏 2=错题本) + Future> getSubjects(int type) async { + final data = await DioClient.instance.get( + '/api/v1/UserCollect/GetUserCollectSubject/$type', + ); + // 兼容裸数组 / {data: [...]} 响应壳 + final List list = data is List + ? data + : (data is Map + ? (data['data'] as List? ?? const []) + : const []); + return list + .whereType>() + .map(CollectTab.fromJson) + .toList(); + } + + /// 收藏/错题分页列表 + /// + /// [type] 1=我的收藏 2=错题本;[subjectId] 不传 = 全部学科; + /// [prevPageId] 游标:首页传 0,翻页传上一页最后一条 id。 + Future getCollectList({ + required int type, + int? subjectId, + required int pageIndex, + int pageSize = 10, + int prevPageId = 0, + }) async { + final query = { + 'type': type, + 'pageIndex': pageIndex, + 'pageSize': pageSize, + 'prevPageId': prevPageId, + }; + // 对齐 Vue useList:subjectId 为 null 时不带该参数 + if (subjectId != null) query['subjectId'] = subjectId; + + final data = await DioClient.instance.get( + '/api/v1/UserCollect/GetUserCollect', + query: query, + ); + return UserCollectPage.fromJson(data as Map); + } +} diff --git a/lib/core/api/user_collect/model.dart b/lib/core/api/user_collect/model.dart new file mode 100644 index 0000000..0ce843a --- /dev/null +++ b/lib/core/api/user_collect/model.dart @@ -0,0 +1,124 @@ +/// ============================================================ +/// 【user_collect 模块模型】我的收藏 + 错题本 —— 对标 Vue3 端 api/userCollect/type.ts +/// +/// CollectTab 学科筛选 Tab(GetUserCollectSubject 返回项) +/// UserCollectItem 列表项(收藏/错题通用,靠 type 区分) +/// UserCollectPage 分页响应(游标分页:pageIndex + prevPageId) +/// +/// 说明:错题本页(myWrong)与收藏页(myCollect)共用同一套接口, +/// type=1 我的收藏 / type=2 错题本。 +/// 序列化:手动 fromJson(兼容 {code, message, data: {...}} 响应壳), +/// 不依赖 build_runner。 +/// ============================================================ +library; + +/// 兼容后端 id 字段:雪花 id 后端可能以 String 返回(防 JS 精度丢失), +/// 也可能直接是 num;统一转 int,无法解析返回 null。 +int? _toInt(dynamic v) { + if (v == null) return null; + if (v is num) return v.toInt(); + if (v is String && v.isNotEmpty) return int.tryParse(v); + return null; +} + +/// 学科筛选 Tab +class CollectTab { + const CollectTab({ + this.id, + required this.name, + this.remark = '', + this.status = 0, + }); + + /// 学科 id;null = 客户端插入的"全部" + final int? id; + final String name; + final String remark; + final int status; + + factory CollectTab.fromJson(Map json) { + return CollectTab( + id: _toInt(json['id']), + name: json['name'] as String? ?? '', + remark: json['remark'] as String? ?? '', + status: _toInt(json['status']) ?? 0, + ); + } +} + +/// 收藏/错题列表项 +class UserCollectItem { + const UserCollectItem({ + this.id = 0, + this.questionId = 0, + this.recordQuestionId = 0, + this.questionStem = '', + this.subjectName = '', + this.packName = '', + this.createTime = '', + }); + + /// 列表明细 id(游标分页 prevPageId 取本页最后一条的 id) + final int id; + + /// 题目 id(详情页参数 questionId) + final int questionId; + + /// 答题记录 id(详情页参数 recordQuestionId) + final int recordQuestionId; + + /// 题干 HTML(RichTextContent 渲染) + final String questionStem; + + /// 学科名(SubjectTag 配色用) + final String subjectName; + + /// 来源试卷/礼包名 + final String packName; + + /// 收藏/错题时间字符串 + final String createTime; + + factory UserCollectItem.fromJson(Map json) { + return UserCollectItem( + id: _toInt(json['id']) ?? 0, + questionId: _toInt(json['questionId']) ?? 0, + recordQuestionId: _toInt(json['recordQuestionId']) ?? 0, + questionStem: json['questionStem'] as String? ?? '', + subjectName: json['subjectName'] as String? ?? '', + packName: json['packName'] as String? ?? '', + createTime: json['createTime'] as String? ?? '', + ); + } +} + +/// 收藏/错题分页响应 +class UserCollectPage { + const UserCollectPage({ + this.data = const [], + this.total = 0, + this.pageIndex = 1, + this.pageSize = 10, + }); + + final List data; // 当前页数据 + final int total; // 总条数(顶部"X 道错题/收藏题目") + final int pageIndex; + final int pageSize; + + /// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data + factory UserCollectPage.fromJson(Map json) { + final payload = (json['data'] is Map) + ? json['data'] as Map + : json; + return UserCollectPage( + data: (payload['data'] as List? ?? const []) + .whereType>() + .map(UserCollectItem.fromJson) + .toList(), + total: _toInt(payload['total']) ?? 0, + pageIndex: _toInt(payload['pageIndex']) ?? 1, + pageSize: _toInt(payload['pageSize']) ?? 10, + ); + } +} diff --git a/lib/core/components/subject_tag.dart b/lib/core/components/subject_tag.dart new file mode 100644 index 0000000..f612076 --- /dev/null +++ b/lib/core/components/subject_tag.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +/// ============================================================ +/// 【SubjectTag】学科标签(对标 Vue components/SubjectTag/SubjectTag.vue) +/// +/// 四学科配色(与 Vue subjectMap 一致),未知学科兜底数学蓝: +/// 数学 #3D7DF5 / #E5F0FF 物理 #A45CFF / #F5ECFF +/// 英语 #E5A301 / #FFF6E3 化学 #2DB985 / #E5FAF3 +/// +/// 描边:Vue 用 ::before + mask-composite 画 2rpx 的学科色渐变描边 +/// (两端 60% 学科色、中间 60% 近白色);Flutter 用"外层渐变容器 + +/// 内层底色容器留 1px 边"的嵌套方案等价实现。 +/// +/// 原 class 模块私有组件,因错题本/我的收藏列表也需要,按项目规范 +/// 提升为 core/components 全局公共组件。 +/// ============================================================ +class SubjectTag extends StatelessWidget { + const SubjectTag({super.key, required this.subject}); + + final String subject; + + static const Map _map = { + // 数学:描边 rgba(90,158,255,.6) → rgba(242,248,255,.6) + '数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF, 0x995A9EFF, 0x99F2F8FF), + // 物理:描边 rgba(182,127,255,.6) → rgba(252,246,255,.6) + '物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF, 0x99B67FFF, 0x99FCF6FF), + // 英语:描边 rgba(247,194,82,.6) → rgba(255,251,243,.6) + '英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3, 0x99F7C252, 0x99FFFBF3), + // 化学:描边 rgba(91,209,165,.6) → rgba(240,255,248,.6) + '化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3, 0x995BD1A5, 0x99F0FFF8), + }; + + @override + Widget build(BuildContext context) { + final t = subject.trim(); + if (t.isEmpty) return const SizedBox.shrink(); + final c = + _map[t] ?? + const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF, 0x995A9EFF, 0x99F2F8FF); + + // Vue border-radius: 12rpx + final outerRadius = BorderRadius.circular(6.r); + // Vue ::before padding: 2rpx(描边厚度) + final borderWidth = 1.w; + + return Container( + // 外层:学科色横向渐变,露出的 1px 边即"渐变描边" + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(c.borderA), Color(c.borderB), Color(c.borderA)], + ), + borderRadius: outerRadius, + ), + child: Container( + margin: EdgeInsets.all(borderWidth), + // Vue padding: 4rpx 14rpx,扣除 2rpx 描边后内层 2rpx 12rpx + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.h), + decoration: BoxDecoration( + color: Color(c.bg), + borderRadius: BorderRadius.circular(6.r - borderWidth), + ), + // Vue white-space: nowrap:学科名不换行 + child: Text( + t, + softWrap: false, + style: TextStyle(fontSize: 11, color: Color(c.fg)), + ), + ), + ); + } +} + +class _SubjectColor { + const _SubjectColor(this.fg, this.bg, this.borderA, this.borderB); + + final int fg; // 文字色 + final int bg; // 底色 + final int borderA; // 渐变描边两端色(60% 学科色) + final int borderB; // 渐变描边中间色(60% 近白) +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 034921e..837f8bf 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -15,6 +15,7 @@ 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/question_list/question_list_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/exam/exam_page.dart'; @@ -138,6 +139,15 @@ final GoRouter appRouter = GoRouter( MyUpdateDetailPage(initialPost: state.extra as UserPostDto), ), GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), + // 错题本(type=2)/ 我的收藏(type=1)共用 QuestionListPage + GoRoute( + path: '/myWrong', + builder: (_, _) => const QuestionListPage(type: 2), + ), + GoRoute( + path: '/myCollect', + builder: (_, _) => const QuestionListPage(type: 1), + ), GoRoute(path: '/myReport', builder: (_, _) => const MyReportPage()), GoRoute(path: '/myExam', builder: (_, _) => const ExamPage()), GoRoute( diff --git a/lib/features/class/widgets/class_update_widgets.dart b/lib/features/class/widgets/class_update_widgets.dart index 696fae0..63b2b67 100644 --- a/lib/features/class/widgets/class_update_widgets.dart +++ b/lib/features/class/widgets/class_update_widgets.dart @@ -6,6 +6,7 @@ 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/subject_tag.dart'; import 'package:new_learn/core/components/user_circle_avatar.dart'; import 'package:new_learn/core/utils/img_url.dart'; @@ -236,35 +237,6 @@ class CardHeader extends StatelessWidget { } } -/// 学科标签(对标 SubjectTag) -class SubjectTag extends StatelessWidget { - const SubjectTag({super.key, required this.subject}); - - final String subject; - - static const Map _map = { - '数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF), - '物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF), - '英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3), - '化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3), - }; - - @override - Widget build(BuildContext context) { - final t = subject.trim(); - if (t.isEmpty) return const SizedBox.shrink(); - final c = _map[t] ?? const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF); - return Container( - padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h), - decoration: BoxDecoration( - color: Color(c.bg), - borderRadius: BorderRadius.circular(6.r), - ), - child: Text(t, style: TextStyle(fontSize: 11, color: Color(c.fg))), - ); - } -} - /// 纯文字笔记(album-desc,最多 2 行) class AlbumDesc extends StatelessWidget { const AlbumDesc({super.key, required this.text}); @@ -613,9 +585,3 @@ class _CommentInputState extends State { ); } } - -class _SubjectColor { - const _SubjectColor(this.fg, this.bg); - final int fg; - final int bg; -} diff --git a/lib/features/my/exam/exam_page.dart b/lib/features/my/exam/exam_page.dart index 57309d3..7f6bf7e 100644 --- a/lib/features/my/exam/exam_page.dart +++ b/lib/features/my/exam/exam_page.dart @@ -111,8 +111,8 @@ class _ExamPageState extends State { child: CommonPagedList( key: ValueKey('exam-list-${_currentTab.id}'), load: _loadPage, - padding: EdgeInsets.fromLTRB(12.w, 16.h, 14.w, 20.h), - separatorBuilder: (_, __) => SizedBox(height: 14.h), + padding: EdgeInsets.fromLTRB(12.w, 24.h, 12.w, 0), + // separatorBuilder: (_, __) => SizedBox(height: 14.h), noMoreText: '- AI 爱学蝶变提醒你 没有更多了 -', emptyWidget: _buildEmpty(), buildItem: (ctx, item, index) { @@ -212,14 +212,14 @@ class _ExamCard extends StatelessWidget { // 书图标设计尺寸(目标渲染尺寸) static const double _bookWidth = 124; - static const double _bookHeight = 134; + static const double _bookHeight = 124; // 卡片尺寸 static const double _cardHeight = 92; static const double _cardRadius = 24; // ⚠ book_icon.png 自带透明 padding,下面这些偏移用于补偿图片留白 // 让书图案的左下角对齐卡片左下角。换图时需重新调整。 static const double _bookOffsetLeft = -14; // 左补留白(宽度加大了,偏移也加大) - static const double _bookOffsetBottom = 10; // 底补留白 + static const double _bookOffsetBottom = 6; // 底补留白 static const double _bookExtraHeight = 0; // 放大补偿,让图案达到目标高度 String get _timeRange { diff --git a/lib/features/my/my_page.dart b/lib/features/my/my_page.dart index f279ddc..ec0d05b 100644 --- a/lib/features/my/my_page.dart +++ b/lib/features/my/my_page.dart @@ -125,7 +125,7 @@ class MyPage extends ConsumerWidget { '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/error.png', title: '错题本', onTap: () { - // TODO: 跳转到错题本页 + context.push('/myWrong'); }, ), _MenuItem( @@ -133,7 +133,7 @@ class MyPage extends ConsumerWidget { '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/collect.png', title: '我的收藏', onTap: () { - // TODO: 跳转到我的收藏页 + context.push('/myCollect'); }, ), _MenuItem( diff --git a/lib/features/my/question_list/question_list_page.dart b/lib/features/my/question_list/question_list_page.dart new file mode 100644 index 0000000..5ea1f63 --- /dev/null +++ b/lib/features/my/question_list/question_list_page.dart @@ -0,0 +1,505 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/api/user_collect/api.dart'; +import 'package:new_learn/core/api/user_collect/model.dart'; +import 'package:new_learn/core/components/common_paged_list.dart'; +import 'package:new_learn/core/components/rich_text_content.dart'; +import 'package:new_learn/core/components/subject_tag.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/utils/img_url.dart'; +import 'package:new_learn/core/utils/screen_adapt_template.dart'; + +/// ============================================================ +/// 【QuestionListPage】我的收藏 / 错题本 列表页 +/// —— 对标 Vue pages/myPackage/myCollect/myCollect.vue +/// 和 pages/myPackage/myWrong/myWrong.vue +/// +/// 两个页面结构完全一致(同一套接口 UserCollect),仅 [type] 不同: +/// type=1 我的收藏(/myCollect) type=2 错题本(/myWrong) +/// 标题 / 总数文案 / 顶部背景图 / 空状态 / 装饰色带 由 [_config] 按 type 切换。 +/// +/// 页面结构(对齐 Vue,从上到下): +/// 1. 学科筛选 Tab("全部" + 接口返回学科,胶囊样式,点击切学科重载) +/// 2. 总数头:主题背景图 + 大号总数 + 装饰色带 + 文案(道错题/收藏题目) +/// 3. #F5F8FA 列表区:CommonPagedList 游标分页 +/// 卡片 = SubjectTag + 时间 + packName + 题干富文本(限高渐隐) +/// +/// 分页要点(游标分页,对齐 Vue useList): +/// pageKey 当 pageIndex;prevPageId 在闭包里维护——首页/刷新重置为 0, +/// 每页请求后更新为本页最后一条 id;切学科用 ValueKey 重建列表(天然回首页)。 +/// +/// 题干限高:Vue 用 DOM 测量后加渐隐遮罩;Flutter 用 OverflowBox 限高 70.h +/// 裁剪 + 底部固定渐隐遮罩(遮罩终点色与题干底色色值一致,内容不足时不可见)。 +/// ============================================================ +class QuestionListPage extends StatefulWidget { + const QuestionListPage({super.key, required this.type}); + + /// 1=我的收藏 2=错题本 + final int type; + + @override + State createState() => _QuestionListPageState(); +} + +class _QuestionListPageState extends State { + static const int _pageSize = 10; + + /// 学科 Tab 列表("全部"为客户端插入,id=null) + List _tabs = const [CollectTab(name: '全部')]; + + /// 当前选中的学科 id(null = 全部) + int? _subjectId; + + /// 顶部总数(来自分页响应 total) + int _total = 0; + + /// 游标:本页最后一条 id,首页/刷新/切学科时重置为 0 + int _prevPageId = 0; + + _PageConfig get _config => + widget.type == 1 ? _PageConfig.collect : _PageConfig.wrong; + + @override + void initState() { + super.initState(); + talker.debug('打开页面:${widget.type == 1 ? '我的收藏' : '错题本'}'); + _loadTabs(); + } + + /// 拉取学科筛选项;失败兜底只有"全部"(对齐 Vue catch 逻辑) + Future _loadTabs() async { + try { + final tabs = await UserCollectApi.instance.getSubjects(widget.type); + if (!mounted) return; + setState(() { + _tabs = [const CollectTab(name: '全部'), ...tabs]; + }); + } catch (e) { + talker.error('获取学科Tab失败', e); + if (!mounted) return; + setState(() { + _tabs = const [CollectTab(name: '全部')]; + }); + } + } + + /// 分页加载(CommonPagedList 回调):只写请求 + 返回数据 + Future> _load(int pageKey) async { + // 首页/下拉刷新:游标重置(对齐 Vue useList pageNo===1 时 prevPageId=0) + if (pageKey == 1) _prevPageId = 0; + + final res = await UserCollectApi.instance.getCollectList( + type: widget.type, + subjectId: _subjectId, + pageIndex: pageKey, + pageSize: _pageSize, + prevPageId: _prevPageId, + ); + + // 更新游标为本页最后一条 id + _prevPageId = res.data.isNotEmpty ? res.data.last.id : 0; + + if (mounted) { + setState(() => _total = res.total); + } + + return PagingResult( + data: res.data, + hasNext: res.total > pageKey * _pageSize, + ); + } + + /// 切学科:ValueKey 变化后 CommonPagedList 整体重建,自动回到第一页 + void _onTabTap(CollectTab tab) { + if (_subjectId == tab.id) return; + setState(() { + _subjectId = tab.id; + _prevPageId = 0; + _total = 0; + }); + } + + /// 点击卡片 → 题目解析详情页 + /// TODO: 详情页待接入——错题本跳 questionAnalysis、收藏跳 collectAnalysis, + /// 参数:questionId=${item.questionId} + /// recordQuestionId=${item.recordQuestionId} + /// listDetailId=${item.id} + void _goDetail(UserCollectItem item) { + talker.debug( + '题目详情待接入:type=${widget.type} questionId=${item.questionId} ' + 'recordQuestionId=${item.recordQuestionId} listDetailId=${item.id}', + ); + } + + @override + Widget build(BuildContext context) { + final cfg = _config; + final isPad = + MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + surfaceTintColor: Colors.white, + elevation: 0, + title: Text( + cfg.title, + style: const 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'), + ), + ), + body: Column( + children: [ + // 1. 学科筛选 Tab(白底) + _buildTabBar(), + // 2. 总数头(背景图 + 大号数字) + _buildTotalHeader(cfg, isPad), + // 3. 列表区(#F5F8FA 灰底) + Expanded( + child: Container( + color: const Color(0xFFF5F8FA), + child: CommonPagedList( + // 切学科时重建列表,状态自动回第一页 + key: ValueKey('question_list_${widget.type}_$_subjectId'), + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), + emptyWidget: _buildEmpty(cfg), + load: _load, + buildItem: (context, item, index) => _QuestionCard( + item: item, + onTap: () => _goDetail(item), + ), + ), + ), + ), + ], + ), + ); + } + + /// 学科筛选胶囊 Tab 栏(横向滚动) + Widget _buildTabBar() { + return Container( + color: Colors.white, + height: 56.h, // 上下 padding 12.h + 胶囊 32.h(对齐 Vue 24rpx+64rpx) + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: 12.w), + itemCount: _tabs.length, + separatorBuilder: (_, __) => SizedBox(width: 12.w), + itemBuilder: (context, i) { + final tab = _tabs[i]; + final active = _subjectId == tab.id; + return Center( + child: GestureDetector( + onTap: () => _onTabTap(tab), + child: Container( + height: 32.h, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: active + ? const Color(0xFF3D7DF5) + : const Color(0xFFF5F8FA), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + tab.name, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w400, + color: active + ? Colors.white + : const Color(0xFF687FAD), + ), + ), + ), + ), + ); + }, + ), + ); + } + + /// 总数头:背景图(widthFix)+ 大号总数 + 装饰色带 + 文案 + Widget _buildTotalHeader(_PageConfig cfg, bool isPad) { + final numFontSize = isPad ? 52.5.sp : 35.sp; + final labelFontSize = isPad ? 21.sp : 14.sp; + + return Stack( + children: [ + // 背景图:宽度撑满,高度按图比例(对齐 Vue mode="widthFix") + CachedNetworkImage( + imageUrl: ImgUrl.build(isPad ? cfg.topBgPad : cfg.topBgPhone), + width: double.infinity, + fit: BoxFit.fitWidth, + alignment: Alignment.topCenter, + placeholder: (_, __) => const SizedBox.shrink(), + errorWidget: (_, __, ___) => const SizedBox.shrink(), + ), + // 数字 + 色带 + 文案(绝对定位叠在图上) + Positioned( + top: isPad ? 45.h : 16.5.h, // Vue padding-top: 90rpx / 33rpx + left: 30.w, // Vue padding-left: 60rpx + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 大号数字 + 底部装饰色带 + Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + right: 0, + bottom: isPad ? -7.h : -4.h, + child: Container( + height: 9.h, // Vue 18rpx + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.r), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: cfg.underlineColors, + stops: const [0, 0.36, 1], + ), + ), + ), + ), + Text( + '$_total', + style: TextStyle( + fontSize: numFontSize, + fontWeight: isPad ? FontWeight.w500 : FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + ], + ), + SizedBox(width: 10.w), // Vue margin-l-20 + // 文案(Vue margin-t-30:相对数字略微下沉) + Padding( + padding: EdgeInsets.only(top: 15.h), + child: Text( + cfg.totalLabel, + style: TextStyle( + fontSize: labelFontSize, + fontWeight: FontWeight.w400, + color: const Color(0xFF687FAD), + ), + ), + ), + ], + ), + ), + ], + ); + } + + /// 空状态:空态图 + 文案(对齐 Vue DataEmpty) + Widget _buildEmpty(_PageConfig cfg) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CachedNetworkImage( + imageUrl: ImgUrl.build(cfg.emptyImg), + width: 160.w, + fit: BoxFit.contain, + placeholder: (_, __) => const SizedBox.shrink(), + errorWidget: (_, __, ___) => const SizedBox.shrink(), + ), + SizedBox(height: 12.h), + Text( + cfg.emptyText, + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), + ), + ], + ), + ); + } +} + +/// 单条收藏/错题卡片:SubjectTag + 时间 + packName + 题干富文本 +class _QuestionCard extends StatelessWidget { + const _QuestionCard({required this.item, required this.onTap}); + + final UserCollectItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: 14.h), // Vue listItem margin-bottom: 28rpx + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(16.r), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16.r), + child: Padding( + // Vue padding: t-32 l/r-28 b-28 + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:学科标签 + 时间 + Row( + children: [ + SubjectTag(subject: item.subjectName), + const Spacer(), + Text( + item.createTime, + style: TextStyle( + fontSize: 12.sp, // Vue font-24 + color: const Color(0xFFB4C2E0), + ), + ), + ], + ), + SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx + // 试卷/礼包名 + Text( + item.packName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.sp, // Vue font-32 + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox(height: 16.h), // Vue question-stem-wrap margin-top: 32rpx + // 题干富文本(限高渐隐) + _StemBox(html: item.questionStem), + ], + ), + ), + ), + ), + ); + } +} + +/// 题干容器:#FAFBFD 底 + #E3ECFF 边框,内容限高 70.h(Vue 140rpx), +/// 超出部分底部渐隐遮罩 +class _StemBox extends StatelessWidget { + const _StemBox({required this.html}); + + final String html; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(12.w), // Vue padding: 24rpx + decoration: BoxDecoration( + color: const Color(0xFFFAFBFD), + borderRadius: BorderRadius.circular(8.r), // Vue 16rpx + border: Border.all(color: const Color(0xFFE3ECFF)), + ), + child: Stack( + children: [ + // 限高裁剪:OverflowBox 让富文本按自然高度布局,超出 70.h 的部分裁掉 + ClipRect( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: 70.h), + child: OverflowBox( + alignment: Alignment.topCenter, + minHeight: 0, + maxHeight: double.infinity, + child: RichTextContent( + html: html, + textStyle: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + color: const Color(0xFF495A7A), + height: 1.5, + ), + ), + ), + ), + ), + // 底部渐隐遮罩(Vue mask-layer 60rpx): + // 终点色与底色 #FAFBFD 一致,内容不足时遮罩盖在底色上视觉不可见 + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 30.h, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00FAFBFD), Color(0xFFFAFBFD)], + ), + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// 页面差异化配置(按 type 切换文案/图片/配色) +class _PageConfig { + const _PageConfig({ + required this.title, + required this.totalLabel, + required this.topBgPhone, + required this.topBgPad, + required this.emptyImg, + required this.emptyText, + required this.underlineColors, + }); + + final String title; + final String totalLabel; + final String topBgPhone; + final String topBgPad; + final String emptyImg; + final String emptyText; + final List underlineColors; + + /// 我的收藏(type=1):橙色系装饰 + static final _PageConfig collect = _PageConfig( + title: '我的收藏', + totalLabel: '收藏题目', + topBgPhone: '/StaticResource/2026-04-08/my-collect-top-bg.png', + topBgPad: '/StaticResource/2026-06-02/my-collect-top-bg-pad1780378706432.png', + emptyImg: '/StaticResource/2026-04-11/contentEmptys.png', + emptyText: '暂无收藏', + // Vue: linear-gradient(90deg, #F59C3D00 0%, #FFCA1D33 36%, #FDAD8A80 100%) + underlineColors: const [ + Color(0x00F59C3D), + Color(0x33FFCA1D), + Color(0x80FDAD8A), + ], + ); + + /// 错题本(type=2):蓝色系装饰 + static final _PageConfig wrong = _PageConfig( + title: '错题本', + totalLabel: '道错题', + topBgPhone: '/StaticResource/2026-04-08/my-wrong-top-bg.png', + topBgPad: '/StaticResource/2026-06-02/my-wrong-top-bg-pad1780372865693.png', + emptyImg: '/StaticResource/2026-04-11/noDatas.png', + emptyText: '暂无错题', + // Vue: linear-gradient(90deg, #3D7DF500 0%, #1D9CFF33 36%, #8AECFD80 100%) + underlineColors: const [ + Color(0x003D7DF5), + Color(0x331D9CFF), + Color(0x808AECFD), + ], + ); +} From c65df455f2f8346b68e30a737004088283684ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E5=AE=9D=E6=96=B0?= Date: Thu, 10 Sep 2026 18:14:41 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat(my):=20add=20=E6=88=91=E7=9A=84?= =?UTF-8?q?=E7=AC=94=E8=AE=B0=20page=20and=20related=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增我的笔记页面,包含学科筛选Tab和游标分页列表 2. 新增user_note接口封装和对应数据模型 3. 在我的页面添加笔记入口跳转 4. 优化收藏/错题卡片的按压反馈效果 --- lib/core/api/user_note/api.dart | 67 ++++ lib/core/api/user_note/model.dart | 90 +++++ lib/core/router/app_router.dart | 3 + lib/features/my/my_page.dart | 2 +- lib/features/my/note/note_page.dart | 372 ++++++++++++++++++ .../my/question_list/question_list_page.dart | 105 +++-- 6 files changed, 597 insertions(+), 42 deletions(-) create mode 100644 lib/core/api/user_note/api.dart create mode 100644 lib/core/api/user_note/model.dart create mode 100644 lib/features/my/note/note_page.dart diff --git a/lib/core/api/user_note/api.dart b/lib/core/api/user_note/api.dart new file mode 100644 index 0000000..ff6dc92 --- /dev/null +++ b/lib/core/api/user_note/api.dart @@ -0,0 +1,67 @@ +import 'package:new_learn/core/api/user_collect/model.dart'; +import 'package:new_learn/core/api/user_note/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【user_note 模块接口】我的笔记 —— 对标 Vue3 端 api/userNote/index.ts +/// +/// getSubjects —— 学科筛选项(顶部 Tab,无参) +/// GET /api/v1/UserNote/GetUserCollectSubject +/// +/// getNoteList —— 笔记分页列表(游标分页) +/// GET /api/v1/UserNote/GetUserNotes +/// 参数:subjectId(可选) / pageIndex / pageSize / prevPageId +/// +/// 列表不按笔记类型过滤;type(1题目/2讲义) 由每条数据自带, +/// 前端用卡片角标图标区分。 +/// +/// 注意:Vue 同文件里的 OSS 凭证/上传地址接口归属 OSS 模块 +/// (Flutter 端见 core/network/oss_api.dart),不在此重复封装。 +/// ============================================================ +class UserNoteApi { + UserNoteApi._(); + + static final UserNoteApi instance = UserNoteApi._(); + + /// 学科筛选项 + Future> getSubjects() async { + final data = await DioClient.instance.get( + '/api/v1/UserNote/GetUserCollectSubject', + ); + // 兼容裸数组 / {data: [...]} 响应壳 + final List list = data is List + ? data + : (data is Map + ? (data['data'] as List? ?? const []) + : const []); + return list + .whereType>() + .map(CollectTab.fromJson) + .toList(); + } + + /// 笔记分页列表 + /// + /// [subjectId] 不传 = 全部学科;[prevPageId] 游标:首页传 0, + /// 翻页传上一页最后一条 id。 + Future getNoteList({ + int? subjectId, + required int pageIndex, + int pageSize = 10, + int prevPageId = 0, + }) async { + final query = { + 'pageIndex': pageIndex, + 'pageSize': pageSize, + 'prevPageId': prevPageId, + }; + // 对齐 Vue useList:subjectId 为 null 时不带该参数 + if (subjectId != null) query['subjectId'] = subjectId; + + final data = await DioClient.instance.get( + '/api/v1/UserNote/GetUserNotes', + query: query, + ); + return UserNotePage.fromJson(data as Map); + } +} diff --git a/lib/core/api/user_note/model.dart b/lib/core/api/user_note/model.dart new file mode 100644 index 0000000..c3b0538 --- /dev/null +++ b/lib/core/api/user_note/model.dart @@ -0,0 +1,90 @@ +/// ============================================================ +/// 【user_note 模块模型】我的笔记 —— 对标 Vue3 端 api/userNote/type.ts +/// +/// UserNoteItem 笔记列表项(type 1=题目笔记 2=讲义笔记) +/// UserNotePage 分页响应(游标分页:pageIndex + prevPageId) +/// +/// 学科筛选 Tab 复用收藏模块的 CollectTab(见 api.dart 的 import, +/// 与 Vue 端 api/userNote/index.ts 从 ../userCollect/type 引入一致)。 +/// ============================================================ +library; + +/// 兼容后端 id 字段:雪花 id 后端可能以 String 返回(防 JS 精度丢失) +int? _noteToInt(dynamic v) { + if (v == null) return null; + if (v is num) return v.toInt(); + if (v is String && v.isNotEmpty) return int.tryParse(v); + return null; +} + +/// 我的笔记列表项 +class UserNoteItem { + const UserNoteItem({ + this.id = 0, + this.subjectName = '', + this.type = 0, + this.title = '', + this.content = '', + this.createTime = '', + }); + + /// 笔记 id(游标分页 prevPageId 取本页最后一条的 id;详情页参数 id) + final int id; + + /// 学科名(SubjectTag 配色用) + final String subjectName; + + /// 笔记类型:1=题目笔记 2=讲义笔记(卡片角标/详情页区分用) + final int type; + + /// 笔记标题 + final String title; + + /// 笔记内容 HTML(RichTextContent 渲染) + final String content; + + /// 记笔记时间字符串 + final String createTime; + + factory UserNoteItem.fromJson(Map json) { + return UserNoteItem( + id: _noteToInt(json['id']) ?? 0, + subjectName: json['subjectName'] as String? ?? '', + type: _noteToInt(json['type']) ?? 0, + title: json['title'] as String? ?? '', + content: json['content'] as String? ?? '', + createTime: json['createTime'] as String? ?? '', + ); + } +} + +/// 我的笔记分页响应 +class UserNotePage { + const UserNotePage({ + this.data = const [], + this.total = 0, + this.pageIndex = 1, + this.pageSize = 10, + }); + + final List data; + final int total; + final int pageIndex; + final int pageSize; + + /// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data + factory UserNotePage.fromJson(Map json) { + final payload = (json['data'] is Map) + ? json['data'] as Map + : json; + return UserNotePage( + data: (payload['data'] as List? ?? const []) + .whereType>() + .map(UserNoteItem.fromJson) + .toList(), + total: _noteToInt(payload['total']) ?? 0, + pageIndex: _noteToInt(payload['pageIndex']) ?? 1, + pageSize: _noteToInt(payload['pageSize']) ?? 10, + ); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 837f8bf..f500c8d 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -13,6 +13,7 @@ import 'package:new_learn/features/class/my_interact_page.dart'; import 'package:new_learn/features/class/my_update_detail_page.dart'; import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/features/my/my_page.dart'; +import 'package:new_learn/features/my/note/note_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/question_list/question_list_page.dart'; @@ -139,6 +140,8 @@ final GoRouter appRouter = GoRouter( MyUpdateDetailPage(initialPost: state.extra as UserPostDto), ), GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), + // 我的笔记 + GoRoute(path: '/myNote', builder: (_, _) => const NotePage()), // 错题本(type=2)/ 我的收藏(type=1)共用 QuestionListPage GoRoute( path: '/myWrong', diff --git a/lib/features/my/my_page.dart b/lib/features/my/my_page.dart index ec0d05b..8217c88 100644 --- a/lib/features/my/my_page.dart +++ b/lib/features/my/my_page.dart @@ -117,7 +117,7 @@ class MyPage extends ConsumerWidget { '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/note.png', title: '我的笔记', onTap: () { - // TODO: 跳转到我的笔记页 + context.push('/myNote'); }, ), _MenuItem( diff --git a/lib/features/my/note/note_page.dart b/lib/features/my/note/note_page.dart new file mode 100644 index 0000000..32a18d2 --- /dev/null +++ b/lib/features/my/note/note_page.dart @@ -0,0 +1,372 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; +import 'package:new_learn/core/api/user_collect/model.dart'; +import 'package:new_learn/core/api/user_note/api.dart'; +import 'package:new_learn/core/api/user_note/model.dart'; +import 'package:new_learn/core/components/common_paged_list.dart'; +import 'package:new_learn/core/components/rich_text_content.dart'; +import 'package:new_learn/core/components/subject_tag.dart'; +import 'package:new_learn/core/logger/app_talker.dart'; +import 'package:new_learn/core/utils/img_url.dart'; + +/// ============================================================ +/// 【NotePage】我的笔记 —— 对标 Vue pages/myPackage/note/note.vue +/// +/// 页面结构(对齐 Vue,从上到下): +/// 1. 学科筛选 Tab("全部" + 接口返回学科,胶囊样式,点击切学科重载) +/// 2. #F5F8FA 列表区:CommonPagedList 游标分页 +/// 卡片 = SubjectTag + 时间 + title + 内容富文本(限高渐隐) +/// + 右上角笔记类型角标 +/// +/// 与收藏/错题列表的差异: +/// ① 没有顶部总数头; +/// ② 列表不按类型过滤,type 由每条数据自带: +/// 1=题目笔记(note-stem.png)2=讲义笔记(note-teaching.png), +/// 仅用角标图标区分,详情页再按 type 渲染不同内容; +/// ③ 标题字段 title、正文字段 content。 +/// +/// 分页:pageKey 当 pageIndex;prevPageId 闭包维护——首页/刷新重置为 0, +/// 每页请求后更新为本页最后一条 id;切学科用 ValueKey 重建列表回首页。 +/// ============================================================ +class NotePage extends StatefulWidget { + const NotePage({super.key}); + + @override + State createState() => _NotePageState(); +} + +class _NotePageState extends State { + static const int _pageSize = 10; + + /// 学科 Tab 列表("全部"为客户端插入,id=null) + List _tabs = const [CollectTab(name: '全部')]; + + /// 当前选中的学科 id(null = 全部) + int? _subjectId; + + /// 游标:本页最后一条 id,首页/刷新/切学科时重置为 0 + int _prevPageId = 0; + + @override + void initState() { + super.initState(); + talker.debug('打开页面:我的笔记'); + _loadTabs(); + } + + /// 拉取学科筛选项;失败兜底只有"全部"(对齐 Vue catch 逻辑) + Future _loadTabs() async { + try { + final tabs = await UserNoteApi.instance.getSubjects(); + if (!mounted) return; + setState(() { + _tabs = [const CollectTab(name: '全部'), ...tabs]; + }); + } catch (e) { + talker.error('获取笔记学科Tab失败', e); + if (!mounted) return; + setState(() { + _tabs = const [CollectTab(name: '全部')]; + }); + } + } + + /// 分页加载(CommonPagedList 回调) + Future> _load(int pageKey) async { + // 首页/下拉刷新:游标重置(对齐 Vue useList pageNo===1 时 prevPageId=0) + if (pageKey == 1) _prevPageId = 0; + + final res = await UserNoteApi.instance.getNoteList( + subjectId: _subjectId, + pageIndex: pageKey, + pageSize: _pageSize, + prevPageId: _prevPageId, + ); + + // 更新游标为本页最后一条 id + _prevPageId = res.data.isNotEmpty ? res.data.last.id : 0; + + return PagingResult( + data: res.data, + hasNext: res.total > pageKey * _pageSize, + ); + } + + /// 切学科:ValueKey 变化后 CommonPagedList 整体重建,自动回到第一页 + void _onTabTap(CollectTab tab) { + if (_subjectId == tab.id) return; + setState(() { + _subjectId = tab.id; + _prevPageId = 0; + }); + } + + /// 点击卡片 → 笔记详情页 + /// TODO: 详情页待接入,需要按 item.type 区分: + /// type=1 题目笔记 / type=2 讲义笔记,详情页内容结构不同; + /// Vue 跳转:/pages/myPackage/noteDetail/index?id=${item.id}&type=${item.type} + void _goDetail(UserNoteItem item) { + talker.debug('笔记详情待接入:id=${item.id} type=${item.type}(1题目笔记/2讲义笔记)'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + surfaceTintColor: Colors.white, + 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'), + ), + ), + body: Column( + children: [ + _buildTabBar(), + // 列表区(#F5F8FA 灰底) + Expanded( + child: Container( + color: const Color(0xFFF5F8FA), + child: CommonPagedList( + // 切学科时重建列表,状态自动回第一页 + key: ValueKey('note_list_$_subjectId'), + // Vue list-refresh-wrap padding-top:32rpx + listWrapper 28rpx + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), + emptyWidget: _buildEmpty(), + load: _load, + buildItem: (context, item, index) => + _NoteCard(item: item, onTap: () => _goDetail(item)), + ), + ), + ), + ], + ), + ); + } + + /// 学科筛选胶囊 Tab 栏(横向滚动) + Widget _buildTabBar() { + return Container( + color: Colors.white, + height: 56.h, // 上下 padding 12.h + 胶囊 32.h(对齐 Vue 24rpx+64rpx) + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: 12.w), + itemCount: _tabs.length, + separatorBuilder: (_, _) => SizedBox(width: 12.w), + itemBuilder: (context, i) { + final tab = _tabs[i]; + final active = _subjectId == tab.id; + return Center( + child: GestureDetector( + onTap: () => _onTabTap(tab), + child: Container( + height: 32.h, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: 16.w), + decoration: BoxDecoration( + color: active + ? const Color(0xFF3D7DF5) + : const Color(0xFFF5F8FA), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + tab.name, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w400, + color: active ? Colors.white : const Color(0xFF687FAD), + ), + ), + ), + ), + ); + }, + ), + ); + } + + /// 空状态:空态图 + 文案(对齐 Vue DataEmpty noNotess.png) + Widget _buildEmpty() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CachedNetworkImage( + imageUrl: ImgUrl.build('/StaticResource/2026-04-11/noNotess.png'), + width: 160.w, + fit: BoxFit.contain, + placeholder: (_, _) => const SizedBox.shrink(), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + SizedBox(height: 12.h), + Text( + '暂无笔记', + style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)), + ), + ], + ), + ); + } +} + +/// 单条笔记卡片:SubjectTag + 时间 + title + 内容富文本 + 右上角类型角标 +class _NoteCard extends StatelessWidget { + const _NoteCard({required this.item, required this.onTap}); + + final UserNoteItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: 14.h, + ), // Vue listItem margin-bottom: 28rpx + child: Stack( + clipBehavior: Clip.none, // 允许类型角标超出卡片顶部 + children: [ + Material( + color: Colors.white, + borderRadius: BorderRadius.circular(16.r), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16.r), + // 去掉点击时默认的灰色水波纹/高亮(Vue 端卡片无按压反馈) + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + child: Padding( + // Vue padding: t-32 l/r-28 b-28 + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:学科标签 + 时间 + Row( + children: [ + SubjectTag(subject: item.subjectName), + SizedBox(width: 12.w), + Text( + item.createTime, + style: TextStyle( + fontSize: 12.sp, // Vue font-24 + color: const Color(0xFFB4C2E0), + ), + ), + ], + ), + SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx + // 笔记标题 + Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.sp, // Vue font-32 + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox( + height: 16.h, + ), // Vue question-stem-wrap margin-top: 32rpx + // 笔记内容富文本(限高渐隐) + _NoteContentBox(html: item.content), + ], + ), + ), + ), + ), + // 右上角类型角标:Vue note-type-icon top:-4rpx right:28rpx 56rpx + Positioned(top: -2.h, right: 14.w, child: _buildTypeIcon()), + ], + ), + ); + } + + /// 笔记类型角标:type=1 题目笔记(note-stem)/ 2 讲义笔记(note-teaching) + Widget _buildTypeIcon() { + final icon = item.type == 2 + ? '/StaticResource/2026-04-07/note-teaching.png' + : '/StaticResource/2026-04-07/note-stem.png'; + return CachedNetworkImage( + imageUrl: ImgUrl.build(icon), + width: 28.w, + height: 28.h, + fit: BoxFit.contain, + placeholder: (_, _) => SizedBox(width: 28.w, height: 28.h), + errorWidget: (_, _, _) => SizedBox(width: 28.w, height: 28.h), + ); + } +} + +/// 笔记内容容器:#FAFBFD 底 + #E3ECFF 边框,内容限高 70.h(Vue 140rpx), +/// 超出部分底部渐隐遮罩(与收藏/错题列表题干样式一致) +class _NoteContentBox extends StatelessWidget { + const _NoteContentBox({required this.html}); + + final String html; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(12.w), // Vue padding: 24rpx + decoration: BoxDecoration( + color: const Color(0xFFFAFBFD), + borderRadius: BorderRadius.circular(8.r), // Vue 16rpx + border: Border.all(color: const Color(0xFFE3ECFF)), + ), + child: Stack( + children: [ + // 限高裁剪:OverflowBox 让富文本按自然高度布局,超出 70.h 的部分裁掉 + ClipRect( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: 70.h), + child: OverflowBox( + alignment: Alignment.topCenter, + minHeight: 0, + maxHeight: double.infinity, + child: RichTextContent( + html: html, + textStyle: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + color: const Color(0xFF495A7A), + height: 1.5, + ), + ), + ), + ), + ), + // 底部渐隐遮罩(Vue mask-layer 60rpx): + // 终点色与底色 #FAFBFD 一致,内容不足时遮罩盖在底色上视觉不可见 + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 30.h, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00FAFBFD), Color(0xFFFAFBFD)], + ), + borderRadius: BorderRadius.circular(8.r), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/my/question_list/question_list_page.dart b/lib/features/my/question_list/question_list_page.dart index 5ea1f63..2e40eb7 100644 --- a/lib/features/my/question_list/question_list_page.dart +++ b/lib/features/my/question_list/question_list_page.dart @@ -326,58 +326,81 @@ class _QuestionListPageState extends State { } /// 单条收藏/错题卡片:SubjectTag + 时间 + packName + 题干富文本 -class _QuestionCard extends StatelessWidget { +/// +/// 按压反馈:按下缩放到 0.97、抬起回弹,背景始终白色 +/// (不用 InkWell 灰底,改用 GestureDetector 驱动 AnimatedScale)。 +class _QuestionCard extends StatefulWidget { const _QuestionCard({required this.item, required this.onTap}); final UserCollectItem item; final VoidCallback onTap; + @override + State<_QuestionCard> createState() => _QuestionCardState(); +} + +class _QuestionCardState extends State<_QuestionCard> { + /// 是否处于按压状态(控制 0.97 缩放) + bool _pressed = false; + + void _setPressed(bool v) { + if (_pressed != v) setState(() => _pressed = v); + } + @override Widget build(BuildContext context) { + final item = widget.item; return Padding( padding: EdgeInsets.only(bottom: 14.h), // Vue listItem margin-bottom: 28rpx - child: Material( - color: Colors.white, - borderRadius: BorderRadius.circular(16.r), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(16.r), - child: Padding( - // Vue padding: t-32 l/r-28 b-28 - padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 第一行:学科标签 + 时间 - Row( - children: [ - SubjectTag(subject: item.subjectName), - const Spacer(), - Text( - item.createTime, - style: TextStyle( - fontSize: 12.sp, // Vue font-24 - color: const Color(0xFFB4C2E0), + child: GestureDetector( + onTapDown: (_) => _setPressed(true), + onTapUp: (_) => _setPressed(false), + onTapCancel: () => _setPressed(false), + onTap: widget.onTap, + child: AnimatedScale( + scale: _pressed ? 0.97 : 1.0, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(16.r), + child: Padding( + // Vue padding: t-32 l/r-28 b-28 + padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 第一行:学科标签 + 时间 + Row( + children: [ + SubjectTag(subject: item.subjectName), + const Spacer(), + Text( + item.createTime, + style: TextStyle( + fontSize: 12.sp, // Vue font-24 + color: const Color(0xFFB4C2E0), + ), ), - ), - ], - ), - SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx - // 试卷/礼包名 - Text( - item.packName, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16.sp, // Vue font-32 - fontWeight: FontWeight.w600, - color: const Color(0xFF1D2B47), + ], ), - ), - SizedBox(height: 16.h), // Vue question-stem-wrap margin-top: 32rpx - // 题干富文本(限高渐隐) - _StemBox(html: item.questionStem), - ], + SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx + // 试卷/礼包名 + Text( + item.packName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.sp, // Vue font-32 + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox(height: 16.h), // Vue question-stem-wrap margin-top: 32rpx + // 题干富文本(限高渐隐) + _StemBox(html: item.questionStem), + ], + ), ), ), ),