#include "AppController.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr int ReconnectIntervalMs = 5000; QDate parseDate(const QString &value, const QDate &fallback) { const QDate date = QDate::fromString(value, Qt::ISODate); return date.isValid() ? date : fallback; } bool isSuccessStatus(const QString &status) { const QString normalized = status.trimmed().toLower(); return normalized == "success" || normalized == "succeeded" || normalized == "ok" || normalized == "pass" || normalized == "passed" || normalized == "present" || normalized == "recognized" || normalized == "verified" || normalized == "valid" || normalized == "200" || normalized == "true" || normalized == "1" || normalized == QStringLiteral("成功") || normalized == QStringLiteral("签到成功") || normalized == QStringLiteral("正常"); } QString csvCell(const QString &value) { QString escaped = value; const bool needsQuotes = escaped.contains(',') || escaped.contains('"') || escaped.contains('\n') || escaped.contains('\r'); escaped.replace("\"", "\"\""); return needsQuotes ? QStringLiteral("\"%1\"").arg(escaped) : escaped; } QString conciseStatusMessage(const QString &message) { if (message.contains(QStringLiteral("Error transferring"), Qt::CaseInsensitive) || message.contains(QStringLiteral("Connection refused"), Qt::CaseInsensitive) || message.contains(QStringLiteral("Host not found"), Qt::CaseInsensitive) || message.contains(QStringLiteral("Network unreachable"), Qt::CaseInsensitive) || message.contains(QStringLiteral("timed out"), Qt::CaseInsensitive)) { return QStringLiteral("服务器未连接,稍后重试"); } return message; } QString safeFileName(QString value) { value = value.trimmed(); for (int i = 0; i < value.size(); ++i) { QChar ch = value.at(i); if (!ch.isLetterOrNumber() && ch != '_' && ch != '-' && ch != '.') { value[i] = '_'; } } return value.isEmpty() ? QStringLiteral("unknown") : value; } QString displayClassOption(const QString &classId, const QString &className) { const QString trimmedId = classId.trimmed(); const QString trimmedName = className.trimmed(); if (trimmedId.isEmpty()) { return {}; } if (trimmedName.isEmpty() || trimmedName == trimmedId) { return trimmedId; } return QStringLiteral("%1 - %2").arg(trimmedId, trimmedName); } QVector legacyDefaultTimeSlots() { return { {"上午签到", QTime(8, 0), QTime(9, 30), true}, {"中午签退", QTime(12, 0), QTime(13, 0), true}, {"下午签到", QTime(14, 0), QTime(15, 0), true}, {"晚上签退", QTime(17, 0), QTime(18, 30), true} }; } bool isLegacyDefaultTimeSlots(const QVector &timeSlots) { const QVector legacy = legacyDefaultTimeSlots(); if (timeSlots.size() != legacy.size()) { return false; } for (int i = 0; i < legacy.size(); ++i) { if (timeSlots.at(i).name != legacy.at(i).name || timeSlots.at(i).startTime != legacy.at(i).startTime || timeSlots.at(i).endTime != legacy.at(i).endTime || timeSlots.at(i).enabled != legacy.at(i).enabled) { return false; } } return true; } } AppController::AppController(QObject *parent) : QObject(parent) { loadLocalState(); m_apiClient.setServerUrl(m_serverUrl); m_apiClient.setApiToken(m_apiToken); m_reconnectTimer.setSingleShot(true); connect(&m_reconnectTimer, &QTimer::timeout, this, [this]() { runReconnectCheck(); }); auto refreshDerivedMappingViews = [this]() { rebuildStudentProfiles(); mergeFaceItems(); updateFilterOptions(); rebuildFilteredRecords(); rebuildStatistics(); }; connect(&m_classMappingModel, &QAbstractItemModel::modelReset, this, refreshDerivedMappingViews); connect(&m_classMappingModel, &QAbstractItemModel::dataChanged, this, refreshDerivedMappingViews); connect(&m_classMappingModel, &QAbstractItemModel::rowsInserted, this, refreshDerivedMappingViews); connect(&m_classMappingModel, &QAbstractItemModel::rowsRemoved, this, refreshDerivedMappingViews); connect(&m_apiClient, &ApiClient::recordsFetched, this, [this](const QVector &records) { m_allRecords = records; updateFilterOptions(); rebuildFilteredRecords(); rebuildStatistics(); setStatusMessage(QStringLiteral("已加载 %1 条记录").arg(records.size())); emit summaryChanged(); }); connect(&m_apiClient, &ApiClient::statisticsFetched, this, [this](int total, int today, const QString &) { m_totalRecords = total; m_todayRecords = today; emit summaryChanged(); }); connect(&m_apiClient, &ApiClient::syncStateFetched, this, [this](qulonglong version, bool changed) { if (version > 0) { m_qtSyncVersion = version; } setStatusMessage(changed ? QStringLiteral("同步完成,版本 %1").arg(version) : QStringLiteral("数据已是最新,版本 %1").arg(version)); }); connect(&m_apiClient, &ApiClient::recordsCleared, this, [this](bool success, const QString &message) { setStatusMessage(success ? QStringLiteral("已清空全部记录") : QStringLiteral("清空失败: %1").arg(message)); if (success) { refreshAll(); } }); connect(&m_apiClient, &ApiClient::recordDeleted, this, [this](bool success, const QString &message) { setStatusMessage(success ? QStringLiteral("记录删除成功") : QStringLiteral("记录删除失败: %1").arg(message)); if (success) { refreshRecords(); } }); connect(&m_apiClient, &ApiClient::timeSlotsFetched, this, [this](const QVector &timeSlots) { m_timeSlotModel.setTimeSlots(timeSlots); saveTimeSlotsToLocal(); setStatusMessage(timeSlots.isEmpty() ? QStringLiteral("已从服务器同步空时间段配置") : QStringLiteral("已从服务器同步时间段")); }); connect(&m_apiClient, &ApiClient::timeSlotsUploaded, this, [this](bool success, const QString &message) { setStatusMessage(success ? QStringLiteral("时间段已保存并同步") : QStringLiteral("时间段同步失败: %1").arg(message)); }); connect(&m_apiClient, &ApiClient::mappingConfigFetched, this, [this](const QMap &studentNameMap, const QMap &studentClassMap, const QMap &classNameMap) { m_studentMappingModel.setMap(studentNameMap); m_studentClassMappingModel.setMap(studentClassMap); m_classMappingModel.setMap(classNameMap); rebuildStudentProfiles(); saveMappingsToLocal(); updateFilterOptions(); rebuildFilteredRecords(); rebuildStatistics(); setStatusMessage("映射配置已同步"); }); connect(&m_apiClient, &ApiClient::mappingConfigUploaded, this, [this](bool success, const QString &message) { setStatusMessage(success ? QStringLiteral("映射配置已保存") : QStringLiteral("映射保存失败: %1").arg(message)); }); connect(&m_apiClient, &ApiClient::faceIdMappingFetched, this, [this](const QMap &mapping) { m_faceIdMapping = mapping; mergeFaceItems(); rebuildStudentProfiles(); }); connect(&m_apiClient, &ApiClient::faceIdMappingUploaded, this, [this](bool success, const QString &message) { setStatusMessage(success ? QStringLiteral("人脸映射已保存") : QStringLiteral("人脸映射保存失败: %1").arg(message)); }); connect(&m_apiClient, &ApiClient::facePhotosFetched, this, [this](const QStringList &photoIds) { m_facePhotoIds = photoIds; m_facePhotoLocalUrls.clear(); mergeFaceItems(); for (const QString &id : m_facePhotoIds) { m_apiClient.fetchFacePhoto(id); } }); connect(&m_apiClient, &ApiClient::facePhotoFetched, this, [this](const QString &faceId, const QByteArray &data, bool success, const QString &message) { if (!success) { qWarning() << "[FacePhoto] load failed:" << faceId << message; return; } const QString path = localFacePhotoPath(faceId); QFile file(path); if (!file.open(QIODevice::WriteOnly)) { qWarning() << "[FacePhoto] cache open failed:" << path; return; } file.write(data); file.close(); m_facePhotoLocalUrls.insert(faceId, QUrl::fromLocalFile(path).toString()); mergeFaceItems(); }); connect(&m_apiClient, &ApiClient::faceDeleted, this, [this](bool success, const QString &studentId, int recordsDeleted, const QString &message) { if (success) { setStatusMessage(recordsDeleted > 0 ? QStringLiteral("已删除 %1,并清除 %2 条记录").arg(studentId).arg(recordsDeleted) : QStringLiteral("已删除 %1").arg(studentId)); refreshFaces(); refreshRecords(); } else { setStatusMessage(QStringLiteral("删除失败: %1").arg(message)); } }); connect(&m_apiClient, &ApiClient::healthChecked, this, [this](bool healthy, const QString &message) { const bool wasReconnecting = m_reconnecting; m_reconnectCheckInFlight = false; if (healthy) { stopReconnect(); setStatusMessage(wasReconnecting ? QStringLiteral("服务器已恢复连接,正在同步数据") : QStringLiteral("服务器连接正常")); if (wasReconnecting) { refreshAll(); refreshFaces(); } } else { startReconnect(message); } }); connect(&m_apiClient, &ApiClient::networkError, this, [this](const QString &message) { setStatusMessage(message); }); connect(&m_apiClient, &ApiClient::connectionUnavailable, this, [this](const QString &message) { startReconnect(message); }); m_reconnecting = true; emit reconnectStateChanged(); runReconnectCheck(); } RecordListModel *AppController::recordModel() { return &m_recordModel; } StatisticsListModel *AppController::statisticsModel() { return &m_statisticsModel; } TimeSlotListModel *AppController::timeSlotModel() { return &m_timeSlotModel; } StudentProfileListModel *AppController::studentProfileModel() { return &m_studentProfileModel; } MappingListModel *AppController::studentMappingModel() { return &m_studentMappingModel; } MappingListModel *AppController::studentClassMappingModel() { return &m_studentClassMappingModel; } MappingListModel *AppController::classMappingModel() { return &m_classMappingModel; } FaceListModel *AppController::faceModel() { return &m_faceModel; } QStringList AppController::classOptions() const { return m_classOptions; } QStringList AppController::studentOptions() const { return m_studentOptions; } QStringList AppController::classSelectionOptions() const { QStringList options; const QMap classMap = m_classMappingModel.toMap(); for (auto it = classMap.cbegin(); it != classMap.cend(); ++it) { const QString option = displayClassOption(it.key(), it.value()); if (!option.isEmpty()) { options.push_back(option); } } return options; } QString AppController::serverUrl() const { return m_serverUrl; } QString AppController::apiToken() const { return m_apiToken; } QString AppController::studentProfileSearchText() const { return m_studentProfileSearchText; } QString AppController::studentProfileClassFilter() const { return m_studentProfileClassFilter; } QString AppController::studentProfileBindingFilter() const { return m_studentProfileBindingFilter; } QStringList AppController::studentProfileClassFilterOptions() const { QStringList options = {QStringLiteral("全部")}; options.append(classSelectionOptions()); return options; } QString AppController::statusMessage() const { return m_statusMessage; } bool AppController::reconnecting() const { return m_reconnecting; } int AppController::reconnectAttempt() const { return m_reconnectAttempt; } int AppController::totalRecords() const { return m_totalRecords; } int AppController::todayRecords() const { return m_todayRecords; } int AppController::filteredCount() const { return m_filteredRecords.size(); } int AppController::loadedRecords() const { return m_allRecords.size(); } int AppController::statisticsStudentCount() const { return m_statisticsStudentCount; } int AppController::statisticsRecordCount() const { return m_statisticsRecordCount; } QString AppController::statisticsRangeText() const { return QString("%1 至 %2") .arg(m_statisticsStartDate.toString("yyyy-MM-dd")) .arg(m_statisticsEndDate.toString("yyyy-MM-dd")); } void AppController::setServerUrl(const QString &url) { const QString trimmed = url.trimmed(); if (trimmed.isEmpty() || trimmed == m_serverUrl) { return; } m_serverUrl = trimmed; m_qtSyncVersion = 0; m_apiClient.setServerUrl(trimmed); saveServerUrl(); emit serverUrlChanged(); stopReconnect(); setStatusMessage("服务器地址已更新,正在检查连接"); m_reconnecting = true; emit reconnectStateChanged(); runReconnectCheck(); } void AppController::setApiToken(const QString &token) { const QString trimmed = token.trimmed(); if (trimmed.isEmpty() || trimmed == m_apiToken) { return; } m_apiToken = trimmed; m_apiClient.setApiToken(trimmed); saveApiToken(); emit apiTokenChanged(); stopReconnect(); setStatusMessage("API Token 已更新,正在检查连接"); m_reconnecting = true; emit reconnectStateChanged(); runReconnectCheck(); } void AppController::saveServerConfig(const QString &url, const QString &token) { const QString trimmedUrl = url.trimmed(); const QString trimmedToken = token.trimmed(); bool changed = false; if (!trimmedUrl.isEmpty() && trimmedUrl != m_serverUrl) { m_serverUrl = trimmedUrl; m_qtSyncVersion = 0; m_apiClient.setServerUrl(trimmedUrl); saveServerUrl(); emit serverUrlChanged(); changed = true; } if (!trimmedToken.isEmpty() && trimmedToken != m_apiToken) { m_apiToken = trimmedToken; m_apiClient.setApiToken(trimmedToken); saveApiToken(); emit apiTokenChanged(); changed = true; } if (!changed) { setStatusMessage("服务器配置未变化"); return; } stopReconnect(); setStatusMessage("服务器配置已保存,正在检查连接"); m_reconnecting = true; emit reconnectStateChanged(); runReconnectCheck(); } void AppController::setStudentProfileSearchText(const QString &text) { const QString trimmed = text.trimmed(); if (trimmed == m_studentProfileSearchText) { return; } m_studentProfileSearchText = trimmed; emit studentProfileFilterChanged(); } void AppController::setStudentProfileClassFilter(const QString &classFilter) { const QString normalized = classFilter.trimmed().isEmpty() ? QStringLiteral("全部") : classFilter.trimmed(); if (normalized == m_studentProfileClassFilter) { return; } m_studentProfileClassFilter = normalized; emit studentProfileFilterChanged(); } void AppController::setStudentProfileBindingFilter(const QString &bindingFilter) { const QString normalized = bindingFilter.trimmed().isEmpty() ? QStringLiteral("全部") : bindingFilter.trimmed(); if (normalized == m_studentProfileBindingFilter) { return; } m_studentProfileBindingFilter = normalized; emit studentProfileFilterChanged(); } void AppController::refreshAll() { m_apiClient.fetchSyncState(m_qtSyncVersion); } void AppController::refreshRecords() { m_apiClient.fetchRecords(1000, 0); } void AppController::applyFilters(const QString &classId, const QString &studentId, const QString &nameFilter, int dateFilterIndex, const QString &specificDate) { m_selectedClassId = classId.isEmpty() ? QStringLiteral("全部") : classId; m_selectedStudentId = studentId.isEmpty() ? QStringLiteral("全部") : studentId; m_nameFilter = nameFilter.trimmed(); m_dateFilterIndex = dateFilterIndex; m_specificDate = parseDate(specificDate, QDate::currentDate()); rebuildFilteredRecords(); } void AppController::updateStatisticsRange(const QString &startDate, const QString &endDate) { m_statisticsStartDate = parseDate(startDate, QDate::currentDate().addDays(-30)); m_statisticsEndDate = parseDate(endDate, QDate::currentDate()); rebuildStatistics(); } bool AppController::exportFilteredRecords(const QString &fileUrl) { const QString filePath = QUrl(fileUrl).toLocalFile(); if (filePath.isEmpty()) { setStatusMessage("导出路径无效"); return false; } QFile file(filePath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { setStatusMessage("无法创建导出文件"); return false; } QTextStream out(&file); out.setEncoding(QStringConverter::Utf8); out << "# 考勤记录导出\n"; out << "# 导出时间: " << QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss") << "\n"; out << "# 记录总数: " << m_filteredRecords.size() << "\n"; out << "签到时间,班级编号,班级名称,学号,姓名,置信度,状态\n"; for (const AttendanceRecord &record : m_filteredRecords) { out << csvCell(record.timestamp) << "," << csvCell(effectiveClassId(record)) << "," << csvCell(mappedClassName(record)) << "," << csvCell(record.studentId) << "," << csvCell(mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record)) << "," << QString::number(record.confidence, 'f', 4) << "," << csvCell(record.status) << "\n"; } setStatusMessage(QStringLiteral("已导出 %1 条记录").arg(m_filteredRecords.size())); return true; } bool AppController::exportStatistics(const QString &fileUrl) { const QString filePath = QUrl(fileUrl).toLocalFile(); if (filePath.isEmpty()) { setStatusMessage("统计导出路径无效"); return false; } QFile file(filePath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { setStatusMessage("无法创建统计文件"); return false; } QTextStream out(&file); out.setEncoding(QStringConverter::Utf8); out << "考勤统计报表," << statisticsRangeText() << "\n"; out << "学号,姓名,总签到次数,今日签到,最后签到时间,平均置信度\n"; for (int row = 0; row < m_statisticsModel.rowCount(); ++row) { const QModelIndex index = m_statisticsModel.index(row); out << csvCell(m_statisticsModel.data(index, StatisticsListModel::StudentIdRole).toString()) << "," << csvCell(m_statisticsModel.data(index, StatisticsListModel::NameRole).toString()) << "," << m_statisticsModel.data(index, StatisticsListModel::TotalCountRole).toInt() << "," << m_statisticsModel.data(index, StatisticsListModel::TodayCountRole).toInt() << "," << csvCell(m_statisticsModel.data(index, StatisticsListModel::LastCheckInRole).toString()) << "," << QString::number(m_statisticsModel.data(index, StatisticsListModel::AverageConfidenceRole).toDouble(), 'f', 4) << "\n"; } setStatusMessage("统计报表已导出"); return true; } void AppController::clearAllRecords() { m_apiClient.clearAllRecords(); } void AppController::saveTimeSlots() { saveTimeSlotsToLocal(); m_apiClient.uploadTimeSlots(m_timeSlotModel.timeSlots()); } void AppController::reloadTimeSlotsFromServer() { m_apiClient.fetchTimeSlots(); } void AppController::loadMappingsFromServer() { m_apiClient.fetchMappingConfig(); } void AppController::saveMappings() { QString validationMessage; if (!validateMappings(&validationMessage)) { setStatusMessage(validationMessage); return; } applyStudentProfilesToMappings(); if (!validateFaceMappings(&validationMessage)) { setStatusMessage(validationMessage); rebuildStudentProfiles(); mergeFaceItems(); return; } saveMappingsToLocal(); m_apiClient.uploadMappingConfig(m_studentMappingModel.toMap(), m_studentClassMappingModel.toMap(), m_classMappingModel.toMap()); m_apiClient.uploadFaceIdMapping(m_faceIdMapping); rebuildStudentProfiles(); mergeFaceItems(); updateFilterOptions(); rebuildFilteredRecords(); rebuildStatistics(); } void AppController::addStudentProfile() { m_studentProfileModel.addProfile({}, {}, {}); emit studentProfilesChanged(); } void AppController::updateStudentProfile(int index, const QString &studentId, const QString &studentName, const QString &classId) { const QVector profiles = m_studentProfileModel.profiles(); const QString oldStudentId = (index >= 0 && index < profiles.size()) ? profiles.at(index).studentId.trimmed() : QString(); const QString newStudentId = studentId.trimmed(); m_studentProfileModel.updateProfile(index, studentId, studentName, classId); if (!oldStudentId.isEmpty() && !newStudentId.isEmpty() && oldStudentId != newStudentId) { bool changed = false; for (auto it = m_faceIdMapping.begin(); it != m_faceIdMapping.end(); ++it) { if (it.value().trimmed() == oldStudentId) { it.value() = newStudentId; changed = true; } } if (changed) { mergeFaceItems(); } } emit studentProfilesChanged(); } void AppController::removeStudentProfile(int index) { const QVector profiles = m_studentProfileModel.profiles(); const QString removedStudentId = (index >= 0 && index < profiles.size()) ? profiles.at(index).studentId.trimmed() : QString(); m_studentProfileModel.removeProfile(index); if (!removedStudentId.isEmpty()) { bool changed = false; for (auto it = m_faceIdMapping.begin(); it != m_faceIdMapping.end(); ++it) { if (it.value().trimmed() == removedStudentId) { it.value().clear(); changed = true; } } if (changed) { mergeFaceItems(); } } emit studentProfilesChanged(); } QString AppController::classDisplayForId(const QString &classId) const { const QString trimmed = classId.trimmed(); if (trimmed.isEmpty()) { return {}; } return displayClassOption(trimmed, m_classMappingModel.toMap().value(trimmed)); } QString AppController::classIdFromDisplay(const QString &display) const { const QString trimmed = display.trimmed(); const int separator = trimmed.indexOf(QStringLiteral(" - ")); return separator > 0 ? trimmed.left(separator).trimmed() : trimmed; } void AppController::refreshFaces() { m_apiClient.fetchFaceIdMapping(); m_apiClient.fetchFacePhotos(); } void AppController::updateFaceStudentId(int index, const QString &studentId) { const QVector items = m_faceModel.items(); if (index < 0 || index >= items.size()) { return; } const QString faceId = items.at(index).faceId.trimmed(); const QString trimmedStudentId = studentId.trimmed(); if (!faceId.isEmpty()) { if (trimmedStudentId.isEmpty()) { m_faceIdMapping.remove(faceId); } else { m_faceIdMapping[faceId] = trimmedStudentId; } } m_faceModel.updateStudentId(index, studentId); mergeFaceItems(); rebuildStudentProfiles(); } void AppController::saveFaceMappings() { QString validationMessage; if (!validateFaceMappings(&validationMessage)) { setStatusMessage(validationMessage); mergeFaceItems(); return; } QMap mapping; const QVector items = m_faceModel.items(); for (const FaceListModel::Item &item : items) { const QString faceId = item.faceId.trimmed(); const QString studentId = item.studentId.trimmed(); if (faceId.isEmpty() || studentId.isEmpty()) { continue; } mapping[faceId] = studentId; } m_faceIdMapping = mapping; mergeFaceItems(); rebuildStudentProfiles(); m_apiClient.uploadFaceIdMapping(mapping); } void AppController::deleteFace(const QString &studentId, bool deleteRecords) { m_apiClient.deleteFace(studentId, deleteRecords); } void AppController::testConnection() { setStatusMessage("正在检查服务器连接"); m_apiClient.checkHealth(); } void AppController::loadLocalState() { QSettings settings; m_serverUrl = settings.value("server/url", "http://127.0.0.1:8082").toString(); m_apiToken = settings.value("server/apiToken", QProcessEnvironment::systemEnvironment() .value(QStringLiteral("ATTENDANCE_API_TOKEN"))).toString(); m_qtSyncVersion = 0; loadMappingsFromLocal(); loadTimeSlotsFromLocal(); } void AppController::saveServerUrl() { QSettings settings; settings.setValue("server/url", m_serverUrl); } void AppController::saveApiToken() { QSettings settings; settings.setValue("server/apiToken", m_apiToken); } void AppController::saveMappingsToLocal() { const QString path = localSettingsPath("mapping_config.json"); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; } QJsonObject root; QJsonObject studentObject; const QMap studentMap = m_studentMappingModel.toMap(); for (auto it = studentMap.begin(); it != studentMap.end(); ++it) { studentObject[it.key()] = it.value(); } QJsonObject classObject; const QMap classMap = m_classMappingModel.toMap(); for (auto it = classMap.begin(); it != classMap.end(); ++it) { classObject[it.key()] = it.value(); } QJsonObject studentClassObject; const QMap studentClassMap = m_studentClassMappingModel.toMap(); for (auto it = studentClassMap.begin(); it != studentClassMap.end(); ++it) { studentClassObject[it.key()] = it.value(); } root["student_name_map"] = studentObject; root["student_class_map"] = studentClassObject; root["class_name_map"] = classObject; file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); } void AppController::loadMappingsFromLocal() { QFile file(localSettingsPath("mapping_config.json")); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { m_studentMappingModel.clear(); m_studentClassMappingModel.clear(); m_classMappingModel.clear(); return; } const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); QMap studentMap; const QJsonObject studentObject = root.value("student_name_map").toObject(); for (auto it = studentObject.begin(); it != studentObject.end(); ++it) { studentMap[it.key()] = it.value().toString(); } QMap classMap; const QJsonObject classObject = root.value("class_name_map").toObject(); for (auto it = classObject.begin(); it != classObject.end(); ++it) { classMap[it.key()] = it.value().toString(); } QMap studentClassMap; const QJsonObject studentClassObject = root.value("student_class_map").toObject(); for (auto it = studentClassObject.begin(); it != studentClassObject.end(); ++it) { studentClassMap[it.key()] = it.value().toString(); } m_studentMappingModel.setMap(studentMap); m_studentClassMappingModel.setMap(studentClassMap); m_classMappingModel.setMap(classMap); rebuildStudentProfiles(); } void AppController::saveTimeSlotsToLocal() { QFile file(localSettingsPath("timeslot_config.json")); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; } QJsonArray array; for (const TimeSlot &slot : m_timeSlotModel.timeSlots()) { QJsonObject object; object["name"] = slot.name; object["start_time"] = slot.startTime.toString("HH:mm"); object["end_time"] = slot.endTime.toString("HH:mm"); object["enabled"] = slot.enabled; array.append(object); } QJsonObject root; root["time_slots"] = array; file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); } void AppController::loadTimeSlotsFromLocal() { QFile file(localSettingsPath("timeslot_config.json")); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { m_timeSlotModel.setTimeSlots({}); return; } QVector timeSlots; const QJsonArray array = QJsonDocument::fromJson(file.readAll()).object().value("time_slots").toArray(); for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); timeSlots.push_back({ object.value("name").toString(), QTime::fromString(object.value("start_time").toString(), "HH:mm"), QTime::fromString(object.value("end_time").toString(), "HH:mm"), object.value("enabled").toBool(true) }); } if (isLegacyDefaultTimeSlots(timeSlots)) { timeSlots.clear(); } m_timeSlotModel.setTimeSlots(timeSlots); if (timeSlots.isEmpty()) { saveTimeSlotsToLocal(); } } QString AppController::localSettingsPath(const QString &fileName) const { const QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(dirPath); return dirPath + "/" + fileName; } QString AppController::localFacePhotoPath(const QString &faceId) const { const QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/face_photos"; QDir().mkpath(dirPath); return dirPath + "/" + safeFileName(faceId) + ".jpg"; } void AppController::updateFilterOptions() { QSet classIds; QSet studentIds; for (const AttendanceRecord &record : m_allRecords) { classIds.insert(effectiveClassId(record)); studentIds.insert(record.studentId); } m_classOptions = {"全部"}; QStringList classValues = classIds.values(); std::sort(classValues.begin(), classValues.end()); m_classOptions.append(classValues); m_studentOptions = {"全部"}; QStringList studentValues = studentIds.values(); std::sort(studentValues.begin(), studentValues.end()); m_studentOptions.append(studentValues); emit filtersChanged(); } void AppController::rebuildStudentProfiles() { const QMap studentNameMap = m_studentMappingModel.toMap(); const QMap studentClassMap = m_studentClassMappingModel.toMap(); const QMap classNameMap = m_classMappingModel.toMap(); QSet studentIds; for (auto it = studentNameMap.cbegin(); it != studentNameMap.cend(); ++it) { studentIds.insert(it.key()); } for (auto it = studentClassMap.cbegin(); it != studentClassMap.cend(); ++it) { studentIds.insert(it.key()); } for (auto it = m_faceIdMapping.cbegin(); it != m_faceIdMapping.cend(); ++it) { if (!it.value().trimmed().isEmpty()) { studentIds.insert(it.value().trimmed()); } } for (const AttendanceRecord &record : m_allRecords) { if (!record.studentId.trimmed().isEmpty()) { studentIds.insert(record.studentId.trimmed()); } } QStringList sortedIds = studentIds.values(); std::sort(sortedIds.begin(), sortedIds.end(), [](const QString &left, const QString &right) { bool leftNumeric = false; bool rightNumeric = false; const int leftValue = left.toInt(&leftNumeric); const int rightValue = right.toInt(&rightNumeric); if (leftNumeric && rightNumeric) { return leftValue < rightValue; } return left < right; }); QVector items; items.reserve(sortedIds.size()); for (const QString &studentId : sortedIds) { const QString classId = studentClassMap.value(studentId); const QString className = classNameMap.value(classId, classId); const QString faceIds = faceBindingTextForStudent(studentId); const QString bindingStatus = faceIds.isEmpty() ? QStringLiteral("未绑定") : QStringLiteral("已绑定"); items.push_back({ studentId, studentNameMap.value(studentId), classId, className, faceIds, bindingStatus }); } m_studentProfileModel.setProfiles(items); emit studentProfilesChanged(); } void AppController::applyStudentProfilesToMappings() { QMap studentNameMap; QMap studentClassMap; const QVector profiles = m_studentProfileModel.profiles(); for (const StudentProfileListModel::Item &profile : profiles) { const QString studentId = profile.studentId.trimmed(); if (studentId.isEmpty()) { continue; } studentNameMap[studentId] = profile.studentName.trimmed(); studentClassMap[studentId] = profile.classId.trimmed(); } m_studentMappingModel.setMap(studentNameMap); m_studentClassMappingModel.setMap(studentClassMap); } bool AppController::validateMappings(QString *message) const { const QMap classMap = m_classMappingModel.toMap(); for (auto it = classMap.cbegin(); it != classMap.cend(); ++it) { if (it.value().trimmed().isEmpty()) { if (message) { *message = QStringLiteral("班级 %1 的名称不能为空").arg(it.key()); } return false; } } QSet studentIds; const QVector profiles = m_studentProfileModel.profiles(); for (int i = 0; i < profiles.size(); ++i) { const StudentProfileListModel::Item &profile = profiles.at(i); const QString rowText = QStringLiteral("第 %1 行").arg(i + 1); const QString studentId = profile.studentId.trimmed(); const QString studentName = profile.studentName.trimmed(); const QString classId = profile.classId.trimmed(); if (studentId.isEmpty() && studentName.isEmpty() && classId.isEmpty()) { continue; } if (studentId.isEmpty()) { if (message) { *message = rowText + QStringLiteral("学生档案的学号不能为空"); } return false; } if (studentName.isEmpty()) { if (message) { *message = QStringLiteral("学号 %1 的姓名不能为空").arg(studentId); } return false; } if (classId.isEmpty()) { if (message) { *message = QStringLiteral("学号 %1 的班级不能为空").arg(studentId); } return false; } if (!classMap.contains(classId)) { if (message) { *message = QStringLiteral("学号 %1 选择的班级 %2 不存在").arg(studentId, classId); } return false; } if (studentIds.contains(studentId)) { if (message) { *message = QStringLiteral("学号 %1 重复,请合并后再保存").arg(studentId); } return false; } studentIds.insert(studentId); } return true; } bool AppController::validateFaceMappings(QString *message) const { const QMap studentMap = m_studentMappingModel.toMap(); QSet faceIds; const QVector items = m_faceModel.items(); for (const FaceListModel::Item &item : items) { const QString faceId = item.faceId.trimmed(); const QString studentId = item.studentId.trimmed(); if (faceId.isEmpty()) { if (message) { *message = QStringLiteral("存在空的人脸编号,无法保存"); } return false; } if (faceIds.contains(faceId)) { if (message) { *message = QStringLiteral("人脸编号 %1 重复,无法保存").arg(faceId); } return false; } faceIds.insert(faceId); if (studentId.isEmpty()) { continue; } if (!studentMap.contains(studentId)) { if (message) { *message = QStringLiteral("人脸 %1 绑定的学号 %2 未建立学生档案").arg(faceId, studentId); } return false; } } return true; } QString AppController::faceBindingTextForStudent(const QString &studentId) const { QStringList faceIds; const QString trimmedStudentId = studentId.trimmed(); for (auto it = m_faceIdMapping.cbegin(); it != m_faceIdMapping.cend(); ++it) { if (it.value().trimmed() == trimmedStudentId) { faceIds.push_back(it.key()); } } std::sort(faceIds.begin(), faceIds.end()); return faceIds.join(QStringLiteral(", ")); } void AppController::rebuildFilteredRecords() { QVector items; m_filteredRecords.clear(); const QDate today = QDate::currentDate(); for (const AttendanceRecord &record : m_allRecords) { const QString classId = effectiveClassId(record); if (m_selectedClassId != "全部" && classId != m_selectedClassId) { continue; } if (m_selectedStudentId != "全部" && record.studentId != m_selectedStudentId) { continue; } const QString displayName = mappedStudentName(record); const QString candidateName = displayName.isEmpty() ? record.originalName : displayName; if (!m_nameFilter.isEmpty() && !candidateName.contains(m_nameFilter, Qt::CaseInsensitive)) { continue; } const QDate recordDate = record.dateTime().date(); if (m_dateFilterIndex == 1 && recordDate != today) { continue; } if (m_dateFilterIndex == 2 && recordDate.daysTo(today) > 7) { continue; } if (m_dateFilterIndex == 3 && recordDate.daysTo(today) > 30) { continue; } if (m_dateFilterIndex == 4 && recordDate != m_specificDate) { continue; } m_filteredRecords.push_back(record); } std::sort(m_filteredRecords.begin(), m_filteredRecords.end(), [](const AttendanceRecord &left, const AttendanceRecord &right) { return left.timestamp > right.timestamp; }); items.reserve(m_filteredRecords.size()); for (const AttendanceRecord &record : m_filteredRecords) { items.push_back({ record.timestamp, effectiveClassId(record), mappedClassName(record), record.studentId, mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record), record.confidence, record.status, isSuccessStatus(record.status) }); } m_recordModel.setItems(items); emit summaryChanged(); } void AppController::rebuildStatistics() { QMap statisticsMap; const QDate today = QDate::currentDate(); for (const AttendanceRecord &record : m_allRecords) { const QDate date = record.dateTime().date(); if (date < m_statisticsStartDate || date > m_statisticsEndDate) { continue; } StudentStatistics &stats = statisticsMap[record.studentId]; stats.studentId = record.studentId; stats.name = mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record); stats.totalCount += 1; stats.averageConfidence += record.confidence; if (date == today) { stats.todayCount += 1; } if (!stats.lastCheckIn.isValid() || record.dateTime() > stats.lastCheckIn) { stats.lastCheckIn = record.dateTime(); } } QVector items; items.reserve(statisticsMap.size()); m_statisticsStudentCount = statisticsMap.size(); m_statisticsRecordCount = 0; for (auto it = statisticsMap.begin(); it != statisticsMap.end(); ++it) { StudentStatistics stats = it.value(); m_statisticsRecordCount += stats.totalCount; if (stats.totalCount > 0) { stats.averageConfidence /= static_cast(stats.totalCount); } items.push_back({ stats.studentId, stats.name, stats.totalCount, stats.todayCount, stats.lastCheckIn.isValid() ? stats.lastCheckIn.toString("yyyy-MM-dd HH:mm:ss") : QStringLiteral("无"), stats.averageConfidence }); } std::sort(items.begin(), items.end(), [](const StatisticsListModel::Item &left, const StatisticsListModel::Item &right) { return left.studentId < right.studentId; }); m_statisticsModel.setItems(items); emit statisticsChanged(); } QString AppController::mappedStudentName(const AttendanceRecord &record) const { return m_studentMappingModel.toMap().value(record.studentId); } QString AppController::effectiveClassId(const AttendanceRecord &record) const { return m_studentClassMappingModel.toMap().value(record.studentId, record.classId); } QString AppController::mappedClassName(const AttendanceRecord &record) const { const QString classId = effectiveClassId(record); return m_classMappingModel.toMap().value(classId, classId); } void AppController::mergeFaceItems() { const QMap studentNameMap = m_studentMappingModel.toMap(); const QMap studentClassMap = m_studentClassMappingModel.toMap(); const QMap classNameMap = m_classMappingModel.toMap(); QSet allIds; for (const QString &id : m_facePhotoIds) { allIds.insert(id); } for (auto it = m_faceIdMapping.cbegin(); it != m_faceIdMapping.cend(); ++it) { allIds.insert(it.key()); } QStringList sortedIds = allIds.values(); std::sort(sortedIds.begin(), sortedIds.end()); QVector items; items.reserve(sortedIds.size()); for (const QString &id : sortedIds) { const QString photoUrl = m_facePhotoLocalUrls.value(id); const bool hasPhoto = !photoUrl.isEmpty(); const QString studentId = m_faceIdMapping.value(id); const QString classId = studentClassMap.value(studentId); const QString className = classNameMap.value(classId, classId); const bool hasStudent = !studentId.trimmed().isEmpty(); items.push_back({ id, studentId, hasPhoto, photoUrl, studentNameMap.value(studentId), classId, className, hasStudent ? QStringLiteral("已绑定") : QStringLiteral("未绑定") }); } m_faceModel.setItems(items); } void AppController::setStatusMessage(const QString &message) { const QString displayMessage = conciseStatusMessage(message); if (displayMessage == m_statusMessage) { return; } m_statusMessage = displayMessage; emit statusMessageChanged(); } void AppController::startReconnect(const QString &reason) { Q_UNUSED(reason) if (!m_reconnecting) { m_reconnecting = true; m_reconnectAttempt = 0; emit reconnectStateChanged(); } if (m_reconnectCheckInFlight || m_reconnectTimer.isActive()) { setStatusMessage(QStringLiteral("服务器未连接,稍后重试")); return; } setStatusMessage(QStringLiteral("服务器未连接,稍后重试")); scheduleReconnect(); } void AppController::stopReconnect() { m_reconnectTimer.stop(); m_reconnectCheckInFlight = false; if (!m_reconnecting && m_reconnectAttempt == 0) { return; } m_reconnecting = false; m_reconnectAttempt = 0; emit reconnectStateChanged(); } void AppController::scheduleReconnect() { m_reconnectTimer.start(ReconnectIntervalMs); } void AppController::runReconnectCheck() { if (!m_reconnecting || m_reconnectCheckInFlight) { return; } m_reconnectCheckInFlight = true; ++m_reconnectAttempt; emit reconnectStateChanged(); setStatusMessage(QStringLiteral("正在重连服务器")); m_apiClient.checkHealth(); }