feat(report): 实现AI学习报告计算页及相关组件

1. 新增报告计算进度圆环、科目卡片、完成弹窗组件
2. 重构report_computation_page,替换旧的自定义组件为新封装组件
3. 新增student_info_page的主题资源和UI重构,适配Flutter屏幕适配
4. 新增临时调试入口用于开发测试
This commit is contained in:
崔宝新
2026-09-10 11:57:18 +08:00
parent e0b95a8b43
commit 0fefc786bb
6 changed files with 1400 additions and 452 deletions
+543 -158
View File
@@ -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<StudentInfoPage> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final extra = GoRouterState.of(context).extra as Map<String, dynamic>? ?? {};
final extra =
GoRouterState.of(context).extra as Map<String, dynamic>? ?? {};
_reportType = (extra['type'] as String?) ?? 'placementTest';
_loadReport();
});
@@ -45,18 +129,9 @@ class _StudentInfoPageState extends ConsumerState<StudentInfoPage> {
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<StudentInfoPage> {
}
}
/// 按钮点击: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 {
@@ -82,10 +154,7 @@ class _StudentInfoPageState extends ConsumerState<StudentInfoPage> {
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<StudentInfoPage> {
: (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,
),
),
),
);
}
}