1. 新增我的考试页面,包含学科筛选Tab和分页考试列表 2. 新增学科图标工具类,替换我的报告页重复的学科图标逻辑 3. 在我的页面菜单中添加我的考试入口 4. 新增exam模块的API接口和数据模型 5. 优化我的报告页面卡片样式,统一与考试卡片的布局
436 lines
14 KiB
Dart
436 lines
14 KiB
Dart
import 'dart:ui' as ui;
|
||
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:new_learn/core/api/exam/api.dart';
|
||
import 'package:new_learn/core/api/exam/model.dart';
|
||
import 'package:new_learn/core/components/common_paged_list.dart';
|
||
import 'package:new_learn/core/logger/app_talker.dart';
|
||
import 'package:new_learn/core/utils/img_url.dart';
|
||
import 'package:new_learn/core/utils/subject_icons.dart';
|
||
|
||
/// ============================================================
|
||
/// 【ExamPage】我的考试列表页 —— 对标 Vue pages/myPackage/exam/exam.vue
|
||
///
|
||
/// 结构:
|
||
/// AppBar + Tab 栏(全部/各学科,胶囊样式,动态拉取)
|
||
/// + 分页列表(CommonPagedList,下拉刷新/触底加载)
|
||
/// + 卡片(学科图标 + 考试名 + 时间 + 得分渐变文字)
|
||
///
|
||
/// 点击卡片 → 跳考试报告详情
|
||
/// (先留 TODO,后续考试详情页做了再接入路由)
|
||
/// ============================================================
|
||
class ExamPage extends StatefulWidget {
|
||
const ExamPage({super.key});
|
||
|
||
@override
|
||
State<ExamPage> createState() => _ExamPageState();
|
||
}
|
||
|
||
class _ExamPageState extends State<ExamPage> {
|
||
final List<ExamSubjectTab> _tabs = [];
|
||
ExamSubjectTab _currentTab = const ExamSubjectTab(id: null, name: '全部');
|
||
bool _tabsLoading = true;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_tabs.add(const ExamSubjectTab(id: null, name: '全部'));
|
||
_loadTabs();
|
||
}
|
||
|
||
Future<void> _loadTabs() async {
|
||
try {
|
||
final list = await ExamApi.instance.getSubjects();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_tabs
|
||
..clear()
|
||
..add(const ExamSubjectTab(id: null, name: '全部'))
|
||
..addAll(list);
|
||
_currentTab = _tabs.first;
|
||
_tabsLoading = false;
|
||
});
|
||
} catch (e) {
|
||
talker.error('获取考试科目Tab失败', e);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_tabs
|
||
..clear()
|
||
..add(const ExamSubjectTab(id: null, name: '全部'));
|
||
_currentTab = _tabs.first;
|
||
_tabsLoading = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 分页加载回调
|
||
Future<PagingResult<ExamDto>> _loadPage(int pageKey) async {
|
||
final res = await ExamApi.instance.search(
|
||
baseSubjectId: _currentTab.id,
|
||
pageIndex: pageKey,
|
||
pageSize: 10,
|
||
);
|
||
// hasNext:当前页返回数据满 pageSize 且总数更大
|
||
final hasMore =
|
||
res.data.length >= res.pageSize && res.total > pageKey * res.pageSize;
|
||
return PagingResult(data: res.data, hasNext: hasMore);
|
||
}
|
||
|
||
void _onTapTab(ExamSubjectTab tab) {
|
||
if (_currentTab.id == tab.id) return;
|
||
setState(() => _currentTab = tab);
|
||
// tab 切换后,通过 ValueKey 让 CommonPagedList 重建 State,自动首屏加载新数据
|
||
}
|
||
|
||
void _goToExamDetail(ExamDto item) {
|
||
// TODO: 接入考试报告详情页路由,对应 Vue /pages/examPackage/examReport/index?recordId=${item.id}
|
||
talker.debug('点击考试详情: ${item.taskName}, id=${item.id}');
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: Colors.white,
|
||
appBar: AppBar(
|
||
title: const Text('我的考试'),
|
||
backgroundColor: Colors.white,
|
||
foregroundColor: const Color(0xFF1D2B47),
|
||
elevation: 0,
|
||
centerTitle: true,
|
||
bottom: PreferredSize(
|
||
preferredSize: Size.fromHeight(44.h),
|
||
child: _buildTabBar(),
|
||
),
|
||
),
|
||
body: _tabsLoading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: Container(
|
||
color: const Color(0xFFF5F8FA),
|
||
child: CommonPagedList<ExamDto>(
|
||
key: ValueKey('exam-list-${_currentTab.id}'),
|
||
load: _loadPage,
|
||
padding: EdgeInsets.fromLTRB(12.w, 16.h, 14.w, 20.h),
|
||
separatorBuilder: (_, __) => SizedBox(height: 14.h),
|
||
noMoreText: '- AI 爱学蝶变提醒你 没有更多了 -',
|
||
emptyWidget: _buildEmpty(),
|
||
buildItem: (ctx, item, index) {
|
||
return _ExamCard(
|
||
item: item,
|
||
onTap: () => _goToExamDetail(item),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ===== Tab 栏(对标 up-tabs + 自定义 activeStyle/inactiveStyle)=====
|
||
Widget _buildTabBar() {
|
||
return Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 12.h),
|
||
color: Colors.white,
|
||
alignment: Alignment.centerLeft,
|
||
child: SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
physics: const BouncingScrollPhysics(),
|
||
child: Row(
|
||
children: _tabs.map((tab) {
|
||
final isActive = tab.id == _currentTab.id;
|
||
return Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 6.w),
|
||
child: GestureDetector(
|
||
onTap: () => _onTapTab(tab),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Container(
|
||
height: 37.h,
|
||
width: 54.w,
|
||
// padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||
decoration: BoxDecoration(
|
||
color: isActive
|
||
? const Color(0xFF3D7DF5)
|
||
: const Color(0xFFF5F8FA),
|
||
borderRadius: BorderRadius.circular(999),
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
tab.name,
|
||
style: TextStyle(
|
||
fontSize: 15.spMin,
|
||
color: isActive ? Colors.white : const Color(0xFF687FAD),
|
||
fontWeight: FontWeight.w400,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ===== 空状态(对标 DataEmpty + exam_empty_bg)=====
|
||
Widget _buildEmpty() {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/StaticResource/2026-06-10/exam_empty_bg1781078431804.png',
|
||
),
|
||
width: 200.w,
|
||
fit: BoxFit.fitWidth,
|
||
errorWidget: (_, _, _) => SizedBox(width: 200.w, height: 200.h),
|
||
),
|
||
SizedBox(height: 12.h),
|
||
Text(
|
||
'暂无内容',
|
||
style: TextStyle(
|
||
fontSize: 14.spMin,
|
||
color: const Color(0xFF687FAD),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ==================== 考试卡片 ====================
|
||
|
||
class _ExamCard extends StatelessWidget {
|
||
const _ExamCard({required this.item, required this.onTap});
|
||
|
||
final ExamDto item;
|
||
final VoidCallback onTap;
|
||
|
||
static const String _cardBgBook =
|
||
'/StaticResource/2026-09-10/book_icon_final1789026573675.png';
|
||
|
||
// 书图标设计尺寸(目标渲染尺寸)
|
||
static const double _bookWidth = 124;
|
||
static const double _bookHeight = 134;
|
||
// 卡片尺寸
|
||
static const double _cardHeight = 92;
|
||
static const double _cardRadius = 24;
|
||
// ⚠ book_icon.png 自带透明 padding,下面这些偏移用于补偿图片留白
|
||
// 让书图案的左下角对齐卡片左下角。换图时需重新调整。
|
||
static const double _bookOffsetLeft = -14; // 左补留白(宽度加大了,偏移也加大)
|
||
static const double _bookOffsetBottom = 10; // 底补留白
|
||
static const double _bookExtraHeight = 0; // 放大补偿,让图案达到目标高度
|
||
|
||
String get _timeRange {
|
||
// 日期相同只展示 endTime 的时分,去除秒
|
||
final startParts = item.startTime.split(' ');
|
||
final startDate = startParts.isNotEmpty ? startParts[0] : '';
|
||
final startHhMm = startParts.length >= 2
|
||
? startParts[1].split(':').take(2).join(':')
|
||
: '';
|
||
final endParts = item.endTime.split(' ');
|
||
final endHhMm = endParts.length >= 2
|
||
? endParts[1].split(':').take(2).join(':')
|
||
: '';
|
||
return '$startDate $startHhMm - $endHhMm';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// 学科图标:按 baseSubjectName 动态取,无匹配兜底数学
|
||
final subjectIcon = subjectIconUrl(item.baseSubjectName);
|
||
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: SizedBox(
|
||
// ⚠ 书图/卡片尺寸统一用 .w:宽高同基准,锁定盒子宽高比,
|
||
// 避免手机(.w按375稿/.h按812稿)与pad(设备尺寸)缩放系数不同导致
|
||
// BoxFit.contain 缩放分支翻转、书图被压扁。
|
||
height: (_bookHeight + _bookExtraHeight).w, // 整体占位 = 书图容器高度(溢出卡片)
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
// 底层:白色圆角卡片(垂直居中)
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
top: (_bookHeight + _bookExtraHeight - _cardHeight).w / 2,
|
||
child: Container(
|
||
height: _cardHeight.w,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(_cardRadius.r),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x0A1D2B47),
|
||
blurRadius: 20,
|
||
offset: Offset(0, 6),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
// 中层:左侧书形装饰图
|
||
// 图片自带透明 padding,用 _bookOffsetLeft/Bottom/ExtraHeight 补偿
|
||
Positioned(
|
||
left: _bookOffsetLeft.w,
|
||
bottom: _bookOffsetBottom.w,
|
||
width: _bookWidth.w,
|
||
height: (_bookHeight + _bookExtraHeight).w,
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(_cardBgBook),
|
||
fit: BoxFit.contain,
|
||
alignment: Alignment.bottomLeft,
|
||
errorWidget: (_, __, ___) =>
|
||
SizedBox(width: _bookWidth.w, height: _bookHeight.w),
|
||
),
|
||
),
|
||
// 学科小图标(叠在书图上半部分,书图 110 宽 / 139 高容器)
|
||
Positioned(
|
||
left: 22.w,
|
||
top: 14.w,
|
||
child: CachedNetworkImage(
|
||
imageUrl: subjectIcon,
|
||
width: 56.w,
|
||
height: 56.w,
|
||
errorWidget: (_, _, _) => Icon(
|
||
Icons.menu_book,
|
||
size: 56.w,
|
||
color: const Color(0xFF3D7DF5),
|
||
),
|
||
),
|
||
),
|
||
// 上层:内容
|
||
Positioned.fill(
|
||
child: Row(
|
||
children: [
|
||
// 左侧书图区域的占位宽(内容从书图右边开始)
|
||
SizedBox(width: 90.w),
|
||
// 中间 + 右侧
|
||
Expanded(
|
||
child: Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||
child: Row(
|
||
children: [
|
||
Expanded(child: _buildMid()),
|
||
_buildRight(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// 学科标签(覆盖在书图底部胶囊上)
|
||
Positioned(
|
||
left: -10.w,
|
||
bottom: 26.w,
|
||
width: _bookWidth.w,
|
||
child: Center(
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: 14.w,
|
||
vertical: 6.w,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFE8F2FF),
|
||
borderRadius: BorderRadius.circular(12.r),
|
||
),
|
||
child: Text(
|
||
item.baseSubjectName,
|
||
style: TextStyle(
|
||
fontSize: 16.spMin,
|
||
fontWeight: FontWeight.w600,
|
||
color: const Color(0xFF3D7DF5),
|
||
height: 1.2,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildMid() {
|
||
return Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.taskName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 18.spMin,
|
||
fontWeight: FontWeight.w600,
|
||
color: const Color(0xFF1D2B47),
|
||
),
|
||
),
|
||
SizedBox(height: 8.h),
|
||
Text(
|
||
_timeRange,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 14.spMin,
|
||
fontWeight: FontWeight.w400,
|
||
color: const Color(0xFF687FAD),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildRight() {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||
textBaseline: TextBaseline.alphabetic,
|
||
children: [
|
||
// 得分
|
||
_gradientScoreText('${item.userScore}', 32.spMin, FontWeight.w700),
|
||
Text(
|
||
' / ${item.totalScore}',
|
||
style: TextStyle(
|
||
fontSize: 16.spMin,
|
||
fontWeight: FontWeight.w400,
|
||
color: const Color(0xFF687FAD),
|
||
),
|
||
),
|
||
SizedBox(width: 10.w),
|
||
Icon(
|
||
Icons.arrow_forward_ios,
|
||
size: 16.w,
|
||
color: const Color(0xFF3D7DF5),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 渐变得分文字:linear-gradient(270deg, #2DC9FF -2.95%, #2B73F9 73.86%)
|
||
Widget _gradientScoreText(String text, double fontSize, FontWeight weight) {
|
||
return ShaderMask(
|
||
shaderCallback: (rect) => ui.Gradient.linear(
|
||
rect.topRight,
|
||
rect.topLeft,
|
||
const [Color(0xFF2DC9FF), Color(0xFF2B73F9)],
|
||
const [-0.0295, 0.7386],
|
||
TileMode.clamp,
|
||
),
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(
|
||
fontSize: fontSize,
|
||
fontWeight: weight,
|
||
color: Colors.white,
|
||
height: 1,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|