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