合并代买

This commit is contained in:
chenshuainan
2026-09-09 16:12:59 +08:00
parent 44fb5f86e2
commit 592e576319
7 changed files with 1227 additions and 332 deletions
File diff suppressed because one or more lines are too long
+30
View File
@@ -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)"
}
}
}
@@ -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<CoursePackResponse> getCoursePack(String courseId) async {
final data = await DioClient.instance.get(
'/api/v1/StudyPack/GetCoursePacks/$courseId',
);
return CoursePackResponse.fromJson(data as Map<String, dynamic>);
}
/// 开始 / 继续攻克学习包
Future<TaskDetail> startOvercome(String packId) async {
final data = await DioClient.instance.post(
'/api/v1/StudyPack/StartOvercome/$packId',
);
return TaskDetail.fromJson(data as Map<String, dynamic>);
}
}
@@ -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<String, dynamic> 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<CourseRecord> records; // 历次攻克记录
final int status; // 状态:0 未开始 / 1 进行中 / 2 已攻克 / 3 失败 / 4 后续学习 / 5 判题中(按后端枚举)
final String statusName; // 状态名称
factory CourseInfo.fromJson(Map<String, dynamic> 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<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.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<CourseInfo> packs; // 学习包列表
final String remark; // 规则说明文字
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
factory CoursePackResponse.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return CoursePackResponse(
packs: (payload['packs'] as List<dynamic>? ??
payload['courses'] as List<dynamic>? ??
const [])
.whereType<Map<String, dynamic>>()
.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<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? 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;
}
+2 -1
View File
@@ -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()),
+311 -327
View File
@@ -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<CaptureRecord> 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<CourseRecord> get records => info.records;
}
class CaptureListPage extends StatefulWidget {
const CaptureListPage({super.key});
const CaptureListPage({super.key, required this.courseId});
final String courseId;
@override
State<CaptureListPage> createState() => _CaptureListPageState();
@@ -130,209 +96,195 @@ class _CaptureListPageState extends State<CaptureListPage> {
// ========== 状态 ==========
String _remark = '';
List<CapturePack> _data = [];
bool _refreshing = false;
bool _isDataEmpty = false;
bool _navigating = false; // 防止连续点击重复请求/跳转
List<String> _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<PagingResult<_CapturePackViewModel>> _loadPage(int pageKey) async {
final requestId = ++_requestId;
final expandedIds = _expandedPackIds.isNotEmpty
? [..._expandedPackIds]
: <String>[];
_isDataEmpty = _data.isEmpty;
}
/// 下拉刷新
Future<void> _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<CourseInfo> list,
List<String> 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<void> _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<String, String> 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<CaptureListPage> {
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<CaptureListPage> {
),
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<CaptureListPage> {
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<CaptureListPage> {
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<CaptureListPage> {
],
),
),
// 规则卡片(透明底 + 白边,置于渐变背景之上)
// 规则卡片
_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<CaptureListPage> {
// 子组件构建方法
// ============================================================
/// 攻克规则说明卡片(透明底 + 白色边框 + 阴影,对标 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<CaptureListPage> {
}
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<CaptureListPage> {
);
}
/// 空状态
Widget _buildEmpty() {
return Center(
child: Column(
@@ -564,8 +508,7 @@ class _CaptureListPageState extends State<CaptureListPage> {
);
}
/// 攻克项卡片
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<CaptureListPage> {
);
}
/// 攻克项头部信息
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<CaptureListPage> {
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<CaptureListPage> {
);
}
/// 右侧攻克标准百分比盒子
Widget _buildStandardBox(int standard) {
return Container(
width: 60.w,
@@ -686,37 +627,48 @@ class _CaptureListPageState extends State<CaptureListPage> {
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<CaptureListPage> {
);
}
/// 进度 + 按钮 + 详情展开区
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<CaptureListPage> {
);
}
Widget _buildProgressTop(CapturePack item) {
Widget _buildProgressTop(_CapturePackViewModel item) {
return Row(
children: [
Expanded(
@@ -765,12 +716,12 @@ class _CaptureListPageState extends State<CaptureListPage> {
);
}
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<CaptureListPage> {
);
}
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<CaptureListPage> {
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<CaptureListPage> {
);
}
/// 右侧按钮组(根据状态不同显示)
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<CaptureListPage> {
),
),
SizedBox(width: 8.w),
_buildOutlineButton('重新攻克', _primary, () => _navigateToCaptureInfo(item)),
_buildSolidButton('重新攻克', () => _navigateToCaptureInfo(item)),
],
);
case CapturePackStatus.judging:
@@ -849,7 +803,8 @@ class _CaptureListPageState extends State<CaptureListPage> {
}
}
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<CaptureListPage> {
);
}
/// 展开的历次攻克详情
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<CaptureListPage> {
),
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<CaptureListPage> {
);
}
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<CaptureListPage> {
),
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),
),
],
),
+32 -4
View File
@@ -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;
}