初始化毕业设计项目
@@ -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
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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` 文件。
|
||||
@@ -0,0 +1,2 @@
|
||||
android.overridePathCheck=true
|
||||
android.useAndroidX=true
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 627 B |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1 @@
|
||||
IDI_ICON1 ICON "@REWRITE_QT_APP_ICON@"
|
||||
@@ -0,0 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=人脸识别考勤系统
|
||||
Exec=apprewrite_qt
|
||||
Icon=rewrite_qt
|
||||
Terminal=false
|
||||
Categories=Utility;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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> ¶ms) 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 ×tamp, 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();
|
||||
});
|
||||
}
|
||||
@@ -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 ×tamp, 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> ¶ms = {}) 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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#ifndef CRYPTO_UTILS_H
|
||||
#define CRYPTO_UTILS_H
|
||||
|
||||
#include <QByteArray>
|
||||
|
||||
class CryptoUtils
|
||||
{
|
||||
public:
|
||||
static QByteArray decryptAes128CbcBase64(const QByteArray &encryptedBase64);
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||