fix: import unpublished exam rooms and add opt-in school HTML feedback

This commit is contained in:
Codex
2026-10-16 16:38:00 +08:00
parent defe7fd50e
commit 437ed14dbe
31 changed files with 969 additions and 39 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
const appVersion = '0.7.4';
const appBuildNumber = 22;
const appVersion = '0.7.5';
const appBuildNumber = 23;
@@ -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++;
@@ -0,0 +1,177 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:html/parser.dart' as html;
/// An opt-in mail attachment, separate from persistent structured logs.
/// Keeps only course/exam evidence, never the surrounding account/login page.
class SchoolPageDiagnostic {
SchoolPageDiagnostic({DateTime Function()? clock})
: clock = clock ?? DateTime.now;
static final instance = SchoolPageDiagnostic();
static const maxBytes = 2 * 1024 * 1024;
static const marker = '<!doctype html><!-- TPSchedule school diagnostic -->';
final DateTime Function() clock;
Uint8List? _bytes;
DateTime? _at;
Timer? _expiry;
void clear() {
_expiry?.cancel();
_expiry = null;
_bytes = null;
_at = null;
}
Uint8List? export() {
final at = _at;
if (at == null ||
clock().difference(at).isNegative ||
clock().difference(at) >= const Duration(minutes: 10)) {
clear();
}
return _bytes == null ? null : Uint8List.fromList(_bytes!);
}
void remember(String content, Uri uri) {
clear();
try {
if (uri.scheme != 'https' ||
uri.host != 'eams.uestc.edu.cn' ||
uri.port != 443 ||
uri.userInfo.isNotEmpty ||
!uri.path.startsWith('/eams/') ||
utf8.encode(content).length > maxBytes) {
return;
}
final doc = html.parse(content);
if (doc.querySelector('input[type="password"]') != null) return;
final tables = doc
.querySelectorAll('table')
.where(
(t) =>
t.id == 'manualArrangeCourseTable' ||
t.querySelector('tr')?.text.contains('课程序号') == true,
)
.toList();
if (tables.isEmpty) return;
final fields = <String, Set<String>>{};
void field(String key, String value) {
if (const ['semester.id', 'examType.id'].contains(key) &&
RegExp(r'^\d{1,6}$').hasMatch(value)) {
fields.putIfAbsent(key, () => {}).add(value);
}
}
for (final input in doc.querySelectorAll('input')) {
field(input.attributes['name'] ?? '', input.attributes['value'] ?? '');
}
for (final node in doc.querySelectorAll('script,input[name="params"]')) {
final value = node.localName == 'input'
? node.attributes['value'] ?? ''
: node.text;
for (final m in RegExp(
r'(semester\.id|examType\.id)=(\d{1,6})(?=[&"\x27\s]|$)',
).allMatches(value)) {
field(m[1]!, m[2]!);
}
}
final script = doc
.querySelectorAll('script')
.map((s) => s.text)
.join('\n');
// Only retain the literal course statements our parser reads. All other
// scripts, URL-bearing attributes and form/account fields are omitted.
final statements = _courseStatements(script);
for (final table in tables) {
for (final node in table.querySelectorAll(
'script,style,link,iframe,object,embed,form,input,textarea,select,button,img,svg,video,audio,source,meta,base',
)) {
node.remove();
}
for (final node in [table, ...table.querySelectorAll('*')]) {
final attrs = Map<String, String>.from(node.attributes);
node.attributes.clear();
if (attrs['id'] == 'manualArrangeCourseTable') {
node.id = 'manualArrangeCourseTable';
}
for (final key in ['rowspan', 'colspan']) {
final value = attrs[key];
if (value != null && RegExp(r'^\d{1,2}$').hasMatch(value)) {
node.attributes[key] = value;
}
}
}
}
final safe =
'$marker<html><head><meta charset="utf-8">'
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; base-uri \'none\'; form-action \'none\'">'
'<title>TPSchedule school diagnostic</title></head><body>'
'${fields.entries.expand((e) => e.value.map((v) => '<input type="hidden" name="${e.key}" value="$v">')).join()}'
'${tables.map((t) => t.outerHtml).join()}'
'<script type="application/x-tpschedule-course-data">${statements.replaceAll('<', r'\u003c')}</script></body></html>';
final bytes = Uint8List.fromList(utf8.encode(safe));
if (bytes.length > maxBytes) return;
_bytes = bytes;
_at = clock();
_expiry = Timer(const Duration(minutes: 10), clear);
} catch (_) {
clear(); // Diagnostic collection must not interfere with importing.
}
}
// Split outside quoted strings without validating the course arguments: a
// malformed argument/index is precisely what this attachment must preserve.
static String _courseStatements(String script) {
final out = <String>[];
var start = 0;
String? quote;
var lineComment = false, blockComment = false;
final allowed = RegExp(
r'^(?:var\s+)?(?:unitCount\s*=|activity\s*=\s*new\s+TaskActivity\b|new\s+TaskActivity\b|index\s*=|table\d+\.activities\s*\[)',
);
void keep(int end) {
final statement = script
.substring(start, end)
.replaceFirst(
RegExp(r'^(?:\s|//[^\r\n]*(?:\r?\n|$)|/\*[\s\S]*?\*/)*'),
'',
)
.trim();
if (allowed.hasMatch(statement)) out.add(statement);
start = end;
}
for (var i = 0; i < script.length; i++) {
final c = script[i];
final next = i + 1 < script.length ? script[i + 1] : '';
if (lineComment) {
if (c == '\n' || c == '\r') lineComment = false;
} else if (blockComment) {
if (c == '*' && next == '/') {
blockComment = false;
i++;
}
} else if (quote != null) {
if (c == '\\') {
i++;
} else if (c == quote) {
quote = null;
}
} else if (c == '/' && next == '/') {
lineComment = true;
i++;
} else if (c == '/' && next == '*') {
blockComment = true;
i++;
} else if (c == '"' || c == "'" || c == '`') {
quote = c;
} else if (c == ';') {
keep(i + 1);
}
}
if (start < script.length) keep(script.length);
return out.join('\n');
}
}