Files
崔宝新 14b8e56bc3 feat(my): 新增错题本和我的收藏页面,重构学科标签组件
1.  为我的页面添加错题本和我的收藏跳转路由,指向共用的QuestionListPage
2.  抽离全局学科标签组件,移除class模块内的重复实现
3.  新增用户收藏/错题本接口层和数据模型
4.  完成QuestionListPage分页列表页实现,支持学科筛选和游标分页
5.  调整考试页面卡片布局和图标偏移参数
2026-09-10 17:14:11 +08:00

436 lines
14 KiB
Dart
Raw Permalink 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 '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, 24.h, 12.w, 0),
// 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 = 124;
// 卡片尺寸
static const double _cardHeight = 92;
static const double _cardRadius = 24;
// ⚠ book_icon.png 自带透明 padding,下面这些偏移用于补偿图片留白
// 让书图案的左下角对齐卡片左下角。换图时需重新调整。
static const double _bookOffsetLeft = -14; // 左补留白(宽度加大了,偏移也加大)
static const double _bookOffsetBottom = 6; // 底补留白
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,
),
),
);
}
}