diff --git a/lib/core/api/exam/api.dart b/lib/core/api/exam/api.dart new file mode 100644 index 0000000..586da56 --- /dev/null +++ b/lib/core/api/exam/api.dart @@ -0,0 +1,51 @@ +import 'package:new_learn/core/api/exam/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【exam 模块接口】我的考试 —— 对标 Vue3 端 api/exam/index.ts +/// +/// getSubjects —— 获取考试学科筛选 Tab 列表 +/// search —— 我的考试分页列表 +/// +/// 注意:考试报告详情(getReport)等考试做题相关接口, +/// 后续扩展时再按模块拆分补进来。 +/// ============================================================ +class ExamApi { + ExamApi._(); + + static final ExamApi instance = ExamApi._(); + + /// 考试学科筛选 Tab 列表 + /// GET /api/v1/Evaluation/GetSubjects + Future> getSubjects() async { + final data = await DioClient.instance + .get('/api/v1/Evaluation/GetSubjects'); + final list = data is List + ? data.whereType>().toList() + : (data is Map + ? (data['data'] as List? ?? const []) + : const []) + .whereType>() + .toList(); + return list.map(ExamSubjectTab.fromJson).toList(); + } + + /// 我的考试分页列表 + /// GET /api/v1/Evaluation/Search + /// 参数:baseSubjectId(可 null=全部)/ pageIndex / pageSize + Future search({ + int? baseSubjectId, + required int pageIndex, + int pageSize = 10, + }) async { + final data = await DioClient.instance.get( + '/api/v1/Evaluation/Search', + query: { + if (baseSubjectId != null) 'baseSubjectId': baseSubjectId, + 'pageIndex': pageIndex, + 'pageSize': pageSize, + }, + ); + return ExamResponse.fromJson(data as Map); + } +} diff --git a/lib/core/api/exam/model.dart b/lib/core/api/exam/model.dart new file mode 100644 index 0000000..fde8f23 --- /dev/null +++ b/lib/core/api/exam/model.dart @@ -0,0 +1,109 @@ +/// ============================================================ +/// 【exam 模块模型】我的考试 —— 对标 Vue3 端 api/exam/type.ts +/// +/// ExamDto —— 单条考试记录 +/// ExamSubjectTab —— 考试科目筛选 Tab +/// ExamResponse —— 考试列表分页响应 +/// +/// 序列化:手动 fromJson,与项目其他模块风格一致,不依赖 build_runner。 +/// +/// ⚠ 注意:后端返回的 id 字段类型不统一(数字 or 字符串), +/// 所有数值字段统一用 _asInt() 做兼容解析,避免类型转换异常。 +/// ============================================================ +library; + +int? _optInt(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is double) return v.toInt(); + final s = v.toString(); + final n = int.tryParse(s); + if (n != null) return n; + return double.tryParse(s)?.toInt(); +} + +int _asInt(dynamic v, [int fallback = 0]) => _optInt(v) ?? fallback; + +/// 单条考试记录 +class ExamDto { + const ExamDto({ + this.id = 0, + this.taskId = 0, + this.taskName = '', + this.baseSubjectId = 0, + this.baseSubjectName = '', + this.startTime = '', + this.endTime = '', + this.totalScore = 0, + this.userScore = 0, + }); + + final int id; + final int taskId; + final String taskName; + final int baseSubjectId; + final String baseSubjectName; + final String startTime; + final String endTime; + final int totalScore; + final int userScore; + + factory ExamDto.fromJson(Map json) { + return ExamDto( + id: _asInt(json['id']), + taskId: _asInt(json['taskId']), + taskName: (json['taskName'] as String?) ?? '', + baseSubjectId: _asInt(json['baseSubjectId']), + baseSubjectName: (json['baseSubjectName'] as String?) ?? '', + startTime: (json['startTime'] as String?) ?? '', + endTime: (json['endTime'] as String?) ?? '', + totalScore: _asInt(json['totalScore']), + userScore: _asInt(json['userScore']), + ); + } +} + +/// 考试科目筛选 Tab +class ExamSubjectTab { + const ExamSubjectTab({this.id, this.name = ''}); + + /// 为 null 表示"全部" + final int? id; + final String name; + + factory ExamSubjectTab.fromJson(Map json) { + return ExamSubjectTab( + id: _optInt(json['id']), + name: (json['name'] as String?) ?? '', + ); + } +} + +/// 考试列表分页响应 +class ExamResponse { + const ExamResponse({ + this.data = const [], + this.total = 0, + this.pageIndex = 1, + this.pageSize = 10, + }); + + final List data; + final int total; + final int pageIndex; + final int pageSize; + + factory ExamResponse.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + final list = (payload['data'] as List? ?? const []) + .whereType>() + .map(ExamDto.fromJson) + .toList(); + return ExamResponse( + data: list, + total: (payload['total'] as num?)?.toInt() ?? list.length, + pageIndex: (payload['pageIndex'] as num?)?.toInt() ?? 1, + pageSize: (payload['pageSize'] as num?)?.toInt() ?? 10, + ); + } +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 3241877..034921e 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -17,6 +17,7 @@ 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/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'; import 'package:new_learn/features/my/settings/settings_page.dart'; import 'package:new_learn/features/my/settings/about_app_page.dart'; import 'package:new_learn/features/recite/recite_page.dart'; @@ -138,6 +139,7 @@ final GoRouter appRouter = GoRouter( ), GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), GoRoute(path: '/myReport', builder: (_, _) => const MyReportPage()), + GoRoute(path: '/myExam', builder: (_, _) => const ExamPage()), GoRoute( path: '/report-detail', builder: (_, state) { @@ -185,8 +187,7 @@ final GoRouter appRouter = GoRouter( // ---------- 攻克包 - 截图名单 ---------- GoRoute( path: '/conquerPackage/captureList', - builder: (_, state) => - CaptureListPage(courseId: state.extra as String), + builder: (_, state) => CaptureListPage(courseId: state.extra as String), ), GoRoute(path: '/register', builder: (_, _) => const RegisterPage()), diff --git a/lib/core/utils/subject_icons.dart b/lib/core/utils/subject_icons.dart new file mode 100644 index 0000000..c2f0ee2 --- /dev/null +++ b/lib/core/utils/subject_icons.dart @@ -0,0 +1,28 @@ +import 'package:new_learn/core/utils/img_url.dart'; + +/// ============================================================ +/// 【学科图标常量】 +/// +/// 对标 Vue utils/constant.js 的 SUBJECT_ICON_MAP。 +/// 我的报告(my_report)、我的考试(exam)等模块共用, +/// 通过学科名(如「数学」「英语」)动态取对应图标。 +/// ============================================================ + +/// 学科名 → 图标资源路径 +const Map kSubjectIconMap = { + '语文': '/StaticResource/2026-06-11/shuxue1781163770612.png', + '化学': '/StaticResource/2026-06-11/huaxue1781163828443.png', + '英语': '/StaticResource/2026-06-11/yingyu1781163725441.png', + '物理': '/StaticResource/2026-06-11/wuli1781148438346.png', + '数学': '/StaticResource/2026-06-11/shuxue1781163770612.png', + '生物': '/StaticResource/2026-06-11/shengwu1781163791768.png', + '历史': '/StaticResource/2026-06-11/lishi1781163811822.png', + '政治': '/StaticResource/2026-06-11/zhengzhi1781163750422.png', + '地理': '/StaticResource/2026-06-11/dili1781163844311.png', +}; + +/// 取学科图标完整 URL(无匹配时用数学兜底,对标 Vue getSubjectIcon) +String subjectIconUrl(String subjectName) { + final path = kSubjectIconMap[subjectName] ?? kSubjectIconMap['数学']!; + return ImgUrl.build(path); +} diff --git a/lib/features/my/exam/exam_page.dart b/lib/features/my/exam/exam_page.dart new file mode 100644 index 0000000..57309d3 --- /dev/null +++ b/lib/features/my/exam/exam_page.dart @@ -0,0 +1,435 @@ +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 createState() => _ExamPageState(); +} + +class _ExamPageState extends State { + final List _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 _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> _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( + key: ValueKey('exam-list-${_currentTab.id}'), + load: _loadPage, + padding: EdgeInsets.fromLTRB(12.w, 16.h, 14.w, 20.h), + 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 = 134; + // 卡片尺寸 + 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 _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, + ), + ), + ); + } +} diff --git a/lib/features/my/my_page.dart b/lib/features/my/my_page.dart index 193bdcc..f279ddc 100644 --- a/lib/features/my/my_page.dart +++ b/lib/features/my/my_page.dart @@ -96,6 +96,14 @@ class MyPage extends ConsumerWidget { // 第一组:学习功能菜单 _MenuCard( items: [ + _MenuItem( + imageUrl: + '${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png', + title: '我的考试', + onTap: () { + context.push('/myExam'); + }, + ), _MenuItem( imageUrl: '${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png', diff --git a/lib/features/my/my_report/my_report_page.dart b/lib/features/my/my_report/my_report_page.dart index 3366a2a..b5c3d03 100644 --- a/lib/features/my/my_report/my_report_page.dart +++ b/lib/features/my/my_report/my_report_page.dart @@ -8,6 +8,7 @@ import 'package:new_learn/core/api/my_report/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'; /// ============================================================ /// 【MyReportPage】我的报告列表 —— 对标 Vue pages/myPackage/myReport/myReport.vue @@ -25,19 +26,6 @@ import 'package:new_learn/core/utils/img_url.dart'; /// 四、先只做 phone 布局,Pad 后续再说 /// ============================================================ -/// 学科 icon 映射表(对标 Vue utils/constant.js SUBJECT_ICON_MAP) -const Map _subjectIconMap = { - '语文': '/StaticResource/2026-06-11/shuxue1781163770612.png', - '化学': '/StaticResource/2026-06-11/huaxue1781163828443.png', - '英语': '/StaticResource/2026-06-11/yingyu1781163725441.png', - '物理': '/StaticResource/2026-06-11/wuli1781148438346.png', - '数学': '/StaticResource/2026-06-11/shuxue1781163770612.png', - '生物': '/StaticResource/2026-06-11/shengwu1781163791768.png', - '历史': '/StaticResource/2026-06-11/lishi1781163811822.png', - '政治': '/StaticResource/2026-06-11/zhengzhi1781163750422.png', - '地理': '/StaticResource/2026-06-11/dili1781163844311.png', -}; - /// 取学科名(报告名前 2 个字,对标 Vue getSubjectName) String _extractSubjectName(UserReportDto item) { final name = item.reportName.trim(); @@ -45,12 +33,6 @@ String _extractSubjectName(UserReportDto item) { return name.substring(0, 2); } -/// 取学科 icon URL(无匹配则用数学兜底,对标 Vue getSubjectIcon) -String _getSubjectIconUrl(String subjectName) { - final path = _subjectIconMap[subjectName] ?? _subjectIconMap['数学']!; - return ImgUrl.build(path); -} - /// 格式化创建时间(对标 Vue getReportTime) String _formatCreateTime(String raw) { if (raw.isEmpty) return ''; @@ -120,8 +102,8 @@ class _MyReportPageState extends ConsumerState { color: const Color(0xFFF5F8FA), child: CommonPagedList( load: _loadPage, - padding: EdgeInsets.fromLTRB(12.w, 16.h, 12.w, 0), - separatorBuilder: (_, _) => SizedBox(height: 14.h), + padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 0), + // separatorBuilder: (_, _) => SizedBox(height: 14.h), buildItem: (context, item, index) { return _ReportCard(item: item, onTap: () => _onTapCard(item)); }, @@ -140,11 +122,11 @@ class _MyReportPageState extends ConsumerState { /// ============================================================ /// 【_ReportCard】报告卡片 /// -/// 布局(Row 从左到右): -/// [icon + 学科标签] → [报告名 + 时间] → [箭头] +/// 左侧装饰与我的考试(exam)卡片一致: +/// 白色圆角卡片 + 左侧溢出书图(book_icon)+ 学科图标叠在书图上 + +/// 底部学科名胶囊。书图/卡片尺寸统一用 .w 锁定宽高比,适配 pad。 /// -/// 背景图叠底,InkWell 提供点击水波纹。 -/// 图标/标签自然靠左,不做偏移 hack。 +/// 内容区:中间 [报告名 + 时间],右侧 [箭头]。 /// ============================================================ class _ReportCard extends StatelessWidget { const _ReportCard({required this.item, required this.onTap}); @@ -152,115 +134,177 @@ class _ReportCard extends StatelessWidget { final UserReportDto item; final VoidCallback onTap; + static const String _cardBgBook = + '/StaticResource/2026-09-10/book_icon_final1789026573675.png'; + + // 书图标 / 卡片尺寸(与 exam 卡片一致,统一 .w 锁比例) + static const double _bookWidth = 124; + static const double _bookHeight = + 124; // 卡片间距 = _bookHeight - _cardHeight(书图向上探出的高度) + 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 _bookExtraHeight = 0; + @override Widget build(BuildContext context) { final subjectName = _extractSubjectName(item); - final subjectIconUrl = _getSubjectIconUrl(subjectName); + final iconUrl = subjectIconUrl(subjectName); final timeText = _formatCreateTime(item.createTime); - const bgPath = - '/StaticResource/2026-06-10/examListItem_bg1781074465963.png'; - - return InkWell( + return GestureDetector( + behavior: HitTestBehavior.opaque, onTap: onTap, - borderRadius: BorderRadius.circular(12.r), - child: Container( - height: 109.h, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12.r)), + child: SizedBox( + height: (_bookHeight + _bookExtraHeight).w, child: Stack( + clipBehavior: Clip.none, children: [ - // 底层:卡片背景图 - Positioned.fill( - child: ClipRRect( - borderRadius: BorderRadius.circular(12.r), - child: CachedNetworkImage( - imageUrl: ImgUrl.build(bgPath), - fit: BoxFit.fill, - placeholder: (_, _) => Container(color: Colors.white), - errorWidget: (_, _, _) => Container(color: Colors.white), + // 底层:白色圆角卡片(底部对齐,书图只向上溢出) + Positioned( + left: 0, + right: 0, + bottom: 0, + 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), + ), + ], ), ), ), - // 上层内容:Row 左对齐,padding 控制整体边距 - Padding( - padding: EdgeInsets.symmetric(horizontal: 22.w), + // 中层:左侧书形装饰图 + 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), + ), + ), + // 学科小图标(叠在书图上半部分) + Positioned( + left: 22.w, + top: 14.w, + child: CachedNetworkImage( + imageUrl: iconUrl, + width: 56.w, + height: 56.w, + errorWidget: (_, _, _) => Icon( + Icons.menu_book, + size: 56.w, + color: const Color(0xFF3D7DF5), + ), + ), + ), + // 上层:内容(与卡片同高,底部对齐) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _cardHeight.w, child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ - // ── 左侧:学科 icon + 标签(垂直排列,自然靠左) ── - Column( - mainAxisSize: MainAxisSize.min, - children: [ - CachedNetworkImage( - imageUrl: subjectIconUrl, - width: 56.w, - height: 56.w, - placeholder: (_, _) => const SizedBox.shrink(), - errorWidget: (_, _, _) => const SizedBox.shrink(), - ), - SizedBox(height: 4.h), - Container( - padding: EdgeInsets.symmetric(horizontal: 8.w), - height: 25.h, - decoration: BoxDecoration( - color: const Color(0xFFEAF5FE), - borderRadius: BorderRadius.circular(7.r), - ), - alignment: Alignment.center, - child: Text( - subjectName, - style: TextStyle( - fontSize: 14.sp, - color: const Color(0xFF3D7DF5), - ), - ), - ), - ], - ), - SizedBox(width: 40.w), - // ── 中间:报告名 + 时间(Expanded 占剩余) ── + // 左侧书图区域占位 + SizedBox(width: 90.w), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(height: 10.h), - Text( - item.reportName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 16.sp, - color: const Color(0xFF1D2B47), - ), - ), - SizedBox(height: 20.h), - Text( - timeText, - style: TextStyle( - fontSize: 12.sp, - color: const Color(0xFF687FAD), - ), - ), - ], - ), - ), - // ── 右侧:箭头 ── - Padding( - padding: EdgeInsets.only(top: 10.h), - child: Icon( - Icons.chevron_right, - color: const Color(0xFF4B6B99), - size: 22.sp, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12.w), + child: Row( + children: [ + Expanded(child: _buildMid(timeText)), + _buildRight(), + ], + ), ), ), ], ), ), + // 学科标签(覆盖在书图底部胶囊上,卡片贴底) + Positioned( + left: -10.w, + bottom: 10.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( + subjectName, + style: TextStyle( + fontSize: 16.spMin, + fontWeight: FontWeight.w600, + color: const Color(0xFF3D7DF5), + height: 1.2, + ), + ), + ), + ), + ), ], ), ), ); } + + // 中间:报告名 + 时间 + Widget _buildMid(String timeText) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.reportName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18.spMin, + fontWeight: FontWeight.w600, + color: const Color(0xFF1D2B47), + ), + ), + SizedBox(height: 8.h), + Text( + timeText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.spMin, + fontWeight: FontWeight.w400, + color: const Color(0xFF687FAD), + ), + ), + ], + ); + } + + // 右侧:箭头 + Widget _buildRight() { + return Icon( + Icons.chevron_right, + color: const Color(0xFF4B6B99), + size: 22.sp, + ); + } }