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
+66
View File
@@ -0,0 +1,66 @@
import 'package:new_learn/core/api/user_collect/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【user_collect 模块接口】我的收藏 + 错题本 —— 对标 Vue3 端 api/userCollect/index.ts
///
/// getSubjects —— 学科筛选项(顶部 Tab)
/// GET /api/v1/UserCollect/GetUserCollectSubject/{type}
///
/// getCollectList —— 收藏/错题分页列表(游标分页)
/// GET /api/v1/UserCollect/GetUserCollect
/// 参数:type(1收藏/2错题) / subjectId(可选) / pageIndex / pageSize / prevPageId
///
/// 游标分页说明(对齐 Vue useList):
/// 第一页 prevPageId=0,之后每页传上一页最后一条的 id;
/// 切学科/下拉刷新时 prevPageId 重置为 0。
/// ============================================================
class UserCollectApi {
UserCollectApi._();
static final UserCollectApi instance = UserCollectApi._();
/// 学科筛选项([type] 1=我的收藏 2=错题本)
Future<List<CollectTab>> getSubjects(int type) async {
final data = await DioClient.instance.get(
'/api/v1/UserCollect/GetUserCollectSubject/$type',
);
// 兼容裸数组 / {data: [...]} 响应壳
final List<dynamic> list = data is List
? data
: (data is Map<String, dynamic>
? (data['data'] as List<dynamic>? ?? const [])
: const []);
return list
.whereType<Map<String, dynamic>>()
.map(CollectTab.fromJson)
.toList();
}
/// 收藏/错题分页列表
///
/// [type] 1=我的收藏 2=错题本;[subjectId] 不传 = 全部学科;
/// [prevPageId] 游标:首页传 0,翻页传上一页最后一条 id。
Future<UserCollectPage> getCollectList({
required int type,
int? subjectId,
required int pageIndex,
int pageSize = 10,
int prevPageId = 0,
}) async {
final query = <String, dynamic>{
'type': type,
'pageIndex': pageIndex,
'pageSize': pageSize,
'prevPageId': prevPageId,
};
// 对齐 Vue useList:subjectId 为 null 时不带该参数
if (subjectId != null) query['subjectId'] = subjectId;
final data = await DioClient.instance.get(
'/api/v1/UserCollect/GetUserCollect',
query: query,
);
return UserCollectPage.fromJson(data as Map<String, dynamic>);
}
}
+124
View File
@@ -0,0 +1,124 @@
/// ============================================================
/// 【user_collect 模块模型】我的收藏 + 错题本 —— 对标 Vue3 端 api/userCollect/type.ts
///
/// CollectTab 学科筛选 Tab(GetUserCollectSubject 返回项)
/// UserCollectItem 列表项(收藏/错题通用,靠 type 区分)
/// UserCollectPage 分页响应(游标分页:pageIndex + prevPageId)
///
/// 说明:错题本页(myWrong)与收藏页(myCollect)共用同一套接口,
/// type=1 我的收藏 / type=2 错题本。
/// 序列化:手动 fromJson(兼容 {code, message, data: {...}} 响应壳),
/// 不依赖 build_runner。
/// ============================================================
library;
/// 兼容后端 id 字段:雪花 id 后端可能以 String 返回(防 JS 精度丢失),
/// 也可能直接是 num;统一转 int,无法解析返回 null。
int? _toInt(dynamic v) {
if (v == null) return null;
if (v is num) return v.toInt();
if (v is String && v.isNotEmpty) return int.tryParse(v);
return null;
}
/// 学科筛选 Tab
class CollectTab {
const CollectTab({
this.id,
required this.name,
this.remark = '',
this.status = 0,
});
/// 学科 id;null = 客户端插入的"全部"
final int? id;
final String name;
final String remark;
final int status;
factory CollectTab.fromJson(Map<String, dynamic> json) {
return CollectTab(
id: _toInt(json['id']),
name: json['name'] as String? ?? '',
remark: json['remark'] as String? ?? '',
status: _toInt(json['status']) ?? 0,
);
}
}
/// 收藏/错题列表项
class UserCollectItem {
const UserCollectItem({
this.id = 0,
this.questionId = 0,
this.recordQuestionId = 0,
this.questionStem = '',
this.subjectName = '',
this.packName = '',
this.createTime = '',
});
/// 列表明细 id(游标分页 prevPageId 取本页最后一条的 id)
final int id;
/// 题目 id(详情页参数 questionId)
final int questionId;
/// 答题记录 id(详情页参数 recordQuestionId)
final int recordQuestionId;
/// 题干 HTML(RichTextContent 渲染)
final String questionStem;
/// 学科名(SubjectTag 配色用)
final String subjectName;
/// 来源试卷/礼包名
final String packName;
/// 收藏/错题时间字符串
final String createTime;
factory UserCollectItem.fromJson(Map<String, dynamic> json) {
return UserCollectItem(
id: _toInt(json['id']) ?? 0,
questionId: _toInt(json['questionId']) ?? 0,
recordQuestionId: _toInt(json['recordQuestionId']) ?? 0,
questionStem: json['questionStem'] as String? ?? '',
subjectName: json['subjectName'] as String? ?? '',
packName: json['packName'] as String? ?? '',
createTime: json['createTime'] as String? ?? '',
);
}
}
/// 收藏/错题分页响应
class UserCollectPage {
const UserCollectPage({
this.data = const [],
this.total = 0,
this.pageIndex = 1,
this.pageSize = 10,
});
final List<UserCollectItem> data; // 当前页数据
final int total; // 总条数(顶部"X 道错题/收藏题目")
final int pageIndex;
final int pageSize;
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
factory UserCollectPage.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] is Map<String, dynamic>)
? json['data'] as Map<String, dynamic>
: json;
return UserCollectPage(
data: (payload['data'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(UserCollectItem.fromJson)
.toList(),
total: _toInt(payload['total']) ?? 0,
pageIndex: _toInt(payload['pageIndex']) ?? 1,
pageSize: _toInt(payload['pageSize']) ?? 10,
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【SubjectTag】学科标签(对标 Vue components/SubjectTag/SubjectTag.vue)
///
/// 四学科配色(与 Vue subjectMap 一致),未知学科兜底数学蓝:
/// 数学 #3D7DF5 / #E5F0FF 物理 #A45CFF / #F5ECFF
/// 英语 #E5A301 / #FFF6E3 化学 #2DB985 / #E5FAF3
///
/// 描边:Vue 用 ::before + mask-composite 画 2rpx 的学科色渐变描边
/// (两端 60% 学科色、中间 60% 近白色);Flutter 用"外层渐变容器 +
/// 内层底色容器留 1px 边"的嵌套方案等价实现。
///
/// 原 class 模块私有组件,因错题本/我的收藏列表也需要,按项目规范
/// 提升为 core/components 全局公共组件。
/// ============================================================
class SubjectTag extends StatelessWidget {
const SubjectTag({super.key, required this.subject});
final String subject;
static const Map<String, _SubjectColor> _map = {
// 数学:描边 rgba(90,158,255,.6) → rgba(242,248,255,.6)
'数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF, 0x995A9EFF, 0x99F2F8FF),
// 物理:描边 rgba(182,127,255,.6) → rgba(252,246,255,.6)
'物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF, 0x99B67FFF, 0x99FCF6FF),
// 英语:描边 rgba(247,194,82,.6) → rgba(255,251,243,.6)
'英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3, 0x99F7C252, 0x99FFFBF3),
// 化学:描边 rgba(91,209,165,.6) → rgba(240,255,248,.6)
'化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3, 0x995BD1A5, 0x99F0FFF8),
};
@override
Widget build(BuildContext context) {
final t = subject.trim();
if (t.isEmpty) return const SizedBox.shrink();
final c =
_map[t] ??
const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF, 0x995A9EFF, 0x99F2F8FF);
// Vue border-radius: 12rpx
final outerRadius = BorderRadius.circular(6.r);
// Vue ::before padding: 2rpx(描边厚度)
final borderWidth = 1.w;
return Container(
// 外层:学科色横向渐变,露出的 1px 边即"渐变描边"
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(c.borderA), Color(c.borderB), Color(c.borderA)],
),
borderRadius: outerRadius,
),
child: Container(
margin: EdgeInsets.all(borderWidth),
// Vue padding: 4rpx 14rpx,扣除 2rpx 描边后内层 2rpx 12rpx
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.h),
decoration: BoxDecoration(
color: Color(c.bg),
borderRadius: BorderRadius.circular(6.r - borderWidth),
),
// Vue white-space: nowrap:学科名不换行
child: Text(
t,
softWrap: false,
style: TextStyle(fontSize: 11, color: Color(c.fg)),
),
),
);
}
}
class _SubjectColor {
const _SubjectColor(this.fg, this.bg, this.borderA, this.borderB);
final int fg; // 文字色
final int bg; // 底色
final int borderA; // 渐变描边两端色(60% 学科色)
final int borderB; // 渐变描边中间色(60% 近白)
}
+10
View File
@@ -15,6 +15,7 @@ import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/features/my/my_page.dart';
import 'package:new_learn/features/conquerPackage/captureList/index.dart';
import 'package:new_learn/features/my/my_point/my_point_page.dart';
import 'package:new_learn/features/my/question_list/question_list_page.dart';
import 'package:new_learn/features/my/my_report/my_report_page.dart';
import 'package:new_learn/features/my/my_report/report_detail_page.dart';
import 'package:new_learn/features/my/exam/exam_page.dart';
@@ -138,6 +139,15 @@ final GoRouter appRouter = GoRouter(
MyUpdateDetailPage(initialPost: state.extra as UserPostDto),
),
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
// 错题本(type=2)/ 我的收藏(type=1)共用 QuestionListPage
GoRoute(
path: '/myWrong',
builder: (_, _) => const QuestionListPage(type: 2),
),
GoRoute(
path: '/myCollect',
builder: (_, _) => const QuestionListPage(type: 1),
),
GoRoute(path: '/myReport', builder: (_, _) => const MyReportPage()),
GoRoute(path: '/myExam', builder: (_, _) => const ExamPage()),
GoRoute(