feat: deliver RSchedule service login exams and elastic glass navigation

This commit is contained in:
Codex
2026-10-13 17:06:50 +08:00
parent 9193fd8e43
commit 8f20a341c4
35 changed files with 1742 additions and 453 deletions
+202
View File
@@ -0,0 +1,202 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_theme.dart';
import '../school/school_exam_adapter.dart';
class ExamsPage extends StatelessWidget {
const ExamsPage({super.key, required this.controller, required this.onSync});
final AppController controller;
final VoidCallback onSync;
Future<void> _import(BuildContext context) async {
try {
final file = await FilePicker.pickFile(
type: FileType.custom,
allowedExtensions: ['html', 'htm'],
);
if (file == null) return;
final bytes = BytesBuilder(copy: false);
var length = 0;
await for (final chunk in file.readAsByteStream()) {
length += chunk.length;
if (length > 2 * 1024 * 1024) {
throw const FormatException('文件不能超过 2 MB');
}
bytes.add(chunk);
}
final draft = SchoolExamAdapter.extract(utf8.decode(bytes.takeBytes()));
if (!context.mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('导入考试信息'),
content: Text(
'${draft.semesterLabel} · ${draft.records.length} 门课程\n将更新本机考试列表,尚未公布的字段显示为“尚未安排”。',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('确认导入'),
),
],
),
);
if (confirmed == true) {
await controller.applyExams(draft);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(controller.lastError ?? '考试信息已导入')),
);
}
}
} catch (error) {
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('导入未完成:$error')));
}
}
}
@override
Widget build(BuildContext context) {
final schedule = controller.snapshot.examSchedule;
return ListView(
key: const PageStorageKey('exams-scroll'),
padding: EdgeInsets.fromLTRB(
22,
10,
22,
140 + MediaQuery.paddingOf(context).bottom,
),
children: [
const Text(
'RSchedule',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(height: 18),
Row(
children: [
const Expanded(
child: Text(
'考试安排',
style: TextStyle(fontSize: 25, fontWeight: FontWeight.w700),
),
),
IconButton(
tooltip: '从学校更新考试',
onPressed: onSync,
icon: const Icon(Icons.sync_rounded),
),
IconButton(
tooltip: '导入考试 HTML',
onPressed: () => _import(context),
icon: const Icon(Icons.file_download_outlined),
),
],
),
const SizedBox(height: 8),
if (schedule == null) ...[
const SizedBox(height: 54),
const Icon(
Icons.assignment_outlined,
size: 64,
color: Color(0xFF9ABBE8),
),
const SizedBox(height: 20),
const Text(
'等待学校公布考试安排',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
const Text(
'连接研究生系统,或导入学校考试页面的 HTML。',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, height: 1.8),
),
const SizedBox(height: 24),
Center(
child: FilledButton.icon(
onPressed: onSync,
icon: const Icon(Icons.school_outlined),
label: const Text('连接学校考试查询'),
),
),
] else ...[
Text(
'${schedule.semesterLabel} · ${schedule.records.length} 门课程',
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.55)),
),
const SizedBox(height: 18),
if (schedule.records.isEmpty) const Text('该学期暂无考试课程'),
for (final exam in schedule.records)
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
exam.name,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 14),
_field(Icons.schedule_rounded, '考试时间', exam.time),
_field(Icons.location_on_outlined, '考场', exam.room),
_field(Icons.event_seat_outlined, '座位号', exam.seat),
if (exam.assessment.isNotEmpty)
_field(
Icons.assignment_outlined,
'考核方式',
exam.assessment,
),
if (exam.candidate.isNotEmpty)
_field(Icons.badge_outlined, '考生编号', exam.candidate),
if (exam.sequence.isNotEmpty)
_field(
Icons.format_list_numbered,
'考场顺序号',
exam.sequence,
),
],
),
),
),
const Text(
'排考信息以学校最新公布为准。',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: Color(0xFF8493A5)),
),
],
],
);
}
Widget _field(IconData icon, String label, String value) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 17, color: const Color(0xFF007AFF)),
const SizedBox(width: 9),
Expanded(
child: Text(
'$label:${value.isEmpty ? '尚未安排' : value}',
style: const TextStyle(fontSize: 13, height: 1.5),
),
),
],
),
);
}