diff --git a/CMakeLists.txt b/CMakeLists.txt index c0b598c..b74315f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -119,6 +119,25 @@ set_target_properties(apprewrite_qt PROPERTIES WIN32_EXECUTABLE TRUE ) +if(WIN32) + get_filename_component(REWRITE_QT_QT_BIN_DIR "${REWRITE_QT_QMAKE_EXECUTABLE}" DIRECTORY) + find_program(REWRITE_QT_WINDEPLOYQT_EXECUTABLE + NAMES windeployqt + HINTS "${REWRITE_QT_QT_BIN_DIR}" + REQUIRED + ) + + add_custom_command(TARGET apprewrite_qt POST_BUILD + COMMAND "${REWRITE_QT_WINDEPLOYQT_EXECUTABLE}" + --dir "$" + --qmldir "${CMAKE_CURRENT_SOURCE_DIR}/qml" + --release + "$" + COMMENT "Deploying Qt runtime dependencies with windeployqt" + COMMAND_EXPAND_LISTS + ) +endif() + include(GNUInstallDirs) if(UNIX AND NOT APPLE AND NOT ANDROID) diff --git a/clear_qt_cache.ps1 b/clear_qt_cache.ps1 index 4575211..cfeb1e9 100644 --- a/clear_qt_cache.ps1 +++ b/clear_qt_cache.ps1 @@ -8,12 +8,14 @@ param( Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' +# 函数作用:将传入路径解析为规范化的绝对路径,便于后续安全校验和文件删除操作。 function Resolve-FullPath { param([Parameter(Mandatory = $true)][string]$Path) return [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $Path).Path) } +# 函数作用:确认待删除目标位于指定项目目录内部,避免误删项目外部文件。 function Assert-InsideDirectory { param( [Parameter(Mandatory = $true)][string]$Parent, @@ -29,6 +31,7 @@ function Assert-InsideDirectory { } } +# 函数作用:检查 Qt Creator、CMake、Ninja 等构建相关进程是否正在运行,防止清理缓存时与构建过程冲突。 function Stop-IfBuildToolsAreRunning { $names = @('qtcreator', 'cmake', 'ninja', 'jom', 'mingw32-make', 'make') $running = Get-Process -ErrorAction SilentlyContinue | @@ -41,6 +44,7 @@ function Stop-IfBuildToolsAreRunning { } } +# 函数作用:收集当前 Qt 项目下可安全清理的 CMake/Qt Creator 缓存文件和构建目录,并去除重复项。 function Get-CacheTargets { param([Parameter(Mandatory = $true)][string]$ProjectPath) diff --git a/main.cpp b/main.cpp index 101ba11..fcccda0 100644 --- a/main.cpp +++ b/main.cpp @@ -9,9 +9,13 @@ #include "src/AppController.h" +// 函数作用:Qt 管理端程序入口,负责初始化应用、注册 C++ 控制器并加载主 QML 界面。 int main(int argc, char *argv[]) { QGuiApplication app(argc, argv); + + // 应用图标同时注册多种尺寸,方便 Windows、Android 和任务栏在不同缩放比例下 + // 选择最合适的资源,避免高 DPI 屏幕上图标发虚。 QIcon appIcon(QStringLiteral(":/assets/favicon.ico")); appIcon.addFile(QStringLiteral(":/assets/favicon-256x256.png")); appIcon.addFile(QStringLiteral(":/assets/favicon-192x192.png")); @@ -28,12 +32,18 @@ int main(int argc, char *argv[]) QQuickStyle::setStyle("Basic"); #if defined(Q_OS_ANDROID) + // Android 端使用系统原生文字渲染,中文字体边缘会比默认距离场渲染更清晰。 QQuickWindow::setTextRenderType(QQuickWindow::NativeTextRendering); #endif + // AppController 是 C++ 后端的统一入口,负责网络请求、本地缓存、数据筛选和导出。 + // 下方通过 contextProperty 暴露给 QML,界面中可直接使用 appController 调用这些能力。 AppController controller; QQmlApplicationEngine engine; + + // 部分打包环境不会自动把 Qt 安装目录中的 QML 模块加入搜索路径, + // 这里显式补充一次,避免发布版运行时找不到 QtQuick.Controls 等模块。 const QString qmlImportPath = QLibraryInfo::path(QLibraryInfo::QmlImportsPath); if (!qmlImportPath.isEmpty()) { engine.addImportPath(qmlImportPath); @@ -47,6 +57,7 @@ int main(int argc, char *argv[]) engine.rootContext()->setContextProperty("appController", &controller); const QUrl url(QStringLiteral("qrc:/qml/Main.qml")); + // 如果主 QML 创建失败,说明资源路径或依赖模块异常,直接退出便于定位启动问题。 QObject::connect( &engine, &QQmlApplicationEngine::objectCreated, diff --git a/qml/Main.qml b/qml/Main.qml index 70df441..a495e5f 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -13,6 +13,8 @@ ApplicationWindow { title: "人脸识别考勤系统" color: "#f6f7f9" + // 全局设计变量:颜色、字体和响应式断点集中定义,后续页面和组件只引用这些变量。 + // 这样桌面端与 Android 端能保持统一风格,也便于毕业设计截图统一视觉效果。 readonly property color ink900: "#182230" readonly property color ink700: "#475467" readonly property color ink500: "#667085" @@ -58,6 +60,7 @@ ApplicationWindow { font.family: appFontFamily + // 窗口拖拽缩放时先标记 resizingWindow,部分复杂列表和动画可据此降低刷新频率。 onWidthChanged: { resizingWindow = true; resizeSettledTimer.restart(); @@ -68,6 +71,7 @@ ApplicationWindow { resizeSettledTimer.restart(); } + // 函数作用:切换主页面,并把页面下标限制在合法范围内。 function switchPage(index) { currentPageIndex = Math.max(0, Math.min(4, index)); } @@ -79,6 +83,8 @@ ApplicationWindow { onTriggered: resizingWindow = false } + // 以下 component 是本文件内部的轻量设计系统:输入框、按钮、空状态、标题等 + // 都在这里统一样式,主页面只负责组合业务内容。 component AppCard: Rectangle { radius: 8 color: panelStrong @@ -441,15 +447,18 @@ ApplicationWindow { } } + // 函数作用:把记录筛选栏的当前条件提交给 C++ 后端。 function applyRecordFilters() { appController.applyFilters(classFilter.currentText, studentFilter.currentText, nameFilter.text, dateFilter.currentIndex, specificDateField.text); } + // 函数作用:根据统计页预设选项计算开始日期和结束日期。 function applyStatisticsPreset() { const today = new Date(); let start = new Date(today); let end = new Date(today); + // 预设只负责改写日期输入框,真正的统计重算仍统一交给 C++ 控制器完成。 if (statisticsPreset === "今天") { start = new Date(today); } else if (statisticsPreset === "最近7天") { @@ -465,9 +474,11 @@ ApplicationWindow { appController.updateStatisticsRange(statsStartField.text, statsEndField.text); } + // 函数作用:打开时间段编辑弹窗,index 为 -1 时表示新增。 function openTimeSlotDialog(index) { editingTimeSlotIndex = index; if (index >= 0) { + // 编辑模式从模型读取当前行,避免 QML delegate 中维护重复状态。 const item = appController.timeSlotModel.get(index); timeSlotNameField.text = item.name; timeSlotStartField.text = item.startTime; @@ -475,6 +486,7 @@ ApplicationWindow { timeSlotEnabledBox.checked = item.enabled; timeSlotDialog.title = "编辑时间段"; } else { + // 新增模式使用一组可直接修改的默认值,用户确认后再写入模型。 timeSlotNameField.text = ""; timeSlotStartField.text = "08:00"; timeSlotEndField.text = "09:30"; @@ -484,6 +496,7 @@ ApplicationWindow { timeSlotDialog.open(); } + // 函数作用:保存时间段弹窗中的内容,并同步到 C++ 后端。 function saveTimeSlotDialog() { if (editingTimeSlotIndex >= 0) { appController.timeSlotModel.updateTimeSlot(editingTimeSlotIndex, timeSlotNameField.text, timeSlotStartField.text, timeSlotEndField.text, timeSlotEnabledBox.checked); @@ -493,6 +506,7 @@ ApplicationWindow { appController.saveTimeSlots(); } + // 函数作用:根据班级编号查找班级下拉框中对应的选项下标。 function classComboIndex(classId) { var display = appController.classDisplayForId(classId); var options = appController.classSelectionOptions; @@ -504,6 +518,7 @@ ApplicationWindow { return options.length > 0 ? 0 : -1; } + // 函数作用:在普通选项数组中查找指定值的下标,找不到时返回 0。 function optionIndex(options, value) { for (var i = 0; i < options.length; ++i) { if (options[i] === value) { @@ -513,18 +528,22 @@ ApplicationWindow { return 0; } + // 函数作用:标记配置中心存在尚未保存的改动。 function markMappingsDirty() { mappingsDirty = true; } + // 函数作用:提交学生档案编辑结果,并把界面班级文本转换为真实班级编号。 function commitStudentProfile(index, studentId, studentName, classDisplay) { appController.updateStudentProfile(index, studentId, studentName, appController.classIdFromDisplay(classDisplay)); markMappingsDirty(); } + // 函数作用:在提交学生档案前检查学号变化是否需要二次确认。 function requestStudentProfileCommit(index, studentId, studentName, classDisplay) { var impactMessage = appController.studentIdChangeImpactMessage(index, studentId); if (impactMessage.length > 0) { + // 学号变更会连带人脸绑定和历史记录显示,先暂存待提交值,等确认弹窗回调再真正保存。 pendingStudentIdChangeIndex = index; pendingStudentIdChangeNewId = studentId; pendingStudentIdChangeName = studentName; @@ -536,6 +555,7 @@ ApplicationWindow { commitStudentProfile(index, studentId, studentName, classDisplay); } + // 函数作用:判断某条学生档案是否符合搜索、班级和绑定状态过滤条件。 function studentProfileMatches(searchText, classFilter, bindingFilter, studentId, studentName, classId, className, faceIds) { var normalizedSearch = searchText ? searchText.trim().toLowerCase() : ""; var classFilterId = appController.classIdFromDisplay(classFilter); @@ -551,10 +571,12 @@ ApplicationWindow { if (normalizedSearch.length === 0) { return true; } + // 搜索同时覆盖学号、姓名、班级编号、班级名称和人脸编号,方便答辩演示时快速定位记录。 var haystack = (studentId + " " + studentName + " " + classId + " " + className + " " + faceIds).toLowerCase(); return haystack.indexOf(normalizedSearch) >= 0; } + // 函数作用:打开人脸档案编辑弹窗,并填充当前绑定信息。 function openFaceEditor(faceId, studentId, studentName, classId) { editingFaceId = faceId; editingFaceOriginalStudentId = studentId; @@ -564,6 +586,7 @@ ApplicationWindow { faceEditDialog.open(); } + // 函数作用:直接保存人脸编辑弹窗中的绑定信息。 function saveFaceEditorNow() { var savedLocally = appController.editFaceProfile(editingFaceId, faceEditStudentIdField.text, @@ -577,10 +600,12 @@ ApplicationWindow { appController.saveFaceMappings(); } + // 函数作用:保存人脸编辑前检查绑定学号变化是否需要确认。 function requestSaveFaceEditor() { var oldId = editingFaceOriginalStudentId ? editingFaceOriginalStudentId.trim() : ""; var newId = faceEditStudentIdField.text ? faceEditStudentIdField.text.trim() : ""; if (oldId.length > 0 && oldId !== newId) { + // 已绑定人脸改到另一个学号时影响范围较大,交给确认弹窗完成最后一次用户确认。 faceStudentIdChangeDialog.text = "修改该人脸绑定会影响识别后发送给 STM32 的学号、考勤记录显示、班级筛选和导出结果。确定继续吗?"; faceStudentIdChangeDialog.open(); return; @@ -588,6 +613,7 @@ ApplicationWindow { saveFaceEditorNow(); } + // 函数作用:页面加载完成后初始化默认记录筛选和统计日期区间。 Component.onCompleted: { applyRecordFilters(); applyStatisticsPreset(); @@ -596,10 +622,12 @@ ApplicationWindow { Connections { target: appController + // 函数作用:监听 C++ 状态消息变化,并转换为右下角 toast 提示。 function onStatusMessageChanged() { if (!appController.statusMessage || appController.statusMessage.length === 0) { return; } + // 后端保存成功后清除脏标记,界面不再提示用户有未保存配置。 if (appController.statusMessage.indexOf("映射配置已保存") >= 0 || appController.statusMessage.indexOf("映射配置已同步") >= 0 || appController.statusMessage.indexOf("人脸档案已保存") >= 0) { @@ -640,6 +668,7 @@ ApplicationWindow { Dialog { id: clearDialog + // 清空全部考勤记录属于高风险操作,必须通过确认弹窗触发。 title: "确认清空" modal: true anchors.centerIn: Overlay.overlay @@ -709,6 +738,7 @@ ApplicationWindow { Dialog { id: timeSlotDialog + // 时间段新增/编辑共用同一个弹窗,通过 editingTimeSlotIndex 判断保存方式。 modal: true anchors.centerIn: Overlay.overlay width: 420 @@ -787,6 +817,7 @@ ApplicationWindow { Dialog { id: deleteFaceDialog + // 删除人脸信息可能连带删除考勤记录,因此单独弹窗确认并提供复选项。 modal: true anchors.centerIn: Overlay.overlay width: 430 @@ -857,6 +888,7 @@ ApplicationWindow { Dialog { id: faceEditDialog + // 编辑人脸档案时只改绑定关系和学生资料,不修改 K210 生成的人脸编号。 modal: true anchors.centerIn: Overlay.overlay width: phoneLayout ? Math.min(window.width - 32, 420) : 460 @@ -977,6 +1009,7 @@ ApplicationWindow { anchors.margins: slimLayout ? 10 : 16 spacing: slimLayout ? 0 : 16 + // 桌面侧边栏导航;窄屏时隐藏,改用下方的移动端 TabBar。 Rectangle { visible: !slimLayout Layout.preferredWidth: visible ? sidebarWidth : 0 @@ -1289,6 +1322,7 @@ ApplicationWindow { StackLayout { id: pageLayout + // 五个主页面共用 StackLayout:考勤记录、统计分析、配置中心、人脸管理、关于。 Layout.fillWidth: true Layout.fillHeight: true currentIndex: window.currentPageIndex @@ -2372,6 +2406,7 @@ ApplicationWindow { spacing: 8 model: appController.timeSlotModel + // 桌面端和手机端共用同一份时间段模型,只在 delegate 内切换行布局。 delegate: Rectangle { width: ListView.view.width height: phoneLayout ? 96 : 52 @@ -2404,6 +2439,7 @@ ApplicationWindow { visible: !phoneLayout spacing: 12 + // 桌面布局按表格列对齐,便于快速比较多个时间段的起止时间。 Label { text: model.name Layout.preferredWidth: 220 @@ -2444,6 +2480,7 @@ ApplicationWindow { visible: phoneLayout spacing: 8 + // 手机布局拆成两行,避免名称、时间和按钮在窄屏上互相挤压。 RowLayout { Layout.fillWidth: true spacing: 8 @@ -2580,6 +2617,7 @@ ApplicationWindow { spacing: 8 model: appController.classMappingModel + // 班级列表直接编辑模型,保存按钮再统一把完整配置同步到服务端。 delegate: Rectangle { width: ListView.view.width height: phoneLayout ? 112 : 56 @@ -2608,6 +2646,7 @@ ApplicationWindow { visible: !phoneLayout spacing: 12 + // 桌面端让编号和名称保持两列,方便核对 class_id 与 class_name。 AppTextField { id: classKeyField Layout.preferredWidth: 220 @@ -2648,6 +2687,7 @@ ApplicationWindow { visible: phoneLayout spacing: 8 + // 窄屏下将编号、名称、删除按钮分行显示,避免输入框宽度过小。 AppTextField { id: classKeyFieldPhone Layout.fillWidth: true @@ -2777,6 +2817,7 @@ ApplicationWindow { cellHeight: phoneLayout ? 444 : 404 model: appController.faceModel + // 人脸卡片展示的是控制器合并后的结果:照片缓存、绑定学号、学生档案和班级名称。 delegate: Rectangle { width: Math.min(phoneLayout ? 300 : 244, GridView.view.cellWidth - 18) height: phoneLayout ? 420 : 380 @@ -2815,6 +2856,7 @@ ApplicationWindow { color: model.hasPhoto ? "#eef3f5" : goldSoft border.color: model.hasPhoto ? "#dbe4ea" : "#eadbb0" + // 照片由 C++ 下载到本地缓存后以 file:// URL 暴露,QML 只负责显示。 Image { anchors.fill: parent anchors.margins: 8 @@ -2916,6 +2958,7 @@ ApplicationWindow { Layout.fillWidth: true text: "删除" onClicked: { + // 删除操作先记录当前卡片的人脸编号,再交给确认弹窗决定是否连带删除考勤记录。 pendingDeleteFaceId = model.faceId; deleteFaceRecordsCheck.checked = false; deleteFaceDialog.open(); diff --git a/src/ApiClient.cpp b/src/ApiClient.cpp index 5107959..7894831 100644 --- a/src/ApiClient.cpp +++ b/src/ApiClient.cpp @@ -16,14 +16,17 @@ namespace { constexpr int NetworkTimeoutMs = 5000; } +// 函数作用:初始化网络客户端,并设置默认服务器地址和 API Token。 ApiClient::ApiClient(QObject *parent) : QObject(parent) , m_serverUrl("http://27.106.113.205:8082") + // Token 优先从环境读取,便于发布或调试时不把真实 Token 写死在配置文件里。 , m_apiToken(QProcessEnvironment::systemEnvironment() .value(QStringLiteral("atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV"))) { } +// 函数作用:更新服务端基础地址,忽略空字符串。 void ApiClient::setServerUrl(const QString &url) { const QString trimmed = url.trimmed(); @@ -32,11 +35,13 @@ void ApiClient::setServerUrl(const QString &url) } } +// 函数作用:返回当前服务端基础地址。 QString ApiClient::serverUrl() const { return m_serverUrl; } +// 函数作用:更新 API Token,空值时回退读取环境变量。 void ApiClient::setApiToken(const QString &token) { const QString trimmed = token.trimmed(); @@ -46,14 +51,17 @@ void ApiClient::setApiToken(const QString &token) : trimmed; } +// 函数作用:返回当前 API Token。 QString ApiClient::apiToken() const { return m_apiToken; } +// 函数作用:拼接完整 API 地址,并编码查询参数。 QString ApiClient::buildUrl(const QString &endpoint, const QMap ¶ms) const { QString baseUrl = m_serverUrl.trimmed(); + // 统一去掉用户输入末尾的斜杠,避免拼接出 http://host//api 这类地址。 while (baseUrl.endsWith('/')) { baseUrl.chop(1); } @@ -68,15 +76,18 @@ QString ApiClient::buildUrl(const QString &endpoint, const QMap(root.value("version").toDouble(0)); const bool changed = root.value("changed").toBool(false); if (!changed) { + // 服务端版本没有变化时只通知控制器更新状态文本,不重复解析或刷新各个模型。 emit syncStateFetched(version, false); reply->deleteLater(); return; @@ -112,6 +126,8 @@ void ApiClient::fetchSyncState(qulonglong lastVersion) const QJsonObject state = root.value("state").toObject(); + // state 中按需包含多个业务分区;每个分区解析后直接复用已有信号, + // 让 AppController 不必区分“增量同步返回”还是“单独接口返回”。 if (state.contains("records")) { emit recordsFetched(parseRecords(QJsonDocument(state.value("records").toArray()).toJson(QJsonDocument::Compact))); } @@ -143,6 +159,7 @@ void ApiClient::fetchSyncState(qulonglong lastVersion) QMap studentClassMap; QMap classMap; + // 学生数组先拆成“学号->姓名”和“学号->班级”两张映射,保持与本地模型结构一致。 const QJsonArray studentArray = state.value("students").toArray(); for (const QJsonValue &value : studentArray) { const QJsonObject object = value.toObject(); @@ -155,6 +172,7 @@ void ApiClient::fetchSyncState(qulonglong lastVersion) } const QJsonArray classes = state.value("classes").toArray(); + // 班级数组单独解析,后面用 class_id 回填学生档案中的班级名称。 for (const QJsonValue &value : classes) { const QJsonObject object = value.toObject(); const QString classId = object.value("class_id").toString().trimmed(); @@ -164,6 +182,7 @@ void ApiClient::fetchSyncState(qulonglong lastVersion) } QVector studentItems; studentItems.reserve(studentMap.size()); + // ApiClient 只做结构转换,是否保存到本地、是否刷新统计由 AppController 决定。 for (auto it = studentMap.cbegin(); it != studentMap.cend(); ++it) { const QString studentId = it.key(); studentItems.push_back({ @@ -183,6 +202,7 @@ void ApiClient::fetchSyncState(qulonglong lastVersion) const QJsonArray profiles = state.value("face_profiles").toArray(); QVector faces; faces.reserve(profiles.size()); + // 这里暂不补学生姓名和班级名称,这些派生字段需要控制器结合学生/班级映射完成。 for (const QJsonValue &value : profiles) { const QJsonObject object = value.toObject(); const QString faceId = object.value("face_id").toString().trimmed(); @@ -207,6 +227,7 @@ void ApiClient::fetchSyncState(qulonglong lastVersion) if (state.contains("face_photos")) { QStringList ids; const QJsonArray photos = state.value("face_photos").toArray(); + // 同步状态只返回照片 ID 列表,真正的 JPEG 数据由控制器按需逐张下载。 for (const QJsonValue &value : photos) { ids.push_back(value.toObject().value("id").toString()); } @@ -218,18 +239,22 @@ void ApiClient::fetchSyncState(qulonglong lastVersion) }); } +// 函数作用:根据响应头判断是否需要解密响应体,并返回可解析的数据。 QByteArray ApiClient::maybeDecrypt(const QByteArray &data, bool encrypted) const { if (!encrypted) { return data; } const QByteArray trimmed = data.trimmed(); + // 调试或兼容场景下,服务端可能带了加密头但直接返回明文 JSON; + // 这里先识别 JSON 起始字符,避免对明文重复解密导致解析失败。 if (trimmed.startsWith('{') || trimmed.startsWith('[')) { return trimmed; } return CryptoUtils::decryptAes128CbcBase64(trimmed); } +// 函数作用:读取 QNetworkReply 响应体,并按响应头自动解密。 QByteArray ApiClient::decryptedReplyBody(QNetworkReply *reply) const { const QByteArray data = reply->readAll(); @@ -237,13 +262,16 @@ QByteArray ApiClient::decryptedReplyBody(QNetworkReply *reply) const return maybeDecrypt(data, encrypted); } +// 函数作用:把 JSON 请求体按服务端约定加密后发送。 QByteArray ApiClient::encryptedJsonBody(const QByteArray &body) const { return CryptoUtils::encryptAes128CbcBase64(body); } +// 函数作用:判断网络错误是否属于连接不可用,需要触发重连。 bool ApiClient::isConnectionError(QNetworkReply::NetworkError error) const { + // 这些错误大多表示服务端不可达或连接中断,需要通知 AppController 进入重连流程。 switch (error) { case QNetworkReply::ConnectionRefusedError: case QNetworkReply::RemoteHostClosedError: @@ -267,14 +295,17 @@ bool ApiClient::isConnectionError(QNetworkReply::NetworkError error) const } } +// 函数作用:统一发出网络错误信号,并在连接类错误时额外通知重连流程。 void ApiClient::emitNetworkError(const QString &prefix, QNetworkReply *reply) { + // networkError 负责界面提示,connectionUnavailable 负责触发自动重连。 emit networkError(prefix); if (isConnectionError(reply->error())) { emit connectionUnavailable(prefix); } } +// 函数作用:解析服务端返回的考勤记录 JSON 数组。 QVector ApiClient::parseRecords(const QByteArray &data) const { QVector records; @@ -285,9 +316,11 @@ QVector ApiClient::parseRecords(const QByteArray &data) const const QJsonArray array = doc.array(); records.reserve(array.size()); + // 服务端历史数据中 status 可能是 bool、数字或字符串,统一转换为字符串保存。 for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); AttendanceRecord record; + // 只保留服务端的原始业务字段;姓名和班级显示值稍后由 AppController 映射生成。 record.timestamp = object.value("timestamp").toString().trimmed(); record.studentId = object.value("student_id").toString().trimmed(); record.originalName = object.value("name").toString().trimmed(); @@ -304,6 +337,7 @@ QVector ApiClient::parseRecords(const QByteArray &data) const if (record.classId.isEmpty()) { record.classId = "0"; } + // 过滤掉初始化时间和缺失关键字段的无效记录,避免界面统计被脏数据污染。 if (!record.timestamp.startsWith("0000-") && !record.timestamp.isEmpty() && !record.studentId.isEmpty()) { records.push_back(record); } @@ -311,8 +345,10 @@ QVector ApiClient::parseRecords(const QByteArray &data) const return records; } +// 函数作用:分页请求考勤记录列表。 void ApiClient::fetchRecords(int limit, int offset) { + // 考勤记录分页接口。当前管理端默认一次取最近 1000 条,后续可通过 limit/offset 扩展分页。 const QString url = buildUrl("/v1/attendance/records", { {"limit", QString::number(limit)}, {"offset", QString::number(offset)} @@ -338,6 +374,7 @@ void ApiClient::fetchRecords(int limit, int offset) return; } + // 先显式验证 JSON 类型,避免 parseRecords 静默返回空列表时界面误以为服务端真的没有记录。 QJsonParseError jsonError{}; const QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); if (jsonError.error != QJsonParseError::NoError || !doc.isArray()) { @@ -353,6 +390,7 @@ void ApiClient::fetchRecords(int limit, int offset) }); } +// 函数作用:请求服务端考勤统计摘要。 void ApiClient::fetchStatistics() { const QString url = buildUrl("/v1/attendance/statistics"); @@ -386,6 +424,7 @@ void ApiClient::fetchStatistics() }); } +// 函数作用:请求服务端清空全部考勤记录。 void ApiClient::clearAllRecords() { QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/attendance/records/clear"), true, true), @@ -407,6 +446,7 @@ void ApiClient::clearAllRecords() }); } +// 函数作用:根据时间戳和学号删除一条考勤记录。 void ApiClient::deleteRecord(const QString ×tamp, const QString &studentId) { QNetworkReply *reply = m_networkManager.deleteResource(createRequest(buildUrl("/v1/attendance/records", { @@ -430,6 +470,7 @@ void ApiClient::deleteRecord(const QString ×tamp, const QString &studentId) }); } +// 函数作用:从服务端加载签到时间段配置。 void ApiClient::fetchTimeSlots() { QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/time-slots"), true)); @@ -444,6 +485,7 @@ void ApiClient::fetchTimeSlots() QVector timeSlots; const QJsonObject root = QJsonDocument::fromJson(data).object(); const QJsonArray array = root.value("time_slots").toArray(); + // 时间使用 HH:mm 字符串传输,进入模型前转换为 QTime,便于后续编辑和格式化。 for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); timeSlots.push_back({ @@ -458,9 +500,11 @@ void ApiClient::fetchTimeSlots() }); } +// 函数作用:上传签到时间段配置到服务端。 void ApiClient::uploadTimeSlots(const QVector &timeSlots) { QJsonArray array; + // 字段名与服务端 Flask 接口保持一致:name/start_time/end_time/enabled。 for (const TimeSlot &slot : timeSlots) { QJsonObject object; object["name"] = slot.name; @@ -473,6 +517,7 @@ void ApiClient::uploadTimeSlots(const QVector &timeSlots) QJsonObject payload; payload["time_slots"] = array; const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); + // 日志打印明文 JSON 只用于本地调试,真正发送前仍会在 encryptedJsonBody 中加密。 qInfo() << "[ApiClient] POST" << buildUrl("/v1/config/time-slots") << "bytes=" << body.size() << "body=" << body.left(300); @@ -494,6 +539,7 @@ void ApiClient::uploadTimeSlots(const QVector &timeSlots) }); } +// 函数作用:从服务端加载学生档案列表。 void ApiClient::fetchStudents() { QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/students"), true)); @@ -508,6 +554,7 @@ void ApiClient::fetchStudents() QVector students; const QJsonArray array = QJsonDocument::fromJson(data).object().value("students").toArray(); students.reserve(array.size()); + // className、faceIds、bindingStatus 是控制器派生字段,接口层保持为空。 for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); students.push_back({ @@ -524,9 +571,11 @@ void ApiClient::fetchStudents() }); } +// 函数作用:上传学生档案列表到服务端。 void ApiClient::uploadStudents(const QVector &students) { QJsonArray array; + // 空学号的临时编辑行不上传,避免服务端产生无主键学生。 for (const StudentProfileListModel::Item &student : students) { const QString studentId = student.studentId.trimmed(); if (studentId.isEmpty()) { @@ -541,6 +590,7 @@ void ApiClient::uploadStudents(const QVector &stu QJsonObject payload; payload["students"] = array; const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); + // 学生档案作为完整快照上传,服务端收到后以当前数组覆盖或同步对应表。 QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/students"), true, true), encryptedJsonBody(body)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { @@ -559,6 +609,7 @@ void ApiClient::uploadStudents(const QVector &stu }); } +// 函数作用:从服务端加载班级配置。 void ApiClient::fetchClasses() { QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/classes"), true)); @@ -572,6 +623,7 @@ void ApiClient::fetchClasses() QMap classMap; const QJsonArray array = QJsonDocument::fromJson(data).object().value("classes").toArray(); + // 空班级编号不进入映射表,防止下拉框出现无法保存的无效选项。 for (const QJsonValue &value : array) { const QJsonObject object = value.toObject(); const QString classId = object.value("class_id").toString().trimmed(); @@ -584,9 +636,11 @@ void ApiClient::fetchClasses() }); } +// 函数作用:上传班级配置到服务端。 void ApiClient::uploadClasses(const QMap &classNameMap) { QJsonArray array; + // QMap 会按 key 排序,上传顺序稳定,便于服务端日志和调试比对。 for (auto it = classNameMap.cbegin(); it != classNameMap.cend(); ++it) { const QString classId = it.key().trimmed(); if (classId.isEmpty()) { @@ -618,6 +672,7 @@ void ApiClient::uploadClasses(const QMap &classNameMap) }); } +// 函数作用:从服务端加载人脸档案绑定关系。 void ApiClient::fetchFaceProfiles() { QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/face_profiles"), true)); @@ -650,9 +705,11 @@ void ApiClient::fetchFaceProfiles() }); } +// 函数作用:上传人脸档案绑定关系到服务端。 void ApiClient::uploadFaceProfiles(const QVector &faces) { QJsonArray array; + // 人脸编号是服务端保存模板和照片的主键;空编号跳过,避免破坏已有档案。 for (const FaceListModel::Item &face : faces) { const QString faceId = face.faceId.trimmed(); if (faceId.isEmpty()) { @@ -661,6 +718,7 @@ void ApiClient::uploadFaceProfiles(const QVector &faces) QJsonObject object; object["face_id"] = faceId; object["student_id"] = face.studentId.trimmed(); + // status 根据绑定状态自动生成,界面不直接编辑该字段。 object["status"] = face.studentId.trimmed().isEmpty() ? QStringLiteral("unbound") : QStringLiteral("active"); array.append(object); } @@ -686,6 +744,7 @@ void ApiClient::uploadFaceProfiles(const QVector &faces) }); } +// 函数作用:从服务端加载已保存照片的人脸编号列表。 void ApiClient::fetchFacePhotos() { QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/photos"), true)); @@ -699,6 +758,7 @@ void ApiClient::fetchFacePhotos() QStringList ids; const QJsonArray photos = QJsonDocument::fromJson(data).object().value("photos").toArray(); + // 只把照片编号交给控制器,避免在一个接口里一次性加载大量图片数据。 for (const QJsonValue &value : photos) { ids.push_back(value.toObject().value("id").toString()); } @@ -707,6 +767,7 @@ void ApiClient::fetchFacePhotos() }); } +// 函数作用:按人脸编号下载单张人脸照片。 void ApiClient::fetchFacePhoto(const QString &faceId) { if (faceId.trimmed().isEmpty()) { @@ -716,6 +777,7 @@ void ApiClient::fetchFacePhoto(const QString &faceId) const QString encodedId = QString::fromUtf8(QUrl::toPercentEncoding(faceId.trimmed())); QNetworkRequest request = createRequest(buildUrl(QStringLiteral("/v1/faces/photos/%1").arg(encodedId))); + // 照片接口返回 JPEG 二进制,不走 JSON 解密流程。 request.setRawHeader("Accept", "image/jpeg,*/*"); QNetworkReply *reply = m_networkManager.get(request); @@ -726,6 +788,7 @@ void ApiClient::fetchFacePhoto(const QString &faceId) const QString message = reply->error() != QNetworkReply::NoError ? reply->errorString() : QStringLiteral("HTTP %1").arg(statusCode); + // 下载失败只影响单张照片显示,不阻断其它照片继续加载。 emit facePhotoFetched(faceId, {}, false, message); reply->deleteLater(); return; @@ -736,6 +799,7 @@ void ApiClient::fetchFacePhoto(const QString &faceId) }); } +// 函数作用:删除指定学生对应的人脸数据,并可选择删除其考勤记录。 void ApiClient::deleteFace(const QString &studentId, bool deleteRecords) { QString baseUrl = m_serverUrl.trimmed(); @@ -744,6 +808,7 @@ void ApiClient::deleteFace(const QString &studentId, bool deleteRecords) } QString url = baseUrl + "/api/v1/faces/" + QString::fromLatin1(QUrl::toPercentEncoding(studentId)); if (deleteRecords) { + // delete_records=true 表示删除人脸时同步清除该学生关联的考勤记录。 url += "?delete_records=true"; } QNetworkReply *reply = m_networkManager.deleteResource(createRequest(url, true)); @@ -767,12 +832,15 @@ void ApiClient::deleteFace(const QString &studentId, bool deleteRecords) }); } +// 函数作用:请求服务端健康检查接口,判断当前连接是否可用。 void ApiClient::checkHealth() { + // 健康检查使用最轻量的系统接口,供启动同步、手动测试和自动重连共用。 QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/system/health"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { + // 健康检查只通过 healthChecked 返回结果,避免普通 networkError toast 反复刷屏。 emit healthChecked(false, reply->errorString()); reply->deleteLater(); return; diff --git a/src/ApiClient.h b/src/ApiClient.h index 84079db..ff83d9d 100644 --- a/src/ApiClient.h +++ b/src/ApiClient.h @@ -13,65 +13,123 @@ #include "StudentProfileListModel.h" #include "FaceListModel.h" +// ApiClient 封装管理端与服务端之间的 HTTP 通信。 +// 该类只负责“请求、解密、解析、通过信号返回结果”,不直接修改界面模型; +// AppController 监听这些信号后再决定如何更新缓存、筛选和 QML 状态。 class ApiClient : public QObject { Q_OBJECT public: + // 函数作用:创建网络客户端并初始化默认服务器地址、访问令牌和网络管理器连接。 explicit ApiClient(QObject *parent = nullptr); + // 函数作用:设置服务端基础地址,后续所有接口请求都会基于该地址拼接路径。 void setServerUrl(const QString &url); + // 函数作用:返回当前配置的服务端基础地址,供控制器和界面显示使用。 QString serverUrl() const; + // 函数作用:设置服务端接口访问令牌,后续请求会写入认证请求头。 void setApiToken(const QString &token); + // 函数作用:返回当前保存的接口访问令牌,便于界面配置页回显。 QString apiToken() const; + // 增量同步接口:lastVersion 为本地已知版本,服务端返回 changed=false 时无需重新拉取所有数据。 + // 函数作用:请求服务端同步状态,判断本地数据是否落后于服务端版本。 void fetchSyncState(qulonglong lastVersion); + // 函数作用:分页拉取考勤记录列表,用于记录表格刷新和导出数据准备。 void fetchRecords(int limit = 1000, int offset = 0); + // 函数作用:拉取服务端统计摘要,包括总记录数、今日记录数和服务器时间。 void fetchStatistics(); + // 函数作用:请求服务端清空全部考勤记录,并通过信号返回操作结果。 void clearAllRecords(); + // 函数作用:按时间戳和学号删除指定考勤记录,避免误删其他同名记录。 void deleteRecord(const QString ×tamp, const QString &studentId); + // 函数作用:从服务端拉取考勤时间段配置,供本地模型和界面编辑使用。 void fetchTimeSlots(); + // 函数作用:上传当前时间段配置到服务端,保持 STM32、K210 与管理端一致。 void uploadTimeSlots(const QVector &timeSlots); + // 函数作用:从服务端拉取学生档案列表,作为姓名、班级和人脸绑定的基础数据。 void fetchStudents(); + // 函数作用:把管理端编辑后的学生档案批量上传到服务端。 void uploadStudents(const QVector &students); + // 函数作用:从服务端拉取班级编号到班级名称的映射表。 void fetchClasses(); + // 函数作用:把管理端维护的班级映射表上传到服务端。 void uploadClasses(const QMap &classNameMap); + // 函数作用:从服务端拉取人脸档案绑定关系,用于管理端人脸列表展示。 void fetchFaceProfiles(); + // 函数作用:上传人脸编号与学生档案之间的绑定关系。 void uploadFaceProfiles(const QVector &faces); + // 函数作用:获取服务端已保存的人脸照片编号列表,用于判断本地照片缓存是否完整。 void fetchFacePhotos(); + // 函数作用:按人脸编号下载单张人脸照片,成功后由 AppController 写入本地缓存。 void fetchFacePhoto(const QString &faceId); + // 函数作用:删除指定学生绑定的人脸档案,并可选择同步删除相关考勤记录。 void deleteFace(const QString &studentId, bool deleteRecords); + // 函数作用:调用健康检查接口验证服务端是否可连接、数据库是否正常。 void checkHealth(); signals: + // 所有网络结果都以信号形式返回,便于 AppController 在主线程中统一更新模型和状态提示。 + // 函数作用:通知考勤记录列表已拉取完成,并携带解析后的强类型记录集合。 void recordsFetched(const QVector &records); + // 函数作用:通知统计摘要已拉取完成,包含总数、今日数量和服务端时间。 void statisticsFetched(int total, int today, const QString &serverTime); + // 函数作用:通知清空记录接口执行结果,携带成功标记和提示信息。 void recordsCleared(bool success, const QString &message); + // 函数作用:通知单条记录删除结果,供界面刷新列表和显示状态提示。 void recordDeleted(bool success, const QString &message); + // 函数作用:通知时间段配置已从服务端获取完成。 void timeSlotsFetched(const QVector &timeSlots); + // 函数作用:通知时间段配置上传结果。 void timeSlotsUploaded(bool success, const QString &message); + // 函数作用:通知学生档案列表已拉取完成。 void studentsFetched(const QVector &students); + // 函数作用:通知学生档案上传结果。 void studentsUploaded(bool success, const QString &message); + // 函数作用:通知班级映射表已拉取完成。 void classesFetched(const QMap &classNameMap); + // 函数作用:通知班级映射表上传结果。 void classesUploaded(bool success, const QString &message); + // 函数作用:通知人脸档案绑定关系已拉取完成。 void faceProfilesFetched(const QVector &faces); + // 函数作用:通知人脸档案绑定关系上传结果。 void faceProfilesUploaded(bool success, const QString &message); + // 函数作用:通知服务端人脸照片编号列表已拉取完成。 void facePhotosFetched(const QStringList &photoIds); + // 函数作用:通知单张人脸照片下载结果,并携带照片二进制数据。 void facePhotoFetched(const QString &faceId, const QByteArray &data, bool success, const QString &message); + // 函数作用:通知人脸删除接口结果,并返回被删除学生及关联记录数量。 void faceDeleted(bool success, const QString &studentId, int recordsDeleted, const QString &message); + // 函数作用:通知健康检查结果,用于界面连接状态和自动重连逻辑。 void healthChecked(bool healthy, const QString &message); + // 函数作用:通知当前服务不可连接,便于控制器进入重连流程。 void connectionUnavailable(const QString &message); + // 函数作用:通知一般网络错误信息,供界面状态栏展示。 void networkError(const QString &message); + // 函数作用:通知同步版本检查结果,changed 表示服务端数据是否发生变化。 void syncStateFetched(qulonglong version, bool changed); private: + // buildUrl 会统一拼接 /api 前缀并编码查询参数,避免各接口重复手写 URL。 + // 函数作用:根据接口路径和查询参数生成完整请求 URL。 QString buildUrl(const QString &endpoint, const QMap ¶ms = {}) const; + + // createRequest 负责设置 Token、JSON 接收类型、加密标记和超时时间。 + // jsonBody=true 时请求体会按服务端约定使用 AES128-CBC 加密后上传。 + // 函数作用:创建统一格式的网络请求对象,写入认证、内容类型和加密相关请求头。 QNetworkRequest createRequest(const QString &url, bool encryptedResponse = false, bool jsonBody = false) const; + // 函数作用:把服务端返回的 JSON 记录数组解析为考勤记录结构体列表。 QVector parseRecords(const QByteArray &data) const; + // 函数作用:根据响应是否加密决定直接返回原文或执行 AES 解密。 QByteArray maybeDecrypt(const QByteArray &data, bool encrypted) const; + // 函数作用:读取网络响应体,并按响应头约定完成可选解密。 QByteArray decryptedReplyBody(QNetworkReply *reply) const; + // 函数作用:把 JSON 请求体加密并编码为服务端可接收的 Base64 文本。 QByteArray encryptedJsonBody(const QByteArray &body) const; + // 函数作用:统一整理网络错误信息,并区分连接失败与普通接口错误。 void emitNetworkError(const QString &prefix, QNetworkReply *reply); + // 函数作用:判断 Qt 网络错误码是否属于服务器不可达或连接中断类型。 bool isConnectionError(QNetworkReply::NetworkError error) const; QNetworkAccessManager m_networkManager; diff --git a/src/AppController.cpp b/src/AppController.cpp index aa9e81f..ac7f327 100644 --- a/src/AppController.cpp +++ b/src/AppController.cpp @@ -19,12 +19,16 @@ namespace { constexpr int ReconnectIntervalMs = 5000; +// 函数作用:将 QML 传入的日期字符串安全转换为 QDate,解析失败时使用调用方给出的默认值。 QDate parseDate(const QString &value, const QDate &fallback) { const QDate date = QDate::fromString(value, Qt::ISODate); return date.isValid() ? date : fallback; } +// 函数作用:判断一条考勤状态是否代表签到成功。 +// 服务端、K210 或历史数据可能用不同文本表达“签到成功”,这里统一归一化, +// 后续表格状态、统计颜色和导出结果都依赖这个判断。 bool isSuccessStatus(const QString &status) { const QString normalized = status.trimmed().toLower(); @@ -45,6 +49,9 @@ bool isSuccessStatus(const QString &status) || normalized == QStringLiteral("正常"); } +// 函数作用:按 CSV 规范转义单元格内容。 +// CSV 单元格需要处理逗号、双引号和换行。这里按标准 CSV 规则转义, +// 避免学生姓名或状态文本中含特殊字符时导出的表格错列。 QString csvCell(const QString &value) { QString escaped = value; @@ -56,6 +63,8 @@ QString csvCell(const QString &value) return needsQuotes ? QStringLiteral("\"%1\"").arg(escaped) : escaped; } +// 函数作用:把底层网络错误转换成适合界面展示的简短状态文本。 +// 网络库返回的错误信息通常很长且偏底层,界面只显示用户能理解的短提示。 QString conciseStatusMessage(const QString &message) { if (message.contains(QStringLiteral("Error transferring"), Qt::CaseInsensitive) @@ -68,6 +77,8 @@ QString conciseStatusMessage(const QString &message) return message; } +// 函数作用:把任意字符串转换成本地文件名可安全使用的形式。 +// 人脸编号会作为本地照片缓存文件名,必须替换掉路径分隔符等非法字符。 QString safeFileName(QString value) { value = value.trimmed(); @@ -80,6 +91,8 @@ QString safeFileName(QString value) return value.isEmpty() ? QStringLiteral("unknown") : value; } +// 函数作用:把班级编号和班级名称组合成下拉框显示文本。 +// 班级下拉框使用“编号 - 名称”的形式显示,同时保留编号作为业务主键。 QString displayClassOption(const QString &classId, const QString &className) { const QString trimmedId = classId.trimmed(); @@ -93,6 +106,9 @@ QString displayClassOption(const QString &classId, const QString &className) return QStringLiteral("%1 - %2").arg(trimmedId, trimmedName); } +// 函数作用:返回旧版本曾使用过的默认时间段配置,用于兼容升级判断。 +// 早期版本内置了默认时间段;现在默认配置交给用户和服务端维护。 +// 保留该函数用于识别并清理旧缓存,避免升级后界面继续显示历史默认值。 QVector legacyDefaultTimeSlots() { return { @@ -103,6 +119,7 @@ QVector legacyDefaultTimeSlots() }; } +// 函数作用:判断当前时间段配置是否等于旧版本默认配置。 bool isLegacyDefaultTimeSlots(const QVector &timeSlots) { const QVector legacy = legacyDefaultTimeSlots(); @@ -124,9 +141,11 @@ bool isLegacyDefaultTimeSlots(const QVector &timeSlots) } +// 函数作用:初始化控制器、恢复本地缓存,并建立网络信号与界面模型之间的连接。 AppController::AppController(QObject *parent) : QObject(parent) { + // 启动时先恢复本地缓存,使界面即使离线也能显示上次的配置和档案。 loadLocalState(); m_apiClient.setServerUrl(m_serverUrl); m_apiClient.setApiToken(m_apiToken); @@ -136,6 +155,8 @@ AppController::AppController(QObject *parent) runReconnectCheck(); }); + // 班级名称变化会影响多个派生视图:学生档案列表、人脸列表、记录筛选和统计。 + // 因此监听模型变化后统一刷新这些只读展示数据。 auto refreshDerivedMappingViews = [this]() { rebuildStudentProfiles(); mergeFaceItems(); @@ -148,6 +169,7 @@ AppController::AppController(QObject *parent) 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(); @@ -163,6 +185,7 @@ AppController::AppController(QObject *parent) emit summaryChanged(); }); + // 同步状态接口可能一次性返回多类数据。version 用于后续增量同步判断。 connect(&m_apiClient, &ApiClient::syncStateFetched, this, [this](qulonglong version, bool changed) { if (version > 0) { m_qtSyncVersion = version; @@ -188,6 +211,7 @@ AppController::AppController(QObject *parent) connect(&m_apiClient, &ApiClient::timeSlotsFetched, this, [this](const QVector &timeSlots) { m_timeSlotModel.setTimeSlots(timeSlots); + // 服务端配置是当前权威来源,同步成功后立即写入本地缓存,保证下次离线启动也能看到最新配置。 saveTimeSlotsToLocal(); setStatusMessage(timeSlots.isEmpty() ? QStringLiteral("已从服务器同步空时间段配置") @@ -207,12 +231,16 @@ AppController::AppController(QObject *parent) setStatusMessage(QStringLiteral("学生档案已同步")); }); + // 保存映射时按“学生 -> 班级 -> 人脸档案”串行上传。这样服务端在保存人脸绑定时 + // 已经能找到对应学生和班级,避免出现临时外键缺失。 connect(&m_apiClient, &ApiClient::studentsUploaded, this, [this](bool success, const QString &message) { if (m_mappingSaveInProgress) { if (success) { + // 串行保存的第二步:学生表已落库,再保存班级表,避免人脸档案引用尚未存在的学生。 m_apiClient.uploadClasses(m_pendingClassMap); setStatusMessage(QStringLiteral("学生档案已保存,正在保存班级")); } else { + // 任一步失败都要清理 pending 状态,避免下一次普通保存误接着旧批次继续上传。 m_mappingSaveInProgress = false; m_pendingClassMap.clear(); m_pendingFaceItems.clear(); @@ -235,9 +263,11 @@ AppController::AppController(QObject *parent) connect(&m_apiClient, &ApiClient::classesUploaded, this, [this](bool success, const QString &message) { if (m_mappingSaveInProgress) { if (success) { + // 串行保存的最后一步:学生和班级均已保存,再上传人脸绑定关系。 m_apiClient.uploadFaceProfiles(m_pendingFaceItems); setStatusMessage(QStringLiteral("班级配置已保存,正在保存人脸档案")); } else { + // 班级保存失败时停止整条保存链,防止人脸档案绑定到未保存的班级关系上。 m_mappingSaveInProgress = false; m_pendingClassMap.clear(); m_pendingFaceItems.clear(); @@ -261,6 +291,8 @@ AppController::AppController(QObject *parent) setStatusMessage(success ? QStringLiteral("人脸档案已保存") : QStringLiteral("人脸档案保存失败: %1").arg(message)); }); + // 人脸照片体积较大,先拉照片 ID 列表,再逐张下载并缓存到本地文件, + // QML Image 直接加载 file:// URL,避免把二进制数据塞进模型 role。 connect(&m_apiClient, &ApiClient::facePhotosFetched, this, [this](const QStringList &photoIds) { m_facePhotoIds = photoIds; m_facePhotoLocalUrls.clear(); @@ -279,6 +311,7 @@ AppController::AppController(QObject *parent) const QString path = localFacePhotoPath(faceId); QFile file(path); + // 照片二进制落盘后再把 file:// URL 放入模型,QML Image 可以直接异步加载本地文件。 if (!file.open(QIODevice::WriteOnly)) { qWarning() << "[FacePhoto] cache open failed:" << path; return; @@ -302,10 +335,12 @@ AppController::AppController(QObject *parent) } }); + // 健康检查是断线重连的核心入口:恢复后自动拉取全量状态和人脸照片。 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) { @@ -325,22 +360,34 @@ AppController::AppController(QObject *parent) startReconnect(message); }); + // 程序启动即进入重连检查流程。服务器可用时会自动同步,不可用时界面显示离线提示。 m_reconnecting = true; emit reconnectStateChanged(); runReconnectCheck(); } +// 函数作用:返回考勤记录列表模型,供 QML 记录页绑定。 RecordListModel *AppController::recordModel() { return &m_recordModel; } +// 函数作用:返回统计结果列表模型,供 QML 统计页绑定。 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; @@ -353,27 +400,43 @@ QStringList AppController::classSelectionOptions() const } return options; } +// 函数作用:返回当前服务端地址。 QString AppController::serverUrl() const { return m_serverUrl; } +// 函数作用:返回当前 API Token。 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") @@ -381,6 +444,7 @@ QString AppController::statisticsRangeText() const .arg(m_statisticsEndDate.toString("yyyy-MM-dd")); } +// 函数作用:更新服务器地址,保存配置并触发连接检查。 void AppController::setServerUrl(const QString &url) { const QString trimmed = url.trimmed(); @@ -400,6 +464,7 @@ void AppController::setServerUrl(const QString &url) runReconnectCheck(); } +// 函数作用:更新 API Token,保存配置并触发连接检查。 void AppController::setApiToken(const QString &token) { const QString trimmed = token.trimmed(); @@ -418,12 +483,14 @@ void AppController::setApiToken(const QString &token) runReconnectCheck(); } +// 函数作用:同时保存服务器地址和 Token,并在配置变化后检查连接。 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; @@ -433,6 +500,7 @@ void AppController::saveServerConfig(const QString &url, const QString &token) changed = true; } + // Token 可以单独更新;为空时保持旧值,避免用户误清空输入框导致无法访问服务端。 if (!trimmedToken.isEmpty() && trimmedToken != m_apiToken) { m_apiToken = trimmedToken; m_apiClient.setApiToken(trimmedToken); @@ -453,6 +521,7 @@ void AppController::saveServerConfig(const QString &url, const QString &token) runReconnectCheck(); } +// 函数作用:更新学生档案搜索关键字并通知 QML 刷新过滤结果。 void AppController::setStudentProfileSearchText(const QString &text) { const QString trimmed = text.trimmed(); @@ -463,6 +532,7 @@ void AppController::setStudentProfileSearchText(const QString &text) emit studentProfileFilterChanged(); } +// 函数作用:更新学生档案班级筛选条件。 void AppController::setStudentProfileClassFilter(const QString &classFilter) { const QString normalized = classFilter.trimmed().isEmpty() ? QStringLiteral("全部") : classFilter.trimmed(); @@ -473,6 +543,7 @@ void AppController::setStudentProfileClassFilter(const QString &classFilter) emit studentProfileFilterChanged(); } +// 函数作用:更新学生档案人脸绑定状态筛选条件。 void AppController::setStudentProfileBindingFilter(const QString &bindingFilter) { const QString normalized = bindingFilter.trimmed().isEmpty() ? QStringLiteral("全部") : bindingFilter.trimmed(); @@ -483,16 +554,20 @@ void AppController::setStudentProfileBindingFilter(const QString &bindingFilter) emit studentProfileFilterChanged(); } +// 函数作用:强制从服务端同步全部业务状态。 void AppController::refreshAll() { + // 传 0 表示忽略本地版本,强制服务端返回当前完整状态。 m_apiClient.fetchSyncState(0); } +// 函数作用:从服务端重新加载考勤记录。 void AppController::refreshRecords() { m_apiClient.fetchRecords(1000, 0); } +// 函数作用:保存记录页筛选条件,并重建筛选后的记录模型。 void AppController::applyFilters(const QString &classId, const QString &studentId, const QString &nameFilter, @@ -504,9 +579,11 @@ void AppController::applyFilters(const QString &classId, m_nameFilter = nameFilter.trimmed(); m_dateFilterIndex = dateFilterIndex; m_specificDate = parseDate(specificDate, QDate::currentDate()); + // 筛选只影响展示与导出,不改变 m_allRecords 中保存的服务端原始记录。 rebuildFilteredRecords(); } +// 函数作用:更新统计页日期区间,并重新计算统计结果。 void AppController::updateStatisticsRange(const QString &startDate, const QString &endDate) { m_statisticsStartDate = parseDate(startDate, QDate::currentDate().addDays(-30)); @@ -514,6 +591,7 @@ void AppController::updateStatisticsRange(const QString &startDate, const QStrin rebuildStatistics(); } +// 函数作用:将当前筛选后的考勤记录导出为 CSV 文件。 bool AppController::exportFilteredRecords(const QString &fileUrl) { const QString filePath = QUrl(fileUrl).toLocalFile(); @@ -535,6 +613,8 @@ bool AppController::exportFilteredRecords(const QString &fileUrl) out << "# 记录总数: " << m_filteredRecords.size() << "\n"; out << "签到时间,班级编号,班级名称,学号,姓名,置信度,状态\n"; + // 导出使用“有效学号/姓名/班级”,保证用户在管理端修改映射后, + // CSV 与界面中看到的记录保持一致。 for (const AttendanceRecord &record : m_filteredRecords) { out << csvCell(record.timestamp) << "," << csvCell(effectiveClassId(record)) << "," @@ -549,6 +629,7 @@ bool AppController::exportFilteredRecords(const QString &fileUrl) return true; } +// 函数作用:将当前统计结果导出为 CSV 文件。 bool AppController::exportStatistics(const QString &fileUrl) { const QString filePath = QUrl(fileUrl).toLocalFile(); @@ -568,6 +649,7 @@ bool AppController::exportStatistics(const QString &fileUrl) 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()) << "," @@ -582,32 +664,41 @@ bool AppController::exportStatistics(const QString &fileUrl) 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.fetchStudents(); m_apiClient.fetchClasses(); m_apiClient.fetchFaceProfiles(); } +// 函数作用:校验并保存学生档案、班级配置和人脸绑定关系。 void AppController::saveMappings() { QString validationMessage; + // 先校验学生和班级,再把学生档案拆回底层映射表,最后校验人脸绑定。 + // 这样可以及时给出精确错误提示,避免把半成品配置上传到服务端。 if (!validateMappings(&validationMessage)) { setStatusMessage(validationMessage); return; @@ -625,6 +716,8 @@ void AppController::saveMappings() m_mappingSaveInProgress = true; m_pendingClassMap = m_classMappingModel.toMap(); m_pendingFaceItems = m_faceModel.items(); + // pending 数据在三段上传回调之间传递,保证用户点击保存那一刻的配置被完整提交。 + // 上传链路的下一步在 studentsUploaded/classesUploaded 回调中继续。 m_apiClient.uploadStudents(m_studentProfileModel.profiles()); rebuildStudentProfiles(); mergeFaceItems(); @@ -633,12 +726,14 @@ void AppController::saveMappings() rebuildStatistics(); } +// 函数作用:在学生档案列表中新增一条空白编辑行。 void AppController::addStudentProfile() { m_studentProfileModel.addProfile({}, {}, {}); emit studentProfilesChanged(); } +// 函数作用:更新指定学生档案,并在学号变化时同步迁移人脸绑定关系。 void AppController::updateStudentProfile(int index, const QString &studentId, const QString &studentName, @@ -652,6 +747,8 @@ void AppController::updateStudentProfile(int index, 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) { @@ -666,6 +763,7 @@ void AppController::updateStudentProfile(int index, emit studentProfilesChanged(); } +// 函数作用:生成修改学号前需要展示给用户的影响提示。 QString AppController::studentIdChangeImpactMessage(int index, const QString &newStudentId) const { const QVector profiles = m_studentProfileModel.profiles(); @@ -683,6 +781,7 @@ QString AppController::studentIdChangeImpactMessage(int index, const QString &ne .arg(oldStudentId, trimmedNewId); } +// 函数作用:删除指定学生档案,并清理其关联的人脸绑定。 void AppController::removeStudentProfile(int index) { const QVector profiles = m_studentProfileModel.profiles(); @@ -706,6 +805,7 @@ void AppController::removeStudentProfile(int index) emit studentProfilesChanged(); } +// 函数作用:删除指定班级映射,并阻止删除仍被学生引用的班级。 bool AppController::removeClassMapping(int index) { const QModelIndex modelIndex = m_classMappingModel.index(index); @@ -716,6 +816,7 @@ bool AppController::removeClassMapping(int index) const QString classId = m_classMappingModel.data(modelIndex, MappingListModel::KeyRole).toString().trimmed(); const QString normalizedClassId = classId.isEmpty() ? QStringLiteral("0") : classId; int studentCount = 0; + // 班级仍被学生引用时不允许删除,避免学生档案出现悬空班级编号。 const QVector profiles = m_studentProfileModel.profiles(); for (const StudentProfileListModel::Item &profile : profiles) { if (profile.classId.trimmed() == normalizedClassId) { @@ -732,6 +833,7 @@ bool AppController::removeClassMapping(int index) return true; } +// 函数作用:根据班级编号生成界面显示文本。 QString AppController::classDisplayForId(const QString &classId) const { const QString trimmed = classId.trimmed(); @@ -741,6 +843,7 @@ QString AppController::classDisplayForId(const QString &classId) const return displayClassOption(trimmed, m_classMappingModel.toMap().value(trimmed)); } +// 函数作用:从“编号 - 名称”显示文本中提取真实班级编号。 QString AppController::classIdFromDisplay(const QString &display) const { const QString trimmed = display.trimmed(); @@ -748,12 +851,15 @@ QString AppController::classIdFromDisplay(const QString &display) const return separator > 0 ? trimmed.left(separator).trimmed() : trimmed; } +// 函数作用:重新拉取人脸档案和人脸照片列表。 void AppController::refreshFaces() { + // 人脸档案和照片是两个接口:档案提供绑定关系,照片列表提供可展示的图片。 m_apiClient.fetchFaceProfiles(); m_apiClient.fetchFacePhotos(); } +// 函数作用:编辑某个人脸档案的学生绑定信息,并同步更新学生档案。 bool AppController::editFaceProfile(const QString &faceId, const QString &studentId, const QString &studentName, @@ -780,6 +886,7 @@ bool AppController::editFaceProfile(const QString &faceId, const QString oldStudentId = m_faceIdMapping.value(trimmedFaceId).trimmed(); if (trimmedStudentId.isEmpty()) { + // 清空学号表示解除该人脸绑定,但仍保留服务端的人脸模板和照片。 m_faceIdMapping.remove(trimmedFaceId); } else { m_faceIdMapping[trimmedFaceId] = trimmedStudentId; @@ -787,6 +894,8 @@ bool AppController::editFaceProfile(const QString &faceId, QVector profiles = m_studentProfileModel.profiles(); int existingIndex = -1; int oldStudentIndex = -1; + // 编辑人脸时允许顺手补全学生档案:如果新学号不存在则新增, + // 如果只是把旧学号改为新学号则更新原档案行。 for (int i = 0; i < profiles.size(); ++i) { if (profiles.at(i).studentId.trimmed() == trimmedStudentId) { existingIndex = i; @@ -804,9 +913,11 @@ bool AppController::editFaceProfile(const QString &faceId, m_studentProfileModel.addProfile(trimmedStudentId, trimmedStudentName, trimmedClassId); } + // 人脸编辑窗口可能同步改了姓名或班级,保存前先把综合档案拆回底层映射表。 applyStudentProfilesToMappings(); } + // 重新生成所有派生视图,让记录列表、统计页和人脸卡片立即反映新的绑定关系。 mergeFaceItems(); rebuildStudentProfiles(); updateFilterOptions(); @@ -817,6 +928,7 @@ bool AppController::editFaceProfile(const QString &faceId, return true; } +// 函数作用:快速更新人脸列表中某一行的绑定学号。 void AppController::updateFaceStudentId(int index, const QString &studentId) { const QVector items = m_faceModel.items(); @@ -839,24 +951,29 @@ void AppController::updateFaceStudentId(int index, const QString &studentId) rebuildStudentProfiles(); } +// 函数作用:保存当前人脸绑定关系。 void AppController::saveFaceMappings() { saveMappings(); } +// 函数作用:请求服务端删除指定学生对应的人脸数据,可选择同时删除考勤记录。 void AppController::deleteFace(const QString &studentId, bool deleteRecords) { m_apiClient.deleteFace(studentId, deleteRecords); } +// 函数作用:手动发起服务器健康检查。 void AppController::testConnection() { setStatusMessage("正在检查服务器连接"); m_apiClient.checkHealth(); } +// 函数作用:加载本地保存的服务器配置、映射缓存和时间段配置。 void AppController::loadLocalState() { + // QSettings 使用平台默认位置,适合保存少量用户配置。 QSettings settings; m_serverUrl = settings.value("server/url", "http://27.106.113.205:8082").toString(); m_apiToken = settings.value("server/apiToken", @@ -867,18 +984,21 @@ void AppController::loadLocalState() loadTimeSlotsFromLocal(); } +// 函数作用:把当前服务器地址写入 QSettings。 void AppController::saveServerUrl() { QSettings settings; settings.setValue("server/url", m_serverUrl); } +// 函数作用:把当前 API Token 写入 QSettings。 void AppController::saveApiToken() { QSettings settings; settings.setValue("server/apiToken", m_apiToken); } +// 函数作用:把学生、班级和学生班级映射写入本地 JSON 缓存。 void AppController::saveMappingsToLocal() { const QString path = localSettingsPath("profiles_cache.json"); @@ -889,6 +1009,7 @@ void AppController::saveMappingsToLocal() QJsonObject root; QJsonObject studentObject; + // 本地缓存保存为三个独立映射,方便离线启动时快速恢复学生、班级和归属关系。 const QMap studentMap = m_studentMappingModel.toMap(); for (auto it = studentMap.begin(); it != studentMap.end(); ++it) { studentObject[it.key()] = it.value(); @@ -909,9 +1030,11 @@ void AppController::saveMappingsToLocal() root["students"] = studentObject; root["student_classes"] = studentClassObject; root["classes"] = classObject; + // 使用缩进格式写入,便于毕业设计调试时直接打开缓存文件检查内容。 file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); } +// 函数作用:从本地 JSON 缓存读取学生、班级和学生班级映射。 void AppController::loadMappingsFromLocal() { QFile file(localSettingsPath("profiles_cache.json")); @@ -925,6 +1048,7 @@ void AppController::loadMappingsFromLocal() const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); QMap studentMap; const QJsonObject studentObject = root.value("students").toObject(); + // 三个 JSON object 分别恢复到三个模型,后续再由 rebuildStudentProfiles 合成综合档案。 for (auto it = studentObject.begin(); it != studentObject.end(); ++it) { studentMap[it.key()] = it.value().toString(); } @@ -947,6 +1071,7 @@ void AppController::loadMappingsFromLocal() rebuildStudentProfiles(); } +// 函数作用:把当前时间段配置写入本地 JSON 缓存。 void AppController::saveTimeSlotsToLocal() { QFile file(localSettingsPath("timeslot_config.json")); @@ -969,6 +1094,7 @@ void AppController::saveTimeSlotsToLocal() file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); } +// 函数作用:从本地 JSON 缓存读取时间段配置。 void AppController::loadTimeSlotsFromLocal() { QFile file(localSettingsPath("timeslot_config.json")); @@ -990,6 +1116,7 @@ void AppController::loadTimeSlotsFromLocal() } if (isLegacyDefaultTimeSlots(timeSlots)) { + // 旧版默认时间段不再作为有效配置保留,清空后让用户或服务端重新下发。 timeSlots.clear(); } @@ -999,6 +1126,7 @@ void AppController::loadTimeSlotsFromLocal() } } +// 函数作用:生成应用数据目录下指定配置文件的完整路径。 QString AppController::localSettingsPath(const QString &fileName) const { const QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); @@ -1006,6 +1134,7 @@ QString AppController::localSettingsPath(const QString &fileName) const return dirPath + "/" + fileName; } +// 函数作用:生成指定人脸照片在本地缓存中的完整文件路径。 QString AppController::localFacePhotoPath(const QString &faceId) const { const QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/face_photos"; @@ -1013,11 +1142,13 @@ QString AppController::localFacePhotoPath(const QString &faceId) const return dirPath + "/" + safeFileName(faceId) + ".jpg"; } +// 函数作用:根据当前记录和映射表重建筛选栏选项。 void AppController::updateFilterOptions() { QSet classIds; QSet studentIds; + // 选项来源于当前记录的“有效学号/班级”,这样修改人脸绑定或学生班级后筛选栏也会跟着变化。 for (const AttendanceRecord &record : m_allRecords) { classIds.insert(effectiveClassId(record)); studentIds.insert(effectiveStudentId(record)); @@ -1036,6 +1167,7 @@ void AppController::updateFilterOptions() emit filtersChanged(); } +// 函数作用:应用服务端返回的学生档案数据。 void AppController::applyFetchedStudents(const QVector &students) { QMap studentNameMap; @@ -1051,6 +1183,7 @@ void AppController::applyFetchedStudents(const QVector &classNameMap) { m_classMappingModel.setMap(classNameMap); @@ -1069,11 +1203,14 @@ void AppController::applyFetchedClasses(const QMap &classNameM mergeFaceItems(); } +// 函数作用:应用服务端返回的人脸档案绑定数据。 void AppController::applyFetchedFaceProfiles(const QVector &faces) { QMap mapping; const QMap studentNameMap = m_studentMappingModel.toMap(); QStringList faceIds; + // 当学生表已经从服务端加载过时,只接受能匹配到学生档案的人脸绑定。 + // 这样可以防止服务端残留的旧学号污染管理端显示。 for (const FaceListModel::Item &face : faces) { const QString faceId = face.faceId.trimmed(); if (faceId.isEmpty()) { @@ -1093,6 +1230,7 @@ void AppController::applyFetchedFaceProfiles(const QVector rebuildStudentProfiles(); } +// 函数作用:根据底层映射表重建学生档案综合列表。 void AppController::rebuildStudentProfiles() { const QMap studentNameMap = m_studentMappingModel.toMap(); @@ -1100,6 +1238,7 @@ void AppController::rebuildStudentProfiles() const QMap classNameMap = m_classMappingModel.toMap(); QSet studentIds; + // 学生档案以学生姓名表为主,班级表只补充已存在学生的班级信息。 for (auto it = studentNameMap.cbegin(); it != studentNameMap.cend(); ++it) { studentIds.insert(it.key()); } @@ -1110,6 +1249,7 @@ void AppController::rebuildStudentProfiles() } QStringList sortedIds = studentIds.values(); + // 学号如果是纯数字就按数值排序,避免 10 排在 2 前面;混合编号则退回字符串排序。 std::sort(sortedIds.begin(), sortedIds.end(), [](const QString &left, const QString &right) { bool leftNumeric = false; bool rightNumeric = false; @@ -1123,6 +1263,7 @@ void AppController::rebuildStudentProfiles() QVector items; items.reserve(sortedIds.size()); + // 这里补齐班级名称、人脸绑定文本和绑定状态,QML 只需按 role 展示,不再做业务拼装。 for (const QString &studentId : sortedIds) { const QString classId = studentClassMap.value(studentId); const QString className = classNameMap.value(classId, classId); @@ -1143,11 +1284,13 @@ void AppController::rebuildStudentProfiles() 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()) { @@ -1161,9 +1304,11 @@ void AppController::applyStudentProfilesToMappings() 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) { @@ -1175,6 +1320,8 @@ bool AppController::validateMappings(QString *message) const 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); @@ -1209,11 +1356,13 @@ bool AppController::validateMappings(QString *message) const 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(); @@ -1244,6 +1393,7 @@ bool AppController::validateFaceMappings(QString *message) const return true; } +// 函数作用:返回指定学生已绑定的人脸编号列表文本。 QString AppController::faceBindingTextForStudent(const QString &studentId) const { QStringList faceIds; @@ -1257,12 +1407,15 @@ QString AppController::faceBindingTextForStudent(const QString &studentId) const 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) { @@ -1301,6 +1454,7 @@ void AppController::rebuildFilteredRecords() return left.timestamp > right.timestamp; }); + // 将业务记录转换为 QML 表格行,同时生成成功/失败布尔值和映射后的显示文本。 items.reserve(m_filteredRecords.size()); for (const AttendanceRecord &record : m_filteredRecords) { items.push_back({ @@ -1319,11 +1473,15 @@ void AppController::rebuildFilteredRecords() 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) { @@ -1349,10 +1507,12 @@ void AppController::rebuildStatistics() m_statisticsStudentCount = statisticsMap.size(); m_statisticsRecordCount = 0; + // 第二轮把聚合结构转换为 QML 模型行,同时把置信度总和归一化为平均值。 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({ @@ -1373,28 +1533,34 @@ void AppController::rebuildStatistics() emit statisticsChanged(); } +// 函数作用:根据映射表获取记录对应的学生姓名。 QString AppController::mappedStudentName(const AttendanceRecord &record) const { return m_studentMappingModel.toMap().value(effectiveStudentId(record)); } +// 函数作用:把记录中的人脸编号或原始学号转换为最终展示使用的学号。 QString AppController::effectiveStudentId(const AttendanceRecord &record) const { const QString recordStudentId = record.studentId.trimmed(); + // 记录里的 studentId 可能实际是人脸编号;如果存在人脸绑定,则转换为真实学号。 return m_faceIdMapping.value(recordStudentId, recordStudentId).trimmed(); } +// 函数作用:获取记录最终归属的班级编号。 QString AppController::effectiveClassId(const AttendanceRecord &record) const { return m_studentClassMappingModel.toMap().value(effectiveStudentId(record), 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(); @@ -1402,6 +1568,8 @@ void AppController::mergeFaceItems() const QMap classNameMap = m_classMappingModel.toMap(); QSet allIds; + // 人脸列表由“有照片的 ID”和“有绑定关系的 ID”取并集生成。 + // 这样即使某张照片暂未绑定,也能在界面中显示并等待用户处理。 for (const QString &id : m_facePhotoIds) { allIds.insert(id); } @@ -1410,10 +1578,12 @@ void AppController::mergeFaceItems() } QStringList sortedIds = allIds.values(); + // 排序后再生成卡片,保证照片下载完成、绑定变化后列表位置稳定。 std::sort(sortedIds.begin(), sortedIds.end()); QVector items; items.reserve(sortedIds.size()); + // 每张卡片合并照片 URL、绑定学号、学生姓名、班级名称和绑定状态。 for (const QString &id : sortedIds) { const QString photoUrl = m_facePhotoLocalUrls.value(id); const bool hasPhoto = !photoUrl.isEmpty(); @@ -1436,6 +1606,7 @@ void AppController::mergeFaceItems() m_faceModel.setItems(items); } +// 函数作用:更新界面状态提示,并过滤底层冗长网络错误。 void AppController::setStatusMessage(const QString &message) { const QString displayMessage = conciseStatusMessage(message); @@ -1446,6 +1617,7 @@ void AppController::setStatusMessage(const QString &message) emit statusMessageChanged(); } +// 函数作用:进入自动重连状态并安排下一次健康检查。 void AppController::startReconnect(const QString &reason) { Q_UNUSED(reason) @@ -1457,6 +1629,7 @@ void AppController::startReconnect(const QString &reason) } if (m_reconnectCheckInFlight || m_reconnectTimer.isActive()) { + // 已有检查或计时器时不重复发起请求,避免网络异常时堆积大量 health 请求。 setStatusMessage(QStringLiteral("服务器未连接,稍后重试")); return; } @@ -1465,6 +1638,7 @@ void AppController::startReconnect(const QString &reason) scheduleReconnect(); } +// 函数作用:停止自动重连并重置重连状态。 void AppController::stopReconnect() { m_reconnectTimer.stop(); @@ -1478,17 +1652,20 @@ void AppController::stopReconnect() emit reconnectStateChanged(); } +// 函数作用:按固定间隔安排下一次重连检查。 void AppController::scheduleReconnect() { m_reconnectTimer.start(ReconnectIntervalMs); } +// 函数作用:执行一次服务器健康检查,用于自动重连流程。 void AppController::runReconnectCheck() { if (!m_reconnecting || m_reconnectCheckInFlight) { return; } + // 标记 in-flight 后再发起健康检查,回调会在 healthChecked 中清除该标记。 m_reconnectCheckInFlight = true; ++m_reconnectAttempt; emit reconnectStateChanged(); diff --git a/src/AppController.h b/src/AppController.h index 31eb307..963582b 100644 --- a/src/AppController.h +++ b/src/AppController.h @@ -16,9 +16,16 @@ #include "StudentProfileListModel.h" #include "TimeSlotListModel.h" +// AppController 是 Qt 管理端的业务中枢: +// 1. 连接 ApiClient,把服务端数据转换为 QML 可直接绑定的列表模型; +// 2. 保存服务器地址、Token、学生/班级/时间段等本地缓存; +// 3. 维护筛选、统计、导出、人脸绑定和断线重连等界面状态。 class AppController : public QObject { Q_OBJECT + + // 这些 model 指针以 CONSTANT 暴露给 QML,模型对象生命周期跟随 AppController。 + // QML 只负责绑定和调用 Q_INVOKABLE 方法,不直接持有或释放这些 C++ 对象。 Q_PROPERTY(RecordListModel* recordModel READ recordModel CONSTANT) Q_PROPERTY(StatisticsListModel* statisticsModel READ statisticsModel CONSTANT) Q_PROPERTY(TimeSlotListModel* timeSlotModel READ timeSlotModel CONSTANT) @@ -48,120 +55,225 @@ class AppController : public QObject Q_PROPERTY(QString statisticsRangeText READ statisticsRangeText NOTIFY statisticsChanged) public: + // 函数作用:创建管理端业务控制器,加载本地状态并连接网络客户端信号。 explicit AppController(QObject *parent = nullptr); + // 函数作用:返回考勤记录模型指针,供 QML 表格绑定。 RecordListModel *recordModel(); + // 函数作用:返回统计结果模型指针,供 QML 统计页绑定。 StatisticsListModel *statisticsModel(); + // 函数作用:返回时间段配置模型指针,供 QML 配置页编辑。 TimeSlotListModel *timeSlotModel(); + // 函数作用:返回学生档案模型指针,供 QML 学生管理页绑定。 StudentProfileListModel *studentProfileModel(); + // 函数作用:返回学号到姓名映射模型指针,兼容旧版映射配置。 MappingListModel *studentMappingModel(); + // 函数作用:返回学号到班级编号映射模型指针,兼容旧版映射配置。 MappingListModel *studentClassMappingModel(); + // 函数作用:返回班级编号到班级名称映射模型指针。 MappingListModel *classMappingModel(); + // 函数作用:返回人脸档案模型指针,供 QML 人脸管理页绑定。 FaceListModel *faceModel(); + // 函数作用:返回考勤记录筛选栏可选班级列表。 QStringList classOptions() const; + // 函数作用:返回考勤记录筛选栏可选学生列表。 QStringList studentOptions() const; + // 函数作用:返回学生档案编辑时可选择的班级显示列表。 QStringList classSelectionOptions() const; + // 函数作用:返回当前服务端基础地址。 QString serverUrl() const; + // 函数作用:返回当前接口访问令牌。 QString apiToken() const; + // 函数作用:返回学生档案页当前搜索文本。 QString studentProfileSearchText() const; + // 函数作用:返回学生档案页当前班级筛选条件。 QString studentProfileClassFilter() const; + // 函数作用:返回学生档案页当前人脸绑定筛选条件。 QString studentProfileBindingFilter() const; + // 函数作用:返回学生档案页可选班级筛选项。 QStringList studentProfileClassFilterOptions() const; + // 函数作用:返回界面状态栏当前提示信息。 QString statusMessage() const; + // 函数作用:返回当前是否处于自动重连状态。 bool reconnecting() const; + // 函数作用:返回自动重连已尝试次数。 int reconnectAttempt() const; + // 函数作用:返回服务端统计的总考勤记录数。 int totalRecords() const; + // 函数作用:返回服务端统计的今日考勤记录数。 int todayRecords() const; + // 函数作用:返回当前筛选后记录数量。 int filteredCount() const; + // 函数作用:返回已加载到管理端内存中的原始记录数量。 int loadedRecords() const; + // 函数作用:返回当前统计区间内涉及的学生数量。 int statisticsStudentCount() const; + // 函数作用:返回当前统计区间内的考勤记录数量。 int statisticsRecordCount() const; + // 函数作用:返回统计区间的显示文本。 QString statisticsRangeText() const; + // 函数作用:更新服务端基础地址,写入本地配置并触发连接状态刷新。 void setServerUrl(const QString &url); + // 函数作用:更新接口访问令牌,写入本地配置并同步到网络客户端。 void setApiToken(const QString &token); + // 函数作用:更新学生档案页搜索关键词并重新应用档案筛选。 void setStudentProfileSearchText(const QString &text); + // 函数作用:更新学生档案页班级筛选条件并刷新筛选结果。 void setStudentProfileClassFilter(const QString &classFilter); + // 函数作用:更新学生档案页人脸绑定筛选条件并刷新筛选结果。 void setStudentProfileBindingFilter(const QString &bindingFilter); + // 函数作用:刷新所有核心数据,包括同步状态、记录、统计、时间段、学生、班级和人脸档案。 Q_INVOKABLE void refreshAll(); + // 函数作用:单独刷新考勤记录和统计摘要,用于记录页快速更新。 Q_INVOKABLE void refreshRecords(); + + // 供 QML 筛选栏调用:班级、学号、姓名、日期条件都保存在控制器中, + // 每次变化后重新构建 m_filteredRecords 和 RecordListModel。 Q_INVOKABLE void applyFilters(const QString &classId, const QString &studentId, const QString &nameFilter, int dateFilterIndex, const QString &specificDate); + // 函数作用:更新统计分析页的起止日期,并重新计算统计结果。 Q_INVOKABLE void updateStatisticsRange(const QString &startDate, const QString &endDate); + // 函数作用:把当前筛选后的考勤记录导出为 CSV 文件。 Q_INVOKABLE bool exportFilteredRecords(const QString &fileUrl); + // 函数作用:把当前统计分析结果导出为 CSV 文件。 Q_INVOKABLE bool exportStatistics(const QString &fileUrl); + // 函数作用:清空服务端全部考勤记录,并在成功后刷新本地模型。 Q_INVOKABLE void clearAllRecords(); + // 函数作用:保存当前时间段配置到本地缓存并上传服务端。 Q_INVOKABLE void saveTimeSlots(); + // 函数作用:从服务端重新拉取时间段配置并覆盖本地模型。 Q_INVOKABLE void reloadTimeSlotsFromServer(); + // 函数作用:从服务端重新拉取学生、班级和人脸绑定映射。 Q_INVOKABLE void loadMappingsFromServer(); + // 函数作用:校验并保存学生、班级和人脸绑定映射到服务端。 Q_INVOKABLE void saveMappings(); + // 函数作用:在学生档案模型中新增一条空白学生记录。 Q_INVOKABLE void addStudentProfile(); + // 函数作用:按行号更新学生档案的学号、姓名和班级。 Q_INVOKABLE void updateStudentProfile(int index, const QString &studentId, const QString &studentName, const QString &classId); + // 函数作用:生成学号变更影响提示,提醒用户相关人脸绑定和记录映射会被调整。 Q_INVOKABLE QString studentIdChangeImpactMessage(int index, const QString &newStudentId) const; + // 函数作用:按行号删除学生档案,并同步处理相关映射关系。 Q_INVOKABLE void removeStudentProfile(int index); + // 函数作用:删除指定班级映射项,并返回是否删除成功。 Q_INVOKABLE bool removeClassMapping(int index); + // 函数作用:根据班级编号生成界面显示文本。 Q_INVOKABLE QString classDisplayForId(const QString &classId) const; + // 函数作用:从界面显示文本中解析出班级编号。 Q_INVOKABLE QString classIdFromDisplay(const QString &display) const; + // 函数作用:刷新服务端人脸档案和照片状态。 Q_INVOKABLE void refreshFaces(); + // 函数作用:编辑单个人脸档案的绑定学生、姓名和班级信息。 Q_INVOKABLE bool editFaceProfile(const QString &faceId, const QString &studentId, const QString &studentName, const QString &classId); + // 函数作用:按人脸模型行号更新绑定学号。 Q_INVOKABLE void updateFaceStudentId(int index, const QString &studentId); + // 函数作用:保存人脸档案绑定关系到服务端。 Q_INVOKABLE void saveFaceMappings(); + // 函数作用:删除指定学生的人脸档案,并可选择删除该学生考勤记录。 Q_INVOKABLE void deleteFace(const QString &studentId, bool deleteRecords); + // 函数作用:手动触发服务端连接测试。 Q_INVOKABLE void testConnection(); + // 函数作用:保存服务器地址和令牌配置,并立即更新 ApiClient 设置。 Q_INVOKABLE void saveServerConfig(const QString &url, const QString &token); signals: + // 函数作用:通知筛选选项或筛选结果发生变化。 void filtersChanged(); + // 函数作用:通知服务端地址发生变化。 void serverUrlChanged(); + // 函数作用:通知接口访问令牌发生变化。 void apiTokenChanged(); + // 函数作用:通知界面状态提示文本发生变化。 void statusMessageChanged(); + // 函数作用:通知自动重连状态或重连次数发生变化。 void reconnectStateChanged(); + // 函数作用:通知记录总览数字发生变化。 void summaryChanged(); + // 函数作用:通知统计分析结果发生变化。 void statisticsChanged(); + // 函数作用:通知学生档案或班级选择项发生变化。 void studentProfilesChanged(); + // 函数作用:通知学生档案筛选条件发生变化。 void studentProfileFilterChanged(); private: + // 本地状态分为两类: + // - QSettings:服务器地址、Token 等简单配置; + // - AppDataLocation 下的 JSON:学生/班级缓存、时间段缓存、人脸照片缓存。 + // 函数作用:从本地配置和缓存文件恢复管理端启动时需要的状态。 void loadLocalState(); + // 函数作用:把当前服务端地址写入本地持久化配置。 void saveServerUrl(); + // 函数作用:把当前接口令牌写入本地持久化配置。 void saveApiToken(); + // 函数作用:把学生、班级和人脸绑定映射保存到本地缓存文件。 void saveMappingsToLocal(); + // 函数作用:从本地缓存文件读取学生、班级和人脸绑定映射。 void loadMappingsFromLocal(); + // 函数作用:把当前时间段配置保存到本地缓存文件。 void saveTimeSlotsToLocal(); + // 函数作用:从本地缓存文件读取时间段配置。 void loadTimeSlotsFromLocal(); + // 函数作用:生成 AppDataLocation 下指定缓存文件的完整路径。 QString localSettingsPath(const QString &fileName) const; + // 函数作用:根据当前记录和映射表重建班级、学生筛选选项。 void updateFilterOptions(); + // 函数作用:应用服务端拉取的学生档案,并更新相关本地模型。 void applyFetchedStudents(const QVector &students); + // 函数作用:应用服务端拉取的班级映射表,并刷新班级显示选项。 void applyFetchedClasses(const QMap &classNameMap); + // 函数作用:应用服务端拉取的人脸档案,并与本地照片状态合并。 void applyFetchedFaceProfiles(const QVector &faces); + + // 以下 rebuild/merge 方法不会访问网络,只根据当前内存中的记录和映射表重建界面模型。 + // 这样服务端同步、本地编辑、筛选条件变化都可以复用同一套派生数据逻辑。 + // 函数作用:根据学生、班级和人脸绑定数据重建学生档案综合模型。 void rebuildStudentProfiles(); + // 函数作用:把学生档案模型中的修改同步回旧版映射模型。 void applyStudentProfilesToMappings(); + // 函数作用:校验学生和班级映射是否完整、学号是否重复,并返回错误提示。 bool validateMappings(QString *message) const; + // 函数作用:校验人脸绑定关系是否指向有效学生,并返回错误提示。 bool validateFaceMappings(QString *message) const; + // 函数作用:根据学生学号生成人脸绑定状态描述文本。 QString faceBindingTextForStudent(const QString &studentId) const; + // 函数作用:根据当前筛选条件重建考勤记录显示模型。 void rebuildFilteredRecords(); + // 函数作用:根据当前统计日期范围重建统计分析模型。 void rebuildStatistics(); + // 函数作用:计算考勤记录最终使用的学号,兼容人脸编号映射结果。 QString effectiveStudentId(const AttendanceRecord &record) const; + // 函数作用:根据考勤记录和学生映射表推导显示姓名。 QString mappedStudentName(const AttendanceRecord &record) const; + // 函数作用:根据考勤记录和学生班级映射推导最终班级编号。 QString effectiveClassId(const AttendanceRecord &record) const; + // 函数作用:根据班级编号映射表推导班级显示名称。 QString mappedClassName(const AttendanceRecord &record) const; + // 函数作用:生成人脸照片在本地缓存目录中的文件路径。 QString localFacePhotoPath(const QString &faceId) const; + // 函数作用:合并人脸档案、照片列表和学生档案,生成最终人脸模型数据。 void mergeFaceItems(); + // 函数作用:更新状态提示文本并发出变更信号。 void setStatusMessage(const QString &message); + // 函数作用:进入自动重连状态,并记录触发重连的原因。 void startReconnect(const QString &reason); + // 函数作用:停止自动重连状态并复位重连计数。 void stopReconnect(); + // 函数作用:安排下一次重连健康检查。 void scheduleReconnect(); + // 函数作用:执行一次重连健康检查,成功后恢复数据刷新流程。 void runReconnectCheck(); ApiClient m_apiClient; @@ -177,6 +289,9 @@ private: QVector m_allRecords; QVector m_filteredRecords; + + // 当前筛选条件与统计区间。m_allRecords 保存服务端原始记录, + // m_filteredRecords 保存筛选后的结果,用于表格显示和 CSV 导出。 QStringList m_classOptions; QStringList m_studentOptions; QString m_serverUrl; @@ -203,9 +318,14 @@ private: int m_statisticsRecordCount = 0; qulonglong m_qtSyncVersion = 0; + // faceId -> studentId 的绑定表。K210/服务端识别结果可能只携带人脸编号, + // 管理端通过该表把人脸编号映射为学号,再继续推导姓名和班级。 QMap m_faceIdMapping; QHash m_facePhotoLocalUrls; QStringList m_facePhotoIds; + + // 保存学生、班级、人脸档案时需要按服务端接口顺序串行上传, + // 这几个字段记录当前批量保存的中间状态。 bool m_studentsLoadedFromServer = false; bool m_mappingSaveInProgress = false; QMap m_pendingClassMap; diff --git a/src/AttendanceTypes.h b/src/AttendanceTypes.h index 63d2c6f..2c8733d 100644 --- a/src/AttendanceTypes.h +++ b/src/AttendanceTypes.h @@ -7,6 +7,8 @@ #include #include +// 服务端返回的一条原始考勤记录。该结构只保存与业务有关的字段, +// 显示名称、班级名称等派生信息由 AppController 根据映射表实时补齐。 struct AttendanceRecord { QString timestamp; @@ -16,12 +18,16 @@ struct AttendanceRecord QString status; QString classId = "0"; + // 服务端统一使用 "yyyy-MM-dd HH:mm:ss" 字符串,界面筛选和统计时需要转为 QDateTime。 + // 函数作用:把服务端时间字符串转换为 Qt 日期时间对象。 QDateTime dateTime() const { return QDateTime::fromString(timestamp, "yyyy-MM-dd HH:mm:ss"); } }; +// 统计页使用的聚合结果。AppController 会按学号累加签到次数和置信度, +// 最终再转换为 StatisticsListModel::Item 暴露给 QML。 struct StudentStatistics { QString studentId; @@ -32,6 +38,7 @@ struct StudentStatistics double averageConfidence = 0.0; }; +// 签到时间段配置,用于管理端维护“上午签到、下午签退”等时间窗口。 struct TimeSlot { QString name; @@ -40,11 +47,13 @@ struct TimeSlot bool enabled = true; }; +// 函数作用:返回系统默认签到时间段配置。 inline QVector defaultTimeSlots() { return {}; } +// 注册元类型后,QVector / QVector 可以安全地穿过 Qt 信号槽。 Q_DECLARE_METATYPE(AttendanceRecord) Q_DECLARE_METATYPE(StudentStatistics) Q_DECLARE_METATYPE(TimeSlot) diff --git a/src/CryptoUtils.cpp b/src/CryptoUtils.cpp index aa40a66..3ea1972 100644 --- a/src/CryptoUtils.cpp +++ b/src/CryptoUtils.cpp @@ -7,19 +7,24 @@ const uint8_t AesKey[AES_KEYLEN] = { 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c }; +// 固定 IV 与服务端约定保持一致。该方案用于课程设计中的传输混淆, +// 如用于生产环境应改为随机 IV 并随密文一起传输。 const uint8_t AesIv[AES_BLOCKLEN] = { 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f }; +// 函数作用:为明文追加 PKCS#7 填充,使长度满足 AES 块大小要求。 QByteArray addPkcs7Padding(const QByteArray &data) { + // AES-CBC 要求明文长度为 16 字节整数倍,PKCS#7 用填充值表示补齐长度。 const int padding = AES_BLOCKLEN - (data.size() % AES_BLOCKLEN); QByteArray padded = data; padded.append(QByteArray(padding, static_cast(padding))); return padded; } +// 函数作用:验证并移除解密结果中的 PKCS#7 填充。 QByteArray removePkcs7Padding(const QByteArray &data) { if (data.isEmpty()) { @@ -27,6 +32,7 @@ QByteArray removePkcs7Padding(const QByteArray &data) } const int padding = static_cast(data.back()); + // 填充值非法时返回原数据,避免错误密文导致越界或丢失可诊断内容。 if (padding < 1 || padding > AES_BLOCKLEN || padding > data.size()) { return data; } @@ -41,8 +47,10 @@ QByteArray removePkcs7Padding(const QByteArray &data) } } +// 函数作用:使用 AES-128-CBC 加密明文,并输出 Base64 字符串。 QByteArray CryptoUtils::encryptAes128CbcBase64(const QByteArray &plainData) { + // 加密后再 Base64,便于通过 HTTP 请求体和 JSON 调试工具传输二进制密文。 QByteArray buffer = addPkcs7Padding(plainData); AES_ctx ctx; AES_init_ctx_iv(&ctx, AesKey, AesIv); @@ -50,9 +58,11 @@ QByteArray CryptoUtils::encryptAes128CbcBase64(const QByteArray &plainData) return buffer.toBase64(); } +// 函数作用:把 Base64 密文解码后使用 AES-128-CBC 解密。 QByteArray CryptoUtils::decryptAes128CbcBase64(const QByteArray &encryptedBase64) { const QByteArray encrypted = QByteArray::fromBase64(encryptedBase64); + // 密文长度必须是 AES 块大小整数倍,否则直接视为无效数据。 if (encrypted.isEmpty() || (encrypted.size() % AES_BLOCKLEN) != 0) { return {}; } diff --git a/src/CryptoUtils.h b/src/CryptoUtils.h index e89b319..b8b4bf4 100644 --- a/src/CryptoUtils.h +++ b/src/CryptoUtils.h @@ -3,10 +3,14 @@ #include +// 与服务端约定的 AES-128-CBC + Base64 工具类。 +// ApiClient 上传 JSON 前加密,请求返回带 X-Encrypted 头时再解密。 class CryptoUtils { public: + // 函数作用:使用约定密钥和 IV 对明文数据执行 AES-128-CBC 加密,并输出 Base64 字节串。 static QByteArray encryptAes128CbcBase64(const QByteArray &plainData); + // 函数作用:对 Base64 编码的 AES-128-CBC 密文进行解码和解密,恢复原始明文数据。 static QByteArray decryptAes128CbcBase64(const QByteArray &encryptedBase64); }; diff --git a/src/FaceListModel.cpp b/src/FaceListModel.cpp index 4973905..a2417cb 100644 --- a/src/FaceListModel.cpp +++ b/src/FaceListModel.cpp @@ -1,10 +1,12 @@ #include "FaceListModel.h" +// 函数作用:初始化人脸档案列表模型。 FaceListModel::FaceListModel(QObject *parent) : QAbstractListModel(parent) { } +// 函数作用:返回人脸档案行数。 int FaceListModel::rowCount(const QModelIndex &parent) const { if (parent.isValid()) { @@ -13,6 +15,7 @@ int FaceListModel::rowCount(const QModelIndex &parent) const return m_items.size(); } +// 函数作用:根据行号和 role 返回人脸档案字段。 QVariant FaceListModel::data(const QModelIndex &index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { @@ -33,6 +36,7 @@ QVariant FaceListModel::data(const QModelIndex &index, int role) const } } +// 函数作用:修改人脸档案的可编辑字段,目前仅支持绑定学号。 bool FaceListModel::setData(const QModelIndex &index, const QVariant &value, int role) { if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { @@ -40,6 +44,7 @@ bool FaceListModel::setData(const QModelIndex &index, const QVariant &value, int } if (role == StudentIdRole) { + // 人脸列表只允许直接修改绑定学号,姓名、班级、照片等字段由控制器合并生成。 m_items[index.row()].studentId = value.toString().trimmed(); emit dataChanged(index, index, {role}); return true; @@ -48,6 +53,7 @@ bool FaceListModel::setData(const QModelIndex &index, const QVariant &value, int return false; } +// 函数作用:返回人脸档案列表项的可编辑状态标记。 Qt::ItemFlags FaceListModel::flags(const QModelIndex &index) const { if (!index.isValid()) { @@ -56,6 +62,7 @@ Qt::ItemFlags FaceListModel::flags(const QModelIndex &index) const return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; } +// 函数作用:声明人脸档案模型 role 与 QML 字段名之间的映射关系。 QHash FaceListModel::roleNames() const { return { @@ -70,24 +77,29 @@ QHash FaceListModel::roleNames() const }; } +// 函数作用:整体替换人脸档案列表。 void FaceListModel::setItems(const QVector &items) { + // 人脸列表由照片列表和绑定表合并而来,整体替换更符合数据来源的刷新方式。 beginResetModel(); m_items = items; endResetModel(); } +// 函数作用:返回当前人脸档案列表。 QVector FaceListModel::items() const { return m_items; } +// 函数作用:更新指定人脸档案行的绑定学号。 void FaceListModel::updateStudentId(int index, const QString &studentId) { if (index < 0 || index >= m_items.size()) { return; } + // 只通知 StudentIdRole,可减少 QML 委托中无关字段的重新绑定。 m_items[index].studentId = studentId.trimmed(); emit dataChanged(this->index(index), this->index(index), {StudentIdRole}); } diff --git a/src/FaceListModel.h b/src/FaceListModel.h index 2b65e82..895187a 100644 --- a/src/FaceListModel.h +++ b/src/FaceListModel.h @@ -9,11 +9,14 @@ #include #include +// 人脸档案模型。每一行代表服务端保存的一张人脸模板/照片, +// 并附带管理端解析出的学生姓名、班级和绑定状态,供人脸管理页展示和编辑。 class FaceListModel : public QAbstractListModel { Q_OBJECT public: + // StudentIdRole 是当前唯一可编辑 role,其余字段由 AppController 合并照片和学生档案后生成。 enum Roles { FaceIdRole = Qt::UserRole + 1, StudentIdRole, @@ -25,12 +28,18 @@ public: BindingStatusRole }; + // 函数作用:创建人脸列表模型实例,并初始化给 QML 使用的角色结构。 explicit FaceListModel(QObject *parent = nullptr); + // 函数作用:返回模型当前包含的人脸档案行数。 int rowCount(const QModelIndex &parent = QModelIndex()) const override; + // 函数作用:根据行号和角色返回人脸编号、照片、本地路径、学生信息等字段。 QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + // 函数作用:处理界面对可编辑字段的修改,目前主要用于更新人脸绑定的学号。 bool setData(const QModelIndex &index, const QVariant &value, int role) override; + // 函数作用:返回指定行的编辑标志,控制 QML 侧是否允许直接修改绑定字段。 Qt::ItemFlags flags(const QModelIndex &index) const override; + // 函数作用:提供 QML 可识别的 role 名称映射,例如 faceId、studentName、photoUrl。 QHash roleNames() const override; struct Item { @@ -44,8 +53,11 @@ public: QString bindingStatus; }; + // 函数作用:整体替换人脸档案列表,并通知视图刷新全部行。 void setItems(const QVector &items); + // 函数作用:返回当前人脸档案列表的副本,供控制器保存或上传绑定关系。 QVector items() const; + // 函数作用:从 QML 按行号更新人脸绑定的学生学号。 Q_INVOKABLE void updateStudentId(int index, const QString &studentId); private: diff --git a/src/MappingListModel.cpp b/src/MappingListModel.cpp index 1ff47f6..16e7f99 100644 --- a/src/MappingListModel.cpp +++ b/src/MappingListModel.cpp @@ -1,11 +1,13 @@ #include "MappingListModel.h" #include +// 函数作用:初始化通用键值映射列表模型。 MappingListModel::MappingListModel(QObject *parent) : QAbstractListModel(parent) { } +// 函数作用:返回映射条目数量。 int MappingListModel::rowCount(const QModelIndex &parent) const { if (parent.isValid()) { @@ -14,6 +16,7 @@ int MappingListModel::rowCount(const QModelIndex &parent) const return m_items.size(); } +// 函数作用:根据行号和 role 返回映射条目的 key 或 value。 QVariant MappingListModel::data(const QModelIndex &index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { @@ -28,6 +31,7 @@ QVariant MappingListModel::data(const QModelIndex &index, int role) const } } +// 函数作用:修改指定映射条目的 key 或 value。 bool MappingListModel::setData(const QModelIndex &index, const QVariant &value, int role) { if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { @@ -45,6 +49,7 @@ bool MappingListModel::setData(const QModelIndex &index, const QVariant &value, return true; } +// 函数作用:返回映射列表项的可编辑状态标记。 Qt::ItemFlags MappingListModel::flags(const QModelIndex &index) const { if (!index.isValid()) { @@ -53,6 +58,7 @@ Qt::ItemFlags MappingListModel::flags(const QModelIndex &index) const return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; } +// 函数作用:声明映射模型 role 与 QML 字段名之间的映射关系。 QHash MappingListModel::roleNames() const { return { @@ -61,6 +67,7 @@ QHash MappingListModel::roleNames() const }; } +// 函数作用:把 QMap 转换为有序列表并替换当前模型数据。 void MappingListModel::setMap(const QMap &map) { QVector items; @@ -69,6 +76,7 @@ void MappingListModel::setMap(const QMap &map) items.push_back({it.key(), it.value()}); } + // 编号类 key 尽量按数字排序,例如 2 排在 10 前面;非数字 key 再按字符串排序。 std::sort(items.begin(), items.end(), [](const Item &left, const Item &right) { bool leftNumeric = false; bool rightNumeric = false; @@ -85,9 +93,11 @@ void MappingListModel::setMap(const QMap &map) endResetModel(); } +// 函数作用:把当前模型数据转换回 QMap,供保存和同步使用。 QMap MappingListModel::toMap() const { QMap map; + // 空 key 不写入业务映射,避免用户新增的空白编辑行污染配置。 for (const Item &item : m_items) { if (!item.key.trimmed().isEmpty()) { map[item.key.trimmed()] = item.value.trimmed(); @@ -96,6 +106,7 @@ QMap MappingListModel::toMap() const return map; } +// 函数作用:新增一条键值映射。 void MappingListModel::addEntry(const QString &key, const QString &value) { beginInsertRows(QModelIndex(), m_items.size(), m_items.size()); @@ -103,6 +114,7 @@ void MappingListModel::addEntry(const QString &key, const QString &value) endInsertRows(); } +// 函数作用:更新指定索引处的键值映射。 void MappingListModel::updateEntry(int index, const QString &key, const QString &value) { if (index < 0 || index >= m_items.size()) { @@ -113,6 +125,7 @@ void MappingListModel::updateEntry(int index, const QString &key, const QString emit dataChanged(this->index(index), this->index(index)); } +// 函数作用:删除指定索引处的键值映射。 void MappingListModel::removeEntry(int index) { if (index < 0 || index >= m_items.size()) { @@ -124,8 +137,10 @@ void MappingListModel::removeEntry(int index) endRemoveRows(); } +// 函数作用:清空全部键值映射。 void MappingListModel::clear() { + // 清空时使用模型重置,确保所有依赖该模型的 QML 视图立即刷新。 beginResetModel(); m_items.clear(); endResetModel(); diff --git a/src/MappingListModel.h b/src/MappingListModel.h index 2a4a5c7..3288866 100644 --- a/src/MappingListModel.h +++ b/src/MappingListModel.h @@ -10,6 +10,10 @@ #include #include +// 通用键值映射模型。管理端用同一个模型承载: +// - 学号 -> 姓名 +// - 学号 -> 班级编号 +// - 班级编号 -> 班级名称 class MappingListModel : public QAbstractListModel { Q_OBJECT @@ -20,20 +24,33 @@ public: ValueRole }; + // 函数作用:创建键值映射列表模型实例,用于承载学生、班级等映射表。 explicit MappingListModel(QObject *parent = nullptr); + // 函数作用:返回当前映射条目数量。 int rowCount(const QModelIndex &parent = QModelIndex()) const override; + // 函数作用:按行号和角色返回映射键或映射值。 QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + // 函数作用:处理 QML 对映射键和值的编辑并发出数据变化通知。 bool setData(const QModelIndex &index, const QVariant &value, int role) override; + // 函数作用:返回映射条目的可选中、可启用和可编辑标志。 Qt::ItemFlags flags(const QModelIndex &index) const override; + // 函数作用:提供 key、value 两个 QML role 名称。 QHash roleNames() const override; + // setMap/toMap 是模型与业务映射表之间的转换入口,保存和同步前都走这里。 + // 函数作用:用 QMap 批量重建列表模型,并按键排序生成稳定显示顺序。 void setMap(const QMap &map); + // 函数作用:把当前列表内容转换回 QMap,供本地保存和服务端上传。 QMap toMap() const; + // 函数作用:从 QML 新增一条键值映射记录。 Q_INVOKABLE void addEntry(const QString &key, const QString &value); + // 函数作用:从 QML 按行号更新一条映射记录的键和值。 Q_INVOKABLE void updateEntry(int index, const QString &key, const QString &value); + // 函数作用:从 QML 按行号删除一条映射记录。 Q_INVOKABLE void removeEntry(int index); + // 函数作用:清空当前模型中的全部映射记录。 Q_INVOKABLE void clear(); private: diff --git a/src/RecordListModel.cpp b/src/RecordListModel.cpp index 3850932..0b7f5cb 100644 --- a/src/RecordListModel.cpp +++ b/src/RecordListModel.cpp @@ -1,20 +1,25 @@ #include "RecordListModel.h" +// 函数作用:初始化考勤记录列表模型。 RecordListModel::RecordListModel(QObject *parent) : QAbstractListModel(parent) { } +// 函数作用:返回模型中的考勤记录行数。 int RecordListModel::rowCount(const QModelIndex &parent) const { + // ListModel 是一维列表,Qt 传入有效 parent 时代表请求子节点数量,这里始终返回 0。 if (parent.isValid()) { return 0; } return m_items.size(); } +// 函数作用:根据行号和 role 返回 QML 需要显示的记录字段。 QVariant RecordListModel::data(const QModelIndex &index, int role) const { + // QML 在列表刷新或动画过程中可能请求越界索引,直接返回空值可避免崩溃。 if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { return {}; } @@ -27,6 +32,7 @@ QVariant RecordListModel::data(const QModelIndex &index, int role) const case StudentIdRole: return item.studentId; case NameRole: return item.name; case ConfidenceRole: return item.confidence; + // 原始置信度按 0~1 保存,界面显示时转换为百分比文本。 case ConfidenceTextRole: return QString::number(item.confidence * 100.0, 'f', 2) + "%"; case StatusRole: return item.status; case StatusTextRole: return item.success ? QStringLiteral("成功") : QStringLiteral("失败"); @@ -35,6 +41,7 @@ QVariant RecordListModel::data(const QModelIndex &index, int role) const } } +// 函数作用:声明 C++ role 与 QML 字段名之间的映射关系。 QHash RecordListModel::roleNames() const { return { @@ -51,8 +58,10 @@ QHash RecordListModel::roleNames() const }; } +// 函数作用:整体替换考勤记录列表数据并通知 QML 刷新。 void RecordListModel::setItems(const QVector &items) { + // 数据由 AppController 批量重建,重置模型比逐行 diff 更简单,也能确保 QML 完整刷新。 beginResetModel(); m_items = items; endResetModel(); diff --git a/src/RecordListModel.h b/src/RecordListModel.h index d367d5b..21b19fc 100644 --- a/src/RecordListModel.h +++ b/src/RecordListModel.h @@ -9,11 +9,14 @@ #include #include +// 考勤记录表格模型。QML 的 ListView/Table-like 布局通过 role 名称读取每列数据, +// C++ 侧保留强类型 Item,避免界面层直接处理 QVariantMap。 class RecordListModel : public QAbstractListModel { Q_OBJECT public: + // roleNames() 会把这些枚举映射为 timestamp、studentId、confidenceText 等 QML 字段。 enum Roles { TimestampRole = Qt::UserRole + 1, ClassIdRole, @@ -27,12 +30,17 @@ public: SuccessRole }; + // 函数作用:创建考勤记录列表模型实例,并准备给 QML 使用的字段角色。 explicit RecordListModel(QObject *parent = nullptr); + // 函数作用:返回当前显示的考勤记录行数。 int rowCount(const QModelIndex &parent = QModelIndex()) const override; + // 函数作用:根据行号和角色返回考勤时间、学号、姓名、班级、置信度和状态等字段。 QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + // 函数作用:提供 QML 读取考勤记录字段所需的 role 名称映射。 QHash roleNames() const override; + // Item 是已经完成映射后的显示数据:学号、姓名、班级名都由 AppController 处理好。 struct Item { QString timestamp; QString classId; @@ -44,6 +52,7 @@ public: bool success = false; }; + // 函数作用:整体替换当前显示的考勤记录列表,并通知视图刷新。 void setItems(const QVector &items); private: diff --git a/src/StatisticsListModel.cpp b/src/StatisticsListModel.cpp index bd10986..3c0ee70 100644 --- a/src/StatisticsListModel.cpp +++ b/src/StatisticsListModel.cpp @@ -1,20 +1,25 @@ #include "StatisticsListModel.h" +// 函数作用:初始化统计结果列表模型。 StatisticsListModel::StatisticsListModel(QObject *parent) : QAbstractListModel(parent) { } +// 函数作用:返回统计结果行数。 int StatisticsListModel::rowCount(const QModelIndex &parent) const { + // QAbstractListModel 不使用树形子节点,有效 parent 返回 0 是 Qt 模型约定。 if (parent.isValid()) { return 0; } return m_items.size(); } +// 函数作用:根据行号和 role 返回统计页需要显示的字段。 QVariant StatisticsListModel::data(const QModelIndex &index, int role) const { + // 防御式边界检查,避免 QML 在模型重置瞬间读取到无效行。 if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { return {}; } @@ -27,11 +32,13 @@ QVariant StatisticsListModel::data(const QModelIndex &index, int role) const case TodayCountRole: return item.todayCount; case LastCheckInRole: return item.lastCheckIn; case AverageConfidenceRole: return item.averageConfidence; + // 与记录表保持一致:内部保存 0~1,界面显示百分比。 case AverageConfidenceTextRole: return QString::number(item.averageConfidence * 100.0, 'f', 2) + "%"; default: return {}; } } +// 函数作用:声明统计模型 role 与 QML 字段名之间的映射关系。 QHash StatisticsListModel::roleNames() const { return { @@ -45,8 +52,10 @@ QHash StatisticsListModel::roleNames() const }; } +// 函数作用:整体替换统计结果数据并通知 QML 刷新。 void StatisticsListModel::setItems(const QVector &items) { + // 统计结果按日期区间整体重算,因此使用模型重置通知 QML 全量替换。 beginResetModel(); m_items = items; endResetModel(); diff --git a/src/StatisticsListModel.h b/src/StatisticsListModel.h index 0fff392..ffdcff1 100644 --- a/src/StatisticsListModel.h +++ b/src/StatisticsListModel.h @@ -9,11 +9,14 @@ #include #include +// 统计列表模型。每一行对应一个学生在当前统计区间内的汇总结果, +// 用于统计分析页显示签到次数、今日签到、最后签到时间和平均置信度。 class StatisticsListModel : public QAbstractListModel { Q_OBJECT public: + // QML 通过 role 名称读取字段,AverageConfidenceTextRole 专门用于显示百分比文本。 enum Roles { StudentIdRole = Qt::UserRole + 1, NameRole, @@ -24,10 +27,14 @@ public: AverageConfidenceTextRole }; + // 函数作用:创建统计列表模型实例,并初始化统计页所需的角色定义。 explicit StatisticsListModel(QObject *parent = nullptr); + // 函数作用:返回当前统计结果行数。 int rowCount(const QModelIndex &parent = QModelIndex()) const override; + // 函数作用:根据行号和角色返回学生统计数据,例如总次数、今日次数和平均置信度。 QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + // 函数作用:提供 QML 读取统计字段所需的 role 名称映射。 QHash roleNames() const override; struct Item { @@ -39,6 +46,7 @@ public: double averageConfidence = 0.0; }; + // 函数作用:整体替换统计结果列表,并通知界面刷新统计页。 void setItems(const QVector &items); private: diff --git a/src/StudentProfileListModel.cpp b/src/StudentProfileListModel.cpp index a1cc2c8..bed3de6 100644 --- a/src/StudentProfileListModel.cpp +++ b/src/StudentProfileListModel.cpp @@ -1,10 +1,12 @@ #include "StudentProfileListModel.h" +// 函数作用:初始化学生档案列表模型。 StudentProfileListModel::StudentProfileListModel(QObject *parent) : QAbstractListModel(parent) { } +// 函数作用:返回学生档案行数。 int StudentProfileListModel::rowCount(const QModelIndex &parent) const { if (parent.isValid()) { @@ -13,6 +15,7 @@ int StudentProfileListModel::rowCount(const QModelIndex &parent) const return m_items.size(); } +// 函数作用:根据行号和 role 返回学生档案字段。 QVariant StudentProfileListModel::data(const QModelIndex &index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { @@ -31,6 +34,7 @@ QVariant StudentProfileListModel::data(const QModelIndex &index, int role) const } } +// 函数作用:修改指定学生档案字段,并通知 QML 对应行已变化。 bool StudentProfileListModel::setData(const QModelIndex &index, const QVariant &value, int role) { if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { @@ -38,6 +42,7 @@ bool StudentProfileListModel::setData(const QModelIndex &index, const QVariant & } Item &item = m_items[index.row()]; + // 只允许编辑学生档案的基础字段,班级名称和绑定状态由 AppController 重新派生。 switch (role) { case StudentIdRole: item.studentId = value.toString().trimmed(); break; case StudentNameRole: item.studentName = value.toString().trimmed(); break; @@ -49,6 +54,7 @@ bool StudentProfileListModel::setData(const QModelIndex &index, const QVariant & return true; } +// 函数作用:返回学生档案列表项的可编辑状态标记。 Qt::ItemFlags StudentProfileListModel::flags(const QModelIndex &index) const { if (!index.isValid()) { @@ -57,6 +63,7 @@ Qt::ItemFlags StudentProfileListModel::flags(const QModelIndex &index) const return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; } +// 函数作用:声明学生档案模型 role 与 QML 字段名之间的映射关系。 QHash StudentProfileListModel::roleNames() const { return { @@ -69,25 +76,31 @@ QHash StudentProfileListModel::roleNames() const }; } +// 函数作用:整体替换学生档案列表。 void StudentProfileListModel::setProfiles(const QVector &items) { + // 学生档案经常由多个映射表合成,整体替换可以避免中间状态被 QML 看到。 beginResetModel(); m_items = items; endResetModel(); } +// 函数作用:返回当前学生档案列表。 QVector StudentProfileListModel::profiles() const { return m_items; } +// 函数作用:新增一条学生档案。 void StudentProfileListModel::addProfile(const QString &studentId, const QString &studentName, const QString &classId) { + // 新增行只写入基础字段,派生字段留空,保存或重建时再补齐。 beginInsertRows(QModelIndex(), m_items.size(), m_items.size()); m_items.push_back({studentId.trimmed(), studentName.trimmed(), classId.trimmed(), {}, {}, {}}); endInsertRows(); } +// 函数作用:更新指定索引处的学生档案。 void StudentProfileListModel::updateProfile(int index, const QString &studentId, const QString &studentName, @@ -104,6 +117,7 @@ void StudentProfileListModel::updateProfile(int index, emit dataChanged(this->index(index), this->index(index), {StudentIdRole, StudentNameRole, ClassIdRole}); } +// 函数作用:删除指定索引处的学生档案。 void StudentProfileListModel::removeProfile(int index) { if (index < 0 || index >= m_items.size()) { diff --git a/src/StudentProfileListModel.h b/src/StudentProfileListModel.h index 5098bdb..7948492 100644 --- a/src/StudentProfileListModel.h +++ b/src/StudentProfileListModel.h @@ -9,11 +9,14 @@ #include #include +// 学生档案模型。它是“学号-姓名-班级-人脸绑定状态”的综合视图, +// 数据来源可能是服务端学生表,也可能是本地新建或编辑后的临时状态。 class StudentProfileListModel : public QAbstractListModel { Q_OBJECT public: + // FaceIdsRole 和 BindingStatusRole 是派生字段,用于界面快速判断是否已绑定人脸。 enum Roles { StudentIdRole = Qt::UserRole + 1, StudentNameRole, @@ -32,19 +35,30 @@ public: QString bindingStatus; }; + // 函数作用:创建学生档案列表模型实例,并准备 QML 读取和编辑所需的角色。 explicit StudentProfileListModel(QObject *parent = nullptr); + // 函数作用:返回当前学生档案行数。 int rowCount(const QModelIndex &parent = QModelIndex()) const override; + // 函数作用:根据行号和角色返回学号、姓名、班级、人脸编号和绑定状态等字段。 QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + // 函数作用:处理 QML 对学生档案字段的编辑并同步更新模型数据。 bool setData(const QModelIndex &index, const QVariant &value, int role) override; + // 函数作用:返回学生档案行的可选中、可启用和可编辑标志。 Qt::ItemFlags flags(const QModelIndex &index) const override; + // 函数作用:提供 studentId、studentName、classId 等 QML role 名称。 QHash roleNames() const override; + // 函数作用:整体替换学生档案列表,并通知视图刷新全部学生行。 void setProfiles(const QVector &items); + // 函数作用:返回当前学生档案列表副本,供控制器保存、筛选或上传。 QVector profiles() const; + // 函数作用:新增一条学生档案记录。 void addProfile(const QString &studentId, const QString &studentName, const QString &classId); + // 函数作用:按行号更新指定学生档案的学号、姓名和班级编号。 void updateProfile(int index, const QString &studentId, const QString &studentName, const QString &classId); + // 函数作用:按行号删除指定学生档案记录。 void removeProfile(int index); private: diff --git a/src/TimeSlotListModel.cpp b/src/TimeSlotListModel.cpp index 98e7919..8f7db93 100644 --- a/src/TimeSlotListModel.cpp +++ b/src/TimeSlotListModel.cpp @@ -1,11 +1,13 @@ #include "TimeSlotListModel.h" #include +// 函数作用:初始化签到时间段列表模型。 TimeSlotListModel::TimeSlotListModel(QObject *parent) : QAbstractListModel(parent) { } +// 函数作用:返回时间段配置的行数。 int TimeSlotListModel::rowCount(const QModelIndex &parent) const { if (parent.isValid()) { @@ -14,6 +16,7 @@ int TimeSlotListModel::rowCount(const QModelIndex &parent) const return m_timeSlots.size(); } +// 函数作用:根据行号和 role 返回时间段字段。 QVariant TimeSlotListModel::data(const QModelIndex &index, int role) const { if (!index.isValid() || index.row() < 0 || index.row() >= m_timeSlots.size()) { @@ -30,6 +33,7 @@ QVariant TimeSlotListModel::data(const QModelIndex &index, int role) const } } +// 函数作用:修改指定时间段字段,并通知 QML 对应行已变化。 bool TimeSlotListModel::setData(const QModelIndex &index, const QVariant &value, int role) { if (!index.isValid() || index.row() < 0 || index.row() >= m_timeSlots.size()) { @@ -37,6 +41,7 @@ bool TimeSlotListModel::setData(const QModelIndex &index, const QVariant &value, } TimeSlot &slot = m_timeSlots[index.row()]; + // QML 编辑器传入字符串形式的 HH:mm,这里统一转换为 QTime 存储。 switch (role) { case NameRole: slot.name = value.toString(); break; case StartTimeRole: slot.startTime = QTime::fromString(value.toString(), "HH:mm"); break; @@ -49,6 +54,7 @@ bool TimeSlotListModel::setData(const QModelIndex &index, const QVariant &value, return true; } +// 函数作用:返回时间段列表项的可编辑状态标记。 Qt::ItemFlags TimeSlotListModel::flags(const QModelIndex &index) const { if (!index.isValid()) { @@ -57,6 +63,7 @@ Qt::ItemFlags TimeSlotListModel::flags(const QModelIndex &index) const return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; } +// 函数作用:声明时间段模型 role 与 QML 字段名之间的映射关系。 QHash TimeSlotListModel::roleNames() const { return { @@ -67,6 +74,7 @@ QHash TimeSlotListModel::roleNames() const }; } +// 函数作用:整体替换时间段配置。 void TimeSlotListModel::setTimeSlots(const QVector &timeSlots) { beginResetModel(); @@ -74,14 +82,17 @@ void TimeSlotListModel::setTimeSlots(const QVector &timeSlots) endResetModel(); } +// 函数作用:返回当前全部时间段配置。 QVector TimeSlotListModel::timeSlots() const { return m_timeSlots; } +// 函数作用:把当前时间段配置转换为可保存或上传的 JSON 数组。 QJsonArray TimeSlotListModel::toJsonArray() const { QJsonArray array; + // 与服务端接口字段保持一致,避免调用方重复写序列化逻辑。 for (const TimeSlot &slot : m_timeSlots) { QJsonObject object; object["name"] = slot.name; @@ -93,8 +104,10 @@ QJsonArray TimeSlotListModel::toJsonArray() const return array; } +// 函数作用:返回指定时间段行的字段映射,供 QML 编辑弹窗使用。 QVariantMap TimeSlotListModel::get(int index) const { + // QML 弹窗编辑某一行时使用该方法一次性取出当前值。 if (index < 0 || index >= m_timeSlots.size()) { return {}; } @@ -108,13 +121,16 @@ QVariantMap TimeSlotListModel::get(int index) const }; } +// 函数作用:新增一条签到时间段配置。 void TimeSlotListModel::addTimeSlot(const QString &name, const QString &startTime, const QString &endTime, bool enabled) { + // 使用 beginInsertRows/endInsertRows 可以让 QML ListView 产生正确的插入动画和索引更新。 beginInsertRows(QModelIndex(), m_timeSlots.size(), m_timeSlots.size()); m_timeSlots.push_back({name.trimmed(), QTime::fromString(startTime, "HH:mm"), QTime::fromString(endTime, "HH:mm"), enabled}); endInsertRows(); } +// 函数作用:更新指定索引处的签到时间段配置。 void TimeSlotListModel::updateTimeSlot(int index, const QString &name, const QString &startTime, const QString &endTime, bool enabled) { if (index < 0 || index >= m_timeSlots.size()) { @@ -129,6 +145,7 @@ void TimeSlotListModel::updateTimeSlot(int index, const QString &name, const QSt emit dataChanged(this->index(index), this->index(index)); } +// 函数作用:删除指定索引处的签到时间段配置。 void TimeSlotListModel::removeTimeSlot(int index) { if (index < 0 || index >= m_timeSlots.size()) { @@ -140,7 +157,9 @@ void TimeSlotListModel::removeTimeSlot(int index) endRemoveRows(); } +// 函数作用:将时间段配置重置为系统默认值。 void TimeSlotListModel::resetToDefault() { + // 当前版本默认时间段为空,表示完全由用户或服务端配置。 setTimeSlots(defaultTimeSlots()); } diff --git a/src/TimeSlotListModel.h b/src/TimeSlotListModel.h index 1c5e07e..e4444f2 100644 --- a/src/TimeSlotListModel.h +++ b/src/TimeSlotListModel.h @@ -11,6 +11,8 @@ #include #include "AttendanceTypes.h" +// 签到时间段模型。时间段既要在配置中心列表中展示,也要能被 QML 直接增删改, +// 因此这里实现了 setData、flags 和若干 Q_INVOKABLE 辅助方法。 class TimeSlotListModel : public QAbstractListModel { Q_OBJECT @@ -23,22 +25,39 @@ public: EnabledRole }; + // 函数作用:创建时间段列表模型实例,并初始化默认考勤时间段数据结构。 explicit TimeSlotListModel(QObject *parent = nullptr); + // 函数作用:返回当前时间段配置数量。 int rowCount(const QModelIndex &parent = QModelIndex()) const override; + // 函数作用:根据行号和角色返回时间段名称、开始时间、结束时间和启用状态。 QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + // 函数作用:处理 QML 对时间段字段的编辑并触发数据变化通知。 bool setData(const QModelIndex &index, const QVariant &value, int role) override; + // 函数作用:返回时间段行的可选中、可启用和可编辑标志。 Qt::ItemFlags flags(const QModelIndex &index) const override; + // 函数作用:提供 name、startTime、endTime、enabled 等 QML role 名称。 QHash roleNames() const override; + // 函数作用:整体替换当前时间段配置,并通知界面刷新。 void setTimeSlots(const QVector &timeSlots); + // 函数作用:返回当前时间段配置副本,供本地保存和服务端上传。 QVector timeSlots() const; + + // 上传到服务端和写入本地缓存时统一使用该 JSON 结构,字段名与服务端接口保持一致。 + // 函数作用:把当前时间段列表转换成服务端接口约定的 JSON 数组。 QJsonArray toJsonArray() const; + // QML 编辑弹窗通过 get(index) 读取当前行数据,避免在界面层重复拼装 role。 + // 函数作用:按行号返回时间段的 QVariantMap,供 QML 编辑弹窗预填数据。 Q_INVOKABLE QVariantMap get(int index) const; + // 函数作用:从 QML 新增一个考勤时间段配置。 Q_INVOKABLE void addTimeSlot(const QString &name, const QString &startTime, const QString &endTime, bool enabled); + // 函数作用:从 QML 按行号更新时间段名称、起止时间和启用状态。 Q_INVOKABLE void updateTimeSlot(int index, const QString &name, const QString &startTime, const QString &endTime, bool enabled); + // 函数作用:从 QML 按行号删除一个时间段配置。 Q_INVOKABLE void removeTimeSlot(int index); + // 函数作用:恢复默认时间段配置,便于用户快速回到初始考勤规则。 Q_INVOKABLE void resetToDefault(); private: diff --git a/src/crypto/tiny_aes.hpp b/src/crypto/tiny_aes.hpp index 472ca85..d402283 100644 --- a/src/crypto/tiny_aes.hpp +++ b/src/crypto/tiny_aes.hpp @@ -64,6 +64,7 @@ static const uint8_t Rcon[11] = { #define getSBoxValue(num) (sbox[(num)]) #define getSBoxInvert(num) (rsbox[(num)]) +// 函数作用:根据原始AES密钥扩展生成每一轮加解密使用的轮密钥。 inline void KeyExpansion(uint8_t *RoundKey, const uint8_t *Key) { unsigned i, j, k; @@ -105,6 +106,7 @@ inline void KeyExpansion(uint8_t *RoundKey, const uint8_t *Key) } } +// 函数作用:将指定轮次的轮密钥异或到AES状态矩阵中,完成AddRoundKey步骤。 static void AddRoundKey(uint8_t round, uint8_t *state, const uint8_t *RoundKey) { uint8_t i, j; @@ -115,6 +117,7 @@ static void AddRoundKey(uint8_t round, uint8_t *state, const uint8_t *RoundKey) } } +// 函数作用:对AES状态矩阵执行逆S盒替换,用于解密流程中的InvSubBytes步骤。 static void InvSubBytes(uint8_t *state) { for (uint8_t i = 0; i < 16; ++i) { @@ -122,6 +125,7 @@ static void InvSubBytes(uint8_t *state) } } +// 函数作用:对AES状态矩阵执行逆行移位,恢复加密时ShiftRows造成的行循环位移。 static void InvShiftRows(uint8_t *state) { uint8_t temp; @@ -145,11 +149,13 @@ static void InvShiftRows(uint8_t *state) state[15] = temp; } +// 函数作用:在AES有限域中对字节执行乘以x的运算,是列混合乘法的基础操作。 static uint8_t xtime(uint8_t x) { return ((x << 1) ^ (((x >> 7) & 1U) * 0x1bU)); } +// 函数作用:在AES GF(2^8)有限域中完成两个字节的乘法,用于正向和逆向列混合。 static uint8_t Multiply(uint8_t x, uint8_t y) { return (((y & 1U) * x) ^ @@ -159,6 +165,7 @@ static uint8_t Multiply(uint8_t x, uint8_t y) (((y >> 4U) & 1U) * xtime(xtime(xtime(xtime(x)))))); } +// 函数作用:对AES状态矩阵执行逆列混合,用于解密时撤销加密阶段的MixColumns变换。 static void InvMixColumns(uint8_t *state) { int i; @@ -176,6 +183,7 @@ static void InvMixColumns(uint8_t *state) } } +// 函数作用:对AES状态矩阵逐字节执行S盒替换,用于加密流程中的SubBytes步骤。 static void SubBytes(uint8_t *state) { for (uint8_t i = 0; i < 16; ++i) { @@ -183,6 +191,7 @@ static void SubBytes(uint8_t *state) } } +// 函数作用:对AES状态矩阵执行行循环移位,完成加密流程中的ShiftRows步骤。 static void ShiftRows(uint8_t *state) { uint8_t temp; @@ -206,6 +215,7 @@ static void ShiftRows(uint8_t *state) state[3] = temp; } +// 函数作用:对AES状态矩阵执行列混合扩散变换,提高每一轮加密后的字节关联性。 static void MixColumns(uint8_t *state) { for (uint8_t i = 0; i < 4; ++i) { @@ -230,6 +240,7 @@ static void MixColumns(uint8_t *state) } } +// 函数作用:对单个16字节AES状态块执行完整的AES-128加密轮变换。 inline void Cipher(uint8_t *state, const uint8_t *RoundKey) { AddRoundKey(0, state, RoundKey); @@ -246,6 +257,7 @@ inline void Cipher(uint8_t *state, const uint8_t *RoundKey) AddRoundKey(10, state, RoundKey); } +// 函数作用:对单个16字节AES状态块执行完整的AES-128逆向解密轮变换。 inline void InvCipher(uint8_t *state, const uint8_t *RoundKey) { uint8_t round = 0; @@ -262,12 +274,14 @@ inline void InvCipher(uint8_t *state, const uint8_t *RoundKey) } } +// 函数作用:初始化AES CBC上下文,写入扩展轮密钥并保存初始向量IV。 inline void AES_init_ctx_iv(struct AES_ctx *ctx, const uint8_t *key, const uint8_t *iv) { KeyExpansion(ctx->RoundKey, key); memcpy(ctx->Iv, iv, AES_BLOCKLEN); } +// 函数作用:按CBC模式对缓冲区进行AES-128分块加密,并持续更新上下文中的IV。 inline void AES_CBC_encrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t length) { for (size_t i = 0; i < length; i += AES_BLOCKLEN) { @@ -279,6 +293,7 @@ inline void AES_CBC_encrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t len } } +// 函数作用:按CBC模式对缓冲区进行AES-128分块解密,并使用密文块链式更新IV。 inline void AES_CBC_decrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t length) { size_t i;