feat(my): add 我的考试 page and related modules
1. 新增我的考试页面,包含学科筛选Tab和分页考试列表 2. 新增学科图标工具类,替换我的报告页重复的学科图标逻辑 3. 在我的页面菜单中添加我的考试入口 4. 新增exam模块的API接口和数据模型 5. 优化我的报告页面卡片样式,统一与考试卡片的布局
This commit is contained in:
@@ -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<List<ExamSubjectTab>> getSubjects() async {
|
||||
final data = await DioClient.instance
|
||||
.get('/api/v1/Evaluation/GetSubjects');
|
||||
final list = data is List
|
||||
? data.whereType<Map<String, dynamic>>().toList()
|
||||
: (data is Map<String, dynamic>
|
||||
? (data['data'] as List<dynamic>? ?? const [])
|
||||
: const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
return list.map(ExamSubjectTab.fromJson).toList();
|
||||
}
|
||||
|
||||
/// 我的考试分页列表
|
||||
/// GET /api/v1/Evaluation/Search
|
||||
/// 参数:baseSubjectId(可 null=全部)/ pageIndex / pageSize
|
||||
Future<ExamResponse> 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<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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<ExamDto> data;
|
||||
final int total;
|
||||
final int pageIndex;
|
||||
final int pageSize;
|
||||
|
||||
factory ExamResponse.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
final list = (payload['data'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
|
||||
@@ -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<String, String> 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);
|
||||
}
|
||||
@@ -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<ExamPage> createState() => _ExamPageState();
|
||||
}
|
||||
|
||||
class _ExamPageState extends State<ExamPage> {
|
||||
final List<ExamSubjectTab> _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<void> _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<PagingResult<ExamDto>> _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<ExamDto>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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<String, String> _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<MyReportPage> {
|
||||
color: const Color(0xFFF5F8FA),
|
||||
child: CommonPagedList<UserReportDto>(
|
||||
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<MyReportPage> {
|
||||
/// ============================================================
|
||||
/// 【_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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user