fix: import unpublished exam rooms and add opt-in school HTML feedback
This commit is contained in:
@@ -120,10 +120,11 @@ class HttpFeedbackSender implements FeedbackSender {
|
||||
throw const FeedbackException('服务器不支持此附件的格式或内容,请移除附件,或换用正常的图片、视频后提交');
|
||||
}
|
||||
if (response.statusCode == 422 && errorCode == 'validation_error') {
|
||||
if (submission.diagnosticLog != null &&
|
||||
if ((submission.diagnosticLog != null ||
|
||||
submission.diagnosticHtml != null) &&
|
||||
result?['detail'] == 'Unexpected file field.') {
|
||||
throw const FeedbackException(
|
||||
'反馈服务器尚未更新日志附件协议,请先更新服务器,或取消附带日志后提交。填写内容已保留。',
|
||||
'反馈服务器尚未更新日志或网页附件协议,请先更新服务器,或取消附带排查附件后提交。填写内容已保留。',
|
||||
);
|
||||
}
|
||||
throw const FeedbackException('填写内容未通过服务器校验,请检查必填内容、长度及附件后重新提交');
|
||||
@@ -206,6 +207,14 @@ class HttpFeedbackSender implements FeedbackSender {
|
||||
bytes.add(submission.diagnosticLog!);
|
||||
write('\r\n');
|
||||
}
|
||||
if (submission.diagnosticHtml != null) {
|
||||
write(
|
||||
'--$boundary\r\nContent-Disposition: form-data; name="diagnostic_html"; filename="TPSchedule-school-page.html"\r\n'
|
||||
'Content-Type: text/html; charset=utf-8\r\n\r\n',
|
||||
);
|
||||
bytes.add(submission.diagnosticHtml!);
|
||||
write('\r\n');
|
||||
}
|
||||
write('--$boundary--\r\n');
|
||||
return bytes.takeBytes();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_theme.dart';
|
||||
import '../../services/diagnostic_log.dart';
|
||||
import '../../services/school_page_diagnostic.dart';
|
||||
import 'feedback_submission.dart';
|
||||
|
||||
typedef FeedbackAttachmentPicker = Future<FeedbackAttachment?> Function();
|
||||
@@ -34,6 +35,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
var _success = false;
|
||||
var _uncertain = false;
|
||||
var _includeLogs = false;
|
||||
var _includeHtml = false;
|
||||
String? _error;
|
||||
FeedbackSubmission? _pendingSubmission;
|
||||
|
||||
@@ -107,6 +109,20 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
FocusScope.of(context).unfocus();
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final html =
|
||||
_pendingSubmission == null &&
|
||||
_kind == FeedbackKind.problem &&
|
||||
_includeHtml
|
||||
? SchoolPageDiagnostic.instance.export()
|
||||
: null;
|
||||
if (_pendingSubmission == null &&
|
||||
_kind == FeedbackKind.problem &&
|
||||
_includeHtml &&
|
||||
html == null) {
|
||||
throw const FeedbackException(
|
||||
'最近10分钟没有可用的本科导入页面。请返回学校页面再尝试导入,然后提交反馈;也可以取消附带HTML。',
|
||||
);
|
||||
}
|
||||
_pendingSubmission ??= _kind == FeedbackKind.problem
|
||||
? FeedbackSubmission.problem(
|
||||
id: FeedbackSubmission.newId(),
|
||||
@@ -117,6 +133,7 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
diagnosticLog: _includeLogs
|
||||
? await DiagnosticLog.instance.export()
|
||||
: null,
|
||||
diagnosticHtml: html,
|
||||
)
|
||||
: FeedbackSubmission.development(
|
||||
id: FeedbackSubmission.newId(),
|
||||
@@ -128,6 +145,9 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
_error = null;
|
||||
});
|
||||
await widget.sender.send(_pendingSubmission!);
|
||||
if (_pendingSubmission?.diagnosticHtml != null) {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
}
|
||||
if (mounted) setState(() => _success = true);
|
||||
} on FeedbackDeliveryUncertain catch (error) {
|
||||
if (mounted) {
|
||||
@@ -275,6 +295,58 @@ class _FeedbackPageState extends State<FeedbackPage> {
|
||||
: (value) {
|
||||
setState(() {
|
||||
_includeLogs = value ?? false;
|
||||
if (!_includeLogs) _includeHtml = false;
|
||||
_pendingSubmission = null;
|
||||
_error = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
key: const Key('feedback-html'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('附带最近一次本科导入页面HTML'),
|
||||
subtitle: const Text(
|
||||
'需先勾选日志。网页可能含姓名、学号、课程、教师和考场信息,仅保留最近10分钟,单独作为邮件附件发送。',
|
||||
),
|
||||
value: _includeHtml,
|
||||
onChanged: _submitting || !_includeLogs
|
||||
? null
|
||||
: (value) async {
|
||||
var accepted = value ?? false;
|
||||
if (accepted) {
|
||||
accepted =
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('附带网页排查资料?'),
|
||||
content: const Text(
|
||||
'附件可能包含你的姓名、学号、课程及考试信息,将发送给开发者用于排查导入问题。密码、验证码、Cookie和认证参数不会包含在附件中。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(
|
||||
context,
|
||||
false,
|
||||
),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
Navigator.pop(
|
||||
context,
|
||||
true,
|
||||
),
|
||||
child: const Text('同意附带'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_includeHtml = accepted;
|
||||
_pendingSubmission = null;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
@@ -103,6 +103,7 @@ class FeedbackSubmission {
|
||||
this.school,
|
||||
this.attachments = const [],
|
||||
this.diagnosticLog,
|
||||
this.diagnosticHtml,
|
||||
});
|
||||
|
||||
factory FeedbackSubmission.problem({
|
||||
@@ -112,6 +113,7 @@ class FeedbackSubmission {
|
||||
List<FeedbackAttachment> attachments = const [],
|
||||
FeedbackLimits limits = FeedbackLimits.defaults,
|
||||
Uint8List? diagnosticLog,
|
||||
Uint8List? diagnosticHtml,
|
||||
}) {
|
||||
final text = message.trim();
|
||||
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
|
||||
@@ -122,6 +124,10 @@ class FeedbackSubmission {
|
||||
(diagnosticLog.isEmpty || diagnosticLog.length > 256 * 1024)) {
|
||||
throw const FeedbackException('日志附件不能超过256 KB');
|
||||
}
|
||||
if (diagnosticHtml != null &&
|
||||
(diagnosticHtml.isEmpty || diagnosticHtml.length > 2 * 1024 * 1024)) {
|
||||
throw const FeedbackException('网页排查附件不能超过2 MB');
|
||||
}
|
||||
return FeedbackSubmission._(
|
||||
id: id,
|
||||
kind: FeedbackKind.problem,
|
||||
@@ -131,6 +137,9 @@ class FeedbackSubmission {
|
||||
diagnosticLog: diagnosticLog == null
|
||||
? null
|
||||
: Uint8List.fromList(diagnosticLog).asUnmodifiableView(),
|
||||
diagnosticHtml: diagnosticHtml == null
|
||||
? null
|
||||
: Uint8List.fromList(diagnosticHtml).asUnmodifiableView(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,6 +170,7 @@ class FeedbackSubmission {
|
||||
final String? school;
|
||||
final List<FeedbackAttachment> attachments;
|
||||
final Uint8List? diagnosticLog;
|
||||
final Uint8List? diagnosticHtml;
|
||||
|
||||
static String newId() {
|
||||
final random = Random.secure();
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
class UndergraduateActivityException extends FormatException {
|
||||
const UndergraduateActivityException(
|
||||
this.code,
|
||||
String message,
|
||||
this.activityNumber,
|
||||
) : super(message);
|
||||
final String code;
|
||||
final int activityNumber;
|
||||
}
|
||||
|
||||
class UndergraduateActivity {
|
||||
UndergraduateActivity(
|
||||
this.teacher,
|
||||
@@ -30,8 +40,26 @@ List<UndergraduateActivity> parseActivityData(
|
||||
final m = matches[i], args = _strings(m[1]!);
|
||||
if (args.length != 7) throw const FormatException('课程活动字段不完整');
|
||||
final suffix = RegExp(r'\(([A-Za-z0-9_.-]+)\)$').firstMatch(args[3]);
|
||||
if (suffix == null || args[1].trim().isEmpty || args[5].trim().isEmpty) {
|
||||
throw const FormatException('教师、教室或课程序号缺失');
|
||||
if (suffix == null) {
|
||||
throw UndergraduateActivityException(
|
||||
'course_sequence_invalid',
|
||||
'课程序号格式无法识别,请附带网页反馈',
|
||||
i + 1,
|
||||
);
|
||||
}
|
||||
if (args[1].trim().isEmpty) {
|
||||
throw UndergraduateActivityException(
|
||||
'teacher_missing',
|
||||
'课程活动未提供教师信息,请附带网页反馈',
|
||||
i + 1,
|
||||
);
|
||||
}
|
||||
if (args[5].trim().isEmpty) {
|
||||
throw UndergraduateActivityException(
|
||||
'room_missing',
|
||||
'课程活动未提供教室信息,请附带网页反馈',
|
||||
i + 1,
|
||||
);
|
||||
}
|
||||
final bitmap = args[6];
|
||||
if (!RegExp(r'^0[01]+$').hasMatch(bitmap) ||
|
||||
|
||||
@@ -102,11 +102,26 @@ class UndergraduateExamAdapter {
|
||||
final explicitEmpty = RegExp(r'暂无考试|无考试安排|没有考试|暂无记录|没有符合条件的记录|没有数据|无数据')
|
||||
.hasMatch(table.text);
|
||||
for (final row in table.querySelectorAll('tr').skip(1)) {
|
||||
final cells = row.children
|
||||
.map((c) => c.text.replaceAll(RegExp(r'\s+'), ' ').trim())
|
||||
.toList();
|
||||
if (explicitEmpty && row.children.length == 1) continue;
|
||||
final cells = <String>[];
|
||||
for (final cell in row.children) {
|
||||
final value = cell.text.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final span = int.tryParse(cell.attributes['colspan'] ?? '1');
|
||||
if ((cell.attributes['rowspan'] ?? '1') != '1') {
|
||||
throw const FormatException('考试记录行合并结构无法识别');
|
||||
}
|
||||
if (span == 1) {
|
||||
cells.add(value);
|
||||
} else if (span == 2 &&
|
||||
cells.length == 4 &&
|
||||
RegExp(r'^[\[【]?考试情况尚未发布[\]】]?$').hasMatch(value)) {
|
||||
// EAMS merges the room and seat columns until it publishes them.
|
||||
cells.addAll([value, '']);
|
||||
} else {
|
||||
throw const FormatException('考试记录列合并结构无法识别');
|
||||
}
|
||||
}
|
||||
if (cells.length != 9) {
|
||||
if (explicitEmpty && cells.length == 1) continue;
|
||||
throw const FormatException('考试记录字段不完整');
|
||||
}
|
||||
final rowType = _type(cells[7]);
|
||||
|
||||
@@ -8,6 +8,8 @@ import '../../../data/json_schedule_import.dart';
|
||||
import '../../../domain/exam_schedule.dart';
|
||||
import '../../../domain/undergraduate_exam_query.dart';
|
||||
import '../../../services/diagnostic_log.dart';
|
||||
import '../../../services/school_page_diagnostic.dart';
|
||||
import 'activity_data.dart';
|
||||
import 'navigation_policy.dart';
|
||||
import 'page_probe.dart';
|
||||
import 'timetable_adapter.dart';
|
||||
@@ -206,6 +208,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
),
|
||||
);
|
||||
Future<void> openTimetable() async {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
DiagnosticLog.instance.add(
|
||||
'login_opened',
|
||||
fields: {'school': 'undergraduate', 'phase': 'timetable'},
|
||||
@@ -222,6 +225,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> openExams(UndergraduateExamQuery value) async {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
DiagnosticLog.instance.add(
|
||||
'login_opened',
|
||||
fields: {
|
||||
@@ -434,6 +438,10 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
if (pages.length != 1) throw const FormatException('排考结果页面不明确');
|
||||
SchoolPageDiagnostic.instance.remember(
|
||||
pages.single.html,
|
||||
pages.single.url,
|
||||
);
|
||||
final part = UndergraduateExamAdapter.extract(
|
||||
pages.single.html,
|
||||
expectedTerm: query!.term,
|
||||
@@ -519,6 +527,10 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
if (pages.length != 1) {
|
||||
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
|
||||
}
|
||||
SchoolPageDiagnostic.instance.remember(
|
||||
pages.single.html,
|
||||
pages.single.url,
|
||||
);
|
||||
final draft = UndergraduateTimetableAdapter.extract(
|
||||
pages.single.html,
|
||||
pages.single.url,
|
||||
@@ -531,6 +543,18 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
_emit();
|
||||
} catch (error) {
|
||||
if (_valid(g) && d == _document) {
|
||||
DiagnosticLog.instance.add(
|
||||
'school_read_failed',
|
||||
fields: {
|
||||
'school': 'undergraduate',
|
||||
'phase': 'timetable',
|
||||
'code': error is UndergraduateActivityException
|
||||
? error.code
|
||||
: error.runtimeType.toString(),
|
||||
if (error is UndergraduateActivityException)
|
||||
'count': error.activityNumber,
|
||||
},
|
||||
);
|
||||
message = '导入未完成:$error';
|
||||
_emit();
|
||||
}
|
||||
@@ -554,6 +578,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> reauthenticate() async {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
await _logoutFuture;
|
||||
if (_disposed) return;
|
||||
_reset();
|
||||
@@ -566,6 +591,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void invalidateAuthentication() {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
_reset();
|
||||
_active = false;
|
||||
hasOpened = false;
|
||||
@@ -601,6 +627,7 @@ class UndergraduateSession extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SchoolPageDiagnostic.instance.clear();
|
||||
_disposed = true;
|
||||
_active = false;
|
||||
_generation++;
|
||||
|
||||
Reference in New Issue
Block a user