feat: 新增过渡页面路由、上传组件及完善跳转逻辑

- 为conquerPackage模块添加过渡页面路由并实现页面跳转逻辑
- 新增通用多图上传组件Upload,支持多种模式与图片操作功能
- 新增任务相关的API及Model基础文件
- 注释课前课中页面的测试试题内容
This commit is contained in:
chenshuainan
2026-09-14 09:09:25 +08:00
parent fe86b6d824
commit 847986f861
8 changed files with 853 additions and 4 deletions
@@ -22,6 +22,10 @@ import 'package:new_learn/core/network/dio_client.dart';
/// —— 获取攻克报告(view 模式数据源) /// —— 获取攻克报告(view 模式数据源)
/// GET /api/v1/StudyPack/GetReport/{recordId} /// GET /api/v1/StudyPack/GetReport/{recordId}
/// ///
/// getRecord
/// —— 获取攻克记录信息(过渡页使用)
/// GET /api/v1/StudyPack/GetRecord/{recordId}
///
/// 注意: /// 注意:
/// - 页面只调这里,不直接碰 Dio; /// - 页面只调这里,不直接碰 Dio;
/// - 异常不在 catch,交页面统一处理; /// - 异常不在 catch,交页面统一处理;
@@ -77,4 +81,17 @@ class BeforeDuringClassApi {
} }
return const HandoutListResponse(); 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 接口返回的列表) /// 讲义列表响应(Handout 接口返回的列表)
class HandoutListResponse { class HandoutListResponse {
const HandoutListResponse({this.items = const []}); const HandoutListResponse({this.items = const []});
+38 -2
View File
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; 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: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/audio_player_card.dart';
import 'package:new_learn/core/components/video_player_view.dart'; import 'package:new_learn/core/components/video_player_view.dart';
@@ -79,13 +81,47 @@ class RichTextContent extends StatelessWidget {
textStyle: textStyle, textStyle: textStyle,
onTapUrl: onTapUrl ?? _defaultOnTapUrl, onTapUrl: onTapUrl ?? _defaultOnTapUrl,
customStylesBuilder: customStylesBuilder, customStylesBuilder: customStylesBuilder,
customWidgetBuilder: _combinedCustomWidget, customWidgetBuilder: _combinedCustomWidget(context),
renderMode: renderMode, 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 // 内置:<audio> → 本项目 AudioPlayerCard
if (element.localName == 'audio') { if (element.localName == 'audio') {
final src = element.attributes['src']; final src = element.attributes['src'];
+7
View File
@@ -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/my/note/note_page.dart';
import 'package:new_learn/features/conquerPackage/captureList/index.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/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/my_point/my_point_page.dart';
import 'package:new_learn/features/my/question_list/question_list_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'; 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>?, 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()), GoRoute(path: '/register', builder: (_, _) => const RegisterPage()),
// app_router.dart 的 routes 里加,不用管跳转逻辑 // 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/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter_screenutil/flutter_screenutil.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/api.dart';
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart'; import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
import 'package:new_learn/core/api/public_upload/model.dart'; import 'package:new_learn/core/api/public_upload/model.dart';
@@ -176,6 +177,13 @@ class _BeforeDuringClassPageState extends State<BeforeDuringClassPage>
.getListByRecordHandout(_recordId); .getListByRecordHandout(_recordId);
} }
_pageList = List<HandoutItem>.from(resp.items); _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) { // _pageList = List<HandoutItem>.from(resp.items).map((item) {
// return item.copyWith( // return item.copyWith(
// isUploadNotes: true, // isUploadNotes: true,
@@ -480,8 +488,17 @@ class _BeforeDuringClassPageState extends State<BeforeDuringClassPage>
} }
void _goTransitionPage() { void _goTransitionPage() {
// TODO: 跳转到 transition 页,等路由配置好后替换 // 判断下一个状态:课前(1)→课中(2)→课后(3)
Navigator.of(context).pop(); // 此处简化:完成学习后默认进入课后练习过渡页(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,
),
),
),
),
),
],
),
),
),
),
);
},
);
}
}
+40
View File
@@ -675,6 +675,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" 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: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -1143,6 +1151,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1748,6 +1764,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.6.0" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -119,6 +119,7 @@ dependencies:
# 替换(customWidgetBuilder),video 留扩展点,图片/表格原生支持。 # 替换(customWidgetBuilder),video 留扩展点,图片/表格原生支持。
# 版本:0.17.x 要求 Flutter 3.32+ / just_audio ^0.10.4(本项目满足) # 版本:0.17.x 要求 Flutter 3.32+ / just_audio ^0.10.4(本项目满足)
flutter_widget_from_html_core: ^0.17.2 flutter_widget_from_html_core: ^0.17.2
flutter_svg: ^2.0.17
# 图片预览:社区成熟方案,支持双指缩放/拖拽/多图左右切换 # 图片预览:社区成熟方案,支持双指缩放/拖拽/多图左右切换
# 单图用 PhotoView,多图用 PhotoViewGallery; # 单图用 PhotoView,多图用 PhotoViewGallery;