Files
App.K12.Student.Miniapp_Flu…/lib/features/report/student_info_page.dart
T
崔宝新 0fefc786bb feat(report): 实现AI学习报告计算页及相关组件
1. 新增报告计算进度圆环、科目卡片、完成弹窗组件
2. 重构report_computation_page,替换旧的自定义组件为新封装组件
3. 新增student_info_page的主题资源和UI重构,适配Flutter屏幕适配
4. 新增临时调试入口用于开发测试
2026-09-10 11:57:18 +08:00

655 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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';
/// ============================================================
/// 【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(出门测)
///
/// 完整链路:登录成功/全局拦截 → 此页 → 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});
@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 report = isExit
? await ReportApi.instance.getCurrentAfterReport()
: await ReportApi.instance.getCurrentReport();
if (mounted) {
setState(() {
_report = report;
_loading = false;
});
}
} catch (e) {
talker.error('加载报告数据失败', e);
if (mounted) setState(() => _loading = false);
}
}
Future<void> _onStart() async {
if (_report.subjectReports.isEmpty) {
if (_reportType == 'exitTest') {
await ref.read(currentUserProvider.notifier).markShowAfterReportFalse();
} else {
await ref.read(currentUserProvider.notifier).markShowReportFalse();
}
if (mounted) context.go('/');
return;
}
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: _StudentTheme.pageBg,
body: _loading
? const Center(child: CircularProgressIndicator())
: SizedBox.expand(
child: Stack(
children: [
// ===== 背景层(Vue bg-wrapper)铺满 =====
Positioned.fill(
child: Image.network(
_img(_StudentTheme.bg),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
// ===== 内容层(滚动,底部留出按钮高度)=====
SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 140),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(userName),
const SizedBox(height: 130),
// _buildAnalysisLevel(),
// const SizedBox(height: 15),
_buildSubjectCard(),
const SizedBox(height: 14),
_buildLogo(),
],
),
),
),
// ===== 底部按钮(fixed,无边距)=====
Positioned(
left: 0,
right: 0,
bottom: 0,
child: SafeArea(top: false, child: _buildBottomButton()),
),
],
),
),
);
}
// ===== 顶部问候区 + 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(),
),
],
),
);
}
// ===== 当前档次等级卡片(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),
),
],
),
),
],
),
),
);
}
// ===== 诊断科目分析卡片 =====
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)),
),
)
: 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: 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,
),
),
],
),
),
),
),
),
],
),
);
}
}
/// 科目卡片 —— 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,
),
),
),
);
}
}