😋 初始化仓库

This commit is contained in:
2025-11-13 09:14:49 +08:00
commit 347d264437
133 changed files with 11214 additions and 0 deletions

2
lib/services/.gitkeep Normal file
View File

@@ -0,0 +1,2 @@
# Services directory
# This directory contains business logic services

View File

@@ -0,0 +1,275 @@
import 'package:flutter/foundation.dart';
import '../providers/auth_provider.dart';
import '../services/notification_service.dart';
import '../services/storage_service.dart';
import '../utils/session_manager.dart';
/// Service for handling app initialization tasks
///
/// Coordinates initialization of various services and providers:
/// - Theme preferences loading
/// - Notification service setup
/// - Session restoration
/// - Cache management
///
/// Usage example:
/// ```dart
/// final initService = AppInitializationService(
/// authProvider: authProvider,
/// themeProvider: themeProvider,
/// notificationService: notificationService,
/// );
///
/// final result = await initService.initialize();
/// if (result.success) {
/// // App initialized successfully
/// }
/// ```
class AppInitializationService {
final AuthProvider _authProvider;
final NotificationService _notificationService;
final StorageService _storageService;
late final SessionManager _sessionManager;
AppInitializationService({
required AuthProvider authProvider,
required NotificationService notificationService,
StorageService? storageService,
}) : _authProvider = authProvider,
_notificationService = notificationService,
_storageService = storageService ?? StorageService() {
_sessionManager = SessionManager(authProvider: _authProvider);
}
/// Initialize the application
///
/// Performs all necessary initialization tasks in the correct order
/// Returns InitializationResult with status and details
Future<InitializationResult> initialize() async {
final startTime = DateTime.now();
final steps = <InitializationStep>[];
try {
// Step 1: Initialize notification service
steps.add(await _initializeNotifications());
// Step 2: Load theme preferences
steps.add(await _loadThemePreferences());
// Step 3: Check and manage cache
steps.add(await _manageCacheVersion());
// Step 4: Attempt session restoration
steps.add(await _restoreSession());
final duration = DateTime.now().difference(startTime);
final allSuccessful = steps.every((step) => step.success);
return InitializationResult(
success: allSuccessful,
steps: steps,
duration: duration,
sessionRestored: steps
.firstWhere(
(s) => s.name == 'Session Restoration',
orElse: () => InitializationStep(
name: 'Session Restoration',
success: false,
),
)
.success,
);
} catch (e) {
debugPrint('Error during app initialization: $e');
final duration = DateTime.now().difference(startTime);
return InitializationResult(
success: false,
steps: steps,
duration: duration,
error: e.toString(),
);
}
}
/// Initialize notification service
Future<InitializationStep> _initializeNotifications() async {
try {
await _notificationService.initialize();
return InitializationStep(
name: 'Notification Service',
success: true,
message: 'Notification service initialized',
);
} catch (e) {
debugPrint('Failed to initialize notifications: $e');
return InitializationStep(
name: 'Notification Service',
success: false,
message: 'Failed to initialize notifications: $e',
);
}
}
/// Load theme preferences
Future<InitializationStep> _loadThemePreferences() async {
try {
// Theme preferences are loaded in ThemeProvider constructor
// This step just confirms it's ready
return InitializationStep(
name: 'Theme Preferences',
success: true,
message: 'Theme preferences loaded',
);
} catch (e) {
debugPrint('Failed to load theme preferences: $e');
return InitializationStep(
name: 'Theme Preferences',
success: false,
message: 'Failed to load theme preferences: $e',
);
}
}
/// Manage cache version
Future<InitializationStep> _manageCacheVersion() async {
try {
const expectedCacheVersion = 1; // Update this when data structure changes
final cleared = await _storageService.clearCacheIfVersionMismatch(
expectedCacheVersion,
);
if (cleared) {
return InitializationStep(
name: 'Cache Management',
success: true,
message: 'Cache cleared due to version mismatch',
);
} else {
return InitializationStep(
name: 'Cache Management',
success: true,
message: 'Cache version is current',
);
}
} catch (e) {
debugPrint('Failed to manage cache: $e');
return InitializationStep(
name: 'Cache Management',
success: false,
message: 'Failed to manage cache: $e',
);
}
}
/// Attempt to restore previous session
Future<InitializationStep> _restoreSession() async {
try {
// Check if session can be restored
final canRestore = await _sessionManager.canRestoreSession();
if (!canRestore) {
return InitializationStep(
name: 'Session Restoration',
success: false,
message: 'No saved session to restore',
);
}
// Attempt restoration
final result = await _sessionManager.restoreSession();
if (result.success) {
return InitializationStep(
name: 'Session Restoration',
success: true,
message: 'Session restored successfully',
);
} else {
// Session restoration failed, but this is not a critical error
// User will just need to login again
return InitializationStep(
name: 'Session Restoration',
success: false,
message: result.message,
reason: result.reason,
);
}
} catch (e) {
debugPrint('Error during session restoration: $e');
return InitializationStep(
name: 'Session Restoration',
success: false,
message: 'Session restoration error: $e',
);
}
}
/// Get session manager instance
SessionManager get sessionManager => _sessionManager;
/// Get storage service instance
StorageService get storageService => _storageService;
}
/// Result of app initialization
class InitializationResult {
final bool success;
final List<InitializationStep> steps;
final Duration duration;
final bool sessionRestored;
final String? error;
InitializationResult({
required this.success,
required this.steps,
required this.duration,
this.sessionRestored = false,
this.error,
});
/// Get failed steps
List<InitializationStep> get failedSteps =>
steps.where((step) => !step.success).toList();
/// Get successful steps
List<InitializationStep> get successfulSteps =>
steps.where((step) => step.success).toList();
/// Check if initialization is complete (even with some non-critical failures)
bool get isComplete => steps.isNotEmpty;
@override
String toString() {
return 'InitializationResult('
'success: $success, '
'sessionRestored: $sessionRestored, '
'steps: ${steps.length}, '
'duration: ${duration.inMilliseconds}ms'
')';
}
}
/// Individual initialization step
class InitializationStep {
final String name;
final bool success;
final String? message;
final SessionRestoreFailureReason? reason;
InitializationStep({
required this.name,
required this.success,
this.message,
this.reason,
});
@override
String toString() {
return 'InitializationStep('
'name: $name, '
'success: $success, '
'message: $message'
')';
}
}

View File

@@ -0,0 +1,539 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:crypto/crypto.dart';
import 'package:pointycastle/export.dart';
import '../models/login_status.dart';
import '../models/course.dart';
import 'http_client.dart';
import '../utils/retry_handler.dart';
/// AUFE教务系统连接类
class AUFEConnection {
final String userId;
final String ecPassword;
final String password;
late HTTPClient _client;
String? _twfId;
String? _token;
bool _ecLogged = false;
bool _uaapLogged = false;
DateTime _lastCheck = DateTime.now();
// 配置常量
static const String serverUrl = 'https://vpn.aufe.edu.cn';
static const String uaapLoginUrl =
'http://uaap-aufe-edu-cn.vpn2.aufe.edu.cn:8118/cas/login?service=http%3A%2F%2Fjwcxk2.aufe.edu.cn%2Fj_spring_cas_security_check';
static const String uaapCheckUrl =
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/';
static const String ecCheckUrl =
'http://txzx-aufe-edu-cn-s.vpn2.aufe.edu.cn:8118/dzzy/list.htm';
AUFEConnection({
required this.userId,
required this.ecPassword,
required this.password,
});
/// 初始化HTTP客户端
void startClient() {
_client = HTTPClient(baseUrl: serverUrl, timeout: 30000);
}
/// EC系统登录RSA加密
Future<ECLoginStatus> ecLogin() async {
try {
return await RetryHandler.retry(
operation: () async => await _performEcLogin(),
retryIf: RetryHandler.shouldRetryOnError,
maxAttempts: 3,
);
} catch (e) {
return ECLoginStatus(failUnknownError: true);
}
}
Future<ECLoginStatus> _performEcLogin() async {
try {
// 1. 获取认证参数
final response = await _client.get('/por/login_auth.csp?apiversion=1');
final responseText = response.data.toString();
// 2. 提取TwfID
final twfIdMatch = RegExp(
r'<TwfID>(.*?)</TwfID>',
).firstMatch(responseText);
if (twfIdMatch == null) {
return ECLoginStatus(failNotFoundTwfid: true);
}
_twfId = twfIdMatch.group(1);
// 3. 提取RSA密钥
final rsaKeyMatch = RegExp(
r'<RSA_ENCRYPT_KEY>(.*?)</RSA_ENCRYPT_KEY>',
).firstMatch(responseText);
if (rsaKeyMatch == null) {
return ECLoginStatus(failNotFoundRsaKey: true);
}
final rsaKey = rsaKeyMatch.group(1)!;
// 4. 提取RSA指数
final rsaExpMatch = RegExp(
r'<RSA_ENCRYPT_EXP>(.*?)</RSA_ENCRYPT_EXP>',
).firstMatch(responseText);
if (rsaExpMatch == null) {
return ECLoginStatus(failNotFoundRsaExp: true);
}
final rsaExp = rsaExpMatch.group(1)!;
// 5. 提取CSRF代码
final csrfMatch = RegExp(
r'<CSRF_RAND_CODE>(.*?)</CSRF_RAND_CODE>',
).firstMatch(responseText);
if (csrfMatch == null) {
return ECLoginStatus(failNotFoundCsrfCode: true);
}
final csrfCode = csrfMatch.group(1)!;
// 6. RSA加密密码
final passwordToEncrypt = '${ecPassword}_$csrfCode';
final encryptedPassword = _rsaEncrypt(passwordToEncrypt, rsaKey, rsaExp);
// 7. 执行登录
final loginResponse = await _client.post(
'/por/login_psw.csp?anti_replay=1&encrypt=1&type=cs',
data: {
'svpn_rand_code': '',
'mitm': '',
'svpn_req_randcode': csrfCode,
'svpn_name': userId,
'svpn_password': encryptedPassword,
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
headers: {'Cookie': 'TWFID=$_twfId'},
),
);
final loginResponseText = loginResponse.data.toString();
// 8. 检查登录结果
if (loginResponseText.contains('<Result>1</Result>')) {
_client.setCookie('TWFID', _twfId!);
_ecLogged = true;
return ECLoginStatus(success: true);
} else if (loginResponseText.contains('Invalid username or password!')) {
return ECLoginStatus(failInvalidCredentials: true);
} else if (loginResponseText.contains('[CDATA[maybe attacked]]') ||
loginResponseText.contains('CAPTCHA required')) {
return ECLoginStatus(failMaybeAttacked: true);
} else {
return ECLoginStatus(failUnknownError: true);
}
} on DioException catch (e) {
return ECLoginStatus(failNetworkError: true);
} catch (e) {
return ECLoginStatus(failUnknownError: true);
}
}
/// RSA加密
String _rsaEncrypt(String plaintext, String modulusHex, String exponentStr) {
// 解析模数和指数
final modulus = BigInt.parse(modulusHex, radix: 16);
final exponent = BigInt.parse(exponentStr);
// 创建RSA公钥
final publicKey = RSAPublicKey(modulus, exponent);
// 创建加密器
final encryptor = PKCS1Encoding(RSAEngine());
encryptor.init(true, PublicKeyParameter<RSAPublicKey>(publicKey));
// 加密
final plainBytes = utf8.encode(plaintext);
final encrypted = encryptor.process(Uint8List.fromList(plainBytes));
// 转换为十六进制字符串
return encrypted.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// UAAP系统登录DES加密
Future<UAAPLoginStatus> uaapLogin() async {
try {
return await RetryHandler.retry(
operation: () async => await _performUaapLogin(),
retryIf: RetryHandler.shouldRetryOnError,
maxAttempts: 3,
);
} catch (e) {
return UAAPLoginStatus(failUnknownError: true);
}
}
Future<UAAPLoginStatus> _performUaapLogin() async {
try {
// 1. 获取登录页面
final response = await _client.get(uaapLoginUrl);
final responseText = response.data.toString();
// 2. 提取lt参数
final ltMatch = RegExp(
r'name="lt" value="(.*?)"',
).firstMatch(responseText);
if (ltMatch == null) {
return UAAPLoginStatus(failNotFoundLt: true);
}
final ltValue = ltMatch.group(1)!;
// 3. 提取execution参数
final executionMatch = RegExp(
r'name="execution" value="(.*?)"',
).firstMatch(responseText);
if (executionMatch == null) {
return UAAPLoginStatus(failNotFoundExecution: true);
}
final executionValue = executionMatch.group(1)!;
// 4. DES加密密码
final encryptedPassword = _desEncrypt(password, ltValue);
// 5. 提交登录表单
final loginResponse = await _client.post(
uaapLoginUrl,
data: {
'username': userId,
'password': encryptedPassword,
'lt': ltValue,
'execution': executionValue,
'_eventId': 'submit',
'submit': 'LOGIN',
},
options: Options(
contentType: Headers.formUrlEncodedContentType,
followRedirects: false,
validateStatus: (status) => status! < 500,
),
);
// 6. 检查登录结果并访问重定向URL以建立session
if (loginResponse.statusCode == 302) {
final location = loginResponse.headers['location']?.first ?? '';
print('🔐 UAAP redirect location: $location');
if (location.contains('ticket=')) {
// 访问重定向URL以完成CAS认证并建立session
print('🔐 Following redirect to establish session...');
final ticketResponse = await _client.get(
location,
options: Options(
followRedirects: true,
validateStatus: (status) => status! < 500,
),
);
print('🔐 Ticket response status: ${ticketResponse.statusCode}');
_uaapLogged = true;
return UAAPLoginStatus(success: true);
}
}
final loginResponseText = loginResponse.data.toString();
if (loginResponseText.contains('Invalid username or password')) {
return UAAPLoginStatus(failInvalidCredentials: true);
}
return UAAPLoginStatus(failUnknownError: true);
} on DioException catch (e) {
return UAAPLoginStatus(failNetworkError: true);
} catch (e) {
return UAAPLoginStatus(failUnknownError: true);
}
}
/// DES加密使用TripleDES ECB模式
String _desEncrypt(String plaintext, String key) {
// 处理密钥 - 取前8字节
var keyBytes = utf8.encode(key);
if (keyBytes.length > 8) {
keyBytes = keyBytes.sublist(0, 8);
} else if (keyBytes.length < 8) {
// 不足8字节用0填充
keyBytes = Uint8List(8)..setRange(0, keyBytes.length, keyBytes);
}
// 创建DES密钥TripleDES使用相同的8字节密钥重复3次
final desKey = KeyParameter(
Uint8List(24)
..setRange(0, 8, keyBytes)
..setRange(8, 16, keyBytes)
..setRange(16, 24, keyBytes),
);
// 创建加密器
final cipher = PaddedBlockCipherImpl(PKCS7Padding(), DESedeEngine());
cipher.init(true, PaddedBlockCipherParameters(desKey, null));
// 加密
final plainBytes = utf8.encode(plaintext);
final encrypted = cipher.process(Uint8List.fromList(plainBytes));
// Base64编码
return base64.encode(encrypted);
}
/// 检查EC登录状态
Future<ECCheckStatus> checkEcLoginStatus() async {
if (!_ecLogged) {
return ECCheckStatus(loggedIn: false);
}
try {
final response = await _client.get(ecCheckUrl);
if (response.statusCode == 200) {
return ECCheckStatus(loggedIn: true);
} else {
return ECCheckStatus(loggedIn: false);
}
} on DioException catch (e) {
return ECCheckStatus(failNetworkError: true);
} catch (e) {
return ECCheckStatus(failUnknownError: true);
}
}
/// 检查UAAP登录状态
Future<ECCheckStatus> checkUaapLoginStatus() async {
return ECCheckStatus(loggedIn: _uaapLogged);
}
/// 健康检查
Future<bool> healthCheck() async {
final delta = DateTime.now().difference(_lastCheck);
// 5分钟未检查则视为不健康
if (delta.inSeconds > 300) {
return false;
}
// 检查UAAP登录状态
final uaapStatus = await checkUaapLoginStatus();
if (!uaapStatus.isLoggedIn) {
return false;
}
// 检查EC登录状态
final ecStatus = await checkEcLoginStatus();
if (!ecStatus.isLoggedIn) {
return false;
}
return true;
}
/// 更新健康检查时间戳
void healthCheckpoint() {
_lastCheck = DateTime.now();
}
/// 关闭连接
Future<void> close() async {
_client.close();
}
/// 获取HTTP客户端
HTTPClient get client {
healthCheckpoint();
return _client;
}
/// 获取CSRF Token
Future<String?> getToken() async {
try {
final response = await _client.get(
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/evaluation/index',
);
if (response.statusCode != 200) {
return null;
}
final html = response.data.toString();
final tokenMatch = RegExp(
r'id="tokenValue"[^>]*value="([^"]*)"',
).firstMatch(html);
if (tokenMatch != null) {
_token = tokenMatch.group(1);
return _token;
}
return null;
} catch (e) {
return null;
}
}
/// 获取待评课程列表
Future<List<Course>> fetchCourseList() async {
try {
final response = await _client.post(
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/search?sf_request_type=ajax',
data: {'optType': '1', 'pagesize': '50'},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
if (response.statusCode != 200) {
return [];
}
final data = response.data;
if (data is Map<String, dynamic> && data['data'] is List) {
final courseList = (data['data'] as List)
.map((item) => _parseCourse(item))
.where((course) => course != null)
.cast<Course>()
.toList();
return courseList;
}
return [];
} catch (e) {
return [];
}
}
/// 解析课程数据
Course? _parseCourse(dynamic item) {
try {
if (item is! Map<String, dynamic>) return null;
final id = item['id'] as Map<String, dynamic>?;
final questionnaire = item['questionnaire'] as Map<String, dynamic>?;
return Course(
id: id?['evaluatedPeople']?.toString() ?? '',
name: item['evaluationContent']?.toString() ?? '',
teacher: item['evaluatedPeople']?.toString() ?? '',
evaluatedPeople: item['evaluatedPeople']?.toString() ?? '',
evaluatedPeopleNumber: id?['evaluatedPeople']?.toString() ?? '',
coureSequenceNumber: id?['coureSequenceNumber']?.toString() ?? '',
evaluationContentNumber:
id?['evaluationContentNumber']?.toString() ?? '',
questionnaireCode:
questionnaire?['questionnaireNumber']?.toString() ?? '',
questionnaireName:
questionnaire?['questionnaireName']?.toString() ?? '',
isEvaluated: item['isEvaluated']?.toString() == '',
);
} catch (e) {
return null;
}
}
/// 访问评价页面并返回HTML内容
Future<String?> accessEvaluationPage(
Course course, {
int? totalCourses,
}) async {
try {
print('📝 Accessing evaluation page for: ${course.name}');
if (_token == null) {
print('📝 Getting token first...');
await getToken();
print('📝 Token: $_token');
}
// count is the total number of courses in the course list
final count = totalCourses?.toString() ?? '28';
print('📝 Using count: $count (total courses: $totalCourses)');
print('📝 Posting to evaluation page...');
final response = await _client.post(
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/evaluationPage',
data: {
'count': count,
'evaluatedPeople': course.evaluatedPeople,
'evaluatedPeopleNumber': course.evaluatedPeopleNumber,
'questionnaireCode': course.questionnaireCode,
'questionnaireName': course.questionnaireName,
'coureSequenceNumber': course.coureSequenceNumber,
'evaluationContentNumber': course.evaluationContentNumber,
'evaluationContentContent': '',
'tokenValue': _token ?? '',
},
options: Options(contentType: Headers.formUrlEncodedContentType),
);
print('📝 Access evaluation page status: ${response.statusCode}');
if (response.statusCode == 200) {
final html = response.data.toString();
print('📝 Got HTML content, length: ${html.length}');
return html;
}
return null;
} catch (e, stackTrace) {
print('❌ accessEvaluationPage error: $e');
print('❌ Stack trace: $stackTrace');
return null;
}
}
/// 提交评价表单
Future<EvaluationResponse> submitEvaluation(
Map<String, String> formData,
) async {
try {
print('📤 Submitting evaluation with form data:');
print('📤 Form data entries: ${formData.length}');
formData.forEach((key, value) {
print(
' $key = ${value.length > 50 ? value.substring(0, 50) + "..." : value}',
);
});
final response = await _client.post(
'http://jwcxk2-aufe-edu-cn.vpn2.aufe.edu.cn:8118/student/teachingEvaluation/teachingEvaluation/assessment?sf_request_type=ajax',
data: formData,
options: Options(contentType: Headers.formUrlEncodedContentType),
);
print('📤 Submit response status: ${response.statusCode}');
print('📤 Submit response data: ${response.data}');
if (response.statusCode != 200) {
return EvaluationResponse(
result: 'error',
msg: '网络请求失败 (${response.statusCode})',
);
}
final data = response.data;
if (data is Map<String, dynamic>) {
return EvaluationResponse(
result: data['result']?.toString() ?? 'error',
msg: data['msg']?.toString() ?? '未知错误',
);
}
return EvaluationResponse(result: 'error', msg: '响应格式错误');
} catch (e) {
print('❌ Submit evaluation error: $e');
return EvaluationResponse(result: 'error', msg: '请求异常: $e');
}
}
}
/// 评价响应
class EvaluationResponse {
final String result;
final String msg;
EvaluationResponse({required this.result, required this.msg});
bool get isSuccess => result == 'success';
}

View File

@@ -0,0 +1,431 @@
import 'dart:math';
import '../models/course.dart';
import '../models/questionnaire.dart';
import '../models/evaluation_result.dart';
import 'aufe_connection.dart';
import 'questionnaire_parser.dart';
import '../utils/text_generator.dart';
/// Service for handling course evaluation operations
/// Manages the evaluation process including form data building,
/// option selection, and text generation
class EvaluationService {
final AUFEConnection _connection;
final QuestionnaireParser _parser;
final TextGenerator _textGenerator;
final Random _random = Random();
EvaluationService({
required AUFEConnection connection,
required QuestionnaireParser parser,
required TextGenerator textGenerator,
}) : _connection = connection,
_parser = parser,
_textGenerator = textGenerator;
/// Prepare evaluation for a course (access page, parse questionnaire, generate answers)
///
/// [course] - The course to evaluate
/// [totalCourses] - Total number of courses (for count parameter)
/// Returns form data map if successful, null otherwise
Future<Map<String, String>?> prepareEvaluation(
Course course, {
int? totalCourses,
}) async {
try {
// 1. Access evaluation page to get questionnaire HTML
final html = await _connection.accessEvaluationPage(
course,
totalCourses: totalCourses,
);
if (html == null) {
return null;
}
// 2. Parse questionnaire
final questionnaire = await _parser.parseAsync(html);
// 3. Build form data
final formData = _buildFormData(
questionnaire,
course: course,
totalCourses: totalCourses,
);
return formData;
} catch (e, stackTrace) {
print('❌ prepareEvaluation error: $e');
print('❌ Stack trace: $stackTrace');
return null;
}
}
/// Submit evaluation with prepared form data
///
/// [course] - The course being evaluated
/// [formData] - Prepared form data from prepareEvaluation
/// Returns [EvaluationResult] with success status and error message if failed
Future<EvaluationResult> submitEvaluation(
Course course,
Map<String, String> formData,
) async {
try {
final submitResponse = await _connection.submitEvaluation(formData);
if (submitResponse.isSuccess) {
return EvaluationResult(course: course, success: true);
} else {
return EvaluationResult(
course: course,
success: false,
errorMessage: submitResponse.msg,
);
}
} catch (e, stackTrace) {
print('❌ submitEvaluation error: $e');
print('❌ Stack trace: $stackTrace');
return EvaluationResult(
course: course,
success: false,
errorMessage: '提交评价出错: $e',
);
}
}
/// Evaluate a single course (legacy method, combines prepare and submit with countdown)
///
/// [course] - The course to evaluate
/// [totalCourses] - Total number of courses (for count parameter)
/// [onStatusChange] - Optional callback to report status changes during evaluation
/// [onCountdown] - Optional callback to report countdown progress (seconds remaining, total seconds)
/// Returns [EvaluationResult] with success status and error message if failed
Future<EvaluationResult> evaluateCourse(
Course course, {
int? totalCourses,
Function(String status)? onStatusChange,
Function(int remaining, int total)? onCountdown,
}) async {
try {
// 1. Prepare evaluation
onStatusChange?.call('正在访问评价页面...');
final formData = await prepareEvaluation(
course,
totalCourses: totalCourses,
);
if (formData == null) {
return EvaluationResult(
course: course,
success: false,
errorMessage: '无法访问评价页面',
);
}
onStatusChange?.call('正在解析问卷结构...');
onStatusChange?.call('正在生成答案...');
// 2. Wait 140 seconds before submission (server anti-bot requirement)
onStatusChange?.call('等待提交中...');
const totalSeconds = 140;
for (int i = totalSeconds; i > 0; i--) {
onCountdown?.call(i, totalSeconds);
// Also report to status for logging
final progress = (totalSeconds - i) / totalSeconds;
final barLength = 20;
final filledLength = (progress * barLength).round();
final bar = '' * filledLength + '' * (barLength - filledLength);
final percent = (progress * 100).toInt();
onStatusChange?.call('等待提交 [$bar] $percent% (${i}s)');
await Future.delayed(const Duration(seconds: 1));
}
// 3. Submit evaluation
onStatusChange?.call('正在提交评价...');
final result = await submitEvaluation(course, formData);
if (result.success) {
onStatusChange?.call('提交成功');
} else {
onStatusChange?.call('提交失败: ${result.errorMessage}');
}
return result;
} catch (e, stackTrace) {
print('❌ evaluateCourse error: $e');
print('❌ Stack trace: $stackTrace');
onStatusChange?.call('评教失败');
return EvaluationResult(
course: course,
success: false,
errorMessage: '评教过程出错: $e',
);
}
}
/// Build form data from questionnaire
///
/// Automatically selects best options for radio questions
/// and generates appropriate text for text questions
Map<String, String> _buildFormData(
Questionnaire questionnaire, {
required Course course,
int? totalCourses,
}) {
final formData = <String, String>{};
// Add required metadata fields (matching the correct request format)
// Use Course data as primary source, fallback to questionnaire parsed data
formData['optType'] = 'submit';
formData['tokenValue'] = questionnaire.tokenValue;
formData['questionnaireCode'] = course.questionnaireCode.isNotEmpty
? course.questionnaireCode
: questionnaire.questionnaireCode;
formData['evaluationContent'] = course.evaluationContentNumber.isNotEmpty
? course.evaluationContentNumber
: questionnaire.evaluationContent;
formData['evaluatedPeopleNumber'] = course.evaluatedPeopleNumber.isNotEmpty
? course.evaluatedPeopleNumber
: questionnaire.evaluatedPeopleNumber;
formData['count'] = totalCourses?.toString() ?? '';
// Process radio questions - select best option for each
for (final question in questionnaire.radioQuestions) {
final selectedOption = _selectBestOption(question.options);
formData[question.key] = selectedOption.value;
}
// Process text questions - generate appropriate text
for (final question in questionnaire.textQuestions) {
final generatedText = _generateTextAnswer(question);
formData[question.key] = generatedText;
}
print('📝 Form data keys: ${formData.keys.join(", ")}');
print('📝 Metadata fields:');
print(' optType = ${formData['optType']}');
print(' tokenValue = ${formData['tokenValue']}');
print(' questionnaireCode = ${formData['questionnaireCode']}');
print(' evaluationContent = ${formData['evaluationContent']}');
print(' evaluatedPeopleNumber = ${formData['evaluatedPeopleNumber']}');
print(' count = ${formData['count']}');
print(
'📝 Question answers (${questionnaire.radioQuestions.length} radio + ${questionnaire.textQuestions.length} text):',
);
formData.forEach((key, value) {
if (key.startsWith('0000') || key == 'zgpj') {
print(' $key = $value');
}
});
return formData;
}
/// Select the best option from a list of radio options
///
/// Strategy: Prefer weight 1.0 options (80% probability)
/// Otherwise select second highest weight option (20% probability)
///
/// [options] - List of available radio options
/// Returns the selected [RadioOption]
RadioOption _selectBestOption(List<RadioOption> options) {
if (options.isEmpty) {
throw ArgumentError('Options list cannot be empty');
}
// Sort options by weight in descending order
final sortedOptions = List<RadioOption>.from(options)
..sort((a, b) => b.weight.compareTo(a.weight));
// Find options with weight 1.0
final weight1Options = sortedOptions
.where((opt) => opt.weight == 1.0)
.toList();
// If there are weight 1.0 options
if (weight1Options.isNotEmpty) {
// 80% chance to select weight 1.0 option
if (_random.nextDouble() < 0.8) {
// If multiple weight 1.0 options, randomly select one
return weight1Options[_random.nextInt(weight1Options.length)];
}
}
// 20% chance or no weight 1.0 options: select second highest weight
if (sortedOptions.length > 1) {
// Find second highest weight
final highestWeight = sortedOptions[0].weight;
final secondHighestOptions = sortedOptions
.where((opt) => opt.weight < highestWeight)
.toList();
if (secondHighestOptions.isNotEmpty) {
// Get all options with second highest weight
final secondHighestWeight = secondHighestOptions[0].weight;
final secondHighestGroup = secondHighestOptions
.where((opt) => opt.weight == secondHighestWeight)
.toList();
return secondHighestGroup[_random.nextInt(secondHighestGroup.length)];
}
}
// Fallback: return highest weight option
return sortedOptions[0];
}
/// Generate text answer for a text question
///
/// Uses TextGenerator to create appropriate text based on question type
/// Validates the generated text meets all requirements
///
/// [question] - The text question to answer
/// Returns generated text string
String _generateTextAnswer(TextQuestion question) {
// Generate text based on question type
String text = _textGenerator.generate(question.type);
// Validate the generated text
// If validation fails, try again (up to 3 attempts)
int attempts = 0;
while (!_textGenerator.validate(text) && attempts < 3) {
text = _textGenerator.generate(question.type);
attempts++;
}
return text;
}
/// Batch evaluate all pending courses
///
/// [onProgress] - Callback function to report progress with detailed status
/// Returns [BatchEvaluationResult] with statistics and individual results
Future<BatchEvaluationResult> batchEvaluate({
required Function(int current, int total, Course course, String status)
onProgress,
}) async {
final startTime = DateTime.now();
final results = <EvaluationResult>[];
try {
// 1. Fetch all pending courses
final courses = await _connection.fetchCourseList();
// Filter out already evaluated courses
final pendingCourses = courses.where((c) => !c.isEvaluated).toList();
final total = pendingCourses.length;
if (total == 0) {
return BatchEvaluationResult(
total: 0,
success: 0,
failed: 0,
results: [],
duration: DateTime.now().difference(startTime),
);
}
// 2. Evaluate each course
for (int i = 0; i < pendingCourses.length; i++) {
final course = pendingCourses[i];
// Evaluate the course with status updates
final result = await evaluateCourse(
course,
totalCourses: total,
onStatusChange: (status) {
// Report progress with current course and status
// Use i (not i+1) to show completed count, current course is still in progress
onProgress(i, total, course, status);
},
);
results.add(result);
// Check if evaluation failed
if (!result.success) {
// Report failure
onProgress(i + 1, total, course, '评教失败,任务中断');
// Stop batch evaluation on first error
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
return BatchEvaluationResult(
total: i + 1, // Only count evaluated courses
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
}
// Verify evaluation by checking course list
onProgress(i + 1, total, course, '验证评教结果');
final updatedCourses = await _connection.fetchCourseList();
final updatedCourse = updatedCourses.firstWhere(
(c) => c.id == course.id,
orElse: () => course,
);
if (!updatedCourse.isEvaluated) {
// Evaluation not confirmed, treat as failure
results[results.length - 1] = EvaluationResult(
course: course,
success: false,
errorMessage: '评教未生效,服务器未确认',
);
onProgress(i + 1, total, course, '评教未生效,任务中断');
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
return BatchEvaluationResult(
total: i + 1,
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
}
// Report successful completion
onProgress(i + 1, total, course, '评教完成');
// Small delay between evaluations to avoid overwhelming the server
if (i < pendingCourses.length - 1) {
await Future.delayed(const Duration(milliseconds: 500));
}
}
// 3. Calculate statistics
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
return BatchEvaluationResult(
total: total,
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
} catch (e) {
// If batch evaluation fails completely, return partial results
final successCount = results.where((r) => r.success).length;
final failedCount = results.where((r) => !r.success).length;
final duration = DateTime.now().difference(startTime);
return BatchEvaluationResult(
total: results.length,
success: successCount,
failed: failedCount,
results: results,
duration: duration,
);
}
}
}

View File

@@ -0,0 +1,143 @@
import 'package:dio/dio.dart';
/// HTTP客户端封装类提供统一的网络请求接口
class HTTPClient {
late Dio _dio;
final Map<String, String> _cookies = {};
HTTPClient({String? baseUrl, int timeout = 30000}) {
_dio = Dio(
BaseOptions(
baseUrl: baseUrl ?? '',
connectTimeout: Duration(milliseconds: timeout),
receiveTimeout: Duration(milliseconds: timeout),
sendTimeout: Duration(milliseconds: timeout),
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
},
validateStatus: (status) => status != null && status < 500,
),
);
// 添加拦截器用于Cookie管理和日志
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
// 添加Cookie到请求头
if (_cookies.isNotEmpty) {
final cookieStr = _cookies.entries
.map((e) => '${e.key}=${e.value}')
.join('; ');
options.headers['Cookie'] = cookieStr;
}
// 打印请求信息
print('🌐 ${options.method} ${options.uri}');
return handler.next(options);
},
onResponse: (response, handler) {
// 从响应中提取Cookie
final setCookie = response.headers['set-cookie'];
if (setCookie != null) {
for (var cookie in setCookie) {
_parseCookie(cookie);
}
}
final statusCode = response.statusCode ?? 0;
// 如果状态码 >= 400打印详细错误信息
if (statusCode >= 400) {
print(
'❌ Response Error: $statusCode ${response.requestOptions.uri}',
);
print('❌ Response Headers: ${response.headers}');
print('❌ Response Data: ${response.data}');
} else {
// 正常响应只打印状态码
print('$statusCode ${response.requestOptions.uri}');
}
return handler.next(response);
},
onError: (error, handler) {
print('❌ HTTP Error: ${error.message}');
print('❌ Error type: ${error.type}');
print(
'❌ Request: ${error.requestOptions.method} ${error.requestOptions.uri}',
);
if (error.response != null) {
print('❌ Status code: ${error.response?.statusCode}');
print('❌ Response Headers: ${error.response?.headers}');
print('❌ Response Data: ${error.response?.data}');
}
return handler.next(error);
},
),
);
}
/// 解析Cookie字符串并存储
void _parseCookie(String cookieStr) {
final parts = cookieStr.split(';')[0].split('=');
if (parts.length == 2) {
_cookies[parts[0].trim()] = parts[1].trim();
}
}
/// GET请求
Future<Response> get(
String path, {
Map<String, dynamic>? params,
Options? options,
}) async {
return await _dio.get(path, queryParameters: params, options: options);
}
/// POST请求
Future<Response> post(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
}) async {
return await _dio.post(
path,
data: data,
queryParameters: queryParameters,
options: options,
);
}
/// 设置Cookie
void setCookie(String name, String value) {
_cookies[name] = value;
}
/// 获取Cookie
String? getCookie(String name) {
return _cookies[name];
}
/// 获取所有Cookie
Map<String, String> getAllCookies() {
return Map.from(_cookies);
}
/// 清除所有Cookie
void clearCookies() {
_cookies.clear();
}
/// 关闭客户端
void close() {
_dio.close();
}
/// 获取Dio实例用于高级操作
Dio get dio => _dio;
}

View File

@@ -0,0 +1,203 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
/// Service for managing local notifications
/// Handles batch evaluation progress notifications and completion alerts
///
/// Usage example:
/// ```dart
/// final notificationService = NotificationService();
/// await notificationService.initialize();
///
/// // Set up tap callback
/// notificationService.onNotificationTapped = (payload) {
/// // Handle navigation based on payload
/// if (payload == 'batch_complete') {
/// // Navigate to results screen
/// }
/// };
///
/// // Show batch start notification
/// await notificationService.showBatchStartNotification(10);
///
/// // Update progress
/// await notificationService.updateProgressNotification(
/// current: 5,
/// total: 10,
/// courseName: '高等数学',
/// );
///
/// // Show completion
/// await notificationService.showCompletionNotification(
/// success: 9,
/// failed: 1,
/// total: 10,
/// );
/// ```
class NotificationService {
static const String _channelId = 'evaluation_progress';
static const String _channelName = '评教进度';
static const String _channelDescription = '显示批量评教的实时进度';
static const int _batchNotificationId = 1000;
static const int _progressNotificationId = 1001;
static const int _completionNotificationId = 1002;
static const int _errorNotificationId = 1003;
final FlutterLocalNotificationsPlugin _notifications;
bool _isInitialized = false;
// Callback for when notification is tapped
Function(String?)? onNotificationTapped;
NotificationService() : _notifications = FlutterLocalNotificationsPlugin();
/// Initialize the notification service
///
/// Configures notification channels for Android and iOS
/// Sets up notification icons and default settings
///
/// Returns true if initialization succeeds, false otherwise
Future<bool> initialize() async {
if (_isInitialized) {
return true;
}
try {
// Android initialization settings
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
// iOS initialization settings
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
// Combined initialization settings
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
// Initialize the plugin
final initialized = await _notifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationTapped,
onDidReceiveBackgroundNotificationResponse: _onNotificationTapped,
);
if (initialized == true) {
// Create notification channel for Android
await _createNotificationChannel();
_isInitialized = true;
return true;
}
return false;
} catch (e) {
debugPrint('Failed to initialize notification service: $e');
return false;
}
}
/// Create Android notification channel
///
/// Sets up a high-priority channel for evaluation progress notifications
Future<void> _createNotificationChannel() async {
const androidChannel = AndroidNotificationChannel(
_channelId,
_channelName,
description: _channelDescription,
importance: Importance.high,
enableVibration: true,
playSound: true,
);
await _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(androidChannel);
}
/// Handle notification tap events
///
/// Called when user taps on a notification
/// Triggers the callback to allow navigation to specific screens
void _onNotificationTapped(NotificationResponse response) {
debugPrint('Notification tapped: ${response.payload}');
// Trigger the callback if set
if (onNotificationTapped != null) {
onNotificationTapped!(response.payload);
}
}
/// Check if the service is initialized
bool get isInitialized => _isInitialized;
/// Show notification when batch evaluation starts
///
/// [totalCourses] - Total number of courses to evaluate
Future<void> showBatchStartNotification(int totalCourses) async {
// Notification service disabled
return;
}
/// Update progress notification with current status
///
/// Shows a progress bar and current course being evaluated
///
/// [current] - Number of courses completed
/// [total] - Total number of courses
/// [courseName] - Name of the current course being evaluated
Future<void> updateProgressNotification({
required int current,
required int total,
required String courseName,
}) async {
// Notification service disabled
return;
}
/// Show completion notification with final statistics
///
/// [success] - Number of successfully evaluated courses
/// [failed] - Number of failed evaluations
/// [total] - Total number of courses
Future<void> showCompletionNotification({
required int success,
required int failed,
required int total,
}) async {
// Notification service disabled
return;
}
/// Show error notification
///
/// [message] - Error message to display
Future<void> showErrorNotification(String message) async {
// Notification service disabled
return;
}
/// Cancel all active notifications
///
/// Clears all notifications from the notification tray
Future<void> cancelAll() async {
if (!_isInitialized) {
debugPrint('Notification service not initialized');
return;
}
try {
await _notifications.cancelAll();
} catch (e) {
debugPrint('Failed to cancel notifications: $e');
}
}
}

View File

@@ -0,0 +1,412 @@
import 'package:flutter/foundation.dart';
import 'package:html/parser.dart' as html_parser;
import 'package:html/dom.dart';
import '../models/questionnaire.dart';
/// Parser for HTML questionnaire documents
/// Dynamically extracts questionnaire structure including radio questions,
/// text questions, and metadata
class QuestionnaireParser {
// Cache for parsed questionnaires
static final Map<String, Questionnaire> _cache = {};
/// Parse HTML in isolate for better performance
///
/// [htmlContent] - The HTML content of the questionnaire page
/// [useCache] - Whether to use cached results (default: true)
/// Returns a [Questionnaire] object containing all parsed data
Future<Questionnaire> parseAsync(
String htmlContent, {
bool useCache = true,
}) async {
// Generate cache key from content hash
final cacheKey = htmlContent.hashCode.toString();
// Check cache first
if (useCache && _cache.containsKey(cacheKey)) {
return _cache[cacheKey]!;
}
// Parse in isolate to avoid blocking UI thread
final questionnaire = await compute(_parseInIsolate, htmlContent);
// Store in cache
if (useCache) {
_cache[cacheKey] = questionnaire;
// Limit cache size to prevent memory issues
if (_cache.length > 50) {
// Remove oldest entries (simple FIFO)
final keysToRemove = _cache.keys.take(_cache.length - 50).toList();
for (var key in keysToRemove) {
_cache.remove(key);
}
}
}
return questionnaire;
}
/// Clear the parser cache
static void clearCache() {
_cache.clear();
}
/// Static method for isolate parsing
static Questionnaire _parseInIsolate(String htmlContent) {
final parser = QuestionnaireParser();
return parser.parse(htmlContent);
}
/// Parse HTML document and extract questionnaire structure
///
/// [htmlContent] - The HTML content of the questionnaire page
/// Returns a [Questionnaire] object containing all parsed data
Questionnaire parse(String htmlContent) {
final document = html_parser.parse(htmlContent);
// Extract metadata first
final metadata = _extractMetadata(document);
// Extract radio questions (single-choice questions)
final radioQuestions = _extractRadioQuestions(document);
// Extract text questions (open-ended questions)
final textQuestions = _extractTextQuestions(document);
return Questionnaire(
metadata: metadata,
radioQuestions: radioQuestions,
textQuestions: textQuestions,
tokenValue: metadata.tokenValue,
questionnaireCode: metadata.questionnaireCode,
evaluationContent: metadata.evaluationContent,
evaluatedPeopleNumber: metadata.evaluatedPeopleNumber,
);
}
/// Extract questionnaire metadata from HTML document
///
/// Extracts:
/// - Title (questionnaire title)
/// - Evaluated person (teacher name)
/// - Evaluation content
/// - Token value (CSRF token)
/// - Questionnaire code
/// - Evaluated people number
QuestionnaireMetadata _extractMetadata(Document document) {
String title = '';
String evaluatedPerson = '';
String evaluationContent = '';
String tokenValue = '';
String questionnaireCode = '';
String evaluatedPeopleNumber = '';
// Extract title - usually in a specific div or h1/h2 tag
final titleElement =
document.querySelector('div.title') ??
document.querySelector('h1') ??
document.querySelector('h2');
if (titleElement != null) {
title = titleElement.text.trim();
}
// Extract token value from hidden input
final tokenInput = document.querySelector('input[name="tokenValue"]');
if (tokenInput != null) {
tokenValue = tokenInput.attributes['value'] ?? '';
}
// Extract questionnaire code from hidden input
final codeInput = document.querySelector('input[name="wjdm"]');
if (codeInput != null) {
questionnaireCode = codeInput.attributes['value'] ?? '';
}
// Extract evaluated people number from hidden input
final peopleNumberInput = document.querySelector('input[name="bprdm"]');
if (peopleNumberInput != null) {
evaluatedPeopleNumber = peopleNumberInput.attributes['value'] ?? '';
}
// Extract evaluation content from hidden input
final contentInput = document.querySelector('input[name="pgnr"]');
if (contentInput != null) {
evaluationContent = contentInput.attributes['value'] ?? '';
}
// Try to extract evaluated person name from table or specific elements
// Look for teacher name in common patterns
final teacherElements = document.querySelectorAll('td');
for (var element in teacherElements) {
final text = element.text.trim();
if (text.contains('被评人') || text.contains('教师')) {
// Get the next sibling or adjacent cell
final nextSibling = element.nextElementSibling;
if (nextSibling != null) {
evaluatedPerson = nextSibling.text.trim();
break;
}
}
}
return QuestionnaireMetadata(
title: title,
evaluatedPerson: evaluatedPerson,
evaluationContent: evaluationContent,
tokenValue: tokenValue,
questionnaireCode: questionnaireCode,
evaluatedPeopleNumber: evaluatedPeopleNumber,
);
}
/// Extract all radio questions from the document
///
/// Parses all input[type="radio"] elements and groups them by name attribute
/// Extracts score and weight from value attribute (format: "score_weight")
List<RadioQuestion> _extractRadioQuestions(Document document) {
final Map<String, RadioQuestion> questionsMap = {};
// Find all radio input elements
final radioInputs = document.querySelectorAll('input[type="radio"]');
for (var input in radioInputs) {
final name = input.attributes['name'];
final value = input.attributes['value'];
if (name == null || value == null || name.isEmpty || value.isEmpty) {
continue;
}
// Parse value format "score_weight" (e.g., "5_1" means 5 points with 100% weight)
final parts = value.split('_');
double score = 0.0;
double weight = 0.0;
if (parts.length >= 2) {
score = double.tryParse(parts[0]) ?? 0.0;
weight = double.tryParse(parts[1]) ?? 0.0;
}
// Extract option label - look for adjacent label or text
String label = '';
// Try to find label element associated with this input
final inputId = input.attributes['id'];
if (inputId != null && inputId.isNotEmpty) {
final labelElement = document.querySelector('label[for="$inputId"]');
if (labelElement != null) {
label = labelElement.text.trim();
}
}
// If no label found, look for parent label
if (label.isEmpty) {
var parent = input.parent;
while (parent != null && parent.localName != 'label') {
parent = parent.parent;
}
if (parent != null && parent.localName == 'label') {
label = parent.text.trim();
}
}
// If still no label, look for adjacent text in the same td/cell
if (label.isEmpty) {
var cell = input.parent;
while (cell != null && cell.localName != 'td') {
cell = cell.parent;
}
if (cell != null) {
label = cell.text.trim();
}
}
// Create RadioOption
final option = RadioOption(
label: label,
value: value,
score: score,
weight: weight,
);
// Extract question text and category
if (!questionsMap.containsKey(name)) {
String questionText = '';
String category = '';
// Find the question text - usually in a td with rowspan or previous row
var row = input.parent;
while (row != null && row.localName != 'tr') {
row = row.parent;
}
if (row != null) {
// Look for td with rowspan (category indicator)
final categoryCell = row.querySelector('td[rowspan]');
if (categoryCell != null) {
category = categoryCell.text.trim();
}
// Look for question text in the first td or a specific class
final cells = row.querySelectorAll('td');
for (var cell in cells) {
final text = cell.text.trim();
// Skip cells that only contain radio buttons or are too short
if (text.isNotEmpty &&
!text.contains('input') &&
text.length > 5 &&
cell.querySelector('input[type="radio"]') == null) {
questionText = text;
break;
}
}
// If question text not found in current row, check previous rows
if (questionText.isEmpty) {
var prevRow = row.previousElementSibling;
while (prevRow != null) {
final prevCells = prevRow.querySelectorAll('td');
for (var cell in prevCells) {
final text = cell.text.trim();
if (text.isNotEmpty && text.length > 5) {
questionText = text;
break;
}
}
if (questionText.isNotEmpty) break;
prevRow = prevRow.previousElementSibling;
}
}
}
questionsMap[name] = RadioQuestion(
key: name,
questionText: questionText,
options: [option],
category: category,
);
} else {
// Add option to existing question
final existingQuestion = questionsMap[name]!;
questionsMap[name] = RadioQuestion(
key: existingQuestion.key,
questionText: existingQuestion.questionText,
options: [...existingQuestion.options, option],
category: existingQuestion.category,
);
}
}
return questionsMap.values.toList();
}
/// Extract all text questions from the document
///
/// Parses all textarea elements and identifies question types
/// based on surrounding text content
List<TextQuestion> _extractTextQuestions(Document document) {
final List<TextQuestion> textQuestions = [];
// Find all textarea elements
final textareas = document.querySelectorAll('textarea');
for (var textarea in textareas) {
final name = textarea.attributes['name'];
if (name == null || name.isEmpty) {
continue;
}
// Extract question text from adjacent elements
String questionText = '';
// Look for question text in the same row or previous elements
var cell = textarea.parent;
while (cell != null && cell.localName != 'td') {
cell = cell.parent;
}
if (cell != null) {
// Check previous sibling cells for question text
var prevCell = cell.previousElementSibling;
if (prevCell != null) {
questionText = prevCell.text.trim();
}
// If not found, look in the same cell before the textarea
if (questionText.isEmpty) {
final cellText = cell.text.trim();
if (cellText.isNotEmpty) {
questionText = cellText;
}
}
// If still not found, look in previous row
if (questionText.isEmpty) {
var row = cell.parent;
if (row != null && row.localName == 'tr') {
var prevRow = row.previousElementSibling;
if (prevRow != null) {
final prevCells = prevRow.querySelectorAll('td');
for (var prevCell in prevCells) {
final text = prevCell.text.trim();
if (text.isNotEmpty && text.length > 3) {
questionText = text;
break;
}
}
}
}
}
}
// Analyze question type based on text content and name
final questionType = _analyzeQuestionType(questionText, name);
// Determine if required - zgpj is typically required
final isRequired = name == 'zgpj' || name.contains('zgpj');
textQuestions.add(
TextQuestion(
key: name,
questionText: questionText,
type: questionType,
isRequired: isRequired,
),
);
}
return textQuestions;
}
/// Analyze question type based on question text and field name
///
/// Uses keyword matching to identify:
/// - Inspiration questions (contains "启发")
/// - Suggestion questions (contains "建议" or "意见")
/// - Overall evaluation (name is "zgpj")
/// - General questions (default)
QuestionType _analyzeQuestionType(String questionText, String fieldName) {
// Check field name first
if (fieldName == 'zgpj' || fieldName.contains('zgpj')) {
return QuestionType.overall;
}
// Check question text for keywords
final lowerText = questionText.toLowerCase();
if (lowerText.contains('启发') || lowerText.contains('启示')) {
return QuestionType.inspiration;
}
if (lowerText.contains('建议') ||
lowerText.contains('意见') ||
lowerText.contains('改进')) {
return QuestionType.suggestion;
}
// Default to general type
return QuestionType.general;
}
}

View File

@@ -0,0 +1,325 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/evaluation_history.dart';
/// Service for managing local storage using SharedPreferences
///
/// Handles storage of evaluation history, cache management,
/// and other persistent data (excluding secure credentials)
///
/// Usage example:
/// ```dart
/// final storageService = StorageService();
///
/// // Save evaluation history
/// await storageService.saveEvaluationHistory(history);
///
/// // Load evaluation history
/// final histories = await storageService.loadEvaluationHistory();
///
/// // Clear all data
/// await storageService.clearAllData();
/// ```
class StorageService {
static const String _evaluationHistoryKey = 'evaluation_history';
static const String _lastSyncTimeKey = 'last_sync_time';
static const String _cacheVersionKey = 'cache_version';
static const int _maxHistoryItems = 100; // Maximum history items to keep
/// Save evaluation history to local storage
///
/// Appends new history item to existing list
/// Automatically trims list if it exceeds max items
///
/// [history] - The evaluation history to save
Future<void> saveEvaluationHistory(EvaluationHistory history) async {
try {
final prefs = await SharedPreferences.getInstance();
// Load existing history
final histories = await loadEvaluationHistory();
// Add new history at the beginning
histories.insert(0, history);
// Trim if exceeds max items
if (histories.length > _maxHistoryItems) {
histories.removeRange(_maxHistoryItems, histories.length);
}
// Convert to JSON and save
final jsonList = histories.map((h) => h.toJson()).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(_evaluationHistoryKey, jsonString);
} catch (e) {
throw StorageException('Failed to save evaluation history: $e');
}
}
/// Save multiple evaluation histories at once
///
/// Useful for batch operations
///
/// [histories] - List of evaluation histories to save
Future<void> saveEvaluationHistories(
List<EvaluationHistory> histories,
) async {
try {
final prefs = await SharedPreferences.getInstance();
// Load existing history
final existingHistories = await loadEvaluationHistory();
// Merge new histories at the beginning
final mergedHistories = [...histories, ...existingHistories];
// Trim if exceeds max items
final trimmedHistories = mergedHistories.length > _maxHistoryItems
? mergedHistories.sublist(0, _maxHistoryItems)
: mergedHistories;
// Convert to JSON and save
final jsonList = trimmedHistories.map((h) => h.toJson()).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(_evaluationHistoryKey, jsonString);
} catch (e) {
throw StorageException('Failed to save evaluation histories: $e');
}
}
/// Load evaluation history from local storage
///
/// Returns list of evaluation histories sorted by timestamp (newest first)
/// Returns empty list if no history exists
Future<List<EvaluationHistory>> loadEvaluationHistory() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_evaluationHistoryKey);
if (jsonString == null || jsonString.isEmpty) {
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
final histories = jsonList
.map(
(json) => EvaluationHistory.fromJson(json as Map<String, dynamic>),
)
.toList();
// Sort by timestamp (newest first)
histories.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return histories;
} catch (e) {
throw StorageException('Failed to load evaluation history: $e');
}
}
/// Get evaluation history for a specific course
///
/// [courseId] - The course ID to filter by
/// Returns list of histories for the specified course
Future<List<EvaluationHistory>> getHistoryByCourse(String courseId) async {
try {
final allHistories = await loadEvaluationHistory();
return allHistories.where((h) => h.course.id == courseId).toList();
} catch (e) {
throw StorageException('Failed to get history by course: $e');
}
}
/// Get successful evaluation count
///
/// Returns the total number of successful evaluations
Future<int> getSuccessfulEvaluationCount() async {
try {
final histories = await loadEvaluationHistory();
return histories.where((h) => h.success).length;
} catch (e) {
throw StorageException('Failed to get successful evaluation count: $e');
}
}
/// Get failed evaluation count
///
/// Returns the total number of failed evaluations
Future<int> getFailedEvaluationCount() async {
try {
final histories = await loadEvaluationHistory();
return histories.where((h) => !h.success).length;
} catch (e) {
throw StorageException('Failed to get failed evaluation count: $e');
}
}
/// Clear evaluation history
///
/// Removes all stored evaluation history
Future<void> clearEvaluationHistory() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_evaluationHistoryKey);
} catch (e) {
throw StorageException('Failed to clear evaluation history: $e');
}
}
/// Save last sync time
///
/// Records when the last data synchronization occurred
///
/// [time] - The timestamp to save
Future<void> saveLastSyncTime(DateTime time) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastSyncTimeKey, time.toIso8601String());
} catch (e) {
throw StorageException('Failed to save last sync time: $e');
}
}
/// Load last sync time
///
/// Returns the timestamp of the last synchronization
/// Returns null if no sync has occurred
Future<DateTime?> loadLastSyncTime() async {
try {
final prefs = await SharedPreferences.getInstance();
final timeString = prefs.getString(_lastSyncTimeKey);
if (timeString == null) {
return null;
}
return DateTime.parse(timeString);
} catch (e) {
throw StorageException('Failed to load last sync time: $e');
}
}
/// Get cache version
///
/// Returns the current cache version number
/// Used for cache invalidation when data structure changes
Future<int> getCacheVersion() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_cacheVersionKey) ?? 1;
} catch (e) {
throw StorageException('Failed to get cache version: $e');
}
}
/// Set cache version
///
/// Updates the cache version number
///
/// [version] - The new version number
Future<void> setCacheVersion(int version) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_cacheVersionKey, version);
} catch (e) {
throw StorageException('Failed to set cache version: $e');
}
}
/// Clear cache if version mismatch
///
/// Compares current cache version with expected version
/// Clears cache if they don't match
///
/// [expectedVersion] - The expected cache version
/// Returns true if cache was cleared, false otherwise
Future<bool> clearCacheIfVersionMismatch(int expectedVersion) async {
try {
final currentVersion = await getCacheVersion();
if (currentVersion != expectedVersion) {
await clearEvaluationHistory();
await setCacheVersion(expectedVersion);
return true;
}
return false;
} catch (e) {
throw StorageException('Failed to check cache version: $e');
}
}
/// Clear all local data
///
/// Removes all data stored by this service
/// Does NOT clear secure storage (credentials)
/// Does NOT clear theme preferences
Future<void> clearAllData() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_evaluationHistoryKey);
await prefs.remove(_lastSyncTimeKey);
// Keep cache version to maintain compatibility
} catch (e) {
throw StorageException('Failed to clear all data: $e');
}
}
/// Get storage statistics
///
/// Returns information about stored data
Future<StorageStats> getStorageStats() async {
try {
final histories = await loadEvaluationHistory();
final lastSync = await loadLastSyncTime();
final cacheVersion = await getCacheVersion();
return StorageStats(
totalHistoryItems: histories.length,
successfulEvaluations: histories.where((h) => h.success).length,
failedEvaluations: histories.where((h) => !h.success).length,
lastSyncTime: lastSync,
cacheVersion: cacheVersion,
);
} catch (e) {
throw StorageException('Failed to get storage stats: $e');
}
}
}
/// Storage statistics data class
class StorageStats {
final int totalHistoryItems;
final int successfulEvaluations;
final int failedEvaluations;
final DateTime? lastSyncTime;
final int cacheVersion;
StorageStats({
required this.totalHistoryItems,
required this.successfulEvaluations,
required this.failedEvaluations,
this.lastSyncTime,
required this.cacheVersion,
});
@override
String toString() {
return 'StorageStats('
'total: $totalHistoryItems, '
'success: $successfulEvaluations, '
'failed: $failedEvaluations, '
'lastSync: $lastSyncTime, '
'cacheVersion: $cacheVersion'
')';
}
}
/// Exception thrown when storage operations fail
class StorageException implements Exception {
final String message;
StorageException(this.message);
@override
String toString() => 'StorageException: $message';
}