1332 lines
45 KiB
C++
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();
|
|
}
|