feat: 新增诊断报告全流程功能,适配登录后报告跳转逻辑

实现了入门测/出门测报告的完整链路:
1. 新增UserInfo模型的出门测报告标记字段与相关接口
2. 新增全局报告路由拦截器,自动在登录后/应用回前台时检查报告状态
3. 新增报告相关API、页面与路由配置,包含欢迎页、H5壳子、AI计算页
4. 适配登录成功后的报告跳转逻辑,更新用户状态管理方法
This commit is contained in:
崔宝新
2026-09-09 16:49:51 +08:00
parent c0c673e2d2
commit 5e4cfe9ac6
13 changed files with 1441 additions and 18 deletions
+20
View File
@@ -137,6 +137,26 @@ class UserApi {
}
}
// ==================== 报告状态更新 ====================
/// 更新入门测试报告显示状态(看完后置回 false)
/// → POST /api/v1/User/ChangeShowReport
///
/// 对齐 Vue api/user/index.ts 的 UpdateReportStatus(),
/// 在 reportComputation finishedPopup 点"立即开启学习蝶变"后调用。
static Future<void> 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<void> 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();
+5
View File
@@ -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<String, dynamic> 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,
);
}
}
+46
View File
@@ -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<UserReportType> getCurrentReport() async {
final data = await DioClient.instance.get(
'/api/v1/UserReport/GetCurrentReport',
);
return UserReportType.fromJson(data as Map<String, dynamic>);
}
/// 获取当前用户出门测试报告 → GET /api/v1/UserReport/GetCurrentAfterReport
Future<UserReportType> getCurrentAfterReport() async {
final data = await DioClient.instance.get(
'/api/v1/UserReport/GetCurrentAfterReport',
);
return UserReportType.fromJson(data as Map<String, dynamic>);
}
/// 获取当前用户未掌握的知识点 → GET /api/v1/UserReport/GetCurrentReportKnow
Future<List<UserReportKnowType>> getCurrentReportKnow() async {
final data = await DioClient.instance.get(
'/api/v1/UserReport/GetCurrentReportKnow',
);
final list = data is List ? data : (data['data'] as List<dynamic>? ?? const []);
return list
.whereType<Map<String, dynamic>>()
.map(UserReportKnowType.fromJson)
.toList();
}
}
+80
View File
@@ -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<String, dynamic> 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<SubjectReportType> subjectReports;
factory UserReportType.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return UserReportType(
userName: (payload['userName'] as String?) ?? '',
userLevel: (payload['userLevel'] as String?) ?? '',
subjectReports: (payload['subjectReports'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.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<String, dynamic> json) {
return UserReportKnowType(
subjectName: (json['subjectName'] as String?) ?? '',
knowCount: (json['knowCount'] as num?)?.toInt() ?? 0,
);
}
}
+7 -2
View File
@@ -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(
+14 -3
View File
@@ -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,
);
}
+44
View File
@@ -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<CurrentUserExtension?> {
// 也还是最新头像。
await UserStorage.instance.saveUserInfo(updated);
}
/// 强制从服务器拉最新用户信息(对齐 Vue App.vue 的 fetchCurrentUserExtension(true))
///
/// 使用场景:
/// 1. App 回前台(WidgetsBindingObserver resumed):拿最新 isShowReport / isShowAfterReport;
/// 2. 报告页调完 ChangeShowReport / ChangeShowAfterReport 后:刷新全局状态;
/// 3. 登录成功后:确保后端最新数据。
Future<CurrentUserExtension?> 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<void> 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<void> 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 用)
+184
View File
@@ -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<ReportRedirectNotifier> createState() =>
_ReportRedirectNotifierState();
}
class _ReportRedirectNotifierState extends ConsumerState<ReportRedirectNotifier>
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<void> _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;
}
+19
View File
@@ -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 = <String>{'/', '/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',