diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..2743973 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/settings.gradle b/android/settings.gradle index 643d520..e565048 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -23,3 +23,33 @@ plugins { } include ":app" + +// ============================================================ +// 【Gradle 兼容补丁】flutter_inappwebview_android 1.1.3 的 AGP 9 兼容问题 +// +// 问题:AGP 9 不支持 proguard-android.txt,必须用 proguard-android-optimize.txt。 +// 插件 build.gradle 写死了 proguard-android.txt → Gradle 解析时就报错 +// (debug 构建也炸,因为 AGP 9 在解析阶段就校验了这个方法调用)。 +// +// 修复:在 Gradle 解析插件 build.gradle 之前(beforeProject 钩子), +// 把插件文件里的字符串替换掉。这个钩子比 Gradle 自带机制还早触发, +// 所以能赶在 Gradle 报错前修好。 +// +// 优势: +// - Gradle 原生,不依赖 Python/PowerShell +// - 跨平台(Windows/Mac/Linux,Gradle 必备) +// - 所有同事拉代码后自动生效 +// - Gradle daemon 每次构建都会重新解析 build.gradle,不会有缓存问题 +// ============================================================ +gradle.beforeProject { Project project -> + if (project.name == 'flutter_inappwebview_android') { + def gradleFile = project.file('build.gradle') + def text = gradleFile.text + if (text.contains("proguard-android.txt")) { + gradleFile.text = text.replace( + "proguard-android.txt", "proguard-android-optimize.txt" + ) + println "✅ Patched flutter_inappwebview_android/build.gradle (AGP 9 compat)" + } + } +} diff --git a/lib/core/api/conquerPackage/captureList/api.dart b/lib/core/api/conquerPackage/captureList/api.dart new file mode 100644 index 0000000..3ed60b7 --- /dev/null +++ b/lib/core/api/conquerPackage/captureList/api.dart @@ -0,0 +1,33 @@ +import 'package:new_learn/core/api/conquerPackage/captureList/model.dart'; +import 'package:new_learn/core/network/dio_client.dart'; + +/// ============================================================ +/// 【captureList 模块接口】攻克学习包 —— 对标 Vue3 端 api/task/index.ts +/// +/// getCoursePack —— 获取课程下的攻克学习包列表 +/// GET /api/v1/StudyPack/GetCoursePacks/{courseId} +/// +/// startOvercome —— 开始 / 继续攻克学习包 +/// POST /api/v1/StudyPack/StartOvercome/{packId} +/// ============================================================ +class CaptureListApi { + CaptureListApi._(); + + static final CaptureListApi instance = CaptureListApi._(); + + /// 获取课程下的攻克学习包列表 + Future getCoursePack(String courseId) async { + final data = await DioClient.instance.get( + '/api/v1/StudyPack/GetCoursePacks/$courseId', + ); + return CoursePackResponse.fromJson(data as Map); + } + + /// 开始 / 继续攻克学习包 + Future startOvercome(String packId) async { + final data = await DioClient.instance.post( + '/api/v1/StudyPack/StartOvercome/$packId', + ); + return TaskDetail.fromJson(data as Map); + } +} diff --git a/lib/core/api/conquerPackage/captureList/model.dart b/lib/core/api/conquerPackage/captureList/model.dart new file mode 100644 index 0000000..8232622 --- /dev/null +++ b/lib/core/api/conquerPackage/captureList/model.dart @@ -0,0 +1,156 @@ +/// ============================================================ +/// 【captureList 模块模型】攻克学习包 —— 对标 Vue3 端 api/task/type.ts +/// +/// CoursePackResponse / CourseInfo / CourseRecord +/// TaskDetail(开始/继续攻克返回) +/// +/// 序列化:手动 fromJson(兼容 {code, message, data: {...}} 响应壳) +/// ============================================================ +library; + +/// 攻克记录(单次攻克的结果) +class CourseRecord { + const CourseRecord({ + this.id = 0, + this.createTime = '', + this.accuracyRate = 0, + this.status = 0, + this.statusName = '', + }); + + final int id; // 记录 id + final String createTime; // 创建时间 + final int accuracyRate; // 正确率(0-100) + final int status; // 状态:0 未开始 / 1 进行中 / 2 成功 / 3 失败 等(按后端枚举) + final String statusName; // 状态名称 + + factory CourseRecord.fromJson(Map json) { + return CourseRecord( + id: _parseInt(json['id']), + createTime: json['createTime'] as String? ?? '', + accuracyRate: _parseInt(json['accuracyRate']), + status: _parseInt(json['status']), + statusName: json['statusName'] as String? ?? '', + ); + } +} + +/// 单个学习包(攻克包)信息 +class CourseInfo { + const CourseInfo({ + this.id = 0, + this.name = '', + this.baseSubjectName = '', + this.overcomeStandard = 0, + this.estimatedTime = 0, + this.maxOvercomeCount = 0, + this.recordCount = 0, + this.records = const [], + this.status = 0, + this.statusName = '', + }); + + final int id; // 学习包 id + final String name; // 名称 + final String baseSubjectName; // 学科名称 + final int overcomeStandard; // 攻克标准(百分比) + final int estimatedTime; // 预计用时(分钟) + final int maxOvercomeCount; // 最大可攻克次数 + final int recordCount; // 已攻克次数 + final List records; // 历次攻克记录 + final int status; // 状态:0 未开始 / 1 进行中 / 2 已攻克 / 3 失败 / 4 后续学习 / 5 判题中(按后端枚举) + final String statusName; // 状态名称 + + factory CourseInfo.fromJson(Map json) { + return CourseInfo( + id: _parseInt(json['id']), + name: json['name'] as String? ?? '', + baseSubjectName: json['baseSubjectName'] as String? ?? '', + overcomeStandard: _parseInt(json['overcomeStandard']), + estimatedTime: _parseInt(json['estimatedTime']), + maxOvercomeCount: _parseInt(json['maxOvercomeCount']), + recordCount: _parseInt(json['recordCount']), + records: (json['records'] as List? ?? const []) + .whereType>() + .map(CourseRecord.fromJson) + .toList(), + status: _parseInt(json['status']), + statusName: json['statusName'] as String? ?? '', + ); + } +} + +/// 学习包列表响应(接口返回 {packs: [...], remark: '...'}) +class CoursePackResponse { + const CoursePackResponse({ + this.packs = const [], + this.remark = '', + }); + + final List packs; // 学习包列表 + final String remark; // 规则说明文字 + + /// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data + factory CoursePackResponse.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return CoursePackResponse( + packs: (payload['packs'] as List? ?? + payload['courses'] as List? ?? + const []) + .whereType>() + .map(CourseInfo.fromJson) + .toList(), + remark: payload['remark'] as String? ?? '', + ); + } +} + +/// 开始 / 继续攻克 接口返回(精简版,包含进入下一步所需字段) +class TaskDetail { + const TaskDetail({ + this.id = 0, + this.name = '', + this.overcomeStandard = 0, + this.planTimeMinute = 0, + this.recordId = 0, + this.nowStudyObjectType = 0, + this.nowStudyObjectId = 0, + this.hasHandout = false, + this.hasQuestion = false, + }); + + final int id; // 学习包 id + final String name; // 名称 + final int overcomeStandard; // 攻克标准 + final int planTimeMinute; // 计划用时(分钟) + final int recordId; // 学习记录 id(跳转到习题/讲义页用) + final int nowStudyObjectType; // 当前学习对象类型:0 讲义 / 11 习题 等 + final int nowStudyObjectId; // 当前学习对象 id + final bool hasHandout; // 是否有讲义 + final bool hasQuestion; // 是否有题目 + + factory TaskDetail.fromJson(Map json) { + final payload = (json['data'] as Map?) ?? json; + return TaskDetail( + id: _parseInt(payload['id']), + name: payload['name'] as String? ?? '', + overcomeStandard: _parseInt(payload['overcomeStandard']), + planTimeMinute: _parseInt(payload['planTimeMinute']), + recordId: _parseInt(payload['recordId']), + nowStudyObjectType: _parseInt(payload['nowStudyObjectType']), + nowStudyObjectId: _parseInt(payload['nowStudyObjectId']), + hasHandout: payload['hasHandout'] as bool? ?? false, + hasQuestion: payload['hasQuestion'] as bool? ?? false, + ); + } +} + +/// 安全解析 int:兼容数字和字符串数字,解析失败返回 0 +int _parseInt(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value) ?? 0; + return 0; +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 5131e11..4e71001 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -148,7 +148,8 @@ final GoRouter appRouter = GoRouter( // ---------- 攻克包 - 截图名单 ---------- GoRoute( path: '/conquerPackage/captureList', - builder: (_, _) => const CaptureListPage(), + builder: (_, state) => + CaptureListPage(courseId: state.extra as String), ), // app_router.dart 的 routes 里加,不用管跳转逻辑 // GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()), diff --git a/lib/features/conquerPackage/captureList/index.dart b/lib/features/conquerPackage/captureList/index.dart index d7b7099..fbdbc60 100644 --- a/lib/features/conquerPackage/captureList/index.dart +++ b/lib/features/conquerPackage/captureList/index.dart @@ -1,41 +1,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:new_learn/core/api/conquerPackage/captureList/api.dart'; +import 'package:new_learn/core/api/conquerPackage/captureList/model.dart'; +import 'package:new_learn/core/components/common_paged_list.dart'; import 'package:new_learn/core/utils/img_url.dart'; /// ============================================================ /// 【CaptureListPage】攻克列表页 —— 对标 Vue pages/conquerPackage/captureList/index.vue /// -/// 一、Vue 源结构: -/// HeaderBac + NavBar(title: "攻克列表", bg-color: #ADD1FF00) -/// ├── rule-view 攻克规则说明卡片(remark 富文本) -/// └── capture-list-refresh-wrap -/// └── up-pull-refresh -/// ├── captureList 攻克项列表 -/// │ └── capture-item -/// │ ├── capture-info (左:标题+学科标签;右:攻克标准百分比) -/// │ └── capture-progress -/// │ ├── progress-top (进度条 + 右侧按钮) -/// │ └── capture-progress-info (展开的历次攻克详情) -/// └── DataEmpty 空状态 +/// 入参:courseId(课程 id) /// -/// 二、换算规则(本工程统一):Vue 的 `N rpx` → Flutter 的 `(N/2).w/.h/.sp/.r` -/// 例:间距 28rpx → 14.w;圆角 32rpx → 16.r;字号 28rpx → 14.sp +/// 接口: +/// - CaptureListApi.getCoursePack(courseId) → 列表 + remark +/// - CaptureListApi.startOvercome(packId) → 开始/继续攻克,跳转对应页面 /// -/// 三、状态(StatefulWidget): -/// - remark: 规则说明文字 -/// - data: 攻克包列表 -/// - refreshing: 下拉刷新状态 -/// - isDataEmpty: 空数据标记 -/// -/// 四、接口暂未对接,使用 Mock 数据展示页面效果。 +/// 换算规则(本工程统一):Vue 的 `N rpx` → Flutter 的 `(N/2).w/.h/.sp/.r` /// ============================================================ -/// 攻克记录状态枚举 +/// 攻克记录状态枚举(与后端 status 对齐:0未开始/1成功/2失败/3判题中) enum CaptureRecordStatus { - none(0), // 未开始 - success(1), // 成功 - fail(2), // 失败 + none(0), // 未开始 + success(1), // 成功 + fail(2), // 失败 processing(3); // 判题中 final int value; @@ -49,14 +36,14 @@ enum CaptureRecordStatus { } } -/// 攻克包状态 +/// 攻克包状态枚举(与后端 status 对齐) enum CapturePackStatus { - notStarted(0), // 未开始 - inProgress(1), // 进行中 - conquered(2), // 已攻克 - failed(3), // 攻克失败 - laterStudy(4), // 后续学习 - judging(5); // 判题中 + notStarted(0), // 未开始 + inProgress(1), // 进行中 + conquered(2), // 已攻克 + failed(3), // 攻克失败 + laterStudy(4), // 后续学习 + judging(5); // 判题中 final int value; const CapturePackStatus(this.value); @@ -69,48 +56,27 @@ enum CapturePackStatus { } } -/// 攻克记录模型 -class CaptureRecord { - final String id; - final CaptureRecordStatus status; - final int? accuracyRate; // 正确率百分比 - final int? overcomeTimeSecond; // 攻克用时(秒) +/// 页面内部包装模型(在 CourseInfo 基础上增加 UI 状态 isShowCaptureInfo) +class _CapturePackViewModel { + final CourseInfo info; + bool isShowCaptureInfo; - const CaptureRecord({ - required this.id, - required this.status, - this.accuracyRate, - this.overcomeTimeSecond, - }); -} + _CapturePackViewModel(this.info, {this.isShowCaptureInfo = false}); -/// 攻克包模型 -class CapturePack { - final String id; - final String name; - final String baseSubjectName; - final int overcomeStandard; // 攻克标准百分比 - final int maxOvercomeCount; // 最大攻克次数 - final int recordCount; // 已进行次数 - final CapturePackStatus status; - final List records; - bool isShowCaptureInfo; // 是否展开详情 - - CapturePack({ - required this.id, - required this.name, - required this.baseSubjectName, - required this.overcomeStandard, - required this.maxOvercomeCount, - required this.recordCount, - required this.status, - required this.records, - this.isShowCaptureInfo = false, - }); + String get id => '${info.id}'; + String get name => info.name; + String get baseSubjectName => info.baseSubjectName; + int get overcomeStandard => info.overcomeStandard; + int get maxOvercomeCount => info.maxOvercomeCount; + int get recordCount => info.recordCount; + CapturePackStatus get status => CapturePackStatus.fromInt(info.status); + List get records => info.records; } class CaptureListPage extends StatefulWidget { - const CaptureListPage({super.key}); + const CaptureListPage({super.key, required this.courseId}); + + final String courseId; @override State createState() => _CaptureListPageState(); @@ -130,209 +96,195 @@ class _CaptureListPageState extends State { // ========== 状态 ========== String _remark = ''; - List _data = []; - bool _refreshing = false; - bool _isDataEmpty = false; + bool _navigating = false; // 防止连续点击重复请求/跳转 + List _expandedPackIds = []; // 展开的包 id(刷新时保留展开状态) + int _requestId = 0; // 请求序号,防止并发请求错乱 @override void initState() { super.initState(); - _loadMockData(); } - /// Mock 数据 —— 替代真实接口,仅用于展示页面效果 - void _loadMockData() { - _remark = '1. 每次攻克需达到指定正确率才算成功;\n' - '2. 攻克失败可重新挑战,最多可挑战 N 次;\n' - '3. 判题中状态下不能进行操作;\n' - '4. 详情可查看每次攻克的用时与正确率。'; + // ========== 接口 ========== - _data = [ - CapturePack( - id: '1', - name: '一元一次方程入门', - baseSubjectName: '数学', - overcomeStandard: 80, - maxOvercomeCount: 3, - recordCount: 2, - status: CapturePackStatus.inProgress, - records: [ - CaptureRecord( - id: 'r1-1', - status: CaptureRecordStatus.fail, - accuracyRate: 60, - overcomeTimeSecond: 480, - ), - CaptureRecord( - id: 'r1-2', - status: CaptureRecordStatus.success, - accuracyRate: 90, - overcomeTimeSecond: 600, - ), - ], - isShowCaptureInfo: true, - ), - CapturePack( - id: '2', - name: '英语单词 Unit 3', - baseSubjectName: '英语', - overcomeStandard: 70, - maxOvercomeCount: 5, - recordCount: 0, - status: CapturePackStatus.notStarted, - records: [], - ), - CapturePack( - id: '3', - name: '文言文翻译技巧', - baseSubjectName: '语文', - overcomeStandard: 75, - maxOvercomeCount: 3, - recordCount: 3, - status: CapturePackStatus.conquered, - records: [ - CaptureRecord( - id: 'r3-1', - status: CaptureRecordStatus.fail, - accuracyRate: 50, - overcomeTimeSecond: 300, - ), - CaptureRecord( - id: 'r3-2', - status: CaptureRecordStatus.fail, - accuracyRate: 65, - overcomeTimeSecond: 420, - ), - CaptureRecord( - id: 'r3-3', - status: CaptureRecordStatus.success, - accuracyRate: 88, - overcomeTimeSecond: 540, - ), - ], - ), - CapturePack( - id: '4', - name: '物理力学综合', - baseSubjectName: '物理', - overcomeStandard: 85, - maxOvercomeCount: 3, - recordCount: 1, - status: CapturePackStatus.judging, - records: [ - CaptureRecord( - id: 'r4-1', - status: CaptureRecordStatus.processing, - accuracyRate: null, - overcomeTimeSecond: 700, - ), - ], - isShowCaptureInfo: true, - ), - CapturePack( - id: '5', - name: '化学方程式配平', - baseSubjectName: '化学', - overcomeStandard: 80, - maxOvercomeCount: 3, - recordCount: 3, - status: CapturePackStatus.failed, - records: [ - CaptureRecord( - id: 'r5-1', - status: CaptureRecordStatus.fail, - accuracyRate: 40, - overcomeTimeSecond: 200, - ), - CaptureRecord( - id: 'r5-2', - status: CaptureRecordStatus.fail, - accuracyRate: 55, - overcomeTimeSecond: 300, - ), - CaptureRecord( - id: 'r5-3', - status: CaptureRecordStatus.fail, - accuracyRate: 60, - overcomeTimeSecond: 400, - ), - ], - ), - CapturePack( - id: '6', - name: '历史时间轴梳理', - baseSubjectName: '历史', - overcomeStandard: 70, - maxOvercomeCount: 3, - recordCount: 2, - status: CapturePackStatus.laterStudy, - records: [ - CaptureRecord( - id: 'r6-1', - status: CaptureRecordStatus.success, - accuracyRate: 80, - overcomeTimeSecond: 360, - ), - CaptureRecord( - id: 'r6-2', - status: CaptureRecordStatus.success, - accuracyRate: 95, - overcomeTimeSecond: 300, - ), - ], - ), - ]; + /// 分页加载:CommonPagedList 回调。接口无分页,当作一页返回,hasNext=false。 + Future> _loadPage(int pageKey) async { + final requestId = ++_requestId; + final expandedIds = _expandedPackIds.isNotEmpty + ? [..._expandedPackIds] + : []; - _isDataEmpty = _data.isEmpty; - } - - /// 下拉刷新 - Future _onRefresh() async { - if (_refreshing) return; - setState(() => _refreshing = true); - // 模拟网络请求延迟 - await Future.delayed(const Duration(milliseconds: 800)); - if (mounted) { - setState(() { - _loadMockData(); - _refreshing = false; - }); + // courseId 为空直接返回空列表 + if (widget.courseId.isEmpty) { + if (!mounted) return const PagingResult(data: [], hasNext: false); + setState(() => _remark = ''); + return const PagingResult(data: [], hasNext: false); } + + final res = await CaptureListApi.instance.getCoursePack(widget.courseId); + if (requestId != _requestId) { + // 已被新请求覆盖,丢弃结果(返回空让库忽略也可) + return const PagingResult(data: [], hasNext: false); + } + + final list = _restoreExpandedState(res.packs, expandedIds); + if (!mounted) return PagingResult(data: list, hasNext: false); + + setState(() { + _remark = res.remark; + _expandedPackIds = list + .where((item) => item.isShowCaptureInfo) + .map((item) => item.id) + .toList(); + }); + + // 只有一页 + return PagingResult(data: list, hasNext: false); + } + + // ========== 展开状态管理(对标 Vue saveExpandedState / restoreExpandedState) ========== + + /// 恢复展开状态 + 计算 recordCount(status != 0 的记录数) + List<_CapturePackViewModel> _restoreExpandedState( + List list, + List expandedIds, + ) { + final expandedIdSet = expandedIds.toSet(); + return list.map((info) { + final records = info.records; + final validCount = records.where((r) => r.status != 0).length; + // 构造一个新的 CourseInfo(recordCount 取有效记录数) + final adjustedInfo = CourseInfo( + id: info.id, + name: info.name, + baseSubjectName: info.baseSubjectName, + overcomeStandard: info.overcomeStandard, + estimatedTime: info.estimatedTime, + maxOvercomeCount: info.maxOvercomeCount, + recordCount: validCount, + records: records, + status: info.status, + statusName: info.statusName, + ); + final hasValidRecords = records.any((r) => + r.status == 1 || r.status == 2 || r.status == 3); + final shouldExpand = + expandedIdSet.contains('${info.id}') || hasValidRecords; + return _CapturePackViewModel(adjustedInfo, + isShowCaptureInfo: shouldExpand); + }).toList(); } /// 切换攻克详情展开/收起 - void _toggleCaptureInfo(CapturePack item) { - final validRecords = item.records.where((r) => r.status != CaptureRecordStatus.none).toList(); + void _toggleCaptureInfo(_CapturePackViewModel item) { + if (item.records.isEmpty) return; + final validRecords = item.records.where((r) => r.status != 0).toList(); if (validRecords.isEmpty) return; setState(() { item.isShowCaptureInfo = !item.isShowCaptureInfo; + if (item.isShowCaptureInfo) { + if (!_expandedPackIds.contains(item.id)) { + _expandedPackIds.add(item.id); + } + } else { + _expandedPackIds.remove(item.id); + } }); } - /// 点击按钮跳转(占位,不接接口) - void _navigateToCaptureInfo(CapturePack item) { + // ========== 点击事件 ========== + + /// 点击按钮 → 开始/继续攻克(对接 startOvercome 接口) + Future _navigateToCaptureInfo(_CapturePackViewModel item) async { if (item.status == CapturePackStatus.judging) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('该攻克正在判题中,暂不能操作'), behavior: SnackBarBehavior.floating), ); return; } - // TODO(业务逻辑): 对接 GetStartOvercome 接口后跳转 + if (_navigating) return; + _navigating = true; + + // 显示加载 + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + + try { + final res = await CaptureListApi.instance.startOvercome(item.id); + if (!mounted) return; + Navigator.of(context).pop(); // 关闭加载 + + // 跳转分支(对标 Vue navigateToCaptureInfo) + if (res.hasHandout) { + // 有讲义 → 课前课中页 + _navigateTo('/conquer/beforeDuringClass', { + 'status': '1', + 'packId': item.id, + 'courseId': widget.courseId, + 'recordId': '${res.recordId}', + if (res.nowStudyObjectType == 11) + 'nowStudyObjectId': '${res.nowStudyObjectId}', + }); + return; + } + if (res.hasQuestion) { + // 有题目 → 过渡页 或 习题页 + final pagePath = res.nowStudyObjectType == 0 + ? '/conquer/transitionPage' + : '/conquer/classExercises'; + _navigateTo(pagePath, { + 'status': '3', + 'courseId': widget.courseId, + 'packId': item.id, + 'recordId': '${res.recordId}', + if (res.nowStudyObjectType == 12) + 'nowStudyObjectId': '${res.nowStudyObjectId}', + }); + return; + } + // 既没讲义也没题目 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('该攻克暂无讲义或题目'), behavior: SnackBarBehavior.floating), + ); + } catch (e) { + if (mounted) { + Navigator.of(context).pop(); // 关闭加载 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('加载失败:${e.toString()}'), behavior: SnackBarBehavior.floating), + ); + } + } finally { + _navigating = false; + } } - /// 点击报告(占位,不接接口) - void _getReport(CaptureRecord record, CapturePack pack) { - if (record.status == CaptureRecordStatus.processing) return; - // TODO(业务逻辑): 跳转报告页 + /// 页面跳转(占位 —— 待路由统一后替换为正式路由名) + void _navigateTo(String path, Map params) { + // TODO(路由): 替换为项目统一的路由跳转方式 + debugPrint('navigateTo: $path $params'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('跳转 $path ${params.toString()}'), behavior: SnackBarBehavior.floating), + ); } - /// 数字转中文(1-9) + /// 点击报告(跳转报告页) + void _getReport(CourseRecord record, _CapturePackViewModel pack) { + if (CaptureRecordStatus.fromInt(record.status) == + CaptureRecordStatus.processing) return; + // TODO(路由): 跳转报告页 + debugPrint('跳转报告页 recordId=${record.id}, packId=${pack.id}'); + } + + // ========== 工具方法 ========== + String _numberToChinese(int num) { const chineseNum = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九']; return chineseNum[num.clamp(0, 9)]; } - /// 秒数格式化:mm分ss秒 / h时mm分 String _formatDuration(int seconds) { if (seconds <= 0) return '0秒'; final h = seconds ~/ 3600; @@ -343,15 +295,16 @@ class _CaptureListPageState extends State { return '${s}秒'; } + // ========== 构建 ========== + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _bgColor, body: Stack( children: [ - // 页面底色 const Positioned.fill(child: ColoredBox(color: _bgColor)), - // 顶部背景图(对标 HeaderBac,bg-size: 100% auto → width 100%,高度自适应) + // 顶部背景图 Positioned( top: 0, left: 0, @@ -362,13 +315,15 @@ class _CaptureListPageState extends State { ), fit: BoxFit.fitWidth, width: double.infinity, + placeholder: (context, url) => const SizedBox.shrink(), + errorWidget: (context, url, error) => const SizedBox.shrink(), ), ), - // 主内容(安全区内,含透明 AppBar + 规则卡片 + 列表) + // 主内容 SafeArea( child: Column( children: [ - // 顶部导航栏(对标 NavBar,bg-color 透明,白色文字) + // 导航栏 SizedBox( height: 44.h, child: Stack( @@ -377,7 +332,7 @@ class _CaptureListPageState extends State { child: Text( '攻克列表', style: TextStyle( - color: Colors.white, + color: Color.fromRGBO(0, 0, 0, 0.8), fontSize: 18, fontWeight: FontWeight.w600, ), @@ -388,7 +343,8 @@ class _CaptureListPageState extends State { top: 0, bottom: 0, child: IconButton( - icon: const Icon(Icons.arrow_back_ios, color: Colors.white, size: 20), + icon: const Icon(Icons.arrow_back_ios, + color: Color.fromRGBO(0, 0, 0, 0.8), size: 20), onPressed: () => Navigator.of(context).maybePop(), padding: EdgeInsets.zero, constraints: const BoxConstraints(), @@ -397,31 +353,17 @@ class _CaptureListPageState extends State { ], ), ), - // 规则卡片(透明底 + 白边,置于渐变背景之上) + // 规则卡片 _buildRuleView(), SizedBox(height: 19.h), - // 列表区域 + // 列表 Expanded( - child: RefreshIndicator( - onRefresh: _onRefresh, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - if (_isDataEmpty) - SliverFillRemaining(child: _buildEmpty()) - else - SliverPadding( - padding: EdgeInsets.symmetric(horizontal: 14.w), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => _buildCaptureItem(_data[index]), - childCount: _data.length, - ), - ), - ), - SliverToBoxAdapter(child: SizedBox(height: 20.h)), - ], - ), + child: CommonPagedList<_CapturePackViewModel>( + load: _loadPage, + buildItem: (context, item, index) => _buildCaptureItem(item), + padding: EdgeInsets.symmetric(horizontal: 14.w), + emptyWidget: _buildEmpty(), + noMoreText: '', // 只有一页,不显示"没有更多" ), ), ], @@ -436,7 +378,6 @@ class _CaptureListPageState extends State { // 子组件构建方法 // ============================================================ - /// 攻克规则说明卡片(透明底 + 白色边框 + 阴影,对标 Vue rule-view) Widget _buildRuleView() { return Container( margin: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 0), @@ -514,6 +455,10 @@ class _CaptureListPageState extends State { } Widget _buildRuleContent() { + if (_remark.isEmpty) { + return Text('暂无规则说明', + style: TextStyle(fontSize: 14.sp, color: _subtitleColor)); + } final lines = _remark.split('\n'); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -546,7 +491,6 @@ class _CaptureListPageState extends State { ); } - /// 空状态 Widget _buildEmpty() { return Center( child: Column( @@ -564,8 +508,7 @@ class _CaptureListPageState extends State { ); } - /// 攻克项卡片 - Widget _buildCaptureItem(CapturePack item) { + Widget _buildCaptureItem(_CapturePackViewModel item) { return Container( margin: EdgeInsets.only(bottom: 14.h), decoration: BoxDecoration( @@ -586,8 +529,7 @@ class _CaptureListPageState extends State { ); } - /// 攻克项头部信息 - Widget _buildCaptureInfo(CapturePack item) { + Widget _buildCaptureInfo(_CapturePackViewModel item) { return Padding( padding: EdgeInsets.fromLTRB(14.w, 18.5.h, 14.w, 0), child: Row( @@ -643,8 +585,8 @@ class _CaptureListPageState extends State { padding: EdgeInsets.symmetric(horizontal: 1.w, vertical: 1.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(6.r), - gradient: LinearGradient( - colors: const [Color(0xFF5A9EFF), Color(0xFFF2F8FF), Color(0xFF5A9EFF)], + gradient: const LinearGradient( + colors: [Color(0xFF5A9EFF), Color(0xFFF2F8FF), Color(0xFF5A9EFF)], begin: Alignment.centerLeft, end: Alignment.centerRight, ), @@ -667,7 +609,6 @@ class _CaptureListPageState extends State { ); } - /// 右侧攻克标准百分比盒子 Widget _buildStandardBox(int standard) { return Container( width: 60.w, @@ -686,37 +627,48 @@ class _CaptureListPageState extends State { Expanded( flex: 1, child: Center( - child: Text( - '攻克标准', - style: TextStyle(fontSize: 12.sp, color: Colors.white), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '攻克标准', + style: TextStyle(fontSize: 12.sp, color: Colors.white), + ), ), ), ), - Container( - padding: EdgeInsets.symmetric(vertical: 1.5.h, horizontal: 4.w), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(6.r), - bottomRight: Radius.circular(6.r), + Expanded( + child: Container( + margin: EdgeInsets.fromLTRB(1.5.w, 0, 1.5.w, 1.5.h), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(6.r), + bottomRight: Radius.circular(6.r), + ), ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CachedNetworkImage( - imageUrl: ImgUrl.build('/StaticResource/2026-04-10/capture-list-success.png'), - width: 14.w, - height: 14.w, - fit: BoxFit.fitWidth, + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CachedNetworkImage( + imageUrl: + ImgUrl.build('/StaticResource/2026-04-10/capture-list-success.png'), + width: 14.w, + height: 14.w, + fit: BoxFit.fitWidth, + ), + SizedBox(width: 5.5.w), + Text( + '$standard%', + style: TextStyle( + fontSize: 15.sp, color: _success, fontWeight: FontWeight.bold), + ), + ], + ), ), - SizedBox(width: 5.5.w), - Text( - '$standard%', - style: TextStyle(fontSize: 15.sp, color: _success, fontWeight: FontWeight.bold), - ), - ], + ), ), ), ], @@ -724,8 +676,7 @@ class _CaptureListPageState extends State { ); } - /// 进度 + 按钮 + 详情展开区 - Widget _buildCaptureProgress(CapturePack item) { + Widget _buildCaptureProgress(_CapturePackViewModel item) { return Container( margin: EdgeInsets.only(top: 10.5.h), padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 16.h), @@ -742,7 +693,7 @@ class _CaptureListPageState extends State { ); } - Widget _buildProgressTop(CapturePack item) { + Widget _buildProgressTop(_CapturePackViewModel item) { return Row( children: [ Expanded( @@ -765,12 +716,12 @@ class _CaptureListPageState extends State { ); } - Widget _buildProgressBars(CapturePack item) { + Widget _buildProgressBars(_CapturePackViewModel item) { return Row( children: List.generate(item.maxOvercomeCount, (idx) { final record = idx < item.records.length ? item.records[idx] : null; Color color; - switch (record?.status ?? CaptureRecordStatus.none) { + switch (CaptureRecordStatus.fromInt(record?.status)) { case CaptureRecordStatus.success: color = _success; break; @@ -796,7 +747,7 @@ class _CaptureListPageState extends State { ); } - Widget _buildProgressCount(CapturePack item) { + Widget _buildProgressCount(_CapturePackViewModel item) { final hasRecords = item.recordCount > 0 && item.records.isNotEmpty; return Row( children: [ @@ -808,7 +759,9 @@ class _CaptureListPageState extends State { Padding( padding: EdgeInsets.only(left: 4.5.w), child: Icon( - item.isShowCaptureInfo ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + item.isShowCaptureInfo + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, size: 12.sp, color: _mutedText, ), @@ -817,18 +770,19 @@ class _CaptureListPageState extends State { ); } - /// 右侧按钮组(根据状态不同显示) - Widget _buildButtonGroup(CapturePack item) { + Widget _buildButtonGroup(_CapturePackViewModel item) { switch (item.status) { case CapturePackStatus.notStarted: - return _buildOutlineButton('开始攻克', _primary, () => _navigateToCaptureInfo(item)); + return _buildSolidButton('开始攻克', () => _navigateToCaptureInfo(item)); case CapturePackStatus.inProgress: final text = item.records.isNotEmpty ? '继续攻克' : '开始攻克'; - return _buildOutlineButton(text, _primary, () => _navigateToCaptureInfo(item)); + return _buildSolidButton(text, () => _navigateToCaptureInfo(item)); case CapturePackStatus.conquered: return _buildOutlineButton('已攻克', _success, null, opacity: 0.5); case CapturePackStatus.failed: - return _buildOutlineButton('攻克失败', _error, () => _navigateToCaptureInfo(item), opacity: 0.5); + return _buildOutlineButton('攻克失败', _error, + () => _navigateToCaptureInfo(item), + opacity: 0.5); case CapturePackStatus.laterStudy: return Row( mainAxisSize: MainAxisSize.min, @@ -841,7 +795,7 @@ class _CaptureListPageState extends State { ), ), SizedBox(width: 8.w), - _buildOutlineButton('重新攻克', _primary, () => _navigateToCaptureInfo(item)), + _buildSolidButton('重新攻克', () => _navigateToCaptureInfo(item)), ], ); case CapturePackStatus.judging: @@ -849,7 +803,8 @@ class _CaptureListPageState extends State { } } - Widget _buildOutlineButton(String text, Color color, VoidCallback? onTap, {double opacity = 1.0}) { + Widget _buildOutlineButton(String text, Color color, VoidCallback? onTap, + {double opacity = 1.0}) { return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, @@ -873,9 +828,34 @@ class _CaptureListPageState extends State { ); } - /// 展开的历次攻克详情 - Widget _buildProgressInfo(CapturePack item) { - final validRecords = item.records.where((r) => r.status != CaptureRecordStatus.none).toList(); + Widget _buildSolidButton(String text, VoidCallback? onTap, + {double opacity = 1.0}) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Opacity( + opacity: opacity, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h), + height: 30.h, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30.r), + color: _primary, + ), + child: Text( + text, + style: TextStyle( + fontSize: 13.sp, color: Colors.white, fontWeight: FontWeight.w500), + ), + ), + ), + ); + } + + Widget _buildProgressInfo(_CapturePackViewModel item) { + final validRecords = + item.records.where((r) => r.status != 0).toList(); if (validRecords.isEmpty) return const SizedBox.shrink(); return Container( @@ -906,13 +886,15 @@ class _CaptureListPageState extends State { ), SizedBox(width: 13.w), Text( - _formatDuration(record.overcomeTimeSecond ?? 0), + _formatDuration(0), // TODO: 后端 record 没有用时字段,需确认 style: TextStyle(fontSize: 12.sp, color: _subtitleColor), ), ], ), - if (record.status == CaptureRecordStatus.processing) - Text('判题中', style: TextStyle(fontSize: 12.sp, color: _processing)) + if (CaptureRecordStatus.fromInt(record.status) == + CaptureRecordStatus.processing) + Text('判题中', + style: TextStyle(fontSize: 12.sp, color: _processing)) else _buildInfoRight(record, item), ], @@ -924,9 +906,10 @@ class _CaptureListPageState extends State { ); } - Widget _buildInfoRight(CaptureRecord record, CapturePack item) { - final isSuccess = (record.accuracyRate ?? 0) >= item.overcomeStandard; - final bgColor = isSuccess ? const Color(0xFFE8FAF6) : const Color(0xFFFFF0F0); + Widget _buildInfoRight(CourseRecord record, _CapturePackViewModel pack) { + final isSuccess = record.accuracyRate >= pack.overcomeStandard; + final bgColor = + isSuccess ? const Color(0xFFE8FAF6) : const Color(0xFFFFF0F0); final textColor = isSuccess ? _success : _error; final iconPath = isSuccess ? '/StaticResource/2026-04-22/capture-list-success.png' @@ -949,8 +932,9 @@ class _CaptureListPageState extends State { ), SizedBox(width: 5.5.w), Text( - '${record.accuracyRate ?? 0}%', - style: TextStyle(fontSize: 12.sp, color: textColor, fontWeight: FontWeight.w500), + '${record.accuracyRate}%', + style: TextStyle( + fontSize: 12.sp, color: textColor, fontWeight: FontWeight.w500), ), ], ), diff --git a/lib/features/task/task_page.dart b/lib/features/task/task_page.dart index b3226c7..afaf4e1 100644 --- a/lib/features/task/task_page.dart +++ b/lib/features/task/task_page.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; import 'package:new_learn/core/components/video_player_page.dart'; import 'package:new_learn/features/auth/login_page.dart'; +import 'package:new_learn/features/conquerPackage/captureList/index.dart'; import 'package:new_learn/pages/demo/align_demo.dart'; import 'package:new_learn/pages/demo/animation_demo.dart'; import 'package:new_learn/pages/demo/form_demo.dart'; @@ -228,6 +230,21 @@ class TaskPage extends StatelessWidget { Icons.description_outlined, _page(ExamDemoPage.new), ), + _DemoItem( + '攻克列表', + '课程攻克包列表 + 进度条 + 展开详情(go_router 跳转 + extra 传参)', + Icons.emoji_events_outlined, + _page(() => const Scaffold()), + onTap: (context) { + // 延时到当前帧结束后再导航,避免 build 过程中触发 Router rebuild + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + context.push('/conquerPackage/captureList', + extra: '841985856751366'); + } + }); + }, + ), ]; @override @@ -278,9 +295,13 @@ class TaskPage extends StatelessWidget { ), // 点击 → 路由跳转到对应 Demo 页面 onTap: () { - Navigator.of(context).push( - MaterialPageRoute(builder: demo.builder), - ); + if (demo.onTap != null) { + demo.onTap!(context); + } else { + Navigator.of(context).push( + MaterialPageRoute(builder: demo.builder), + ); + } }, ), ); @@ -308,10 +329,17 @@ WidgetBuilder _page(Widget Function() create) { /// Demo 列表项数据模型 class _DemoItem { - const _DemoItem(this.title, this.subtitle, this.icon, this.builder); + const _DemoItem( + this.title, + this.subtitle, + this.icon, + this.builder, { + this.onTap, + }); final String title; final String subtitle; final IconData icon; final WidgetBuilder builder; + final void Function(BuildContext context)? onTap; }