feat(my): 新增错题本和我的收藏页面,重构学科标签组件
1. 为我的页面添加错题本和我的收藏跳转路由,指向共用的QuestionListPage 2. 抽离全局学科标签组件,移除class模块内的重复实现 3. 新增用户收藏/错题本接口层和数据模型 4. 完成QuestionListPage分页列表页实现,支持学科筛选和游标分页 5. 调整考试页面卡片布局和图标偏移参数
This commit is contained in:
@@ -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>);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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% 近白)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user