初始化毕业设计项目

This commit is contained in:
2026-05-01 20:29:02 +08:00
commit df5b05e936
158 changed files with 67957 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# Build directories
build/
build-*/
cmake-build-*/
out/
dist/
# CMake generated files
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
install_manifest.txt
compile_commands.json
CTestTestfile.cmake
DartConfiguration.tcl
# Qt Creator local settings
.qtcreator/
*.user
*.user.*
# Qt generated artifacts
*_autogen/
*.qmlc
*.jsc
qrc_*.cpp
moc_*.cpp
moc_*.h
ui_*.h
# Compiled binaries and libraries
*.exe
*.dll
*.so
*.dylib
*.a
*.lib
*.o
*.obj
*.pdb
*.ilk
# Android build outputs
android-build/
*.apk
*.aab
# Logs and temporary files
*.log
*.tmp
*.temp
*.bak
*.swp
*~
# OS files
.DS_Store
Thumbs.db
desktop.ini
+145
View File
@@ -0,0 +1,145 @@
cmake_minimum_required(VERSION 3.16)
project(rewrite_qt VERSION 1.0.0 LANGUAGES CXX)
set(REWRITE_QT_DISPLAY_NAME "人脸识别考勤系统")
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
set(REWRITE_QT_OUTPUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dist")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${REWRITE_QT_OUTPUT_DIR}")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${REWRITE_QT_OUTPUT_DIR}")
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${REWRITE_QT_OUTPUT_DIR}")
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOMOC_COMPILER_PREDEFINES OFF)
set(CMAKE_AUTORCC ON)
file(TO_CMAKE_PATH "$ENV{LOCALAPPDATA}" REWRITE_QT_LOCALAPPDATA)
if(REWRITE_QT_LOCALAPPDATA)
set(REWRITE_QT_AUTOGEN_DIR "${REWRITE_QT_LOCALAPPDATA}/rewrite_qt_autogen")
else()
set(REWRITE_QT_AUTOGEN_DIR "${CMAKE_BINARY_DIR}/autogen_ascii")
endif()
file(MAKE_DIRECTORY "${REWRITE_QT_AUTOGEN_DIR}")
find_package(Qt6 6.2 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 Network)
get_target_property(REWRITE_QT_QMAKE_EXECUTABLE Qt6::qmake IMPORTED_LOCATION)
if(REWRITE_QT_QMAKE_EXECUTABLE)
execute_process(
COMMAND "${REWRITE_QT_QMAKE_EXECUTABLE}" -query QT_INSTALL_QML
OUTPUT_VARIABLE REWRITE_QT_INSTALL_QML
OUTPUT_STRIP_TRAILING_WHITESPACE
)
endif()
qt_standard_project_setup()
set(REWRITE_QT_APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/favicon.ico")
set(REWRITE_QT_ICON_RESOURCES
assets/favicon.ico
assets/favicon-256x256.png
assets/favicon-192x192.png
assets/favicon-128x128.png
assets/favicon-64x64.png
assets/favicon-48x48.png
assets/favicon-32x32.png
assets/favicon-16x16.png
)
qt_add_executable(apprewrite_qt
main.cpp
src/AppController.cpp
src/ApiClient.cpp
src/CryptoUtils.cpp
src/RecordListModel.cpp
src/StatisticsListModel.cpp
src/TimeSlotListModel.cpp
src/MappingListModel.cpp
src/FaceListModel.cpp
)
if(REWRITE_QT_INSTALL_QML)
target_compile_definitions(apprewrite_qt PRIVATE
REWRITE_QT_INSTALL_QML="${REWRITE_QT_INSTALL_QML}"
)
endif()
if(WIN32 AND EXISTS "${REWRITE_QT_APP_ICON}")
set(REWRITE_QT_WIN32_ICON_RC "${CMAKE_CURRENT_BINARY_DIR}/app_icon.rc")
configure_file(cmake/app_icon.rc.in "${REWRITE_QT_WIN32_ICON_RC}" @ONLY)
target_sources(apprewrite_qt PRIVATE "${REWRITE_QT_WIN32_ICON_RC}")
endif()
if(ANDROID)
set_target_properties(apprewrite_qt PROPERTIES
QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android"
QT_ANDROID_APP_NAME "${REWRITE_QT_DISPLAY_NAME}"
QT_ANDROID_APP_ICON "@mipmap/ic_launcher"
)
endif()
target_include_directories(apprewrite_qt PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
set(REWRITE_QT_RESOURCE_FILES
qml/Main.qml
)
foreach(REWRITE_QT_ICON_RESOURCE IN LISTS REWRITE_QT_ICON_RESOURCES)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${REWRITE_QT_ICON_RESOURCE}")
list(APPEND REWRITE_QT_RESOURCE_FILES "${REWRITE_QT_ICON_RESOURCE}")
endif()
endforeach()
qt_add_resources(apprewrite_qt "qml_resources"
PREFIX "/"
FILES
${REWRITE_QT_RESOURCE_FILES}
)
target_link_libraries(apprewrite_qt
PRIVATE
Qt6::Core
Qt6::Gui
Qt6::Qml
Qt6::Quick
Qt6::QuickControls2
Qt6::Network
)
set_target_properties(apprewrite_qt PROPERTIES
AUTOGEN_BUILD_DIR "${REWRITE_QT_AUTOGEN_DIR}"
QT_QML_MODULE_NO_IMPORT_SCAN TRUE
MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
MACOSX_BUNDLE TRUE
WIN32_EXECUTABLE TRUE
)
include(GNUInstallDirs)
if(UNIX AND NOT APPLE AND NOT ANDROID)
set(REWRITE_QT_DESKTOP_FILE "${CMAKE_CURRENT_BINARY_DIR}/rewrite_qt.desktop")
configure_file(cmake/rewrite_qt.desktop.in "${REWRITE_QT_DESKTOP_FILE}" @ONLY)
install(FILES "${REWRITE_QT_DESKTOP_FILE}"
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications"
)
foreach(REWRITE_QT_ICON_SIZE IN ITEMS 16 32 48 64 128 192 256)
set(REWRITE_QT_LINUX_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/favicon-${REWRITE_QT_ICON_SIZE}x${REWRITE_QT_ICON_SIZE}.png")
if(EXISTS "${REWRITE_QT_LINUX_ICON}")
install(FILES "${REWRITE_QT_LINUX_ICON}"
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${REWRITE_QT_ICON_SIZE}x${REWRITE_QT_ICON_SIZE}/apps"
RENAME rewrite_qt.png
)
endif()
endforeach()
endif()
install(TARGETS apprewrite_qt
BUNDLE DESTINATION .
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jitianye
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+517
View File
@@ -0,0 +1,517 @@
# 人脸识别考勤系统
基于 Qt 6 Quick/QML 开发的跨平台人脸识别考勤管理客户端,用于连接考勤服务端,完成签到记录查看、筛选导出、统计分析、签到时段配置、学生与班级名称映射、人脸照片与学号绑定等管理工作。
本项目是纪田野的个人毕业设计作品,主要用于人脸识别考勤场景的功能展示、系统联调和学习研究。
## 项目特点
- 跨平台客户端:支持 Windows 桌面端和 Android 端构建。
- 单窗口管理:考勤记录、统计分析、配置中心、人脸管理和系统说明集中在一个界面中。
- 响应式界面:针对手机窄屏做了独立布局适配,避免控件重叠和文字遮挡。
- 数据导出:支持将考勤记录和统计报表导出为 CSV 文件。
- 本地配置缓存:服务端地址、签到时段、映射配置会保存到本地。
- 自动重连:服务端不可达时自动重连,并使用简短状态提示。
- Android 字体优化:Android 端使用原生文字渲染,并降低粗体权重,减少中文粗体重影。
- Android 路径兼容:Gradle 配置已允许中文路径构建,并启用 AndroidX。
## 技术栈
| 分类 | 内容 |
| --- | --- |
| 开发语言 | C++17、QML |
| UI 框架 | Qt Quick / Qt Quick Controls 2 |
| Qt 模块 | Core、Gui、Qml、Quick、QuickControls2、Network |
| 构建系统 | CMake 3.16+ |
| 桌面编译器 | MinGW / MSVC / Clang / GCC |
| Android 构建 | Qt for Android、Android SDK、Android NDK、JDK |
| 加密处理 | AES-128-CBC,代码位于 `src/CryptoUtils.cpp` |
## 目录结构
```text
rewrite_qt/
├── android/ # Android 打包补充资源和 Gradle 配置
│ ├── gradle.properties
│ └── res/mipmap-*/ # Android 启动图标
├── assets/ # 桌面端图标资源
├── cmake/ # 平台相关模板文件
├── qml/
│ └── Main.qml # 主界面
├── src/
│ ├── ApiClient.* # 服务端 API 客户端
│ ├── AppController.* # QML 与业务逻辑中枢
│ ├── AttendanceTypes.h # 考勤数据结构
│ ├── CryptoUtils.* # AES 解密工具
│ ├── *ListModel.* # 暴露给 QML 的列表模型
│ └── crypto/tiny_aes.hpp # AES 底层实现
├── CMakeLists.txt
├── LICENSE
├── README.md
└── main.cpp
```
## 功能说明
### 考勤记录
考勤记录页用于查看和管理服务端同步下来的签到记录。
- 查看总记录数、今日签到数、当前筛选结果数。
- 按班级、学号、姓名关键字筛选记录。
- 按全部时间、今天、最近 7 天、最近 30 天、指定日期筛选。
- 刷新最新记录。
- 导出当前筛选结果为 CSV。
- 清空服务端全部考勤记录。
导出字段:
```text
签到时间,班级编号,班级名称,学号,姓名,置信度,状态
```
### 统计分析
统计分析页基于当前加载的考勤记录在本地计算统计结果。
- 支持今天、最近 7 天、最近 30 天、本月等快捷范围。
- 支持手动指定开始日期和结束日期。
- 展示统计学生数、统计记录数、统计范围。
- 按学生汇总总签到次数、今日签到次数、最后签到时间和平均置信度。
- 导出统计报表为 CSV。
导出字段:
```text
学号,姓名,总签到次数,今日签到,最后签到时间,平均置信度
```
### 配置中心
配置中心用于维护客户端与服务端交互所需的配置。
- 设置服务端地址。
- 测试服务端连接状态。
- 管理签到时段。
- 从服务端同步签到时段。
- 管理学号到姓名的映射。
- 管理班级编号到班级名称的映射。
- 保存配置并同步到服务端。
默认签到时段:
| 名称 | 开始时间 | 结束时间 | 默认状态 |
| --- | --- | --- | --- |
| 上午签到 | 08:00 | 09:30 | 启用 |
| 中午签退 | 12:00 | 13:00 | 启用 |
| 下午签到 | 14:00 | 15:00 | 启用 |
| 晚上签退 | 17:00 | 18:30 | 启用 |
### 人脸管理
人脸管理页用于维护 K210 上传的人脸照片记录与真实学号之间的映射关系。
- 同步人脸照片 ID。
- 同步服务端学生 ID。
- 展示人脸照片或无照片状态。
- 为人脸 ID 绑定真实学号。
- 保存人脸 ID 与学号映射。
- 删除指定人脸信息。
- 可选择同时删除该学生相关考勤记录。
照片访问地址格式:
```text
{serverUrl}/api/face_photo/{id}?ts={timestamp}
```
其中 `ts` 用于避免缓存导致照片不刷新。
## 服务端配置
默认服务端地址:
```text
http://127.0.0.1:8082
```
应用启动后会从本地配置读取已保存的服务端地址。用户可在“配置中心”中修改并保存。
本地保存键名:
```text
server/url
```
## 服务端 API
客户端会基于服务端地址拼接 `/api` 前缀访问接口。
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/api/records?limit={limit}&offset={offset}` | 获取考勤记录 |
| `GET` | `/api/statistics` | 获取总数、今日数和服务端时间 |
| `POST` | `/api/clear` | 清空全部记录 |
| `DELETE` | `/api/record?timestamp={timestamp}&student_id={studentId}` | 删除单条记录 |
| `GET` | `/api/timeslots` | 获取签到时段 |
| `POST` | `/api/timeslots` | 保存签到时段 |
| `GET` | `/api/mapping_config` | 获取学生和班级映射 |
| `POST` | `/api/mapping_config` | 保存学生和班级映射 |
| `GET` | `/api/face_id_mapping` | 获取人脸 ID 到学号映射 |
| `POST` | `/api/face_id_mapping` | 保存人脸 ID 到学号映射 |
| `GET` | `/api/face_photos` | 获取照片 ID 列表 |
| `GET` | `/api/all_student_ids` | 获取所有学生 ID |
| `GET` | `/api/face_photo/{id}` | 获取人脸照片 |
| `DELETE` | `/api/face/{studentId}` | 删除人脸信息 |
| `DELETE` | `/api/face/{studentId}?delete_records=true` | 删除人脸信息并删除相关考勤记录 |
| `GET` | `/api/health` | 健康检查 |
### 考勤记录格式
`/api/records` 解密后应返回 JSON 数组。单条记录示例:
```json
{
"timestamp": "2026-04-24 08:10:23",
"student_id": "20240001",
"name": "张三",
"confidence": 0.9821,
"status": "success",
"class_id": "1"
}
```
### 统计数据格式
```json
{
"total": 120,
"today": 8,
"server_time": "2026-04-24 10:30:00"
}
```
### 签到时段格式
```json
{
"time_slots": [
{
"name": "上午签到",
"start_time": "08:00",
"end_time": "09:30",
"enabled": true
}
]
}
```
### 映射配置格式
```json
{
"student_name_map": {
"20240001": "张三"
},
"class_name_map": {
"1": "软件工程一班"
}
}
```
### 人脸映射格式
```json
{
"mappings": {
"face_001": "20240001"
}
}
```
## 加密响应
客户端对 `/api/records` 和 `/api/statistics` 请求添加请求头:
```text
X-Encrypted-Response: true
```
客户端期望服务端返回 Base64 编码的 AES-128-CBC 密文。
解密参数位于 `src/CryptoUtils.cpp`:
| 参数 | 值 |
| --- | --- |
| 算法 | AES-128-CBC |
| Padding | PKCS#7 |
| Key | `2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c` |
| IV | `00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f` |
## 本地配置文件
应用使用 `QSettings` 和 `QStandardPaths::AppDataLocation` 保存本地状态。
| 配置 | 保存位置 |
| --- | --- |
| 服务端地址 | `QSettings`,键名 `server/url` |
| 学生和班级映射 | `mapping_config.json` |
| 签到时段配置 | `timeslot_config.json` |
应用信息:
```text
ApplicationName: 人脸识别考勤系统
ApplicationVersion: 3.0.0
OrganizationName: STM32 Face Recognition
```
## 构建环境
### 基础要求
- Qt 6.2 或更高版本
- CMake 3.16 或更高版本
- 支持 C++17 的编译器
Windows 桌面端推荐使用 Qt 6.x MinGW 64-bit Kit。
Android 端需要额外配置:
- Qt for Android
- Android SDK
- Android NDK
- JDK
- Android 平台版本,例如 `android-35`
## 使用 Qt Creator 构建
1. 打开 Qt Creator。
2. 选择项目根目录下的 `CMakeLists.txt`。
3. 选择对应 Kit,例如 Desktop Qt 6.x 或 Qt for Android。
4. 等待 CMake 配置完成。
5. 构建目标 `apprewrite_qt`。
6. 桌面端可直接运行;Android 端可部署到设备或模拟器。
## 命令行构建
Windows 示例:
```powershell
cmake -S . -B build -DCMAKE_PREFIX_PATH=D:/Qt/6.10.0/mingw_64
cmake --build build --target apprewrite_qt
```
Linux 示例:
```bash
cmake -S . -B build -DCMAKE_PREFIX_PATH=/path/to/Qt/6.x/gcc_64
cmake --build build --target apprewrite_qt
```
安装到系统路径:
```bash
cmake --install build
```
## Windows 独立运行
直接双击构建出来的 `apprewrite_qt.exe` 时,如果提示找不到 `Qt6Gui.dll`、`Qt6Core.dll` 等动态库,需要使用 `windeployqt` 收集运行时依赖。
示例:
```powershell
D:\Qt\6.10.0\mingw_64\bin\windeployqt.exe "D:\path\to\build\Desktop_Qt_6_10_0_MinGW_64_bit-Debug\apprewrite_qt.exe"
```
如果项目路径包含中文导致部署工具输出异常,建议复制 exe 到纯英文路径后部署:
```powershell
mkdir C:\tmp\rewrite_qt_release
copy "D:\path\to\apprewrite_qt.exe" C:\tmp\rewrite_qt_release\
D:\Qt\6.10.0\mingw_64\bin\windeployqt.exe C:\tmp\rewrite_qt_release\apprewrite_qt.exe
```
部署完成后,将整个发布目录一起复制到目标电脑运行。
## Android 构建说明
Android 相关配置位于 `CMakeLists.txt`:
```cmake
QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android"
QT_ANDROID_APP_NAME "${REWRITE_QT_DISPLAY_NAME}"
QT_ANDROID_APP_ICON "@mipmap/ic_launcher"
```
Android 补充配置位于 `android/gradle.properties`:
```properties
android.overridePathCheck=true
android.useAndroidX=true
```
说明:
- `android.overridePathCheck=true` 用于绕过 Windows 下 Android Gradle 对中文路径的检查。
- `android.useAndroidX=true` 用于启用 AndroidX,避免依赖检查失败。
- Android 桌面显示名由 `QT_ANDROID_APP_NAME` 设置为“人脸识别考勤系统”。
- Android 启动图标位于 `android/res/mipmap-*`。
如果 Android 构建目录已经生成过旧的 Gradle 工程,修改配置后建议清理对应构建目录或重新运行 CMake 配置。
## 图标资源
桌面端图标位于 `assets/`:
```text
assets/favicon.ico
assets/favicon-16x16.png
assets/favicon-32x32.png
assets/favicon-48x48.png
assets/favicon-64x64.png
assets/favicon-128x128.png
assets/favicon-192x192.png
assets/favicon-256x256.png
```
Android 启动图标位于:
```text
android/res/mipmap-mdpi/ic_launcher.png
android/res/mipmap-hdpi/ic_launcher.png
android/res/mipmap-xhdpi/ic_launcher.png
android/res/mipmap-xxhdpi/ic_launcher.png
android/res/mipmap-xxxhdpi/ic_launcher.png
```
图标尺寸:
| 目录 | 尺寸 |
| --- | --- |
| `mipmap-mdpi` | 48x48 |
| `mipmap-hdpi` | 72x72 |
| `mipmap-xhdpi` | 96x96 |
| `mipmap-xxhdpi` | 144x144 |
| `mipmap-xxxhdpi` | 192x192 |
## 界面与移动端适配
主界面位于 `qml/Main.qml`,采用 `ApplicationWindow`、`StackLayout`、`GridLayout`、`ColumnLayout` 等组件组织页面。
当前界面包含五个页面:
- 考勤记录
- 统计分析
- 配置中心
- 人脸管理
- 关于
移动端适配要点:
- `phoneLayout` 用于 560px 以下窄屏布局。
- 顶部页签使用统一的 `MobileNavTabButton` 样式。
- 考勤筛选区使用两列布局,按钮等宽排布。
- 统计页前两张统计卡左右排布,统计范围独占一整行。
- 表格在窄屏隐藏部分非关键列,避免横向溢出。
- 空状态卡片会根据可用高度自动缩小。
- Android 端使用 `sans-serif` 字体和 `Font.Medium` 加粗权重,减少粗体重影。
## 运行状态提示
客户端通过底部 Toast 展示状态信息。服务端不可达时,提示会被压缩为简短文案:
```text
服务器未连接,稍后重试
```
重连过程中显示:
```text
正在重连服务器
```
这样可以避免将底层网络错误、URL 和长异常文本直接展示给用户。
## 常见问题
### Android 构建提示项目路径包含中文
项目已经在 `android/gradle.properties` 中配置:
```properties
android.overridePathCheck=true
```
如果仍然失败,清理 Android 构建目录后重新构建。
### Android 构建提示 AndroidX 未启用
项目已经在 `android/gradle.properties` 中配置:
```properties
android.useAndroidX=true
```
如果旧构建目录仍然报错,删除生成的 `android-build-*` 目录后重新部署。
### Android 端粗体文字重影
项目在 Android 端启用了原生文字渲染:
```cpp
QQuickWindow::setTextRenderType(QQuickWindow::NativeTextRendering);
```
同时 QML 中将 Android 端粗体权重降为 `Font.Medium`,避免合成粗体产生重影。
### Windows 双击 exe 提示缺少 Qt DLL
使用 `windeployqt` 部署依赖后再运行。部署时建议使用和构建 Kit 一致的 Qt 目录。
### 修改图标后没有变化
重新运行 CMake 配置并重新构建。Windows 资源管理器可能缓存 exe 图标,可尝试换构建目录、改输出文件名或重启资源管理器。
### 启动后没有数据
检查服务端地址是否正确,并在配置中心点击“测试连接”。如果服务端不可达,底部会显示简短连接失败提示。
### 姓名或班级显示不正确
在配置中心维护学生姓名映射和班级名称映射,保存后记录列表和统计结果会重新计算。
### 人脸照片不显示
检查 `/api/face_photos` 是否返回照片 ID,并确认 `/api/face_photo/{id}` 可以访问。
## 仓库与作者
仓库地址:
```text
ssh://git@git.yzfh.eu.cc:2084/yzfh/qt.git
```
作者:
```text
纪田野
```
关于页中也展示了仓库地址和查看账号信息。若项目公开发布,请避免在仓库中提交真实密码。
## 维护建议
- 不要提交 `build/`、`build-codex/` 等构建目录。
- 修改 QML 后建议运行 `qmlformat` 保持格式一致。
- 服务端记录量超过 1000 条时,需要扩展分页加载逻辑。
- 服务端加密参数变化时,需要同步修改 `src/CryptoUtils.cpp`。
- 发布桌面版本时,使用 Qt 部署工具收集运行时依赖。
- 发布 Android 版本时,建议补充签名配置和 release 构建流程。
- 如果需要修改历史提交作者,请在推送前完成,避免远程协作历史冲突。
## 开源协议
本项目采用 MIT License 开源,详见 `LICENSE` 文件。
+2
View File
@@ -0,0 +1,2 @@
android.overridePathCheck=true
android.useAndroidX=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+1
View File
@@ -0,0 +1 @@
IDI_ICON1 ICON "@REWRITE_QT_APP_ICON@"
+7
View File
@@ -0,0 +1,7 @@
[Desktop Entry]
Type=Application
Name=人脸识别考勤系统
Exec=apprewrite_qt
Icon=rewrite_qt
Terminal=false
Categories=Utility;
+63
View File
@@ -0,0 +1,63 @@
#include <QGuiApplication>
#include <QIcon>
#include <QLibraryInfo>
#include <QDir>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickStyle>
#include <QQuickWindow>
#include "src/AppController.h"
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
QIcon appIcon(QStringLiteral(":/assets/favicon.ico"));
appIcon.addFile(QStringLiteral(":/assets/favicon-256x256.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-192x192.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-128x128.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-64x64.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-48x48.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-32x32.png"));
appIcon.addFile(QStringLiteral(":/assets/favicon-16x16.png"));
app.setWindowIcon(appIcon);
QCoreApplication::setApplicationName("人脸识别考勤系统");
QCoreApplication::setApplicationVersion("3.0.0");
QCoreApplication::setOrganizationName("STM32 Face Recognition");
QQuickStyle::setStyle("Basic");
#if defined(Q_OS_ANDROID)
QQuickWindow::setTextRenderType(QQuickWindow::NativeTextRendering);
#endif
AppController controller;
QQmlApplicationEngine engine;
const QString qmlImportPath = QLibraryInfo::path(QLibraryInfo::QmlImportsPath);
if (!qmlImportPath.isEmpty()) {
engine.addImportPath(qmlImportPath);
}
#if defined(REWRITE_QT_INSTALL_QML)
const QString qtInstallQmlPath = QStringLiteral(REWRITE_QT_INSTALL_QML);
if (QDir(qtInstallQmlPath).exists()) {
engine.addImportPath(qtInstallQmlPath);
}
#endif
engine.rootContext()->setContextProperty("appController", &controller);
const QUrl url(QStringLiteral("qrc:/qml/Main.qml"));
QObject::connect(
&engine,
&QQmlApplicationEngine::objectCreated,
&app,
[url](QObject *obj, const QUrl &objUrl) {
if (!obj && url == objUrl) {
QCoreApplication::exit(-1);
}
},
Qt::QueuedConnection);
engine.load(url);
return app.exec();
}
+2945
View File
File diff suppressed because it is too large Load Diff
+673
View File
@@ -0,0 +1,673 @@
#include "ApiClient.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDebug>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QProcessEnvironment>
#include <QUrl>
#include <QUrlQuery>
#include "CryptoUtils.h"
namespace {
constexpr int NetworkTimeoutMs = 5000;
constexpr auto DefaultApiToken = "change-me-token";
}
ApiClient::ApiClient(QObject *parent)
: QObject(parent)
, m_serverUrl("http://127.0.0.1:8082")
{
}
void ApiClient::setServerUrl(const QString &url)
{
const QString trimmed = url.trimmed();
if (!trimmed.isEmpty()) {
m_serverUrl = trimmed;
}
}
QString ApiClient::serverUrl() const
{
return m_serverUrl;
}
QString ApiClient::buildUrl(const QString &endpoint, const QMap<QString, QString> &params) const
{
QString baseUrl = m_serverUrl.trimmed();
while (baseUrl.endsWith('/')) {
baseUrl.chop(1);
}
QString url = baseUrl + "/api" + endpoint;
if (!params.isEmpty()) {
QUrlQuery query;
for (auto it = params.begin(); it != params.end(); ++it) {
query.addQueryItem(it.key(), it.value());
}
url += "?" + query.toString(QUrl::FullyEncoded);
}
return url;
}
QNetworkRequest ApiClient::createRequest(const QString &url, bool encryptedResponse, bool jsonBody) const
{
QNetworkRequest request{QUrl(url)};
if (encryptedResponse) {
request.setRawHeader("X-Encrypted-Response", "true");
}
const QString apiToken = QProcessEnvironment::systemEnvironment()
.value(QStringLiteral("ATTENDANCE_API_TOKEN"),
QString::fromLatin1(DefaultApiToken));
request.setRawHeader("X-Api-Token", apiToken.toUtf8());
request.setRawHeader("Accept", "application/json");
if (jsonBody) {
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
}
request.setTransferTimeout(NetworkTimeoutMs);
return request;
}
void ApiClient::fetchSyncState(qulonglong lastVersion)
{
const QString url = buildUrl("/v1/sync/state", {
{"client", "qt"},
{"last_version", QString::number(lastVersion)}
});
qInfo() << "[ApiClient] GET sync state" << url;
QNetworkReply *reply = m_networkManager.get(createRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("同步状态加载失败"), reply);
reply->deleteLater();
return;
}
const QJsonObject root = QJsonDocument::fromJson(data).object();
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;
}
const QJsonObject state = root.value("state").toObject();
if (state.contains("records")) {
emit recordsFetched(parseRecords(QJsonDocument(state.value("records").toArray()).toJson(QJsonDocument::Compact)));
}
if (state.contains("statistics")) {
const QJsonObject object = state.value("statistics").toObject();
emit statisticsFetched(object.value("total").toInt(),
object.value("today").toInt(),
object.value("server_time").toString());
}
if (state.contains("time_slots")) {
QVector<TimeSlot> timeSlots;
const QJsonArray array = state.value("time_slots").toArray();
for (const QJsonValue &value : array) {
const QJsonObject object = value.toObject();
timeSlots.push_back({
object.value("name").toString(),
QTime::fromString(object.value("start_time").toString(), "HH:mm"),
QTime::fromString(object.value("end_time").toString(), "HH:mm"),
object.value("enabled").toBool(true)
});
}
emit timeSlotsFetched(timeSlots);
}
if (state.contains("mapping_config")) {
const QJsonObject mappingConfig = state.value("mapping_config").toObject();
QMap<QString, QString> studentMap;
QMap<QString, QString> classMap;
const QJsonObject studentObject = mappingConfig.value("student_name_map").toObject();
for (auto it = studentObject.begin(); it != studentObject.end(); ++it) {
studentMap[it.key()] = it.value().toString();
}
const QJsonObject classObject = mappingConfig.value("class_name_map").toObject();
for (auto it = classObject.begin(); it != classObject.end(); ++it) {
classMap[it.key()] = it.value().toString();
}
emit mappingConfigFetched(studentMap, classMap);
}
if (state.contains("face_mappings")) {
const QJsonObject mappings = state.value("face_mappings").toObject();
QMap<QString, QString> mapping;
for (auto it = mappings.begin(); it != mappings.end(); ++it) {
mapping[it.key()] = it.value().toString();
}
emit faceIdMappingFetched(mapping);
}
if (state.contains("face_photos")) {
QStringList ids;
const QJsonArray photos = state.value("face_photos").toArray();
for (const QJsonValue &value : photos) {
ids.push_back(value.toObject().value("id").toString());
}
emit facePhotosFetched(ids);
}
if (state.contains("all_student_ids")) {
QStringList ids;
const QJsonArray array = state.value("all_student_ids").toArray();
for (const QJsonValue &value : array) {
ids.push_back(value.toString());
}
emit allStudentIdsFetched(ids);
}
emit syncStateFetched(version, true);
reply->deleteLater();
});
}
QByteArray ApiClient::maybeDecrypt(const QByteArray &data, bool encrypted) const
{
if (!encrypted) {
return data;
}
const QByteArray trimmed = data.trimmed();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return trimmed;
}
return CryptoUtils::decryptAes128CbcBase64(trimmed);
}
bool ApiClient::isConnectionError(QNetworkReply::NetworkError error) const
{
switch (error) {
case QNetworkReply::ConnectionRefusedError:
case QNetworkReply::RemoteHostClosedError:
case QNetworkReply::HostNotFoundError:
case QNetworkReply::TimeoutError:
case QNetworkReply::OperationCanceledError:
case QNetworkReply::SslHandshakeFailedError:
case QNetworkReply::TemporaryNetworkFailureError:
case QNetworkReply::NetworkSessionFailedError:
case QNetworkReply::BackgroundRequestNotAllowedError:
case QNetworkReply::TooManyRedirectsError:
case QNetworkReply::InsecureRedirectError:
case QNetworkReply::UnknownNetworkError:
case QNetworkReply::UnknownProxyError:
case QNetworkReply::UnknownContentError:
case QNetworkReply::ProtocolFailure:
case QNetworkReply::UnknownServerError:
return true;
default:
return false;
}
}
void ApiClient::emitNetworkError(const QString &prefix, QNetworkReply *reply)
{
emit networkError(prefix);
if (isConnectionError(reply->error())) {
emit connectionUnavailable(prefix);
}
}
QVector<AttendanceRecord> ApiClient::parseRecords(const QByteArray &data) const
{
QVector<AttendanceRecord> records;
const QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isArray()) {
return records;
}
const QJsonArray array = doc.array();
records.reserve(array.size());
for (const QJsonValue &value : array) {
const QJsonObject object = value.toObject();
AttendanceRecord record;
record.timestamp = object.value("timestamp").toString().trimmed();
record.studentId = object.value("student_id").toString().trimmed();
record.originalName = object.value("name").toString().trimmed();
record.confidence = object.value("confidence").toDouble();
const QJsonValue statusValue = object.value("status");
if (statusValue.isBool()) {
record.status = statusValue.toBool() ? QStringLiteral("success") : QStringLiteral("failed");
} else if (statusValue.isDouble()) {
record.status = QString::number(statusValue.toInt());
} else {
record.status = statusValue.toString().trimmed();
}
record.classId = object.value("class_id").toString().trimmed();
if (record.classId.isEmpty()) {
record.classId = "0";
}
if (!record.timestamp.startsWith("0000-") && !record.timestamp.isEmpty() && !record.studentId.isEmpty()) {
records.push_back(record);
}
}
return records;
}
void ApiClient::fetchRecords(int limit, int offset)
{
const QString url = buildUrl("/v1/attendance/records", {
{"limit", QString::number(limit)},
{"offset", QString::number(offset)}
});
qInfo() << "[ApiClient] GET" << url;
QNetworkReply *reply = m_networkManager.get(createRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray rawData = reply->readAll();
const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qInfo() << "[ApiClient] records status=" << statusCode
<< "error=" << reply->error()
<< "bytes=" << rawData.size()
<< "body=" << rawData.left(200);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载记录失败"), reply);
reply->deleteLater();
return;
}
const QByteArray data = maybeDecrypt(rawData, false);
if (data.isEmpty()) {
emit networkError("记录数据为空");
reply->deleteLater();
return;
}
QJsonParseError jsonError{};
const QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError);
if (jsonError.error != QJsonParseError::NoError || !doc.isArray()) {
emit networkError(QStringLiteral("记录数据解析失败: %1").arg(jsonError.errorString()));
reply->deleteLater();
return;
}
const QVector<AttendanceRecord> records = parseRecords(data);
qInfo() << "[ApiClient] parsed records=" << records.size();
emit recordsFetched(records);
reply->deleteLater();
});
}
void ApiClient::fetchStatistics()
{
const QString url = buildUrl("/v1/attendance/statistics");
qInfo() << "[ApiClient] GET" << url;
QNetworkReply *reply = m_networkManager.get(createRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray rawData = reply->readAll();
const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qInfo() << "[ApiClient] statistics status=" << statusCode
<< "error=" << reply->error()
<< "bytes=" << rawData.size()
<< "body=" << rawData.left(200);
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载统计失败"), reply);
reply->deleteLater();
return;
}
const QByteArray data = maybeDecrypt(rawData, false);
if (data.isEmpty()) {
emit networkError("统计数据为空");
reply->deleteLater();
return;
}
const QJsonDocument doc = QJsonDocument::fromJson(data);
const QJsonObject object = doc.object();
emit statisticsFetched(object.value("total").toInt(),
object.value("today").toInt(),
object.value("server_time").toString());
reply->deleteLater();
});
}
void ApiClient::clearAllRecords()
{
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/attendance/records/clear"), false, true), QByteArray());
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("清空失败: %1").arg(reply->errorString()));
}
emit recordsCleared(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit recordsCleared(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::deleteRecord(const QString &timestamp, const QString &studentId)
{
QNetworkReply *reply = m_networkManager.deleteResource(createRequest(buildUrl("/v1/attendance/records", {
{"timestamp", timestamp},
{"student_id", studentId}
})));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("记录删除失败: %1").arg(reply->errorString()));
}
emit recordDeleted(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit recordDeleted(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::fetchTimeSlots()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/time-slots")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载时间段失败"), reply);
reply->deleteLater();
return;
}
QVector<TimeSlot> timeSlots;
const QJsonObject root = QJsonDocument::fromJson(data).object();
const QJsonArray array = root.value("time_slots").toArray();
for (const QJsonValue &value : array) {
const QJsonObject object = value.toObject();
timeSlots.push_back({
object.value("name").toString(),
QTime::fromString(object.value("start_time").toString(), "HH:mm"),
QTime::fromString(object.value("end_time").toString(), "HH:mm"),
object.value("enabled").toBool(true)
});
}
emit timeSlotsFetched(timeSlots);
reply->deleteLater();
});
}
void ApiClient::uploadTimeSlots(const QVector<TimeSlot> &timeSlots)
{
QJsonArray array;
for (const TimeSlot &slot : timeSlots) {
QJsonObject object;
object["name"] = slot.name;
object["start_time"] = slot.startTime.toString("HH:mm");
object["end_time"] = slot.endTime.toString("HH:mm");
object["enabled"] = slot.enabled;
array.append(object);
}
QJsonObject payload;
payload["time_slots"] = array;
const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact);
qInfo() << "[ApiClient] POST" << buildUrl("/v1/config/time-slots")
<< "bytes=" << body.size()
<< "body=" << body.left(300);
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/time-slots"), false, true), body);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("时间段同步失败: %1").arg(reply->errorString()));
}
emit timeSlotsUploaded(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit timeSlotsUploaded(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::fetchMappingConfig()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/mappings")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载映射配置失败"), reply);
reply->deleteLater();
return;
}
const QJsonObject root = QJsonDocument::fromJson(data).object();
QMap<QString, QString> studentMap;
QMap<QString, QString> classMap;
const QJsonObject studentObject = root.value("student_name_map").toObject();
for (auto it = studentObject.begin(); it != studentObject.end(); ++it) {
studentMap[it.key()] = it.value().toString();
}
const QJsonObject classObject = root.value("class_name_map").toObject();
for (auto it = classObject.begin(); it != classObject.end(); ++it) {
classMap[it.key()] = it.value().toString();
}
emit mappingConfigFetched(studentMap, classMap);
reply->deleteLater();
});
}
void ApiClient::uploadMappingConfig(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &classNameMap)
{
QJsonObject studentObject;
for (auto it = studentNameMap.begin(); it != studentNameMap.end(); ++it) {
studentObject[it.key()] = it.value();
}
QJsonObject classObject;
for (auto it = classNameMap.begin(); it != classNameMap.end(); ++it) {
classObject[it.key()] = it.value();
}
QJsonObject payload;
payload["student_name_map"] = studentObject;
payload["class_name_map"] = classObject;
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/mappings"), false, true),
QJsonDocument(payload).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("映射保存失败: %1").arg(reply->errorString()));
}
emit mappingConfigUploaded(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit mappingConfigUploaded(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::fetchFaceIdMapping()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/mappings")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载人脸映射失败"), reply);
reply->deleteLater();
return;
}
const QJsonObject mappings = QJsonDocument::fromJson(data).object().value("mappings").toObject();
QMap<QString, QString> mapping;
for (auto it = mappings.begin(); it != mappings.end(); ++it) {
mapping[it.key()] = it.value().toString();
}
emit faceIdMappingFetched(mapping);
reply->deleteLater();
});
}
void ApiClient::uploadFaceIdMapping(const QMap<QString, QString> &mapping)
{
QJsonObject mappings;
for (auto it = mapping.begin(); it != mapping.end(); ++it) {
if (!it.value().trimmed().isEmpty()) {
mappings[it.key()] = it.value();
}
}
QJsonObject payload;
payload["mappings"] = mappings;
QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/faces/mappings"), false, true),
QJsonDocument(payload).toJson(QJsonDocument::Compact));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("人脸映射保存失败: %1").arg(reply->errorString()));
}
emit faceIdMappingUploaded(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit faceIdMappingUploaded(object.value("success").toBool(), object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::fetchFacePhotos()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/photos")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载照片列表失败"), reply);
reply->deleteLater();
return;
}
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());
}
emit facePhotosFetched(ids);
reply->deleteLater();
});
}
void ApiClient::fetchFacePhoto(const QString &faceId)
{
if (faceId.trimmed().isEmpty()) {
emit facePhotoFetched(faceId, {}, false, QStringLiteral("人脸ID为空"));
return;
}
const QString encodedId = QString::fromUtf8(QUrl::toPercentEncoding(faceId.trimmed()));
QNetworkRequest request = createRequest(buildUrl(QStringLiteral("/v1/faces/photos/%1").arg(encodedId)));
request.setRawHeader("Accept", "image/jpeg,*/*");
QNetworkReply *reply = m_networkManager.get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply, faceId]() {
const QByteArray data = reply->readAll();
const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->error() != QNetworkReply::NoError || statusCode >= 400 || data.isEmpty()) {
const QString message = reply->error() != QNetworkReply::NoError
? reply->errorString()
: QStringLiteral("HTTP %1").arg(statusCode);
emit facePhotoFetched(faceId, {}, false, message);
reply->deleteLater();
return;
}
emit facePhotoFetched(faceId, data, true, QString());
reply->deleteLater();
});
}
void ApiClient::fetchAllStudentIds()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/student-ids")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emitNetworkError(QStringLiteral("加载学生ID失败"), reply);
reply->deleteLater();
return;
}
QStringList ids;
const QJsonArray array = QJsonDocument::fromJson(data).object().value("student_ids").toArray();
for (const QJsonValue &value : array) {
ids.push_back(value.toString());
}
emit allStudentIdsFetched(ids);
reply->deleteLater();
});
}
void ApiClient::deleteFace(const QString &studentId, bool deleteRecords)
{
QString baseUrl = m_serverUrl.trimmed();
while (baseUrl.endsWith('/')) {
baseUrl.chop(1);
}
QString url = baseUrl + "/api/v1/faces/" + QString::fromLatin1(QUrl::toPercentEncoding(studentId));
if (deleteRecords) {
url += "?delete_records=true";
}
QNetworkReply *reply = m_networkManager.deleteResource(createRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply, studentId]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
if (isConnectionError(reply->error())) {
emit connectionUnavailable(QStringLiteral("删除失败: %1").arg(reply->errorString()));
}
emit faceDeleted(false, studentId, 0, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
emit faceDeleted(object.value("success").toBool(),
studentId,
object.value("records_deleted").toInt(),
object.value("message").toString());
reply->deleteLater();
});
}
void ApiClient::checkHealth()
{
QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/system/health")));
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QByteArray data = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
emit healthChecked(false, reply->errorString());
reply->deleteLater();
return;
}
const QJsonObject object = QJsonDocument::fromJson(data).object();
const bool healthy = object.value("status").toString() == "healthy";
emit healthChecked(healthy, healthy ? QStringLiteral("连接成功") : QStringLiteral("服务器无响应"));
reply->deleteLater();
});
}
+75
View File
@@ -0,0 +1,75 @@
#ifndef API_CLIENT_H
#define API_CLIENT_H
#include <QObject>
#include <QByteArray>
#include <QMap>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStringList>
#include <QVector>
#include "AttendanceTypes.h"
class ApiClient : public QObject
{
Q_OBJECT
public:
explicit ApiClient(QObject *parent = nullptr);
void setServerUrl(const QString &url);
QString serverUrl() const;
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();
void uploadTimeSlots(const QVector<TimeSlot> &timeSlots);
void fetchMappingConfig();
void uploadMappingConfig(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &classNameMap);
void fetchFaceIdMapping();
void uploadFaceIdMapping(const QMap<QString, QString> &mapping);
void fetchFacePhotos();
void fetchFacePhoto(const QString &faceId);
void fetchAllStudentIds();
void deleteFace(const QString &studentId, bool deleteRecords);
void checkHealth();
signals:
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 mappingConfigFetched(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &classNameMap);
void mappingConfigUploaded(bool success, const QString &message);
void faceIdMappingFetched(const QMap<QString, QString> &mapping);
void faceIdMappingUploaded(bool success, const QString &message);
void facePhotosFetched(const QStringList &photoIds);
void facePhotoFetched(const QString &faceId, const QByteArray &data, bool success, const QString &message);
void allStudentIdsFetched(const QStringList &studentIds);
void faceDeleted(bool success, const QString &studentId, int recordsDeleted, const QString &message);
void healthChecked(bool healthy, const QString &message);
void connectionUnavailable(const QString &message);
void networkError(const QString &message);
void syncStateFetched(qulonglong version, bool changed);
private:
QString buildUrl(const QString &endpoint, const QMap<QString, QString> &params = {}) const;
QNetworkRequest createRequest(const QString &url, bool encryptedResponse = false, bool jsonBody = false) const;
QVector<AttendanceRecord> parseRecords(const QByteArray &data) const;
QByteArray maybeDecrypt(const QByteArray &data, bool encrypted) const;
void emitNetworkError(const QString &prefix, QNetworkReply *reply);
bool isConnectionError(QNetworkReply::NetworkError error) const;
QNetworkAccessManager m_networkManager;
QString m_serverUrl;
};
#endif
+806
View File
@@ -0,0 +1,806 @@
#include "AppController.h"
#include <QDir>
#include <QDebug>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
#include <QStandardPaths>
#include <QStringConverter>
#include <QSet>
#include <QTextStream>
#include <QUrl>
#include <algorithm>
#include <utility>
namespace {
constexpr int ReconnectIntervalMs = 5000;
QDate parseDate(const QString &value, const QDate &fallback)
{
const QDate date = QDate::fromString(value, Qt::ISODate);
return date.isValid() ? date : fallback;
}
bool isSuccessStatus(const QString &status)
{
const QString normalized = status.trimmed().toLower();
return normalized == "success"
|| normalized == "succeeded"
|| normalized == "ok"
|| normalized == "pass"
|| normalized == "passed"
|| normalized == "present"
|| normalized == "recognized"
|| normalized == "verified"
|| normalized == "valid"
|| normalized == "200"
|| normalized == "true"
|| normalized == "1"
|| normalized == QStringLiteral("成功")
|| normalized == QStringLiteral("签到成功")
|| normalized == QStringLiteral("正常");
}
QString csvCell(const QString &value)
{
QString escaped = value;
const bool needsQuotes = escaped.contains(',')
|| escaped.contains('"')
|| escaped.contains('\n')
|| escaped.contains('\r');
escaped.replace("\"", "\"\"");
return needsQuotes ? QStringLiteral("\"%1\"").arg(escaped) : escaped;
}
QString conciseStatusMessage(const QString &message)
{
if (message.contains(QStringLiteral("Error transferring"), Qt::CaseInsensitive)
|| message.contains(QStringLiteral("Connection refused"), Qt::CaseInsensitive)
|| message.contains(QStringLiteral("Host not found"), Qt::CaseInsensitive)
|| message.contains(QStringLiteral("Network unreachable"), Qt::CaseInsensitive)
|| message.contains(QStringLiteral("timed out"), Qt::CaseInsensitive)) {
return QStringLiteral("服务器未连接,稍后重试");
}
return message;
}
QString safeFileName(QString value)
{
value = value.trimmed();
for (int i = 0; i < value.size(); ++i) {
QChar ch = value.at(i);
if (!ch.isLetterOrNumber() && ch != '_' && ch != '-' && ch != '.') {
value[i] = '_';
}
}
return value.isEmpty() ? QStringLiteral("unknown") : value;
}
}
AppController::AppController(QObject *parent)
: QObject(parent)
{
loadLocalState();
m_apiClient.setServerUrl(m_serverUrl);
m_reconnectTimer.setSingleShot(true);
connect(&m_reconnectTimer, &QTimer::timeout, this, [this]() {
runReconnectCheck();
});
connect(&m_apiClient, &ApiClient::recordsFetched, this, [this](const QVector<AttendanceRecord> &records) {
m_allRecords = records;
updateFilterOptions();
rebuildFilteredRecords();
rebuildStatistics();
setStatusMessage(QStringLiteral("已加载 %1 条记录").arg(records.size()));
emit summaryChanged();
});
connect(&m_apiClient, &ApiClient::statisticsFetched, this, [this](int total, int today, const QString &) {
m_totalRecords = total;
m_todayRecords = today;
emit summaryChanged();
});
connect(&m_apiClient, &ApiClient::syncStateFetched, this, [this](qulonglong version, bool changed) {
if (version > 0) {
m_qtSyncVersion = version;
}
setStatusMessage(changed
? QStringLiteral("同步完成,版本 %1").arg(version)
: QStringLiteral("数据已是最新,版本 %1").arg(version));
});
connect(&m_apiClient, &ApiClient::recordsCleared, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("已清空全部记录") : QStringLiteral("清空失败: %1").arg(message));
if (success) {
refreshAll();
}
});
connect(&m_apiClient, &ApiClient::recordDeleted, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("记录删除成功") : QStringLiteral("记录删除失败: %1").arg(message));
if (success) {
refreshRecords();
}
});
connect(&m_apiClient, &ApiClient::timeSlotsFetched, this, [this](const QVector<TimeSlot> &timeSlots) {
if (!timeSlots.isEmpty()) {
m_timeSlotModel.setTimeSlots(timeSlots);
saveTimeSlotsToLocal();
setStatusMessage(QStringLiteral("已从服务器同步时间段"));
}
});
connect(&m_apiClient, &ApiClient::timeSlotsUploaded, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("时间段已保存并同步") : QStringLiteral("时间段同步失败: %1").arg(message));
});
connect(&m_apiClient, &ApiClient::mappingConfigFetched, this,
[this](const QMap<QString, QString> &studentNameMap, const QMap<QString, QString> &classNameMap) {
m_studentMappingModel.setMap(studentNameMap);
m_classMappingModel.setMap(classNameMap);
saveMappingsToLocal();
rebuildFilteredRecords();
rebuildStatistics();
setStatusMessage("映射配置已同步");
});
connect(&m_apiClient, &ApiClient::mappingConfigUploaded, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("映射配置已保存") : QStringLiteral("映射保存失败: %1").arg(message));
});
connect(&m_apiClient, &ApiClient::faceIdMappingFetched, this, [this](const QMap<QString, QString> &mapping) {
m_faceIdMapping = mapping;
mergeFaceItems();
});
connect(&m_apiClient, &ApiClient::faceIdMappingUploaded, this, [this](bool success, const QString &message) {
setStatusMessage(success ? QStringLiteral("人脸映射已保存") : QStringLiteral("人脸映射保存失败: %1").arg(message));
});
connect(&m_apiClient, &ApiClient::facePhotosFetched, this, [this](const QStringList &photoIds) {
m_facePhotoIds = photoIds;
m_facePhotoLocalUrls.clear();
mergeFaceItems();
for (const QString &id : m_facePhotoIds) {
m_apiClient.fetchFacePhoto(id);
}
});
connect(&m_apiClient, &ApiClient::facePhotoFetched, this,
[this](const QString &faceId, const QByteArray &data, bool success, const QString &message) {
if (!success) {
qWarning() << "[FacePhoto] load failed:" << faceId << message;
return;
}
const QString path = localFacePhotoPath(faceId);
QFile file(path);
if (!file.open(QIODevice::WriteOnly)) {
qWarning() << "[FacePhoto] cache open failed:" << path;
return;
}
file.write(data);
file.close();
m_facePhotoLocalUrls.insert(faceId, QUrl::fromLocalFile(path).toString());
mergeFaceItems();
});
connect(&m_apiClient, &ApiClient::allStudentIdsFetched, this, [this](const QStringList &studentIds) {
m_allStudentIds = studentIds;
mergeFaceItems();
});
connect(&m_apiClient, &ApiClient::faceDeleted, this, [this](bool success, const QString &studentId, int recordsDeleted, const QString &message) {
if (success) {
setStatusMessage(recordsDeleted > 0
? QStringLiteral("已删除 %1,并清除 %2 条记录").arg(studentId).arg(recordsDeleted)
: QStringLiteral("已删除 %1").arg(studentId));
refreshFaces();
refreshRecords();
} else {
setStatusMessage(QStringLiteral("删除失败: %1").arg(message));
}
});
connect(&m_apiClient, &ApiClient::healthChecked, this, [this](bool healthy, const QString &message) {
const bool wasReconnecting = m_reconnecting;
m_reconnectCheckInFlight = false;
if (healthy) {
stopReconnect();
setStatusMessage(wasReconnecting ? QStringLiteral("服务器已恢复连接,正在同步数据") : QStringLiteral("服务器连接正常"));
if (wasReconnecting) {
refreshAll();
refreshFaces();
}
} else {
startReconnect(message);
}
});
connect(&m_apiClient, &ApiClient::networkError, this, [this](const QString &message) {
setStatusMessage(message);
});
connect(&m_apiClient, &ApiClient::connectionUnavailable, this, [this](const QString &message) {
startReconnect(message);
});
m_reconnecting = true;
emit reconnectStateChanged();
runReconnectCheck();
}
RecordListModel *AppController::recordModel() { return &m_recordModel; }
StatisticsListModel *AppController::statisticsModel() { return &m_statisticsModel; }
TimeSlotListModel *AppController::timeSlotModel() { return &m_timeSlotModel; }
MappingListModel *AppController::studentMappingModel() { return &m_studentMappingModel; }
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; }
QString AppController::serverUrl() const { return m_serverUrl; }
QString AppController::statusMessage() const { return m_statusMessage; }
bool AppController::reconnecting() const { return m_reconnecting; }
int AppController::reconnectAttempt() const { return m_reconnectAttempt; }
int AppController::totalRecords() const { return m_totalRecords; }
int AppController::todayRecords() const { return m_todayRecords; }
int AppController::filteredCount() const { return m_filteredRecords.size(); }
int AppController::loadedRecords() const { return m_allRecords.size(); }
int AppController::statisticsStudentCount() const { return m_statisticsStudentCount; }
int AppController::statisticsRecordCount() const { return m_statisticsRecordCount; }
QString AppController::statisticsRangeText() const
{
return QString("%1 至 %2")
.arg(m_statisticsStartDate.toString("yyyy-MM-dd"))
.arg(m_statisticsEndDate.toString("yyyy-MM-dd"));
}
void AppController::setServerUrl(const QString &url)
{
const QString trimmed = url.trimmed();
if (trimmed.isEmpty() || trimmed == m_serverUrl) {
return;
}
m_serverUrl = trimmed;
m_qtSyncVersion = 0;
m_apiClient.setServerUrl(trimmed);
saveServerUrl();
emit serverUrlChanged();
stopReconnect();
setStatusMessage("服务器地址已更新,正在检查连接");
m_reconnecting = true;
emit reconnectStateChanged();
runReconnectCheck();
}
void AppController::refreshAll()
{
m_apiClient.fetchSyncState(m_qtSyncVersion);
}
void AppController::refreshRecords()
{
m_apiClient.fetchRecords(1000, 0);
}
void AppController::applyFilters(const QString &classId,
const QString &studentId,
const QString &nameFilter,
int dateFilterIndex,
const QString &specificDate)
{
m_selectedClassId = classId.isEmpty() ? QStringLiteral("全部") : classId;
m_selectedStudentId = studentId.isEmpty() ? QStringLiteral("全部") : studentId;
m_nameFilter = nameFilter.trimmed();
m_dateFilterIndex = dateFilterIndex;
m_specificDate = parseDate(specificDate, QDate::currentDate());
rebuildFilteredRecords();
}
void AppController::updateStatisticsRange(const QString &startDate, const QString &endDate)
{
m_statisticsStartDate = parseDate(startDate, QDate::currentDate().addDays(-30));
m_statisticsEndDate = parseDate(endDate, QDate::currentDate());
rebuildStatistics();
}
bool AppController::exportFilteredRecords(const QString &fileUrl)
{
const QString filePath = QUrl(fileUrl).toLocalFile();
if (filePath.isEmpty()) {
setStatusMessage("导出路径无效");
return false;
}
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
setStatusMessage("无法创建导出文件");
return false;
}
QTextStream out(&file);
out.setEncoding(QStringConverter::Utf8);
out << "# 考勤记录导出\n";
out << "# 导出时间: " << QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss") << "\n";
out << "# 记录总数: " << m_filteredRecords.size() << "\n";
out << "签到时间,班级编号,班级名称,学号,姓名,置信度,状态\n";
for (const AttendanceRecord &record : m_filteredRecords) {
out << csvCell(record.timestamp) << ","
<< csvCell(record.classId) << ","
<< csvCell(mappedClassName(record)) << ","
<< csvCell(record.studentId) << ","
<< csvCell(mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record)) << ","
<< QString::number(record.confidence, 'f', 4) << ","
<< csvCell(record.status) << "\n";
}
setStatusMessage(QStringLiteral("已导出 %1 条记录").arg(m_filteredRecords.size()));
return true;
}
bool AppController::exportStatistics(const QString &fileUrl)
{
const QString filePath = QUrl(fileUrl).toLocalFile();
if (filePath.isEmpty()) {
setStatusMessage("统计导出路径无效");
return false;
}
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
setStatusMessage("无法创建统计文件");
return false;
}
QTextStream out(&file);
out.setEncoding(QStringConverter::Utf8);
out << "考勤统计报表," << statisticsRangeText() << "\n";
out << "学号,姓名,总签到次数,今日签到,最后签到时间,平均置信度\n";
for (int row = 0; row < m_statisticsModel.rowCount(); ++row) {
const QModelIndex index = m_statisticsModel.index(row);
out << csvCell(m_statisticsModel.data(index, StatisticsListModel::StudentIdRole).toString()) << ","
<< csvCell(m_statisticsModel.data(index, StatisticsListModel::NameRole).toString()) << ","
<< m_statisticsModel.data(index, StatisticsListModel::TotalCountRole).toInt() << ","
<< m_statisticsModel.data(index, StatisticsListModel::TodayCountRole).toInt() << ","
<< csvCell(m_statisticsModel.data(index, StatisticsListModel::LastCheckInRole).toString()) << ","
<< QString::number(m_statisticsModel.data(index, StatisticsListModel::AverageConfidenceRole).toDouble(), 'f', 4) << "\n";
}
setStatusMessage("统计报表已导出");
return true;
}
void AppController::clearAllRecords()
{
m_apiClient.clearAllRecords();
}
void AppController::saveTimeSlots()
{
saveTimeSlotsToLocal();
m_apiClient.uploadTimeSlots(m_timeSlotModel.timeSlots());
}
void AppController::reloadTimeSlotsFromServer()
{
m_apiClient.fetchTimeSlots();
}
void AppController::loadMappingsFromServer()
{
m_apiClient.fetchMappingConfig();
}
void AppController::saveMappings()
{
saveMappingsToLocal();
m_apiClient.uploadMappingConfig(m_studentMappingModel.toMap(), m_classMappingModel.toMap());
rebuildFilteredRecords();
rebuildStatistics();
}
void AppController::refreshFaces()
{
m_apiClient.fetchFaceIdMapping();
m_apiClient.fetchFacePhotos();
m_apiClient.fetchAllStudentIds();
}
void AppController::saveFaceMappings()
{
QMap<QString, QString> mapping;
const QVector<FaceListModel::Item> items = m_faceModel.items();
for (const FaceListModel::Item &item : items) {
if (!item.studentId.trimmed().isEmpty()) {
mapping[item.faceId] = item.studentId.trimmed();
}
}
m_faceIdMapping = mapping;
m_apiClient.uploadFaceIdMapping(mapping);
}
void AppController::deleteFace(const QString &studentId, bool deleteRecords)
{
m_apiClient.deleteFace(studentId, deleteRecords);
}
void AppController::testConnection()
{
setStatusMessage("正在检查服务器连接");
m_apiClient.checkHealth();
}
void AppController::loadLocalState()
{
QSettings settings;
m_serverUrl = settings.value("server/url", "http://127.0.0.1:8082").toString();
m_qtSyncVersion = 0;
loadMappingsFromLocal();
loadTimeSlotsFromLocal();
}
void AppController::saveServerUrl()
{
QSettings settings;
settings.setValue("server/url", m_serverUrl);
}
void AppController::saveMappingsToLocal()
{
const QString path = localSettingsPath("mapping_config.json");
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
return;
}
QJsonObject root;
QJsonObject studentObject;
const QMap<QString, QString> studentMap = m_studentMappingModel.toMap();
for (auto it = studentMap.begin(); it != studentMap.end(); ++it) {
studentObject[it.key()] = it.value();
}
QJsonObject classObject;
const QMap<QString, QString> classMap = m_classMappingModel.toMap();
for (auto it = classMap.begin(); it != classMap.end(); ++it) {
classObject[it.key()] = it.value();
}
root["student_name_map"] = studentObject;
root["class_name_map"] = classObject;
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
}
void AppController::loadMappingsFromLocal()
{
QFile file(localSettingsPath("mapping_config.json"));
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
m_studentMappingModel.clear();
m_classMappingModel.clear();
return;
}
const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
QMap<QString, QString> studentMap;
const QJsonObject studentObject = root.value("student_name_map").toObject();
for (auto it = studentObject.begin(); it != studentObject.end(); ++it) {
studentMap[it.key()] = it.value().toString();
}
QMap<QString, QString> classMap;
const QJsonObject classObject = root.value("class_name_map").toObject();
for (auto it = classObject.begin(); it != classObject.end(); ++it) {
classMap[it.key()] = it.value().toString();
}
m_studentMappingModel.setMap(studentMap);
m_classMappingModel.setMap(classMap);
}
void AppController::saveTimeSlotsToLocal()
{
QFile file(localSettingsPath("timeslot_config.json"));
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
return;
}
QJsonArray array;
for (const TimeSlot &slot : m_timeSlotModel.timeSlots()) {
QJsonObject object;
object["name"] = slot.name;
object["start_time"] = slot.startTime.toString("HH:mm");
object["end_time"] = slot.endTime.toString("HH:mm");
object["enabled"] = slot.enabled;
array.append(object);
}
QJsonObject root;
root["time_slots"] = array;
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
}
void AppController::loadTimeSlotsFromLocal()
{
QFile file(localSettingsPath("timeslot_config.json"));
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
m_timeSlotModel.setTimeSlots(defaultTimeSlots());
return;
}
QVector<TimeSlot> timeSlots;
const QJsonArray array = QJsonDocument::fromJson(file.readAll()).object().value("time_slots").toArray();
for (const QJsonValue &value : array) {
const QJsonObject object = value.toObject();
timeSlots.push_back({
object.value("name").toString(),
QTime::fromString(object.value("start_time").toString(), "HH:mm"),
QTime::fromString(object.value("end_time").toString(), "HH:mm"),
object.value("enabled").toBool(true)
});
}
m_timeSlotModel.setTimeSlots(timeSlots.isEmpty() ? defaultTimeSlots() : timeSlots);
}
QString AppController::localSettingsPath(const QString &fileName) const
{
const QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir().mkpath(dirPath);
return dirPath + "/" + fileName;
}
QString AppController::localFacePhotoPath(const QString &faceId) const
{
const QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/face_photos";
QDir().mkpath(dirPath);
return dirPath + "/" + safeFileName(faceId) + ".jpg";
}
void AppController::updateFilterOptions()
{
QSet<QString> classIds;
QSet<QString> studentIds;
for (const AttendanceRecord &record : m_allRecords) {
classIds.insert(record.classId);
studentIds.insert(record.studentId);
}
m_classOptions = {"全部"};
QStringList classValues = classIds.values();
std::sort(classValues.begin(), classValues.end());
m_classOptions.append(classValues);
m_studentOptions = {"全部"};
QStringList studentValues = studentIds.values();
std::sort(studentValues.begin(), studentValues.end());
m_studentOptions.append(studentValues);
emit filtersChanged();
}
void AppController::rebuildFilteredRecords()
{
QVector<RecordListModel::Item> items;
m_filteredRecords.clear();
const QDate today = QDate::currentDate();
for (const AttendanceRecord &record : m_allRecords) {
if (m_selectedClassId != "全部" && record.classId != m_selectedClassId) {
continue;
}
if (m_selectedStudentId != "全部" && record.studentId != m_selectedStudentId) {
continue;
}
const QString displayName = mappedStudentName(record);
const QString candidateName = displayName.isEmpty() ? record.originalName : displayName;
if (!m_nameFilter.isEmpty() && !candidateName.contains(m_nameFilter, Qt::CaseInsensitive)) {
continue;
}
const QDate recordDate = record.dateTime().date();
if (m_dateFilterIndex == 1 && recordDate != today) {
continue;
}
if (m_dateFilterIndex == 2 && recordDate.daysTo(today) > 7) {
continue;
}
if (m_dateFilterIndex == 3 && recordDate.daysTo(today) > 30) {
continue;
}
if (m_dateFilterIndex == 4 && recordDate != m_specificDate) {
continue;
}
m_filteredRecords.push_back(record);
}
std::sort(m_filteredRecords.begin(), m_filteredRecords.end(), [](const AttendanceRecord &left, const AttendanceRecord &right) {
return left.timestamp > right.timestamp;
});
items.reserve(m_filteredRecords.size());
for (const AttendanceRecord &record : m_filteredRecords) {
items.push_back({
record.timestamp,
record.classId,
mappedClassName(record),
record.studentId,
mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record),
record.confidence,
record.status,
isSuccessStatus(record.status)
});
}
m_recordModel.setItems(items);
emit summaryChanged();
}
void AppController::rebuildStatistics()
{
QMap<QString, StudentStatistics> statisticsMap;
const QDate today = QDate::currentDate();
for (const AttendanceRecord &record : m_allRecords) {
const QDate date = record.dateTime().date();
if (date < m_statisticsStartDate || date > m_statisticsEndDate) {
continue;
}
StudentStatistics &stats = statisticsMap[record.studentId];
stats.studentId = record.studentId;
stats.name = mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record);
stats.totalCount += 1;
stats.averageConfidence += record.confidence;
if (date == today) {
stats.todayCount += 1;
}
if (!stats.lastCheckIn.isValid() || record.dateTime() > stats.lastCheckIn) {
stats.lastCheckIn = record.dateTime();
}
}
QVector<StatisticsListModel::Item> items;
items.reserve(statisticsMap.size());
m_statisticsStudentCount = statisticsMap.size();
m_statisticsRecordCount = 0;
for (auto it = statisticsMap.begin(); it != statisticsMap.end(); ++it) {
StudentStatistics stats = it.value();
m_statisticsRecordCount += stats.totalCount;
if (stats.totalCount > 0) {
stats.averageConfidence /= static_cast<double>(stats.totalCount);
}
items.push_back({
stats.studentId,
stats.name,
stats.totalCount,
stats.todayCount,
stats.lastCheckIn.isValid() ? stats.lastCheckIn.toString("yyyy-MM-dd HH:mm:ss") : QStringLiteral("无"),
stats.averageConfidence
});
}
std::sort(items.begin(), items.end(), [](const StatisticsListModel::Item &left, const StatisticsListModel::Item &right) {
return left.studentId < right.studentId;
});
m_statisticsModel.setItems(items);
emit statisticsChanged();
}
QString AppController::mappedStudentName(const AttendanceRecord &record) const
{
return m_studentMappingModel.toMap().value(record.studentId);
}
QString AppController::mappedClassName(const AttendanceRecord &record) const
{
return m_classMappingModel.toMap().value(record.classId, record.classId);
}
void AppController::mergeFaceItems()
{
QSet<QString> allIds;
for (const QString &id : m_facePhotoIds) {
allIds.insert(id);
}
for (const QString &id : m_allStudentIds) {
allIds.insert(id);
}
QStringList sortedIds = allIds.values();
std::sort(sortedIds.begin(), sortedIds.end());
QVector<FaceListModel::Item> items;
items.reserve(sortedIds.size());
for (const QString &id : sortedIds) {
const QString photoUrl = m_facePhotoLocalUrls.value(id);
const bool hasPhoto = !photoUrl.isEmpty();
items.push_back({
id,
m_faceIdMapping.value(id, id),
hasPhoto,
photoUrl
});
}
m_faceModel.setItems(items);
}
void AppController::setStatusMessage(const QString &message)
{
const QString displayMessage = conciseStatusMessage(message);
if (displayMessage == m_statusMessage) {
return;
}
m_statusMessage = displayMessage;
emit statusMessageChanged();
}
void AppController::startReconnect(const QString &reason)
{
Q_UNUSED(reason)
if (!m_reconnecting) {
m_reconnecting = true;
m_reconnectAttempt = 0;
emit reconnectStateChanged();
}
if (m_reconnectCheckInFlight || m_reconnectTimer.isActive()) {
setStatusMessage(QStringLiteral("服务器未连接,稍后重试"));
return;
}
setStatusMessage(QStringLiteral("服务器未连接,稍后重试"));
scheduleReconnect();
}
void AppController::stopReconnect()
{
m_reconnectTimer.stop();
m_reconnectCheckInFlight = false;
if (!m_reconnecting && m_reconnectAttempt == 0) {
return;
}
m_reconnecting = false;
m_reconnectAttempt = 0;
emit reconnectStateChanged();
}
void AppController::scheduleReconnect()
{
m_reconnectTimer.start(ReconnectIntervalMs);
}
void AppController::runReconnectCheck()
{
if (!m_reconnecting || m_reconnectCheckInFlight) {
return;
}
m_reconnectCheckInFlight = true;
++m_reconnectAttempt;
emit reconnectStateChanged();
setStatusMessage(QStringLiteral("正在重连服务器"));
m_apiClient.checkHealth();
}
+155
View File
@@ -0,0 +1,155 @@
#ifndef APP_CONTROLLER_H
#define APP_CONTROLLER_H
#include <QDate>
#include <QHash>
#include <QMap>
#include <QObject>
#include <QStringList>
#include <QTimer>
#include <QVector>
#include "ApiClient.h"
#include "FaceListModel.h"
#include "MappingListModel.h"
#include "RecordListModel.h"
#include "StatisticsListModel.h"
#include "TimeSlotListModel.h"
class AppController : public QObject
{
Q_OBJECT
Q_PROPERTY(RecordListModel* recordModel READ recordModel CONSTANT)
Q_PROPERTY(StatisticsListModel* statisticsModel READ statisticsModel CONSTANT)
Q_PROPERTY(TimeSlotListModel* timeSlotModel READ timeSlotModel CONSTANT)
Q_PROPERTY(MappingListModel* studentMappingModel READ studentMappingModel CONSTANT)
Q_PROPERTY(MappingListModel* classMappingModel READ classMappingModel CONSTANT)
Q_PROPERTY(FaceListModel* faceModel READ faceModel CONSTANT)
Q_PROPERTY(QStringList classOptions READ classOptions NOTIFY filtersChanged)
Q_PROPERTY(QStringList studentOptions READ studentOptions NOTIFY filtersChanged)
Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged)
Q_PROPERTY(QString statusMessage READ statusMessage NOTIFY statusMessageChanged)
Q_PROPERTY(bool reconnecting READ reconnecting NOTIFY reconnectStateChanged)
Q_PROPERTY(int reconnectAttempt READ reconnectAttempt NOTIFY reconnectStateChanged)
Q_PROPERTY(int totalRecords READ totalRecords NOTIFY summaryChanged)
Q_PROPERTY(int todayRecords READ todayRecords NOTIFY summaryChanged)
Q_PROPERTY(int filteredCount READ filteredCount NOTIFY summaryChanged)
Q_PROPERTY(int loadedRecords READ loadedRecords NOTIFY summaryChanged)
Q_PROPERTY(int statisticsStudentCount READ statisticsStudentCount NOTIFY statisticsChanged)
Q_PROPERTY(int statisticsRecordCount READ statisticsRecordCount NOTIFY statisticsChanged)
Q_PROPERTY(QString statisticsRangeText READ statisticsRangeText NOTIFY statisticsChanged)
public:
explicit AppController(QObject *parent = nullptr);
RecordListModel *recordModel();
StatisticsListModel *statisticsModel();
TimeSlotListModel *timeSlotModel();
MappingListModel *studentMappingModel();
MappingListModel *classMappingModel();
FaceListModel *faceModel();
QStringList classOptions() const;
QStringList studentOptions() const;
QString serverUrl() 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);
Q_INVOKABLE void refreshAll();
Q_INVOKABLE void refreshRecords();
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);
Q_INVOKABLE bool exportFilteredRecords(const QString &fileUrl);
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 refreshFaces();
Q_INVOKABLE void saveFaceMappings();
Q_INVOKABLE void deleteFace(const QString &studentId, bool deleteRecords);
Q_INVOKABLE void testConnection();
signals:
void filtersChanged();
void serverUrlChanged();
void statusMessageChanged();
void reconnectStateChanged();
void summaryChanged();
void statisticsChanged();
private:
void loadLocalState();
void saveServerUrl();
void saveMappingsToLocal();
void loadMappingsFromLocal();
void saveTimeSlotsToLocal();
void loadTimeSlotsFromLocal();
QString localSettingsPath(const QString &fileName) const;
void updateFilterOptions();
void rebuildFilteredRecords();
void rebuildStatistics();
QString mappedStudentName(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;
QTimer m_reconnectTimer;
RecordListModel m_recordModel;
StatisticsListModel m_statisticsModel;
TimeSlotListModel m_timeSlotModel;
MappingListModel m_studentMappingModel;
MappingListModel m_classMappingModel;
FaceListModel m_faceModel;
QVector<AttendanceRecord> m_allRecords;
QVector<AttendanceRecord> m_filteredRecords;
QStringList m_classOptions;
QStringList m_studentOptions;
QString m_serverUrl;
QString m_statusMessage;
bool m_reconnecting = false;
bool m_reconnectCheckInFlight = false;
int m_reconnectAttempt = 0;
int m_totalRecords = 0;
int m_todayRecords = 0;
QString m_selectedClassId = "全部";
QString m_selectedStudentId = "全部";
QString m_nameFilter;
int m_dateFilterIndex = 0;
QDate m_specificDate = QDate::currentDate();
QDate m_statisticsStartDate = QDate::currentDate().addDays(-30);
QDate m_statisticsEndDate = QDate::currentDate();
int m_statisticsStudentCount = 0;
int m_statisticsRecordCount = 0;
qulonglong m_qtSyncVersion = 0;
QMap<QString, QString> m_faceIdMapping;
QHash<QString, QString> m_facePhotoLocalUrls;
QStringList m_facePhotoIds;
QStringList m_allStudentIds;
};
#endif
+59
View File
@@ -0,0 +1,59 @@
#ifndef ATTENDANCE_TYPES_H
#define ATTENDANCE_TYPES_H
#include <QDate>
#include <QDateTime>
#include <QString>
#include <QTime>
#include <QVector>
struct AttendanceRecord
{
QString timestamp;
QString studentId;
QString originalName;
double confidence = 0.0;
QString status;
QString classId = "0";
QDateTime dateTime() const
{
return QDateTime::fromString(timestamp, "yyyy-MM-dd HH:mm:ss");
}
};
struct StudentStatistics
{
QString studentId;
QString name;
int totalCount = 0;
int todayCount = 0;
QDateTime lastCheckIn;
double averageConfidence = 0.0;
};
struct TimeSlot
{
QString name;
QTime startTime;
QTime endTime;
bool enabled = true;
};
inline QVector<TimeSlot> defaultTimeSlots()
{
return {
{"上午签到", QTime(8, 0), QTime(9, 30), true},
{"中午签退", QTime(12, 0), QTime(13, 0), true},
{"下午签到", QTime(14, 0), QTime(15, 0), true},
{"晚上签退", QTime(17, 0), QTime(18, 30), true}
};
}
Q_DECLARE_METATYPE(AttendanceRecord)
Q_DECLARE_METATYPE(StudentStatistics)
Q_DECLARE_METATYPE(TimeSlot)
Q_DECLARE_METATYPE(QVector<AttendanceRecord>)
Q_DECLARE_METATYPE(QVector<TimeSlot>)
#endif
+48
View File
@@ -0,0 +1,48 @@
#include "CryptoUtils.h"
#include "crypto/tiny_aes.hpp"
namespace {
QByteArray removePkcs7Padding(const QByteArray &data)
{
if (data.isEmpty()) {
return {};
}
const int padding = static_cast<unsigned char>(data.back());
if (padding < 1 || padding > AES_BLOCKLEN || padding > data.size()) {
return data;
}
for (int i = data.size() - padding; i < data.size(); ++i) {
if (static_cast<unsigned char>(data.at(i)) != padding) {
return data;
}
}
return data.left(data.size() - padding);
}
}
QByteArray CryptoUtils::decryptAes128CbcBase64(const QByteArray &encryptedBase64)
{
static const uint8_t key[AES_KEYLEN] = {
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c
};
static const uint8_t iv[AES_BLOCKLEN] = {
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f
};
const QByteArray encrypted = QByteArray::fromBase64(encryptedBase64);
if (encrypted.isEmpty() || (encrypted.size() % AES_BLOCKLEN) != 0) {
return {};
}
QByteArray buffer = encrypted;
AES_ctx ctx;
AES_init_ctx_iv(&ctx, key, iv);
AES_CBC_decrypt_buffer(&ctx, reinterpret_cast<uint8_t*>(buffer.data()), static_cast<size_t>(buffer.size()));
return removePkcs7Padding(buffer);
}
+12
View File
@@ -0,0 +1,12 @@
#ifndef CRYPTO_UTILS_H
#define CRYPTO_UTILS_H
#include <QByteArray>
class CryptoUtils
{
public:
static QByteArray decryptAes128CbcBase64(const QByteArray &encryptedBase64);
};
#endif
+85
View File
@@ -0,0 +1,85 @@
#include "FaceListModel.h"
FaceListModel::FaceListModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int FaceListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_items.size();
}
QVariant FaceListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return {};
}
const Item &item = m_items.at(index.row());
switch (role) {
case FaceIdRole: return item.faceId;
case StudentIdRole: return item.studentId;
case HasPhotoRole: return item.hasPhoto;
case PhotoUrlRole: return item.photoUrl;
default: return {};
}
}
bool FaceListModel::setData(const QModelIndex &index, const QVariant &value, int role)
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return false;
}
if (role == StudentIdRole) {
m_items[index.row()].studentId = value.toString().trimmed();
emit dataChanged(index, index, {role});
return true;
}
return false;
}
Qt::ItemFlags FaceListModel::flags(const QModelIndex &index) const
{
if (!index.isValid()) {
return Qt::NoItemFlags;
}
return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable;
}
QHash<int, QByteArray> FaceListModel::roleNames() const
{
return {
{FaceIdRole, "faceId"},
{StudentIdRole, "studentId"},
{HasPhotoRole, "hasPhoto"},
{PhotoUrlRole, "photoUrl"}
};
}
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;
}
m_items[index].studentId = studentId.trimmed();
emit dataChanged(this->index(index), this->index(index), {StudentIdRole});
}
+47
View File
@@ -0,0 +1,47 @@
#ifndef FACE_LIST_MODEL_H
#define FACE_LIST_MODEL_H
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QModelIndex>
#include <QString>
#include <QVariant>
#include <QVector>
class FaceListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
FaceIdRole = Qt::UserRole + 1,
StudentIdRole,
HasPhotoRole,
PhotoUrlRole
};
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;
Qt::ItemFlags flags(const QModelIndex &index) const override;
QHash<int, QByteArray> roleNames() const override;
struct Item {
QString faceId;
QString studentId;
bool hasPhoto = false;
QString photoUrl;
};
void setItems(const QVector<Item> &items);
QVector<Item> items() const;
Q_INVOKABLE void updateStudentId(int index, const QString &studentId);
private:
QVector<Item> m_items;
};
#endif
+132
View File
@@ -0,0 +1,132 @@
#include "MappingListModel.h"
#include <algorithm>
MappingListModel::MappingListModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int MappingListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_items.size();
}
QVariant MappingListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return {};
}
const Item &item = m_items.at(index.row());
switch (role) {
case KeyRole: return item.key;
case ValueRole: return item.value;
default: return {};
}
}
bool MappingListModel::setData(const QModelIndex &index, const QVariant &value, int role)
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return false;
}
Item &item = m_items[index.row()];
switch (role) {
case KeyRole: item.key = value.toString().trimmed(); break;
case ValueRole: item.value = value.toString().trimmed(); break;
default: return false;
}
emit dataChanged(index, index, {role});
return true;
}
Qt::ItemFlags MappingListModel::flags(const QModelIndex &index) const
{
if (!index.isValid()) {
return Qt::NoItemFlags;
}
return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable;
}
QHash<int, QByteArray> MappingListModel::roleNames() const
{
return {
{KeyRole, "entryKey"},
{ValueRole, "entryValue"}
};
}
void MappingListModel::setMap(const QMap<QString, QString> &map)
{
QVector<Item> items;
items.reserve(map.size());
for (auto it = map.begin(); it != map.end(); ++it) {
items.push_back({it.key(), it.value()});
}
std::sort(items.begin(), items.end(), [](const Item &left, const Item &right) {
bool leftNumeric = false;
bool rightNumeric = false;
int leftValue = left.key.toInt(&leftNumeric);
int rightValue = right.key.toInt(&rightNumeric);
if (leftNumeric && rightNumeric) {
return leftValue < rightValue;
}
return left.key < right.key;
});
beginResetModel();
m_items = items;
endResetModel();
}
QMap<QString, QString> MappingListModel::toMap() const
{
QMap<QString, QString> map;
for (const Item &item : m_items) {
if (!item.key.trimmed().isEmpty()) {
map[item.key.trimmed()] = item.value.trimmed();
}
}
return map;
}
void MappingListModel::addEntry(const QString &key, const QString &value)
{
beginInsertRows(QModelIndex(), m_items.size(), m_items.size());
m_items.push_back({key.trimmed(), value.trimmed()});
endInsertRows();
}
void MappingListModel::updateEntry(int index, const QString &key, const QString &value)
{
if (index < 0 || index >= m_items.size()) {
return;
}
m_items[index] = {key.trimmed(), value.trimmed()};
emit dataChanged(this->index(index), this->index(index));
}
void MappingListModel::removeEntry(int index)
{
if (index < 0 || index >= m_items.size()) {
return;
}
beginRemoveRows(QModelIndex(), index, index);
m_items.removeAt(index);
endRemoveRows();
}
void MappingListModel::clear()
{
beginResetModel();
m_items.clear();
endResetModel();
}
+48
View File
@@ -0,0 +1,48 @@
#ifndef MAPPING_LIST_MODEL_H
#define MAPPING_LIST_MODEL_H
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QMap>
#include <QModelIndex>
#include <QString>
#include <QVariant>
#include <QVector>
class MappingListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
KeyRole = Qt::UserRole + 1,
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;
bool setData(const QModelIndex &index, const QVariant &value, int role) override;
Qt::ItemFlags flags(const QModelIndex &index) const override;
QHash<int, QByteArray> roleNames() const override;
void setMap(const QMap<QString, QString> &map);
QMap<QString, QString> toMap() const;
Q_INVOKABLE void addEntry(const QString &key, const QString &value);
Q_INVOKABLE void updateEntry(int index, const QString &key, const QString &value);
Q_INVOKABLE void removeEntry(int index);
Q_INVOKABLE void clear();
private:
struct Item {
QString key;
QString value;
};
QVector<Item> m_items;
};
#endif
+59
View File
@@ -0,0 +1,59 @@
#include "RecordListModel.h"
RecordListModel::RecordListModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int RecordListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_items.size();
}
QVariant RecordListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return {};
}
const Item &item = m_items.at(index.row());
switch (role) {
case TimestampRole: return item.timestamp;
case ClassIdRole: return item.classId;
case ClassNameRole: return item.className;
case StudentIdRole: return item.studentId;
case NameRole: return item.name;
case ConfidenceRole: return item.confidence;
case ConfidenceTextRole: return QString::number(item.confidence * 100.0, 'f', 2) + "%";
case StatusRole: return item.status;
case StatusTextRole: return item.success ? QStringLiteral("成功") : QStringLiteral("失败");
case SuccessRole: return item.success;
default: return {};
}
}
QHash<int, QByteArray> RecordListModel::roleNames() const
{
return {
{TimestampRole, "timestamp"},
{ClassIdRole, "classId"},
{ClassNameRole, "className"},
{StudentIdRole, "studentId"},
{NameRole, "name"},
{ConfidenceRole, "confidence"},
{ConfidenceTextRole, "confidenceText"},
{StatusRole, "status"},
{StatusTextRole, "statusText"},
{SuccessRole, "success"}
};
}
void RecordListModel::setItems(const QVector<Item> &items)
{
beginResetModel();
m_items = items;
endResetModel();
}
+53
View File
@@ -0,0 +1,53 @@
#ifndef RECORD_LIST_MODEL_H
#define RECORD_LIST_MODEL_H
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QModelIndex>
#include <QString>
#include <QVariant>
#include <QVector>
class RecordListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
TimestampRole = Qt::UserRole + 1,
ClassIdRole,
ClassNameRole,
StudentIdRole,
NameRole,
ConfidenceRole,
ConfidenceTextRole,
StatusRole,
StatusTextRole,
SuccessRole
};
explicit RecordListModel(QObject *parent = nullptr);
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
QHash<int, QByteArray> roleNames() const override;
struct Item {
QString timestamp;
QString classId;
QString className;
QString studentId;
QString name;
double confidence = 0.0;
QString status;
bool success = false;
};
void setItems(const QVector<Item> &items);
private:
QVector<Item> m_items;
};
#endif
+53
View File
@@ -0,0 +1,53 @@
#include "StatisticsListModel.h"
StatisticsListModel::StatisticsListModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int StatisticsListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_items.size();
}
QVariant StatisticsListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) {
return {};
}
const Item &item = m_items.at(index.row());
switch (role) {
case StudentIdRole: return item.studentId;
case NameRole: return item.name;
case TotalCountRole: return item.totalCount;
case TodayCountRole: return item.todayCount;
case LastCheckInRole: return item.lastCheckIn;
case AverageConfidenceRole: return item.averageConfidence;
case AverageConfidenceTextRole: return QString::number(item.averageConfidence * 100.0, 'f', 2) + "%";
default: return {};
}
}
QHash<int, QByteArray> StatisticsListModel::roleNames() const
{
return {
{StudentIdRole, "studentId"},
{NameRole, "name"},
{TotalCountRole, "totalCount"},
{TodayCountRole, "todayCount"},
{LastCheckInRole, "lastCheckIn"},
{AverageConfidenceRole, "averageConfidence"},
{AverageConfidenceTextRole, "averageConfidenceText"}
};
}
void StatisticsListModel::setItems(const QVector<Item> &items)
{
beginResetModel();
m_items = items;
endResetModel();
}
+48
View File
@@ -0,0 +1,48 @@
#ifndef STATISTICS_LIST_MODEL_H
#define STATISTICS_LIST_MODEL_H
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QModelIndex>
#include <QString>
#include <QVariant>
#include <QVector>
class StatisticsListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
StudentIdRole = Qt::UserRole + 1,
NameRole,
TotalCountRole,
TodayCountRole,
LastCheckInRole,
AverageConfidenceRole,
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;
QHash<int, QByteArray> roleNames() const override;
struct Item {
QString studentId;
QString name;
int totalCount = 0;
int todayCount = 0;
QString lastCheckIn;
double averageConfidence = 0.0;
};
void setItems(const QVector<Item> &items);
private:
QVector<Item> m_items;
};
#endif
+146
View File
@@ -0,0 +1,146 @@
#include "TimeSlotListModel.h"
#include <QJsonObject>
TimeSlotListModel::TimeSlotListModel(QObject *parent)
: QAbstractListModel(parent)
{
}
int TimeSlotListModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return m_timeSlots.size();
}
QVariant TimeSlotListModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_timeSlots.size()) {
return {};
}
const TimeSlot &slot = m_timeSlots.at(index.row());
switch (role) {
case NameRole: return slot.name;
case StartTimeRole: return slot.startTime.toString("HH:mm");
case EndTimeRole: return slot.endTime.toString("HH:mm");
case EnabledRole: return slot.enabled;
default: return {};
}
}
bool TimeSlotListModel::setData(const QModelIndex &index, const QVariant &value, int role)
{
if (!index.isValid() || index.row() < 0 || index.row() >= m_timeSlots.size()) {
return false;
}
TimeSlot &slot = m_timeSlots[index.row()];
switch (role) {
case NameRole: slot.name = value.toString(); break;
case StartTimeRole: slot.startTime = QTime::fromString(value.toString(), "HH:mm"); break;
case EndTimeRole: slot.endTime = QTime::fromString(value.toString(), "HH:mm"); break;
case EnabledRole: slot.enabled = value.toBool(); break;
default: return false;
}
emit dataChanged(index, index, {role});
return true;
}
Qt::ItemFlags TimeSlotListModel::flags(const QModelIndex &index) const
{
if (!index.isValid()) {
return Qt::NoItemFlags;
}
return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable;
}
QHash<int, QByteArray> TimeSlotListModel::roleNames() const
{
return {
{NameRole, "name"},
{StartTimeRole, "startTime"},
{EndTimeRole, "endTime"},
{EnabledRole, "enabled"}
};
}
void TimeSlotListModel::setTimeSlots(const QVector<TimeSlot> &timeSlots)
{
beginResetModel();
m_timeSlots = timeSlots;
endResetModel();
}
QVector<TimeSlot> TimeSlotListModel::timeSlots() const
{
return m_timeSlots;
}
QJsonArray TimeSlotListModel::toJsonArray() const
{
QJsonArray array;
for (const TimeSlot &slot : m_timeSlots) {
QJsonObject object;
object["name"] = slot.name;
object["start_time"] = slot.startTime.toString("HH:mm");
object["end_time"] = slot.endTime.toString("HH:mm");
object["enabled"] = slot.enabled;
array.append(object);
}
return array;
}
QVariantMap TimeSlotListModel::get(int index) const
{
if (index < 0 || index >= m_timeSlots.size()) {
return {};
}
const TimeSlot &slot = m_timeSlots.at(index);
return {
{"name", slot.name},
{"startTime", slot.startTime.toString("HH:mm")},
{"endTime", slot.endTime.toString("HH:mm")},
{"enabled", slot.enabled}
};
}
void TimeSlotListModel::addTimeSlot(const QString &name, const QString &startTime, const QString &endTime, bool enabled)
{
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()) {
return;
}
TimeSlot &slot = m_timeSlots[index];
slot.name = name.trimmed();
slot.startTime = QTime::fromString(startTime, "HH:mm");
slot.endTime = QTime::fromString(endTime, "HH:mm");
slot.enabled = enabled;
emit dataChanged(this->index(index), this->index(index));
}
void TimeSlotListModel::removeTimeSlot(int index)
{
if (index < 0 || index >= m_timeSlots.size()) {
return;
}
beginRemoveRows(QModelIndex(), index, index);
m_timeSlots.removeAt(index);
endRemoveRows();
}
void TimeSlotListModel::resetToDefault()
{
setTimeSlots(defaultTimeSlots());
}
+48
View File
@@ -0,0 +1,48 @@
#ifndef TIMESLOT_LIST_MODEL_H
#define TIMESLOT_LIST_MODEL_H
#include <QAbstractListModel>
#include <QByteArray>
#include <QHash>
#include <QJsonArray>
#include <QModelIndex>
#include <QVariant>
#include <QVariantMap>
#include <QVector>
#include "AttendanceTypes.h"
class TimeSlotListModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
NameRole = Qt::UserRole + 1,
StartTimeRole,
EndTimeRole,
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;
bool setData(const QModelIndex &index, const QVariant &value, int role) override;
Qt::ItemFlags flags(const QModelIndex &index) const override;
QHash<int, QByteArray> roleNames() const override;
void setTimeSlots(const QVector<TimeSlot> &timeSlots);
QVector<TimeSlot> timeSlots() const;
QJsonArray toJsonArray() const;
Q_INVOKABLE QVariantMap get(int index) const;
Q_INVOKABLE void addTimeSlot(const QString &name, const QString &startTime, const QString &endTime, bool enabled);
Q_INVOKABLE void updateTimeSlot(int index, const QString &name, const QString &startTime, const QString &endTime, bool enabled);
Q_INVOKABLE void removeTimeSlot(int index);
Q_INVOKABLE void resetToDefault();
private:
QVector<TimeSlot> m_timeSlots;
};
#endif
+217
View File
@@ -0,0 +1,217 @@
/*
* Tiny AES128 C++ 移植版本
* 与STM32端使用相同的AES实现,保证兼容性
*/
#ifndef _TINY_AES_HPP_
#define _TINY_AES_HPP_
#include <cstdint>
#include <cstring>
#define AES_BLOCKLEN 16
#define AES_KEYLEN 16
#define AES_keyExpSize 176
struct AES_ctx
{
uint8_t RoundKey[AES_keyExpSize];
uint8_t Iv[AES_BLOCKLEN];
};
static const uint8_t sbox[256] = {
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
};
static const uint8_t rsbox[256] = {
0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb,
0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb,
0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e,
0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25,
0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92,
0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84,
0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06,
0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b,
0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73,
0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e,
0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b,
0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4,
0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f,
0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef,
0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61,
0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d
};
static const uint8_t Rcon[11] = {
0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36
};
#define getSBoxValue(num) (sbox[(num)])
#define getSBoxInvert(num) (rsbox[(num)])
inline void KeyExpansion(uint8_t *RoundKey, const uint8_t *Key)
{
unsigned i, j, k;
uint8_t tempa[4];
for (i = 0; i < 4; ++i) {
RoundKey[(i * 4) + 0] = Key[(i * 4) + 0];
RoundKey[(i * 4) + 1] = Key[(i * 4) + 1];
RoundKey[(i * 4) + 2] = Key[(i * 4) + 2];
RoundKey[(i * 4) + 3] = Key[(i * 4) + 3];
}
for (i = 4; i < 44; ++i) {
k = (i - 1) * 4;
tempa[0] = RoundKey[k + 0];
tempa[1] = RoundKey[k + 1];
tempa[2] = RoundKey[k + 2];
tempa[3] = RoundKey[k + 3];
if (i % 4 == 0) {
const uint8_t u8tmp = tempa[0];
tempa[0] = tempa[1];
tempa[1] = tempa[2];
tempa[2] = tempa[3];
tempa[3] = u8tmp;
tempa[0] = getSBoxValue(tempa[0]);
tempa[1] = getSBoxValue(tempa[1]);
tempa[2] = getSBoxValue(tempa[2]);
tempa[3] = getSBoxValue(tempa[3]);
tempa[0] ^= Rcon[i / 4];
}
j = i * 4;
k = (i - 4) * 4;
RoundKey[j + 0] = RoundKey[k + 0] ^ tempa[0];
RoundKey[j + 1] = RoundKey[k + 1] ^ tempa[1];
RoundKey[j + 2] = RoundKey[k + 2] ^ tempa[2];
RoundKey[j + 3] = RoundKey[k + 3] ^ tempa[3];
}
}
static void AddRoundKey(uint8_t round, uint8_t *state, const uint8_t *RoundKey)
{
uint8_t i, j;
for (i = 0; i < 4; ++i) {
for (j = 0; j < 4; ++j) {
state[i * 4 + j] ^= RoundKey[(round * 16) + (i * 4 + j)];
}
}
}
static void InvSubBytes(uint8_t *state)
{
for (uint8_t i = 0; i < 16; ++i) {
state[i] = getSBoxInvert(state[i]);
}
}
static void InvShiftRows(uint8_t *state)
{
uint8_t temp;
temp = state[13];
state[13] = state[9];
state[9] = state[5];
state[5] = state[1];
state[1] = temp;
temp = state[2];
state[2] = state[10];
state[10] = temp;
temp = state[6];
state[6] = state[14];
state[14] = temp;
temp = state[3];
state[3] = state[7];
state[7] = state[11];
state[11] = state[15];
state[15] = temp;
}
static uint8_t xtime(uint8_t x)
{
return ((x << 1) ^ (((x >> 7) & 1U) * 0x1bU));
}
static uint8_t Multiply(uint8_t x, uint8_t y)
{
return (((y & 1U) * x) ^
(((y >> 1U) & 1U) * xtime(x)) ^
(((y >> 2U) & 1U) * xtime(xtime(x))) ^
(((y >> 3U) & 1U) * xtime(xtime(xtime(x)))) ^
(((y >> 4U) & 1U) * xtime(xtime(xtime(xtime(x))))));
}
static void InvMixColumns(uint8_t *state)
{
int i;
uint8_t a, b, c, d;
for (i = 0; i < 4; ++i) {
a = state[i * 4 + 0];
b = state[i * 4 + 1];
c = state[i * 4 + 2];
d = state[i * 4 + 3];
state[i * 4 + 0] = Multiply(a, 0x0eU) ^ Multiply(b, 0x0bU) ^ Multiply(c, 0x0dU) ^ Multiply(d, 0x09U);
state[i * 4 + 1] = Multiply(a, 0x09U) ^ Multiply(b, 0x0eU) ^ Multiply(c, 0x0bU) ^ Multiply(d, 0x0dU);
state[i * 4 + 2] = Multiply(a, 0x0dU) ^ Multiply(b, 0x09U) ^ Multiply(c, 0x0eU) ^ Multiply(d, 0x0bU);
state[i * 4 + 3] = Multiply(a, 0x0bU) ^ Multiply(b, 0x0dU) ^ Multiply(c, 0x09U) ^ Multiply(d, 0x0eU);
}
}
inline void InvCipher(uint8_t *state, const uint8_t *RoundKey)
{
uint8_t round = 0;
AddRoundKey(10, state, RoundKey);
for (round = 9; ; --round) {
InvShiftRows(state);
InvSubBytes(state);
AddRoundKey(round, state, RoundKey);
if (round == 0) {
break;
}
InvMixColumns(state);
}
}
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);
}
inline void AES_CBC_decrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t length)
{
size_t i;
uint8_t storeNextIv[AES_BLOCKLEN];
for (i = 0; i < length; i += AES_BLOCKLEN) {
memcpy(storeNextIv, buf + i, AES_BLOCKLEN);
InvCipher(buf + i, ctx->RoundKey);
for (uint8_t j = 0; j < AES_BLOCKLEN; ++j) {
buf[i + j] ^= ctx->Iv[j];
}
memcpy(ctx->Iv, storeNextIv, AES_BLOCKLEN);
}
}
#endif