feat(my): 新增错题本和我的收藏页面,重构学科标签组件

1.  为我的页面添加错题本和我的收藏跳转路由,指向共用的QuestionListPage
2.  抽离全局学科标签组件,移除class模块内的重复实现
3.  新增用户收藏/错题本接口层和数据模型
4.  完成QuestionListPage分页列表页实现,支持学科筛选和游标分页
5.  调整考试页面卡片布局和图标偏移参数
This commit is contained in:
崔宝新
2026-09-10 17:14:11 +08:00
parent 96ab0221a5
commit 14b8e56bc3
8 changed files with 795 additions and 41 deletions
@@ -0,0 +1,505 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/user_collect/api.dart';
import 'package:new_learn/core/api/user_collect/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/components/rich_text_content.dart';
import 'package:new_learn/core/components/subject_tag.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/screen_adapt_template.dart';
/// ============================================================
/// 【QuestionListPage】我的收藏 / 错题本 列表页
/// —— 对标 Vue pages/myPackage/myCollect/myCollect.vue
/// 和 pages/myPackage/myWrong/myWrong.vue
///
/// 两个页面结构完全一致(同一套接口 UserCollect),仅 [type] 不同:
/// type=1 我的收藏(/myCollect) type=2 错题本(/myWrong)
/// 标题 / 总数文案 / 顶部背景图 / 空状态 / 装饰色带 由 [_config] 按 type 切换。
///
/// 页面结构(对齐 Vue,从上到下):
/// 1. 学科筛选 Tab("全部" + 接口返回学科,胶囊样式,点击切学科重载)
/// 2. 总数头:主题背景图 + 大号总数 + 装饰色带 + 文案(道错题/收藏题目)
/// 3. #F5F8FA 列表区:CommonPagedList 游标分页
/// 卡片 = SubjectTag + 时间 + packName + 题干富文本(限高渐隐)
///
/// 分页要点(游标分页,对齐 Vue useList):
/// pageKey 当 pageIndex;prevPageId 在闭包里维护——首页/刷新重置为 0,
/// 每页请求后更新为本页最后一条 id;切学科用 ValueKey 重建列表(天然回首页)。
///
/// 题干限高:Vue 用 DOM 测量后加渐隐遮罩;Flutter 用 OverflowBox 限高 70.h
/// 裁剪 + 底部固定渐隐遮罩(遮罩终点色与题干底色色值一致,内容不足时不可见)。
/// ============================================================
class QuestionListPage extends StatefulWidget {
const QuestionListPage({super.key, required this.type});
/// 1=我的收藏 2=错题本
final int type;
@override
State<QuestionListPage> createState() => _QuestionListPageState();
}
class _QuestionListPageState extends State<QuestionListPage> {
static const int _pageSize = 10;
/// 学科 Tab 列表("全部"为客户端插入,id=null)
List<CollectTab> _tabs = const [CollectTab(name: '全部')];
/// 当前选中的学科 id(null = 全部)
int? _subjectId;
/// 顶部总数(来自分页响应 total)
int _total = 0;
/// 游标:本页最后一条 id,首页/刷新/切学科时重置为 0
int _prevPageId = 0;
_PageConfig get _config =>
widget.type == 1 ? _PageConfig.collect : _PageConfig.wrong;
@override
void initState() {
super.initState();
talker.debug('打开页面:${widget.type == 1 ? '我的收藏' : '错题本'}');
_loadTabs();
}
/// 拉取学科筛选项;失败兜底只有"全部"(对齐 Vue catch 逻辑)
Future<void> _loadTabs() async {
try {
final tabs = await UserCollectApi.instance.getSubjects(widget.type);
if (!mounted) return;
setState(() {
_tabs = [const CollectTab(name: '全部'), ...tabs];
});
} catch (e) {
talker.error('获取学科Tab失败', e);
if (!mounted) return;
setState(() {
_tabs = const [CollectTab(name: '全部')];
});
}
}
/// 分页加载(CommonPagedList 回调):只写请求 + 返回数据
Future<PagingResult<UserCollectItem>> _load(int pageKey) async {
// 首页/下拉刷新:游标重置(对齐 Vue useList pageNo===1 时 prevPageId=0)
if (pageKey == 1) _prevPageId = 0;
final res = await UserCollectApi.instance.getCollectList(
type: widget.type,
subjectId: _subjectId,
pageIndex: pageKey,
pageSize: _pageSize,
prevPageId: _prevPageId,
);
// 更新游标为本页最后一条 id
_prevPageId = res.data.isNotEmpty ? res.data.last.id : 0;
if (mounted) {
setState(() => _total = res.total);
}
return PagingResult<UserCollectItem>(
data: res.data,
hasNext: res.total > pageKey * _pageSize,
);
}
/// 切学科:ValueKey 变化后 CommonPagedList 整体重建,自动回到第一页
void _onTabTap(CollectTab tab) {
if (_subjectId == tab.id) return;
setState(() {
_subjectId = tab.id;
_prevPageId = 0;
_total = 0;
});
}
/// 点击卡片 → 题目解析详情页
/// TODO: 详情页待接入——错题本跳 questionAnalysis、收藏跳 collectAnalysis,
/// 参数:questionId=${item.questionId}
/// recordQuestionId=${item.recordQuestionId}
/// listDetailId=${item.id}
void _goDetail(UserCollectItem item) {
talker.debug(
'题目详情待接入:type=${widget.type} questionId=${item.questionId} '
'recordQuestionId=${item.recordQuestionId} listDetailId=${item.id}',
);
}
@override
Widget build(BuildContext context) {
final cfg = _config;
final isPad =
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
surfaceTintColor: Colors.white,
elevation: 0,
title: Text(
cfg.title,
style: const TextStyle(color: Color(0xFF1D2B47)),
),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
),
),
body: Column(
children: [
// 1. 学科筛选 Tab(白底)
_buildTabBar(),
// 2. 总数头(背景图 + 大号数字)
_buildTotalHeader(cfg, isPad),
// 3. 列表区(#F5F8FA 灰底)
Expanded(
child: Container(
color: const Color(0xFFF5F8FA),
child: CommonPagedList<UserCollectItem>(
// 切学科时重建列表,状态自动回第一页
key: ValueKey('question_list_${widget.type}_$_subjectId'),
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
emptyWidget: _buildEmpty(cfg),
load: _load,
buildItem: (context, item, index) => _QuestionCard(
item: item,
onTap: () => _goDetail(item),
),
),
),
),
],
),
);
}
/// 学科筛选胶囊 Tab 栏(横向滚动)
Widget _buildTabBar() {
return Container(
color: Colors.white,
height: 56.h, // 上下 padding 12.h + 胶囊 32.h(对齐 Vue 24rpx+64rpx)
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 12.w),
itemCount: _tabs.length,
separatorBuilder: (_, __) => SizedBox(width: 12.w),
itemBuilder: (context, i) {
final tab = _tabs[i];
final active = _subjectId == tab.id;
return Center(
child: GestureDetector(
onTap: () => _onTabTap(tab),
child: Container(
height: 32.h,
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: active
? const Color(0xFF3D7DF5)
: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(999),
),
child: Text(
tab.name,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w400,
color: active
? Colors.white
: const Color(0xFF687FAD),
),
),
),
),
);
},
),
);
}
/// 总数头:背景图(widthFix)+ 大号总数 + 装饰色带 + 文案
Widget _buildTotalHeader(_PageConfig cfg, bool isPad) {
final numFontSize = isPad ? 52.5.sp : 35.sp;
final labelFontSize = isPad ? 21.sp : 14.sp;
return Stack(
children: [
// 背景图:宽度撑满,高度按图比例(对齐 Vue mode="widthFix")
CachedNetworkImage(
imageUrl: ImgUrl.build(isPad ? cfg.topBgPad : cfg.topBgPhone),
width: double.infinity,
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
// 数字 + 色带 + 文案(绝对定位叠在图上)
Positioned(
top: isPad ? 45.h : 16.5.h, // Vue padding-top: 90rpx / 33rpx
left: 30.w, // Vue padding-left: 60rpx
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 大号数字 + 底部装饰色带
Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
bottom: isPad ? -7.h : -4.h,
child: Container(
height: 9.h, // Vue 18rpx
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50.r),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: cfg.underlineColors,
stops: const [0, 0.36, 1],
),
),
),
),
Text(
'$_total',
style: TextStyle(
fontSize: numFontSize,
fontWeight: isPad ? FontWeight.w500 : FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
],
),
SizedBox(width: 10.w), // Vue margin-l-20
// 文案(Vue margin-t-30:相对数字略微下沉)
Padding(
padding: EdgeInsets.only(top: 15.h),
child: Text(
cfg.totalLabel,
style: TextStyle(
fontSize: labelFontSize,
fontWeight: FontWeight.w400,
color: const Color(0xFF687FAD),
),
),
),
],
),
),
],
);
}
/// 空状态:空态图 + 文案(对齐 Vue DataEmpty)
Widget _buildEmpty(_PageConfig cfg) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(cfg.emptyImg),
width: 160.w,
fit: BoxFit.contain,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
SizedBox(height: 12.h),
Text(
cfg.emptyText,
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
),
],
),
);
}
}
/// 单条收藏/错题卡片:SubjectTag + 时间 + packName + 题干富文本
class _QuestionCard extends StatelessWidget {
const _QuestionCard({required this.item, required this.onTap});
final UserCollectItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: 14.h), // Vue listItem margin-bottom: 28rpx
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16.r),
child: Padding(
// Vue padding: t-32 l/r-28 b-28
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:学科标签 + 时间
Row(
children: [
SubjectTag(subject: item.subjectName),
const Spacer(),
Text(
item.createTime,
style: TextStyle(
fontSize: 12.sp, // Vue font-24
color: const Color(0xFFB4C2E0),
),
),
],
),
SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx
// 试卷/礼包名
Text(
item.packName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp, // Vue font-32
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
SizedBox(height: 16.h), // Vue question-stem-wrap margin-top: 32rpx
// 题干富文本(限高渐隐)
_StemBox(html: item.questionStem),
],
),
),
),
),
);
}
}
/// 题干容器:#FAFBFD 底 + #E3ECFF 边框,内容限高 70.h(Vue 140rpx),
/// 超出部分底部渐隐遮罩
class _StemBox extends StatelessWidget {
const _StemBox({required this.html});
final String html;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(12.w), // Vue padding: 24rpx
decoration: BoxDecoration(
color: const Color(0xFFFAFBFD),
borderRadius: BorderRadius.circular(8.r), // Vue 16rpx
border: Border.all(color: const Color(0xFFE3ECFF)),
),
child: Stack(
children: [
// 限高裁剪:OverflowBox 让富文本按自然高度布局,超出 70.h 的部分裁掉
ClipRect(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 70.h),
child: OverflowBox(
alignment: Alignment.topCenter,
minHeight: 0,
maxHeight: double.infinity,
child: RichTextContent(
html: html,
textStyle: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: const Color(0xFF495A7A),
height: 1.5,
),
),
),
),
),
// 底部渐隐遮罩(Vue mask-layer 60rpx):
// 终点色与底色 #FAFBFD 一致,内容不足时遮罩盖在底色上视觉不可见
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 30.h,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00FAFBFD), Color(0xFFFAFBFD)],
),
borderRadius: BorderRadius.circular(8.r),
),
),
),
),
],
),
);
}
}
/// 页面差异化配置(按 type 切换文案/图片/配色)
class _PageConfig {
const _PageConfig({
required this.title,
required this.totalLabel,
required this.topBgPhone,
required this.topBgPad,
required this.emptyImg,
required this.emptyText,
required this.underlineColors,
});
final String title;
final String totalLabel;
final String topBgPhone;
final String topBgPad;
final String emptyImg;
final String emptyText;
final List<Color> underlineColors;
/// 我的收藏(type=1):橙色系装饰
static final _PageConfig collect = _PageConfig(
title: '我的收藏',
totalLabel: '收藏题目',
topBgPhone: '/StaticResource/2026-04-08/my-collect-top-bg.png',
topBgPad: '/StaticResource/2026-06-02/my-collect-top-bg-pad1780378706432.png',
emptyImg: '/StaticResource/2026-04-11/contentEmptys.png',
emptyText: '暂无收藏',
// Vue: linear-gradient(90deg, #F59C3D00 0%, #FFCA1D33 36%, #FDAD8A80 100%)
underlineColors: const [
Color(0x00F59C3D),
Color(0x33FFCA1D),
Color(0x80FDAD8A),
],
);
/// 错题本(type=2):蓝色系装饰
static final _PageConfig wrong = _PageConfig(
title: '错题本',
totalLabel: '道错题',
topBgPhone: '/StaticResource/2026-04-08/my-wrong-top-bg.png',
topBgPad: '/StaticResource/2026-06-02/my-wrong-top-bg-pad1780372865693.png',
emptyImg: '/StaticResource/2026-04-11/noDatas.png',
emptyText: '暂无错题',
// Vue: linear-gradient(90deg, #3D7DF500 0%, #1D9CFF33 36%, #8AECFD80 100%)
underlineColors: const [
Color(0x003D7DF5),
Color(0x331D9CFF),
Color(0x808AECFD),
],
);
}