更新
This commit is contained in:
@@ -20,8 +20,7 @@ import 'package:new_learn/core/storage/user_storage.dart';
|
||||
/// updateUserAvatar → 更改用户头像
|
||||
///
|
||||
/// 二、后续继续写(对齐 Vue,用到再加):
|
||||
/// checkStudyPackStatus / setChangeUserOnline
|
||||
/// getUserPointLevel / UpdateGuideStatus / UpdateReportStatus
|
||||
/// checkStudyPackStatus / setChangeUserOnline / getUserPointLevel
|
||||
///
|
||||
/// 三、高频坑:
|
||||
/// 1. 登录密码 MD5 加密(对齐 Vue stringToMd5),提交前在 Api 层完成;
|
||||
@@ -157,6 +156,15 @@ class UserApi {
|
||||
await DioClient.instance.post('/api/v1/User/ChangeShowAfterReport');
|
||||
}
|
||||
|
||||
/// 更新攻克报告引导状态(报告页引导只出现一次,默认 false,触发后变 true)
|
||||
/// → POST /api/v1/User/ChangeShowGuide
|
||||
///
|
||||
/// 对齐 Vue api/user/index.ts 的 UpdateGuideStatus(),
|
||||
/// 在攻克报告页 GuideModal 关闭时调用。
|
||||
static Future<void> changeShowGuide() async {
|
||||
await DioClient.instance.post('/api/v1/User/ChangeShowGuide');
|
||||
}
|
||||
|
||||
/// MD5 加密(UniApp stringToMd5 的等价实现)
|
||||
static String _md5(String text) {
|
||||
return md5.convert(utf8.encode(text)).toString();
|
||||
|
||||
@@ -16,6 +16,7 @@ class CourseRecord {
|
||||
this.accuracyRate = 0,
|
||||
this.status = 0,
|
||||
this.statusName = '',
|
||||
this.overcomeTimeSecond = 0,
|
||||
});
|
||||
|
||||
final int id; // 记录 id
|
||||
@@ -23,6 +24,7 @@ class CourseRecord {
|
||||
final int accuracyRate; // 正确率(0-100)
|
||||
final int status; // 状态:0 未开始 / 1 进行中 / 2 成功 / 3 失败 等(按后端枚举)
|
||||
final String statusName; // 状态名称
|
||||
final int overcomeTimeSecond; // 攻克用时(秒)
|
||||
|
||||
factory CourseRecord.fromJson(Map<String, dynamic> json) {
|
||||
return CourseRecord(
|
||||
@@ -31,6 +33,7 @@ class CourseRecord {
|
||||
accuracyRate: _parseInt(json['accuracyRate']),
|
||||
status: _parseInt(json['status']),
|
||||
statusName: json['statusName'] as String? ?? '',
|
||||
overcomeTimeSecond: _parseInt(json['overcomeTimeSecond']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:new_learn/core/api/conquerPackage/classExercises/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【课堂习题模块接口】
|
||||
///
|
||||
/// 对标 Vue 端 api/task/index.ts、api/userCollect/index.ts:
|
||||
///
|
||||
/// GetRecordData GET /api/v1/StudyPack/GetRecord/{recordId}
|
||||
/// GetListByRecordQuestion GET /api/v1/Question/GetListByRecord/{recordId}
|
||||
/// QuestionRenewTime POST /api/v1/Question/RenewTime
|
||||
/// PushAIGrading POST /api/v1/Question/PushAIGrading
|
||||
/// RecordSubmit POST /api/v1/StudyPack/RecordSubmit
|
||||
/// GetSubmitReport GET /api/v1/StudyPack/GetReport/{packId}(60s 超时)
|
||||
/// AddUserCollect POST /api/v1/UserCollect/AddUserCollect
|
||||
/// CancleCollect POST /api/v1/UserCollect/CancleCollect
|
||||
/// Feedback POST /api/v1/Question/Feedback
|
||||
///
|
||||
/// 说明:
|
||||
/// - RenewTime / PushAIGrading / RecordSubmit 不脱壳,返回原始 data,
|
||||
/// 由提交控制器读取 code / message / aiGradingCount / id;
|
||||
/// - 异常统一抛 ApiException,交页面/控制器处理。
|
||||
/// ============================================================
|
||||
class ClassExercisesApi {
|
||||
ClassExercisesApi._();
|
||||
|
||||
static final ClassExercisesApi instance = ClassExercisesApi._();
|
||||
|
||||
/// 获取学习记录信息(edit 模式 onLoad 第一步)
|
||||
Future<ClassExercisesRecord> getRecordData(String recordId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/StudyPack/GetRecord/$recordId',
|
||||
);
|
||||
return ClassExercisesRecord.fromJson(data);
|
||||
}
|
||||
|
||||
/// 根据学习记录获取题目列表
|
||||
Future<ClassExercisesQuestionList> getListByRecordQuestion(
|
||||
String recordId,
|
||||
) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Question/GetListByRecord/$recordId',
|
||||
);
|
||||
return ClassExercisesQuestionList.fromJson(data);
|
||||
}
|
||||
|
||||
/// 提交单题作答 / 更新作答时长
|
||||
/// body: {id, childId, courseId, userAnswer, startTime, endTime,
|
||||
/// answerStatus, index?}
|
||||
/// 返回原始响应(提交失败重试队列会读 requestParams/data 兜底)
|
||||
Future<dynamic> questionRenewTime(Map<String, dynamic> body) {
|
||||
return DioClient.instance.post('/api/v1/Question/RenewTime', data: body);
|
||||
}
|
||||
|
||||
/// 提交 AI 图片批改
|
||||
/// body: {id, childId, courseId, startTime, endTime,
|
||||
/// answerImgs:[{url,thumbnailUrl}]}
|
||||
Future<dynamic> pushAIGrading(Map<String, dynamic> body) {
|
||||
return DioClient.instance.post(
|
||||
'/api/v1/Question/PushAIGrading',
|
||||
data: body,
|
||||
);
|
||||
}
|
||||
|
||||
/// 交卷
|
||||
/// body: {recordId};返回 {code,message,aiGradingCount}
|
||||
Future<dynamic> recordSubmit(String recordId) {
|
||||
return DioClient.instance.post(
|
||||
'/api/v1/StudyPack/RecordSubmit',
|
||||
data: {'recordId': recordId},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取攻克报告(view 模式数据源,题目批改可能耗时,超时 60s)
|
||||
Future<ClassExercisesReport> getSubmitReport(String packId) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/StudyPack/GetReport/$packId',
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
);
|
||||
return ClassExercisesReport.fromJson(data);
|
||||
}
|
||||
|
||||
/// 添加收藏 / 错题本
|
||||
/// body: {type, packId, questionId, recordQuestionId}
|
||||
/// 返回原始响应(成功时 res.id 为收藏记录 id)
|
||||
Future<dynamic> addUserCollect(Map<String, dynamic> body) {
|
||||
return DioClient.instance.post(
|
||||
'/api/v1/UserCollect/AddUserCollect',
|
||||
data: body,
|
||||
);
|
||||
}
|
||||
|
||||
/// 取消收藏 / 移出错题本
|
||||
/// body: {type, id}
|
||||
Future<dynamic> cancleCollect(Map<String, dynamic> body) {
|
||||
return DioClient.instance.post(
|
||||
'/api/v1/UserCollect/CancleCollect',
|
||||
data: body,
|
||||
);
|
||||
}
|
||||
|
||||
/// 题目反馈
|
||||
/// body: {id, questionId, feedbackTypes, description, images}
|
||||
Future<dynamic> feedback(Map<String, dynamic> body) {
|
||||
return DioClient.instance.post('/api/v1/Question/Feedback', data: body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/// ============================================================
|
||||
/// 【课堂习题模块模型】接口响应壳解析
|
||||
///
|
||||
/// 对标 Vue 端:
|
||||
/// api/task/index.ts → GetRecordData / GetListByRecordQuestion
|
||||
/// / GetSubmitReport / RecordSubmit
|
||||
/// api/question/index.ts → RenewTime / PushAIGrading / Feedback
|
||||
///
|
||||
/// 说明:
|
||||
/// 题目字段非常多且解析器(questionParser)会动态增删字段,
|
||||
/// 题目列表统一以 `Map<String, dynamic>` 承载,业务侧再包 QuestionModel,
|
||||
/// 全量保留后端原字段名,不做字段裁剪。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 从响应中取业务 payload(兼容 {code,message,data:{...}} 脱壳)
|
||||
Map<String, dynamic> unwrapData(dynamic resp) {
|
||||
if (resp is Map<String, dynamic>) {
|
||||
final data = resp['data'];
|
||||
if (data is Map<String, dynamic>) return data;
|
||||
return resp;
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
/// GET StudyPack/GetRecord/{recordId} 响应
|
||||
class ClassExercisesRecord {
|
||||
const ClassExercisesRecord({
|
||||
this.recordId = '',
|
||||
this.name = '',
|
||||
this.baseSubjectId,
|
||||
this.raw = const {},
|
||||
});
|
||||
|
||||
/// 学习记录 id
|
||||
final String recordId;
|
||||
|
||||
/// 攻克包名称(用于 NavBar 标题:`${name}专项智能攻克`)
|
||||
final String name;
|
||||
|
||||
/// 学科 id(决定 iSFilllankItemUpload:0 输入框 / 1 图片作答)
|
||||
final int? baseSubjectId;
|
||||
|
||||
final Map<String, dynamic> raw;
|
||||
|
||||
factory ClassExercisesRecord.fromJson(dynamic json) {
|
||||
final data = unwrapData(json);
|
||||
return ClassExercisesRecord(
|
||||
recordId:
|
||||
data['recordId']?.toString() ?? data['id']?.toString() ?? '',
|
||||
name: data['name']?.toString() ?? '',
|
||||
baseSubjectId:
|
||||
int.tryParse('${data['baseSubjectId'] ?? ''}'),
|
||||
raw: data,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET Question/GetListByRecord/{recordId} 响应
|
||||
class ClassExercisesQuestionList {
|
||||
const ClassExercisesQuestionList({
|
||||
this.items = const [],
|
||||
this.raw = const {},
|
||||
});
|
||||
|
||||
/// 原始题目列表(字段全量保留,交给 parseQuestionData 处理)
|
||||
final List<Map<String, dynamic>> items;
|
||||
|
||||
final Map<String, dynamic> raw;
|
||||
|
||||
factory ClassExercisesQuestionList.fromJson(dynamic json) {
|
||||
final data = unwrapData(json);
|
||||
final rawList = data['questions'] ?? data['list'] ?? data['data'];
|
||||
final list = <Map<String, dynamic>>[];
|
||||
if (rawList is List) {
|
||||
for (final item in rawList) {
|
||||
if (item is Map) {
|
||||
list.add(Map<String, dynamic>.from(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
return ClassExercisesQuestionList(items: list, raw: data);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET StudyPack/GetReport/{packId} 响应(view 模式数据源)
|
||||
class ClassExercisesReport {
|
||||
const ClassExercisesReport({
|
||||
this.id = '',
|
||||
this.name = '',
|
||||
this.baseSubjectId,
|
||||
this.questions = const [],
|
||||
this.raw = const {},
|
||||
});
|
||||
|
||||
/// 报告/记录 id(view 模式下作为 recordId 使用)
|
||||
final String id;
|
||||
|
||||
final String name;
|
||||
|
||||
final int? baseSubjectId;
|
||||
|
||||
/// 报告内题目列表(字段全量保留)
|
||||
final List<Map<String, dynamic>> questions;
|
||||
|
||||
final Map<String, dynamic> raw;
|
||||
|
||||
factory ClassExercisesReport.fromJson(dynamic json) {
|
||||
final data = unwrapData(json);
|
||||
final rawList = data['questions'];
|
||||
final list = <Map<String, dynamic>>[];
|
||||
if (rawList is List) {
|
||||
for (final item in rawList) {
|
||||
if (item is Map) {
|
||||
list.add(Map<String, dynamic>.from(item));
|
||||
}
|
||||
}
|
||||
}
|
||||
return ClassExercisesReport(
|
||||
id: data['id']?.toString() ?? data['recordId']?.toString() ?? '',
|
||||
name: data['name']?.toString() ?? '',
|
||||
baseSubjectId: int.tryParse('${data['baseSubjectId'] ?? ''}'),
|
||||
questions: list,
|
||||
raw: data,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'media_pause_scope.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AudioPlayerCard】通用音频播放控制条(在线音频)
|
||||
@@ -43,7 +44,7 @@ import 'package:just_audio/just_audio.dart';
|
||||
/// 不支持(返回 200 全量)→ ExoPlayer 无法字节定位,只能从头重新
|
||||
/// 下载,表现就是"点中间段位置被重置回起点"(且第二次打开有系统
|
||||
/// 缓存时会看似正常,极易误判)。验收新音频源:
|
||||
/// curl -s -D - -o NUL -H "Range: bytes=0-1023" <url> 看状态码。
|
||||
/// curl -s -D - -o NUL -H "Range: bytes=0-1023" `<url>` 看状态码。
|
||||
/// ============================================================
|
||||
class AudioPlayerCard extends StatefulWidget {
|
||||
const AudioPlayerCard({
|
||||
@@ -88,10 +89,14 @@ class AudioPlayerCard extends StatefulWidget {
|
||||
}
|
||||
|
||||
class AudioPlayerCardState extends State<AudioPlayerCard>
|
||||
with WidgetsBindingObserver {
|
||||
with WidgetsBindingObserver
|
||||
implements MediaPausable {
|
||||
/// 底层播放器(just_audio)
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
|
||||
/// 所属媒体暂停作用域(随挂载位置自动注册/注销)
|
||||
MediaPauseRegistry? _pauseScope;
|
||||
|
||||
/// 是否正在加载(setUrl 未完成)
|
||||
bool _loading = true;
|
||||
|
||||
@@ -115,15 +120,29 @@ class AudioPlayerCardState extends State<AudioPlayerCard>
|
||||
_initPlayer();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// 自动挂到最近的 MediaPauseScope(含 fwfh 富文本内部的播放器)
|
||||
final scope = MediaPauseScope.of(context);
|
||||
if (scope != _pauseScope) {
|
||||
_pauseScope?.unregister(this);
|
||||
_pauseScope = scope;
|
||||
_pauseScope?.register(this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pauseScope?.unregister(this);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// 必须释放播放器,否则音频解码资源泄漏
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停音频)
|
||||
/// 暂停播放(切页时由 MediaPauseRegistry 统一调用)
|
||||
@override
|
||||
void pause() {
|
||||
_player.pause();
|
||||
}
|
||||
@@ -403,19 +422,11 @@ class AudioPlayerCardState extends State<AudioPlayerCard>
|
||||
);
|
||||
}
|
||||
|
||||
/// 标题区:标题 + 地址/缓冲提示小字
|
||||
/// 标题区:标题 + 缓冲提示小字(不展示音频源地址)
|
||||
Widget _buildTitle({required bool buffering, required int percent}) {
|
||||
// 小字:缓冲中显示百分比,否则显示源地址
|
||||
final String subText;
|
||||
if (buffering) {
|
||||
subText = '缓冲中 $percent%...';
|
||||
} else if (widget.asset != null) {
|
||||
subText = widget.asset!;
|
||||
} else {
|
||||
subText = widget.url ?? '';
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.title ?? (widget.asset != null ? '本地音频' : '音频'),
|
||||
@@ -427,16 +438,19 @@ class AudioPlayerCardState extends State<AudioPlayerCard>
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: buffering ? const Color(0xFFF59E0B) : const Color(0xFF999999),
|
||||
// 仅缓冲中展示百分比小字,不展示播放链接 URL
|
||||
if (buffering) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'缓冲中 $percent%...',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFFF59E0B),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 可被外部暂停的媒体(音频/视频播放器 State 实现)
|
||||
abstract class MediaPausable {
|
||||
void pause();
|
||||
}
|
||||
|
||||
/// 媒体暂停注册表:播放器挂载时注册、卸载时注销,
|
||||
/// 暂停时直接遍历注册表,不再运行时递归 visitChildElements,
|
||||
/// 避免在 build/翻页动画/element 停用阶段遍历 element 树
|
||||
/// 导致的生命周期断言(RenderViewport 收到 RenderErrorBox 等连锁报错)。
|
||||
class MediaPauseRegistry {
|
||||
final Set<MediaPausable> _items = <MediaPausable>{};
|
||||
|
||||
void register(MediaPausable item) => _items.add(item);
|
||||
|
||||
void unregister(MediaPausable item) => _items.remove(item);
|
||||
|
||||
/// 暂停注册表里的全部媒体。
|
||||
/// 复制一份再遍历,防止 pause 触发的异步卸载修改集合。
|
||||
void pauseAll() {
|
||||
for (final item in _items.toList(growable: false)) {
|
||||
item.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 媒体暂停作用域:包裹需要统一暂停媒体的区域(如单个题目页/子题页)。
|
||||
/// 子树中的 AudioPlayerCard/VideoPlayerView 会自动注册到最近的作用域。
|
||||
class MediaPauseScope extends InheritedWidget {
|
||||
final MediaPauseRegistry registry;
|
||||
|
||||
const MediaPauseScope({
|
||||
super.key,
|
||||
required this.registry,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
/// 查找最近的作用域注册表;不存在返回 null(不建立依赖关系)。
|
||||
static MediaPauseRegistry? of(BuildContext context) {
|
||||
final widget = context
|
||||
.getElementForInheritedWidgetOfExactType<MediaPauseScope>()
|
||||
?.widget;
|
||||
return widget is MediaPauseScope ? widget.registry : null;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(MediaPauseScope oldWidget) =>
|
||||
registry != oldWidget.registry;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import 'package:new_learn/core/components/video_player_view.dart';
|
||||
/// customStylesBuilder ← 自定义标签样式(如把 .answer 高亮)
|
||||
/// customWidgetBuilder ← 自定义元素替换(把任意标签换成业务 widget)
|
||||
/// renderMode ← 渲染模式(默认 column;列表预览可用 listView)
|
||||
/// debugImage ← 是否打印 img 解析日志
|
||||
/// assumeRpx ← HTML 里的 width/height 是否按 rpx 处理
|
||||
///
|
||||
/// 三、内置扩展点(本组件已经帮你处理的):
|
||||
/// 1. <audio> 标签 → 自动替换成 AudioPlayerCard(本项目封装的音频控制条)
|
||||
@@ -29,7 +31,12 @@ import 'package:new_learn/core/components/video_player_view.dart';
|
||||
/// 2. <video> 标签 → 自动替换成 VideoPlayerView(本项目内嵌播放器,
|
||||
/// 无 Scaffold/AppBar),默认 autoPlay: false(避免富文本里
|
||||
/// 多个视频同时解码占内存),取 title 属性作为错误提示;
|
||||
/// 3. 公式(\(...\) LaTeX)→ 需要 flutter_math_fork,后续扩展。
|
||||
/// 3. <img> 标签 → 统一接管:
|
||||
/// - .svg 走 flutter_svg(带 SSAA 超采样,去锯齿);
|
||||
/// - 位图走 Image.network;
|
||||
/// - 公式图(有明确 width/height,或 parent 是行内标签)
|
||||
/// 用 InlineCustomWidget,按原始尺寸渲染,不缩放;
|
||||
/// - 块级大图宽度撑满父容器,高度按比例自适应。
|
||||
///
|
||||
/// 四、学生端场景:
|
||||
/// - 字号调节:外部用 Slider 控制 textStyle.fontSize 即可(demo 页示范);
|
||||
@@ -54,8 +61,15 @@ class RichTextContent extends StatelessWidget {
|
||||
this.customStylesBuilder,
|
||||
this.customWidgetBuilder,
|
||||
this.renderMode = RenderMode.column,
|
||||
this.htmlWidgetKey,
|
||||
this.debugImage = false,
|
||||
this.assumeRpx = true,
|
||||
});
|
||||
|
||||
/// 透传给内部 HtmlWidget 的 key(外部用于调用
|
||||
/// HtmlWidgetState.scrollToAnchor 滚动到 #anchor)
|
||||
final Key? htmlWidgetKey;
|
||||
|
||||
/// HTML 字符串(后端返回)
|
||||
final String html;
|
||||
|
||||
@@ -74,65 +88,124 @@ class RichTextContent extends StatelessWidget {
|
||||
/// 渲染模式:column(默认,嵌在滚动容器里)/ listView(页面级)
|
||||
final RenderMode renderMode;
|
||||
|
||||
/// 是否打印 img 解析日志(定位“图片特别大”时打开)
|
||||
final bool debugImage;
|
||||
|
||||
/// HTML 里的 width/height 是否按 rpx 处理。
|
||||
/// true:750 设计稿,除以 2 再 .w;
|
||||
/// false:直接当 px,走 .w。
|
||||
final bool assumeRpx;
|
||||
|
||||
/// 行内图片最大高度(逻辑像素)。
|
||||
/// 没给 width/height 的图片按这个高度兜底,避免撑满整行。
|
||||
static const double _inlineMaxHeight = 28.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HtmlWidget(
|
||||
key: htmlWidgetKey,
|
||||
html,
|
||||
textStyle: textStyle,
|
||||
onTapUrl: onTapUrl ?? _defaultOnTapUrl,
|
||||
customStylesBuilder: customStylesBuilder,
|
||||
customStylesBuilder: _combinedStylesBuilder,
|
||||
customWidgetBuilder: _combinedCustomWidget(context),
|
||||
renderMode: renderMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// 合并默认样式 + 用户自定义样式
|
||||
CustomStylesBuilder? get _combinedStylesBuilder {
|
||||
return (element) {
|
||||
final userResult = customStylesBuilder?.call(element);
|
||||
if (element.localName == 'img') {
|
||||
final base = <String, String>{
|
||||
'max-width': '100%',
|
||||
'height': 'auto',
|
||||
};
|
||||
if (userResult != null) base.addAll(userResult);
|
||||
return base;
|
||||
}
|
||||
return userResult;
|
||||
};
|
||||
}
|
||||
|
||||
/// 合并默认替换 + 用户自定义替换
|
||||
CustomWidgetBuilder _combinedCustomWidget(BuildContext context) => (element) {
|
||||
// 内置:<img src="*.svg"> → flutter_svg 行内渲染
|
||||
// core 版 flutter_widget_from_html 不支持 SVG,手动处理
|
||||
// ============================================================
|
||||
// 1. 统一处理 <img>: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
|
||||
final src = element.attributes['src'];
|
||||
if (src == null || src.isEmpty) return null;
|
||||
|
||||
final isSvg = src.toLowerCase().split('?').first.endsWith('.svg');
|
||||
|
||||
// 解析 width / height(有就用,没有就 null)
|
||||
double? parsePx(String? raw) {
|
||||
if (raw == null) return null;
|
||||
return double.tryParse(raw.replaceAll(RegExp(r'[^0-9.]'), ''));
|
||||
}
|
||||
|
||||
double? width = parsePx(element.attributes['width']);
|
||||
double? height = parsePx(element.attributes['height']);
|
||||
|
||||
final style = element.attributes['style'];
|
||||
if (style != null) {
|
||||
final wMatch = RegExp(r'width\s*:\s*([0-9.]+)px').firstMatch(style);
|
||||
final hMatch = RegExp(r'height\s*:\s*([0-9.]+)px').firstMatch(style);
|
||||
width = double.tryParse(wMatch?.group(1) ?? '') ?? width;
|
||||
height = double.tryParse(hMatch?.group(1) ?? '') ?? height;
|
||||
}
|
||||
|
||||
if (isSvg) {
|
||||
return InlineCustomWidget(
|
||||
alignment: PlaceholderAlignment.middle, // ← 关键
|
||||
child: SvgPicture.network(
|
||||
src,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.fill,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
// SVG 解码/下载期间显示加载动效,否则默认空白
|
||||
placeholderBuilder: (_) =>
|
||||
_buildLoadingPlaceholder(width, height),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return InlineCustomWidget(
|
||||
alignment: PlaceholderAlignment.middle, // ← 关键
|
||||
child: Image.network(
|
||||
src,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.fill,
|
||||
// 网络图片加载期间显示进度小菊花,否则默认空白
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
final expected = loadingProgress.expectedTotalBytes;
|
||||
final progress = expected == null
|
||||
? null
|
||||
: loadingProgress.cumulativeBytesLoaded / expected;
|
||||
return _buildLoadingPlaceholder(width, height, progress);
|
||||
},
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. 内置:<audio> → 本项目 AudioPlayerCard
|
||||
// ============================================================
|
||||
if (element.localName == 'audio') {
|
||||
final src = element.attributes['src'];
|
||||
if (src != null && src.isNotEmpty) {
|
||||
// 支持 asset:// 协议 → 本地音频(seek 秒响应)
|
||||
// 注意:网络源必须支持 HTTP Range(响应 206),
|
||||
// 否则 ExoPlayer 无法字节定位,seek 会失效"回到起始"。
|
||||
// 免费测试站(如 samplelib)常返回 200 不支持 Range,请勿用。
|
||||
final isAsset = src.startsWith('asset://');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: EdgeInsets.symmetric(vertical: 8.h),
|
||||
child: AudioPlayerCard(
|
||||
url: isAsset ? null : src,
|
||||
asset: isAsset ? src.substring('asset://'.length) : null,
|
||||
@@ -141,13 +214,16 @@ class RichTextContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
// 内置:<video> → 本项目 VideoPlayerView(内嵌播放器)
|
||||
// 默认不自动播,避免富文本里多个视频同时解码占内存
|
||||
|
||||
// ============================================================
|
||||
// 3. 内置:<video> → 本项目 VideoPlayerView(内嵌播放器)
|
||||
// 默认不自动播,避免富文本里多个视频同时解码占内存
|
||||
// ============================================================
|
||||
if (element.localName == 'video') {
|
||||
final src = element.attributes['src'];
|
||||
if (src != null && src.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: EdgeInsets.symmetric(vertical: 8.h),
|
||||
child: VideoPlayerView(
|
||||
url: src,
|
||||
title: element.attributes['title'] ?? '题目视频',
|
||||
@@ -156,13 +232,36 @@ class RichTextContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
// 透传用户自定义替换(公式等扩展点)
|
||||
|
||||
// ============================================================
|
||||
// 4. 透传用户自定义替换(公式等扩展点)
|
||||
// ============================================================
|
||||
return customWidgetBuilder?.call(element);
|
||||
};
|
||||
|
||||
/// 图片/SVG 加载中的占位:居中的小菊花(有已知大小时按图片尺寸占位,
|
||||
/// 行内无尺寸图给一个最小兜底盒子,避免占位为 0 看不到动效)
|
||||
Widget _buildLoadingPlaceholder(double? width, double? height,
|
||||
[double? progress]) {
|
||||
return Container(
|
||||
width: width ?? 40.w,
|
||||
height: height ?? _inlineMaxHeight.h,
|
||||
alignment: Alignment.center,
|
||||
child: SizedBox(
|
||||
width: 14.w,
|
||||
height: 14.w,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1.5,
|
||||
// 拿不到总字节数时传 null,小菊花保持 indeterminate 匀速旋转
|
||||
value: progress,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 默认链接拦截:防止题目里夹带外部危险链接
|
||||
static Future<bool> _defaultOnTapUrl(String url) async {
|
||||
debugPrint('[RichTextContent] 已拦截外部链接: $url');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+335
-30
@@ -1,8 +1,10 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart' show compute, kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:new_learn/core/api/public_upload/api.dart';
|
||||
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||
import 'package:new_learn/core/components/center_toast.dart';
|
||||
@@ -58,6 +60,9 @@ enum UploadMode { edit, view }
|
||||
/// 上传组件类型
|
||||
enum UploadType { handouts, questions, aiGrading, feedback }
|
||||
|
||||
/// 选图后的“原始图 + 压缩处理图”配对
|
||||
typedef UploadPair = ({PickedImage origin, ProcessedImage processed});
|
||||
|
||||
/// 图片项(统一承载:本地已�?+ 已上传)
|
||||
class UploadItem {
|
||||
const UploadItem({
|
||||
@@ -267,18 +272,28 @@ class UploadState extends State<Upload> {
|
||||
processingLoadingShown = true;
|
||||
|
||||
// 压缩 + EXIF 修正 + 尺寸过滤
|
||||
final List<({PickedImage origin, ProcessedImage processed})> validList =
|
||||
[];
|
||||
final List<UploadPair> validList = [];
|
||||
|
||||
for (final img in picked) {
|
||||
final bytes = img.bytes ?? await img.entity?.originBytes;
|
||||
if (bytes == null || bytes.isEmpty) continue;
|
||||
|
||||
final processed = await ImageProcessor.process(
|
||||
bytes: bytes,
|
||||
fileName: img.name,
|
||||
maxDimension: 1920,
|
||||
);
|
||||
final ProcessedImage? processed;
|
||||
if (kIsWeb) {
|
||||
// Web:只有字节,走 bytes 压缩
|
||||
final bytes = img.bytes;
|
||||
if (bytes == null || bytes.isEmpty) continue;
|
||||
processed = await ImageProcessor.process(
|
||||
bytes: bytes,
|
||||
fileName: img.name,
|
||||
maxDimension: 1920,
|
||||
);
|
||||
} else {
|
||||
// 原生:只传文件路径,原图字节不进 Dart 堆、UI 线程不解码
|
||||
final path = img.path;
|
||||
if (path == null || path.isEmpty) continue;
|
||||
processed = await ImageProcessor.processFile(
|
||||
path: path,
|
||||
maxDimension: 1920,
|
||||
);
|
||||
}
|
||||
if (processed == null) {
|
||||
// 压缩失败 / 解码失败 �?跳过
|
||||
continue;
|
||||
@@ -301,9 +316,27 @@ class UploadState extends State<Upload> {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: AIGrading 类型的旋转预览弹窗待实现(Vue 端有 $showImagePreview�?
|
||||
// 暂时直接上传,后续有需求再�?
|
||||
await _startUpload(validList);
|
||||
// AIGrading(上传图片作答):先进入全屏旋转预览,
|
||||
// 用户点“完成”才烘焙旋转并上传;点“取消”则整批放弃(对齐 Vue)
|
||||
List<UploadPair> pairsToUpload = validList;
|
||||
if (widget.type == UploadType.aiGrading) {
|
||||
_hideLoading();
|
||||
processingLoadingShown = false;
|
||||
if (!mounted) return;
|
||||
|
||||
final rotated = await _ImageRotateEditor.open(
|
||||
context,
|
||||
items: validList,
|
||||
);
|
||||
if (rotated == null) return; // 用户取消
|
||||
pairsToUpload = rotated;
|
||||
if (!mounted) return;
|
||||
|
||||
_showLoading('上传中...');
|
||||
processingLoadingShown = true;
|
||||
}
|
||||
|
||||
await _startUpload(pairsToUpload);
|
||||
} catch (e) {
|
||||
_toast('图片处理失败,请重试');
|
||||
debugPrint('[Upload] 选图异常: $e');
|
||||
@@ -488,7 +521,7 @@ class UploadState extends State<Upload> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 模式�?view 且没有图片时不显示(对齐 Vue v-if�?
|
||||
// 模式:view 且没有图片时不显示(对齐 Vue v-if)
|
||||
if (widget.mode == UploadMode.edit || _imageList.isNotEmpty) {
|
||||
return _buildUploadBox();
|
||||
}
|
||||
@@ -520,31 +553,38 @@ class UploadState extends State<Upload> {
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.only(right: 14.w),
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
Stack(
|
||||
children: [
|
||||
// 标题装饰底图:固定尺寸钉在右下角(对标 background-size: 119rpx auto)
|
||||
Positioned(
|
||||
right: 10.w,
|
||||
bottom: 0,
|
||||
child: Image.network(
|
||||
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
|
||||
width: 59.5.w,
|
||||
height: 9.h,
|
||||
fit: BoxFit.fill,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
alignment: Alignment.bottomRight,
|
||||
repeat: ImageRepeat.noRepeat,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1D2B47),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(right: 14.w),
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1D2B47),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
Text(
|
||||
widget.mode == UploadMode.edit
|
||||
? '已上传:${_imageList.length}/${widget.maxCount}张'
|
||||
: '共 $_imageList.length 张',
|
||||
: '共 ${_imageList.length} 张',
|
||||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFFB4C2E0)),
|
||||
),
|
||||
],
|
||||
@@ -672,3 +712,268 @@ class UploadState extends State<Upload> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 全屏旋转预览(对标 Vue components/ImagePreviewPopup):
|
||||
/// - 左右滑动逐张查看;
|
||||
/// - “↻ 旋转90°”只改当前图片的视觉角度;
|
||||
/// - “完成”才把角度烘焙进图片字节并返回(随后才上传);
|
||||
/// - “取消”/系统返回 → 返回 null,整批放弃。
|
||||
/// ============================================================
|
||||
class _ImageRotateEditor extends StatefulWidget {
|
||||
const _ImageRotateEditor({required this.items});
|
||||
|
||||
final List<UploadPair> items;
|
||||
|
||||
/// 打开旋转预览;完成返回旋转后的列表,取消返回 null
|
||||
static Future<List<UploadPair>?> open(
|
||||
BuildContext context, {
|
||||
required List<UploadPair> items,
|
||||
}) {
|
||||
return Navigator.of(context).push<List<UploadPair>>(
|
||||
PageRouteBuilder<List<UploadPair>>(
|
||||
opaque: true,
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 200),
|
||||
pageBuilder: (_, __, ___) => _ImageRotateEditor(items: items),
|
||||
transitionsBuilder: (_, animation, __, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<_ImageRotateEditor> createState() => _ImageRotateEditorState();
|
||||
}
|
||||
|
||||
class _ImageRotateEditorState extends State<_ImageRotateEditor> {
|
||||
late final List<int> _angles = List.filled(widget.items.length, 0);
|
||||
final PageController _pageController = PageController();
|
||||
int _currentIndex = 0;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onPageChanged(int index) => setState(() => _currentIndex = index);
|
||||
|
||||
void _handleRotate() {
|
||||
if (_busy) return;
|
||||
setState(() {
|
||||
_angles[_currentIndex] = (_angles[_currentIndex] + 90) % 360;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleConfirm() async {
|
||||
if (_busy) return;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
final result = <UploadPair>[];
|
||||
for (var i = 0; i < widget.items.length; i++) {
|
||||
final pair = widget.items[i];
|
||||
final angle = _angles[i] % 360;
|
||||
if (angle == 0) {
|
||||
result.add(pair);
|
||||
} else {
|
||||
result.add((
|
||||
origin: pair.origin,
|
||||
processed: await _bakeRotation(pair.processed, angle),
|
||||
));
|
||||
}
|
||||
}
|
||||
if (mounted) Navigator.of(context).pop(result);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _busy = false);
|
||||
CenterToast.show(context, '图片处理失败,请重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleCancel() {
|
||||
if (_busy) return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: widget.items.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
itemBuilder: (context, index) {
|
||||
return Center(
|
||||
child: AnimatedRotation(
|
||||
turns: _angles[index] / 360,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Image.memory(
|
||||
widget.items[index].processed.bytes,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 指示点(多图时)
|
||||
if (widget.items.length > 1)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 72.h,
|
||||
child: _buildDots(),
|
||||
),
|
||||
|
||||
// 底部操作栏
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _buildFooter(),
|
||||
),
|
||||
|
||||
// “处理图片中...”遮罩
|
||||
if (_busy)
|
||||
const ColoredBox(
|
||||
color: Colors.black38,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDots() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var i = 0; i < widget.items.length; i++)
|
||||
Container(
|
||||
width: 7.w,
|
||||
height: 7.w,
|
||||
margin: EdgeInsets.symmetric(horizontal: 4.w),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: i == _currentIndex
|
||||
? Colors.white
|
||||
: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
|
||||
decoration: const BoxDecoration(color: Color(0xB3000000)),
|
||||
child: Row(
|
||||
children: [
|
||||
_FooterTextButton(label: '取消', onTap: _handleCancel),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: _handleRotate,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20.w,
|
||||
vertical: 8.h,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x26FFFFFF),
|
||||
borderRadius: BorderRadius.circular(30.r),
|
||||
),
|
||||
child: Text(
|
||||
'↻ 旋转90°',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_FooterTextButton(label: '完成', primary: true, onTap: _handleConfirm),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FooterTextButton extends StatelessWidget {
|
||||
const _FooterTextButton({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.primary = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool primary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
color: primary ? const Color(0xFF2979FF) : Colors.white,
|
||||
fontWeight: primary ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 旋转 isolate 任务
|
||||
typedef _RotateTask = ({Uint8List bytes, int angle, bool isPng});
|
||||
|
||||
/// 把旋转角度烘焙进 [processed] 的字节(失败时保留原图)
|
||||
Future<ProcessedImage> _bakeRotation(
|
||||
ProcessedImage processed,
|
||||
int angle,
|
||||
) async {
|
||||
final isPng = processed.contentType == 'image/png';
|
||||
final rotated = await compute<_RotateTask, Uint8List?>(
|
||||
_rotateInIsolate,
|
||||
(bytes: processed.bytes, angle: angle, isPng: isPng),
|
||||
);
|
||||
if (rotated == null || rotated.isEmpty) return processed;
|
||||
|
||||
return ProcessedImage(
|
||||
bytes: rotated,
|
||||
extension: isPng ? 'png' : 'jpg',
|
||||
contentType: isPng ? 'image/png' : 'image/jpeg',
|
||||
originalBytes: processed.originalBytes,
|
||||
processedBytes: rotated.length,
|
||||
);
|
||||
}
|
||||
|
||||
/// isolate 内:解码 → 顺时针旋转 → 编码(JPEG 质量 90,对齐 Vue canvas 0.9)
|
||||
Uint8List? _rotateInIsolate(_RotateTask task) {
|
||||
final src = img.decodeImage(task.bytes);
|
||||
if (src == null) return null;
|
||||
final dst = img.copyRotate(src, angle: task.angle);
|
||||
final encoded =
|
||||
task.isPng ? img.encodePng(dst) : img.encodeJpg(dst, quality: 90);
|
||||
return Uint8List.fromList(encoded);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'media_pause_scope.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【VideoPlayerView】通用视频播放器(内嵌版,无 Scaffold/AppBar)
|
||||
@@ -111,7 +112,8 @@ class VideoPlayerView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class VideoPlayerViewState extends State<VideoPlayerView>
|
||||
with WidgetsBindingObserver {
|
||||
with WidgetsBindingObserver
|
||||
implements MediaPausable {
|
||||
/// 官方底层控制器
|
||||
VideoPlayerController? _videoController;
|
||||
|
||||
@@ -127,6 +129,9 @@ class VideoPlayerViewState extends State<VideoPlayerView>
|
||||
/// 初始化成功后视频的真实宽高比(未成功前 null → 兜底 16:9)
|
||||
double? _aspectRatio;
|
||||
|
||||
/// 所属媒体暂停作用域(随挂载位置自动注册/注销)
|
||||
MediaPauseRegistry? _pauseScope;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -135,8 +140,21 @@ class VideoPlayerViewState extends State<VideoPlayerView>
|
||||
_initPlayer();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// 自动挂到最近的 MediaPauseScope(含 fwfh 富文本内部的播放器)
|
||||
final scope = MediaPauseScope.of(context);
|
||||
if (scope != _pauseScope) {
|
||||
_pauseScope?.unregister(this);
|
||||
_pauseScope = scope;
|
||||
_pauseScope?.register(this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pauseScope?.unregister(this);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// 释放顺序不能反:先 chewie 再 video_player(反了会闪退)
|
||||
_chewieController?.dispose();
|
||||
@@ -144,7 +162,8 @@ class VideoPlayerViewState extends State<VideoPlayerView>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停视频)
|
||||
/// 暂停播放(切页时由 MediaPauseRegistry 统一调用)
|
||||
@override
|
||||
void pause() {
|
||||
_videoController?.pause();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
@@ -53,6 +55,10 @@ class DioClient {
|
||||
),
|
||||
) {
|
||||
if (enableNetworkLog) {
|
||||
// 必须在 Talker 之前:把二进制 body 临时换成占位文本,
|
||||
// 防止 Talker 把整张图(上百万字节)转成巨型日志字符串导致 OOM。
|
||||
// 真实字节存在 extra 里,由 _buildInterceptors 的 onRequest 还原。
|
||||
_dio.interceptors.add(_BinaryLogMaskInterceptor());
|
||||
_dio.interceptors.add(
|
||||
TalkerDioLogger(
|
||||
talker: talker,
|
||||
@@ -88,6 +94,11 @@ class DioClient {
|
||||
return InterceptorsWrapper(
|
||||
// ① 请求发出前:加 Token + 打印日志
|
||||
onRequest: (options, handler) {
|
||||
// 还原被 _BinaryLogMaskInterceptor 临时替换的二进制 body,保证实际上传内容不变
|
||||
final realBody = options.extra.remove(_kRealBodyKey);
|
||||
if (realBody != null) {
|
||||
options.data = realBody;
|
||||
}
|
||||
// OSS 直传会带自己的 authorization 头(OSS4-HMAC-SHA256 签名),
|
||||
// 任何形式存在 authorization 都不再加 Bearer,否则会覆盖 OSS 签名
|
||||
final hasAuth =
|
||||
@@ -177,16 +188,20 @@ class DioClient {
|
||||
ProgressCallback? onReceiveProgress,
|
||||
int retries = 0,
|
||||
Map<String, dynamic>? headers,
|
||||
Duration? receiveTimeout,
|
||||
}) async {
|
||||
var attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
final options = (headers != null || receiveTimeout != null)
|
||||
? Options(headers: headers, receiveTimeout: receiveTimeout)
|
||||
: null;
|
||||
final resp = await _dio.get<dynamic>(
|
||||
path,
|
||||
queryParameters: query,
|
||||
cancelToken: cancelToken,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
options: headers == null ? null : Options(headers: headers),
|
||||
options: options,
|
||||
);
|
||||
return resp.data;
|
||||
} on DioException catch (e) {
|
||||
@@ -337,3 +352,25 @@ class ApiException implements Exception {
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// extra 中暂存真实二进制 body 的 key
|
||||
const String _kRealBodyKey = '_realBinaryBody@dio';
|
||||
|
||||
/// 二进制日志屏蔽拦截器(必须在 TalkerDioLogger 之前执行):
|
||||
/// 把二进制 body 暂存到 extra 并替换成简短占位文本,
|
||||
/// 避免 Talker 把整张图转成巨型日志字符串、堆积在日志历史里造成 OOM。
|
||||
/// 真实字节由 _buildInterceptors 的 onRequest 还原,不影响实际请求内容。
|
||||
class _BinaryLogMaskInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
final data = options.data;
|
||||
if (data is TypedData) {
|
||||
options.extra[_kRealBodyKey] = data;
|
||||
options.data = '<binary ${data.lengthInBytes} bytes>';
|
||||
} else if (data is Stream<List<int>>) {
|
||||
options.extra[_kRealBodyKey] = data;
|
||||
options.data = '<binary stream>';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ 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/classExercises/class_exercises_page.dart';
|
||||
import 'package:new_learn/features/conquerPackage/report/report_page.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';
|
||||
@@ -217,6 +219,20 @@ final GoRouter appRouter = GoRouter(
|
||||
extra: state.extra as Map<String, dynamic>?,
|
||||
),
|
||||
),
|
||||
// ---------- 攻克包 - 课后习题 ----------
|
||||
GoRoute(
|
||||
path: '/conquerPackage/classExercises',
|
||||
builder: (_, state) => ClassExercisesPage(
|
||||
extra: state.extra as Map<String, dynamic>?,
|
||||
),
|
||||
),
|
||||
// ---------- 攻克包 - 学习报告(占位) ----------
|
||||
GoRoute(
|
||||
path: '/conquerPackage/report',
|
||||
builder: (_, state) => ReportPage(
|
||||
extra: state.extra as Map<String, dynamic>?,
|
||||
),
|
||||
),
|
||||
GoRoute(path: '/register', builder: (_, _) => const RegisterPage()),
|
||||
|
||||
// app_router.dart 的 routes 里加,不用管跳转逻辑
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// ============================================================
|
||||
// 图片文件头信息(条件导出):
|
||||
// native(dart.library.io 为真)→ image_file_header_io.dart
|
||||
// web(dart.library.io 为假) → image_file_header_web.dart
|
||||
//
|
||||
// 仅用 RandomAccessFile 读 JPEG/PNG 文件头获取尺寸/格式,
|
||||
// 不把整张图读进内存、不做像素解码。
|
||||
// ============================================================
|
||||
export 'image_file_header_web.dart'
|
||||
if (dart.library.io) 'image_file_header_io.dart';
|
||||
@@ -0,0 +1,118 @@
|
||||
// 仅当 dart.library.io 为真(native)时编译。
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// 图片文件头信息(仅读头部,不加载像素)
|
||||
class ImageFileHeader {
|
||||
const ImageFileHeader({
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.length,
|
||||
required this.isPng,
|
||||
required this.isJpeg,
|
||||
});
|
||||
|
||||
final int width;
|
||||
final int height;
|
||||
final int length;
|
||||
final bool isPng;
|
||||
final bool isJpeg;
|
||||
}
|
||||
|
||||
/// 读取本地图片文件头:尺寸 / 格式 / 文件大小。
|
||||
///
|
||||
/// 使用 RandomAccessFile 按 marker 顺序扫描,每次仅读几个字节:
|
||||
/// - PNG:IHDR 固定在偏移 16;
|
||||
/// - JPEG:从 SOI 后逐段扫描,SOF 段内即宽高(在 SOS 之前)。
|
||||
/// 不支持的格式 / 损坏文件返回 null。
|
||||
Future<ImageFileHeader?> readImageFileHeader(String path) async {
|
||||
final file = File(path);
|
||||
final raf = await file.open();
|
||||
try {
|
||||
final sig = await raf.read(4);
|
||||
if (sig.length < 4) return null;
|
||||
|
||||
// -------- PNG:89 50 4E 47 0D 0A 1A 0A,IHDR 在偏移 16 --------
|
||||
if (sig[0] == 0x89 &&
|
||||
sig[1] == 0x50 &&
|
||||
sig[2] == 0x4E &&
|
||||
sig[3] == 0x47) {
|
||||
await raf.setPosition(16);
|
||||
final d = await raf.read(8);
|
||||
if (d.length < 8) return null;
|
||||
final width = (d[0] << 24) | (d[1] << 16) | (d[2] << 8) | d[3];
|
||||
final height = (d[4] << 24) | (d[5] << 16) | (d[6] << 8) | d[7];
|
||||
return ImageFileHeader(
|
||||
width: width,
|
||||
height: height,
|
||||
length: await raf.length(),
|
||||
isPng: true,
|
||||
isJpeg: false,
|
||||
);
|
||||
}
|
||||
|
||||
// -------- JPEG:FF D8 FF --------
|
||||
if (sig[0] == 0xFF && sig[1] == 0xD8 && sig[2] == 0xFF) {
|
||||
await raf.setPosition(2);
|
||||
while (true) {
|
||||
// 定位段 marker(跳过填充字节)
|
||||
final prefix = await raf.read(1);
|
||||
if (prefix.isEmpty || prefix[0] != 0xFF) return null;
|
||||
int marker;
|
||||
do {
|
||||
final m = await raf.read(1);
|
||||
if (m.isEmpty) return null;
|
||||
marker = m[0];
|
||||
} while (marker == 0xFF);
|
||||
|
||||
// 无长度段:SOI / TEM / RSTn
|
||||
if (marker == 0xD8 || marker == 0x01) continue;
|
||||
if (marker >= 0xD0 && marker <= 0xD9) {
|
||||
if (marker == 0xD9) return null; // EOI:未找到 SOF
|
||||
continue;
|
||||
}
|
||||
|
||||
final lenBytes = await raf.read(2);
|
||||
if (lenBytes.length < 2) return null;
|
||||
final segLen = (lenBytes[0] << 8) | lenBytes[1];
|
||||
if (segLen < 2) return null;
|
||||
|
||||
// SOF 段(排除 C4=DHT、C8=JPG、CC=DAC)
|
||||
if (marker >= 0xC0 &&
|
||||
marker <= 0xCF &&
|
||||
marker != 0xC4 &&
|
||||
marker != 0xC8 &&
|
||||
marker != 0xCC) {
|
||||
// precision(1) + height(2) + width(2)
|
||||
final sof = await raf.read(5);
|
||||
if (sof.length < 5) return null;
|
||||
final height = (sof[1] << 8) | sof[2];
|
||||
final width = (sof[3] << 8) | sof[4];
|
||||
return ImageFileHeader(
|
||||
width: width,
|
||||
height: height,
|
||||
length: await raf.length(),
|
||||
isPng: false,
|
||||
isJpeg: true,
|
||||
);
|
||||
}
|
||||
|
||||
// 其它段:跳过段体(长度含自身 2 字节)
|
||||
await raf.setPosition(await raf.position() + segLen - 2);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取文件全部字节(仅用于桌面等不支持原生压缩时的回退路径)
|
||||
Future<Uint8List?> readImageFileBytes(String path) async {
|
||||
try {
|
||||
return Uint8List.fromList(await File(path).readAsBytes());
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 仅当 dart.library.io 为假(Web)时编译。
|
||||
// Web 端选图直接拿到字节,不需要读文件头。
|
||||
import 'dart:typed_data';
|
||||
|
||||
class ImageFileHeader {
|
||||
const ImageFileHeader({
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.length,
|
||||
required this.isPng,
|
||||
required this.isJpeg,
|
||||
});
|
||||
|
||||
final int width;
|
||||
final int height;
|
||||
final int length;
|
||||
final bool isPng;
|
||||
final bool isJpeg;
|
||||
}
|
||||
|
||||
/// Web 端无文件系统,返回 null
|
||||
Future<ImageFileHeader?> readImageFileHeader(String path) async => null;
|
||||
|
||||
/// Web 端无文件系统,返回 null
|
||||
Future<Uint8List?> readImageFileBytes(String path) async => null;
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
import 'image_file_header.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ImageProcessor】图片压缩 + 方向修正 + 尺寸过滤
|
||||
///
|
||||
@@ -87,6 +89,68 @@ class ImageProcessor {
|
||||
processedBytes: result.processedBytes,
|
||||
);
|
||||
}
|
||||
|
||||
/// 处理本地图片【文件】(原生端推荐入口):
|
||||
/// - 尺寸/格式只读文件头(RandomAccessFile 读几字节),
|
||||
/// UI 线程不做完整解码;
|
||||
/// - 压缩交给原生侧直接读文件,Dart 堆不持有原图;
|
||||
/// - 原生压缩不可用(如桌面平台)时回退纯 Dart isolate。
|
||||
static Future<ProcessedImage?> processFile({
|
||||
required String path,
|
||||
int maxDimension = 1920,
|
||||
int quality = 85,
|
||||
}) async {
|
||||
final header = await readImageFileHeader(path);
|
||||
if (header == null) return null;
|
||||
if (!_isInAllowedSize(header.width, header.height)) return null;
|
||||
|
||||
final target = _targetSize(
|
||||
width: header.width,
|
||||
height: header.height,
|
||||
maxDimension: maxDimension,
|
||||
);
|
||||
|
||||
final compressed = await FlutterImageCompress.compressWithFile(
|
||||
path,
|
||||
minWidth: target.width,
|
||||
minHeight: target.height,
|
||||
quality: quality,
|
||||
format: header.isPng ? CompressFormat.png : CompressFormat.jpeg,
|
||||
autoCorrectionAngle: true,
|
||||
);
|
||||
|
||||
if (compressed != null && compressed.isNotEmpty) {
|
||||
final bytes = Uint8List.fromList(compressed);
|
||||
return ProcessedImage(
|
||||
bytes: bytes,
|
||||
extension: header.isPng ? 'png' : 'jpg',
|
||||
contentType: header.isPng ? 'image/png' : 'image/jpeg',
|
||||
originalBytes: header.length,
|
||||
processedBytes: bytes.length,
|
||||
);
|
||||
}
|
||||
|
||||
// 回退:读文件字节 → 纯 Dart isolate(桌面等场景)
|
||||
final fileBytes = await readImageFileBytes(path);
|
||||
if (fileBytes == null || fileBytes.isEmpty) return null;
|
||||
final result = await compute(
|
||||
_processInIsolate,
|
||||
(
|
||||
bytes: fileBytes,
|
||||
fileName: null,
|
||||
maxDimension: maxDimension,
|
||||
quality: quality,
|
||||
),
|
||||
);
|
||||
if (result == null) return null;
|
||||
return ProcessedImage(
|
||||
bytes: result.bytes,
|
||||
extension: result.extension,
|
||||
contentType: result.contentType,
|
||||
originalBytes: result.originalBytes,
|
||||
processedBytes: result.processedBytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _ProcessTask = ({
|
||||
|
||||
@@ -106,8 +106,10 @@ class PickerService {
|
||||
return image == null ? null : [image];
|
||||
}
|
||||
// 相册权限:Android 13+ 用 photos (READ_MEDIA_IMAGES),旧版用 storage
|
||||
final galleryPermission = await _resolveGalleryPermission();
|
||||
if (!context.mounted) return null;
|
||||
final ok = await _ensurePermission(
|
||||
permission: await _resolveGalleryPermission(),
|
||||
permission: galleryPermission,
|
||||
title: '需要相册权限',
|
||||
purpose: '从相册选取图片用于上传错题、作业图片及更换头像',
|
||||
context: context,
|
||||
@@ -190,10 +192,10 @@ class PickerService {
|
||||
// 不再强制锁拍照方向。题目/解答/讲义拍摄经常需要横拍,
|
||||
// 后续交给 ImageProcessor 的原生压缩链做 autoCorrectionAngle。
|
||||
onEntitySaving: (dialogContext, viewType, file) async {
|
||||
final xFile = XFile(file.path);
|
||||
// 只持有文件路径,不 readAsBytes:原图字节不进 Dart 堆,
|
||||
// 后续压缩由原生侧直接读文件(避免大图 OOM)。
|
||||
picked = PickedImage(
|
||||
path: file.path,
|
||||
bytes: await xFile.readAsBytes(),
|
||||
name: file.path.split('/').last,
|
||||
);
|
||||
if (dialogContext.mounted) {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/// ============================================================
|
||||
/// 答题卡构建(1:1 移植 Vue utils/examAnswerCard.js)
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 子题带选项、需按“小题答题卡”处理的复合题型
|
||||
const Set<String> kQuestionTypesWithOptionChildren = {
|
||||
'yuedulijie',
|
||||
'yueduxuanzeti',
|
||||
'qixuanwu',
|
||||
'wanxingtiankongxuanzeti',
|
||||
'tingli',
|
||||
};
|
||||
|
||||
/// 仅这些学科按正常逻辑判断 hasOptionChildrenQuestion,其余学科直接 true
|
||||
const List<int> kSubjectIdsForNormalCheck = [2, 4, 5, 8, 9];
|
||||
|
||||
bool _truthy(dynamic v) {
|
||||
if (v == null) return false;
|
||||
if (v is bool) return v;
|
||||
if (v is num) return v != 0;
|
||||
if (v is String) return v.isNotEmpty && v != '0';
|
||||
return true;
|
||||
}
|
||||
|
||||
dynamic _or(dynamic a, dynamic b) => _truthy(a) ? a : b;
|
||||
|
||||
int _num(dynamic v, [int def = 0]) {
|
||||
if (v is num) return v.toInt();
|
||||
final n = int.tryParse('${v ?? ''}');
|
||||
return n ?? def;
|
||||
}
|
||||
|
||||
/// 答案是否有值('' / null / '[]' 视为未答)
|
||||
bool hasValue(dynamic value) {
|
||||
if (value == null) return false;
|
||||
final str = '$value'.trim();
|
||||
if (str.isEmpty || str == '[]') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
List<dynamic> _metaList(Map<String, dynamic>? question) {
|
||||
if (question == null) return const [];
|
||||
final v = question['bkTagMetaList'];
|
||||
return v is List ? v : const [];
|
||||
}
|
||||
|
||||
/// 填空题:每个空都作答才算已答;非填空题返回 null
|
||||
bool? isFillBlankFullyAnswered(Map<String, dynamic>? question) {
|
||||
final metas = _metaList(question);
|
||||
if (metas.isEmpty) return null;
|
||||
|
||||
bool metaHasAnswer(dynamic rawMeta) {
|
||||
final meta = rawMeta is Map ? rawMeta : const {};
|
||||
final answer = meta['myAnswer'] ?? meta['userAnswer'];
|
||||
if (answer is List) return answer.isNotEmpty;
|
||||
return hasValue(answer);
|
||||
}
|
||||
|
||||
return metas.every(metaHasAnswer);
|
||||
}
|
||||
|
||||
/// 题目是否已答
|
||||
bool isQuestionAnswered(Map<String, dynamic> question) {
|
||||
final fillBlankAnswered = isFillBlankFullyAnswered(question);
|
||||
final answered = (fillBlankAnswered == null || !fillBlankAnswered)
|
||||
? hasValue(question['userAnswer'])
|
||||
: fillBlankAnswered;
|
||||
return answered;
|
||||
}
|
||||
|
||||
/// 读取题目子项(subQuestions 优先,其次 childs)
|
||||
List<dynamic> getQuestionChildren(Map<String, dynamic>? item) {
|
||||
if (item == null) return const [];
|
||||
if (item['subQuestions'] is List && (item['subQuestions'] as List).isNotEmpty) {
|
||||
return item['subQuestions'] as List;
|
||||
}
|
||||
if (item['childs'] is List && (item['childs'] as List).isNotEmpty) {
|
||||
return item['childs'] as List;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// 给题目列表补充 hasOptionChildrenQuestion(会就地改写 item 个别字段)
|
||||
List<Map<String, dynamic>> appendExamQuestionChildOptionStatus(
|
||||
List<dynamic>? list,
|
||||
dynamic subjectId,
|
||||
) {
|
||||
return (list ?? const []).map((rawItem) {
|
||||
final item = Map<String, dynamic>.from(rawItem as Map);
|
||||
final childs = item['childs'] is List ? item['childs'] as List : const [];
|
||||
|
||||
// 学科不在白名单:默认按小题答题卡处理
|
||||
if (!kSubjectIdsForNormalCheck.contains(_num(subjectId, -1))) {
|
||||
var hasOptionFlag = childs.isNotEmpty;
|
||||
|
||||
// 输入型填空题(仅一空)合并到大题
|
||||
if (item['questionTypes'] == 'tiankongti' && childs.length == 1) {
|
||||
final firstChild = childs.first as Map;
|
||||
item['answerStatus'] = _or(firstChild['answerStatus'], item['answerStatus']);
|
||||
item['userAnswer'] = _or(firstChild['userAnswer'], item['userAnswer']);
|
||||
hasOptionFlag = false;
|
||||
}
|
||||
|
||||
// 解答题/问答题有子题:整题按一道题提交,图片作答
|
||||
if ((item['questionTypes'] == 'jiedati' || item['questionTypes'] == 'wendati') &&
|
||||
childs.isNotEmpty) {
|
||||
hasOptionFlag = false;
|
||||
item['iSFilllankItemUpload'] = 1;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
'hasOptionChildrenQuestion': hasOptionFlag,
|
||||
};
|
||||
}
|
||||
|
||||
final childList = getQuestionChildren(item);
|
||||
final hasOptionChildren =
|
||||
kQuestionTypesWithOptionChildren.contains(item['questionTypes']) &&
|
||||
childList.isNotEmpty &&
|
||||
childList.any((c) =>
|
||||
c is Map && c['options'] is List && (c['options'] as List).isNotEmpty);
|
||||
final isInputFillBlankQuestion = item['questionTypes'] == 'tiankongti' &&
|
||||
_num(item['iSFilllankItemUpload']) == 0 &&
|
||||
(item['childs'] is List && (item['childs'] as List).length > 1);
|
||||
|
||||
return {
|
||||
...item,
|
||||
'hasOptionChildrenQuestion': hasOptionChildren || isInputFillBlankQuestion,
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _rawId(dynamic sub) => sub is Map && sub['raw'] is Map
|
||||
? Map<String, dynamic>.from(sub['raw'] as Map)
|
||||
: const {};
|
||||
|
||||
/// 将题目拍平成答题卡题号列表
|
||||
List<Map<String, dynamic>> buildExamAnswerCardQuestions(
|
||||
List<dynamic>? questionsList, {
|
||||
bool isExamAnswerCard = true,
|
||||
}) {
|
||||
final list = <Map<String, dynamic>>[];
|
||||
var displaySort = 1;
|
||||
|
||||
(questionsList ?? const []).asMap().forEach((index, rawItem) {
|
||||
final item = Map<String, dynamic>.from(rawItem as Map);
|
||||
final subQuestions =
|
||||
item['subQuestions'] is List ? item['subQuestions'] as List : const [];
|
||||
|
||||
if (isExamAnswerCard != true) {
|
||||
if (item['hasChild'] == true && subQuestions.isNotEmpty) {
|
||||
subQuestions.asMap().forEach((subIndex, rawSub) {
|
||||
final sub = Map<String, dynamic>.from(rawSub as Map);
|
||||
final subRaw = _rawId(sub);
|
||||
final question = {
|
||||
...sub,
|
||||
'questionId': subRaw['id'] ?? '${item['id'] ?? index}-$subIndex',
|
||||
'parentIndex': index,
|
||||
'subIndex': subIndex,
|
||||
'sort': _num(sub['sort']),
|
||||
'targetSort': _num(sub['sort']),
|
||||
'questionTypeName': item['questionTypeName'] ?? '题目',
|
||||
'userAnswer': _or(sub['userAnswer'], item['userAnswer']),
|
||||
'correctAnswer': _or(sub['answer'], subRaw['answer']) ?? '',
|
||||
'answerStatus': _or(sub['answerStatus'], 0),
|
||||
'bkTagMetaList': sub['bkTagMetaList'] ?? const [],
|
||||
};
|
||||
list.add({...question, 'answered': isQuestionAnswered(question)});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final question = {
|
||||
...item,
|
||||
'questionId': item['id'] ?? index,
|
||||
'parentIndex': index,
|
||||
'subIndex': -1,
|
||||
'sort': _num(item['sort']),
|
||||
'targetSort': _num(item['sort']),
|
||||
'questionTypeName': item['questionTypeName'] ?? '题目',
|
||||
'userAnswer': item['userAnswer'] ?? '',
|
||||
'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
|
||||
'answerStatus': item['answerStatus'] ?? 0,
|
||||
'bkTagMetaList': item['bkTagMetaList'] ?? const [],
|
||||
};
|
||||
list.add({...question, 'answered': isQuestionAnswered(question)});
|
||||
return;
|
||||
}
|
||||
|
||||
final children = getQuestionChildren(item);
|
||||
final collapseAsSingleQuestion = item['hasOptionChildrenQuestion'] == false;
|
||||
|
||||
if (collapseAsSingleQuestion) {
|
||||
final childs = item['childs'] is List ? item['childs'] as List : const [];
|
||||
final firstChildSort = children.isNotEmpty
|
||||
? _num((children.first as Map)['sort'])
|
||||
: 0;
|
||||
var targetSort = _num(item['sort']);
|
||||
if (targetSort == 0) targetSort = firstChildSort;
|
||||
|
||||
if (item['questionTypes'] == 'tiankongti' && childs.length == 1) {
|
||||
final firstChild = childs.first as Map;
|
||||
firstChild['questionTypeName'] = item['questionTypeName'] ?? '题目';
|
||||
item['userAnswer'] = _or(firstChild['userAnswer'], item['userAnswer']);
|
||||
item['answerStatus'] = _or(firstChild['answerStatus'], item['answerStatus']);
|
||||
}
|
||||
|
||||
final question = {
|
||||
...item,
|
||||
'questionId': item['id'] ?? index,
|
||||
'parentIndex': index,
|
||||
'subIndex': -1,
|
||||
'sort': displaySort++,
|
||||
'targetSort': targetSort,
|
||||
'questionTypeName': item['questionTypeName'] ?? '题目',
|
||||
'userAnswer': item['userAnswer'] ?? '',
|
||||
'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
|
||||
'answerStatus': item['answerStatus'] ?? 0,
|
||||
'bkTagMetaList': item['bkTagMetaList'] ?? const [],
|
||||
};
|
||||
list.add({...question, 'answered': isQuestionAnswered(question)});
|
||||
return;
|
||||
}
|
||||
|
||||
if (children.isNotEmpty) {
|
||||
children.asMap().forEach((subIndex, rawSub) {
|
||||
final sub = Map<String, dynamic>.from(rawSub as Map);
|
||||
final subRaw = _rawId(sub);
|
||||
final question = {
|
||||
...sub,
|
||||
'questionId': subRaw['id'] ?? sub['id'] ?? '${item['id'] ?? index}-$subIndex',
|
||||
'parentIndex': index,
|
||||
'subIndex': subIndex,
|
||||
'sort': displaySort++,
|
||||
'targetSort': _num(sub['sort']),
|
||||
'questionTypeName': item['questionTypeName'] ?? '题目',
|
||||
'userAnswer': _or(sub['userAnswer'], item['userAnswer']),
|
||||
'correctAnswer':
|
||||
sub['correctAnswer'] ?? sub['answer'] ?? subRaw['answer'] ?? '',
|
||||
'answerStatus': sub['answerStatus'] ?? 0,
|
||||
'bkTagMetaList': sub['bkTagMetaList'] ?? const [],
|
||||
};
|
||||
list.add({...question, 'answered': isQuestionAnswered(question)});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final question = {
|
||||
...item,
|
||||
'questionId': item['id'] ?? index,
|
||||
'parentIndex': index,
|
||||
'subIndex': -1,
|
||||
'sort': displaySort++,
|
||||
'targetSort': _num(item['sort']),
|
||||
'questionTypeName': item['questionTypeName'] ?? '题目',
|
||||
'userAnswer': item['userAnswer'] ?? '',
|
||||
'correctAnswer': item['correctAnswer'] ?? item['answer'] ?? '',
|
||||
'answerStatus': item['answerStatus'] ?? 0,
|
||||
'bkTagMetaList': item['bkTagMetaList'] ?? const [],
|
||||
};
|
||||
list.add({...question, 'answered': isQuestionAnswered(question)});
|
||||
});
|
||||
|
||||
final filtered = list.where((item) {
|
||||
final sort = _num(item['sort']);
|
||||
if (sort <= 0) return false;
|
||||
if (isExamAnswerCard == true) return true;
|
||||
return _num(item['targetSort']) > 0 || sort > 0;
|
||||
}).toList();
|
||||
filtered.sort((a, b) => _num(a['sort']).compareTo(_num(b['sort'])));
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/// 按相邻 questionTypeName 分组,供答题卡渲染
|
||||
List<Map<String, dynamic>> buildExamAnswerCard(
|
||||
List<dynamic>? questionsList, {
|
||||
bool isExamAnswerCard = true,
|
||||
}) {
|
||||
final sortedQuestions = buildExamAnswerCardQuestions(
|
||||
questionsList,
|
||||
isExamAnswerCard: isExamAnswerCard,
|
||||
);
|
||||
final result = <Map<String, dynamic>>[];
|
||||
Map<String, dynamic>? currentGroup;
|
||||
|
||||
for (final question in sortedQuestions) {
|
||||
final typeName = '${question['questionTypeName'] ?? '题目'}';
|
||||
if (currentGroup == null || currentGroup['name'] != typeName) {
|
||||
currentGroup = {
|
||||
'name': typeName,
|
||||
'questions': <Map<String, dynamic>>[],
|
||||
};
|
||||
result.add(currentGroup);
|
||||
}
|
||||
(currentGroup['questions'] as List).add({
|
||||
...question,
|
||||
'status': question['answerStatus'],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/// ============================================================
|
||||
/// 题干媒体拆分(1:1 移植 Vue utils/extractMediaFromStem.js)
|
||||
///
|
||||
/// 从题干 HTML 中拆出内嵌 audio/video,避免富文本直接渲染媒体异常。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
class StemMediaBlock {
|
||||
const StemMediaBlock({required this.type, this.content, this.audioUrl, this.videoUrl, this.videoCoverUrl});
|
||||
|
||||
/// html / audio / video
|
||||
final String type;
|
||||
final String? content;
|
||||
final String? audioUrl;
|
||||
final String? videoUrl;
|
||||
final String? videoCoverUrl;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'type': type,
|
||||
if (content != null) 'content': content,
|
||||
if (audioUrl != null) 'audioUrl': audioUrl,
|
||||
if (videoUrl != null) 'videoUrl': videoUrl,
|
||||
if (videoCoverUrl != null) 'videoCoverUrl': videoCoverUrl,
|
||||
};
|
||||
}
|
||||
|
||||
class StemMedia {
|
||||
const StemMedia({
|
||||
this.content = '',
|
||||
this.audios = const [],
|
||||
this.videos = const [],
|
||||
this.blocks = const [],
|
||||
});
|
||||
|
||||
final String content;
|
||||
final List<Map<String, dynamic>> audios;
|
||||
final List<Map<String, dynamic>> videos;
|
||||
final List<StemMediaBlock> blocks;
|
||||
}
|
||||
|
||||
String _normalizeMediaAttr(String? value) =>
|
||||
(value ?? '').replaceAll(RegExp(r'''[`'"\s]'''), '').trim();
|
||||
|
||||
String? _firstNonEmpty(List<String?> groups) {
|
||||
for (final g in groups) {
|
||||
if (g != null && g.isNotEmpty) return g;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
({String src, String poster}) _extractMediaAttrs(String tagHtml) {
|
||||
|
||||
final srcMatch = RegExp(
|
||||
r'''\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(tagHtml);
|
||||
final posterMatch = RegExp(
|
||||
r'''\bposter\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(tagHtml);
|
||||
final sourceMatch = RegExp(
|
||||
r'''<source[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(tagHtml);
|
||||
|
||||
final src = _normalizeMediaAttr(_firstNonEmpty([
|
||||
sourceMatch?.group(1),
|
||||
sourceMatch?.group(2),
|
||||
sourceMatch?.group(3),
|
||||
srcMatch?.group(1),
|
||||
srcMatch?.group(2),
|
||||
srcMatch?.group(3),
|
||||
]));
|
||||
final poster = _normalizeMediaAttr(_firstNonEmpty([
|
||||
posterMatch?.group(1),
|
||||
posterMatch?.group(2),
|
||||
posterMatch?.group(3),
|
||||
]));
|
||||
return (src: src, poster: poster);
|
||||
}
|
||||
|
||||
StemMedia extractMediaFromStem(String? html) {
|
||||
if (html == null || html.isEmpty) {
|
||||
return const StemMedia();
|
||||
}
|
||||
|
||||
final rawHtml = html;
|
||||
final mediaTagReg = RegExp(
|
||||
r'<audio\b[\s\S]*?</audio>|<video\b[\s\S]*?</video>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final audios = <Map<String, dynamic>>[];
|
||||
final videos = <Map<String, dynamic>>[];
|
||||
final blocks = <StemMediaBlock>[];
|
||||
var lastIndex = 0;
|
||||
|
||||
void pushHtmlBlock(String content) {
|
||||
if (content.trim().isEmpty) return;
|
||||
blocks.add(StemMediaBlock(type: 'html', content: content));
|
||||
}
|
||||
|
||||
for (final m in mediaTagReg.allMatches(rawHtml)) {
|
||||
final tagHtml = m.group(0)!;
|
||||
final offset = m.start;
|
||||
pushHtmlBlock(rawHtml.substring(lastIndex, offset));
|
||||
|
||||
if (RegExp(r'^<audio\b', caseSensitive: false).hasMatch(tagHtml)) {
|
||||
final attrs = _extractMediaAttrs(tagHtml);
|
||||
if (attrs.src.isNotEmpty) {
|
||||
final audioItem = {'audioUrl': attrs.src};
|
||||
audios.add(audioItem);
|
||||
blocks.add(StemMediaBlock(type: 'audio', audioUrl: attrs.src));
|
||||
}
|
||||
} else {
|
||||
final attrs = _extractMediaAttrs(tagHtml);
|
||||
if (attrs.src.isNotEmpty) {
|
||||
final videoItem = {
|
||||
'videoUrl': attrs.src,
|
||||
'videoCoverUrl': attrs.poster,
|
||||
};
|
||||
videos.add(videoItem);
|
||||
blocks.add(StemMediaBlock(
|
||||
type: 'video',
|
||||
videoUrl: attrs.src,
|
||||
videoCoverUrl: attrs.poster,
|
||||
));
|
||||
}
|
||||
}
|
||||
lastIndex = offset + tagHtml.length;
|
||||
}
|
||||
|
||||
pushHtmlBlock(rawHtml.substring(lastIndex));
|
||||
|
||||
var content = rawHtml
|
||||
.replaceAll(RegExp(r'<audio\b[\s\S]*?</audio>', caseSensitive: false), '')
|
||||
.replaceAll(RegExp(r'<video\b[\s\S]*?</video>', caseSensitive: false), '');
|
||||
|
||||
content = content
|
||||
.replaceAll(
|
||||
RegExp(
|
||||
r'<(div|p)([^>]*)>\s*(?:<br\s*/?>| |\s)*</\1>',
|
||||
caseSensitive: false,
|
||||
),
|
||||
'',
|
||||
)
|
||||
.trim();
|
||||
|
||||
return StemMedia(content: content, audios: audios, videos: videos, blocks: blocks);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/// ============================================================
|
||||
/// 公式渲染(对标 Vue utils/formula.js 的 renderLatexInHtml)
|
||||
///
|
||||
/// 注意:Vue 端该函数第 960 行 `return content` 是空操作(公式已由
|
||||
/// 后端转成 SVG,见 RichTextContent 内置 flutter_svg 渲染)。
|
||||
/// Flutter 侧保持 identity,不引入任何 LaTeX 依赖。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
String renderLatexInHtml(String? content) => content ?? '';
|
||||
@@ -0,0 +1,139 @@
|
||||
/// ============================================================
|
||||
/// JS 正则兼容层(JRegExp / JMatch)
|
||||
///
|
||||
/// Vue 端大量解析脚本依赖 JavaScript 正则语义:
|
||||
/// - 带 g 标志时 exec() 从 lastIndex 开始,命中后把 lastIndex
|
||||
/// 推进到本次匹配末尾;失配后 lastIndex 归 0;
|
||||
/// - replace(regex, fn) 回调参数为 (match, p1..pn, offset, src);
|
||||
/// - 默认 i(忽略大小写)+ s(dotAll,. 匹配换行)。
|
||||
///
|
||||
/// Dart 的 RegExp 语法/语义与 JS 基本一致(支持反向引用 \1、
|
||||
/// 先行断言 (?=...),不支持后行断言),但没有 lastIndex 游标,
|
||||
/// 这里做一层薄封装,供移植代码保持原有 while(exec) 写法。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// JS 风格单次匹配结果
|
||||
class JMatch {
|
||||
JMatch(this._m, this.source);
|
||||
|
||||
final RegExpMatch _m;
|
||||
|
||||
/// 原始字符串
|
||||
final String source;
|
||||
|
||||
/// 匹配起始下标(JS match.index)
|
||||
int get index => _m.start;
|
||||
|
||||
/// 匹配结束下标
|
||||
int get end => _m.end;
|
||||
|
||||
/// 整个匹配文本(JS match[0])
|
||||
String get group0 => _m.group(0)!;
|
||||
|
||||
/// 捕获组总数
|
||||
int get groupCount => _m.groupCount;
|
||||
|
||||
/// 取捕获组(JS match[n]),无匹配返回 null
|
||||
String? call(int n) => _m.group(n);
|
||||
|
||||
/// 取捕获组,空串也按空串返回;不存在才返回 null
|
||||
String? groupOrNull(int n) =>
|
||||
n <= _m.groupCount ? _m.group(n) : null;
|
||||
|
||||
RegExpMatch get raw => _m;
|
||||
}
|
||||
|
||||
/// JS 风格正则(默认 global + ignoreCase + dotAll)
|
||||
class JRegExp {
|
||||
JRegExp(
|
||||
String pattern, {
|
||||
this.global = true,
|
||||
bool caseInsensitive = true,
|
||||
bool dotAll = true,
|
||||
bool multiLine = false,
|
||||
}) : _re = RegExp(
|
||||
pattern,
|
||||
caseSensitive: !caseInsensitive,
|
||||
dotAll: dotAll,
|
||||
multiLine: multiLine,
|
||||
);
|
||||
|
||||
final RegExp _re;
|
||||
final bool global;
|
||||
|
||||
/// JS regex.lastIndex
|
||||
int lastIndex = 0;
|
||||
|
||||
/// JS regex.exec(string)
|
||||
JMatch? exec(String input) {
|
||||
if (lastIndex < 0) lastIndex = 0;
|
||||
if (lastIndex > input.length) {
|
||||
lastIndex = 0;
|
||||
return null;
|
||||
}
|
||||
RegExpMatch? found;
|
||||
for (final m in _re.allMatches(input, lastIndex)) {
|
||||
found = m;
|
||||
break;
|
||||
}
|
||||
if (found == null) {
|
||||
lastIndex = 0;
|
||||
return null;
|
||||
}
|
||||
final match = JMatch(found, input);
|
||||
if (global) {
|
||||
// 零宽匹配时 JS 会让 lastIndex 前进 1 位,避免死循环
|
||||
lastIndex = found.end == found.start ? found.end + 1 : found.end;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/// 重置游标
|
||||
void reset() => lastIndex = 0;
|
||||
|
||||
/// JS String.match(regex):非 global 返回首个匹配,global 返回全量
|
||||
List<JMatch>? all(String input) {
|
||||
if (!global) {
|
||||
final m = _re.firstMatch(input);
|
||||
return m == null ? null : [JMatch(m, input)];
|
||||
}
|
||||
return _re.allMatches(input).map((m) => JMatch(m, input)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// 非全局取首个匹配(对标 string.match(/.../i))
|
||||
JMatch? jsFirstMatch(String input, String pattern, {bool dotAll = true}) {
|
||||
final m = RegExp(pattern, dotAll: dotAll).firstMatch(input);
|
||||
return m == null ? null : JMatch(m, input);
|
||||
}
|
||||
|
||||
/// 对标 JS parseInt(value, radix):允许前导空白、前缀数字,尾部垃圾忽略
|
||||
int? jsParseInt(dynamic value, [int radix = 10]) {
|
||||
if (value == null) return null;
|
||||
var s = '$value'.trim();
|
||||
if (s.isEmpty) return null;
|
||||
if (radix == 10) {
|
||||
final m = RegExp(r'^[+-]?\d+').firstMatch(s);
|
||||
if (m == null) return null;
|
||||
return int.tryParse(m.group(0)!);
|
||||
}
|
||||
return int.tryParse(s, radix: radix);
|
||||
}
|
||||
|
||||
/// 对标 JS Number(value)(仅在需要时使用)
|
||||
num? jsNumber(dynamic value) {
|
||||
if (value == null) return 0;
|
||||
if (value is num) return value;
|
||||
final s = '$value'.trim();
|
||||
if (s.isEmpty) return 0;
|
||||
return num.tryParse(s);
|
||||
}
|
||||
|
||||
/// 对标 JS String.prototype.replace(regex, '$1') 中最常用的形态:
|
||||
/// 用捕获组 1 替换每处匹配
|
||||
String replaceWithGroup1(String input, String pattern) {
|
||||
return input.replaceAllMapped(RegExp(pattern, dotAll: true), (m) {
|
||||
return m.group(1) ?? '';
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/// ============================================================
|
||||
/// 题型 / 学科常量(对标 Vue utils/constant.js)
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 根据 questionTypeId 和 isObjective 获取对应的题型代码
|
||||
class QuestionTypeCodeMap {
|
||||
QuestionTypeCodeMap._();
|
||||
|
||||
/// 客观题(isObjective=true 时查这张表)
|
||||
static const Map<String, String> objective = {
|
||||
'12352055339057157': 'danxuanti', // 单选题
|
||||
'12352065806598149': 'duoxuanti', // 多选题
|
||||
'12352066848751621': 'panduanti', // 判断题
|
||||
'792156155195462': 'panduanti', // 判断题(临时匹配数据,待定)
|
||||
'805449231827078': 'qixuanwu', // 七选五
|
||||
'807952051372102': 'qixuanwu', // 七选五
|
||||
'12357397088370693': 'wanxingtiankongxuanzeti', // 完形填空选择题
|
||||
'12357391078981637': 'yueduxuanzeti', // 阅读选择题
|
||||
'806897094647878': 'wendati', // 问答题
|
||||
'12357387196366853': 'tingli', // 听力
|
||||
'792171174588486': 'yuedulijie', // 阅读理解
|
||||
'12352067804725253': 'tiankongti', // 填空题
|
||||
'12352069399805957': 'jiedati', // 解答题
|
||||
'12352070327861253': 'jisuanti', // 计算题
|
||||
'12352071266336773': 'yingyongti', // 应用题
|
||||
'12352072842215429': 'shiyanti', // 实验题
|
||||
'12357399440457733': 'xiezuo', // 写作
|
||||
'12361758319509574': 'zhengmingti', // 证明题
|
||||
};
|
||||
|
||||
/// 主观题(isObjective=false 时查这张表)
|
||||
static const Map<String, String> subjective = {
|
||||
'12352055339057157': 'danxuanti', // 单选题
|
||||
'12352065806598149': 'duoxuanti', // 多选题
|
||||
'12352066848751621': 'panduanti', // 判断题
|
||||
'792156155195462': 'panduanti', // 判断题(临时匹配数据,待定)
|
||||
'792171174588486': 'yuedulijie', // 阅读理解
|
||||
'12352067804725253': 'tiankongti', // 填空题
|
||||
'12352069399805957': 'jiedati', // 解答题
|
||||
'12352070327861253': 'jisuanti', // 计算题
|
||||
'12352071266336773': 'yingyongti', // 应用题
|
||||
'12352072842215429': 'shiyanti', // 实验题
|
||||
'12357387196366853': 'tingli', // 听力
|
||||
'12357391078981637': 'yueduxuanzeti', // 阅读选择题
|
||||
'12357397088370693': 'wanxingtiankongxuanzeti', // 完形填空选择题
|
||||
'12357399440457733': 'xiezuo', // 写作
|
||||
'12361758319509574': 'zhengmingti', // 证明题
|
||||
'805449231827078': 'qixuanwu', // 七选五
|
||||
'807952051372102': 'qixuanwu', // 七选五
|
||||
'806897094647878': 'wendati', // 问答题
|
||||
};
|
||||
|
||||
/// 通过 Code 获取题型
|
||||
static const Map<String, String> typeCode = {
|
||||
'1': 'danxuanti', // 单选题
|
||||
'2': 'duoxuanti', // 多选题
|
||||
'3': 'panduanti', // 判断题
|
||||
'4': 'tiankongti', // 填空题
|
||||
'5': 'jiedati', // 解答题
|
||||
'9': 'tingli', // 听力
|
||||
'qixuanwu': 'qixuanwu', // 七选五
|
||||
'wangxingtiankong': 'wanxingtiankongxuanzeti', // 完形填空选择题
|
||||
'wendati': 'wendati', // 问答题
|
||||
'yueduljie': 'yuedulijie', // 阅读理解
|
||||
};
|
||||
}
|
||||
|
||||
/// 学科作答方式(type:0=输入框,1=图片作答/手写作答)
|
||||
class SubjectInfo {
|
||||
const SubjectInfo(this.id, this.name, this.type);
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final int type;
|
||||
}
|
||||
|
||||
const List<SubjectInfo> kSubjectList = [
|
||||
SubjectInfo(1, '语文', 0),
|
||||
SubjectInfo(2, '数学', 1),
|
||||
SubjectInfo(3, '英语', 0),
|
||||
SubjectInfo(4, '物理', 1),
|
||||
SubjectInfo(5, '化学', 1),
|
||||
SubjectInfo(6, '生物学', 0),
|
||||
SubjectInfo(7, '政治(道法)', 0),
|
||||
SubjectInfo(8, '历史', 1),
|
||||
SubjectInfo(9, '地理', 1),
|
||||
SubjectInfo(100013, '艺起看世界 ', 0), // 只有客观题,没有主观题
|
||||
];
|
||||
|
||||
/// Vue: getSubjectTypeById —— 找不到返回 ''(load 处 `|| 0` 兜底)
|
||||
int? getSubjectTypeById(dynamic subjectId) {
|
||||
final id = int.tryParse('$subjectId');
|
||||
if (id == null || id == 0) return null;
|
||||
for (final item in kSubjectList) {
|
||||
if (item.id == id) return item.type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/// ============================================================
|
||||
/// 题目解析总入口(1:1 移植 Vue utils/questionParser.ts)
|
||||
///
|
||||
/// parseQuestionData:根据 hasChild/childs 分发给 Standard /
|
||||
/// NonStandard 解析器,并支持把后端原始 childs 按索引合并回来。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'js_regex.dart';
|
||||
import 'question_parser_base.dart';
|
||||
import 'question_parser_standard.dart';
|
||||
import 'question_parser_non_standard.dart';
|
||||
|
||||
export 'question_parser_base.dart';
|
||||
export 'question_parser_standard.dart' show parseStandardQuestion, analysisTextOf;
|
||||
export 'question_parser_non_standard.dart';
|
||||
|
||||
/// 对标 Vue formatQuestionText:去掉空格与换行
|
||||
String formatQuestionText(String? s) {
|
||||
if (s == null) return '';
|
||||
return s.replaceAll(RegExp(r'[ \r\n]'), '');
|
||||
}
|
||||
|
||||
/// 去掉 <strong class="hxdb-placeholder">...</strong> 占位盒
|
||||
String _stripHxdbPlaceholder(String? html) {
|
||||
if (html == null || html.isEmpty) return '';
|
||||
var result = html;
|
||||
|
||||
result = result.replaceAll(
|
||||
RegExp(
|
||||
r"""<([a-z0-9]+)\b[^>]*\bclass\s*=\s*(["'])[^"']*\bhxdb-placeholder\b[^"']*\2[^>]*/>""",
|
||||
caseSensitive: false,
|
||||
),
|
||||
'',
|
||||
);
|
||||
|
||||
final startTagRegex = JRegExp(
|
||||
r"""<([a-z0-9]+)\b([^>]*)\bclass\s*=\s*(["'])([^"']*\bhxdb-placeholder\b[^"']*)\3([^>]*)>""",
|
||||
);
|
||||
while (true) {
|
||||
final match = startTagRegex.exec(result);
|
||||
if (match == null) break;
|
||||
final fullBlock = findFullElement(result, match);
|
||||
result = result.replaceFirst(fullBlock, '');
|
||||
startTagRegex.reset();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 按索引把原始 childs 合并回解析后的列表({...parsed, ...original})
|
||||
List<Map<String, dynamic>> _mergeItemListWithOriginalChilds(
|
||||
List<dynamic>? parsedList,
|
||||
List<dynamic> originalChilds,
|
||||
) {
|
||||
final list = parsedList ?? const [];
|
||||
final maxLen = list.length > originalChilds.length
|
||||
? list.length
|
||||
: originalChilds.length;
|
||||
if (maxLen == 0) return const [];
|
||||
|
||||
return List.generate(maxLen, (index) {
|
||||
final parsedItem = index < list.length && list[index] is Map
|
||||
? Map<String, dynamic>.from(list[index] as Map)
|
||||
: null;
|
||||
final original = index < originalChilds.length && originalChilds[index] is Map
|
||||
? Map<String, dynamic>.from(originalChilds[index] as Map)
|
||||
: null;
|
||||
if (parsedItem != null && original != null) {
|
||||
return {...parsedItem, ...original};
|
||||
}
|
||||
if (parsedItem != null) return parsedItem;
|
||||
return {...?original};
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mergeParsedWithOriginalChilds(
|
||||
Map<String, dynamic> parsed,
|
||||
List<dynamic> originalChilds,
|
||||
) {
|
||||
final mergedSubQuestions =
|
||||
_mergeItemListWithOriginalChilds(parsed['subQuestions'] as List?, originalChilds);
|
||||
// JS: parsed.childs 非空时用 childs,否则回退 subQuestions
|
||||
final parsedChildsSource = (parsed['childs'] is List &&
|
||||
(parsed['childs'] as List).isNotEmpty)
|
||||
? parsed['childs'] as List
|
||||
: (parsed['subQuestions'] as List? ?? const []);
|
||||
final mergedChilds =
|
||||
_mergeItemListWithOriginalChilds(parsedChildsSource, originalChilds);
|
||||
final mergedBkTagMetaList =
|
||||
_mergeItemListWithOriginalChilds(parsed['bkTagMetaList'] as List?, originalChilds);
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
'subQuestions': mergedSubQuestions,
|
||||
'childs': mergedChilds,
|
||||
'bkTagMetaList':
|
||||
mergedBkTagMetaList.isNotEmpty ? mergedBkTagMetaList : parsed['bkTagMetaList'],
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> _emptyParsed() => {
|
||||
'stem': '',
|
||||
'options': const <Map<String, String>>[],
|
||||
'isSelect': false,
|
||||
'answer': '',
|
||||
'analysis': '',
|
||||
'questionTypes': '',
|
||||
};
|
||||
|
||||
/// 解析单道后端题目
|
||||
Map<String, dynamic> parseQuestionData(
|
||||
Map<String, dynamic>? detail, {
|
||||
bool mergeOriginalChilds = false,
|
||||
}) {
|
||||
// 兼容外层包了一层 questions
|
||||
final questionData = (detail?['questions'] is Map
|
||||
? detail!['questions'] as Map
|
||||
: detail)
|
||||
as Map<String, dynamic>?;
|
||||
|
||||
if (questionData == null) return _emptyParsed();
|
||||
|
||||
final rawStem = _stripHxdbPlaceholder('${questionData['stem'] ?? ''}');
|
||||
final rawAnswer = '${questionData['answer'] ?? ''}';
|
||||
final rawCorrectAnswer = '${questionData['correctAnswer'] ?? ''}';
|
||||
final rawAnalysisValue = questionData['answerAnalysis'];
|
||||
final rawAnalysis = rawAnalysisValue is Map
|
||||
? '${rawAnalysisValue['analysis'] ?? ''}'
|
||||
: '${rawAnalysisValue ?? ''}';
|
||||
// 只有空标签的解析置空
|
||||
final analysis = cleanRichText(rawAnalysis, '').isNotEmpty ? rawAnalysis : '';
|
||||
|
||||
final hasChildsArray =
|
||||
questionData['childs'] is List && (questionData['childs'] as List).isNotEmpty;
|
||||
final originalChilds =
|
||||
hasChildsArray ? (questionData['childs'] as List) : const [];
|
||||
|
||||
final hasChilds = questionData['hasChild'] == true && hasChildsArray;
|
||||
|
||||
final parsed = hasChilds
|
||||
? parseStandardQuestion(
|
||||
questionData, rawStem, rawAnswer, rawCorrectAnswer, analysis)
|
||||
: parseNonStandardQuestion(
|
||||
questionData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
|
||||
|
||||
if (mergeOriginalChilds && originalChilds.isNotEmpty) {
|
||||
return _mergeParsedWithOriginalChilds(parsed, originalChilds);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
/// ============================================================
|
||||
/// 题目解析基础工具(1:1 移植 Vue utils/questionParserBase.ts)
|
||||
///
|
||||
/// 包含:选项提取、平衡闭合标签查找、`<bk>` 标签解析、判断题处理、
|
||||
/// 题型代码、排序、富文本清洗、数学填空圈号、复合题题干格式化等。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'question_constant.dart';
|
||||
import 'js_regex.dart';
|
||||
|
||||
/// 填空占位元数据(对标 Vue BkTagMeta)
|
||||
typedef BkTagMeta = Map<String, dynamic>;
|
||||
|
||||
/// 统一的选项结构
|
||||
Map<String, String> makeOption(String label, String content) =>
|
||||
{'optionLabel': label, 'options': content};
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 选项提取
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/// 从 HTML 字符串中提取选项(hxdb-option-table-td / hxdb-sub-option-table-td)
|
||||
List<Map<String, String>> extractOptionsFromHtml(String html) {
|
||||
final options = <Map<String, String>>[];
|
||||
final tdRegex = JRegExp(
|
||||
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-(?:sub-)?option-table-td[^"]*"[^>]*>([\s\S]*?)</\1>',
|
||||
);
|
||||
const labelSrc = r'^(\s|<[^>]+>)*([A-Z])[\.\、\:\s]\s*( )?';
|
||||
var index = 0;
|
||||
JMatch? tdMatch;
|
||||
while ((tdMatch = tdRegex.exec(html)) != null) {
|
||||
final tdContent = tdMatch!(2) ?? '';
|
||||
final labelMatch = RegExp(labelSrc, caseSensitive: false).firstMatch(tdContent);
|
||||
var optionLabel = labelMatch != null ? labelMatch.group(2)! : '';
|
||||
if (optionLabel.isEmpty) {
|
||||
optionLabel = String.fromCharCode(65 + index);
|
||||
}
|
||||
// 去掉选项字母与分隔符,保留前面的空白/标签结构
|
||||
final cleanedContent = tdContent.replaceFirstMapped(
|
||||
RegExp(labelSrc, caseSensitive: false),
|
||||
(m) => m.group(1) ?? '',
|
||||
);
|
||||
options.add(makeOption(optionLabel, cleanedContent));
|
||||
index++;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/// 寻找平衡的闭合标签(解决嵌套 div/table 匹配问题)
|
||||
String findFullElement(String html, JMatch startTagMatch) {
|
||||
final startIdx = startTagMatch.index;
|
||||
final startTagFull = startTagMatch.group0;
|
||||
|
||||
final tagNameMatch = RegExp(r'^<([a-z0-9]+)', caseSensitive: false)
|
||||
.firstMatch(startTagFull);
|
||||
if (tagNameMatch == null) return startTagFull;
|
||||
|
||||
final tagName = tagNameMatch.group(1)!;
|
||||
var depth = 1;
|
||||
var currentPos = startIdx + startTagFull.length;
|
||||
|
||||
final tagRegex = JRegExp('<(\\/)?$tagName[^>]*>');
|
||||
tagRegex.lastIndex = currentPos;
|
||||
|
||||
JMatch? tagMatch;
|
||||
while ((tagMatch = tagRegex.exec(html)) != null) {
|
||||
if (tagMatch!(1) == '/') {
|
||||
depth--;
|
||||
} else {
|
||||
if (!tagMatch.group0.endsWith('/>')) {
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
if (depth == 0) {
|
||||
return html.substring(startIdx, tagMatch.end);
|
||||
}
|
||||
}
|
||||
return startTagFull;
|
||||
}
|
||||
|
||||
/// 剔除 stem 中的 hxdb-sub-option-table 选项表格
|
||||
String stripOptionTable(String html) {
|
||||
if (html.isEmpty) return '';
|
||||
var result = html;
|
||||
final tableRegex = JRegExp(
|
||||
r'<table[^>]*class="[^"]*hxdb-sub-option-table[^"]*"[^>]*>',
|
||||
);
|
||||
while (true) {
|
||||
final match = tableRegex.exec(result);
|
||||
if (match == null) break;
|
||||
final fullBlock = findFullElement(result, match);
|
||||
result = result.replaceFirst(fullBlock, '');
|
||||
tableRegex.reset();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 纯文本 A. xxx 选项兜底提取
|
||||
List<Map<String, String>> extractPlainTextOptions(String html) {
|
||||
final result = <Map<String, String>>[];
|
||||
final plain = html
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '\n')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll(RegExp(r'\n{2,}'), '\n')
|
||||
.trim();
|
||||
final lines = plain.split('\n').where((l) => l.trim().isNotEmpty);
|
||||
final optionRegex = RegExp(r'^([A-Z])[\.\、\s]\s*(.+)$');
|
||||
for (final line in lines) {
|
||||
final m = optionRegex.firstMatch(line.trim());
|
||||
if (m != null) {
|
||||
final label = m.group(1)!;
|
||||
result.add(makeOption(
|
||||
label,
|
||||
'<span class="hxdb-option-label">$label.</span> ${m.group(2)!.trim()}',
|
||||
));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// <bk> 填空标签解析
|
||||
// ------------------------------------------------------------
|
||||
|
||||
final RegExp _attrIndexRe = RegExp(
|
||||
r'''\bindex\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final RegExp _attrTypeRe = RegExp(
|
||||
r'''\btype\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final RegExp _attrDataInputRe = RegExp(
|
||||
r'''\bdata-input-type\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))''',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
String? _attrValue(RegExpMatch? m) =>
|
||||
m == null ? null : (m.group(2) ?? m.group(3));
|
||||
|
||||
/// 解析填空题的 `<bk>` 标签,生成每个空的元数据
|
||||
List<BkTagMeta> parseBkTagMetaFromStem(
|
||||
String? stem,
|
||||
String? correctAnswerHtml,
|
||||
String? userAnswerJson, [
|
||||
dynamic iSFilllankItemUpload,
|
||||
List<Map<String, String>>? options,
|
||||
]) {
|
||||
if (stem == null || stem.isEmpty) return const [];
|
||||
final correctAnswerMap = correctAnswerHtml != null && correctAnswerHtml.isNotEmpty
|
||||
? parseCorrectAnswerMapFromHtml(correctAnswerHtml)
|
||||
: <int, String>{};
|
||||
final userAnswerMap = parseUserAnswerMapFromJson(
|
||||
userAnswerJson,
|
||||
jsNumber(iSFilllankItemUpload) == 1,
|
||||
);
|
||||
|
||||
final list = <BkTagMeta>[];
|
||||
final seenIndexes = <int>{};
|
||||
final tagRegex = JRegExp(r'<bk\b([^>]*)>', caseInsensitive: true);
|
||||
JMatch? m;
|
||||
while ((m = tagRegex.exec(stem)) != null) {
|
||||
final attrs = m!(1) ?? '';
|
||||
final indexMatch = _attrIndexRe.firstMatch(attrs);
|
||||
if (indexMatch == null) continue;
|
||||
|
||||
final index = jsNumber(_attrValue(indexMatch))?.toInt() ?? 0;
|
||||
if (index <= 0) continue;
|
||||
if (seenIndexes.contains(index)) continue;
|
||||
seenIndexes.add(index);
|
||||
|
||||
final typeMatch = _attrTypeRe.firstMatch(attrs);
|
||||
final dataInputTypeMatch = _attrDataInputRe.firstMatch(attrs);
|
||||
|
||||
final item = <String, dynamic>{
|
||||
'index': index,
|
||||
'type': _attrValue(typeMatch) ?? '',
|
||||
'dataInputType': _attrValue(dataInputTypeMatch) ?? '',
|
||||
'correctAnswer': correctAnswerMap[index] ?? '',
|
||||
'options': options ?? const [],
|
||||
};
|
||||
|
||||
final fromUser = userAnswerMap[index];
|
||||
final isUpload = jsNumber(iSFilllankItemUpload) == 1;
|
||||
final myAnswer = fromUser?['myAnswer'];
|
||||
final hasImgArray = myAnswer is List &&
|
||||
myAnswer.isNotEmpty &&
|
||||
(myAnswer.first is Map) &&
|
||||
(myAnswer.first as Map)['url'] != null;
|
||||
|
||||
if (isUpload || hasImgArray) {
|
||||
final imgs = myAnswer is List ? myAnswer : const [];
|
||||
if (imgs.isNotEmpty) item['myAnswer'] = imgs;
|
||||
} else {
|
||||
final my = myAnswer is String ? myAnswer.trim() : '';
|
||||
if (my.isNotEmpty) item['myAnswer'] = my;
|
||||
}
|
||||
if (fromUser?['answerStatus'] is int) {
|
||||
item['answerStatus'] = fromUser!['answerStatus'];
|
||||
}
|
||||
list.add(item);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
String _normalizeAnswerUrl(dynamic u) =>
|
||||
'$u'.replaceAll(RegExp(r'[`\s]'), '').trim();
|
||||
|
||||
/// 将用户作答 JSON 字符串解析为 {空索引: {myAnswer, answerStatus}}
|
||||
Map<int, Map<String, dynamic>> parseUserAnswerMapFromJson([
|
||||
String? userAnswerJson,
|
||||
bool isUpload = false,
|
||||
]) {
|
||||
final map = <int, Map<String, dynamic>>{};
|
||||
if (userAnswerJson == null || userAnswerJson.isEmpty) return map;
|
||||
dynamic parsed;
|
||||
try {
|
||||
parsed = _jsonDecode(userAnswerJson);
|
||||
} catch (_) {
|
||||
return map;
|
||||
}
|
||||
if (parsed is! List) return map;
|
||||
|
||||
for (final rawItem in parsed) {
|
||||
if (rawItem is! Map) continue;
|
||||
final item = Map<String, dynamic>.from(rawItem);
|
||||
final idx = jsNumber(item['index'] ?? item['Index'])?.toInt() ?? 0;
|
||||
if (idx <= 0) continue;
|
||||
|
||||
final hasImg = item['Url'] != null ||
|
||||
item['url'] != null ||
|
||||
item['ThumbnailUrl'] != null ||
|
||||
item['thumbnailUrl'] != null;
|
||||
|
||||
if (isUpload || hasImg) {
|
||||
if (item.containsKey('myAnswer') || item.containsKey('answerStatus')) {
|
||||
dynamic myAnswer = item['myAnswer'];
|
||||
if (myAnswer is String) {
|
||||
try {
|
||||
final arr = _jsonDecode(myAnswer);
|
||||
myAnswer = arr is List ? arr : <dynamic>[];
|
||||
} catch (_) {
|
||||
myAnswer = <dynamic>[];
|
||||
}
|
||||
}
|
||||
if (myAnswer is! List) myAnswer = <dynamic>[];
|
||||
|
||||
final answerStatus = item['answerStatus'];
|
||||
final payload = <String, dynamic>{};
|
||||
if (myAnswer.isNotEmpty) payload['myAnswer'] = myAnswer;
|
||||
if (answerStatus is int) payload['answerStatus'] = answerStatus;
|
||||
|
||||
final merged = Map<String, dynamic>.from(map[idx] ?? const {});
|
||||
if (payload.containsKey('myAnswer')) merged['myAnswer'] = payload['myAnswer'];
|
||||
if (payload.containsKey('answerStatus')) {
|
||||
merged['answerStatus'] = payload['answerStatus'];
|
||||
}
|
||||
if (merged.containsKey('myAnswer') || merged.containsKey('answerStatus')) {
|
||||
map[idx] = merged;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
final url = _normalizeAnswerUrl(item['Url'] ?? item['url']);
|
||||
if (url.isEmpty) continue;
|
||||
final thumbnailUrl = _normalizeAnswerUrl(
|
||||
item['ThumbnailUrl'] ?? item['thumbnailUrl'] ?? url,
|
||||
);
|
||||
final type = item['Type'] ?? item['type'] ?? 0;
|
||||
final imgItem = <String, dynamic>{
|
||||
...item,
|
||||
'index': idx,
|
||||
'type': type,
|
||||
'url': url,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
};
|
||||
|
||||
final existing = map[idx];
|
||||
final list = existing?['myAnswer'] is List
|
||||
? List<dynamic>.from(existing!['myAnswer'] as List)
|
||||
: <dynamic>[];
|
||||
list.add(imgItem);
|
||||
map[idx] = {
|
||||
...?existing,
|
||||
'myAnswer': list,
|
||||
};
|
||||
} else {
|
||||
final myAnswer = item['myAnswer'] is String ? item['myAnswer'] as String : '';
|
||||
final answerStatus = item['answerStatus'];
|
||||
final payload = <String, dynamic>{};
|
||||
if (myAnswer.trim().isNotEmpty) payload['myAnswer'] = myAnswer;
|
||||
if (answerStatus is int) payload['answerStatus'] = answerStatus;
|
||||
if (payload.containsKey('myAnswer') || payload.containsKey('answerStatus')) {
|
||||
map[idx] = payload;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// 从标准答案 HTML 中按 data-index 切分 hxdb-subquestion 块
|
||||
Map<int, String> parseCorrectAnswerMapFromHtml(String html) {
|
||||
final result = <int, String>{};
|
||||
if (html.isEmpty) return result;
|
||||
|
||||
final startTagRegex = JRegExp(
|
||||
r'''<div\b([^>]*)\bclass\s*=\s*(["'])([^"']*\bhxdb-subquestion\b[^"']*)\2([^>]*)>''',
|
||||
);
|
||||
JMatch? match;
|
||||
while ((match = startTagRegex.exec(html)) != null) {
|
||||
final attrs = '${match!(1) ?? ''} ${match(3) ?? ''} ${match(4) ?? ''}';
|
||||
final indexMatch = RegExp(
|
||||
r'''\bdata-index\s*=\s*(["'])(\d+)\1''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(attrs);
|
||||
if (indexMatch == null) continue;
|
||||
final idx = int.tryParse(indexMatch.group(2)!) ?? 0;
|
||||
if (idx <= 0) continue;
|
||||
final fullBlock = findFullElement(html, match);
|
||||
result[idx] = fullBlock;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 题型 / 排序 / 答案清洗
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/// 判断题识别:命中则向 options 补“正确/错误”两项
|
||||
bool processPanDuan(Map<String, dynamic>? detail, List<Map<String, String>> options) {
|
||||
final answer = '${detail?['answer'] ?? ''}';
|
||||
final typeId = '${detail?['questionTypeId'] ?? ''}';
|
||||
|
||||
final isPanduan = answer.contains('data-hxdb-type="panduanti"') ||
|
||||
typeId == '12352066848751621' ||
|
||||
typeId == '792156155195462';
|
||||
|
||||
if (isPanduan) {
|
||||
if (options.isEmpty) {
|
||||
options
|
||||
..add(makeOption('正确', '正确'))
|
||||
..add(makeOption('错误', '错误'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 题型代码(对标 Vue getQuestionTypeCode)
|
||||
String getQuestionTypeCode(dynamic typeId, dynamic isObjective, [dynamic typecodeKey]) {
|
||||
final id = '$typeId';
|
||||
if (typecodeKey != null &&
|
||||
'$typecodeKey'.isNotEmpty &&
|
||||
QuestionTypeCodeMap.typeCode.containsKey('$typecodeKey')) {
|
||||
return QuestionTypeCodeMap.typeCode['$typecodeKey']!;
|
||||
}
|
||||
if (isObjective == true) {
|
||||
return QuestionTypeCodeMap.objective[id] ?? '';
|
||||
}
|
||||
return QuestionTypeCodeMap.subjective[id] ?? '';
|
||||
}
|
||||
|
||||
/// 给题目(含子题)追加从 1 开始的连续 sort
|
||||
List<Map<String, dynamic>> appendQuestionSort(List<dynamic>? questions) {
|
||||
if (questions == null) return const [];
|
||||
var sort = 1;
|
||||
return questions.map((rawItem) {
|
||||
final item = rawItem is Map
|
||||
? Map<String, dynamic>.from(rawItem)
|
||||
: <String, dynamic>{};
|
||||
final hasChild = item['hasChild'] == true;
|
||||
final hasSubQuestions = item['subQuestions'] is List &&
|
||||
(item['subQuestions'] as List).isNotEmpty;
|
||||
|
||||
if (hasChild && hasSubQuestions) {
|
||||
final subQuestions = (item['subQuestions'] as List).map((rawSub) {
|
||||
final sub = Map<String, dynamic>.from(rawSub as Map);
|
||||
sub['sort'] = sort++;
|
||||
return sub;
|
||||
}).toList();
|
||||
return {
|
||||
...item,
|
||||
'subQuestions': subQuestions,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
'sort': sort++,
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 去 HTML 标签(对标 Vue stripAnswerPlain)
|
||||
String stripAnswerPlain(dynamic html) {
|
||||
if (html == null) return '';
|
||||
return '$html'.replaceAll(RegExp(r'<[^>]+>'), '').trim();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 富文本清洗
|
||||
// ------------------------------------------------------------
|
||||
|
||||
final RegExp _htmlAttrNameRe = RegExp(r'^[A-Za-z_:][A-Za-z0-9:._-]*$');
|
||||
|
||||
String _normalizeRichTextUrl(String value) =>
|
||||
value.replaceAll(RegExp(r'[`\s]'), '').trim();
|
||||
|
||||
/// 统一处理 HTML 中图片尺寸样式(Flutter 为非 MP 端,尺寸输出 px)
|
||||
String fixImageStyles(String? html) {
|
||||
if (html == null || html.isEmpty) return '';
|
||||
return html.replaceAllMapped(RegExp(r'<img\b([^>]*)>', caseSensitive: false),
|
||||
(match) {
|
||||
var newAttrs = match.group(1)!;
|
||||
var extractedWidth = '';
|
||||
var extractedHeight = '';
|
||||
|
||||
// 提取并移除原有 width/height 属性
|
||||
newAttrs = newAttrs.replaceAllMapped(
|
||||
RegExp(
|
||||
r'''\b(width|height)\s*=\s*(?:(["'])(.*?)\2|([^\s>]+))''',
|
||||
caseSensitive: false,
|
||||
),
|
||||
(m) {
|
||||
final attrName = (m.group(1) ?? '').toLowerCase();
|
||||
final val = m.group(3) ?? m.group(4) ?? '';
|
||||
if (attrName == 'width') extractedWidth = val;
|
||||
if (attrName == 'height') extractedHeight = val;
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
final styleObj = <String, String>{};
|
||||
if (RegExp(r'\bstyle\s*=\s*', caseSensitive: false).hasMatch(newAttrs)) {
|
||||
newAttrs = newAttrs.replaceAllMapped(
|
||||
RegExp(r'''\bstyle\s*=\s*(["'])([\s\S]*?)\1''', caseSensitive: false),
|
||||
(m) {
|
||||
final styleValue = m.group(2) ?? '';
|
||||
for (final item in styleValue.split(';')) {
|
||||
final idx = item.indexOf(':');
|
||||
if (idx > 0) {
|
||||
final key = item.substring(0, idx).trim().toLowerCase();
|
||||
final value = item.substring(idx + 1).trim();
|
||||
if (key.isNotEmpty && value.isNotEmpty) styleObj[key] = value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (styleObj['width'] != null && extractedWidth.isEmpty) {
|
||||
extractedWidth = styleObj['width']!;
|
||||
}
|
||||
if (styleObj['height'] != null && extractedHeight.isEmpty) {
|
||||
extractedHeight = styleObj['height']!;
|
||||
}
|
||||
|
||||
styleObj.remove('width');
|
||||
styleObj.remove('height');
|
||||
styleObj.remove('display');
|
||||
|
||||
// 非 MP-WEIXIN:纯数字尺寸输出 px
|
||||
String processSize(String sizeVal) {
|
||||
if (sizeVal.isEmpty) return '';
|
||||
sizeVal = sizeVal.trim().toLowerCase();
|
||||
if (RegExp(r'(ex|em|rem|%|vw|vh|rpx|auto)$').hasMatch(sizeVal)) {
|
||||
return sizeVal;
|
||||
}
|
||||
final numMatch = RegExp(r'^([0-9.]+)').firstMatch(sizeVal);
|
||||
if (numMatch != null) {
|
||||
final num = double.tryParse(numMatch.group(1)!);
|
||||
if (num != null) {
|
||||
return '${num}px';
|
||||
}
|
||||
}
|
||||
return sizeVal;
|
||||
}
|
||||
|
||||
final finalWidth = processSize(extractedWidth);
|
||||
final finalHeight = processSize(extractedHeight);
|
||||
|
||||
if (finalWidth.isNotEmpty) styleObj['width'] = finalWidth;
|
||||
if (finalHeight.isNotEmpty) styleObj['height'] = finalHeight;
|
||||
|
||||
styleObj['max-width'] = '100% !important';
|
||||
|
||||
if (finalWidth.isEmpty && finalHeight.isEmpty) {
|
||||
styleObj['display'] = 'block';
|
||||
styleObj['margin'] = '10rpx auto';
|
||||
} else {
|
||||
styleObj['display'] = 'inline-block !important';
|
||||
styleObj['vertical-align'] = 'middle !important';
|
||||
styleObj['padding'] = '5px 0 !important';
|
||||
styleObj['margin'] = '0 2px !important';
|
||||
}
|
||||
|
||||
final styleStr = styleObj.entries
|
||||
.map((e) => '${e.key}: ${e.value}')
|
||||
.join('; ');
|
||||
newAttrs += ' style="$styleStr"';
|
||||
return '<img$newAttrs>';
|
||||
});
|
||||
}
|
||||
|
||||
String _sanitizeHtmlAttributeValue(String name, String value) {
|
||||
var normalized = value.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '');
|
||||
if (RegExp(r'^(src|href|poster)$', caseSensitive: false).hasMatch(name)) {
|
||||
normalized = _normalizeRichTextUrl(normalized);
|
||||
} else if (name == 'style') {
|
||||
normalized = normalized.replaceAll('"', '').replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
}
|
||||
return normalized.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
String _sanitizeHtmlTag(String tag) {
|
||||
if (tag.isEmpty || tag.startsWith('</') || tag.startsWith('<!')) {
|
||||
return tag;
|
||||
}
|
||||
final tagMatch = RegExp(r'^<([A-Za-z0-9-]+)([\s\S]*?)(/?)>$').firstMatch(tag);
|
||||
if (tagMatch == null) return tag;
|
||||
|
||||
final tagName = tagMatch.group(1)!;
|
||||
final rawAttrs = tagMatch.group(2) ?? '';
|
||||
final selfClosing = tagMatch.group(3) ?? '';
|
||||
final attrs = <String>[];
|
||||
final attrRegex = RegExp(
|
||||
r'''([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?''',
|
||||
);
|
||||
for (final attrMatch in attrRegex.allMatches(rawAttrs)) {
|
||||
final attrName = (attrMatch.group(1) ?? '').trim();
|
||||
if (!_htmlAttrNameRe.hasMatch(attrName)) continue;
|
||||
|
||||
final hasValue = attrMatch.group(2) != null ||
|
||||
attrMatch.group(3) != null ||
|
||||
attrMatch.group(4) != null;
|
||||
if (!hasValue) {
|
||||
attrs.add(attrName);
|
||||
continue;
|
||||
}
|
||||
final attrValue = attrMatch.group(2) ?? attrMatch.group(3) ?? attrMatch.group(4) ?? '';
|
||||
attrs.add('$attrName="${_sanitizeHtmlAttributeValue(attrName, attrValue)}"');
|
||||
}
|
||||
|
||||
return '<$tagName${attrs.isNotEmpty ? ' ${attrs.join(' ')}' : ''}${selfClosing.isNotEmpty ? ' /' : ''}>';
|
||||
}
|
||||
|
||||
/// 修复编辑器产生的破碎 style 属性(font-family 被拆碎等)
|
||||
String _repairMalformedTagAttributes(String html) {
|
||||
if (html.isEmpty) return '';
|
||||
return html.replaceAllMapped(RegExp(r'<[^>]+>'), (m) {
|
||||
var tag = m.group(0)!;
|
||||
if (tag.startsWith('</') || tag.startsWith('<!') ||
|
||||
!RegExp(r'\bstyle\s*=').hasMatch(tag)) {
|
||||
return tag;
|
||||
}
|
||||
final brokenFontFamily = RegExp(
|
||||
r'''\sstyle=(["'])\s*font-family\s*:\s*\1''',
|
||||
caseSensitive: false,
|
||||
).hasMatch(tag);
|
||||
if (!brokenFontFamily) return tag;
|
||||
|
||||
tag = tag.replaceAll(
|
||||
RegExp(r'''\sstyle=(["'])\s*font-family\s*:\s*\1''', caseSensitive: false),
|
||||
'',
|
||||
);
|
||||
tag = tag.replaceAll(
|
||||
RegExp(r'''\s+[a-zA-Z][a-zA-Z0-9-]*=""(?=[\s/>])'''),
|
||||
'',
|
||||
);
|
||||
tag = tag.replaceAll(RegExp(r'\s{2,}'), ' ');
|
||||
return tag;
|
||||
});
|
||||
}
|
||||
|
||||
/// 将 <audio>/<video> 内 <source src> 提升到父标签
|
||||
String _hoistMediaSourceToParent(String html) {
|
||||
if (html.isEmpty) return '';
|
||||
return html.replaceAllMapped(
|
||||
RegExp(r'<(audio|video)\b([^>]*)>([\s\S]*?)</\1>', caseSensitive: false),
|
||||
(m) {
|
||||
final tagName = m.group(1)!;
|
||||
final rawAttrs = m.group(2) ?? '';
|
||||
final innerHtml = m.group(3) ?? '';
|
||||
|
||||
final directSrcMatch = RegExp(
|
||||
r'''\bsrc\s*=\s*(["'])(.*?)\1''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(rawAttrs);
|
||||
final sourceSrcMatch = RegExp(
|
||||
r'''<source\b[^>]*\bsrc\s*=\s*(["'])(.*?)\1''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(innerHtml);
|
||||
final finalSrc = _normalizeRichTextUrl(
|
||||
(directSrcMatch?.group(2) ?? sourceSrcMatch?.group(2) ?? ''),
|
||||
);
|
||||
if (finalSrc.isEmpty) return m.group(0)!;
|
||||
|
||||
var nextAttrs = rawAttrs;
|
||||
if (directSrcMatch != null) {
|
||||
nextAttrs = nextAttrs.replaceFirst(directSrcMatch.group(0)!, 'src="$finalSrc"');
|
||||
} else {
|
||||
nextAttrs = '$nextAttrs src="$finalSrc"';
|
||||
}
|
||||
return '<$tagName$nextAttrs></$tagName>';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 富文本进入组件前的轻量清洗
|
||||
String sanitizeRichTextHtml(String? content) {
|
||||
if (content == null || content.isEmpty) return '';
|
||||
var result = fixImageStyles(content);
|
||||
result = _repairMalformedTagAttributes(result);
|
||||
result = _hoistMediaSourceToParent(result);
|
||||
|
||||
result = result.replaceAllMapped(
|
||||
RegExp(r'''\sstyle=(["'])([\s\S]*?)\1''', caseSensitive: false),
|
||||
(m) {
|
||||
final raw = m.group(2) ?? '';
|
||||
if (RegExp(r'^\s*font-family\s*:\s*;?\s*$', caseSensitive: false).hasMatch(raw)) {
|
||||
return '';
|
||||
}
|
||||
return m.group(0)!;
|
||||
},
|
||||
);
|
||||
|
||||
// 移除 KaTeX 隐藏 MathML
|
||||
result = result.replaceAll(
|
||||
RegExp(
|
||||
r'''<span\b[^>]*class=(["'])[^"']*\bkatex-mathml\b[^"']*\1[^>]*>[\s\S]*?</span>''',
|
||||
caseSensitive: false,
|
||||
),
|
||||
'',
|
||||
);
|
||||
result = result.replaceAllMapped(
|
||||
RegExp(
|
||||
r'''<(audio|video|img|source)\b[^>]*?(?:src|poster)\s*=\s*(["'])(.*?)\2''',
|
||||
caseSensitive: false,
|
||||
),
|
||||
(m) {
|
||||
final cleanedUrl = _normalizeRichTextUrl(m.group(3) ?? '');
|
||||
return m.group(0)!.replaceFirst(m.group(3)!, cleanedUrl);
|
||||
},
|
||||
);
|
||||
|
||||
return result.replaceAllMapped(
|
||||
RegExp(r'</?[A-Za-z][^>]*>'),
|
||||
(m) => _sanitizeHtmlTag(m.group(0)!),
|
||||
);
|
||||
}
|
||||
|
||||
/// 富文本清洗 + 空内容兜底
|
||||
String cleanRichText(String? content, [String defaultText = '']) {
|
||||
if (content == null || content.isEmpty) return defaultText;
|
||||
|
||||
var result = fixImageStyles(content);
|
||||
result = sanitizeRichTextHtml(result).replaceAll(
|
||||
RegExp(r'<br>\s*<br>'),
|
||||
'<br>',
|
||||
);
|
||||
result = result.replaceAllMapped(
|
||||
RegExp(r'''src=(["'])(.*?)\1'''),
|
||||
(m) => 'src=${m.group(1)}${(m.group(2) ?? '').replaceAll(RegExp(r'[`\s]'), '')}${m.group(1)}',
|
||||
);
|
||||
|
||||
final plainText = result
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||||
.replaceAll(' ', '')
|
||||
.trim();
|
||||
final hasImgOrVideo = RegExp(
|
||||
r'<(img|video|audio|iframe|embed|object|source)[^>]*>',
|
||||
caseSensitive: false,
|
||||
).hasMatch(result);
|
||||
|
||||
if (plainText.isEmpty && !hasImgOrVideo) return defaultText;
|
||||
return result.isEmpty ? defaultText : result;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 数学填空圈号
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const List<String> _circledNumbers = [
|
||||
'①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩',
|
||||
'⑪','⑫','⑬','⑭','⑮','⑯','⑰','⑱','⑲','⑳',
|
||||
'㉑','㉒','㉓','㉔','㉕','㉖','㉗','㉘','㉙','㉚',
|
||||
'㉛','㉜','㉝','㉞','㉟','㊱','㊲','㊳','㊴','㊵',
|
||||
'㊶','㊷','㊸','㊹','㊺','㊻','㊼','㊽','㊾','㊿',
|
||||
];
|
||||
|
||||
/// 将题干数字占位符替换成圈号(数学学科)
|
||||
String convertBlankToCircled(String? stem) {
|
||||
if (stem == null || stem.isEmpty) return stem ?? '';
|
||||
|
||||
// 1. ___数字___ → ___①___(跳过 URL/文件名场景)
|
||||
var result = stem.replaceAllMapped(
|
||||
RegExp(r'(_+)(\d+)(_+)', caseSensitive: false),
|
||||
(m) {
|
||||
final prevChar = m.start > 0 ? stem[m.start - 1] : '';
|
||||
final nextChar = m.end < stem.length ? stem[m.end] : '';
|
||||
if (RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(prevChar) ||
|
||||
RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(nextChar)) {
|
||||
return m.group(0)!;
|
||||
}
|
||||
final num = int.tryParse(m.group(2)!) ?? 0;
|
||||
if (num >= 1 && num <= 50) {
|
||||
return '${m.group(1)}${_circledNumbers[num - 1]}${m.group(3)}';
|
||||
}
|
||||
return m.group(0)!;
|
||||
},
|
||||
);
|
||||
|
||||
// 2. text-decoration:underline 标签内纯数字 → 圈号
|
||||
result = result.replaceAllMapped(
|
||||
RegExp(
|
||||
r'''(<[^>]*style=["'][^"']*text-decoration:\s*underline[^"']*["'][^>]*>)([\s\S]*?)(</[a-zA-Z][^>]*>)''',
|
||||
caseSensitive: false,
|
||||
),
|
||||
(m) {
|
||||
final openTag = m.group(1)!;
|
||||
final content = m.group(2)!;
|
||||
final closeTag = m.group(3)!;
|
||||
final textOnly = content
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||||
.replaceAll(' ', ' ')
|
||||
.trim();
|
||||
if (RegExp(r'^\d+$').hasMatch(textOnly)) {
|
||||
final num = int.tryParse(textOnly) ?? 0;
|
||||
if (num >= 1 && num <= 50) {
|
||||
final newOpenTag = openTag.replaceAll(
|
||||
RegExp(r'text-decoration:\s*underline', caseSensitive: false),
|
||||
'text-decoration:underline;text-underline-offset:3px',
|
||||
);
|
||||
return '$newOpenTag${content.replaceFirst(RegExp(r'\d+'), _circledNumbers[num - 1])}$closeTag';
|
||||
}
|
||||
}
|
||||
return m.group(0)!;
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 复合题题干:<bk> → 可点击 <a href="#subquestion-N">
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/// 格式化复合题题干(subQuestions 为解析后的子题 Map 列表)
|
||||
String formatCompoundStem(
|
||||
String? stem,
|
||||
int activeSubIndex, {
|
||||
bool disableHighlight = false,
|
||||
List<Map<String, dynamic>> subQuestions = const [],
|
||||
}) {
|
||||
if (stem == null || stem.isEmpty) return '暂无题干';
|
||||
var formattedStem = stem;
|
||||
|
||||
// 非自闭合 <bk ...>...</bk>
|
||||
formattedStem = formattedStem.replaceAllMapped(
|
||||
RegExp(r'<bk\b([^>]*)>([\s\S]*?)</bk>', caseSensitive: false),
|
||||
(m) {
|
||||
final attrs = m.group(1) ?? '';
|
||||
final inner = m.group(2) ?? '';
|
||||
final indexMatch = RegExp(
|
||||
r'''(?:^|\s)index\s*=(['"]?)([^'"\s>]+)\1''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(attrs);
|
||||
if (indexMatch == null) return m.group(0)!;
|
||||
final idx = (int.tryParse(indexMatch.group(2)!) ?? 1) - 1;
|
||||
final hasNumber = RegExp(r'\d').hasMatch(inner);
|
||||
final isOnlyNumber = RegExp(r'^\s*\d+\s*$').hasMatch(inner);
|
||||
var content = inner;
|
||||
if (!isOnlyNumber && !hasNumber) {
|
||||
content = '___${idx + 1}___';
|
||||
}
|
||||
return '<a data-idx="$idx">$content</a>';
|
||||
},
|
||||
);
|
||||
|
||||
// 自闭合 <bk ... />
|
||||
formattedStem = formattedStem.replaceAllMapped(
|
||||
RegExp(r'<bk\b([^>]*?)\s*/\s*>', caseSensitive: false),
|
||||
(m) {
|
||||
final attrs = m.group(1) ?? '';
|
||||
final indexMatch = RegExp(
|
||||
r'''(?:^|\s)index\s*=(['"]?)([^'"\s>]+)\1''',
|
||||
caseSensitive: false,
|
||||
).firstMatch(attrs);
|
||||
if (indexMatch == null) return m.group(0)!;
|
||||
final idx = (int.tryParse(indexMatch.group(2)!) ?? 1) - 1;
|
||||
return '<a data-idx="$idx">___${idx + 1}___</a>';
|
||||
},
|
||||
);
|
||||
|
||||
// 保护所有 HTML 标签,避免属性/标签内部文本被替换
|
||||
final tagPlaceholders = <String>[];
|
||||
var tagCounter = 0;
|
||||
final protectedStem = formattedStem.replaceAllMapped(
|
||||
RegExp(r'''<(?:[^>"']|"[^"]*"|'[^']*')*>'''),
|
||||
(m) {
|
||||
final token = '__TAG_${tagCounter++}__';
|
||||
tagPlaceholders.add(m.group(0)!);
|
||||
return token;
|
||||
},
|
||||
);
|
||||
|
||||
// 纯文本 ___数字___ → <a data-idx>
|
||||
var processedStem = protectedStem.replaceAllMapped(
|
||||
RegExp(r'(_+)(\d+)(_+)', caseSensitive: false),
|
||||
(m) {
|
||||
final src = m.input;
|
||||
final prevChar = m.start > 0 ? src[m.start - 1] : '';
|
||||
final nextChar = m.end < src.length ? src[m.end] : '';
|
||||
if (RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(prevChar) ||
|
||||
RegExp(r'[a-zA-Z0-9_\-./]').hasMatch(nextChar)) {
|
||||
return m.group(0)!;
|
||||
}
|
||||
final idx = (int.tryParse(m.group(2)!) ?? 1) - 1;
|
||||
return '<a data-idx="$idx">${m.group(0)}</a>';
|
||||
},
|
||||
);
|
||||
|
||||
processedStem = processedStem.replaceAllMapped(
|
||||
RegExp(r'__TAG_(\d+)__'),
|
||||
(m) => tagPlaceholders[int.parse(m.group(1)!)],
|
||||
);
|
||||
formattedStem = processedStem;
|
||||
|
||||
// 注入样式/激活态/跳转链接
|
||||
formattedStem = formattedStem.replaceAllMapped(
|
||||
RegExp(r'<a data-idx="(\d+)">([\s\S]*?)</a>', caseSensitive: false),
|
||||
(m) {
|
||||
final idxStr = m.group(1)!;
|
||||
final content = m.group(2) ?? '';
|
||||
final textOnly = content
|
||||
.replaceAll(RegExp(r'<[^>]+>'), '')
|
||||
.replaceAll(' ', ' ')
|
||||
.trim();
|
||||
final hasImg = RegExp(r'<img', caseSensitive: false).hasMatch(content);
|
||||
|
||||
if (textOnly.isEmpty && !hasImg) return content;
|
||||
|
||||
final dataIdx = int.tryParse(idxStr) ?? -1;
|
||||
var isActive = false;
|
||||
var targetSubIndex = dataIdx;
|
||||
|
||||
if (subQuestions.isNotEmpty) {
|
||||
if (dataIdx >= 0 && dataIdx < subQuestions.length) {
|
||||
targetSubIndex = dataIdx;
|
||||
isActive = dataIdx == activeSubIndex;
|
||||
} else {
|
||||
final numMatch = RegExp(r'\d+').firstMatch(textOnly);
|
||||
var matchedIndex = -1;
|
||||
if (numMatch != null) {
|
||||
final textNum = int.parse(numMatch.group(0)!);
|
||||
matchedIndex = subQuestions.indexWhere(
|
||||
(q) => jsNumber(q['sort'])?.toInt() == textNum);
|
||||
if (matchedIndex == -1) {
|
||||
matchedIndex = subQuestions.indexWhere((q) {
|
||||
final stemMatch = RegExp(
|
||||
r'''<label[^>]*>.*?(\d+)\.\s*(?: )*</label>|<p>.*?(\d+)\.\s*(?: )*</p>''',
|
||||
caseSensitive: false,
|
||||
dotAll: true,
|
||||
).firstMatch('${q['stem'] ?? ''}');
|
||||
if (stemMatch != null) {
|
||||
final n = int.tryParse(stemMatch.group(1) ?? stemMatch.group(2) ?? '');
|
||||
return n == textNum;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (matchedIndex != -1) {
|
||||
targetSubIndex = matchedIndex;
|
||||
isActive = matchedIndex == activeSubIndex;
|
||||
} else {
|
||||
targetSubIndex = -1;
|
||||
isActive = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isActive = dataIdx == activeSubIndex;
|
||||
}
|
||||
|
||||
const baseStyle =
|
||||
'text-decoration: none; color: inherit; cursor: pointer; display: inline-block; line-height: 1; margin: 0 4px; white-space: nowrap; word-break: keep-all; vertical-align: middle;';
|
||||
var activeStyle = 'border: 1px solid transparent; padding: 0 2px;';
|
||||
|
||||
if (isActive && !disableHighlight) {
|
||||
final shouldHighlight = RegExp(r'\d').hasMatch(textOnly) ||
|
||||
RegExp(r'_+').hasMatch(textOnly) ||
|
||||
hasImg;
|
||||
if (shouldHighlight) {
|
||||
activeStyle =
|
||||
'line-height:24rpx;height:24px;border: 1px solid rgba(61, 125, 245, 1); border-radius: 8px; background: rgba(41, 121, 255, 0.1); box-sizing: border-box; padding: 0 4px;';
|
||||
}
|
||||
}
|
||||
|
||||
return '<a href="#subquestion-$targetSubIndex" id="subquestion-$targetSubIndex" style="$baseStyle $activeStyle">$content</a>';
|
||||
},
|
||||
);
|
||||
|
||||
return formattedStem;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 内部工具
|
||||
// ------------------------------------------------------------
|
||||
|
||||
dynamic _jsonDecode(String text) => jsonDecode(text);
|
||||
@@ -0,0 +1,204 @@
|
||||
/// ============================================================
|
||||
/// 非标准题 / 单题解析器(1:1 移植 Vue utils/questionParserNonStandard.ts)
|
||||
///
|
||||
/// 处理不带 childs 的独立题目:单选 / 多选 / 判断 / 填空。
|
||||
/// 七选五与多空填空题会在本地构造 childs 后转交 Standard 解析器。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'question_parser_base.dart';
|
||||
import 'question_parser_standard.dart';
|
||||
import 'formula.dart';
|
||||
|
||||
Map<String, dynamic> parseNonStandardQuestion(
|
||||
Map<String, dynamic> questionData,
|
||||
String rawStemIn,
|
||||
String rawAnswer,
|
||||
String rawCorrectAnswer,
|
||||
String analysis,
|
||||
) {
|
||||
var rawStem = rawStemIn;
|
||||
final options = <Map<String, String>>[];
|
||||
var isSelect = questionData['isSelect'] == true;
|
||||
var isMainPanDuan = false;
|
||||
|
||||
// 1) 主判断题
|
||||
if (processPanDuan(questionData, options)) {
|
||||
isSelect = true;
|
||||
isMainPanDuan = true;
|
||||
}
|
||||
|
||||
// 2) 题干选项表
|
||||
final optionTableRegex = RegExp(
|
||||
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)</\1>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final match = optionTableRegex.firstMatch(rawStem);
|
||||
if (match != null) {
|
||||
isSelect = true;
|
||||
options.addAll(extractOptionsFromHtml(match.group(2) ?? ''));
|
||||
rawStem = rawStem.replaceFirst(optionTableRegex, '');
|
||||
}
|
||||
|
||||
var finalQuestionTypes = getQuestionTypeCode(
|
||||
questionData['questionTypeId'],
|
||||
questionData['isObjective'],
|
||||
questionData['questionTypeCode'],
|
||||
);
|
||||
|
||||
// 3) 纯文本选项兜底
|
||||
if (options.isEmpty &&
|
||||
(finalQuestionTypes == 'danxuanti' || finalQuestionTypes == 'duoxuanti')) {
|
||||
final plainOptions = extractPlainTextOptions(rawStem);
|
||||
if (plainOptions.isNotEmpty) {
|
||||
options.addAll(plainOptions);
|
||||
isSelect = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainPanDuan) {
|
||||
finalQuestionTypes = 'panduanti';
|
||||
}
|
||||
|
||||
// --- 七选五:按填空元数据构造 childs,转复合题 ---
|
||||
if (finalQuestionTypes == 'qixuanwu') {
|
||||
final metas = parseBkTagMetaFromStem(
|
||||
rawStem,
|
||||
rawCorrectAnswer,
|
||||
questionData['userAnswer']?.toString(),
|
||||
questionData['iSFilllankItemUpload'],
|
||||
options,
|
||||
);
|
||||
if (metas.isNotEmpty) {
|
||||
final indexes = (questionData['indexes'] as List?) ?? const [];
|
||||
final generatedChilds = List.generate(metas.length, (index) {
|
||||
final meta = metas[index];
|
||||
return <String, dynamic>{
|
||||
...meta,
|
||||
'id': '${questionData['id']}_${meta['index']}',
|
||||
'stem': '',
|
||||
'answer': meta['correctAnswer'],
|
||||
'isObjective': true,
|
||||
'questionTypes': 'qixuanwu_sub',
|
||||
'options': options,
|
||||
if (index < indexes.length && indexes[index] is Map)
|
||||
...Map<String, dynamic>.from(indexes[index] as Map),
|
||||
'sort': meta['index'],
|
||||
};
|
||||
});
|
||||
|
||||
final compositeData = <String, dynamic>{
|
||||
...questionData,
|
||||
'hasChild': true,
|
||||
'isObjective': false,
|
||||
'childs': generatedChilds,
|
||||
'questionTypes': 'qixuanwu',
|
||||
};
|
||||
return parseStandardQuestion(
|
||||
compositeData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 填空题:构造 childs 转复合题 ---
|
||||
if (finalQuestionTypes == 'tiankongti') {
|
||||
var metasList = parseBkTagMetaFromStem(
|
||||
rawStem,
|
||||
rawCorrectAnswer,
|
||||
questionData['userAnswer']?.toString(),
|
||||
questionData['iSFilllankItemUpload'],
|
||||
);
|
||||
if (metasList.isEmpty) {
|
||||
metasList = [
|
||||
{
|
||||
'index': 1,
|
||||
'type': 0,
|
||||
'dataInputType': 0,
|
||||
'correctAnswer': rawCorrectAnswer,
|
||||
'questionTypes': 'tiankongti',
|
||||
'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
|
||||
}
|
||||
];
|
||||
final userAnswerMap = parseUserAnswerMapFromJson(
|
||||
questionData['userAnswer']?.toString(),
|
||||
questionData['iSFilllankItemUpload'] == 1,
|
||||
);
|
||||
final fromUser = userAnswerMap[1];
|
||||
if (fromUser != null) {
|
||||
final isUpload = questionData['iSFilllankItemUpload'] == 1;
|
||||
final myAnswer = fromUser['myAnswer'];
|
||||
final hasImgArray = myAnswer is List &&
|
||||
myAnswer.isNotEmpty &&
|
||||
myAnswer.first is Map &&
|
||||
(myAnswer.first as Map)['url'] != null;
|
||||
if (isUpload || hasImgArray) {
|
||||
metasList[0]['myAnswer'] = myAnswer is List ? myAnswer : const [];
|
||||
} else {
|
||||
metasList[0]['myAnswer'] = myAnswer is String ? myAnswer.trim() : '';
|
||||
}
|
||||
if (fromUser['answerStatus'] is int) {
|
||||
metasList[0]['answerStatus'] = fromUser['answerStatus'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (metasList.isNotEmpty) {
|
||||
final indexes = (questionData['indexes'] as List?) ?? const [];
|
||||
final generatedChilds = List.generate(metasList.length, (index) {
|
||||
final meta = metasList[index];
|
||||
return <String, dynamic>{
|
||||
...meta,
|
||||
'id': '${questionData['id']}_${meta['index']}',
|
||||
'questionId': questionData['id'],
|
||||
'stem': '',
|
||||
'answer': meta['correctAnswer'],
|
||||
'isObjective': false,
|
||||
'questionTypes': 'tiankongti',
|
||||
'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
|
||||
if (index < indexes.length && indexes[index] is Map)
|
||||
...Map<String, dynamic>.from(indexes[index] as Map),
|
||||
'sort': meta['index'],
|
||||
};
|
||||
});
|
||||
final compositeData = <String, dynamic>{
|
||||
...questionData,
|
||||
'hasChild': true,
|
||||
'isObjective': false,
|
||||
'childs': generatedChilds,
|
||||
'questionTypes': 'tiankongti',
|
||||
'iSFilllankItemUpload': questionData['iSFilllankItemUpload'],
|
||||
};
|
||||
return parseStandardQuestion(
|
||||
compositeData, rawStem, rawAnswer, rawCorrectAnswer, analysis);
|
||||
}
|
||||
}
|
||||
|
||||
final optionSource = options.isNotEmpty
|
||||
? options
|
||||
: ((questionData['options'] as List?)?.whereType<Map>().toList() ?? const []);
|
||||
|
||||
return {
|
||||
...questionData,
|
||||
'raw': questionData,
|
||||
'stem': fixImageStyles(renderLatexInHtml(rawStem)),
|
||||
'options': optionSource.map((rawOpt) {
|
||||
final opt = Map<String, dynamic>.from(rawOpt);
|
||||
return {
|
||||
...opt,
|
||||
'options': fixImageStyles(renderLatexInHtml('${opt['options'] ?? ''}')),
|
||||
};
|
||||
}).toList(),
|
||||
'isSelect': isSelect,
|
||||
'questionTypes': finalQuestionTypes,
|
||||
'answer': rawAnswer,
|
||||
'analysis': fixImageStyles(renderLatexInHtml(analysis)),
|
||||
'subQuestions': const [],
|
||||
if (finalQuestionTypes == 'tiankongti')
|
||||
'bkTagMetaList': [
|
||||
{
|
||||
'index': 1,
|
||||
'type': 0,
|
||||
'dataInputType': 0,
|
||||
'correctAnswer': fixImageStyles(renderLatexInHtml(rawCorrectAnswer)),
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/// ============================================================
|
||||
/// 标准复合题解析器(1:1 移植 Vue utils/questionParserStandard.ts)
|
||||
///
|
||||
/// 处理带 childs 的复合题(阅读理解、完形填空、听力等):
|
||||
/// 提取子题题干 / 选项 / 题型 / 答案,清洗大题干。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'question_parser_base.dart';
|
||||
import 'formula.dart';
|
||||
import 'js_regex.dart';
|
||||
|
||||
bool _truthy(dynamic v) {
|
||||
if (v == null) return false;
|
||||
if (v is bool) return v;
|
||||
if (v is num) return v != 0;
|
||||
if (v is String) return v.isNotEmpty && v != '0';
|
||||
return true;
|
||||
}
|
||||
|
||||
/// answerAnalysis 字段可能是字符串,也可能是 {analysis: '...'}
|
||||
String analysisTextOf(dynamic v) {
|
||||
if (v == null) return '';
|
||||
if (v is Map) return '${v['analysis'] ?? ''}';
|
||||
return '$v';
|
||||
}
|
||||
|
||||
String _latex(dynamic v) => renderLatexInHtml(v == null ? '' : '$v');
|
||||
|
||||
Map<String, dynamic> _renderBk(Map<String, dynamic> bk) {
|
||||
return {
|
||||
...bk,
|
||||
'correctAnswer': fixImageStyles(_latex(bk['correctAnswer'])),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> parseStandardQuestion(
|
||||
Map<String, dynamic> questionData,
|
||||
String rawStem,
|
||||
String rawAnswer,
|
||||
String rawCorrectAnswer,
|
||||
String analysis,
|
||||
) {
|
||||
final options = <Map<String, String>>[];
|
||||
var isSelect = _truthy(questionData['isSelect']);
|
||||
|
||||
final subQuestions = <Map<String, dynamic>>[];
|
||||
var newStem = rawStem;
|
||||
|
||||
final childsRaw = (questionData['childs'] as List?) ?? const [];
|
||||
|
||||
for (var index = 0; index < childsRaw.length; index++) {
|
||||
final child = Map<String, dynamic>.from(childsRaw[index] as Map);
|
||||
final subOptions = <Map<String, String>>[];
|
||||
var childStem = _latex(child['stem']);
|
||||
var childAnswer = '${child['answer'] ?? ''}';
|
||||
var childRawAnalysis = _latex(
|
||||
child['answerAnalysis'] is Map
|
||||
? (child['answerAnalysis'] as Map)['analysis']
|
||||
: (child['answerAnalysis'] ?? child['analysis']),
|
||||
);
|
||||
|
||||
// 子题无独立答案时,从大题干答案 rawAnswer 中按 data-index 提取
|
||||
if (childAnswer.isEmpty && rawAnswer.isNotEmpty) {
|
||||
final subAnswerRegex = JRegExp(
|
||||
'<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*hxdb-subquestion[^"]*")'
|
||||
'(?=[^>]*data-index="${index + 1}")[^>]*>',
|
||||
);
|
||||
final match = subAnswerRegex.exec(rawAnswer);
|
||||
if (match != null) {
|
||||
childAnswer = findFullElement(rawAnswer, match);
|
||||
}
|
||||
}
|
||||
|
||||
final childAnalysis = childRawAnalysis;
|
||||
|
||||
// 1) 从大题干中提取属于该子题的内容 / 选项表格
|
||||
final subquestionRegex = JRegExp(
|
||||
'<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*(?:hxdb-subquestion|hxdb-option-table-wrapper)[^"]*")'
|
||||
'(?=[^>]*data-index="${index + 1}")[^>]*>',
|
||||
);
|
||||
|
||||
JMatch? subMatch;
|
||||
while ((subMatch = subquestionRegex.exec(newStem)) != null) {
|
||||
final fullBlock = findFullElement(newStem, subMatch!);
|
||||
final startTag = subMatch.group0;
|
||||
final tagNameLen = (subMatch(1) ?? '').length;
|
||||
final divContent = fullBlock.substring(
|
||||
startTag.length,
|
||||
fullBlock.length - (tagNameLen + 3),
|
||||
);
|
||||
|
||||
if (childStem.isEmpty || childStem == '<p></p>') {
|
||||
childStem = divContent;
|
||||
}
|
||||
|
||||
subOptions.addAll(extractOptionsFromHtml(divContent));
|
||||
newStem = newStem.replaceFirst(fullBlock, '');
|
||||
subquestionRegex.reset();
|
||||
}
|
||||
|
||||
// 2) 从子题自身 stem 提取选项
|
||||
final optionTableRegex = RegExp(
|
||||
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-(?:sub-)?option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)</\1>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final childMatch = optionTableRegex.firstMatch(childStem);
|
||||
if (childMatch != null) {
|
||||
if (subOptions.isEmpty) {
|
||||
subOptions.addAll(extractOptionsFromHtml(childMatch.group(2) ?? ''));
|
||||
}
|
||||
childStem = childStem.replaceFirst(optionTableRegex, '');
|
||||
}
|
||||
|
||||
if (subOptions.isEmpty &&
|
||||
RegExp(r'hxdb-sub-option-table', caseSensitive: false).hasMatch(childStem)) {
|
||||
subOptions.addAll(extractOptionsFromHtml(childStem));
|
||||
}
|
||||
|
||||
// 按 optionLabel 去重
|
||||
final seenLabels = <String>{};
|
||||
final deduped = subOptions.where((opt) {
|
||||
if (opt['optionLabel']!.isEmpty) return true;
|
||||
if (seenLabels.contains(opt['optionLabel'])) return false;
|
||||
seenLabels.add(opt['optionLabel']!);
|
||||
return true;
|
||||
}).toList();
|
||||
subOptions
|
||||
..clear()
|
||||
..addAll(deduped);
|
||||
|
||||
// 3) 判断题
|
||||
final childIsPanDuan = processPanDuan(
|
||||
{...child, 'answer': childAnswer},
|
||||
subOptions,
|
||||
);
|
||||
child['questionTypeId'] =
|
||||
(_truthy(child['questionTypeId']) && child['questionTypeId'] != 0)
|
||||
? child['questionTypeId']
|
||||
: questionData['questionTypeId'];
|
||||
var childQuestionTypes = getQuestionTypeCode(
|
||||
child['questionTypeId'],
|
||||
child['isObjective'],
|
||||
child['questionTypeCode'],
|
||||
);
|
||||
if (childIsPanDuan) {
|
||||
childQuestionTypes = 'panduanti';
|
||||
}
|
||||
|
||||
// 4) 组装子题(注意:...child 覆盖前面的显式字段,与 JS 展开顺序一致)
|
||||
final computedOptions = subOptions.isNotEmpty
|
||||
? subOptions
|
||||
.map((opt) => makeOption(opt['optionLabel']!, _latex(opt['options'])))
|
||||
.toList()
|
||||
: (child['options'] is List
|
||||
? (child['options'] as List)
|
||||
.whereType<Map>()
|
||||
.map((opt) => makeOption(
|
||||
'${opt['optionLabel'] ?? ''}',
|
||||
_latex(opt['options']),
|
||||
))
|
||||
.toList()
|
||||
: <Map<String, String>>[]);
|
||||
|
||||
final subQuestionObj = <String, dynamic>{
|
||||
'index': '${index + 1}.',
|
||||
'stem': childStem,
|
||||
'answer': childAnswer,
|
||||
'analysis': childAnalysis,
|
||||
'options': computedOptions,
|
||||
'isSelect': childIsPanDuan || subOptions.isNotEmpty || _truthy(child['isSelect']),
|
||||
};
|
||||
subQuestionObj.addAll(child); // ...child
|
||||
subQuestionObj['questionTypeId'] =
|
||||
(_truthy(child['questionTypeId']) && child['questionTypeId'] != 0)
|
||||
? child['questionTypeId']
|
||||
: questionData['questionTypeId'];
|
||||
subQuestionObj['questionTypeName'] = _truthy(child['questionTypeName'])
|
||||
? child['questionTypeName']
|
||||
: questionData['questionTypeName'];
|
||||
subQuestionObj['questionTypes'] = childQuestionTypes;
|
||||
subQuestionObj['raw'] = child;
|
||||
|
||||
if (childQuestionTypes == 'tiankongti') {
|
||||
final childBkTagMetaList = parseBkTagMetaFromStem(
|
||||
childStem,
|
||||
'${child['correctAnswer'] ?? rawCorrectAnswer}',
|
||||
child['userAnswer']?.toString(),
|
||||
questionData['iSFilllankItemUpload'],
|
||||
);
|
||||
if (childBkTagMetaList.isNotEmpty) {
|
||||
subQuestionObj['bkTagMetaList'] = childBkTagMetaList;
|
||||
}
|
||||
}
|
||||
|
||||
subQuestions.add(subQuestionObj);
|
||||
}
|
||||
|
||||
// 清洗主干残留的子题块
|
||||
final cleanupRegex = JRegExp(
|
||||
'<([a-zA-Z][a-zA-Z0-9]*)(?=[^>]*class="[^"]*(?:hxdb-subquestion|hxdb-option-table-wrapper)[^"]*")[^>]*>',
|
||||
);
|
||||
while (true) {
|
||||
final cleanupMatch = cleanupRegex.exec(newStem);
|
||||
if (cleanupMatch == null) break;
|
||||
final fullBlock = findFullElement(newStem, cleanupMatch);
|
||||
newStem = newStem.replaceFirst(fullBlock, '');
|
||||
cleanupRegex.reset();
|
||||
}
|
||||
|
||||
var finalStem = newStem;
|
||||
|
||||
// 大题干自身的选项表(普通单选/多选)
|
||||
final optionTableRegex = RegExp(
|
||||
r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*class="[^"]*hxdb-option-table(?!-wrapper)[^"]*"[^>]*>([\s\S]*?)</\1>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final match = optionTableRegex.firstMatch(finalStem);
|
||||
if (match != null) {
|
||||
isSelect = true;
|
||||
options.addAll(extractOptionsFromHtml(match.group(2) ?? ''));
|
||||
finalStem = finalStem.replaceFirst(optionTableRegex, '');
|
||||
}
|
||||
|
||||
var finalQuestionTypes = getQuestionTypeCode(
|
||||
questionData['questionTypeId'],
|
||||
questionData['isObjective'],
|
||||
questionData['questionTypeCode'],
|
||||
);
|
||||
|
||||
if (options.isEmpty &&
|
||||
(finalQuestionTypes == 'danxuanti' || finalQuestionTypes == 'duoxuanti')) {
|
||||
final plainOptions = extractPlainTextOptions(finalStem);
|
||||
if (plainOptions.isNotEmpty) {
|
||||
options.addAll(plainOptions);
|
||||
isSelect = true;
|
||||
}
|
||||
}
|
||||
|
||||
var bkTagMetaList = <Map<String, dynamic>>[];
|
||||
if (finalQuestionTypes == 'tiankongti') {
|
||||
bkTagMetaList = parseBkTagMetaFromStem(
|
||||
finalStem,
|
||||
rawCorrectAnswer,
|
||||
questionData['userAnswer']?.toString(),
|
||||
questionData['iSFilllankItemUpload'],
|
||||
);
|
||||
if (bkTagMetaList.isEmpty) {
|
||||
bkTagMetaList = [
|
||||
{'index': 1, 'type': 0, 'dataInputType': 0, 'correctAnswer': rawCorrectAnswer}
|
||||
];
|
||||
final userAnswerMap = parseUserAnswerMapFromJson(
|
||||
questionData['userAnswer']?.toString(),
|
||||
questionData['iSFilllankItemUpload'] == 1,
|
||||
);
|
||||
final fromUser = userAnswerMap[1];
|
||||
if (fromUser != null) {
|
||||
final isUpload = questionData['iSFilllankItemUpload'] == 1;
|
||||
final myAnswer = fromUser['myAnswer'];
|
||||
final hasImgArray = myAnswer is List &&
|
||||
myAnswer.isNotEmpty &&
|
||||
myAnswer.first is Map &&
|
||||
(myAnswer.first as Map)['url'] != null;
|
||||
if (isUpload || hasImgArray) {
|
||||
if (myAnswer is List && myAnswer.isNotEmpty) {
|
||||
bkTagMetaList[0]['myAnswer'] = myAnswer;
|
||||
}
|
||||
} else {
|
||||
final my = myAnswer is String ? myAnswer.trim() : '';
|
||||
if (my.isNotEmpty) bkTagMetaList[0]['myAnswer'] = my;
|
||||
}
|
||||
if (fromUser['answerStatus'] is int) {
|
||||
bkTagMetaList[0]['answerStatus'] = fromUser['answerStatus'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final renderedStem = fixImageStyles(_latex(finalStem));
|
||||
final renderedAnalysis = fixImageStyles(_latex(analysis));
|
||||
final optionSource = options.isNotEmpty
|
||||
? options
|
||||
: ((questionData['options'] as List?)?.whereType<Map>().toList() ?? const []);
|
||||
final renderedOptions = optionSource.map((rawOpt) {
|
||||
final opt = Map<String, dynamic>.from(rawOpt);
|
||||
return {
|
||||
...opt,
|
||||
'options': fixImageStyles(_latex(opt['options'])),
|
||||
};
|
||||
}).toList();
|
||||
|
||||
final renderedSubQuestions = subQuestions.map((sub) {
|
||||
final rendered = <String, dynamic>{...sub};
|
||||
rendered['stem'] = fixImageStyles(_latex(sub['stem']));
|
||||
rendered['analysis'] = fixImageStyles(_latex(sub['analysis']));
|
||||
rendered['answerAnalysis'] = fixImageStyles(_latex(sub['answerAnalysis']));
|
||||
final subOpts = (sub['options'] as List?) ?? const [];
|
||||
rendered['options'] = subOpts.whereType<Map>().map((rawOpt) {
|
||||
final opt = Map<String, dynamic>.from(rawOpt);
|
||||
return {
|
||||
...opt,
|
||||
'options': fixImageStyles(_latex(opt['options'])),
|
||||
};
|
||||
}).toList();
|
||||
final bks = (sub['bkTagMetaList'] as List?) ?? const [];
|
||||
rendered['bkTagMetaList'] =
|
||||
bks.whereType<Map>().map((bk) => _renderBk(Map<String, dynamic>.from(bk))).toList();
|
||||
return rendered;
|
||||
}).toList();
|
||||
|
||||
final renderedBkTagMetaList = bkTagMetaList.map(_renderBk).toList();
|
||||
|
||||
return {
|
||||
...questionData,
|
||||
'raw': questionData,
|
||||
'stem': renderedStem,
|
||||
'options': renderedOptions,
|
||||
'isSelect': isSelect,
|
||||
'questionTypes': finalQuestionTypes,
|
||||
'childs': renderedSubQuestions,
|
||||
'answer': rawAnswer,
|
||||
'analysis': renderedAnalysis,
|
||||
'subQuestions': renderedSubQuestions,
|
||||
'bkTagMetaList': renderedBkTagMetaList.isNotEmpty
|
||||
? renderedBkTagMetaList
|
||||
: (finalQuestionTypes == 'tiankongti'
|
||||
? [
|
||||
{
|
||||
'index': 1,
|
||||
'type': 0,
|
||||
'dataInputType': 0,
|
||||
'correctAnswer': fixImageStyles(_latex(rawCorrectAnswer)),
|
||||
}
|
||||
]
|
||||
: const []),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/// ============================================================
|
||||
/// 题型标签渐变色(对标 Vue utils/tagQuestionTypeColor.js)
|
||||
/// getTagColor(tag) → 题型名对应渐变;找不到=默认单选题蓝
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 题型渐变描述(起止色 + 角度近似:281.96deg≈右上→左下,126.17deg≈左上→右下)
|
||||
class TagGradient {
|
||||
const TagGradient(this.begin, this.end, this.colors);
|
||||
|
||||
final Alignment begin;
|
||||
final Alignment end;
|
||||
final List<Color> colors;
|
||||
|
||||
Gradient get gradient => LinearGradient(
|
||||
begin: begin,
|
||||
end: end,
|
||||
colors: colors,
|
||||
);
|
||||
}
|
||||
|
||||
const TagGradient _defaultTagGradient = TagGradient(
|
||||
Alignment(-0.95, -0.2), // 281.96deg
|
||||
Alignment(0.95, 0.2),
|
||||
[Color(0xFF3D7DF5), Color(0xFF5A9EFF)],
|
||||
);
|
||||
|
||||
const Map<String, TagGradient> _tagColorMap = {
|
||||
'单选题': TagGradient(
|
||||
Alignment(-0.95, -0.2),
|
||||
Alignment(0.95, 0.2),
|
||||
[Color(0xFF3D7DF5), Color(0xFF5A9EFF)],
|
||||
),
|
||||
'多选题': TagGradient(
|
||||
Alignment(-0.95, -0.2),
|
||||
Alignment(0.95, 0.2),
|
||||
[Color(0xFF3DB8F5), Color(0xFF5AC8FF)],
|
||||
),
|
||||
'判断题': TagGradient(
|
||||
Alignment(-0.95, -0.2),
|
||||
Alignment(0.95, 0.2),
|
||||
[Color(0xFF3FC8A6), Color(0xFF89E9D1)],
|
||||
),
|
||||
'填空题': TagGradient(
|
||||
Alignment(-0.95, -0.2),
|
||||
Alignment(0.95, 0.2),
|
||||
[Color(0xFF66D969), Color(0xFF93E594)],
|
||||
),
|
||||
'完形填空': TagGradient(
|
||||
Alignment(-0.95, -0.2),
|
||||
Alignment(0.95, 0.2),
|
||||
[Color(0xFF81B84F), Color(0xFFB1E384)],
|
||||
),
|
||||
'阅读理解': TagGradient(
|
||||
Alignment(-0.44, -0.8), // 126.17deg
|
||||
Alignment(0.44, 0.8),
|
||||
[Color(0xFFFFC260), Color(0xFFFF9E45)],
|
||||
),
|
||||
'听力理解': TagGradient(
|
||||
Alignment(-0.44, -0.8),
|
||||
Alignment(0.44, 0.8),
|
||||
[Color(0xFFFFA052), Color(0xFFFF6F3A)],
|
||||
),
|
||||
'7选5': TagGradient(
|
||||
Alignment(-0.44, -0.8),
|
||||
Alignment(0.44, 0.8),
|
||||
[Color(0xFFFF7A41), Color(0xFFFE5837)],
|
||||
),
|
||||
'解答题': TagGradient(
|
||||
Alignment(-0.95, -0.2),
|
||||
Alignment(0.95, 0.2),
|
||||
[Color(0xFFBA59EB), Color(0xFFCD8AEF)],
|
||||
),
|
||||
'作文题': TagGradient(
|
||||
Alignment(-0.95, -0.2),
|
||||
Alignment(0.95, 0.2),
|
||||
[Color(0xFF8845E6), Color(0xFFA87BF5)],
|
||||
),
|
||||
'问答题': TagGradient(
|
||||
Alignment(-0.95, -0.2),
|
||||
Alignment(0.95, 0.2),
|
||||
[Color(0xFF6864E4), Color(0xFF8582E9)],
|
||||
),
|
||||
};
|
||||
|
||||
/// Vue: getTagColor(tag)
|
||||
TagGradient getTagColor(String? tag) {
|
||||
if (tag == null || tag.isEmpty) return _defaultTagGradient;
|
||||
return _tagColorMap[tag] ?? _defaultTagGradient;
|
||||
}
|
||||
Reference in New Issue
Block a user