feat(conquerPackage, upload): 新增课前课中页面与多图上传组件,修复路由配置

- 实现攻克包模块的课前课中学习页面,包含学习计时、笔记上传、分页切换等完整逻辑
- 新增通用多图上传组件,支持编辑/查看两种业务模式
- 配置新页面的路由规则,并更新捕获列表页面的跳转逻辑为正式的go_router跳转
- 修复捕获列表页面跳转路径不匹配的问题
This commit is contained in:
chenshuainan
2026-09-11 09:37:37 +08:00
parent ee14373c6d
commit fe060ac974
11 changed files with 2230 additions and 20 deletions
@@ -0,0 +1,80 @@
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【课前课中模块接口】讲义列表 / 学习时长更新 / 提交报告
///
/// 对标 Vue 端 api/task/index.ts 中的相关接口:
///
/// getListByRecordHandout
/// —— 根据学习记录获取讲义列表
/// GET /api/v1/Handout/GetListByRecord/{recordId}
///
/// startOvercome
/// —— 开始/继续攻克学习包(返回 recordId 等)
/// POST /api/v1/StudyPack/StartOvercome/{packId}
///
/// renewHandoutTime
/// —— 更新讲义学习时长
/// POST /api/v1/Handout/RenewTime
///
/// getSubmitReport
/// —— 获取攻克报告(view 模式数据源)
/// GET /api/v1/StudyPack/GetReport/{recordId}
///
/// 注意:
/// - 页面只调这里,不直接碰 Dio;
/// - 异常不在 catch,交页面统一处理;
/// - view 模式下接口返回的 handouts 列表格式一致,统一走 HandoutListResponse。
/// ============================================================
class BeforeDuringClassApi {
BeforeDuringClassApi._();
static final BeforeDuringClassApi instance = BeforeDuringClassApi._();
/// 根据学习记录获取讲义列表 → GET /api/v1/Handout/GetListByRecord/{recordId}
Future<HandoutListResponse> getListByRecordHandout(String recordId) async {
final data = await DioClient.instance.get(
'/api/v1/Handout/GetListByRecord/$recordId',
);
return HandoutListResponse.fromJson(data);
}
/// 更新讲义学习时长 → POST /api/v1/Handout/RenewTime
///
/// [id] 讲义 id
/// [courseId] 课程 id
/// [startTime] 开始时间 ISO 字符串
/// [endTime] 结束时间 ISO 字符串
Future<void> renewHandoutTime({
required String id,
required String courseId,
required String startTime,
required String endTime,
}) async {
await DioClient.instance.post(
'/api/v1/Handout/RenewTime',
data: {
'id': id,
'courseId': courseId,
'startTime': startTime,
'endTime': endTime,
},
);
}
/// 获取攻克报告(view 模式) → GET /api/v1/StudyPack/GetReport/{recordId}
///
/// 返回结构中 handouts 字段对应讲义列表。
Future<HandoutListResponse> getSubmitReport(String recordId) async {
final data = await DioClient.instance.get(
'/api/v1/StudyPack/GetReport/$recordId',
);
// 报告里 handouts 可能在 data.handouts 下
if (data is Map<String, dynamic>) {
final payload = data['data'] as Map<String, dynamic>? ?? data;
return HandoutListResponse.fromJson(payload['handouts']);
}
return const HandoutListResponse();
}
}
@@ -0,0 +1,191 @@
/// ============================================================
/// 【课前课中模块模型】讲义列表 / 材料 / 笔记
///
/// 对标 Vue 端 api/task/type.ts 中的 Handout 相关类型。
/// 序列化:手动 fromJson,兼容 {code, message, data: {...}} 响应壳。
/// ============================================================
library;
import 'package:new_learn/core/api/public_upload/model.dart';
/// 讲义材料类型
enum HandoutMaterialType {
video(1), // 视频
audio(2), // 音频
text(3), // 文本
unknown(0);
final int value;
const HandoutMaterialType(this.value);
factory HandoutMaterialType.fromInt(int? v) =>
HandoutMaterialType.values.firstWhere(
(e) => e.value == v,
orElse: () => HandoutMaterialType.unknown,
);
}
/// 讲义材料项(视频/音频/文本)
class HandoutMaterial {
const HandoutMaterial({
this.id = '',
this.pageIndex = 0,
this.materialType = HandoutMaterialType.unknown,
this.materialContent = '',
});
final String id; // 材料 id
final int pageIndex; // 页码
final HandoutMaterialType materialType; // 材料类型
final String materialContent; // 内容(URL 或 HTML 文本)
factory HandoutMaterial.fromJson(Map<String, dynamic> json) {
return HandoutMaterial(
id: json['id']?.toString() ?? '',
pageIndex: _parseInt(json['pageIndex']),
materialType:
HandoutMaterialType.fromInt(_parseInt(json['materialType'])),
materialContent: json['materialContent'] as String? ?? '',
);
}
@override
String toString() {
return 'HandoutMaterial{id: $id, pageIndex: $pageIndex, '
'type: ${materialType.name}, content: '
'${materialContent.length > 50 ? '${materialContent.substring(0, 50)}...' : materialContent}}';
}
}
/// 单页讲义
class HandoutItem {
const HandoutItem({
this.id = '',
this.handoutPageId = '',
this.name = '',
this.title = '',
this.content = '',
this.planStudyTimeSecond = 0,
this.planStudyRemainTimeSecond = 0,
this.studyTimeSecond = 0,
this.isFinishPlanTime = false,
this.isUploadNotes = false,
this.materials = const [],
this.notes = const [],
});
final String id; // 讲义 id(用于 RenewTime / NoteAdd)
final String handoutPageId; // 讲义页 id
final String name; // 名称(页面顶部标题)
final String title; // 标题(NavBar 显示)
final String content; // 正文(富文本 HTML)
final int planStudyTimeSecond; // 建议学习时长(秒)
final int planStudyRemainTimeSecond; // 剩余学习时长(秒)
final int studyTimeSecond; // 已学习时长(秒)
final bool isFinishPlanTime; // 是否已完成建议时长
final bool isUploadNotes; // 是否必须上传笔记
final List<HandoutMaterial> materials; // 材料列表
final List<NoteImage> notes; // 笔记图片列表
/// 深拷贝(用于修改某字段后触发 setState)
HandoutItem copyWith({
String? id,
String? handoutPageId,
String? name,
String? title,
String? content,
int? planStudyTimeSecond,
int? planStudyRemainTimeSecond,
int? studyTimeSecond,
bool? isFinishPlanTime,
bool? isUploadNotes,
List<HandoutMaterial>? materials,
List<NoteImage>? notes,
}) {
return HandoutItem(
id: id ?? this.id,
handoutPageId: handoutPageId ?? this.handoutPageId,
name: name ?? this.name,
title: title ?? this.title,
content: content ?? this.content,
planStudyTimeSecond: planStudyTimeSecond ?? this.planStudyTimeSecond,
planStudyRemainTimeSecond:
planStudyRemainTimeSecond ?? this.planStudyRemainTimeSecond,
studyTimeSecond: studyTimeSecond ?? this.studyTimeSecond,
isFinishPlanTime: isFinishPlanTime ?? this.isFinishPlanTime,
isUploadNotes: isUploadNotes ?? this.isUploadNotes,
materials: materials ?? this.materials,
notes: notes ?? this.notes,
);
}
factory HandoutItem.fromJson(Map<String, dynamic> json) {
final materials = (json['materials'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(HandoutMaterial.fromJson)
.toList();
final notes = (json['notes'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(NoteImage.fromJson)
.toList();
return HandoutItem(
id: (json['id']?.toString()) ?? '',
handoutPageId: (json['handoutPageId']?.toString()) ?? '',
name: json['name'] as String? ?? json['title'] as String? ?? '',
title: json['title'] as String? ?? json['name'] as String? ?? '',
content: json['content'] as String? ?? '',
planStudyTimeSecond: _parseInt(json['planStudyTimeSecond']),
planStudyRemainTimeSecond: _parseInt(json['planStudyRemainTimeSecond']),
studyTimeSecond: _parseInt(json['studyTimeSecond']),
isFinishPlanTime:
json['ISFinishPlanTime'] as bool? ?? json['isFinishPlanTime'] as bool? ?? false,
isUploadNotes: json['isUploadNotes'] as bool? ?? false,
materials: materials,
notes: notes,
);
}
@override
String toString() {
return 'HandoutItem{id: $id, name: $name, planTime: $planStudyTimeSecond, '
'studyTime: $studyTimeSecond, isFinishPlanTime: $isFinishPlanTime, '
'isUploadNotes: $isUploadNotes, materials: ${materials.length}, '
'notes: ${notes.length}}';
}
}
/// 讲义列表响应(Handout 接口返回的列表)
class HandoutListResponse {
const HandoutListResponse({this.items = const []});
final List<HandoutItem> items;
/// 兼容响应壳 {code, message, data: [...]},也兼容直接返回数组
factory HandoutListResponse.fromJson(dynamic data) {
List<dynamic> list;
if (data is List) {
list = data;
} else if (data is Map<String, dynamic>) {
final payload = data['data'] ?? data['items'] ?? data['handouts'];
list = (payload is List) ? payload : const [];
} else {
list = const [];
}
return HandoutListResponse(
items: list
.whereType<Map<String, dynamic>>()
.map(HandoutItem.fromJson)
.toList(),
);
}
}
/// 安全解析 int
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;
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:new_learn/core/api/public_upload/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【笔记相关接口】讲义笔记 / 试题笔记
///
/// 对应 Vue 端 api/task/index.ts:
/// HandoutNoteAdd → POST /api/v1/HandoutNote/Add
/// HandoutNoteDel → DELETE /api/v1/HandoutNote/Del/{id}
/// QuestionNoteAdd → POST /api/v1/QuestionNote/Add
/// QuestionNoteDel → DELETE /api/v1/QuestionNote/Del/{id}
///
/// 注意:页面只调这里,不直接碰 Dio;异常不在这里 catch,交页面统一处理。
/// ============================================================
class NoteApi {
NoteApi._();
static final NoteApi instance = NoteApi._();
/// 添加讲义笔记 → POST /api/v1/HandoutNote/Add
///
/// [id] 讲义 id
/// [url] 原图 URL(OSS 直传后返回)
/// [thumbnailUrl] 缩略图 URL
Future<NoteImage> handoutNoteAdd({
required dynamic id,
required String url,
required String thumbnailUrl,
}) async {
final data = await DioClient.instance.post(
'/api/v1/HandoutNote/Add',
data: {
'id': id,
'url': url,
'thumbnailUrl': thumbnailUrl,
},
);
// 后端统一 {code, message, data} 包装:data 存在取 data,否则用顶层
final payload =
(data is Map<String, dynamic> && data.containsKey('data'))
? (data['data'] as Map<String, dynamic>?) ?? <String, dynamic>{}
: data as Map<String, dynamic>;
return NoteImage.fromJson(payload);
}
/// 删除讲义笔记 → DELETE /api/v1/HandoutNote/Del/{id}
///
/// 返回 true 表示删除成功
Future<bool> handoutNoteDel(dynamic id) async {
await DioClient.instance.delete('/api/v1/HandoutNote/Del/$id');
return true;
}
/// 添加试题笔记 → POST /api/v1/QuestionNote/Add
Future<NoteImage> questionNoteAdd({
required dynamic id,
required String url,
required String thumbnailUrl,
}) async {
final data = await DioClient.instance.post(
'/api/v1/QuestionNote/Add',
data: {
'id': id,
'url': url,
'thumbnailUrl': thumbnailUrl,
},
);
final payload =
(data is Map<String, dynamic> && data.containsKey('data'))
? (data['data'] as Map<String, dynamic>?) ?? <String, dynamic>{}
: data as Map<String, dynamic>;
return NoteImage.fromJson(payload);
}
/// 删除试题笔记 → DELETE /api/v1/QuestionNote/Del/{id}
Future<bool> questionNoteDel(dynamic id) async {
await DioClient.instance.delete('/api/v1/QuestionNote/Del/$id');
return true;
}
}
+40
View File
@@ -0,0 +1,40 @@
/// ============================================================
/// 【笔记图片数据模型】讲义笔记 / 试题笔记
///
/// NoteImage 对应 Vue 端上传笔记后返回的图片记录:
/// - id:笔记记录 id(删除接口用)
/// - url:原图 URL
/// - thumbnailUrl:缩略图 URL(列表预览用)
/// ============================================================
/// 笔记图片项(用于已上传图片列表)
class NoteImage {
const NoteImage({
this.id,
required this.url,
this.thumbnailUrl,
});
/// 服务器返回的笔记 id(删除接口用;本地添加的图片可能没有)
final dynamic id;
/// 图片原图 URL
final String url;
/// 缩略图 URL(为空时用 url 兜底)
final String? thumbnailUrl;
factory NoteImage.fromJson(Map<String, dynamic> json) {
return NoteImage(
id: json['id'],
url: (json['url'] as String?) ?? '',
thumbnailUrl: json['thumbnailUrl'] as String?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'thumbnailUrl': thumbnailUrl,
};
}