diff --git a/README.md b/README.md index 072939e..dbb070a 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ Qt 管理端用于连接考勤服务端,完成以下工作: 6. 导出统计 CSV。 7. 配置服务端地址和 API Token。 8. 新增、编辑、删除、清空签到时间段。 -9. 配置学号到姓名、学号到班级编号、班级编号到班级名称的显示映射。 +9. 维护班级信息。 10. 查看 K210 上传的人脸照片。 -11. 为 K210 face_id 绑定真实学号。 +11. 为 K210 face_id 编辑真实学号、姓名和班级。 12. 删除人脸信息,并可选择删除关联考勤记录。 ## 2. 当前重要行为 @@ -60,11 +60,9 @@ API Token 不写入源码。可以通过“配置中心”保存,也可以通 $env:ATTENDANCE_API_TOKEN="你的Token" ``` -### 2.4 响应加密 +### 2.4 加密传输 -服务端支持通过请求头 `X-Encrypted-Response: true` 返回 AES 加密响应,Qt 代码中保留了解密能力。 - -当前普通记录和统计请求没有默认开启这个请求头,实际按 JSON 明文解析。 +除照片 JPEG 下载外,Qt 的业务 JSON 请求和响应统一使用 AES-128-CBC + PKCS#7 + Base64。GET/DELETE 请求会要求服务端返回加密响应,POST JSON 会先加密请求体并携带 `X-Encrypted: AES128-CBC`。 ## 3. 技术栈 @@ -88,7 +86,7 @@ QT/ │ ├── ApiClient.* # HTTP API 客户端 │ ├── AppController.* # 业务逻辑中枢 │ ├── AttendanceTypes.h # 数据结构 -│ ├── CryptoUtils.* # AES 解密工具 +│ ├── CryptoUtils.* # AES 加解密工具 │ └── *ListModel.* # QML 列表模型 ├── CMakeLists.txt ├── clear_qt_cache.ps1 # 清理 Qt Creator/CMake 本地缓存 @@ -156,6 +154,8 @@ powershell -ExecutionPolicy Bypass -File .\clear_qt_cache.ps1 | 保存 | 同时保存服务器地址和 API Token 到 `QSettings`,并切换 `ApiClient` 配置 | | 测试连接 | 使用当前配置请求 `/api/v1/system/health` | +配置中心还包含签到时间段配置和班级管理。班级区域只负责从服务器加载班级、保存班级、新增班级和删除班级;学生学号、姓名和班级归属在人脸管理页点击“编辑”后维护。 + ## 10. 本地数据 Qt 会保存这些本地数据: @@ -165,7 +165,7 @@ Qt 会保存这些本地数据: | 服务端地址 | `QSettings`,键名 `server/url` | | API Token | `QSettings`,键名 `server/apiToken` | | 时间段配置 | `QStandardPaths::AppDataLocation/timeslot_config.json` | -| 学生/班级映射 | `QStandardPaths::AppDataLocation/mapping_config.json` | +| 学生、班级与人脸档案缓存 | `QStandardPaths::AppDataLocation/profiles_cache.json` | | 人脸照片缓存 | `QStandardPaths::AppDataLocation/face_photos/*.jpg` | ## 11. 常见问题 diff --git a/qml/Main.qml b/qml/Main.qml index e2bd743..6a6aa15 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -35,6 +35,12 @@ ApplicationWindow { property string statisticsPreset: "最近30天" property int editingTimeSlotIndex: -1 property string pendingDeleteFaceId: "" + property int pendingStudentIdChangeIndex: -1 + property string pendingStudentIdChangeNewId: "" + property string pendingStudentIdChangeName: "" + property string pendingStudentIdChangeClassDisplay: "" + property string editingFaceId: "" + property string editingFaceOriginalStudentId: "" property string toastMessage: "" property bool toastVisible: false property bool resizingWindow: false @@ -516,6 +522,20 @@ ApplicationWindow { markMappingsDirty(); } + function requestStudentProfileCommit(index, studentId, studentName, classDisplay) { + var impactMessage = appController.studentIdChangeImpactMessage(index, studentId); + if (impactMessage.length > 0) { + pendingStudentIdChangeIndex = index; + pendingStudentIdChangeNewId = studentId; + pendingStudentIdChangeName = studentName; + pendingStudentIdChangeClassDisplay = classDisplay; + studentIdChangeDialog.text = impactMessage; + studentIdChangeDialog.open(); + return; + } + 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); @@ -535,6 +555,39 @@ ApplicationWindow { return haystack.indexOf(normalizedSearch) >= 0; } + function openFaceEditor(faceId, studentId, studentName, classId) { + editingFaceId = faceId; + editingFaceOriginalStudentId = studentId; + faceEditStudentIdField.text = studentId; + faceEditStudentNameField.text = studentName; + faceEditClassCombo.currentIndex = classComboIndex(classId); + faceEditDialog.open(); + } + + function saveFaceEditorNow() { + var savedLocally = appController.editFaceProfile(editingFaceId, + faceEditStudentIdField.text, + faceEditStudentNameField.text, + appController.classIdFromDisplay(faceEditClassCombo.currentText)); + if (!savedLocally) { + faceEditDialog.open(); + return; + } + markMappingsDirty(); + 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; + } + saveFaceEditorNow(); + } + Component.onCompleted: { applyRecordFilters(); applyStatisticsPreset(); @@ -548,7 +601,8 @@ ApplicationWindow { return; } if (appController.statusMessage.indexOf("映射配置已保存") >= 0 - || appController.statusMessage.indexOf("映射配置已同步") >= 0) { + || appController.statusMessage.indexOf("映射配置已同步") >= 0 + || appController.statusMessage.indexOf("人脸档案已保存") >= 0) { mappingsDirty = false; } toastMessage = appController.statusMessage; @@ -572,7 +626,7 @@ ApplicationWindow { id: recordExportDialog fileMode: FileDialog.SaveFile nameFilters: ["CSV 文件 (*.csv)"] - currentFile: "attendance_export.csv" + defaultSuffix: "csv" onAccepted: appController.exportFilteredRecords(selectedFile) } @@ -580,7 +634,7 @@ ApplicationWindow { id: statisticsExportDialog fileMode: FileDialog.SaveFile nameFilters: ["CSV 文件 (*.csv)"] - currentFile: "attendance_statistics.csv" + defaultSuffix: "csv" onAccepted: appController.exportStatistics(selectedFile) } @@ -784,7 +838,7 @@ ApplicationWindow { spacing: 12 Label { - text: "确定删除 ID 为 " + pendingDeleteFaceId + " 的人脸信息吗?" + text: "确定删除人脸编号 " + pendingDeleteFaceId + " 吗?" wrapMode: Text.WordWrap font.pixelSize: 18 font.weight: appBoldWeight @@ -801,6 +855,116 @@ ApplicationWindow { onAccepted: appController.deleteFace(pendingDeleteFaceId, deleteFaceRecordsCheck.checked) } + Dialog { + id: faceEditDialog + modal: true + anchors.centerIn: Overlay.overlay + width: phoneLayout ? Math.min(window.width - 32, 420) : 460 + title: "编辑人脸档案" + standardButtons: Dialog.Save | Dialog.Cancel + + background: Rectangle { + radius: 20 + color: panelStrong + border.color: lineColor + } + + enter: Transition { + ParallelAnimation { + NumberAnimation { + property: "opacity" + from: 0 + to: 1 + duration: 150 + } + NumberAnimation { + property: "scale" + from: 0.96 + to: 1.0 + duration: 180 + easing.type: Easing.OutCubic + } + } + } + + exit: Transition { + ParallelAnimation { + NumberAnimation { + property: "opacity" + from: 1 + to: 0 + duration: 120 + } + NumberAnimation { + property: "scale" + from: 1.0 + to: 0.98 + duration: 120 + } + } + } + + contentItem: ColumnLayout { + spacing: 12 + + Label { + Layout.fillWidth: true + text: "人脸编号: " + editingFaceId + color: ink900 + font.pixelSize: 18 + font.weight: appBoldWeight + elide: Text.ElideRight + } + + AppTextField { + id: faceEditStudentIdField + Layout.fillWidth: true + placeholderText: "真实学号" + } + + AppTextField { + id: faceEditStudentNameField + Layout.fillWidth: true + placeholderText: "姓名" + } + + AppComboBox { + id: faceEditClassCombo + Layout.fillWidth: true + model: appController.classSelectionOptions + } + + Label { + Layout.fillWidth: true + text: "人脸编号由 K210 生成,不要改成学号;这里只编辑学号、姓名和班级。留空学号可解除绑定。" + wrapMode: Text.WordWrap + color: ink500 + font.pixelSize: 12 + } + } + + onAccepted: requestSaveFaceEditor() + } + + MessageDialog { + id: faceStudentIdChangeDialog + title: "确认修改绑定学号" + buttons: MessageDialog.Yes | MessageDialog.No + onAccepted: saveFaceEditorNow() + } + + MessageDialog { + id: studentIdChangeDialog + title: "确认修改学号" + buttons: MessageDialog.Yes | MessageDialog.No + onAccepted: { + commitStudentProfile(pendingStudentIdChangeIndex, + pendingStudentIdChangeNewId, + pendingStudentIdChangeName, + pendingStudentIdChangeClassDisplay); + } + } + Rectangle { anchors.fill: parent anchors.margins: slimLayout ? 8 : 16 @@ -2353,8 +2517,8 @@ ApplicationWindow { SectionHeader { Layout.fillWidth: true - title: "名称映射配置" - description: "让学号和班级编号显示为更清晰的真实名称。" + title: "班级管理" + description: "新建、命名和删除班级;学生归属在人脸管理的编辑窗口中维护。" accent: tealColor } @@ -2367,398 +2531,155 @@ ApplicationWindow { } GridLayout { Layout.fillWidth: true - columns: 2 + columns: phoneLayout ? 1 : 4 columnSpacing: 8 rowSpacing: 8 SecondaryButton { Layout.fillWidth: true visible: mappingsExpanded - text: phoneLayout ? "加载" : "从服务器加载" + text: phoneLayout ? "加载" : "从服务器加载班级" onClicked: appController.loadMappingsFromServer() } PrimaryButton { Layout.fillWidth: true visible: mappingsExpanded - text: "保存映射" + text: "保存班级" fillColor: tealColor onClicked: appController.saveMappings() } + SecondaryButton { + Layout.fillWidth: phoneLayout + Layout.preferredWidth: phoneLayout ? -1 : 120 + visible: mappingsExpanded + text: "新增班级" + onClicked: { + appController.classMappingModel.addEntry("", ""); + markMappingsDirty(); + } + } + Label { + Layout.fillWidth: phoneLayout + Layout.alignment: Qt.AlignVCenter + visible: mappingsExpanded + text: mappingsDirty ? "有未保存改动" : "配置已保存" + color: mappingsDirty ? goldColor : successColor + font.pixelSize: 12 + font.weight: appBoldWeight + } } } - TabBar { - id: mappingTabs + ListView { + id: classMappingListView visible: mappingsExpanded Layout.fillWidth: true - Layout.preferredHeight: visible ? 42 : 0 + Layout.preferredHeight: visible ? Math.max(phoneLayout ? 112 : 64, contentHeight) : 0 + clip: false + interactive: false spacing: 8 - padding: 0 - background: Rectangle { - color: "transparent" - } + model: appController.classMappingModel - SegmentTabButton { - width: (mappingTabs.width - mappingTabs.spacing) / 2 - text: "学生档案" - } - SegmentTabButton { - width: (mappingTabs.width - mappingTabs.spacing) / 2 - text: "班级配置" - } - } + delegate: Rectangle { + width: ListView.view.width + height: phoneLayout ? 112 : 56 + property bool hovered: classMappingHoverHandler.hovered + radius: 8 + color: hovered ? "#eef5f4" : (index % 2 === 0 ? "#ffffff" : "#f8f3ec") + border.color: hovered ? "#c7dddd" : (index % 2 === 0 ? "#ece4da" : "#e7ded3") - StackLayout { - visible: mappingsExpanded - Layout.fillWidth: true - Layout.preferredHeight: visible ? Math.max(phoneLayout ? 168 : 74, mappingTabs.currentIndex === 0 ? studentProfileListView.contentHeight : classMappingListView.contentHeight) : 0 - currentIndex: mappingTabs.currentIndex + Behavior on color { + enabled: !resizingWindow + ColorAnimation { duration: 120 } + } + Behavior on border.color { + enabled: !resizingWindow + ColorAnimation { duration: 120 } + } - Rectangle { - color: "transparent" + HoverHandler { + id: classMappingHoverHandler + } - ListView { - id: studentProfileListView + RowLayout { anchors.fill: parent - clip: false - interactive: false - spacing: 8 - model: appController.studentProfileModel + anchors.leftMargin: 12 + anchors.rightMargin: 12 + visible: !phoneLayout + spacing: 12 - header: ColumnLayout { - width: ListView.view.width - spacing: 8 - - GridLayout { - Layout.fillWidth: true - columns: phoneLayout ? 1 : 5 - columnSpacing: 8 - rowSpacing: 8 - - AppTextField { - Layout.fillWidth: true - placeholderText: "搜索学号、姓名、班级或人脸 ID" - text: appController.studentProfileSearchText - onTextEdited: appController.studentProfileSearchText = text - } - - AppComboBox { - Layout.fillWidth: true - model: appController.studentProfileClassFilterOptions - currentIndex: optionIndex(model, appController.studentProfileClassFilter) - onActivated: appController.studentProfileClassFilter = currentText - } - - AppComboBox { - Layout.fillWidth: true - model: ["全部", "已绑定", "未绑定"] - currentIndex: optionIndex(model, appController.studentProfileBindingFilter) - onActivated: appController.studentProfileBindingFilter = currentText - } - - SecondaryButton { - Layout.fillWidth: true - text: "新增学生" - onClicked: { - appController.addStudentProfile(); - markMappingsDirty(); - } - } - - Label { - Layout.alignment: Qt.AlignVCenter - text: mappingsDirty ? "有未保存改动" : "配置已保存" - color: mappingsDirty ? goldColor : successColor - font.pixelSize: 12 - font.weight: appBoldWeight - } + AppTextField { + id: classKeyField + Layout.preferredWidth: 220 + text: model.entryKey + placeholderText: "班级编号" + onEditingFinished: { + appController.classMappingModel.updateEntry(index, text, classValueField.text); + markMappingsDirty(); } } - delegate: Rectangle { - id: studentProfileRow - width: ListView.view.width - height: visible ? (phoneLayout ? 168 : 64) : 0 - visible: studentProfileMatches(appController.studentProfileSearchText, - appController.studentProfileClassFilter, - appController.studentProfileBindingFilter, - model.studentId, - model.studentName, - model.classId, - model.className, - model.faceIds) - property int rowIndex: index - property string rowClassId: model.classId - property bool hovered: studentProfileHoverHandler.hovered - radius: 8 - color: hovered ? "#eef5f4" : (index % 2 === 0 ? "#ffffff" : "#f8f3ec") - border.color: hovered ? "#c7dddd" : (index % 2 === 0 ? "#ece4da" : "#e7ded3") - - Behavior on color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - Behavior on border.color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } + AppTextField { + id: classValueField + Layout.preferredWidth: 260 + text: model.entryValue + placeholderText: "班级名称" + onEditingFinished: { + appController.classMappingModel.updateEntry(index, classKeyField.text, text); + markMappingsDirty(); } + } - HoverHandler { - id: studentProfileHoverHandler - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - visible: !phoneLayout - spacing: 10 - - AppTextField { - id: profileStudentIdField - Layout.preferredWidth: 160 - text: model.studentId - placeholderText: "学号" - onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, text, profileStudentNameField.text, profileClassCombo.currentText) - } - - AppTextField { - id: profileStudentNameField - Layout.preferredWidth: 150 - text: model.studentName - placeholderText: "姓名" - onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdField.text, text, profileClassCombo.currentText) - } - - AppComboBox { - id: profileClassCombo - Layout.preferredWidth: 220 - model: appController.classSelectionOptions - Component.onCompleted: currentIndex = classComboIndex(studentProfileRow.rowClassId) - onModelChanged: currentIndex = classComboIndex(studentProfileRow.rowClassId) - onActivated: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdField.text, profileStudentNameField.text, currentText) - } - - Label { - Layout.preferredWidth: 160 - text: model.faceIds.length > 0 ? model.faceIds : "未绑定人脸" - color: model.faceIds.length > 0 ? successColor : ink500 - font.pixelSize: 12 - elide: Text.ElideRight - } - - Item { - Layout.fillWidth: true - } - SecondaryButton { - text: "删除" - onClicked: { - appController.removeStudentProfile(studentProfileRow.rowIndex); - markMappingsDirty(); - } - } - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 12 - visible: phoneLayout - spacing: 8 - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - AppTextField { - id: profileStudentIdFieldPhone - Layout.fillWidth: true - text: model.studentId - placeholderText: "学号" - onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, text, profileStudentNameFieldPhone.text, profileClassComboPhone.currentText) - } - - AppTextField { - id: profileStudentNameFieldPhone - Layout.fillWidth: true - text: model.studentName - placeholderText: "姓名" - onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdFieldPhone.text, text, profileClassComboPhone.currentText) - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - AppComboBox { - id: profileClassComboPhone - Layout.fillWidth: true - model: appController.classSelectionOptions - Component.onCompleted: currentIndex = classComboIndex(studentProfileRow.rowClassId) - onModelChanged: currentIndex = classComboIndex(studentProfileRow.rowClassId) - onActivated: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdFieldPhone.text, profileStudentNameFieldPhone.text, currentText) - } - - SecondaryButton { - Layout.preferredWidth: 76 - text: "删除" - onClicked: { - appController.removeStudentProfile(studentProfileRow.rowIndex); - markMappingsDirty(); - } - } - } - - Label { - Layout.fillWidth: true - text: model.faceIds.length > 0 ? "绑定人脸: " + model.faceIds : "未绑定人脸" - color: model.faceIds.length > 0 ? successColor : ink500 - font.pixelSize: 12 - elide: Text.ElideRight - } + Item { + Layout.fillWidth: true + } + SecondaryButton { + text: "删除" + onClicked: { + if (appController.removeClassMapping(index)) + markMappingsDirty(); } } } - } - Rectangle { - color: "transparent" - - ListView { - id: classMappingListView + ColumnLayout { anchors.fill: parent - clip: false - interactive: false + anchors.margins: 12 + visible: phoneLayout spacing: 8 - model: appController.classMappingModel - header: RowLayout { - width: ListView.view.width - spacing: 12 + AppTextField { + id: classKeyFieldPhone + Layout.fillWidth: true + text: model.entryKey + placeholderText: "班级编号" + onEditingFinished: { + appController.classMappingModel.updateEntry(index, text, classValueFieldPhone.text); + markMappingsDirty(); + } + } - SecondaryButton { - text: "新增班级" - onClicked: { - appController.classMappingModel.addEntry("", ""); + RowLayout { + Layout.fillWidth: true + spacing: 8 + + AppTextField { + id: classValueFieldPhone + Layout.fillWidth: true + text: model.entryValue + placeholderText: "班级名称" + onEditingFinished: { + appController.classMappingModel.updateEntry(index, classKeyFieldPhone.text, text); markMappingsDirty(); } } - Item { - Layout.fillWidth: true - } - } - - delegate: Rectangle { - width: ListView.view.width - height: phoneLayout ? 112 : 56 - property bool hovered: classMappingHoverHandler.hovered - radius: 8 - color: hovered ? "#eef5f4" : (index % 2 === 0 ? "#ffffff" : "#f8f3ec") - border.color: hovered ? "#c7dddd" : (index % 2 === 0 ? "#ece4da" : "#e7ded3") - - Behavior on color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - Behavior on border.color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - - HoverHandler { - id: classMappingHoverHandler - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - visible: !phoneLayout - spacing: 12 - - AppTextField { - id: classKeyField - Layout.preferredWidth: 220 - text: model.entryKey - placeholderText: "班级编号" - onEditingFinished: { - appController.classMappingModel.updateEntry(index, text, classValueField.text); + SecondaryButton { + Layout.preferredWidth: 76 + text: "删除" + onClicked: { + if (appController.removeClassMapping(index)) markMappingsDirty(); - } - } - - AppTextField { - id: classValueField - Layout.preferredWidth: 260 - text: model.entryValue - placeholderText: "班级名称" - onEditingFinished: { - appController.classMappingModel.updateEntry(index, classKeyField.text, text); - markMappingsDirty(); - } - } - - Item { - Layout.fillWidth: true - } - SecondaryButton { - text: "删除" - onClicked: { - appController.classMappingModel.removeEntry(index); - markMappingsDirty(); - } - } - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 12 - visible: phoneLayout - spacing: 8 - - AppTextField { - id: classKeyFieldPhone - Layout.fillWidth: true - text: model.entryKey - placeholderText: "班级编号" - onEditingFinished: { - appController.classMappingModel.updateEntry(index, text, classValueFieldPhone.text); - markMappingsDirty(); - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - AppTextField { - id: classValueFieldPhone - Layout.fillWidth: true - text: model.entryValue - placeholderText: "班级名称" - onEditingFinished: { - appController.classMappingModel.updateEntry(index, classKeyFieldPhone.text, text); - markMappingsDirty(); - } - } - - SecondaryButton { - Layout.preferredWidth: 76 - text: "删除" - onClicked: { - appController.classMappingModel.removeEntry(index); - markMappingsDirty(); - } - } } } } @@ -2817,8 +2738,8 @@ ApplicationWindow { SectionHeader { Layout.fillWidth: true - title: "K210 人脸照片与学号映射" - description: "同步照片状态,维护人脸与真实学号的对应关系。" + title: "K210 人脸照片与档案绑定" + description: "同步照片状态,维护人脸、学号、姓名和班级信息。" accent: goldColor } @@ -2832,7 +2753,7 @@ ApplicationWindow { onClicked: appController.refreshFaces() } PrimaryButton { - text: "保存映射" + text: "保存档案" onClicked: appController.saveFaceMappings() } } @@ -2853,12 +2774,12 @@ ApplicationWindow { clip: true property int cardColumns: phoneLayout ? 1 : Math.max(1, Math.floor(width / (compactLayout ? 236 : 260))) cellWidth: Math.max(phoneLayout ? 280 : 240, Math.floor(width / cardColumns)) - cellHeight: phoneLayout ? 462 : 424 + cellHeight: phoneLayout ? 444 : 404 model: appController.faceModel delegate: Rectangle { width: Math.min(phoneLayout ? 300 : 244, GridView.view.cellWidth - 18) - height: phoneLayout ? 438 : 400 + height: phoneLayout ? 420 : 380 property bool hovered: faceHoverHandler.hovered radius: 8 color: hovered ? "#fff6ef" : "#ffffff" @@ -2912,23 +2833,25 @@ ApplicationWindow { } Label { - text: "ID: " + model.faceId + text: "人脸编号: " + model.faceId color: ink900 font.pixelSize: compactLayout ? 15 : 16 font.weight: appBoldWeight } - AppTextField { - Layout.fillWidth: true - text: model.studentId - placeholderText: narrowLayout ? "学号" : "输入真实学号" - onEditingFinished: appController.updateFaceStudentId(index, text) - } - ColumnLayout { Layout.fillWidth: true spacing: 4 + Label { + Layout.fillWidth: true + text: model.studentId.length > 0 ? "学号: " + model.studentId : "学号: 未绑定" + color: model.studentId.length > 0 ? ink700 : goldColor + font.pixelSize: 12 + font.weight: model.studentId.length > 0 ? appBoldWeight : Font.Normal + elide: Text.ElideRight + } + Label { Layout.fillWidth: true text: model.studentName.length > 0 ? "姓名: " + model.studentName : "姓名: 未建档" @@ -2950,12 +2873,12 @@ ApplicationWindow { Layout.fillWidth: true Layout.preferredHeight: 34 radius: 8 - color: model.studentId.length === 0 ? "#f9ebcf" : (model.studentName.length > 0 ? "#e6f4ec" : "#fce8e5") + color: model.studentId.length === 0 ? "#f9ebcf" : (model.studentName.length > 0 ? "#e6f4ec" : "#f9ebcf") Label { anchors.centerIn: parent - text: model.studentId.length === 0 ? "未绑定学生" : (model.studentName.length > 0 ? "已绑定学生档案" : "学号未建档") - color: model.studentId.length === 0 ? goldColor : (model.studentName.length > 0 ? successColor : dangerColor) + text: model.studentId.length === 0 ? "未绑定学生" : (model.studentName.length > 0 ? "已绑定学生档案" : "待补全学生信息") + color: model.studentId.length === 0 ? goldColor : (model.studentName.length > 0 ? successColor : goldColor) font.pixelSize: narrowLayout ? 11 : 12 font.weight: appBoldWeight } @@ -2980,13 +2903,23 @@ ApplicationWindow { Layout.fillHeight: true } - DangerButton { + RowLayout { Layout.fillWidth: true - text: "删除该人脸" - onClicked: { - pendingDeleteFaceId = model.faceId; - deleteFaceRecordsCheck.checked = false; - deleteFaceDialog.open(); + + SecondaryButton { + Layout.fillWidth: true + text: "编辑" + onClicked: openFaceEditor(model.faceId, model.studentId, model.studentName, model.classId) + } + + DangerButton { + Layout.fillWidth: true + text: "删除" + onClicked: { + pendingDeleteFaceId = model.faceId; + deleteFaceRecordsCheck.checked = false; + deleteFaceDialog.open(); + } } } } @@ -3040,12 +2973,10 @@ ApplicationWindow { anchors.margins: phoneLayout ? 16 : 24 clip: true contentWidth: availableWidth - contentHeight: aboutContentLayout.height ColumnLayout { id: aboutContentLayout width: aboutScrollView.availableWidth - height: Math.max(aboutScrollView.availableHeight, implicitHeight) spacing: phoneLayout ? 12 : 18 SectionHeader { @@ -3079,12 +3010,6 @@ ApplicationWindow { lineHeight: 1.25 } - Item { - Layout.fillHeight: true - Layout.minimumHeight: 0 - Layout.preferredHeight: 0 - } - Rectangle { Layout.fillWidth: true Layout.preferredHeight: thesisInfoTitle.implicitHeight + thesisInfoBody.paintedHeight + thesisInfoLayout.spacing + 32 diff --git a/src/ApiClient.cpp b/src/ApiClient.cpp index dcb8b48..d306484 100644 --- a/src/ApiClient.cpp +++ b/src/ApiClient.cpp @@ -77,7 +77,8 @@ QNetworkRequest ApiClient::createRequest(const QString &url, bool encryptedRespo request.setRawHeader("X-Api-Token", m_apiToken.toUtf8()); request.setRawHeader("Accept", "application/json"); if (jsonBody) { - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/octet-stream"); + request.setRawHeader("X-Encrypted", "AES128-CBC"); } request.setTransferTimeout(NetworkTimeoutMs); return request; @@ -91,9 +92,9 @@ void ApiClient::fetchSyncState(qulonglong lastVersion) }); qInfo() << "[ApiClient] GET sync state" << url; - QNetworkReply *reply = m_networkManager.get(createRequest(url)); + QNetworkReply *reply = m_networkManager.get(createRequest(url, true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("同步状态加载失败"), reply); reply->deleteLater(); @@ -137,36 +138,70 @@ void ApiClient::fetchSyncState(qulonglong lastVersion) emit timeSlotsFetched(timeSlots); } - if (state.contains("mapping_config")) { - const QJsonObject mappingConfig = state.value("mapping_config").toObject(); + if (state.contains("students") || state.contains("classes")) { QMap studentMap; QMap studentClassMap; QMap classMap; - const QJsonObject studentObject = mappingConfig.value("student_name_map").toObject(); - for (auto it = studentObject.begin(); it != studentObject.end(); ++it) { - studentMap[it.key()] = it.value().toString(); + const QJsonArray studentArray = state.value("students").toArray(); + for (const QJsonValue &value : studentArray) { + const QJsonObject object = value.toObject(); + const QString studentId = object.value("student_id").toString().trimmed(); + if (studentId.isEmpty()) { + continue; + } + studentMap[studentId] = object.value("student_name").toString().trimmed(); + studentClassMap[studentId] = object.value("class_id").toString().trimmed(); } - const QJsonObject studentClassObject = mappingConfig.value("student_class_map").toObject(); - for (auto it = studentClassObject.begin(); it != studentClassObject.end(); ++it) { - studentClassMap[it.key()] = it.value().toString(); + const QJsonArray classes = state.value("classes").toArray(); + for (const QJsonValue &value : classes) { + const QJsonObject object = value.toObject(); + const QString classId = object.value("class_id").toString().trimmed(); + if (!classId.isEmpty()) { + classMap[classId] = object.value("class_name").toString().trimmed(); + } } - - const QJsonObject classObject = mappingConfig.value("class_name_map").toObject(); - for (auto it = classObject.begin(); it != classObject.end(); ++it) { - classMap[it.key()] = it.value().toString(); + QVector studentItems; + studentItems.reserve(studentMap.size()); + for (auto it = studentMap.cbegin(); it != studentMap.cend(); ++it) { + const QString studentId = it.key(); + studentItems.push_back({ + studentId, + it.value(), + studentClassMap.value(studentId), + classMap.value(studentClassMap.value(studentId), studentClassMap.value(studentId)), + {}, + {} + }); } - emit mappingConfigFetched(studentMap, studentClassMap, classMap); + emit studentsFetched(studentItems); + emit classesFetched(classMap); } - if (state.contains("face_mappings")) { - const QJsonObject mappings = state.value("face_mappings").toObject(); - QMap mapping; - for (auto it = mappings.begin(); it != mappings.end(); ++it) { - mapping[it.key()] = it.value().toString(); + if (state.contains("face_profiles")) { + 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(); + const QString studentId = object.value("student_id").toString().trimmed(); + if (faceId.isEmpty()) { + continue; + } + faces.push_back({ + faceId, + studentId, + object.value("has_photo").toBool(false), + {}, + {}, + {}, + {}, + object.value("status").toString().trimmed() + }); } - emit faceIdMappingFetched(mapping); + emit faceProfilesFetched(faces); } if (state.contains("face_photos")) { @@ -178,15 +213,6 @@ void ApiClient::fetchSyncState(qulonglong lastVersion) emit facePhotosFetched(ids); } - if (state.contains("all_student_ids")) { - QStringList ids; - const QJsonArray array = state.value("all_student_ids").toArray(); - for (const QJsonValue &value : array) { - ids.push_back(value.toString()); - } - emit allStudentIdsFetched(ids); - } - emit syncStateFetched(version, true); reply->deleteLater(); }); @@ -204,6 +230,18 @@ QByteArray ApiClient::maybeDecrypt(const QByteArray &data, bool encrypted) const return CryptoUtils::decryptAes128CbcBase64(trimmed); } +QByteArray ApiClient::decryptedReplyBody(QNetworkReply *reply) const +{ + const QByteArray data = reply->readAll(); + const bool encrypted = reply->rawHeader("X-Encrypted").trimmed() == "AES128-CBC"; + return maybeDecrypt(data, encrypted); +} + +QByteArray ApiClient::encryptedJsonBody(const QByteArray &body) const +{ + return CryptoUtils::encryptAes128CbcBase64(body); +} + bool ApiClient::isConnectionError(QNetworkReply::NetworkError error) const { switch (error) { @@ -280,21 +318,20 @@ void ApiClient::fetchRecords(int limit, int offset) {"offset", QString::number(offset)} }); qInfo() << "[ApiClient] GET" << url; - QNetworkReply *reply = m_networkManager.get(createRequest(url)); + QNetworkReply *reply = m_networkManager.get(createRequest(url, true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray rawData = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); qInfo() << "[ApiClient] records status=" << statusCode << "error=" << reply->error() - << "bytes=" << rawData.size() - << "body=" << rawData.left(200); + << "bytes=" << data.size() + << "body=" << data.left(200); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载记录失败"), reply); reply->deleteLater(); return; } - const QByteArray data = maybeDecrypt(rawData, false); if (data.isEmpty()) { emit networkError("记录数据为空"); reply->deleteLater(); @@ -320,21 +357,20 @@ void ApiClient::fetchStatistics() { const QString url = buildUrl("/v1/attendance/statistics"); qInfo() << "[ApiClient] GET" << url; - QNetworkReply *reply = m_networkManager.get(createRequest(url)); + QNetworkReply *reply = m_networkManager.get(createRequest(url, true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray rawData = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); qInfo() << "[ApiClient] statistics status=" << statusCode << "error=" << reply->error() - << "bytes=" << rawData.size() - << "body=" << rawData.left(200); + << "bytes=" << data.size() + << "body=" << data.left(200); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载统计失败"), reply); reply->deleteLater(); return; } - const QByteArray data = maybeDecrypt(rawData, false); if (data.isEmpty()) { emit networkError("统计数据为空"); reply->deleteLater(); @@ -352,9 +388,10 @@ void ApiClient::fetchStatistics() void ApiClient::clearAllRecords() { - QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/attendance/records/clear"), false, true), QByteArray()); + QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/attendance/records/clear"), true, true), + encryptedJsonBody("{}")); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("清空失败: %1").arg(reply->errorString())); @@ -375,9 +412,9 @@ void ApiClient::deleteRecord(const QString ×tamp, const QString &studentId) QNetworkReply *reply = m_networkManager.deleteResource(createRequest(buildUrl("/v1/attendance/records", { {"timestamp", timestamp}, {"student_id", studentId} - }))); + }), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("记录删除失败: %1").arg(reply->errorString())); @@ -395,9 +432,9 @@ void ApiClient::deleteRecord(const QString ×tamp, const QString &studentId) void ApiClient::fetchTimeSlots() { - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/time-slots"))); + QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/time-slots"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载时间段失败"), reply); reply->deleteLater(); @@ -439,9 +476,10 @@ void ApiClient::uploadTimeSlots(const QVector &timeSlots) qInfo() << "[ApiClient] POST" << buildUrl("/v1/config/time-slots") << "bytes=" << body.size() << "body=" << body.left(300); - QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/time-slots"), false, true), body); + QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/time-slots"), true, true), + encryptedJsonBody(body)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("时间段同步失败: %1").arg(reply->errorString())); @@ -456,139 +494,203 @@ void ApiClient::uploadTimeSlots(const QVector &timeSlots) }); } -void ApiClient::fetchMappingConfig() +void ApiClient::fetchStudents() { - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/mappings"))); + QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/students"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("加载映射配置失败"), reply); + emitNetworkError(QStringLiteral("加载学生档案失败"), reply); + reply->deleteLater(); + return; + } + + QVector students; + const QJsonArray array = QJsonDocument::fromJson(data).object().value("students").toArray(); + students.reserve(array.size()); + for (const QJsonValue &value : array) { + const QJsonObject object = value.toObject(); + students.push_back({ + object.value("student_id").toString().trimmed(), + object.value("student_name").toString().trimmed(), + object.value("class_id").toString().trimmed(), + {}, + {}, + {} + }); + } + emit studentsFetched(students); + reply->deleteLater(); + }); +} + +void ApiClient::uploadStudents(const QVector &students) +{ + QJsonArray array; + for (const StudentProfileListModel::Item &student : students) { + const QString studentId = student.studentId.trimmed(); + if (studentId.isEmpty()) { + continue; + } + QJsonObject object; + object["student_id"] = studentId; + object["student_name"] = student.studentName.trimmed(); + object["class_id"] = student.classId.trimmed(); + array.append(object); + } + 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]() { + const QByteArray data = decryptedReplyBody(reply); + if (reply->error() != QNetworkReply::NoError) { + if (isConnectionError(reply->error())) { + emit connectionUnavailable(QStringLiteral("学生档案保存失败: %1").arg(reply->errorString())); + } + emit studentsUploaded(false, reply->errorString()); + reply->deleteLater(); + return; + } + const QJsonObject object = QJsonDocument::fromJson(data).object(); + emit studentsUploaded(object.value("success").toBool(), object.value("message").toString()); + reply->deleteLater(); + }); +} + +void ApiClient::fetchClasses() +{ + QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/classes"), true)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QByteArray data = decryptedReplyBody(reply); + if (reply->error() != QNetworkReply::NoError) { + emitNetworkError(QStringLiteral("加载班级配置失败"), reply); reply->deleteLater(); return; } - const QJsonObject root = QJsonDocument::fromJson(data).object(); - QMap studentMap; - QMap studentClassMap; QMap classMap; - - const QJsonObject studentObject = root.value("student_name_map").toObject(); - for (auto it = studentObject.begin(); it != studentObject.end(); ++it) { - studentMap[it.key()] = it.value().toString(); + 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(); + if (!classId.isEmpty()) { + classMap[classId] = object.value("class_name").toString().trimmed(); + } } - - const QJsonObject studentClassObject = root.value("student_class_map").toObject(); - for (auto it = studentClassObject.begin(); it != studentClassObject.end(); ++it) { - studentClassMap[it.key()] = it.value().toString(); - } - - const QJsonObject classObject = root.value("class_name_map").toObject(); - for (auto it = classObject.begin(); it != classObject.end(); ++it) { - classMap[it.key()] = it.value().toString(); - } - - emit mappingConfigFetched(studentMap, studentClassMap, classMap); + emit classesFetched(classMap); reply->deleteLater(); }); } -void ApiClient::uploadMappingConfig(const QMap &studentNameMap, - const QMap &studentClassMap, - const QMap &classNameMap) +void ApiClient::uploadClasses(const QMap &classNameMap) { - QJsonObject studentObject; - for (auto it = studentNameMap.begin(); it != studentNameMap.end(); ++it) { - studentObject[it.key()] = it.value(); + QJsonArray array; + for (auto it = classNameMap.cbegin(); it != classNameMap.cend(); ++it) { + const QString classId = it.key().trimmed(); + if (classId.isEmpty()) { + continue; + } + QJsonObject object; + object["class_id"] = classId; + object["class_name"] = it.value().trimmed(); + array.append(object); } - - QJsonObject studentClassObject; - for (auto it = studentClassMap.begin(); it != studentClassMap.end(); ++it) { - studentClassObject[it.key()] = it.value(); - } - - QJsonObject classObject; - for (auto it = classNameMap.begin(); it != classNameMap.end(); ++it) { - classObject[it.key()] = it.value(); - } - QJsonObject payload; - payload["student_name_map"] = studentObject; - payload["student_class_map"] = studentClassObject; - payload["class_name_map"] = classObject; - - QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/mappings"), false, true), - QJsonDocument(payload).toJson(QJsonDocument::Compact)); + payload["classes"] = array; + const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); + QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/classes"), true, true), + encryptedJsonBody(body)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { - emit connectionUnavailable(QStringLiteral("映射保存失败: %1").arg(reply->errorString())); + emit connectionUnavailable(QStringLiteral("班级配置保存失败: %1").arg(reply->errorString())); } - emit mappingConfigUploaded(false, reply->errorString()); + emit classesUploaded(false, reply->errorString()); reply->deleteLater(); return; } const QJsonObject object = QJsonDocument::fromJson(data).object(); - emit mappingConfigUploaded(object.value("success").toBool(), object.value("message").toString()); + emit classesUploaded(object.value("success").toBool(), object.value("message").toString()); reply->deleteLater(); }); } -void ApiClient::fetchFaceIdMapping() +void ApiClient::fetchFaceProfiles() { - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/mappings"))); + QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/face_profiles"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("加载人脸映射失败"), reply); + emitNetworkError(QStringLiteral("加载人脸档案失败"), reply); reply->deleteLater(); return; } - const QJsonObject mappings = QJsonDocument::fromJson(data).object().value("mappings").toObject(); - QMap mapping; - for (auto it = mappings.begin(); it != mappings.end(); ++it) { - mapping[it.key()] = it.value().toString(); + QVector faces; + const QJsonArray array = QJsonDocument::fromJson(data).object().value("face_profiles").toArray(); + faces.reserve(array.size()); + for (const QJsonValue &value : array) { + const QJsonObject object = value.toObject(); + faces.push_back({ + object.value("face_id").toString().trimmed(), + object.value("student_id").toString().trimmed(), + object.value("has_photo").toBool(false), + {}, + {}, + {}, + {}, + object.value("status").toString().trimmed() + }); } - emit faceIdMappingFetched(mapping); + emit faceProfilesFetched(faces); reply->deleteLater(); }); } -void ApiClient::uploadFaceIdMapping(const QMap &mapping) +void ApiClient::uploadFaceProfiles(const QVector &faces) { - QJsonObject mappings; - for (auto it = mapping.begin(); it != mapping.end(); ++it) { - if (!it.value().trimmed().isEmpty()) { - mappings[it.key()] = it.value(); + QJsonArray array; + for (const FaceListModel::Item &face : faces) { + const QString faceId = face.faceId.trimmed(); + if (faceId.isEmpty()) { + continue; } + QJsonObject object; + object["face_id"] = faceId; + object["student_id"] = face.studentId.trimmed(); + object["status"] = face.studentId.trimmed().isEmpty() ? QStringLiteral("unbound") : QStringLiteral("active"); + array.append(object); } QJsonObject payload; - payload["mappings"] = mappings; - QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/faces/mappings"), false, true), - QJsonDocument(payload).toJson(QJsonDocument::Compact)); + payload["face_profiles"] = array; + const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); + QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/face_profiles"), true, true), + encryptedJsonBody(body)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { - emit connectionUnavailable(QStringLiteral("人脸映射保存失败: %1").arg(reply->errorString())); + emit connectionUnavailable(QStringLiteral("人脸档案保存失败: %1").arg(reply->errorString())); } - emit faceIdMappingUploaded(false, reply->errorString()); + emit faceProfilesUploaded(false, reply->errorString()); reply->deleteLater(); return; } const QJsonObject object = QJsonDocument::fromJson(data).object(); - emit faceIdMappingUploaded(object.value("success").toBool(), object.value("message").toString()); + emit faceProfilesUploaded(object.value("success").toBool(), object.value("message").toString()); reply->deleteLater(); }); } void ApiClient::fetchFacePhotos() { - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/photos"))); + QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/photos"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { emitNetworkError(QStringLiteral("加载照片列表失败"), reply); reply->deleteLater(); @@ -634,27 +736,6 @@ void ApiClient::fetchFacePhoto(const QString &faceId) }); } -void ApiClient::fetchAllStudentIds() -{ - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/student-ids"))); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("加载学生ID失败"), reply); - reply->deleteLater(); - return; - } - - QStringList ids; - const QJsonArray array = QJsonDocument::fromJson(data).object().value("student_ids").toArray(); - for (const QJsonValue &value : array) { - ids.push_back(value.toString()); - } - emit allStudentIdsFetched(ids); - reply->deleteLater(); - }); -} - void ApiClient::deleteFace(const QString &studentId, bool deleteRecords) { QString baseUrl = m_serverUrl.trimmed(); @@ -665,9 +746,9 @@ void ApiClient::deleteFace(const QString &studentId, bool deleteRecords) if (deleteRecords) { url += "?delete_records=true"; } - QNetworkReply *reply = m_networkManager.deleteResource(createRequest(url)); + QNetworkReply *reply = m_networkManager.deleteResource(createRequest(url, true)); connect(reply, &QNetworkReply::finished, this, [this, reply, studentId]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { if (isConnectionError(reply->error())) { emit connectionUnavailable(QStringLiteral("删除失败: %1").arg(reply->errorString())); @@ -688,9 +769,9 @@ void ApiClient::deleteFace(const QString &studentId, bool deleteRecords) void ApiClient::checkHealth() { - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/system/health"))); + QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/system/health"), true)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); + const QByteArray data = decryptedReplyBody(reply); if (reply->error() != QNetworkReply::NoError) { emit healthChecked(false, reply->errorString()); reply->deleteLater(); diff --git a/src/ApiClient.h b/src/ApiClient.h index 9d3b7fc..84079db 100644 --- a/src/ApiClient.h +++ b/src/ApiClient.h @@ -10,6 +10,8 @@ #include #include #include "AttendanceTypes.h" +#include "StudentProfileListModel.h" +#include "FaceListModel.h" class ApiClient : public QObject { @@ -30,15 +32,14 @@ public: void deleteRecord(const QString ×tamp, const QString &studentId); void fetchTimeSlots(); void uploadTimeSlots(const QVector &timeSlots); - void fetchMappingConfig(); - void uploadMappingConfig(const QMap &studentNameMap, - const QMap &studentClassMap, - const QMap &classNameMap); - void fetchFaceIdMapping(); - void uploadFaceIdMapping(const QMap &mapping); + void fetchStudents(); + void uploadStudents(const QVector &students); + void fetchClasses(); + void uploadClasses(const QMap &classNameMap); + void fetchFaceProfiles(); + void uploadFaceProfiles(const QVector &faces); void fetchFacePhotos(); void fetchFacePhoto(const QString &faceId); - void fetchAllStudentIds(); void deleteFace(const QString &studentId, bool deleteRecords); void checkHealth(); @@ -49,15 +50,14 @@ signals: void recordDeleted(bool success, const QString &message); void timeSlotsFetched(const QVector &timeSlots); void timeSlotsUploaded(bool success, const QString &message); - void mappingConfigFetched(const QMap &studentNameMap, - const QMap &studentClassMap, - const QMap &classNameMap); - void mappingConfigUploaded(bool success, const QString &message); - void faceIdMappingFetched(const QMap &mapping); - void faceIdMappingUploaded(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 allStudentIdsFetched(const QStringList &studentIds); void faceDeleted(bool success, const QString &studentId, int recordsDeleted, const QString &message); void healthChecked(bool healthy, const QString &message); void connectionUnavailable(const QString &message); @@ -69,6 +69,8 @@ private: QNetworkRequest createRequest(const QString &url, bool encryptedResponse = false, bool jsonBody = false) const; QVector parseRecords(const QByteArray &data) const; QByteArray maybeDecrypt(const QByteArray &data, bool encrypted) const; + QByteArray decryptedReplyBody(QNetworkReply *reply) const; + QByteArray encryptedJsonBody(const QByteArray &body) const; void emitNetworkError(const QString &prefix, QNetworkReply *reply); bool isConnectionError(QNetworkReply::NetworkError error) const; diff --git a/src/AppController.cpp b/src/AppController.cpp index a6a6fec..ec5bd6e 100644 --- a/src/AppController.cpp +++ b/src/AppController.cpp @@ -198,33 +198,67 @@ AppController::AppController(QObject *parent) setStatusMessage(success ? QStringLiteral("时间段已保存并同步") : QStringLiteral("时间段同步失败: %1").arg(message)); }); - connect(&m_apiClient, &ApiClient::mappingConfigFetched, this, - [this](const QMap &studentNameMap, - const QMap &studentClassMap, - const QMap &classNameMap) { - m_studentMappingModel.setMap(studentNameMap); - m_studentClassMappingModel.setMap(studentClassMap); - m_classMappingModel.setMap(classNameMap); - rebuildStudentProfiles(); + connect(&m_apiClient, &ApiClient::studentsFetched, this, [this](const QVector &students) { + applyFetchedStudents(students); saveMappingsToLocal(); updateFilterOptions(); rebuildFilteredRecords(); rebuildStatistics(); - setStatusMessage("映射配置已同步"); + setStatusMessage(QStringLiteral("学生档案已同步")); }); - connect(&m_apiClient, &ApiClient::mappingConfigUploaded, this, [this](bool success, const QString &message) { - setStatusMessage(success ? QStringLiteral("映射配置已保存") : QStringLiteral("映射保存失败: %1").arg(message)); + 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 { + m_mappingSaveInProgress = false; + m_pendingClassMap.clear(); + m_pendingFaceItems.clear(); + setStatusMessage(QStringLiteral("学生档案保存失败: %1").arg(message)); + } + return; + } + setStatusMessage(success ? QStringLiteral("学生档案已保存") : QStringLiteral("学生档案保存失败: %1").arg(message)); }); - connect(&m_apiClient, &ApiClient::faceIdMappingFetched, this, [this](const QMap &mapping) { - m_faceIdMapping = mapping; - mergeFaceItems(); - rebuildStudentProfiles(); + connect(&m_apiClient, &ApiClient::classesFetched, this, [this](const QMap &classNameMap) { + applyFetchedClasses(classNameMap); + saveMappingsToLocal(); + updateFilterOptions(); + rebuildFilteredRecords(); + rebuildStatistics(); + setStatusMessage(QStringLiteral("班级配置已同步")); }); - connect(&m_apiClient, &ApiClient::faceIdMappingUploaded, this, [this](bool success, const QString &message) { - setStatusMessage(success ? QStringLiteral("人脸映射已保存") : QStringLiteral("人脸映射保存失败: %1").arg(message)); + 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(); + setStatusMessage(QStringLiteral("班级保存失败: %1").arg(message)); + } + return; + } + setStatusMessage(success ? QStringLiteral("班级配置已保存") : QStringLiteral("班级保存失败: %1").arg(message)); + }); + + connect(&m_apiClient, &ApiClient::faceProfilesFetched, this, [this](const QVector &faces) { + applyFetchedFaceProfiles(faces); + }); + + connect(&m_apiClient, &ApiClient::faceProfilesUploaded, this, [this](bool success, const QString &message) { + if (m_mappingSaveInProgress) { + m_mappingSaveInProgress = false; + m_pendingClassMap.clear(); + m_pendingFaceItems.clear(); + } + setStatusMessage(success ? QStringLiteral("人脸档案已保存") : QStringLiteral("人脸档案保存失败: %1").arg(message)); }); connect(&m_apiClient, &ApiClient::facePhotosFetched, this, [this](const QStringList &photoIds) { @@ -451,7 +485,7 @@ void AppController::setStudentProfileBindingFilter(const QString &bindingFilter) void AppController::refreshAll() { - m_apiClient.fetchSyncState(m_qtSyncVersion); + m_apiClient.fetchSyncState(0); } void AppController::refreshRecords() @@ -505,7 +539,7 @@ bool AppController::exportFilteredRecords(const QString &fileUrl) out << csvCell(record.timestamp) << "," << csvCell(effectiveClassId(record)) << "," << csvCell(mappedClassName(record)) << "," - << csvCell(record.studentId) << "," + << csvCell(effectiveStudentId(record)) << "," << csvCell(mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record)) << "," << QString::number(record.confidence, 'f', 4) << "," << csvCell(record.status) << "\n"; @@ -566,7 +600,9 @@ void AppController::reloadTimeSlotsFromServer() void AppController::loadMappingsFromServer() { - m_apiClient.fetchMappingConfig(); + m_apiClient.fetchStudents(); + m_apiClient.fetchClasses(); + m_apiClient.fetchFaceProfiles(); } void AppController::saveMappings() @@ -586,10 +622,10 @@ void AppController::saveMappings() } saveMappingsToLocal(); - m_apiClient.uploadMappingConfig(m_studentMappingModel.toMap(), - m_studentClassMappingModel.toMap(), - m_classMappingModel.toMap()); - m_apiClient.uploadFaceIdMapping(m_faceIdMapping); + m_mappingSaveInProgress = true; + m_pendingClassMap = m_classMappingModel.toMap(); + m_pendingFaceItems = m_faceModel.items(); + m_apiClient.uploadStudents(m_studentProfileModel.profiles()); rebuildStudentProfiles(); mergeFaceItems(); updateFilterOptions(); @@ -630,6 +666,23 @@ void AppController::updateStudentProfile(int index, emit studentProfilesChanged(); } +QString AppController::studentIdChangeImpactMessage(int index, const QString &newStudentId) const +{ + const QVector profiles = m_studentProfileModel.profiles(); + if (index < 0 || index >= profiles.size()) { + return {}; + } + + const QString oldStudentId = profiles.at(index).studentId.trimmed(); + const QString trimmedNewId = newStudentId.trimmed(); + if (oldStudentId.isEmpty() || trimmedNewId.isEmpty() || oldStudentId == trimmedNewId) { + return {}; + } + + return QStringLiteral("修改学号会同步影响该学生的人脸绑定、考勤记录显示、班级筛选和导出结果。确定将 %1 改为 %2 吗?") + .arg(oldStudentId, trimmedNewId); +} + void AppController::removeStudentProfile(int index) { const QVector profiles = m_studentProfileModel.profiles(); @@ -653,6 +706,32 @@ void AppController::removeStudentProfile(int index) emit studentProfilesChanged(); } +bool AppController::removeClassMapping(int index) +{ + const QModelIndex modelIndex = m_classMappingModel.index(index); + if (!modelIndex.isValid()) { + return false; + } + + 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) { + studentCount++; + } + } + + if (studentCount > 0) { + setStatusMessage(QStringLiteral("班级 %1 下还有 %2 名学生,不能删除").arg(normalizedClassId).arg(studentCount)); + return false; + } + + m_classMappingModel.removeEntry(index); + return true; +} + QString AppController::classDisplayForId(const QString &classId) const { const QString trimmed = classId.trimmed(); @@ -671,10 +750,73 @@ QString AppController::classIdFromDisplay(const QString &display) const void AppController::refreshFaces() { - m_apiClient.fetchFaceIdMapping(); + m_apiClient.fetchFaceProfiles(); m_apiClient.fetchFacePhotos(); } +bool AppController::editFaceProfile(const QString &faceId, + const QString &studentId, + const QString &studentName, + const QString &classId) +{ + const QString trimmedFaceId = faceId.trimmed(); + const QString trimmedStudentId = studentId.trimmed(); + const QString trimmedStudentName = studentName.trimmed(); + const QString trimmedClassId = classId.trimmed(); + + if (trimmedFaceId.isEmpty()) { + setStatusMessage(QStringLiteral("人脸编号不能为空")); + return false; + } + + if (!trimmedStudentId.isEmpty() && !trimmedClassId.isEmpty()) { + const QMap classMap = m_classMappingModel.toMap(); + if (!classMap.contains(trimmedClassId)) { + setStatusMessage(QStringLiteral("班级 %1 不存在,请先在配置中心新增班级").arg(trimmedClassId)); + return false; + } + } + + const QString oldStudentId = m_faceIdMapping.value(trimmedFaceId).trimmed(); + + if (trimmedStudentId.isEmpty()) { + m_faceIdMapping.remove(trimmedFaceId); + } else { + m_faceIdMapping[trimmedFaceId] = trimmedStudentId; + + 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; + } + if (!oldStudentId.isEmpty() && profiles.at(i).studentId.trimmed() == oldStudentId) { + oldStudentIndex = i; + } + } + + if (existingIndex >= 0) { + m_studentProfileModel.updateProfile(existingIndex, trimmedStudentId, trimmedStudentName, trimmedClassId); + } else if (oldStudentIndex >= 0) { + m_studentProfileModel.updateProfile(oldStudentIndex, trimmedStudentId, trimmedStudentName, trimmedClassId); + } else { + m_studentProfileModel.addProfile(trimmedStudentId, trimmedStudentName, trimmedClassId); + } + + applyStudentProfilesToMappings(); + } + + mergeFaceItems(); + rebuildStudentProfiles(); + updateFilterOptions(); + rebuildFilteredRecords(); + rebuildStatistics(); + emit studentProfilesChanged(); + setStatusMessage(QStringLiteral("人脸档案已更新,正在保存到服务端")); + return true; +} + void AppController::updateFaceStudentId(int index, const QString &studentId) { const QVector items = m_faceModel.items(); @@ -699,28 +841,7 @@ void AppController::updateFaceStudentId(int index, const QString &studentId) void AppController::saveFaceMappings() { - QString validationMessage; - if (!validateFaceMappings(&validationMessage)) { - setStatusMessage(validationMessage); - mergeFaceItems(); - return; - } - - QMap mapping; - const QVector items = m_faceModel.items(); - for (const FaceListModel::Item &item : items) { - const QString faceId = item.faceId.trimmed(); - const QString studentId = item.studentId.trimmed(); - if (faceId.isEmpty() || studentId.isEmpty()) { - continue; - } - - mapping[faceId] = studentId; - } - m_faceIdMapping = mapping; - mergeFaceItems(); - rebuildStudentProfiles(); - m_apiClient.uploadFaceIdMapping(mapping); + saveMappings(); } void AppController::deleteFace(const QString &studentId, bool deleteRecords) @@ -760,7 +881,7 @@ void AppController::saveApiToken() void AppController::saveMappingsToLocal() { - const QString path = localSettingsPath("mapping_config.json"); + const QString path = localSettingsPath("profiles_cache.json"); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; @@ -785,15 +906,15 @@ void AppController::saveMappingsToLocal() studentClassObject[it.key()] = it.value(); } - root["student_name_map"] = studentObject; - root["student_class_map"] = studentClassObject; - root["class_name_map"] = classObject; + root["students"] = studentObject; + root["student_classes"] = studentClassObject; + root["classes"] = classObject; file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); } void AppController::loadMappingsFromLocal() { - QFile file(localSettingsPath("mapping_config.json")); + QFile file(localSettingsPath("profiles_cache.json")); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { m_studentMappingModel.clear(); m_studentClassMappingModel.clear(); @@ -803,19 +924,19 @@ void AppController::loadMappingsFromLocal() const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); QMap studentMap; - const QJsonObject studentObject = root.value("student_name_map").toObject(); + const QJsonObject studentObject = root.value("students").toObject(); for (auto it = studentObject.begin(); it != studentObject.end(); ++it) { studentMap[it.key()] = it.value().toString(); } QMap classMap; - const QJsonObject classObject = root.value("class_name_map").toObject(); + const QJsonObject classObject = root.value("classes").toObject(); for (auto it = classObject.begin(); it != classObject.end(); ++it) { classMap[it.key()] = it.value().toString(); } QMap studentClassMap; - const QJsonObject studentClassObject = root.value("student_class_map").toObject(); + const QJsonObject studentClassObject = root.value("student_classes").toObject(); for (auto it = studentClassObject.begin(); it != studentClassObject.end(); ++it) { studentClassMap[it.key()] = it.value().toString(); } @@ -899,7 +1020,7 @@ void AppController::updateFilterOptions() for (const AttendanceRecord &record : m_allRecords) { classIds.insert(effectiveClassId(record)); - studentIds.insert(record.studentId); + studentIds.insert(effectiveStudentId(record)); } m_classOptions = {"全部"}; @@ -915,6 +1036,63 @@ void AppController::updateFilterOptions() emit filtersChanged(); } +void AppController::applyFetchedStudents(const QVector &students) +{ + QMap studentNameMap; + QMap studentClassMap; + for (const StudentProfileListModel::Item &student : students) { + const QString studentId = student.studentId.trimmed(); + if (studentId.isEmpty()) { + continue; + } + studentNameMap[studentId] = student.studentName.trimmed(); + studentClassMap[studentId] = student.classId.trimmed(); + } + m_studentMappingModel.setMap(studentNameMap); + m_studentClassMappingModel.setMap(studentClassMap); + m_studentsLoadedFromServer = true; + for (auto it = m_faceIdMapping.begin(); it != m_faceIdMapping.end();) { + if (!studentNameMap.contains(it.value().trimmed())) { + it = m_faceIdMapping.erase(it); + } else { + ++it; + } + } + rebuildStudentProfiles(); + mergeFaceItems(); +} + +void AppController::applyFetchedClasses(const QMap &classNameMap) +{ + m_classMappingModel.setMap(classNameMap); + rebuildStudentProfiles(); + 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()) { + continue; + } + if (!faceIds.contains(faceId)) { + faceIds.push_back(faceId); + } + const QString studentId = face.studentId.trimmed(); + if (!studentId.isEmpty() && (!m_studentsLoadedFromServer || studentNameMap.contains(studentId))) { + mapping[faceId] = studentId; + } + } + m_faceIdMapping = mapping; + m_facePhotoIds = faceIds; + mergeFaceItems(); + rebuildStudentProfiles(); +} + void AppController::rebuildStudentProfiles() { const QMap studentNameMap = m_studentMappingModel.toMap(); @@ -926,16 +1104,8 @@ void AppController::rebuildStudentProfiles() studentIds.insert(it.key()); } for (auto it = studentClassMap.cbegin(); it != studentClassMap.cend(); ++it) { - studentIds.insert(it.key()); - } - for (auto it = m_faceIdMapping.cbegin(); it != m_faceIdMapping.cend(); ++it) { - if (!it.value().trimmed().isEmpty()) { - studentIds.insert(it.value().trimmed()); - } - } - for (const AttendanceRecord &record : m_allRecords) { - if (!record.studentId.trimmed().isEmpty()) { - studentIds.insert(record.studentId.trimmed()); + if (studentNameMap.contains(it.key())) { + studentIds.insert(it.key()); } } @@ -1021,19 +1191,7 @@ bool AppController::validateMappings(QString *message) const } return false; } - if (studentName.isEmpty()) { - if (message) { - *message = QStringLiteral("学号 %1 的姓名不能为空").arg(studentId); - } - return false; - } - if (classId.isEmpty()) { - if (message) { - *message = QStringLiteral("学号 %1 的班级不能为空").arg(studentId); - } - return false; - } - if (!classMap.contains(classId)) { + if (!classId.isEmpty() && !classMap.contains(classId)) { if (message) { *message = QStringLiteral("学号 %1 选择的班级 %2 不存在").arg(studentId, classId); } @@ -1111,7 +1269,8 @@ void AppController::rebuildFilteredRecords() continue; } - if (m_selectedStudentId != "全部" && record.studentId != m_selectedStudentId) { + const QString studentId = effectiveStudentId(record); + if (m_selectedStudentId != "全部" && studentId != m_selectedStudentId) { continue; } @@ -1148,7 +1307,7 @@ void AppController::rebuildFilteredRecords() record.timestamp, effectiveClassId(record), mappedClassName(record), - record.studentId, + effectiveStudentId(record), mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record), record.confidence, record.status, @@ -1171,8 +1330,9 @@ void AppController::rebuildStatistics() continue; } - StudentStatistics &stats = statisticsMap[record.studentId]; - stats.studentId = record.studentId; + const QString studentId = effectiveStudentId(record); + StudentStatistics &stats = statisticsMap[studentId]; + stats.studentId = studentId; stats.name = mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record); stats.totalCount += 1; stats.averageConfidence += record.confidence; @@ -1215,12 +1375,18 @@ void AppController::rebuildStatistics() QString AppController::mappedStudentName(const AttendanceRecord &record) const { - return m_studentMappingModel.toMap().value(record.studentId); + return m_studentMappingModel.toMap().value(effectiveStudentId(record)); +} + +QString AppController::effectiveStudentId(const AttendanceRecord &record) const +{ + const QString recordStudentId = record.studentId.trimmed(); + return m_faceIdMapping.value(recordStudentId, recordStudentId).trimmed(); } QString AppController::effectiveClassId(const AttendanceRecord &record) const { - return m_studentClassMappingModel.toMap().value(record.studentId, record.classId); + return m_studentClassMappingModel.toMap().value(effectiveStudentId(record), record.classId); } QString AppController::mappedClassName(const AttendanceRecord &record) const diff --git a/src/AppController.h b/src/AppController.h index b399db6..31eb307 100644 --- a/src/AppController.h +++ b/src/AppController.h @@ -105,10 +105,16 @@ public: 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); @@ -136,6 +142,9 @@ private: void loadTimeSlotsFromLocal(); QString localSettingsPath(const QString &fileName) const; void updateFilterOptions(); + void applyFetchedStudents(const QVector &students); + void applyFetchedClasses(const QMap &classNameMap); + void applyFetchedFaceProfiles(const QVector &faces); void rebuildStudentProfiles(); void applyStudentProfilesToMappings(); bool validateMappings(QString *message) const; @@ -143,6 +152,7 @@ private: 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; @@ -196,6 +206,10 @@ private: QMap m_faceIdMapping; QHash m_facePhotoLocalUrls; QStringList m_facePhotoIds; + bool m_studentsLoadedFromServer = false; + bool m_mappingSaveInProgress = false; + QMap m_pendingClassMap; + QVector m_pendingFaceItems; }; #endif diff --git a/src/CryptoUtils.cpp b/src/CryptoUtils.cpp index fcad252..aa40a66 100644 --- a/src/CryptoUtils.cpp +++ b/src/CryptoUtils.cpp @@ -2,6 +2,24 @@ #include "crypto/tiny_aes.hpp" namespace { +const uint8_t AesKey[AES_KEYLEN] = { + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, + 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c +}; + +const uint8_t AesIv[AES_BLOCKLEN] = { + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f +}; + +QByteArray addPkcs7Padding(const QByteArray &data) +{ + const int padding = AES_BLOCKLEN - (data.size() % AES_BLOCKLEN); + QByteArray padded = data; + padded.append(QByteArray(padding, static_cast(padding))); + return padded; +} + QByteArray removePkcs7Padding(const QByteArray &data) { if (data.isEmpty()) { @@ -23,18 +41,17 @@ QByteArray removePkcs7Padding(const QByteArray &data) } } +QByteArray CryptoUtils::encryptAes128CbcBase64(const QByteArray &plainData) +{ + QByteArray buffer = addPkcs7Padding(plainData); + AES_ctx ctx; + AES_init_ctx_iv(&ctx, AesKey, AesIv); + AES_CBC_encrypt_buffer(&ctx, reinterpret_cast(buffer.data()), static_cast(buffer.size())); + return buffer.toBase64(); +} + QByteArray CryptoUtils::decryptAes128CbcBase64(const QByteArray &encryptedBase64) { - static const uint8_t key[AES_KEYLEN] = { - 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, - 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c - }; - - static const uint8_t iv[AES_BLOCKLEN] = { - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f - }; - const QByteArray encrypted = QByteArray::fromBase64(encryptedBase64); if (encrypted.isEmpty() || (encrypted.size() % AES_BLOCKLEN) != 0) { return {}; @@ -42,7 +59,7 @@ QByteArray CryptoUtils::decryptAes128CbcBase64(const QByteArray &encryptedBase64 QByteArray buffer = encrypted; AES_ctx ctx; - AES_init_ctx_iv(&ctx, key, iv); + AES_init_ctx_iv(&ctx, AesKey, AesIv); AES_CBC_decrypt_buffer(&ctx, reinterpret_cast(buffer.data()), static_cast(buffer.size())); return removePkcs7Padding(buffer); } diff --git a/src/CryptoUtils.h b/src/CryptoUtils.h index 3763751..e89b319 100644 --- a/src/CryptoUtils.h +++ b/src/CryptoUtils.h @@ -6,6 +6,7 @@ class CryptoUtils { public: + static QByteArray encryptAes128CbcBase64(const QByteArray &plainData); static QByteArray decryptAes128CbcBase64(const QByteArray &encryptedBase64); }; diff --git a/src/crypto/tiny_aes.hpp b/src/crypto/tiny_aes.hpp index dfb2da5..472ca85 100644 --- a/src/crypto/tiny_aes.hpp +++ b/src/crypto/tiny_aes.hpp @@ -176,6 +176,76 @@ static void InvMixColumns(uint8_t *state) } } +static void SubBytes(uint8_t *state) +{ + for (uint8_t i = 0; i < 16; ++i) { + state[i] = getSBoxValue(state[i]); + } +} + +static void ShiftRows(uint8_t *state) +{ + uint8_t temp; + temp = state[1]; + state[1] = state[5]; + state[5] = state[9]; + state[9] = state[13]; + state[13] = temp; + + temp = state[2]; + state[2] = state[10]; + state[10] = temp; + temp = state[6]; + state[6] = state[14]; + state[14] = temp; + + temp = state[15]; + state[15] = state[11]; + state[11] = state[7]; + state[7] = state[3]; + state[3] = temp; +} + +static void MixColumns(uint8_t *state) +{ + for (uint8_t i = 0; i < 4; ++i) { + const uint8_t t = state[i * 4 + 0]; + const uint8_t tmp = state[i * 4 + 0] ^ state[i * 4 + 1] ^ state[i * 4 + 2] ^ state[i * 4 + 3]; + + uint8_t tm = state[i * 4 + 0] ^ state[i * 4 + 1]; + tm = xtime(tm); + state[i * 4 + 0] ^= tm ^ tmp; + + tm = state[i * 4 + 1] ^ state[i * 4 + 2]; + tm = xtime(tm); + state[i * 4 + 1] ^= tm ^ tmp; + + tm = state[i * 4 + 2] ^ state[i * 4 + 3]; + tm = xtime(tm); + state[i * 4 + 2] ^= tm ^ tmp; + + tm = state[i * 4 + 3] ^ t; + tm = xtime(tm); + state[i * 4 + 3] ^= tm ^ tmp; + } +} + +inline void Cipher(uint8_t *state, const uint8_t *RoundKey) +{ + AddRoundKey(0, state, RoundKey); + + for (uint8_t round = 1; round < 10; ++round) { + SubBytes(state); + ShiftRows(state); + MixColumns(state); + AddRoundKey(round, state, RoundKey); + } + + SubBytes(state); + ShiftRows(state); + AddRoundKey(10, state, RoundKey); +} + inline void InvCipher(uint8_t *state, const uint8_t *RoundKey) { uint8_t round = 0; @@ -198,6 +268,17 @@ inline void AES_init_ctx_iv(struct AES_ctx *ctx, const uint8_t *key, const uint8 memcpy(ctx->Iv, iv, AES_BLOCKLEN); } +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) { + for (uint8_t j = 0; j < AES_BLOCKLEN; ++j) { + buf[i + j] ^= ctx->Iv[j]; + } + Cipher(buf + i, ctx->RoundKey); + memcpy(ctx->Iv, buf + i, AES_BLOCKLEN); + } +} + inline void AES_CBC_decrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t length) { size_t i;