Compare commits
2
Commits
ee14373c6d
...
e0b95a8b43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0b95a8b43 | ||
|
|
5e4cfe9ac6 |
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 用)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,6 +20,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';
|
||||
@@ -70,6 +73,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:
|
||||
@@ -142,6 +151,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',
|
||||
|
||||
@@ -381,6 +381,18 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
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<LoginPage> {
|
||||
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);
|
||||
|
||||
@@ -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<ReportComputationPage> createState() =>
|
||||
_ReportComputationPageState();
|
||||
}
|
||||
|
||||
class _ReportComputationPageState extends ConsumerState<ReportComputationPage> {
|
||||
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<void> _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<UserReportKnowType>;
|
||||
|
||||
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<void> _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>(
|
||||
Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 进度圈
|
||||
SizedBox(
|
||||
width: 240,
|
||||
height: 240,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress / 100,
|
||||
strokeWidth: 8,
|
||||
backgroundColor: Colors.transparent,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
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<Color>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ReportWebViewPage> createState() => _ReportWebViewPageState();
|
||||
}
|
||||
|
||||
class _ReportWebViewPageState extends ConsumerState<ReportWebViewPage> {
|
||||
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<String, dynamic>? ?? {};
|
||||
_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<String, String> _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<String, dynamic>? ?? {};
|
||||
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<void> _handleSwitchTab(String flutterPath) async {
|
||||
if (!mounted) return;
|
||||
context.go(flutterPath);
|
||||
}
|
||||
|
||||
/// H5 壳子调 navigateTo:跳到对应 Flutter 页面
|
||||
Future<void> _handleNavigateTo(String flutterPath) async {
|
||||
if (!mounted) return;
|
||||
context.go(flutterPath);
|
||||
}
|
||||
|
||||
/// H5 壳子调 reLaunch:重建路由栈跳到对应页面
|
||||
Future<void> _handleReLaunch(String flutterPath) async {
|
||||
if (!mounted) return;
|
||||
context.go(flutterPath);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_webController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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<StudentInfoPage> createState() => _StudentInfoPageState();
|
||||
}
|
||||
|
||||
class _StudentInfoPageState extends ConsumerState<StudentInfoPage> {
|
||||
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<String, dynamic>? ?? {};
|
||||
_reportType = (extra['type'] as String?) ?? 'placementTest';
|
||||
_loadReport();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user