feat: 新增过渡页面路由、上传组件及完善跳转逻辑
- 为conquerPackage模块添加过渡页面路由并实现页面跳转逻辑 - 新增通用多图上传组件Upload,支持多种模式与图片操作功能 - 新增任务相关的API及Model基础文件 - 注释课前课中页面的测试试题内容
This commit is contained in:
@@ -22,6 +22,10 @@ import 'package:new_learn/core/network/dio_client.dart';
|
||||
/// —— 获取攻克报告(view 模式数据源)
|
||||
/// GET /api/v1/StudyPack/GetReport/{recordId}
|
||||
///
|
||||
/// getRecord
|
||||
/// —— 获取攻克记录信息(过渡页使用)
|
||||
/// GET /api/v1/StudyPack/GetRecord/{recordId}
|
||||
///
|
||||
/// 注意:
|
||||
/// - 页面只调这里,不直接碰 Dio;
|
||||
/// - 异常不在 catch,交页面统一处理;
|
||||
@@ -77,4 +81,17 @@ class BeforeDuringClassApi {
|
||||
}
|
||||
return const HandoutListResponse();
|
||||
}
|
||||
|
||||
/// 获取攻克记录信息 → GET /api/v1/StudyPack/GetRecord/{recordId}
|
||||
///
|
||||
/// 过渡页使用:获取攻克包名称、攻克标准、是否有题目等。
|
||||
Future<OvercomeRecordInfo> getRecord(String recordId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/StudyPack/GetRecord/$recordId',
|
||||
);
|
||||
if (data is Map<String, dynamic>) {
|
||||
return OvercomeRecordInfo.fromJson(data);
|
||||
}
|
||||
return const OvercomeRecordInfo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,34 @@ class HandoutItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// 攻克记录信息(过渡页使用,对标 Vue GetRecordData 返回值)
|
||||
///
|
||||
/// 接口:GET /api/v1/StudyPack/GetRecord/{recordId}
|
||||
/// 用于过渡页(课前/课中/课后)展示攻克包名称、攻克标准、是否有题目等。
|
||||
class OvercomeRecordInfo {
|
||||
const OvercomeRecordInfo({
|
||||
this.id = '',
|
||||
this.name = '',
|
||||
this.overcomeStandard = 0,
|
||||
this.hasQuestion = false,
|
||||
});
|
||||
|
||||
final String id; // 记录 id
|
||||
final String name; // 攻克包名称
|
||||
final int overcomeStandard; // 攻克标准(百分比 0-100)
|
||||
final bool hasQuestion; // 是否有题目
|
||||
|
||||
factory OvercomeRecordInfo.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return OvercomeRecordInfo(
|
||||
id: payload['id']?.toString() ?? '',
|
||||
name: payload['name'] as String? ?? '',
|
||||
overcomeStandard: _parseInt(payload['overcomeStandard']),
|
||||
hasQuestion: payload['hasQuestion'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 讲义列表响应(Handout 接口返回的列表)
|
||||
class HandoutListResponse {
|
||||
const HandoutListResponse({this.items = const []});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
|
||||
import 'package:new_learn/core/components/audio_player_card.dart';
|
||||
import 'package:new_learn/core/components/video_player_view.dart';
|
||||
@@ -79,13 +81,47 @@ class RichTextContent extends StatelessWidget {
|
||||
textStyle: textStyle,
|
||||
onTapUrl: onTapUrl ?? _defaultOnTapUrl,
|
||||
customStylesBuilder: customStylesBuilder,
|
||||
customWidgetBuilder: _combinedCustomWidget,
|
||||
customWidgetBuilder: _combinedCustomWidget(context),
|
||||
renderMode: renderMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// 合并默认替换 + 用户自定义替换
|
||||
CustomWidgetBuilder get _combinedCustomWidget => (element) {
|
||||
CustomWidgetBuilder _combinedCustomWidget(BuildContext context) => (element) {
|
||||
// 内置:<img src="*.svg"> → flutter_svg 行内渲染
|
||||
// core 版 flutter_widget_from_html 不支持 SVG,手动处理
|
||||
if (element.localName == 'img') {
|
||||
final src = element.attributes['src'];
|
||||
if (src != null && src.toLowerCase().endsWith('.svg')) {
|
||||
final width = double.tryParse(element.attributes['width'] ?? '');
|
||||
final height = double.tryParse(element.attributes['height'] ?? '');
|
||||
final w = width != null ? width.w : null;
|
||||
final h = height != null ? height.h : null;
|
||||
// 超采样(SSAA):SVG 以 3 倍尺寸光栅化再缩回来,消除真机边缘锯齿
|
||||
const scale = 3.0;
|
||||
return InlineCustomWidget(
|
||||
child: SizedBox(
|
||||
width: w,
|
||||
height: h,
|
||||
child: OverflowBox(
|
||||
maxWidth: w != null ? w * scale : double.infinity,
|
||||
maxHeight: h != null ? h * scale : double.infinity,
|
||||
child: Transform.scale(
|
||||
scale: 1 / scale,
|
||||
alignment: Alignment.center,
|
||||
child: SvgPicture.network(
|
||||
src,
|
||||
width: w != null ? w * scale : null,
|
||||
height: h != null ? h * scale : null,
|
||||
fit: BoxFit.contain,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 内置:<audio> → 本项目 AudioPlayerCard
|
||||
if (element.localName == 'audio') {
|
||||
final src = element.attributes['src'];
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:new_learn/features/my/my_page.dart';
|
||||
import 'package:new_learn/features/my/note/note_page.dart';
|
||||
import 'package:new_learn/features/conquerPackage/captureList/index.dart';
|
||||
import 'package:new_learn/features/conquerPackage/beforeDuringClass/index.dart';
|
||||
import 'package:new_learn/features/conquerPackage/transitionPage/transition_page.dart';
|
||||
import 'package:new_learn/features/my/my_point/my_point_page.dart';
|
||||
import 'package:new_learn/features/my/question_list/question_list_page.dart';
|
||||
import 'package:new_learn/features/my/my_report/my_report_page.dart';
|
||||
@@ -210,6 +211,12 @@ final GoRouter appRouter = GoRouter(
|
||||
extra: state.extra as Map<String, dynamic>?,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/conquerPackage/transitionPage',
|
||||
builder: (_, state) => TransitionPage(
|
||||
extra: state.extra as Map<String, dynamic>?,
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/register', builder: (_, _) => const RegisterPage()),
|
||||
|
||||
// app_router.dart 的 routes 里加,不用管跳转逻辑
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/api.dart';
|
||||
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
|
||||
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||
@@ -176,6 +177,13 @@ class _BeforeDuringClassPageState extends State<BeforeDuringClassPage>
|
||||
.getListByRecordHandout(_recordId);
|
||||
}
|
||||
_pageList = List<HandoutItem>.from(resp.items);
|
||||
|
||||
// _pageList = List<HandoutItem>.from(resp.items).map((item) {
|
||||
// return item.copyWith(
|
||||
// content: item.content +
|
||||
// '<div class="hxdb-stem" name="hxdbStem"><div class="hxdb-stem-content" name="hxdbStem-content" data-hxdb-type="stem"><p>某兴趣小组利用 Pt1000 铂电阻测量温度。实验中没有电压表,现将电流表 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/878136bf67384b3ab5998cc6fc15bc32_0_398c9b77.svg" width="25" height="17" style="width: 25px; height: 17px; vertical-align: middle;"> 与已知电阻 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/5a406b64eafd47f3a8686bba4741c29a_1_b71473bc.svg" width="26" height="17" style="width: 26px; height: 17px; vertical-align: middle;"> 串联组成一条支路,将电流表 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/167759d79f984e45a0d48c31a3c6566e_2_fb3fd646.svg" width="26" height="17" style="width: 26px; height: 17px; vertical-align: middle;"> 与待测铂电阻 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/f769c572150d4ce782daa8935a9ceb83_3_b1a4eeff.svg" width="23" height="16" style="width: 23px; height: 16px; vertical-align: middle;"> 串联组成另一条支路,两支路并联。忽略两电流表内阻,<img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/d37447ecfe6440d1bd97e900cacf3b64_4_398c9b77.svg" width="25" height="17" style="width: 25px; height: 17px; vertical-align: middle;">、<img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/8a5d7731a3b744d193a514eeb253ca92_5_fb3fd646.svg" width="26" height="17" style="width: 26px; height: 17px; vertical-align: middle;"> 的示数分别为 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/9265c18c28934f61bc64bef449f1e641_6_6c3f86e2.svg" width="19" height="16" style="width: 19px; height: 16px; vertical-align: middle;">、<img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/b0fdd5ecc5a0477eb7a245d2c0fcf041_7_93189365.svg" width="20" height="17" style="width: 20px; height: 17px; vertical-align: middle;">。铂电阻阻值 <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/340415fadcf24d87a7ed79eb81a64c30_8_b1a4eeff.svg" width="23" height="16" style="width: 23px; height: 16px; vertical-align: middle;"> 的表达式为</p><p><br><img src="https://hx.cnzhyx.cn/K12/Question/png/2026-08-24/85cb7f0925a841c6b894ce351678be4f.png" class="preview-content" style="max-width:100%;"><br></p></div><table class="hxdb-option-table" name="hxdbOptionTable" style="width: 100%; table-layout: fixed; border-collapse: collapse;"><tbody><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">A.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/3094744dbf1644bb80aceae639e61c6a_9_7f8b9fc5.svg" width="92" height="40" style="width: 92px; height: 40px; vertical-align: middle;"></td></tr><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">B.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/96acf8a47eda4cdf81ef01707a28abff_10_fa759021.svg" width="92" height="40" style="width: 92px; height: 40px; vertical-align: middle;"></td></tr><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">C.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/aecc386b27d341e393bbc45ac86e9e20_11_7cd89d59.svg" width="144" height="42" style="width: 144px; height: 42px; vertical-align: middle;"></td></tr><tr class="hxdb-option-table-tr firstRow" name="hxdbOptionTableTr"><td class="hxdb-option-table-td" name="hxdbOptionTableTd" valign="top" style="word-break: break-all;"><span class="hxdb-option-label">D.</span> <img src="https://hx.cnzhyx.cn/K12LatexToSvg/ProductionV2/LatexSvg/2026-08-27/6c8a4a6004f04529b9d6b48b4d7560c3_12_1f4bbe90.svg" width="144" height="42" style="width: 144px; height: 42px; vertical-align: middle;"></td></tr></tbody></table></div>',
|
||||
// );
|
||||
// }).toList();
|
||||
// _pageList = List<HandoutItem>.from(resp.items).map((item) {
|
||||
// return item.copyWith(
|
||||
// isUploadNotes: true,
|
||||
@@ -480,8 +488,17 @@ class _BeforeDuringClassPageState extends State<BeforeDuringClassPage>
|
||||
}
|
||||
|
||||
void _goTransitionPage() {
|
||||
// TODO: 跳转到 transition 页,等路由配置好后替换
|
||||
Navigator.of(context).pop();
|
||||
// 判断下一个状态:课前(1)→课中(2)→课后(3)
|
||||
// 此处简化:完成学习后默认进入课后练习过渡页(status=3)
|
||||
context.push(
|
||||
'/conquerPackage/transitionPage',
|
||||
extra: {
|
||||
'courseId': _courseId,
|
||||
'packId': _packId,
|
||||
'recordId': _recordId,
|
||||
'status': '3',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== 笔记变化 =====================
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/api.dart';
|
||||
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【TransitionPage】课前/课中/课后 过渡页面
|
||||
/// 对标 Vue pages/conquerPackage/transitionPage/transitionPage.vue
|
||||
///
|
||||
/// 一、三种状态:
|
||||
/// status = 1 → 课前讲义(带滑动蒙层引导)
|
||||
/// status = 2 → 课中视频
|
||||
/// status = 3 → 课后练习(含提示卡片 + 攻克标准)
|
||||
///
|
||||
/// 二、路由参数(通过 extra 传入):
|
||||
/// - courseId 课程 id
|
||||
/// - packId 攻克包 id
|
||||
/// - recordId 学习记录 id
|
||||
/// - status 状态:1课前 / 2课中 / 3课后
|
||||
///
|
||||
/// 三、尺寸换算(本工程统一):
|
||||
/// Vue 的 N rpx → Flutter 的 (N/2).w/.h/.sp/.r
|
||||
///
|
||||
/// 四、跳转:
|
||||
/// status 1/2 → 跳 beforeDuringClass 页
|
||||
/// status 3 → 跳 classExercises 页
|
||||
/// ============================================================
|
||||
|
||||
/// 过渡页状态
|
||||
enum TransitionStatus {
|
||||
beforeClass(1), // 课前讲义
|
||||
duringClass(2), // 课中视频
|
||||
afterClass(3); // 课后练习
|
||||
|
||||
final int value;
|
||||
const TransitionStatus(this.value);
|
||||
|
||||
factory TransitionStatus.fromInt(dynamic v) {
|
||||
final n = int.tryParse(v?.toString() ?? '') ?? 1;
|
||||
return TransitionStatus.values.firstWhere(
|
||||
(e) => e.value == n,
|
||||
orElse: () => TransitionStatus.beforeClass,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TransitionPage extends StatefulWidget {
|
||||
const TransitionPage({super.key, this.extra});
|
||||
|
||||
final Map<String, dynamic>? extra;
|
||||
|
||||
@override
|
||||
State<TransitionPage> createState() => _TransitionPageState();
|
||||
}
|
||||
|
||||
class _TransitionPageState extends State<TransitionPage> {
|
||||
// ========== 颜色常量 ==========
|
||||
static const Color _bgColor = Color(0xFFF5F8FA);
|
||||
static const Color _titleColor = Color(0xFF1D2B47);
|
||||
static const Color _subtitleColor = Color(0xFF99A4BA);
|
||||
static const Color _primary = Color(0xFF3D7DF5);
|
||||
static const Color _labelBg = Color(0xFFE3ECFF);
|
||||
|
||||
// ========== 路由参数 ==========
|
||||
late String _courseId;
|
||||
late String _packId;
|
||||
late String _recordId;
|
||||
late TransitionStatus _status;
|
||||
|
||||
// ========== 数据 ==========
|
||||
OvercomeRecordInfo _overcomeInfo = const OvercomeRecordInfo();
|
||||
bool _loading = true;
|
||||
bool _showMask = true; // 课前蒙层是否显示
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_parseExtra();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadData();
|
||||
});
|
||||
}
|
||||
|
||||
// ===================== 初始化 =====================
|
||||
|
||||
void _parseExtra() {
|
||||
final extra = widget.extra ?? const <String, dynamic>{};
|
||||
_courseId = (extra['courseId']?.toString()) ?? '';
|
||||
_packId = (extra['packId']?.toString()) ?? '';
|
||||
_recordId = (extra['recordId']?.toString()) ?? '';
|
||||
_status = TransitionStatus.fromInt(extra['status']);
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
if (_recordId.isEmpty) {
|
||||
throw Exception('recordId 无效');
|
||||
}
|
||||
final info = await BeforeDuringClassApi.instance.getRecord(_recordId);
|
||||
if (!mounted) return;
|
||||
setState(() => _overcomeInfo = info);
|
||||
} catch (e) {
|
||||
debugPrint('[TransitionPage] 加载失败: $e');
|
||||
// 失败时回退到攻克列表页
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('获取攻克信息失败:${e.toString()}'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
context.go('/conquerPackage/captureList', extra: _courseId);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 点击开始学习 =====================
|
||||
|
||||
void _startLearning() {
|
||||
if (_status == TransitionStatus.beforeClass ||
|
||||
_status == TransitionStatus.duringClass) {
|
||||
context.push(
|
||||
'/conquerPackage/beforeDuringClass',
|
||||
extra: {
|
||||
'status': '${_status.value}',
|
||||
'courseId': _courseId,
|
||||
'packId': _packId,
|
||||
'recordId': _recordId,
|
||||
},
|
||||
);
|
||||
} else if (_status == TransitionStatus.afterClass) {
|
||||
if (_overcomeInfo.hasQuestion) {
|
||||
// TODO(路由): classExercises 页建好后替换
|
||||
// context.push('/conquerPackage/classExercises', extra: {
|
||||
// 'courseId': _courseId,
|
||||
// 'packId': _packId,
|
||||
// 'recordId': _recordId,
|
||||
// });
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('习题页待开发'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Build =====================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _bgColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildNavBar(),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildBody(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 课前引导蒙层
|
||||
if (_status == TransitionStatus.beforeClass && _showMask)
|
||||
_SlideMask(
|
||||
visible: _showMask,
|
||||
onClose: () => setState(() => _showMask = false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== NavBar =====================
|
||||
|
||||
Widget _buildNavBar() {
|
||||
return SizedBox(
|
||||
height: 44.h,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 16.w,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IconButton(
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== Body =====================
|
||||
|
||||
Widget _buildBody() {
|
||||
switch (_status) {
|
||||
case TransitionStatus.beforeClass:
|
||||
return _buildBeforeClass();
|
||||
case TransitionStatus.duringClass:
|
||||
return _buildDuringClass();
|
||||
case TransitionStatus.afterClass:
|
||||
return _buildAfterClass();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 课前讲义 ----------
|
||||
|
||||
Widget _buildBeforeClass() {
|
||||
return Column(
|
||||
children: [
|
||||
// 背景插图
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-04-01/before-class-bac.png',
|
||||
),
|
||||
width: 170.w,
|
||||
height: 149.h,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, __) => SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => SizedBox(
|
||||
width: 170.w,
|
||||
height: 149.h,
|
||||
child: const Icon(Icons.menu_book_outlined,
|
||||
size: 80, color: _primary),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 50.h),
|
||||
// 标签
|
||||
_buildLabel('课前讲义'),
|
||||
SizedBox(height: 12.h),
|
||||
// 标题
|
||||
_buildTitle(
|
||||
name: _overcomeInfo.name,
|
||||
subtitle: '知识点讲义学习',
|
||||
),
|
||||
const Spacer(),
|
||||
// 开始学习按钮
|
||||
_buildStartButton('开始学习'),
|
||||
SizedBox(height: 46.h),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 课中视频 ----------
|
||||
|
||||
Widget _buildDuringClass() {
|
||||
return Column(
|
||||
children: [
|
||||
// 背景插图
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-04-01/during-class-bac.png',
|
||||
),
|
||||
width: 186.w,
|
||||
height: 120.h,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, __) => SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => SizedBox(
|
||||
width: 186.w,
|
||||
height: 120.h,
|
||||
child:
|
||||
const Icon(Icons.play_circle_outline, size: 80, color: _primary),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 42.h),
|
||||
_buildLabel('课中视频'),
|
||||
SizedBox(height: 12.h),
|
||||
_buildTitle(
|
||||
name: _overcomeInfo.name,
|
||||
subtitle: '知识点视频学习',
|
||||
),
|
||||
const Spacer(),
|
||||
_buildStartButton('开始学习'),
|
||||
SizedBox(height: 46.h),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 课后练习 ----------
|
||||
|
||||
Widget _buildAfterClass() {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// 背景插图
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-04-01/after-class-bac.png',
|
||||
),
|
||||
width: 170.w,
|
||||
height: 170.h,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, __) => SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => SizedBox(
|
||||
width: 170.w,
|
||||
height: 170.h,
|
||||
child:
|
||||
const Icon(Icons.assignment_outlined, size: 80, color: _primary),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 25.h),
|
||||
_buildLabel('课后练习'),
|
||||
SizedBox(height: 12.h),
|
||||
if (_overcomeInfo.name.isNotEmpty)
|
||||
_buildTitle(
|
||||
name: _overcomeInfo.name,
|
||||
subtitle: '知识点题目攻克',
|
||||
padding: EdgeInsets.symmetric(horizontal: 5.w),
|
||||
),
|
||||
SizedBox(height: 28.h),
|
||||
// 提示卡片
|
||||
_buildAfterTipsCard(),
|
||||
SizedBox(height: 28.h),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== 公共组件 =====================
|
||||
|
||||
Widget _buildLabel(String text) {
|
||||
return Container(
|
||||
width: 60.w,
|
||||
height: 20.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _labelBg,
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: _primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle({
|
||||
required String name,
|
||||
required String subtitle,
|
||||
EdgeInsetsGeometry? padding,
|
||||
}) {
|
||||
return Padding(
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
name.isNotEmpty ? '《$name》' : '',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _titleColor,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _titleColor,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStartButton(String text) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 47.h,
|
||||
child: FilledButton(
|
||||
onPressed: _startLearning,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
child: Text(text),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== 课后提示卡片 =====================
|
||||
|
||||
Widget _buildAfterTipsCard() {
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: EdgeInsets.symmetric(horizontal: 14.w),
|
||||
constraints: BoxConstraints(maxWidth: 347.w),
|
||||
width: double.infinity,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 卡片实际宽度(平板上被 maxWidth 限制,手机上 = 屏宽 - 左右边距)
|
||||
final cardW = constraints.maxWidth;
|
||||
// 设计稿卡片宽度 = 750rpx - 28rpx*2 = 694rpx = 347dp
|
||||
// 内部所有水平/垂直定位都按卡片宽度比例换算,
|
||||
// 保证背景图(fitWidth)和内部定位永远同步
|
||||
double wRatio(double designPx) => cardW * designPx / 347.0;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF2F7FF),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
ImgUrl.build(
|
||||
'/StaticResource/2026-04-01/after-class-tips.png',
|
||||
),
|
||||
),
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
wRatio(14),
|
||||
wRatio(129), // 背景图随宽度缩放,padding-top 也按卡片宽比例
|
||||
wRatio(14),
|
||||
14.h,
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// 攻克标准百分比(按卡片宽度比例定位,与背景图缩放同步)
|
||||
Positioned(
|
||||
right: wRatio(75.5),
|
||||
top: -wRatio(64),
|
||||
child: Text(
|
||||
'${_overcomeInfo.overcomeStandard}%',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTipItem('包含单选、多选、判断、填空全题型'),
|
||||
SizedBox(height: 4.h),
|
||||
_buildTipItem('每题展示深度解析与解题逻辑'),
|
||||
SizedBox(height: 15.h),
|
||||
// 正式进入攻克按钮
|
||||
Opacity(
|
||||
opacity: _overcomeInfo.hasQuestion ? 1.0 : 0.6,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 47.h,
|
||||
child: FilledButton(
|
||||
onPressed: _overcomeInfo.hasQuestion
|
||||
? _startLearning
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _primary,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: _primary,
|
||||
disabledForegroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20.r),
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
child: const Text('正式进入攻克'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTipItem(String text) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: 24.w, bottom: 4.h),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 4.w,
|
||||
height: 4.w,
|
||||
margin: EdgeInsets.only(right: 13.w),
|
||||
decoration: const BoxDecoration(
|
||||
color: _primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: _subtitleColor,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【_SlideMask】滑动蒙层引导组件
|
||||
/// 对标 Vue pages/conquerPackage/transitionPage/slide-mask.vue
|
||||
///
|
||||
/// 功能:
|
||||
/// - 半透明黑色蒙层背景
|
||||
/// - 显示引导图
|
||||
/// - 向左滑动超过阈值 → 整个蒙层滑出屏幕并触发 onClose
|
||||
/// - 滑动不足阈值 → 回弹
|
||||
/// ============================================================
|
||||
class _SlideMask extends StatefulWidget {
|
||||
const _SlideMask({
|
||||
required this.visible,
|
||||
required this.onClose,
|
||||
this.slideThreshold = 80.0,
|
||||
});
|
||||
|
||||
final bool visible;
|
||||
final VoidCallback onClose;
|
||||
final double slideThreshold;
|
||||
|
||||
@override
|
||||
State<_SlideMask> createState() => _SlideMaskState();
|
||||
}
|
||||
|
||||
class _SlideMaskState extends State<_SlideMask>
|
||||
with SingleTickerProviderStateMixin {
|
||||
double _offsetX = 0;
|
||||
double _startX = 0;
|
||||
bool _isSliding = false;
|
||||
bool _isAnimatingOut = false;
|
||||
late final AnimationController _slideOutController;
|
||||
late Animation<double> _slideOutAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_slideOutController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
_slideOutAnim = Tween<double>(begin: 0, end: 0).animate(
|
||||
CurvedAnimation(parent: _slideOutController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_slideOutController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _SlideMask oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.visible && !oldWidget.visible) {
|
||||
_offsetX = 0;
|
||||
_isAnimatingOut = false;
|
||||
_slideOutController.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTouchStart(DragStartDetails details) {
|
||||
if (!widget.visible || _isAnimatingOut) return;
|
||||
_startX = details.globalPosition.dx;
|
||||
_isSliding = true;
|
||||
}
|
||||
|
||||
void _handleTouchUpdate(DragUpdateDetails details) {
|
||||
if (!widget.visible || !_isSliding || _isAnimatingOut) return;
|
||||
final moveX = details.globalPosition.dx;
|
||||
final diffX = _startX - moveX;
|
||||
// 仅处理向左滑动
|
||||
if (diffX > 0) {
|
||||
setState(() => _offsetX = -diffX);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTouchEnd(DragEndDetails details) {
|
||||
if (!widget.visible || !_isSliding) return;
|
||||
_isSliding = false;
|
||||
|
||||
if (_offsetX.abs() >= widget.slideThreshold) {
|
||||
// 超过阈值 → 滑出动画
|
||||
_startSlideOut();
|
||||
} else {
|
||||
// 回弹
|
||||
setState(() => _offsetX = 0);
|
||||
}
|
||||
}
|
||||
|
||||
void _startSlideOut() {
|
||||
_isAnimatingOut = true;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
_slideOutAnim = Tween<double>(begin: _offsetX, end: -screenWidth).animate(
|
||||
CurvedAnimation(parent: _slideOutController, curve: Curves.easeInOut),
|
||||
);
|
||||
_slideOutController.forward().then((_) {
|
||||
widget.onClose();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.visible) return const SizedBox.shrink();
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _slideOutAnim,
|
||||
builder: (context, child) {
|
||||
final dx = _isAnimatingOut ? _slideOutAnim.value : _offsetX;
|
||||
return Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onHorizontalDragStart: _handleTouchStart,
|
||||
onHorizontalDragUpdate: _handleTouchUpdate,
|
||||
onHorizontalDragEnd: _handleTouchEnd,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Transform.translate(
|
||||
offset: Offset(dx, 0),
|
||||
child: Container(
|
||||
color: const Color(0x8C000000), // 55% 透明黑
|
||||
child: Stack(
|
||||
children: [
|
||||
// 引导图
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 187.h,
|
||||
child: Center(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-04-01/guidance.png',
|
||||
),
|
||||
width: 244.5.w,
|
||||
height: 122.5.h,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, __) => SizedBox.shrink(),
|
||||
errorWidget: (_, __, ___) => SizedBox(
|
||||
width: 244.5.w,
|
||||
height: 122.5.h,
|
||||
child: const Icon(
|
||||
Icons.swipe_left,
|
||||
color: Colors.white,
|
||||
size: 60,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -675,6 +675,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_svg
|
||||
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -1143,6 +1151,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1748,6 +1764,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.0"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics
|
||||
sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
vector_graphics_codec:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_codec
|
||||
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.13"
|
||||
vector_graphics_compiler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_graphics_compiler
|
||||
sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -119,6 +119,7 @@ dependencies:
|
||||
# 替换(customWidgetBuilder),video 留扩展点,图片/表格原生支持。
|
||||
# 版本:0.17.x 要求 Flutter 3.32+ / just_audio ^0.10.4(本项目满足)
|
||||
flutter_widget_from_html_core: ^0.17.2
|
||||
flutter_svg: ^2.0.17
|
||||
|
||||
# 图片预览:社区成熟方案,支持双指缩放/拖拽/多图左右切换
|
||||
# 单图用 PhotoView,多图用 PhotoViewGallery;
|
||||
|
||||
Reference in New Issue
Block a user