😋 初始化仓库
This commit is contained in:
2
lib/services/.gitkeep
Normal file
2
lib/services/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# Services directory
|
||||
# This directory contains business logic services
|
||||
275
lib/services/app_initialization_service.dart
Normal file
275
lib/services/app_initialization_service.dart
Normal 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'
|
||||
')';
|
||||
}
|
||||
}
|
||||
539
lib/services/aufe_connection.dart
Normal file
539
lib/services/aufe_connection.dart
Normal 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';
|
||||
}
|
||||
431
lib/services/evaluation_service.dart
Normal file
431
lib/services/evaluation_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
lib/services/http_client.dart
Normal file
143
lib/services/http_client.dart
Normal 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;
|
||||
}
|
||||
203
lib/services/notification_service.dart
Normal file
203
lib/services/notification_service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
412
lib/services/questionnaire_parser.dart
Normal file
412
lib/services/questionnaire_parser.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
325
lib/services/storage_service.dart
Normal file
325
lib/services/storage_service.dart
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user