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