feat: connect undergraduate manual timetable and exam batches

This commit is contained in:
Codex
2026-10-14 23:55:28 +08:00
parent d101ffbf85
commit 68fe71e29a
4 changed files with 719 additions and 1 deletions
@@ -0,0 +1,409 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../../../data/json_schedule_import.dart';
import '../../../domain/exam_schedule.dart';
import '../../../domain/undergraduate_exam_query.dart';
import 'navigation_policy.dart';
import 'page_probe.dart';
import 'timetable_adapter.dart';
import 'exam_adapter.dart';
class UndergraduateSession extends ChangeNotifier {
static final loginUri = Uri.parse('https://eams.uestc.edu.cn/eams/');
static const manualMessage = '请在学校网页打开课表并选择学期,然后点击导入当前课表。';
WebViewController? _web;
WebViewController get web => _web ??= _createWeb();
bool _disposed = false,
_active = false,
_connected = false,
_loading = false,
_manualCapture = false,
_failed = false,
_complete = false;
bool hasOpened = false, awaitingAuthentication = false, logoutFailed = false;
String message = manualMessage;
int _generation = 0, _document = 0, _index = 0;
String? _probeKey;
Uri? _expected;
UndergraduateExamQuery? query;
ImportDraft? pendingDraft;
ExamSchedule? pendingExams;
final _parts = <ExamSchedule>[];
Timer? _poll, _itemTimeout, _batchTimeout;
Future<void>? _logoutFuture;
int get pageGeneration => _generation;
bool get requestingExams => query != null;
bool get canLogout => hasOpened || logoutFailed;
bool get busy => _loading || _manualCapture || _itemTimeout != null;
String get statusLabel => logoutFailed
? '退出未完成'
: _failed
? '读取未完成'
: _connected
? '已连接'
: hasOpened
? '等待学校登录'
: '未连接';
void _emit() {
if (!_disposed) notifyListeners();
}
bool _valid(int g) => !_disposed && _active && _generation == g;
void _cancelTimers() {
_poll?.cancel();
_itemTimeout?.cancel();
_batchTimeout?.cancel();
_poll = null;
_itemTimeout = null;
_batchTimeout = null;
}
void _reset() {
_generation++;
_cancelTimers();
_parts.clear();
_index = 0;
_expected = null;
pendingDraft = null;
pendingExams = null;
_complete = false;
_failed = false;
_manualCapture = false;
_loading = false;
_probeKey = null;
}
WebViewController _createWeb() => WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) async {
final uri = Uri.tryParse(request.url);
final upgrade = uri != null && request.isMainFrame
? UndergraduateNavigationPolicy.secureIdentityRedirect(uri)
: null;
if (upgrade != null) {
try {
await web.loadRequest(upgrade);
} catch (_) {
_failure('身份认证页面暂时无法打开');
}
return NavigationDecision.prevent;
}
if (uri == null ||
!UndergraduateNavigationPolicy.canNavigate(
uri,
isMainFrame: request.isMainFrame,
)) {
if (request.isMainFrame) _failure('学校跳转未完成,请重新认证。');
return NavigationDecision.prevent;
}
if (request.isMainFrame &&
UndergraduateNavigationPolicy.isLogout(uri))
invalidateAuthentication();
return NavigationDecision.navigate;
},
onPageStarted: (_) {
_document++;
_poll?.cancel();
_probeKey = null;
pendingDraft = null;
if (!requestingExams) _generation++;
if (_active) {
_loading = true;
_emit();
}
},
onPageFinished: _loaded,
onWebResourceError: (error) async {
if (error.isForMainFrame != true || error.url == null) return;
final g = _generation,
d = _document,
current = await web.currentUrl();
if (_valid(g) && d == _document && error.url == current)
_failure('学校页面加载未完成,请检查网络后重试。');
},
onHttpError: (error) async {
final g = _generation,
d = _document,
current = await web.currentUrl();
if (_valid(g) &&
d == _document &&
error.request?.uri.toString() == current)
_failure('学校请求未完成,请稍后重试。');
},
),
);
Future<void> openTimetable() async {
await _logoutFuture;
if (_disposed) return;
_reset();
query = null;
_active = true;
hasOpened = true;
awaitingAuthentication = !_connected;
message = manualMessage;
await _navigate(loginUri);
}
Future<void> openExams(UndergraduateExamQuery value) async {
await _logoutFuture;
if (_disposed) return;
_reset();
query = value;
_active = true;
hasOpened = true;
awaitingAuthentication = !_connected;
message = '请在学校网页完成登录,随后自动查找考试。';
if (_connected) {
await _requestExam(_generation);
} else {
await _navigate(loginUri);
}
}
Future<void> _navigate(Uri uri) async {
final g = _generation;
_loading = true;
_emit();
try {
await web.loadRequest(uri);
} catch (_) {
if (_valid(g)) _failure('学校页面打开未完成,请重试。');
}
}
void setReadingActive(bool active) {
if (_disposed) return;
if (!active) {
_active = false;
_reset();
return;
}
_active = true;
if (hasOpened && !_complete && !_failed) {
unawaited(
web.currentUrl().then((url) {
if (url != null && _active) _loaded(url);
}),
);
}
}
void _failure(String text) {
if (_disposed) return;
_reset();
_failed = true;
awaitingAuthentication = false;
message = '读取未完成:$text';
_emit();
}
void _waitForLogin() {
_cancelTimers();
_parts.clear();
_index = 0;
_expected = null;
_connected = false;
awaitingAuthentication = true;
message = requestingExams ? '请在学校网页完成登录和验证码,随后重新读取全部考试。' : manualMessage;
_emit();
}
void _schedulePoll(int g, int document, String url) {
_poll?.cancel();
_poll = Timer(const Duration(milliseconds: 450), () {
if (_valid(g) && _document == document && !_failed && !_complete)
unawaited(_loaded(url));
});
}
Future<UndergraduatePageEvidence> _evidence(Uri uri, bool capture) async =>
UndergraduatePageEvidence.decode(
await web.runJavaScriptReturningResult(
capture ? undergraduateCaptureScript : undergraduateStatusProbeScript,
),
uri,
);
Future<void> _loaded(String url) async {
final g = _generation, d = _document, key = '$_generation:$_document';
if (!_valid(g) || _failed || _complete || _probeKey == key) return;
_probeKey = key;
try {
final current = await web.currentUrl();
if (!_valid(g) || d != _document || current != url) return;
final uri = Uri.tryParse(url);
if (uri == null ||
!UndergraduateNavigationPolicy.canNavigate(uri, isMainFrame: true))
return;
final status = await _evidence(uri, false);
if (!_valid(g) || d != _document) return;
_loading = false;
if (!status.authenticated) {
_waitForLogin();
_schedulePoll(g, d, url);
return;
}
_connected = true;
awaitingAuthentication = false;
if (!requestingExams) {
message = manualMessage;
_emit();
return;
}
if (_expected == null || uri.toString() != _expected.toString()) {
await _requestExam(g);
return;
}
final evidence = await _evidence(uri, true);
if (!_valid(g) || d != _document) return;
final pages = evidence.pages.where((p) => p.kind == 'exams').toList();
if (pages.isEmpty) {
_schedulePoll(g, d, url);
return;
}
if (pages.length != 1) throw const FormatException('排考结果页面不明确');
final part = UndergraduateExamAdapter.extract(
pages.single.html,
expectedTerm: query!.term,
expectedType: query!.orderedTypes[_index],
);
if (!_valid(g) || d != _document) return;
_parts.add(part);
_index++;
_itemTimeout?.cancel();
_itemTimeout = null;
if (_index == query!.orderedTypes.length) {
pendingExams = UndergraduateExamAdapter.merge(query!, _parts);
_complete = true;
_cancelTimers();
message = '考试已全部读取,正在保存…';
_emit();
} else {
await _requestExam(g);
}
} catch (error) {
if (_valid(g) && d == _document) _failure('$error');
} finally {
if (_probeKey == key) _probeKey = null;
}
}
Future<void> _requestExam(int g) async {
if (!_valid(g) || query == null || _complete) return;
final type = query!.orderedTypes[_index];
_expected = query!.uriFor(type);
_loading = true;
message = '正在读取${type.label}(${_index + 1}/${query!.types.length})';
_emit();
_itemTimeout?.cancel();
_itemTimeout = Timer(const Duration(seconds: 45), () {
if (_valid(g)) _failure('${type.label}读取超时,请重试。');
});
_batchTimeout ??= Timer(const Duration(seconds: 120), () {
if (_valid(g)) _failure('考试读取超时,请重试。');
});
await _navigate(_expected!);
}
Future<void> readCurrentTimetable() async {
if (requestingExams || _manualCapture || !_active) return;
final g = _generation, d = _document;
_manualCapture = true;
_emit();
try {
final current = Uri.parse(await web.currentUrl() ?? '');
final evidence = await _evidence(current, true);
if (!_valid(g) || d != _document) return;
final pages = evidence.pages.where((p) => p.kind == 'timetable').toList();
if (pages.length != 1)
throw const FormatException('请先在学校网页打开完整课表,再点击导入。');
final draft = UndergraduateTimetableAdapter.extract(
pages.single.html,
pages.single.url,
);
if (!_valid(g) || d != _document) return;
pendingDraft = draft;
_connected = true;
awaitingAuthentication = false;
message = '课表已识别,正在确认导入…';
_emit();
} catch (error) {
if (_valid(g) && d == _document) {
message = '导入未完成:$error';
_emit();
}
} finally {
if (_valid(g)) {
_manualCapture = false;
_emit();
}
}
}
bool saved({required int generation}) {
if (!_valid(generation) || pendingDraft == null && pendingExams == null)
return false;
pendingDraft = null;
pendingExams = null;
message = '已导入';
_emit();
return true;
}
Future<void> reauthenticate() async {
await _logoutFuture;
if (_disposed) return;
_reset();
_active = true;
_connected = false;
awaitingAuthentication = true;
hasOpened = true;
message = '请在学校网页重新登录。';
await _navigate(loginUri);
}
void invalidateAuthentication() {
_reset();
_connected = false;
awaitingAuthentication = false;
message = '学校登录已退出';
_emit();
}
Future<void> logout() =>
_logoutFuture ??= _logout().whenComplete(() => _logoutFuture = null);
Future<void> _logout() async {
final opened = hasOpened;
_active = false;
invalidateAuthentication();
if (!opened && !logoutFailed) return;
try {
await WebViewCookieManager().clearCookies();
if (_web != null) {
await _web!.clearCache();
await _web!.clearLocalStorage();
await _web!.loadHtmlString('<html></html>');
}
hasOpened = false;
logoutFailed = false;
query = null;
} catch (_) {
logoutFailed = true;
message = '退出未完成,请重试。';
}
_emit();
}
@override
void dispose() {
_disposed = true;
_active = false;
_generation++;
_cancelTimers();
super.dispose();
}
}