Files
bishe-QT/src/AppController.cpp
T
2026-05-04 02:48:33 +08:00

1332 lines
45 KiB
C++

#include "AppController.h"
#include <QDir>
#include <QDebug>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
#include <QStandardPaths>
#include <QProcessEnvironment>
#include <QStringConverter>
#include <QSet>
#include <QTextStream>
#include <QUrl>
#include <algorithm>
#include <utility>
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<TimeSlot> 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<TimeSlot> &timeSlots)
{
const QVector<TimeSlot> 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<AttendanceRecord> &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<TimeSlot> &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<QString, QString> &studentNameMap,
const QMap<QString, QString> &studentClassMap,
const QMap<QString, QString> &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<QString, QString> &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<QString, QString> 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<StudentProfileListModel::Item> 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<StudentProfileListModel::Item> 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<FaceListModel::Item> 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<QString, QString> mapping;
const QVector<FaceListModel::Item> 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<QString, QString> studentMap = m_studentMappingModel.toMap();
for (auto it = studentMap.begin(); it != studentMap.end(); ++it) {
studentObject[it.key()] = it.value();
}
QJsonObject classObject;
const QMap<QString, QString> classMap = m_classMappingModel.toMap();
for (auto it = classMap.begin(); it != classMap.end(); ++it) {
classObject[it.key()] = it.value();
}
QJsonObject studentClassObject;
const QMap<QString, QString> 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<QString, QString> 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<QString, QString> 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<QString, QString> 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<TimeSlot> 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<QString> classIds;
QSet<QString> 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<QString, QString> studentNameMap = m_studentMappingModel.toMap();
const QMap<QString, QString> studentClassMap = m_studentClassMappingModel.toMap();
const QMap<QString, QString> classNameMap = m_classMappingModel.toMap();
QSet<QString> 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<StudentProfileListModel::Item> 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<QString, QString> studentNameMap;
QMap<QString, QString> studentClassMap;
const QVector<StudentProfileListModel::Item> 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<QString, QString> 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<QString> studentIds;
const QVector<StudentProfileListModel::Item> 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<QString, QString> studentMap = m_studentMappingModel.toMap();
QSet<QString> faceIds;
const QVector<FaceListModel::Item> 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<RecordListModel::Item> 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<QString, StudentStatistics> 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<StatisticsListModel::Item> 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<double>(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<QString, QString> studentNameMap = m_studentMappingModel.toMap();
const QMap<QString, QString> studentClassMap = m_studentClassMappingModel.toMap();
const QMap<QString, QString> classNameMap = m_classMappingModel.toMap();
QSet<QString> 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<FaceListModel::Item> 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();
}