更新文档
This commit is contained in:
+114
-474
@@ -1,517 +1,157 @@
|
||||
# 人脸识别考勤系统
|
||||
# Qt 管理端 README
|
||||
|
||||
基于 Qt 6 Quick/QML 开发的跨平台人脸识别考勤管理客户端,用于连接考勤服务端,完成签到记录查看、筛选导出、统计分析、签到时段配置、学生与班级名称映射、人脸照片与学号绑定等管理工作。
|
||||
这是人脸识别考勤系统的 Qt 管理端,支持 Windows 桌面端和 Android 端。更完整的说明见:
|
||||
|
||||
本项目是纪田野的个人毕业设计作品,主要用于人脸识别考勤场景的功能展示、系统联调和学习研究。
|
||||
```text
|
||||
../docs/04_Qt管理端文档.md
|
||||
../docs/00_项目总文档.md
|
||||
```
|
||||
|
||||
## 项目特点
|
||||
## 1. 功能概览
|
||||
|
||||
- 跨平台客户端:支持 Windows 桌面端和 Android 端构建。
|
||||
- 单窗口管理:考勤记录、统计分析、配置中心、人脸管理和系统说明集中在一个界面中。
|
||||
- 响应式界面:针对手机窄屏做了独立布局适配,避免控件重叠和文字遮挡。
|
||||
- 数据导出:支持将考勤记录和统计报表导出为 CSV 文件。
|
||||
- 本地配置缓存:服务端地址、签到时段、映射配置会保存到本地。
|
||||
- 自动重连:服务端不可达时自动重连,并使用简短状态提示。
|
||||
- Android 字体优化:Android 端使用原生文字渲染,并降低粗体权重,减少中文粗体重影。
|
||||
- Android 路径兼容:Gradle 配置已允许中文路径构建,并启用 AndroidX。
|
||||
Qt 管理端用于连接考勤服务端,完成以下工作:
|
||||
|
||||
## 技术栈
|
||||
1. 查看考勤记录。
|
||||
2. 按班级、学号、姓名和日期筛选记录。
|
||||
3. 导出考勤记录 CSV。
|
||||
4. 查看学生维度的统计分析。
|
||||
5. 导出统计 CSV。
|
||||
6. 配置服务端地址。
|
||||
7. 新增、编辑、删除、清空签到时间段。
|
||||
8. 配置学号到姓名、班级编号到班级名称的显示映射。
|
||||
9. 查看 K210 上传的人脸照片。
|
||||
10. 为 K210 face_id 绑定真实学号。
|
||||
11. 删除人脸信息,并可选择删除关联考勤记录。
|
||||
|
||||
## 2. 当前重要行为
|
||||
|
||||
### 2.1 默认服务端地址
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8082
|
||||
```
|
||||
|
||||
启动后会优先读取本地保存的 `server/url`。
|
||||
|
||||
### 2.2 默认签到时间段
|
||||
|
||||
当前默认签到时间段为空:
|
||||
|
||||
```json
|
||||
{
|
||||
"time_slots": []
|
||||
}
|
||||
```
|
||||
|
||||
第一次使用时,需要在“配置中心”里新增时间段并保存同步到服务端。旧版本本地保存的四个默认时间段会被自动迁移为空配置。
|
||||
|
||||
### 2.3 API Token
|
||||
|
||||
Qt 每个请求都会带:
|
||||
|
||||
```text
|
||||
X-Api-Token: <Token>
|
||||
```
|
||||
|
||||
默认 Token 写在 `src/ApiClient.cpp`。可以通过环境变量覆盖:
|
||||
|
||||
```powershell
|
||||
$env:ATTENDANCE_API_TOKEN="你的Token"
|
||||
```
|
||||
|
||||
### 2.4 响应加密
|
||||
|
||||
服务端支持通过请求头 `X-Encrypted-Response: true` 返回 AES 加密响应,Qt 代码中保留了解密能力。
|
||||
|
||||
当前普通记录和统计请求没有默认开启这个请求头,实际按 JSON 明文解析。
|
||||
|
||||
## 3. 技术栈
|
||||
|
||||
| 分类 | 内容 |
|
||||
| --- | --- |
|
||||
| 开发语言 | C++17、QML |
|
||||
| UI 框架 | Qt Quick / Qt Quick Controls 2 |
|
||||
| 语言 | 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` |
|
||||
| 构建 | CMake 3.16+ |
|
||||
| 平台 | Windows 桌面端、Android |
|
||||
|
||||
## 目录结构
|
||||
## 4. 目录结构
|
||||
|
||||
```text
|
||||
rewrite_qt/
|
||||
├── android/ # Android 打包补充资源和 Gradle 配置
|
||||
│ ├── gradle.properties
|
||||
│ └── res/mipmap-*/ # Android 启动图标
|
||||
├── assets/ # 桌面端图标资源
|
||||
├── cmake/ # 平台相关模板文件
|
||||
├── qml/
|
||||
│ └── Main.qml # 主界面
|
||||
QT/
|
||||
├── android/ # Android 打包配置和图标
|
||||
├── assets/ # 桌面端图标
|
||||
├── cmake/ # 平台模板
|
||||
├── qml/Main.qml # 主界面
|
||||
├── src/
|
||||
│ ├── ApiClient.* # 服务端 API 客户端
|
||||
│ ├── AppController.* # QML 与业务逻辑中枢
|
||||
│ ├── AttendanceTypes.h # 考勤数据结构
|
||||
│ ├── CryptoUtils.* # AES 解密工具
|
||||
│ ├── *ListModel.* # 暴露给 QML 的列表模型
|
||||
│ └── crypto/tiny_aes.hpp # AES 底层实现
|
||||
│ ├── ApiClient.* # HTTP API 客户端
|
||||
│ ├── AppController.* # 业务逻辑中枢
|
||||
│ ├── AttendanceTypes.h # 数据结构
|
||||
│ ├── CryptoUtils.* # AES 解密兼容工具
|
||||
│ └── *ListModel.* # QML 列表模型
|
||||
├── CMakeLists.txt
|
||||
├── LICENSE
|
||||
├── README.md
|
||||
└── main.cpp
|
||||
```
|
||||
|
||||
## 功能说明
|
||||
## 5. 构建环境
|
||||
|
||||
### 考勤记录
|
||||
基础要求:
|
||||
|
||||
考勤记录页用于查看和管理服务端同步下来的签到记录。
|
||||
1. Qt 6.2 或更高。
|
||||
2. CMake 3.16 或更高。
|
||||
3. 支持 C++17 的编译器。
|
||||
|
||||
- 查看总记录数、今日签到数、当前筛选结果数。
|
||||
- 按班级、学号、姓名关键字筛选记录。
|
||||
- 按全部时间、今天、最近 7 天、最近 30 天、指定日期筛选。
|
||||
- 刷新最新记录。
|
||||
- 导出当前筛选结果为 CSV。
|
||||
- 清空服务端全部考勤记录。
|
||||
Windows 推荐使用 Qt 6.x MinGW 64-bit Kit。
|
||||
|
||||
导出字段:
|
||||
Android 还需要:
|
||||
|
||||
```text
|
||||
签到时间,班级编号,班级名称,学号,姓名,置信度,状态
|
||||
```
|
||||
1. Qt for Android。
|
||||
2. Android SDK。
|
||||
3. Android NDK。
|
||||
4. JDK。
|
||||
5. Android 平台版本,例如 `android-35`。
|
||||
|
||||
### 统计分析
|
||||
|
||||
统计分析页基于当前加载的考勤记录在本地计算统计结果。
|
||||
|
||||
- 支持今天、最近 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 构建
|
||||
## 6. Qt Creator 构建
|
||||
|
||||
1. 打开 Qt Creator。
|
||||
2. 选择项目根目录下的 `CMakeLists.txt`。
|
||||
3. 选择对应 Kit,例如 Desktop Qt 6.x 或 Qt for Android。
|
||||
2. 打开 `QT/CMakeLists.txt`。
|
||||
3. 选择 Desktop Qt 或 Android Kit。
|
||||
4. 等待 CMake 配置完成。
|
||||
5. 构建目标 `apprewrite_qt`。
|
||||
6. 桌面端可直接运行;Android 端可部署到设备或模拟器。
|
||||
6. 桌面端直接运行;Android 端部署到设备或模拟器。
|
||||
|
||||
## 命令行构建
|
||||
## 7. 命令行构建示例
|
||||
|
||||
Windows 示例:
|
||||
|
||||
```powershell
|
||||
cmake -S . -B build -DCMAKE_PREFIX_PATH=D:/Qt/6.10.0/mingw_64
|
||||
cmake --build build --target apprewrite_qt
|
||||
cd D:\University\graduation\code\Reconstruct_Qt\QT
|
||||
cmake -S . -B build\Desktop -DCMAKE_PREFIX_PATH=D:\Qt\6.10.0\mingw_64
|
||||
cmake --build build\Desktop
|
||||
```
|
||||
|
||||
Linux 示例:
|
||||
Qt 安装路径需要按本机实际情况修改。
|
||||
|
||||
```bash
|
||||
cmake -S . -B build -DCMAKE_PREFIX_PATH=/path/to/Qt/6.x/gcc_64
|
||||
cmake --build build --target apprewrite_qt
|
||||
```
|
||||
## 8. 本地数据
|
||||
|
||||
安装到系统路径:
|
||||
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 |
|
||||
| 服务端地址 | `QSettings`,键名 `server/url` |
|
||||
| 时间段配置 | `QStandardPaths::AppDataLocation/timeslot_config.json` |
|
||||
| 学生/班级映射 | `QStandardPaths::AppDataLocation/mapping_config.json` |
|
||||
| 人脸照片缓存 | `QStandardPaths::AppDataLocation/face_photos/*.jpg` |
|
||||
|
||||
## 界面与移动端适配
|
||||
## 9. 常见问题
|
||||
|
||||
主界面位于 `qml/Main.qml`,采用 `ApplicationWindow`、`StackLayout`、`GridLayout`、`ColumnLayout` 等组件组织页面。
|
||||
| 现象 | 处理 |
|
||||
| --- | --- |
|
||||
| 测试连接失败 | 检查服务端是否启动、地址是否正确、Token 是否一致 |
|
||||
| 没有时间段 | 这是当前默认行为,需要手动新增并保存 |
|
||||
| 识别成功但没有记录 | 检查 STM32 是否在时间段内、是否成功加密上传 |
|
||||
| 人脸照片不显示 | 检查 K210 是否上传照片,服务端 `faces/` 是否有图片 |
|
||||
| Android 布局拥挤 | 当前 QML 已做窄屏自适应,若仍异常请检查实际设备宽度 |
|
||||
|
||||
当前界面包含五个页面:
|
||||
|
||||
- 考勤记录
|
||||
- 统计分析
|
||||
- 配置中心
|
||||
- 人脸管理
|
||||
- 关于
|
||||
|
||||
移动端适配要点:
|
||||
|
||||
- `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` 文件。
|
||||
|
||||
+22
-21
@@ -7,8 +7,8 @@ ApplicationWindow {
|
||||
id: window
|
||||
width: 1560
|
||||
height: 920
|
||||
minimumWidth: 900
|
||||
minimumHeight: 620
|
||||
minimumWidth: Qt.platform.os === "android" ? 360 : 900
|
||||
minimumHeight: Qt.platform.os === "android" ? 560 : 620
|
||||
visible: true
|
||||
title: "人脸识别考勤系统"
|
||||
color: "#f6f7f9"
|
||||
@@ -41,11 +41,12 @@ ApplicationWindow {
|
||||
property int currentPageIndex: 0
|
||||
property bool timeSlotsExpanded: false
|
||||
property bool mappingsExpanded: false
|
||||
readonly property bool androidLayout: Qt.platform.os === "android"
|
||||
readonly property bool compactLayout: width < 1460
|
||||
readonly property bool denseLayout: width < 1380
|
||||
readonly property bool narrowLayout: width < 1280
|
||||
readonly property bool slimLayout: width < 1040
|
||||
readonly property bool phoneLayout: width < 560
|
||||
readonly property bool phoneLayout: width < (androidLayout ? 640 : 560)
|
||||
readonly property int sidebarWidth: slimLayout ? 0 : (compactLayout ? 212 : 240)
|
||||
|
||||
font.family: appFontFamily
|
||||
@@ -169,12 +170,14 @@ ApplicationWindow {
|
||||
}
|
||||
|
||||
contentItem: Label {
|
||||
width: control.availableWidth
|
||||
text: control.text
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: control.labelColor
|
||||
font.pixelSize: 13
|
||||
font.weight: appBoldWeight
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,12 +197,14 @@ ApplicationWindow {
|
||||
}
|
||||
|
||||
contentItem: Label {
|
||||
width: control.availableWidth
|
||||
text: control.text
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
color: control.labelColor
|
||||
font.pixelSize: 13
|
||||
font.weight: appBoldWeight
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1972,7 +1977,7 @@ ApplicationWindow {
|
||||
radius: 8
|
||||
color: panelStrong
|
||||
border.color: lineColor
|
||||
Layout.preferredHeight: phoneLayout ? 218 : (slimLayout ? 174 : 88)
|
||||
Layout.preferredHeight: serverConfigGrid.implicitHeight + (slimLayout ? 24 : 36)
|
||||
clip: true
|
||||
|
||||
GridLayout {
|
||||
@@ -1998,13 +2003,13 @@ ApplicationWindow {
|
||||
}
|
||||
|
||||
SecondaryButton {
|
||||
Layout.fillWidth: phoneLayout
|
||||
Layout.fillWidth: slimLayout
|
||||
text: "保存地址"
|
||||
onClicked: appController.serverUrl = serverUrlField.text
|
||||
}
|
||||
|
||||
PrimaryButton {
|
||||
Layout.fillWidth: phoneLayout
|
||||
Layout.fillWidth: slimLayout
|
||||
text: "测试连接"
|
||||
fillColor: tealColor
|
||||
onClicked: appController.testConnection()
|
||||
@@ -2017,19 +2022,17 @@ ApplicationWindow {
|
||||
radius: 8
|
||||
color: panelStrong
|
||||
border.color: lineColor
|
||||
Layout.preferredHeight: timeSlotsExpanded
|
||||
? 18 + (phoneLayout ? 132 : 64) + 12 + (phoneLayout ? 0 : 42) + (phoneLayout ? 0 : 12) + Math.max(phoneLayout ? 96 : 52, timeSlotListView.contentHeight) + 18
|
||||
: (phoneLayout ? 124 : 100)
|
||||
Layout.preferredHeight: timeSlotCardLayout.implicitHeight + 36
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
id: timeSlotCardLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: phoneLayout ? 132 : 64
|
||||
spacing: 12
|
||||
|
||||
SectionHeader {
|
||||
@@ -2040,7 +2043,7 @@ ApplicationWindow {
|
||||
}
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: phoneLayout ? 3 : 5
|
||||
columns: slimLayout ? 3 : 5
|
||||
columnSpacing: 8
|
||||
rowSpacing: 8
|
||||
|
||||
@@ -2058,7 +2061,7 @@ ApplicationWindow {
|
||||
SecondaryButton {
|
||||
Layout.fillWidth: true
|
||||
visible: timeSlotsExpanded
|
||||
text: "恢复默认"
|
||||
text: "清空时段"
|
||||
onClicked: {
|
||||
appController.timeSlotModel.resetToDefault();
|
||||
appController.saveTimeSlots();
|
||||
@@ -2067,13 +2070,13 @@ ApplicationWindow {
|
||||
SecondaryButton {
|
||||
Layout.fillWidth: true
|
||||
visible: timeSlotsExpanded
|
||||
text: phoneLayout ? "同步" : "从服务器同步"
|
||||
text: slimLayout ? "同步" : "从服务器同步"
|
||||
onClicked: appController.reloadTimeSlotsFromServer()
|
||||
}
|
||||
PrimaryButton {
|
||||
Layout.fillWidth: true
|
||||
visible: timeSlotsExpanded
|
||||
text: phoneLayout ? "保存" : "保存时间段"
|
||||
text: slimLayout ? "保存" : "保存时间段"
|
||||
onClicked: appController.saveTimeSlots()
|
||||
}
|
||||
}
|
||||
@@ -2120,7 +2123,7 @@ ApplicationWindow {
|
||||
id: timeSlotListView
|
||||
visible: timeSlotsExpanded
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: visible ? contentHeight : 0
|
||||
Layout.preferredHeight: visible ? Math.max(phoneLayout ? 96 : 52, contentHeight) : 0
|
||||
clip: false
|
||||
interactive: false
|
||||
spacing: 8
|
||||
@@ -2253,19 +2256,17 @@ ApplicationWindow {
|
||||
radius: 8
|
||||
color: panelStrong
|
||||
border.color: lineColor
|
||||
Layout.preferredHeight: mappingsExpanded
|
||||
? 18 + (phoneLayout ? 120 : 64) + 12 + mappingTabs.implicitHeight + 12 + Math.max(phoneLayout ? 112 : 64, mappingTabs.currentIndex === 0 ? studentMappingListView.contentHeight : classMappingListView.contentHeight) + 18
|
||||
: (phoneLayout ? 124 : 100)
|
||||
Layout.preferredHeight: mappingCardLayout.implicitHeight + 36
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
id: mappingCardLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
spacing: 12
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: phoneLayout ? 120 : 64
|
||||
spacing: 12
|
||||
SectionHeader {
|
||||
Layout.fillWidth: true
|
||||
@@ -2324,7 +2325,7 @@ ApplicationWindow {
|
||||
StackLayout {
|
||||
visible: mappingsExpanded
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: visible ? (mappingTabs.currentIndex === 0 ? studentMappingListView.contentHeight : classMappingListView.contentHeight) : 0
|
||||
Layout.preferredHeight: visible ? Math.max(phoneLayout ? 112 : 64, mappingTabs.currentIndex === 0 ? studentMappingListView.contentHeight : classMappingListView.contentHeight) : 0
|
||||
currentIndex: mappingTabs.currentIndex
|
||||
|
||||
Rectangle {
|
||||
@@ -2861,7 +2862,7 @@ ApplicationWindow {
|
||||
TextArea {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
text: "本系统为纪田野的个人毕业设计作品,仅用于毕业设计展示、学习研究与系统功能验证。\nCopyright © 2026 纪田野。保留所有权利。\n仓库地址:https://git.yzfh.eu.cc/yzfh/qt.git\n演示账号与密码请在实际部署环境中单独配置。"
|
||||
text: "本系统为纪田野的个人毕业设计作品,仅用于毕业设计展示、学习研究与系统功能验证。\n\nCopyright © 2026 纪田野。保留所有权利。\n\n仓库地址:https://git.yzfh.eu.cc/yzfh/qt.git\n\n演示账号:jtybishe 密码:jtybishe"
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
selectByKeyboard: true
|
||||
|
||||
@@ -79,6 +79,35 @@ QString safeFileName(QString value)
|
||||
return value.isEmpty() ? QStringLiteral("unknown") : value;
|
||||
}
|
||||
|
||||
QVector<TimeSlot> legacyDefaultTimeSlots()
|
||||
{
|
||||
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}
|
||||
};
|
||||
}
|
||||
|
||||
bool isLegacyDefaultTimeSlots(const QVector<TimeSlot> &timeSlots)
|
||||
{
|
||||
const QVector<TimeSlot> legacy = legacyDefaultTimeSlots();
|
||||
if (timeSlots.size() != legacy.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < legacy.size(); ++i) {
|
||||
if (timeSlots.at(i).name != legacy.at(i).name
|
||||
|| timeSlots.at(i).startTime != legacy.at(i).startTime
|
||||
|| timeSlots.at(i).endTime != legacy.at(i).endTime
|
||||
|| timeSlots.at(i).enabled != legacy.at(i).enabled) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
AppController::AppController(QObject *parent)
|
||||
@@ -131,11 +160,11 @@ AppController::AppController(QObject *parent)
|
||||
});
|
||||
|
||||
connect(&m_apiClient, &ApiClient::timeSlotsFetched, this, [this](const QVector<TimeSlot> &timeSlots) {
|
||||
if (!timeSlots.isEmpty()) {
|
||||
m_timeSlotModel.setTimeSlots(timeSlots);
|
||||
saveTimeSlotsToLocal();
|
||||
setStatusMessage(QStringLiteral("已从服务器同步时间段"));
|
||||
}
|
||||
m_timeSlotModel.setTimeSlots(timeSlots);
|
||||
saveTimeSlotsToLocal();
|
||||
setStatusMessage(timeSlots.isEmpty()
|
||||
? QStringLiteral("已从服务器同步空时间段配置")
|
||||
: QStringLiteral("已从服务器同步时间段"));
|
||||
});
|
||||
|
||||
connect(&m_apiClient, &ApiClient::timeSlotsUploaded, this, [this](bool success, const QString &message) {
|
||||
@@ -425,8 +454,14 @@ 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();
|
||||
const QString faceId = item.faceId.trimmed();
|
||||
const QString studentId = item.studentId.trimmed();
|
||||
if (faceId.isEmpty() || studentId.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m_faceIdMapping.contains(faceId) || faceId != studentId) {
|
||||
mapping[faceId] = studentId;
|
||||
}
|
||||
}
|
||||
m_faceIdMapping = mapping;
|
||||
@@ -537,7 +572,7 @@ void AppController::loadTimeSlotsFromLocal()
|
||||
{
|
||||
QFile file(localSettingsPath("timeslot_config.json"));
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
m_timeSlotModel.setTimeSlots(defaultTimeSlots());
|
||||
m_timeSlotModel.setTimeSlots({});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -553,7 +588,14 @@ void AppController::loadTimeSlotsFromLocal()
|
||||
});
|
||||
}
|
||||
|
||||
m_timeSlotModel.setTimeSlots(timeSlots.isEmpty() ? defaultTimeSlots() : timeSlots);
|
||||
if (isLegacyDefaultTimeSlots(timeSlots)) {
|
||||
timeSlots.clear();
|
||||
}
|
||||
|
||||
m_timeSlotModel.setTimeSlots(timeSlots);
|
||||
if (timeSlots.isEmpty()) {
|
||||
saveTimeSlotsToLocal();
|
||||
}
|
||||
}
|
||||
|
||||
QString AppController::localSettingsPath(const QString &fileName) const
|
||||
@@ -725,6 +767,9 @@ void AppController::mergeFaceItems()
|
||||
for (const QString &id : m_allStudentIds) {
|
||||
allIds.insert(id);
|
||||
}
|
||||
for (auto it = m_faceIdMapping.cbegin(); it != m_faceIdMapping.cend(); ++it) {
|
||||
allIds.insert(it.key());
|
||||
}
|
||||
|
||||
QStringList sortedIds = allIds.values();
|
||||
std::sort(sortedIds.begin(), sortedIds.end());
|
||||
|
||||
@@ -42,12 +42,7 @@ struct TimeSlot
|
||||
|
||||
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}
|
||||
};
|
||||
return {};
|
||||
}
|
||||
|
||||
Q_DECLARE_METATYPE(AttendanceRecord)
|
||||
|
||||
Reference in New Issue
Block a user