diff --git a/lib/core/api/user_collect/api.dart b/lib/core/api/user_collect/api.dart new file mode 100644 index 0000000..21896a7 --- /dev/null +++ b/lib/core/api/user_collect/api.dart @@ -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> getSubjects(int type) async { + final data = await DioClient.instance.get( + '/api/v1/UserCollect/GetUserCollectSubject/$type', + ); + // 兼容裸数组 / {data: [...]} 响应壳 + final List list = data is List + ? data + : (data is Map + ? (data['data'] as List? ?? const []) + : const []); + return list + .whereType>() + .map(CollectTab.fromJson) + .toList(); + } + + /// 收藏/错题分页列表 + /// + /// [type] 1=我的收藏 2=错题本;[subjectId] 不传 = 全部学科; + /// [prevPageId] 游标:首页传 0,翻页传上一页最后一条 id。 + Future getCollectList({ + required int type, + int? subjectId, + required int pageIndex, + int pageSize = 10, + int prevPageId = 0, + }) async { + final query = { + '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); + } +} diff --git a/lib/core/api/user_collect/model.dart b/lib/core/api/user_collect/model.dart new file mode 100644 index 0000000..0ce843a --- /dev/null +++ b/lib/core/api/user_collect/model.dart @@ -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 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 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 data; // 当前页数据 + final int total; // 总条数(顶部"X 道错题/收藏题目") + final int pageIndex; + final int pageSize; + + /// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data + factory UserCollectPage.fromJson(Map json) { + final payload = (json['data'] is Map) + ? json['data'] as Map + : json; + return UserCollectPage( + data: (payload['data'] as List? ?? const []) + .whereType>() + .map(UserCollectItem.fromJson) + .toList(), + total: _toInt(payload['total']) ?? 0, + pageIndex: _toInt(payload['pageIndex']) ?? 1, + pageSize: _toInt(payload['pageSize']) ?? 10, + ); + } +} diff --git a/lib/core/components/subject_tag.dart b/lib/core/components/subject_tag.dart new file mode 100644 index 0000000..f612076 --- /dev/null +++ b/lib/core/components/subject_tag.dart @@ -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 _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% 近白) +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 034921e..837f8bf 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -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( diff --git a/lib/features/class/widgets/class_update_widgets.dart b/lib/features/class/widgets/class_update_widgets.dart index 696fae0..63b2b67 100644 --- a/lib/features/class/widgets/class_update_widgets.dart +++ b/lib/features/class/widgets/class_update_widgets.dart @@ -6,6 +6,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/core/components/image_preview_viewer.dart'; import 'package:new_learn/core/components/resolve_problem_box.dart'; +import 'package:new_learn/core/components/subject_tag.dart'; import 'package:new_learn/core/components/user_circle_avatar.dart'; import 'package:new_learn/core/utils/img_url.dart'; @@ -236,35 +237,6 @@ class CardHeader extends StatelessWidget { } } -/// 学科标签(对标 SubjectTag) -class SubjectTag extends StatelessWidget { - const SubjectTag({super.key, required this.subject}); - - final String subject; - - static const Map _map = { - '数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF), - '物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF), - '英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3), - '化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3), - }; - - @override - Widget build(BuildContext context) { - final t = subject.trim(); - if (t.isEmpty) return const SizedBox.shrink(); - final c = _map[t] ?? const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF); - return Container( - padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h), - decoration: BoxDecoration( - color: Color(c.bg), - borderRadius: BorderRadius.circular(6.r), - ), - child: Text(t, style: TextStyle(fontSize: 11, color: Color(c.fg))), - ); - } -} - /// 纯文字笔记(album-desc,最多 2 行) class AlbumDesc extends StatelessWidget { const AlbumDesc({super.key, required this.text}); @@ -613,9 +585,3 @@ class _CommentInputState extends State { ); } } - -class _SubjectColor { - const _SubjectColor(this.fg, this.bg); - final int fg; - final int bg; -} diff --git a/lib/features/my/exam/exam_page.dart b/lib/features/my/exam/exam_page.dart index 57309d3..7f6bf7e 100644 --- a/lib/features/my/exam/exam_page.dart +++ b/lib/features/my/exam/exam_page.dart @@ -111,8 +111,8 @@ class _ExamPageState extends State { child: CommonPagedList( key: ValueKey('exam-list-${_currentTab.id}'), load: _loadPage, - padding: EdgeInsets.fromLTRB(12.w, 16.h, 14.w, 20.h), - separatorBuilder: (_, __) => SizedBox(height: 14.h), + padding: EdgeInsets.fromLTRB(12.w, 24.h, 12.w, 0), + // separatorBuilder: (_, __) => SizedBox(height: 14.h), noMoreText: '- AI 爱学蝶变提醒你 没有更多了 -', emptyWidget: _buildEmpty(), buildItem: (ctx, item, index) { @@ -212,14 +212,14 @@ class _ExamCard extends StatelessWidget { // 书图标设计尺寸(目标渲染尺寸) static const double _bookWidth = 124; - static const double _bookHeight = 134; + 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 = 10; // 底补留白 + static const double _bookOffsetBottom = 6; // 底补留白 static const double _bookExtraHeight = 0; // 放大补偿,让图案达到目标高度 String get _timeRange { diff --git a/lib/features/my/my_page.dart b/lib/features/my/my_page.dart index f279ddc..ec0d05b 100644 --- a/lib/features/my/my_page.dart +++ b/lib/features/my/my_page.dart @@ -125,7 +125,7 @@ class MyPage extends ConsumerWidget { '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/error.png', title: '错题本', onTap: () { - // TODO: 跳转到错题本页 + context.push('/myWrong'); }, ), _MenuItem( @@ -133,7 +133,7 @@ class MyPage extends ConsumerWidget { '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/collect.png', title: '我的收藏', onTap: () { - // TODO: 跳转到我的收藏页 + context.push('/myCollect'); }, ), _MenuItem( diff --git a/lib/features/my/question_list/question_list_page.dart b/lib/features/my/question_list/question_list_page.dart new file mode 100644 index 0000000..5ea1f63 --- /dev/null +++ b/lib/features/my/question_list/question_list_page.dart @@ -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 createState() => _QuestionListPageState(); +} + +class _QuestionListPageState extends State { + static const int _pageSize = 10; + + /// 学科 Tab 列表("全部"为客户端插入,id=null) + List _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 _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> _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( + 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( + // 切学科时重建列表,状态自动回第一页 + 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 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), + ], + ); +}