feat(my): add 我的考试 page and related modules

1. 新增我的考试页面,包含学科筛选Tab和分页考试列表
2. 新增学科图标工具类,替换我的报告页重复的学科图标逻辑
3. 在我的页面菜单中添加我的考试入口
4. 新增exam模块的API接口和数据模型
5. 优化我的报告页面卡片样式,统一与考试卡片的布局
This commit is contained in:
崔宝新
2026-09-10 15:59:11 +08:00
parent 0fefc786bb
commit 96ab0221a5
7 changed files with 790 additions and 114 deletions
+156 -112
View File
@@ -8,6 +8,7 @@ import 'package:new_learn/core/api/my_report/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';
/// ============================================================
/// 【MyReportPage】我的报告列表 —— 对标 Vue pages/myPackage/myReport/myReport.vue
@@ -25,19 +26,6 @@ import 'package:new_learn/core/utils/img_url.dart';
/// 四、先只做 phone 布局,Pad 后续再说
/// ============================================================
/// 学科 icon 映射表(对标 Vue utils/constant.js SUBJECT_ICON_MAP)
const Map<String, String> _subjectIconMap = {
'语文': '/StaticResource/2026-06-11/shuxue1781163770612.png',
'化学': '/StaticResource/2026-06-11/huaxue1781163828443.png',
'英语': '/StaticResource/2026-06-11/yingyu1781163725441.png',
'物理': '/StaticResource/2026-06-11/wuli1781148438346.png',
'数学': '/StaticResource/2026-06-11/shuxue1781163770612.png',
'生物': '/StaticResource/2026-06-11/shengwu1781163791768.png',
'历史': '/StaticResource/2026-06-11/lishi1781163811822.png',
'政治': '/StaticResource/2026-06-11/zhengzhi1781163750422.png',
'地理': '/StaticResource/2026-06-11/dili1781163844311.png',
};
/// 取学科名(报告名前 2 个字,对标 Vue getSubjectName)
String _extractSubjectName(UserReportDto item) {
final name = item.reportName.trim();
@@ -45,12 +33,6 @@ String _extractSubjectName(UserReportDto item) {
return name.substring(0, 2);
}
/// 取学科 icon URL(无匹配则用数学兜底,对标 Vue getSubjectIcon)
String _getSubjectIconUrl(String subjectName) {
final path = _subjectIconMap[subjectName] ?? _subjectIconMap['数学']!;
return ImgUrl.build(path);
}
/// 格式化创建时间(对标 Vue getReportTime)
String _formatCreateTime(String raw) {
if (raw.isEmpty) return '';
@@ -120,8 +102,8 @@ class _MyReportPageState extends ConsumerState<MyReportPage> {
color: const Color(0xFFF5F8FA),
child: CommonPagedList<UserReportDto>(
load: _loadPage,
padding: EdgeInsets.fromLTRB(12.w, 16.h, 12.w, 0),
separatorBuilder: (_, _) => SizedBox(height: 14.h),
padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 0),
// separatorBuilder: (_, _) => SizedBox(height: 14.h),
buildItem: (context, item, index) {
return _ReportCard(item: item, onTap: () => _onTapCard(item));
},
@@ -140,11 +122,11 @@ class _MyReportPageState extends ConsumerState<MyReportPage> {
/// ============================================================
/// 【_ReportCard】报告卡片
///
/// 布局(Row 从左到右):
/// [icon + 学科标签] → [报告名 + 时间] → [箭头]
/// 左侧装饰与我的考试(exam)卡片一致:
/// 白色圆角卡片 + 左侧溢出书图(book_icon)+ 学科图标叠在书图上 +
/// 底部学科名胶囊。书图/卡片尺寸统一用 .w 锁定宽高比,适配 pad。
///
/// 背景图叠底,InkWell 提供点击水波纹。
/// 图标/标签自然靠左,不做偏移 hack。
/// 内容区:中间 [报告名 + 时间],右侧 [箭头]。
/// ============================================================
class _ReportCard extends StatelessWidget {
const _ReportCard({required this.item, required this.onTap});
@@ -152,115 +134,177 @@ class _ReportCard extends StatelessWidget {
final UserReportDto item;
final VoidCallback onTap;
static const String _cardBgBook =
'/StaticResource/2026-09-10/book_icon_final1789026573675.png';
// 书图标 / 卡片尺寸(与 exam 卡片一致,统一 .w 锁比例)
static const double _bookWidth = 124;
static const double _bookHeight =
124; // 卡片间距 = _bookHeight - _cardHeight(书图向上探出的高度)
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;
@override
Widget build(BuildContext context) {
final subjectName = _extractSubjectName(item);
final subjectIconUrl = _getSubjectIconUrl(subjectName);
final iconUrl = subjectIconUrl(subjectName);
final timeText = _formatCreateTime(item.createTime);
const bgPath =
'/StaticResource/2026-06-10/examListItem_bg1781074465963.png';
return InkWell(
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
borderRadius: BorderRadius.circular(12.r),
child: Container(
height: 109.h,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12.r)),
child: SizedBox(
height: (_bookHeight + _bookExtraHeight).w,
child: Stack(
clipBehavior: Clip.none,
children: [
// 底层:卡片背景图
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: CachedNetworkImage(
imageUrl: ImgUrl.build(bgPath),
fit: BoxFit.fill,
placeholder: (_, _) => Container(color: Colors.white),
errorWidget: (_, _, _) => Container(color: Colors.white),
// 底层:白色圆角卡片(底部对齐,书图只向上溢出)
Positioned(
left: 0,
right: 0,
bottom: 0,
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),
),
],
),
),
),
// 上层内容:Row 左对齐,padding 控制整体边距
Padding(
padding: EdgeInsets.symmetric(horizontal: 22.w),
// 中层:左侧书形装饰图
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),
),
),
// 学科小图标(叠在书图上半部分)
Positioned(
left: 22.w,
top: 14.w,
child: CachedNetworkImage(
imageUrl: iconUrl,
width: 56.w,
height: 56.w,
errorWidget: (_, _, _) => Icon(
Icons.menu_book,
size: 56.w,
color: const Color(0xFF3D7DF5),
),
),
),
// 上层:内容(与卡片同高,底部对齐)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: _cardHeight.w,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// ── 左侧:学科 icon + 标签(垂直排列,自然靠左) ──
Column(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: subjectIconUrl,
width: 56.w,
height: 56.w,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
SizedBox(height: 4.h),
Container(
padding: EdgeInsets.symmetric(horizontal: 8.w),
height: 25.h,
decoration: BoxDecoration(
color: const Color(0xFFEAF5FE),
borderRadius: BorderRadius.circular(7.r),
),
alignment: Alignment.center,
child: Text(
subjectName,
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF3D7DF5),
),
),
),
],
),
SizedBox(width: 40.w),
// ── 中间:报告名 + 时间(Expanded 占剩余) ──
// 左侧书图区域占位
SizedBox(width: 90.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: 10.h),
Text(
item.reportName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 16.sp,
color: const Color(0xFF1D2B47),
),
),
SizedBox(height: 20.h),
Text(
timeText,
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF687FAD),
),
),
],
),
),
// ── 右侧:箭头 ──
Padding(
padding: EdgeInsets.only(top: 10.h),
child: Icon(
Icons.chevron_right,
color: const Color(0xFF4B6B99),
size: 22.sp,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Row(
children: [
Expanded(child: _buildMid(timeText)),
_buildRight(),
],
),
),
),
],
),
),
// 学科标签(覆盖在书图底部胶囊上,卡片贴底)
Positioned(
left: -10.w,
bottom: 10.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(
subjectName,
style: TextStyle(
fontSize: 16.spMin,
fontWeight: FontWeight.w600,
color: const Color(0xFF3D7DF5),
height: 1.2,
),
),
),
),
),
],
),
),
);
}
// 中间:报告名 + 时间
Widget _buildMid(String timeText) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.reportName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.spMin,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
SizedBox(height: 8.h),
Text(
timeText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.spMin,
fontWeight: FontWeight.w400,
color: const Color(0xFF687FAD),
),
),
],
);
}
// 右侧:箭头
Widget _buildRight() {
return Icon(
Icons.chevron_right,
color: const Color(0xFF4B6B99),
size: 22.sp,
);
}
}