diff --git a/mobile/lib/domain/app_snapshot.dart b/mobile/lib/domain/app_snapshot.dart index 19b0b72..ca15d56 100644 --- a/mobile/lib/domain/app_snapshot.dart +++ b/mobile/lib/domain/app_snapshot.dart @@ -3,6 +3,7 @@ import 'course.dart'; import 'semester.dart'; import 'exam_schedule.dart'; import 'school_term.dart'; +import 'school_identity.dart'; class ImportMetadata { const ImportMetadata({ @@ -23,9 +24,12 @@ class AppSnapshot { this.examSchedule, SchoolTerm? schoolTerm, this.calendarCleanupPending = false, + SupportedSchool? selectedSchool, Map calendarEventIds = const {}, }) : settings = settings ?? AppSettings(), schoolTerm = schoolTerm ?? _legacyTerm(courses, examSchedule), + selectedSchool = selectedSchool ?? schoolTerm?.school ?? + _legacyTerm(courses, examSchedule)?.school ?? examSchedule?.school, courses = List.unmodifiable(courses), calendarEventIds = Map.unmodifiable(calendarEventIds); final AppSettings settings; @@ -35,6 +39,7 @@ class AppSnapshot { final SchoolTerm? schoolTerm; final Map calendarEventIds; final bool calendarCleanupPending; + final SupportedSchool? selectedSchool; static SchoolTerm? _legacyTerm(List courses, ExamSchedule? exams) { final ids = courses .map( @@ -42,7 +47,7 @@ class AppSnapshot { ) .whereType() .toSet(); - if (exams != null) ids.add(exams.termId); + if (exams?.school == SupportedSchool.uestcGraduate) ids.add(exams!.termId); if (ids.length != 1) return null; final id = int.tryParse(ids.single); return id != null && id >= 1 && id <= 9999 ? SchoolTerm(id) : null; @@ -58,6 +63,7 @@ class AppSnapshot { bool clearExams = false, SchoolTerm? schoolTerm, bool? calendarCleanupPending, + SupportedSchool? selectedSchool, }) => AppSnapshot( settings: settings ?? this.settings, courses: courses ?? this.courses, @@ -69,11 +75,13 @@ class AppSnapshot { calendarEventIds: calendarEventIds ?? this.calendarEventIds, calendarCleanupPending: calendarCleanupPending ?? this.calendarCleanupPending, + selectedSchool:selectedSchool ?? this.selectedSchool, ); Map toJson() => { 'schemaVersion': 1, 'examSchedule': examSchedule?.toJson(), 'schoolTerm': schoolTerm?.toJson(), + 'selectedSchoolId':selectedSchool?.id, 'courses': courses.map((c) => c.toJson()).toList(), 'settings': { 'semester': settings.semester == null @@ -144,7 +152,8 @@ class AppSnapshot { totalWeeks: sem['totalWeeks'] as int, ); final metadata = json['importMetadata']; - return AppSnapshot( + final snapshot = AppSnapshot( + selectedSchool:json['selectedSchoolId']==null ? null : SupportedSchool.fromId(json['selectedSchoolId'] as String), schoolTerm: json['schoolTerm'] == null ? null : SchoolTerm.fromJson( @@ -181,6 +190,9 @@ class AppSnapshot { (k, v) => MapEntry(k as String, v as String), ), ); + if(snapshot.schoolTerm?.school != SupportedSchool.uestcUndergraduate && + values.any((c)=>c.endPeriod>11)) throw const FormatException('第12节需要本科课表来源'); + return snapshot; } catch (_) { throw const FormatException('保存文件内容无效,未覆盖已有数据'); } diff --git a/mobile/lib/domain/course.dart b/mobile/lib/domain/course.dart index d1b95fb..b9a55cf 100644 --- a/mobile/lib/domain/course.dart +++ b/mobile/lib/domain/course.dart @@ -11,12 +11,14 @@ class Course { required this.endPeriod, required Set weeks, required this.source, + this.courseCode = '', + this.notes = '', }) : weeks = Set.unmodifiable(weeks) { if ([id, name, teacher, room].any((value) => value.trim().isEmpty) || weekday < 1 || weekday > 7 || startPeriod < 1 || - endPeriod > 11 || + endPeriod > 12 || endPeriod < startPeriod || weeks.isEmpty || weeks.any((week) => week < 1 || week > 30)) { @@ -24,6 +26,7 @@ class Course { } } final String id, name, teacher, room; + final String courseCode, notes; final int weekday, startPeriod, endPeriod; final Set weeks; final CourseSource source; @@ -38,6 +41,8 @@ class Course { 'endPeriod': endPeriod, 'weeks': weeks.toList()..sort(), 'source': source.name, + 'courseCode': courseCode, + 'notes': notes, }; static Course fromJson(Map json, {CourseSource? source}) { @@ -62,6 +67,8 @@ class Course { endPeriod: json['endPeriod'] as int, weeks: rawWeeks.cast().toSet(), source: source ?? CourseSource.values.byName(json['source'] as String), + courseCode: (json['courseCode'] ?? '') as String, + notes: (json['notes'] ?? '') as String, ); } catch (_) { throw const FormatException('课程字段、节次或周次无效'); diff --git a/mobile/lib/domain/exam_schedule.dart b/mobile/lib/domain/exam_schedule.dart index fb4e85d..a65c8c0 100644 --- a/mobile/lib/domain/exam_schedule.dart +++ b/mobile/lib/domain/exam_schedule.dart @@ -1,3 +1,7 @@ +import 'dart:convert'; +import 'school_identity.dart'; +import 'school_term.dart'; + class ExamRecord { const ExamRecord({ required this.code, @@ -8,8 +12,12 @@ class ExamRecord { this.room = '', this.seat = '', this.time = '', + this.date = '', this.status = '', this.notes = '', this.examTypeId, }); final String code, name, assessment, candidate, sequence, room, seat, time; + final String date, status, notes; + final int? examTypeId; + String get undergraduateKey => jsonEncode([examTypeId,code,date,time,room,seat]); Map toJson() => { 'code': code, 'name': name, @@ -19,10 +27,11 @@ class ExamRecord { 'room': room, 'seat': seat, 'time': time, + 'date':date, 'status':status, 'notes':notes, 'examTypeId':examTypeId, }; factory ExamRecord.fromJson(Map json) { - String field(String key) { - final value = json[key]; + String field(String key, {bool optional=false}) { + final value = optional && !json.containsKey(key) ? '' : json[key]; if (value is! String || value.length > 1000) { throw const FormatException('考试信息无效'); } @@ -38,6 +47,8 @@ class ExamRecord { room: field('room'), seat: field('seat'), time: field('time'), + date:field('date',optional:true), status:field('status',optional:true), notes:field('notes',optional:true), + examTypeId:json['examTypeId'] as int?, ); if (record.code.isEmpty || record.name.isEmpty) { throw const FormatException('考试课程无效'); @@ -51,15 +62,21 @@ class ExamSchedule { required this.semesterLabel, required this.termId, required List records, - }) : records = List.unmodifiable(records); + this.school = SupportedSchool.uestcGraduate, + Set requestedExamTypes = const {}, + }) : records = List.unmodifiable(records), requestedExamTypes=Set.unmodifiable(requestedExamTypes); final String semesterLabel, termId; final List records; + final SupportedSchool school; + final Set requestedExamTypes; Map toJson() => { 'semesterLabel': semesterLabel, 'termId': termId, 'records': records.map((r) => r.toJson()).toList(), + 'schoolId':school.id, 'requestedExamTypes':requestedExamTypes.toList()..sort(), }; factory ExamSchedule.fromJson(Map json) { + try { final label = json['semesterLabel'], id = json['termId'], rows = json['records']; @@ -74,9 +91,18 @@ class ExamSchedule { final records = rows .map((r) => ExamRecord.fromJson(Map.from(r as Map))) .toList(); - if (records.map((r) => r.code).toSet().length != records.length) { + final school=json['schoolId']==null ? SupportedSchool.uestcGraduate : SupportedSchool.fromId(json['schoolId'] as String); + final rawTypes=json['requestedExamTypes'] ?? []; + if(rawTypes is! List || rawTypes.any((t)=>t is! int || t<1 || t>3)) throw const FormatException('考试类型无效'); + final types=rawTypes.cast().toSet(); + SchoolTerm(int.parse(id),school:school); + if(school==SupportedSchool.uestcUndergraduate && (types.isEmpty || records.any((r)=>!types.contains(r.examTypeId)))) { + throw const FormatException('本科考试类型无效'); + } + if (records.map((r) => school==SupportedSchool.uestcGraduate ? r.code : r.undergraduateKey).toSet().length != records.length) { throw const FormatException('考试课程重复'); } - return ExamSchedule(semesterLabel: label, termId: id, records: records); + return ExamSchedule(semesterLabel: label, termId: id, records: records,school:school,requestedExamTypes:types); + } catch (_) { throw const FormatException('考试列表无效'); } } } diff --git a/mobile/lib/domain/school_identity.dart b/mobile/lib/domain/school_identity.dart new file mode 100644 index 0000000..b465615 --- /dev/null +++ b/mobile/lib/domain/school_identity.dart @@ -0,0 +1,9 @@ +enum SupportedSchool { + uestcGraduate('uestc_graduate', '电子科技大学(研究生)', '研究生课表与考试安排'), + uestcUndergraduate('uestc_undergraduate', '电子科技大学(本科生)', '手动导入课表 · 自动查询考试'); + + const SupportedSchool(this.id, this.label, this.description); + final String id, label, description; + static SupportedSchool fromId(String id) => values.firstWhere( + (s) => s.id == id, orElse: () => throw const FormatException('学校标识无效')); +} diff --git a/mobile/lib/domain/school_term.dart b/mobile/lib/domain/school_term.dart index 9340440..5eb792d 100644 --- a/mobile/lib/domain/school_term.dart +++ b/mobile/lib/domain/school_term.dart @@ -1,27 +1,54 @@ -/// The graduate system's term number, independent of teaching-week dates. +import 'school_identity.dart'; + +/// School-specific term numbers, independent of teaching-week dates. class SchoolTerm { - SchoolTerm(this.id) { - if (id < 1 || id > 9999) throw ArgumentError('学校学期编号无效'); + SchoolTerm(this.id, {this.school = SupportedSchool.uestcGraduate}) { + if (id < 1 || id > 9999 || + (school == SupportedSchool.uestcUndergraduate && (id < 3 || (id-3)%20 != 0))) { + throw ArgumentError('学校学期编号无效'); + } } final int id; + final SupportedSchool school; String get label { + if (school == SupportedSchool.uestcUndergraduate) { + final n = (id - 3) ~/ 20, year = 2013 + ((id - 3) ~/ 40); + return '$year-${year+1}${n.isEven ? '第一学期' : '第二学期'}'; + } final year = 1990 + ((id - 1) ~/ 2); return '$year-${year + 1}年${id.isOdd ? '秋' : '春'}'; } static List get choices => List.generate(73, (index) => SchoolTerm(73 - index)); - Uri get timetableUri => - Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/xskbcx/index/$id'); - Uri get examUri => - Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/kskcxx/index', {'xq': '$id'}); - Map toJson() => {'id': id}; + static List undergraduateChoices(DateTime now, {SchoolTerm? saved}) { + final maxN = (now.year + 1 - 2013)*2+1; + final ids = {for(var n=0;n<=maxN;n++) 3+n*20, + if(saved?.school == SupportedSchool.uestcUndergraduate) saved!.id}.toList() + ..sort((a,b)=>b.compareTo(a)); + return ids.where((id)=>id<=9999).map((id)=>SchoolTerm(id,school:SupportedSchool.uestcUndergraduate)).toList(); + } + void _graduateOnly() { + if (school != SupportedSchool.uestcGraduate) throw StateError('本科查询不能使用研究生链接'); + } + Uri get timetableUri { + _graduateOnly(); + return Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/xskbcx/index/$id'); + } + Uri get examUri { + _graduateOnly(); + return Uri.https('yjsjy.uestc.edu.cn', '/pyxx/pygl/kskcxx/index', {'xq': '$id'}); + } + Map toJson() => {'id': id, 'schoolId': school.id}; factory SchoolTerm.fromJson(Map json) { if (json['id'] is! int) throw const FormatException('学校学期无效'); - return SchoolTerm(json['id'] as int); + try { + return SchoolTerm(json['id'] as int, school: json['schoolId'] == null + ? SupportedSchool.uestcGraduate : SupportedSchool.fromId(json['schoolId'] as String)); + } catch (_) { throw const FormatException('学校学期无效'); } } @override - bool operator ==(Object other) => other is SchoolTerm && other.id == id; + bool operator ==(Object other) => other is SchoolTerm && other.id == id && other.school == school; @override - int get hashCode => id.hashCode; + int get hashCode => Object.hash(id, school); } diff --git a/mobile/lib/domain/undergraduate_exam_query.dart b/mobile/lib/domain/undergraduate_exam_query.dart new file mode 100644 index 0000000..8bf827f --- /dev/null +++ b/mobile/lib/domain/undergraduate_exam_query.dart @@ -0,0 +1,31 @@ +import 'school_identity.dart'; +import 'school_term.dart'; +import 'exam_schedule.dart'; + +enum UndergraduateExamType { + finalExam(1,'期末考试'), midterm(2,'期中考试'), makeup(3,'补考/缓考'); + const UndergraduateExamType(this.id, this.label); + final int id; + final String label; + static UndergraduateExamType fromId(int id) => values.firstWhere((t)=>t.id==id, + orElse:()=>throw const FormatException('考试类型无效')); +} + +class UndergraduateExamQuery { + UndergraduateExamQuery({required this.term, required Set types}) + : types=Set.unmodifiable(types) { + if(term.school != SupportedSchool.uestcUndergraduate || types.isEmpty) throw ArgumentError('请选择本科学期和考试类型'); + } + final SchoolTerm term; + final Set types; + List get orderedTypes => UndergraduateExamType.values.where(types.contains).toList(); + Uri uriFor(UndergraduateExamType type) { + if(!types.contains(type)) throw ArgumentError('考试类型不在查询选项中'); + return Uri.https('eams.uestc.edu.cn','/eams/stdExamTable!examTable.action',{'semester.id':'${term.id}','examType.id':'${type.id}'}); + } + static UndergraduateExamQuery? fromSchedule(ExamSchedule? schedule) { + if(schedule?.school != SupportedSchool.uestcUndergraduate) return null; + return UndergraduateExamQuery(term:SchoolTerm(int.parse(schedule!.termId),school:schedule.school), + types:schedule.requestedExamTypes.map(UndergraduateExamType.fromId).toSet()); + } +} diff --git a/mobile/lib/features/school/school_selection_page.dart b/mobile/lib/features/school/school_selection_page.dart index 95aea94..8e6b753 100644 --- a/mobile/lib/features/school/school_selection_page.dart +++ b/mobile/lib/features/school/school_selection_page.dart @@ -1,14 +1,8 @@ import 'package:flutter/material.dart'; import '../../app/app_theme.dart'; - -/// Only list schools whose login and import workflow is available. -enum SupportedSchool { - uestcGraduate('电子科技大学(研究生)', '研究生课表与考试安排'); - - const SupportedSchool(this.label, this.description); - final String label, description; -} +import '../../domain/school_identity.dart'; +export '../../domain/school_identity.dart'; class SchoolSelectionPage extends StatelessWidget { const SchoolSelectionPage({super.key, this.selected}); diff --git a/mobile/test/domain/undergraduate_models_test.dart b/mobile/test/domain/undergraduate_models_test.dart new file mode 100644 index 0000000..b57583d --- /dev/null +++ b/mobile/test/domain/undergraduate_models_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/domain/school_identity.dart'; +import 'package:hepan_everyday/domain/school_term.dart'; +import 'package:hepan_everyday/domain/undergraduate_exam_query.dart'; +import 'package:hepan_everyday/domain/exam_schedule.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/course.dart'; + +void main() { + SchoolTerm under(int id) => SchoolTerm(id, school: SupportedSchool.uestcUndergraduate); + test('undergraduate semester labels and final exam URLs', () { + expect([483,503,523,543,563].map((id)=>under(id).label).toList(), + ['2025-2026第一学期','2025-2026第二学期','2026-2027第一学期','2026-2027第二学期','2027-2028第一学期']); + final query = UndergraduateExamQuery(term: under(523), types: {UndergraduateExamType.finalExam}); + expect(query.uriFor(UndergraduateExamType.finalExam).toString(), 'https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action?semester.id=523&examType.id=1'); + expect(() => UndergraduateExamQuery(term: under(523),types: {}),throwsArgumentError); + expect(() => under(504), throwsArgumentError); + expect(under(523), isNot(SchoolTerm(523))); + expect(()=>under(523).examUri, throwsStateError); + expect(SchoolTerm.fromJson({'id':73}).school, SupportedSchool.uestcGraduate); + expect(SchoolTerm.undergraduateChoices(DateTime(2026,10)).first.id, 583); + }); + test('undergraduate exam identity retains both types and rejects corruption', () { + final schedule=ExamSchedule(semesterLabel:under(523).label,termId:'523',school:SupportedSchool.uestcUndergraduate, + requestedExamTypes:{1,2},records:[const ExamRecord(code:'A',name:'课程',examTypeId:1), const ExamRecord(code:'A',name:'课程',examTypeId:2)]); + expect(ExamSchedule.fromJson(schedule.toJson()).records,hasLength(2)); + expect(()=>ExamSchedule.fromJson({...schedule.toJson(),'requestedExamTypes':[]}),throwsFormatException); + expect(()=>ExamSchedule.fromJson({...schedule.toJson(),'schoolId':'unknown'}),throwsFormatException); + final old=ExamSchedule(semesterLabel:'2026秋',termId:'73',records:[const ExamRecord(code:'A',name:'课程'),const ExamRecord(code:'A',name:'课程')]); + expect(()=>ExamSchedule.fromJson(old.toJson()),throwsFormatException); + }); + test('snapshots persist school selection and validate period twelve source', () { + final course=Course(id:'under-523-test',name:'课程',teacher:'教师',room:'教室',weekday:1,startPeriod:9,endPeriod:12,weeks:{1},source:CourseSource.school,courseCode:'A',notes:'备注'); + final saved=AppSnapshot(selectedSchool:SupportedSchool.uestcUndergraduate,schoolTerm:under(523),courses:[course]); + final round=AppSnapshot.fromJson(saved.toJson()); + expect(round.selectedSchool, SupportedSchool.uestcUndergraduate); + expect(round.courses.single.notes,'备注'); + expect(()=>AppSnapshot.fromJson({...saved.toJson(),'schoolTerm':{'id':73}}),throwsFormatException); + expect(()=>AppSnapshot.fromJson({...saved.toJson(),'selectedSchoolId':'unknown'}),throwsFormatException); + final legacy=AppSnapshot(schoolTerm:SchoolTerm(73)).toJson()..remove('selectedSchoolId'); + expect(AppSnapshot.fromJson(legacy).selectedSchool,SupportedSchool.uestcGraduate); + }); +}