初始化

This commit is contained in:
2026-05-01 10:51:39 +08:00
commit fe6e4e428a
25 changed files with 5763 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
build/
bin/
Makefile
.qmake.stash
# Qt generated files
moc_*.cpp
ui_*.h
qrc_*.cpp
*.o
*.obj
# CMake generated files
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
# Local editor and OS files
.DS_Store
Thumbs.db
*.user
*.autosave
+82
View File
@@ -0,0 +1,82 @@
#-------------------------------------------------
# 人脸识别考勤系统 - Qt查看器
# 项目配置文件 (qmake)
#-------------------------------------------------
QT += core gui widgets network
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = AttendanceViewer
TEMPLATE = app
# C++17标准
CONFIG += c++17
# 输出目录
DESTDIR = $$PWD/bin
OBJECTS_DIR = $$PWD/build/obj
MOC_DIR = $$PWD/build/moc
RCC_DIR = $$PWD/build/rcc
UI_DIR = $$PWD/build/ui
# 包含路径
INCLUDEPATH += $$PWD/include
# 源文件
SOURCES += \
src/main.cpp \
src/MainWindow.cpp \
src/AttendanceRecord.cpp \
src/TimeSlotConfig.cpp \
src/SettingsDialog.cpp \
src/StatisticsDialog.cpp \
src/NetworkApiManager.cpp \
src/ServerConfigDialog.cpp \
src/MappingConfigDialog.cpp \
src/FaceManageDialog.cpp
# 头文件
HEADERS += \
include/MainWindow.h \
include/AttendanceRecord.h \
include/TimeSlotConfig.h \
include/SettingsDialog.h \
include/StatisticsDialog.h \
include/NetworkApiManager.h \
include/ServerConfigDialog.h \
include/MappingConfigDialog.h \
include/FaceManageDialog.h
# 默认部署规则
qnx: target.path = /tmp/$${TARGET}/bin
else: unix:!android: target.path = /opt/$${TARGET}/bin
!isEmpty(target.path): INSTALLS += target
# 编译器警告
QMAKE_CXXFLAGS += -Wall
# Windows特定配置
win32 {
RC_ICONS = icon.ico
CONFIG += windows
# 链接OpenSSL库(用于AES加密/解密)
LIBS += -lssl -lcrypto
# 如果使用预编译的OpenSSL,取消下面的注释并设置路径
# INCLUDEPATH += "C:/OpenSSL/include"
# LIBS += -L"C:/OpenSSL/lib"
}
# Linux特定配置
unix:!macx {
CONFIG += link_pkgconfig
# 链接OpenSSL库(用于AES加密/解密)
LIBS += -lssl -lcrypto
}
# 版本信息
VERSION = 1.0.0
DEFINES += APP_VERSION=\\\"$$VERSION\\\"
+65
View File
@@ -0,0 +1,65 @@
cmake_minimum_required(VERSION 3.16)
project(AttendanceViewer VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 自动处理Qt的MOC、UIC和RCC
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
# 查找Qt5组件
find_package(Qt5 REQUIRED COMPONENTS
Core
Widgets
Network
)
# 源文件
set(SOURCES
src/main.cpp
src/MainWindow.cpp
src/AttendanceRecord.cpp
src/TimeSlotConfig.cpp
src/SettingsDialog.cpp
src/StatisticsDialog.cpp
src/NetworkApiManager.cpp
src/ServerConfigDialog.cpp
)
# 头文件
set(HEADERS
include/MainWindow.h
include/AttendanceRecord.h
include/TimeSlotConfig.h
include/SettingsDialog.h
include/StatisticsDialog.h
include/NetworkApiManager.h
include/ServerConfigDialog.h
)
# 包含目录
include_directories(${CMAKE_SOURCE_DIR}/include)
# 创建可执行文件
add_executable(${PROJECT_NAME} ${SOURCES} ${HEADERS})
# 链接Qt库
target_link_libraries(${PROJECT_NAME}
Qt5::Core
Qt5::Widgets
Qt5::Network
)
# Windows平台特定设置
if(WIN32)
set_target_properties(${PROJECT_NAME} PROPERTIES
WIN32_EXECUTABLE ON
)
endif()
# 安装目标
install(TARGETS ${PROJECT_NAME}
RUNTIME DESTINATION bin
)
+330
View File
@@ -0,0 +1,330 @@
# 人脸识别考勤系统 - Qt管理端
基于 Qt 5.15 的全功能考勤管理系统,用于管理STM32人脸识别考勤系统的签到数据。
## 📋 功能特性
### 📊 数据查看
✅ **实时数据显示** - 自动读取服务器端CSV数据库
✅ **自动刷新** - 每5秒自动刷新,支持文件监视
✅ **多条件筛选** - 按学号、姓名、日期筛选记录
✅ **统计信息** - 实时显示总记录数和今日签到数
### ⚙️ 系统管理
✅ **时间段设置** - 自定义签到时间段(上午/下午/晚上等)
✅ **记录管理** - 清空签到记录、导出备份
✅ **文件选择** - 支持切换不同的CSV数据文件
### 📈 统计分析
✅ **统计报表** - 按日期范围生成学生签到统计
✅ **多维度分析** - 总签到次数、今日签到、平均置信度
✅ **报表导出** - 支持导出统计报表为CSV格式
### 🎨 界面优化
✅ **菜单栏** - 文件、管理、帮助菜单
✅ **友好界面** - 清晰的表格展示,交替行颜色
✅ **颜色标识** - 成功/失败状态颜色区分
## 🛠️ 环境要求
- **Qt 5.15** 或更高版本
- **CMake 3.16** 或更高版本
- **C++17** 编译器
- Windows/Linux/macOS
## 📁 项目结构
```
QT/
├── CMakeLists.txt # CMake配置文件
├── AttendanceViewer.pro # qmake项目文件
├── build.sh / build.bat # 构建脚本
├── include/ # 头文件目录
│ ├── MainWindow.h # 主窗口类
│ ├── AttendanceRecord.h # 考勤记录数据模型
│ ├── TimeSlotConfig.h # 时间段配置管理
│ ├── SettingsDialog.h # 设置对话框
│ └── StatisticsDialog.h # 统计报表对话框
├── src/ # 源文件目录
│ ├── main.cpp # 程序入口
│ ├── MainWindow.cpp # 主窗口实现
│ ├── AttendanceRecord.cpp # 数据模型实现
│ ├── TimeSlotConfig.cpp # 时间段配置实现
│ ├── SettingsDialog.cpp # 设置对话框实现
│ └── StatisticsDialog.cpp # 统计报表实现
└── README.md # 本文档
```
## 🚀 编译和运行
### 方法1: 使用 Qt Creator(推荐)
1. **安装Qt 5.15**
- 从 [Qt官网](https://www.qt.io/download) 下载Qt 5.15
- 安装时确保选择 MinGW 或 MSVC 编译器
2. **打开项目**
```
- 启动 Qt Creator
- 打开 QT/AttendanceViewer.pro
- 选择合适的Kit (Qt 5.15 + MinGW/MSVC)
```
3. **编译运行**
- 点击左下角的 "运行" 按钮(绿色三角形)
- 或按快捷键 `Ctrl+R`
### 方法2: 使用 qmake (命令行)
#### Windows
```bash
cd QT
# 方法A: 使用构建脚本
build.bat
# 方法B: 手动编译
qmake AttendanceViewer.pro
mingw32-make
cd bin
AttendanceViewer.exe
```
#### Linux
```bash
cd QT
# 安装Qt5 (如果还没安装)
sudo apt-get install qt5-default
# 方法A: 使用构建脚本
chmod +x build.sh
./build.sh
# 方法B: 手动编译
qmake AttendanceViewer.pro
make
cd bin
./AttendanceViewer
```
### 方法3: 使用 CMake
#### Linux
```bash
cd QT
mkdir build && cd build
cmake ..
make
./AttendanceViewer
```
#### Windows
```bash
cd QT
mkdir build
cd build
cmake -G "MinGW Makefiles" ..
mingw32-make
cd Release
AttendanceViewer.exe
```
## 📊 使用说明
### 主界面功能
#### 1. 菜单栏功能
**文件菜单**
- **选择CSV文件**: 切换到不同的考勤数据文件
- **导出数据**: 导出当前筛选的记录为CSV
- **退出**: 关闭程序
**管理菜单**
- **时间段设置**: 自定义签到时间段
- **统计报表**: 生成考勤统计报表
- **清空签到记录**: 清空所有签到数据(危险操作,需二次确认)
**帮助菜单**
- **关于**: 显示软件版本和功能信息
#### 2. 统计信息栏
- **总记录数**: 显示数据库中的总签到记录数
- **今日签到**: 显示今天的签到人次
- **状态**: 显示当前程序状态(就绪/加载中/显示记录数)
#### 3. 筛选条件
- **学号**: 下拉选择特定学号,显示该学生的所有签到记录
- **姓名**: 输入姓名关键字进行模糊搜索
- **日期**:
- 全部:显示所有记录
- 今天:只显示今天的记录
- 最近7天:显示过去一周的记录
- 最近30天:显示过去一个月的记录
- **清除筛选**: 一键清除所有筛选条件
#### 4. 数据表格
显示筛选后的考勤记录,包含以下列:
- **签到时间**: 格式 `2026-02-01 11:06:41`
- **学号**: 学生学号
- **姓名**: 学生姓名
- **置信度**: 人脸识别置信度(百分比)
- **状态**: 签到状态(成功/失败),成功显示为绿色
#### 5. 底部操作按钮
- **刷新数据**: 手动刷新数据
- **导出数据**: 导出当前筛选的数据为CSV
- **时间段设置**: 快速打开时间段设置对话框
- **统计报表**: 快速打开统计报表对话框
- **清空记录**: 清空所有签到记录(危险操作)
### 自动刷新机制
程序采用双重刷新机制确保数据实时性:
1. **定时刷新**: 每5秒自动检查并更新数据
2. **文件监视**: 当CSV文件被修改时立即触发刷新
## 🔧 配置说明
### CSV文件路径
程序默认读取路径:`../server/attendance_database.csv`
如需修改路径,请编辑 `src/MainWindow.cpp` 中的 `getDefaultCsvPath()` 函数:
```cpp
QString MainWindow::getDefaultCsvPath()
{
// 修改为你的CSV文件路径
return "C:/path/to/your/attendance_database.csv";
}
```
### CSV文件格式
程序读取的CSV格式如下:
```csv
Timestamp,Student_ID,Name,Confidence,Status
2026-02-01 11:06:41,2,ID2,0.90,success
2026-02-01 11:06:58,1,ID1,0.87,success
```
字段说明:
- `Timestamp`: 签到时间(YYYY-MM-DD HH:MM:SS)
- `Student_ID`: 学号
- `Name`: 姓名
- `Confidence`: 置信度(0.0-1.0)
- `Status`: 状态(success/fail)
## 🎯 核心功能实现
### 数据模型 (AttendanceRecord)
负责解析和存储单条考勤记录:
- 从CSV行解析数据
- 时间戳格式转换
- 数据验证
### 主窗口 (MainWindow)
核心功能实现:
- UI界面构建(统计栏、筛选栏、表格、按钮)
- CSV文件读取和解析
- 数据筛选逻辑(学号、姓名、日期)
- 表格动态更新
- 统计信息计算
- 文件监视和自动刷新
- 数据导出
## 📝 开发说明
### 添加新功能
**示例:添加成绩统计图表**
1. 在 `CMakeLists.txt` 中添加 Qt Charts 模块(已包含)
2. 在 `MainWindow.h` 中添加图表成员变量
3. 在 `MainWindow.cpp` 中实现图表绘制逻辑
### 自定义样式
可以在 `MainWindow::setupUI()` 中修改界面样式:
```cpp
// 修改表格样式
m_tableWidget->setStyleSheet("QTableWidget { background-color: #f0f0f0; }");
// 修改按钮样式
m_refreshButton->setStyleSheet("QPushButton { background-color: #4CAF50; color: white; }");
```
## 🐛 故障排除
### 问题1: 找不到CSV文件
**现象**: 提示"找不到考勤数据文件"
**解决方案**:
1. 确认CSV文件路径是否正确
2. 检查 `../server/attendance_database.csv` 是否存在
3. 手动修改 `getDefaultCsvPath()` 函数中的路径
### 问题2: Qt Charts模块找不到
**现象**: 编译错误 `Could not find a package configuration file provided by "Qt5Charts"`
**解决方案**:
```bash
# Ubuntu/Debian
sudo apt-get install libqt5charts5-dev
# 或从CMakeLists.txt中移除Charts(如果不需要图表功能)
find_package(Qt5 REQUIRED COMPONENTS Core Widgets) # 删除Charts
```
### 问题3: 自动刷新不工作
**现象**: 数据不会自动更新
**解决方案**:
1. 检查文件权限,确保程序有读取权限
2. 某些网络驱动器可能不支持文件监视
3. 手动点击"刷新数据"按钮
## 📈 性能优化
- 使用 `QVector` 存储数据,高效访问
- 表格按需更新,避免全量刷新
- 文件监视避免频繁读取磁盘
- 筛选操作在内存中进行,速度快
## 🎓 扩展功能建议
- [ ] **图表统计** - 添加签到趋势图、人员统计图
- [ ] **数据库支持** - 支持SQLite/MySQL数据库
- [ ] **Excel导出** - 使用QXlsx库导出真正的Excel格式
- [ ] **打印功能** - 支持打印考勤报表
- [ ] **多语言支持** - 中英文切换
- [ ] **主题切换** - 明暗主题切换
## 📄 许可证
本项目与主项目使用相同的许可证,仅供学习和研究使用。
## 🙏 致谢
感谢使用本考勤系统Qt查看器!
---
**开始使用**:
1. 确保服务器端已生成 `attendance_database.csv` 文件
2. 编译并运行Qt程序
3. 享受实时查看考勤数据的便利!🚀
+49
View File
@@ -0,0 +1,49 @@
@echo off
REM 人脸识别考勤系统 - Qt查看器编译脚本 (Windows)
echo =========================================
echo 编译 AttendanceViewer
echo =========================================
REM 检查是否设置了Qt环境变量
where qmake >nul 2>&1
if %errorlevel% neq 0 (
echo 错误: 未找到 qmake,请先设置 Qt 环境变量
echo 或使用 Qt Creator 打开项目
pause
exit /b 1
)
REM 清理旧的编译文件
echo [1/4] 清理旧的编译文件...
if exist build rmdir /s /q build
if exist bin rmdir /s /q bin
mkdir build
REM 进入构建目录
cd build
REM 生成 Makefile
echo [2/4] 生成 Makefile...
qmake ..\AttendanceViewer.pro
REM 编译
echo [3/4] 编译项目...
mingw32-make
REM 检查编译结果
if %errorlevel% equ 0 (
echo [4/4] 编译成功!
echo.
echo =========================================
echo 运行程序:
echo cd ..\bin
echo AttendanceViewer.exe
echo =========================================
) else (
echo [4/4] 编译失败!
pause
exit /b 1
)
pause
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# 人脸识别考勤系统 - Qt查看器编译脚本 (Linux)
echo "========================================="
echo " 编译 AttendanceViewer"
echo "========================================="
# 检查是否安装了qmake
if ! command -v qmake &> /dev/null; then
echo "错误: 未找到 qmake,请先安装 Qt5"
echo "运行: sudo apt-get install qt5-default"
exit 1
fi
# 清理旧的编译文件
echo "[1/4] 清理旧的编译文件..."
rm -rf build bin
mkdir -p build
# 进入构建目录
cd build
# 生成 Makefile
echo "[2/4] 生成 Makefile..."
qmake ../AttendanceViewer.pro
# 编译
echo "[3/4] 编译项目..."
make -j$(nproc)
# 检查编译结果
if [ $? -eq 0 ]; then
echo "[4/4] 编译成功!"
echo ""
echo "========================================="
echo " 运行程序:"
echo " cd ../bin && ./AttendanceViewer"
echo "========================================="
else
echo "[4/4] 编译失败!"
exit 1
fi
+53
View File
@@ -0,0 +1,53 @@
#ifndef ATTENDANCERECORD_H
#define ATTENDANCERECORD_H
#include <QString>
#include <QDateTime>
/**
* @brief 考勤记录数据模型
*
* 对应CSV文件格式:Timestamp,Student_ID,Name,Confidence,Status,Class_ID
*/
class AttendanceRecord
{
public:
AttendanceRecord();
AttendanceRecord(const QString &timestamp, const QString &studentId,
const QString &name, float confidence, const QString &status,
const QString &classId = "0");
// Getters
QString getTimestamp() const { return m_timestamp; }
QString getStudentId() const { return m_studentId; }
QString getName() const { return m_name; }
float getConfidence() const { return m_confidence; }
QString getStatus() const { return m_status; }
QString getClassId() const { return m_classId; }
QDateTime getDateTime() const { return m_dateTime; }
// Setters
void setTimestamp(const QString &timestamp);
void setStudentId(const QString &studentId) { m_studentId = studentId; }
void setName(const QString &name) { m_name = name; }
void setConfidence(float confidence) { m_confidence = confidence; }
void setStatus(const QString &status) { m_status = status; }
void setClassId(const QString &classId) { m_classId = classId; }
// 从CSV行解析
static AttendanceRecord fromCsvLine(const QString &line);
// 转换为显示字符串
QString toString() const;
private:
QString m_timestamp; // 时间戳字符串
QString m_studentId; // 学号
QString m_name; // 姓名
float m_confidence; // 置信度 (0.0-1.0)
QString m_status; // 状态 (success/fail)
QString m_classId; // 班级编号
QDateTime m_dateTime; // 解析后的日期时间对象
};
#endif // ATTENDANCERECORD_H
+98
View File
@@ -0,0 +1,98 @@
#ifndef FACEMANAGEDIALOG_H
#define FACEMANAGEDIALOG_H
#include <QDialog>
#include <QGridLayout>
#include <QScrollArea>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QMap>
#include <QNetworkAccessManager>
#include <QNetworkReply>
/**
* @brief 人脸照片项组件
* 显示单个人脸照片及其学号映射
*/
class FacePhotoItem : public QWidget
{
Q_OBJECT
public:
explicit FacePhotoItem(const QString &faceId, bool hasPhoto = true, QWidget *parent = nullptr);
QString getFaceId() const { return m_faceId; }
QString getStudentId() const { return m_studentIdEdit->text().trimmed(); }
void setStudentId(const QString &studentId) { m_studentIdEdit->setText(studentId); }
void setPhoto(const QPixmap &pixmap);
bool hasPhoto() const { return m_hasPhoto; }
signals:
void studentIdChanged(const QString &faceId, const QString &studentId);
void deleteRequested(const QString &faceId);
private:
QString m_faceId;
bool m_hasPhoto;
QLabel *m_photoLabel;
QLabel *m_idLabel;
QLineEdit *m_studentIdEdit;
QPushButton *m_deleteButton;
};
/**
* @brief 人脸管理对话框
* 显示K210上传的人脸照片,允许配置学号映射
*/
class FaceManageDialog : public QDialog
{
Q_OBJECT
public:
explicit FaceManageDialog(QWidget *parent = nullptr);
~FaceManageDialog();
void setServerUrl(const QString &url) { m_serverUrl = url; }
signals:
void mappingSaved();
public slots:
void refreshPhotos();
private slots:
void onPhotoListReceived(QNetworkReply *reply);
void onPhotoReceived(QNetworkReply *reply);
void onMappingReceived(QNetworkReply *reply);
void onMappingSaved(QNetworkReply *reply);
void onAllStudentIdsReceived(QNetworkReply *reply);
void onFaceDeleted(QNetworkReply *reply);
void saveMapping();
void onDeleteRequested(const QString &studentId);
private:
void setupUI();
void fetchPhotoList();
void fetchPhoto(const QString &faceId);
void fetchMapping();
void fetchAllStudentIds();
void uploadMapping();
void deleteFace(const QString &studentId, bool deleteRecords);
void clearPhotoItems();
QString m_serverUrl;
QNetworkAccessManager *m_networkManager;
QScrollArea *m_scrollArea;
QWidget *m_contentWidget;
QGridLayout *m_gridLayout;
QPushButton *m_refreshButton;
QPushButton *m_saveButton;
QLabel *m_statusLabel;
QMap<QString, FacePhotoItem*> m_photoItems; // faceId -> item
QMap<QString, QString> m_idMapping; // faceId -> studentId
int m_pendingPhotoCount;
};
#endif // FACEMANAGEDIALOG_H
+135
View File
@@ -0,0 +1,135 @@
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
#include <QMainWindow>
#include <QTableWidget>
#include <QPushButton>
#include <QLabel>
#include <QTimer>
#include <QComboBox>
#include <QLineEdit>
#include <QCheckBox>
#include <QDateEdit>
#include <QVector>
#include <QEvent>
#include <QDialog>
#include "AttendanceRecord.h"
#include "NetworkApiManager.h"
class MappingConfigDialog;
/**
* @brief 对话框位置限制过滤器
* 限制对话框只能在父窗口范围内移动
*/
class DialogPositionFilter : public QObject
{
Q_OBJECT
public:
explicit DialogPositionFilter(QWidget *parent = nullptr) : QObject(parent) {}
protected:
bool eventFilter(QObject *obj, QEvent *event) override;
};
/**
* @brief 考勤记录管理端主窗口(网络版)
*
* 功能:
* - 通过HTTP API从服务器获取数据
* - 显示考勤记录表格
* - 自动刷新数据(30秒)
* - 按学号、姓名、日期筛选
* - 统计信息显示
* - 服务器配置管理
*/
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow();
private slots:
// 数据操作
void loadAttendanceData(); // 从服务器加载考勤数据
void refreshData(); // 刷新数据
void filterData(); // 筛选数据
void exportToExcel(); // 导出为CSV
void exportSelectedRecords(); // 导出选中的记录
void clearFilter(); // 清除筛选条件
void deleteSelectedRecord(); // 删除选中的记录
void deleteSelectedRecords(); // 批量删除选中的记录
void toggleSelectAll(); // 全选/取消全选
void unselectAll(); // 取消选中
// 管理功能
void onClearAllRecords(); // 清空所有签到记录(调用API)
void onOpenSettings(); // 打开时间段设置
void onOpenMappingConfig(); // 打开映射配置
void onOpenFaceManage(); // 打开人脸管理
void onShowStatistics(); // 显示统计报表
void onAbout(); // 关于对话框
// 网络响应处理
void onRecordsFetched(const QVector<AttendanceRecord> &records);
void onStatisticsFetched(int total, int today, const QString &serverTime);
void onRecordsCleared(bool success);
void onRecordDeleted(bool success); // 新增:删除记录响应
void onNetworkError(const QString &error);
void onMappingConfigFetched(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &classNameMap);
void onMappingConfigUploaded(bool success);
private:
void setupUI(); // 设置UI界面
void setupMenuBar(); // 设置菜单栏
void setupConnections(); // 设置信号槽连接
void updateTable(const QVector<AttendanceRecord> &records); // 更新表格
void updateStatistics(int total, int today); // 更新统计信息
void showConnectionStatus(bool connected); // 显示连接状态
// UI组件
QTableWidget *m_tableWidget;
QPushButton *m_refreshButton;
QPushButton *m_exportButton;
QPushButton *m_exportSelectedButton; // 导出选中
QPushButton *m_deleteSelectedButton; // 删除选中
QPushButton *m_clearFilterButton;
QPushButton *m_settingsButton;
QPushButton *m_statsButton;
QPushButton *m_clearRecordsButton;
QCheckBox *m_selectAllCheckBox; // 全选复选框
QPushButton *m_unselectAllButton; // 取消选中按钮
QLabel *m_totalLabel;
QLabel *m_todayLabel;
QLabel *m_statusLabel;
QLabel *m_connectionLabel;
QLabel *m_selectedCountLabel; // 显示选中数量
QComboBox *m_classFilterCombo; // 班级筛选下拉框
QComboBox *m_studentFilterCombo;
QLineEdit *m_nameFilterEdit;
QComboBox *m_dateFilterCombo;
QDateEdit *m_specificDateEdit; // 特定日期选择器
QCheckBox *m_enableSpecificDateCheckBox; // 启用特定日期筛选
// 数据
QVector<AttendanceRecord> m_allRecords; // 所有记录
QVector<AttendanceRecord> m_filteredRecords; // 筛选后的记录
QSet<QString> m_selectedRecordKeys; // 保存选中记录的唯一键(timestamp+studentId)
QMap<QString, QString> m_studentNameMap; // 学号 -> 真实姓名 映射
QMap<QString, QString> m_classNameMap; // 班级编号 -> 班级名称 映射
// 辅助函数
QString getMappedStudentName(const QString &studentId, const QString &originalName) const;
QString getMappedClassName(const QString &classId) const;
void loadMappingConfigFromSettings();
void saveMappingConfigToSettings();
// 网络
NetworkApiManager *m_apiManager; // 网络API管理器
QTimer *m_autoRefreshTimer; // 自动刷新定时器
};
#endif // MAINWINDOW_H
+89
View File
@@ -0,0 +1,89 @@
#ifndef MAPPINGCONFIGDIALOG_H
#define MAPPINGCONFIGDIALOG_H
#include <QDialog>
#include <QTableWidget>
#include <QPushButton>
#include <QLineEdit>
#include <QMap>
#include <QTabWidget>
#include <QLabel>
/**
* @brief 映射配置对话框
*
* 用于配置:
* 1. 学号 -> 真实姓名 的映射
* 2. 班级编号 -> 真实班级名称 的映射
*
* 特性:
* - 双击单元格可直接编辑(类似Excel)
* - 自动从服务器加载已有映射
* - 一键保存到服务器
*/
class MappingConfigDialog : public QDialog
{
Q_OBJECT
public:
explicit MappingConfigDialog(QWidget *parent = nullptr);
~MappingConfigDialog();
// 获取映射数据
QMap<QString, QString> getStudentNameMap() const { return m_studentNameMap; }
QMap<QString, QString> getClassNameMap() const { return m_classNameMap; }
// 设置映射数据(用于从服务器加载后更新)
void setStudentNameMap(const QMap<QString, QString> &map);
void setClassNameMap(const QMap<QString, QString> &map);
// 从表格收集数据到映射
void collectDataFromTables();
// 设置状态文本
void setStatus(const QString &status);
signals:
void mappingChanged(); // 映射数据已修改,需要保存
void requestSyncFromServer(); // 请求从服务器加载数据
private slots:
void onAddStudent();
void onDeleteStudent();
void onAddClass();
void onDeleteClass();
void onSave();
void onSyncToServer();
void onSyncFromServer();
private:
void setupUI();
void updateStudentTable();
void updateClassTable();
// UI组件
QTabWidget *m_tabWidget;
QLabel *m_statusLabel;
// 学号-姓名映射表
QTableWidget *m_studentTable;
QPushButton *m_addStudentBtn;
QPushButton *m_deleteStudentBtn;
// 班级编号-名称映射表
QTableWidget *m_classTable;
QPushButton *m_addClassBtn;
QPushButton *m_deleteClassBtn;
// 底部按钮
QPushButton *m_syncFromServerBtn;
QPushButton *m_syncToServerBtn;
QPushButton *m_saveBtn;
QPushButton *m_cancelBtn;
// 数据
QMap<QString, QString> m_studentNameMap; // 学号 -> 姓名
QMap<QString, QString> m_classNameMap; // 班级编号 -> 班级名称
};
#endif // MAPPINGCONFIGDIALOG_H
+89
View File
@@ -0,0 +1,89 @@
#ifndef NETWORKAPIMANAGER_H
#define NETWORKAPIMANAGER_H
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QVector>
#include "AttendanceRecord.h"
#include "TimeSlotConfig.h"
/**
* @brief 网络API管理类
*
* 负责与服务器端HTTP API通信(支持AES-128-CBC加密)
* API端点:http://server_ip:8082/api/
*/
class NetworkApiManager : public QObject
{
Q_OBJECT
public:
explicit NetworkApiManager(QObject *parent = nullptr);
~NetworkApiManager();
// 设置服务器地址
void setServerUrl(const QString &url);
QString getServerUrl() const { return m_serverUrl; }
// 设置是否启用加密通信
void setEncryptionEnabled(bool enabled) { m_encryptionEnabled = enabled; }
bool isEncryptionEnabled() const { return m_encryptionEnabled; }
// API调用
void fetchRecords(int limit = 100, int offset = 0);
void fetchStatistics();
void clearAllRecords();
void deleteRecord(const QString &timestamp, const QString &studentId);
void uploadTimeSlots(const QVector<TimeSlot> &timeSlots);
void checkHealth();
// 映射配置API
void uploadMappingConfig(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &classNameMap);
void fetchMappingConfig();
// 人脸照片管理API
void fetchFacePhotos();
void fetchFacePhoto(const QString &faceId);
void uploadFaceIdMapping(const QMap<QString, QString> &mapping);
void fetchFaceIdMapping();
signals:
// 响应信号
void recordsFetched(const QVector<AttendanceRecord> &records);
void statisticsFetched(int total, int today, const QString &serverTime);
void recordsCleared(bool success);
void recordDeleted(bool success);
void timeSlotsUploaded(bool success);
void healthChecked(bool healthy);
void errorOccurred(const QString &error);
// 映射配置信号
void mappingConfigUploaded(bool success);
void mappingConfigFetched(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &classNameMap);
// 人脸照片管理信号
void facePhotosFetched(const QJsonArray &photos);
void facePhotoFetched(const QString &faceId, const QByteArray &photoData);
void faceIdMappingUploaded(bool success);
void faceIdMappingFetched(const QMap<QString, QString> &mapping);
private slots:
void onReplyFinished(QNetworkReply *reply);
private:
QNetworkAccessManager *m_networkManager;
QString m_serverUrl;
bool m_encryptionEnabled; // 是否启用加密通信
QString buildApiUrl(const QString &endpoint, const QMap<QString, QString> &params = QMap<QString, QString>());
QVector<AttendanceRecord> parseRecordsJson(const QByteArray &json);
// AES加密/解密功能
QByteArray aesDecrypt(const QByteArray &encryptedData);
QByteArray removePKCS7Padding(const QByteArray &data);
};
#endif // NETWORKAPIMANAGER_H
+33
View File
@@ -0,0 +1,33 @@
#ifndef SERVERCONFIGDIALOG_H
#define SERVERCONFIGDIALOG_H
#include <QDialog>
#include <QLineEdit>
#include <QPushButton>
#include <QLabel>
/**
* @brief 服务器配置对话框
*/
class ServerConfigDialog : public QDialog
{
Q_OBJECT
public:
explicit ServerConfigDialog(const QString &currentUrl, QWidget *parent = nullptr);
QString getServerUrl() const;
private slots:
void onTestConnection();
void onSave();
private:
void setupUI();
QLineEdit *m_urlEdit;
QPushButton *m_testButton;
QLabel *m_statusLabel;
QString m_serverUrl;
};
#endif // SERVERCONFIGDIALOG_H
+75
View File
@@ -0,0 +1,75 @@
#ifndef SETTINGSDIALOG_H
#define SETTINGSDIALOG_H
#include <QDialog>
#include <QTableWidget>
#include <QPushButton>
#include <QTimeEdit>
#include <QLineEdit>
#include <QCheckBox>
#include "TimeSlotConfig.h"
#include "NetworkApiManager.h"
/**
* @brief 设置对话框 - 管理签到时间段
*/
class SettingsDialog : public QDialog
{
Q_OBJECT
public:
explicit SettingsDialog(QWidget *parent = nullptr);
~SettingsDialog();
private slots:
void onAddTimeSlot();
void onEditTimeSlot();
void onDeleteTimeSlot();
void onResetToDefault();
void onSave();
void onCancel();
void onTimeSlotsUploaded(bool success); // 新增:处理上传结果
private:
void setupUI();
void loadTimeSlots();
void saveTimeSlots();
void refreshTimeSlots(); // 新增:只刷新表格显示,不重新加载数据
// UI组件
QTableWidget *m_tableWidget;
QPushButton *m_addButton;
QPushButton *m_editButton;
QPushButton *m_deleteButton;
QPushButton *m_resetButton;
QPushButton *m_saveButton;
QPushButton *m_cancelButton;
// 临时存储
QVector<TimeSlot> m_tempTimeSlots;
// 网络API管理器
NetworkApiManager *m_apiManager;
};
/**
* @brief 时间段编辑对话框
*/
class TimeSlotEditDialog : public QDialog
{
Q_OBJECT
public:
explicit TimeSlotEditDialog(QWidget *parent = nullptr, const TimeSlot *slot = nullptr);
TimeSlot getTimeSlot() const;
private:
void setupUI();
QLineEdit *m_nameEdit;
QTimeEdit *m_startTimeEdit;
QTimeEdit *m_endTimeEdit;
QCheckBox *m_enabledCheckBox;
};
#endif // SETTINGSDIALOG_H
+58
View File
@@ -0,0 +1,58 @@
#ifndef STATISTICSDIALOG_H
#define STATISTICSDIALOG_H
#include <QDialog>
#include <QTableWidget>
#include <QPushButton>
#include <QLabel>
#include <QDateEdit>
#include <QComboBox>
#include <QMap>
#include "AttendanceRecord.h"
/**
* @brief 统计报表对话框
*/
class StatisticsDialog : public QDialog
{
Q_OBJECT
public:
explicit StatisticsDialog(const QVector<AttendanceRecord> &records, QWidget *parent = nullptr);
~StatisticsDialog();
private slots:
void onDateRangeChanged();
void onExportReport();
void onRefresh();
private:
void setupUI();
void calculateStatistics();
void updateTable();
// 统计数据结构
struct StudentStats {
QString studentId;
QString name;
int totalCount; // 总签到次数
int todayCount; // 今日签到次数
QDateTime lastCheckIn; // 最后签到时间
double avgConfidence; // 平均置信度
};
// UI组件
QDateEdit *m_startDateEdit;
QDateEdit *m_endDateEdit;
QComboBox *m_quickSelectCombo;
QTableWidget *m_tableWidget;
QPushButton *m_exportButton;
QPushButton *m_refreshButton;
QLabel *m_summaryLabel;
// 数据
QVector<AttendanceRecord> m_records;
QMap<QString, StudentStats> m_statsMap;
};
#endif // STATISTICSDIALOG_H
+92
View File
@@ -0,0 +1,92 @@
#ifndef TIMESLOTCONFIG_H
#define TIMESLOTCONFIG_H
#include <QString>
#include <QTime>
#include <QVector>
#include <QSettings>
/**
* @brief 签到时间段结构
*/
struct TimeSlot
{
QString name; // 时段名称(如:上午签到)
QTime startTime; // 开始时间
QTime endTime; // 结束时间
bool enabled; // 是否启用
TimeSlot()
: enabled(true)
{}
TimeSlot(const QString &n, const QTime &start, const QTime &end, bool en = true)
: name(n), startTime(start), endTime(end), enabled(en)
{}
// 检查给定时间是否在此时段内
bool isInRange(const QTime &time) const {
if (!enabled) return false;
return time >= startTime && time <= endTime;
}
QString toString() const {
return QString("%1: %2 - %3 %4")
.arg(name)
.arg(startTime.toString("HH:mm"))
.arg(endTime.toString("HH:mm"))
.arg(enabled ? "(启用)" : "(禁用)");
}
};
/**
* @brief 时间段配置管理类
*/
class TimeSlotConfig
{
public:
static TimeSlotConfig& instance();
// 获取所有时间段
QVector<TimeSlot> getTimeSlots() const { return m_timeSlots; }
// 设置时间段
void setTimeSlots(const QVector<TimeSlot> &timeSlots);
// 添加时间段
void addTimeSlot(const TimeSlot &timeSlot);
// 删除时间段
void removeTimeSlot(int index);
// 更新时间段
void updateTimeSlot(int index, const TimeSlot &timeSlot);
// 检查时间是否在任何启用的时段内
bool isValidTime(const QTime &time) const;
// 获取当前时段名称
QString getCurrentTimeSlotName(const QTime &time) const;
// 保存配置到文件
void saveToFile();
// 从文件加载配置
void loadFromFile();
// 重置为默认配置
void resetToDefault();
private:
TimeSlotConfig();
~TimeSlotConfig();
TimeSlotConfig(const TimeSlotConfig&) = delete;
TimeSlotConfig& operator=(const TimeSlotConfig&) = delete;
QVector<TimeSlot> m_timeSlots;
QString m_configFilePath;
void initDefaultTimeSlots();
};
#endif // TIMESLOTCONFIG_H
+59
View File
@@ -0,0 +1,59 @@
#include "AttendanceRecord.h"
#include <QStringList>
AttendanceRecord::AttendanceRecord()
: m_confidence(0.0f)
, m_classId("0")
{
}
AttendanceRecord::AttendanceRecord(const QString &timestamp, const QString &studentId,
const QString &name, float confidence, const QString &status,
const QString &classId)
: m_timestamp(timestamp)
, m_studentId(studentId)
, m_name(name)
, m_confidence(confidence)
, m_status(status)
, m_classId(classId)
{
// 解析时间戳 - 格式: "2026-02-01 11:06:41"
m_dateTime = QDateTime::fromString(timestamp, "yyyy-MM-dd HH:mm:ss");
}
void AttendanceRecord::setTimestamp(const QString &timestamp)
{
m_timestamp = timestamp;
m_dateTime = QDateTime::fromString(timestamp, "yyyy-MM-dd HH:mm:ss");
}
AttendanceRecord AttendanceRecord::fromCsvLine(const QString &line)
{
// CSV格式: Timestamp,Student_ID,Name,Confidence,Status,Class_ID
// 示例: 2026-02-01 11:06:41,2,ID2,0.90,success,1
QStringList fields = line.split(',');
if (fields.size() < 5) {
return AttendanceRecord(); // 返回空记录
}
QString timestamp = fields[0].trimmed();
QString studentId = fields[1].trimmed();
QString name = fields[2].trimmed();
float confidence = fields[3].trimmed().toFloat();
QString status = fields[4].trimmed();
QString classId = (fields.size() >= 6) ? fields[5].trimmed() : "0";
return AttendanceRecord(timestamp, studentId, name, confidence, status, classId);
}
QString AttendanceRecord::toString() const
{
return QString("[%1] 班级:%2 学号:%3 姓名:%4 置信度:%.2f%% 状态:%5")
.arg(m_timestamp)
.arg(m_classId)
.arg(m_studentId)
.arg(m_name)
.arg(m_confidence * 100)
.arg(m_status);
}
+657
View File
@@ -0,0 +1,657 @@
#include "FaceManageDialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QMessageBox>
#include <QDebug>
#include <QPixmap>
#include <QBuffer>
// ============= FacePhotoItem 实现 =============
FacePhotoItem::FacePhotoItem(const QString &faceId, bool hasPhoto, QWidget *parent)
: QWidget(parent)
, m_faceId(faceId)
, m_hasPhoto(hasPhoto)
{
setFixedSize(180, 280);
setStyleSheet(R"(
QWidget {
background-color: #FFFFFF;
border: 1px solid #EBEEF5;
border-radius: 8px;
}
)");
QVBoxLayout *layout = new QVBoxLayout(this);
layout->setSpacing(8);
layout->setContentsMargins(10, 10, 10, 10);
// 照片显示
m_photoLabel = new QLabel();
m_photoLabel->setFixedSize(160, 160);
m_photoLabel->setAlignment(Qt::AlignCenter);
m_photoLabel->setStyleSheet(R"(
QLabel {
background-color: #F5F7FA;
border: 1px solid #DCDFE6;
border-radius: 4px;
}
)");
if (hasPhoto) {
m_photoLabel->setText("加载中...");
} else {
m_photoLabel->setText("无照片");
m_photoLabel->setStyleSheet(R"(
QLabel {
background-color: #FEF0F0;
border: 1px solid #FBC4C4;
border-radius: 4px;
color: #F56C6C;
}
)");
}
layout->addWidget(m_photoLabel);
// 学号/ID显示
m_idLabel = new QLabel(QString("ID: %1").arg(faceId));
m_idLabel->setStyleSheet("color: #909399; font-size: 12px; border: none;");
m_idLabel->setAlignment(Qt::AlignCenter);
layout->addWidget(m_idLabel);
// 学号输入框
m_studentIdEdit = new QLineEdit();
m_studentIdEdit->setPlaceholderText("输入真实学号...");
m_studentIdEdit->setStyleSheet(R"(
QLineEdit {
padding: 6px;
border: 1px solid #DCDFE6;
border-radius: 4px;
background-color: white;
}
QLineEdit:focus {
border: 1px solid #409EFF;
}
)");
layout->addWidget(m_studentIdEdit);
// 删除按钮
m_deleteButton = new QPushButton("删除");
m_deleteButton->setStyleSheet(R"(
QPushButton {
background-color: #F56C6C;
color: white;
border-radius: 4px;
padding: 6px 12px;
border: none;
font-weight: 600;
}
QPushButton:hover {
background-color: #F78989;
}
)");
connect(m_deleteButton, &QPushButton::clicked, this, [this]() {
emit deleteRequested(m_faceId);
});
layout->addWidget(m_deleteButton);
connect(m_studentIdEdit, &QLineEdit::textChanged, this, [this](const QString &text) {
emit studentIdChanged(m_faceId, text);
});
}
void FacePhotoItem::setPhoto(const QPixmap &pixmap)
{
if (pixmap.isNull()) {
m_photoLabel->setText("无照片");
} else {
m_photoLabel->setPixmap(pixmap.scaled(156, 156, Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
}
// ============= FaceManageDialog 实现 =============
FaceManageDialog::FaceManageDialog(QWidget *parent)
: QDialog(parent)
, m_networkManager(new QNetworkAccessManager(this))
, m_pendingPhotoCount(0)
{
m_serverUrl = "http://27.106.113.205:8082"; // 默认服务器地址
setupUI();
// 加载数据
refreshPhotos();
}
FaceManageDialog::~FaceManageDialog()
{
clearPhotoItems();
}
void FaceManageDialog::setupUI()
{
setWindowTitle("人脸管理 - K210人脸照片与学号映射");
resize(800, 600);
setMinimumSize(600, 400);
QVBoxLayout *mainLayout = new QVBoxLayout(this);
mainLayout->setSpacing(16);
mainLayout->setContentsMargins(20, 20, 20, 20);
// 标题和说明
QLabel *titleLabel = new QLabel("K210 人脸照片管理");
titleLabel->setStyleSheet("font-size: 18px; font-weight: bold; color: #303133;");
mainLayout->addWidget(titleLabel);
QLabel *descLabel = new QLabel("以下显示所有人脸信息(包括有照片和无照片的),可以删除人脸及其考勤记录:");
descLabel->setStyleSheet("color: #606266; font-size: 13px;");
descLabel->setWordWrap(true);
mainLayout->addWidget(descLabel);
// 工具栏
QHBoxLayout *toolbarLayout = new QHBoxLayout();
m_refreshButton = new QPushButton("刷新照片");
m_refreshButton->setStyleSheet(R"(
QPushButton {
background-color: #409EFF;
color: white;
border-radius: 4px;
padding: 8px 16px;
border: none;
font-weight: 600;
}
QPushButton:hover {
background-color: #66B1FF;
}
)");
connect(m_refreshButton, &QPushButton::clicked, this, &FaceManageDialog::refreshPhotos);
toolbarLayout->addWidget(m_refreshButton);
m_statusLabel = new QLabel("就绪");
m_statusLabel->setStyleSheet("color: #909399;");
toolbarLayout->addWidget(m_statusLabel);
toolbarLayout->addStretch();
m_saveButton = new QPushButton("保存映射配置");
m_saveButton->setStyleSheet(R"(
QPushButton {
background-color: #67C23A;
color: white;
border-radius: 4px;
padding: 8px 16px;
border: none;
font-weight: 600;
}
QPushButton:hover {
background-color: #85CE61;
}
)");
connect(m_saveButton, &QPushButton::clicked, this, &FaceManageDialog::saveMapping);
toolbarLayout->addWidget(m_saveButton);
mainLayout->addLayout(toolbarLayout);
// 滚动区域
m_scrollArea = new QScrollArea();
m_scrollArea->setWidgetResizable(true);
m_scrollArea->setStyleSheet(R"(
QScrollArea {
border: 1px solid #EBEEF5;
border-radius: 8px;
background-color: #FAFAFA;
}
)");
m_contentWidget = new QWidget();
m_contentWidget->setStyleSheet("background-color: transparent;");
m_gridLayout = new QGridLayout(m_contentWidget);
m_gridLayout->setSpacing(16);
m_gridLayout->setContentsMargins(16, 16, 16, 16);
m_scrollArea->setWidget(m_contentWidget);
mainLayout->addWidget(m_scrollArea, 1);
// 底部按钮
QHBoxLayout *bottomLayout = new QHBoxLayout();
bottomLayout->addStretch();
QPushButton *closeButton = new QPushButton("关闭");
closeButton->setStyleSheet(R"(
QPushButton {
background-color: white;
color: #606266;
border: 1px solid #DCDFE6;
border-radius: 4px;
padding: 8px 20px;
font-weight: 600;
}
QPushButton:hover {
color: #409EFF;
border-color: #409EFF;
}
)");
connect(closeButton, &QPushButton::clicked, this, &QDialog::accept);
bottomLayout->addWidget(closeButton);
mainLayout->addLayout(bottomLayout);
}
void FaceManageDialog::clearPhotoItems()
{
for (auto item : m_photoItems) {
delete item;
}
m_photoItems.clear();
}
void FaceManageDialog::refreshPhotos()
{
m_statusLabel->setText("正在加载...");
m_refreshButton->setEnabled(false);
fetchMapping(); // 先获取映射配置
fetchPhotoList(); // 获取有照片的列表
fetchAllStudentIds(); // 获取所有学生ID(包括无照片的)
}
void FaceManageDialog::fetchPhotoList()
{
QString url = m_serverUrl + "/api/face_photos";
qDebug() << "[FaceManage] Fetching photo list from:" << url;
QNetworkRequest request(url);
QNetworkReply *reply = m_networkManager->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
onPhotoListReceived(reply);
});
}
void FaceManageDialog::fetchPhoto(const QString &faceId)
{
QString url = m_serverUrl + "/api/face_photo/" + faceId;
qDebug() << "[FaceManage] Fetching photo:" << url;
QNetworkRequest request(url);
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("faceId", faceId);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
onPhotoReceived(reply);
});
}
void FaceManageDialog::fetchMapping()
{
QString url = m_serverUrl + "/api/face_id_mapping";
qDebug() << "[FaceManage] Fetching mapping from:" << url;
QNetworkRequest request(url);
QNetworkReply *reply = m_networkManager->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
onMappingReceived(reply);
});
}
void FaceManageDialog::uploadMapping()
{
QString url = m_serverUrl + "/api/face_id_mapping";
qDebug() << "[FaceManage] Uploading mapping to:" << url;
// 构建JSON
QJsonObject mappingsObj;
for (auto it = m_idMapping.begin(); it != m_idMapping.end(); ++it) {
if (!it.value().isEmpty()) {
mappingsObj[it.key()] = it.value();
}
}
QJsonObject rootObj;
rootObj["mappings"] = mappingsObj;
QJsonDocument doc(rootObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = m_networkManager->post(request, jsonData);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
onMappingSaved(reply);
});
}
void FaceManageDialog::onPhotoListReceived(QNetworkReply *reply)
{
if (reply->error() != QNetworkReply::NoError) {
m_statusLabel->setText("加载失败: " + reply->errorString());
m_refreshButton->setEnabled(true);
reply->deleteLater();
return;
}
QByteArray data = reply->readAll();
reply->deleteLater();
QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) {
m_statusLabel->setText("数据格式错误");
m_refreshButton->setEnabled(true);
return;
}
QJsonArray photosArray = doc.object()["photos"].toArray();
qDebug() << "[FaceManage] Received" << photosArray.size() << "photos";
// 清空现有项
clearPhotoItems();
// 清空网格布局
while (m_gridLayout->count() > 0) {
QLayoutItem *item = m_gridLayout->takeAt(0);
delete item;
}
if (photosArray.isEmpty()) {
QLabel *emptyLabel = new QLabel("暂无人脸照片\n\n请在K210设备上添加人脸后,照片将自动上传到服务器");
emptyLabel->setAlignment(Qt::AlignCenter);
emptyLabel->setStyleSheet("color: #909399; font-size: 14px;");
m_gridLayout->addWidget(emptyLabel, 0, 0);
m_statusLabel->setText("暂无照片");
m_refreshButton->setEnabled(true);
return;
}
m_pendingPhotoCount = photosArray.size();
int col = 0, row = 0;
int maxCols = 4;
for (const QJsonValue &value : photosArray) {
QJsonObject photoObj = value.toObject();
QString faceId = photoObj["id"].toString();
FacePhotoItem *item = new FacePhotoItem(faceId, true, m_contentWidget);
// 设置已有的学号映射
if (m_idMapping.contains(faceId)) {
item->setStudentId(m_idMapping[faceId]);
}
// 连接信号
connect(item, &FacePhotoItem::studentIdChanged, this, [this](const QString &faceId, const QString &studentId) {
m_idMapping[faceId] = studentId;
});
connect(item, &FacePhotoItem::deleteRequested, this, &FaceManageDialog::onDeleteRequested);
m_photoItems[faceId] = item;
m_gridLayout->addWidget(item, row, col);
col++;
if (col >= maxCols) {
col = 0;
row++;
}
// 获取照片
fetchPhoto(faceId);
}
m_statusLabel->setText(QString("正在加载 %1 张照片...").arg(photosArray.size()));
}
void FaceManageDialog::onPhotoReceived(QNetworkReply *reply)
{
QString faceId = reply->property("faceId").toString();
m_pendingPhotoCount--;
if (reply->error() == QNetworkReply::NoError) {
QByteArray data = reply->readAll();
QPixmap pixmap;
if (pixmap.loadFromData(data)) {
if (m_photoItems.contains(faceId)) {
m_photoItems[faceId]->setPhoto(pixmap);
}
}
} else {
qWarning() << "[FaceManage] Failed to load photo for" << faceId << ":" << reply->errorString();
}
reply->deleteLater();
if (m_pendingPhotoCount <= 0) {
m_statusLabel->setText(QString("已加载 %1 张照片").arg(m_photoItems.size()));
m_refreshButton->setEnabled(true);
}
}
void FaceManageDialog::onMappingReceived(QNetworkReply *reply)
{
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[FaceManage] Failed to fetch mapping:" << reply->errorString();
reply->deleteLater();
return;
}
QByteArray data = reply->readAll();
reply->deleteLater();
QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) {
return;
}
QJsonObject mappingsObj = doc.object()["mappings"].toObject();
m_idMapping.clear();
for (auto it = mappingsObj.begin(); it != mappingsObj.end(); ++it) {
m_idMapping[it.key()] = it.value().toString();
}
qDebug() << "[FaceManage] Loaded" << m_idMapping.size() << "mappings";
// 更新已显示的项
for (auto it = m_photoItems.begin(); it != m_photoItems.end(); ++it) {
if (m_idMapping.contains(it.key())) {
it.value()->setStudentId(m_idMapping[it.key()]);
}
}
}
void FaceManageDialog::saveMapping()
{
// 从所有项收集映射
for (auto it = m_photoItems.begin(); it != m_photoItems.end(); ++it) {
QString studentId = it.value()->getStudentId();
if (!studentId.isEmpty()) {
m_idMapping[it.key()] = studentId;
}
}
m_statusLabel->setText("正在保存...");
m_saveButton->setEnabled(false);
uploadMapping();
}
void FaceManageDialog::onMappingSaved(QNetworkReply *reply)
{
m_saveButton->setEnabled(true);
if (reply->error() != QNetworkReply::NoError) {
m_statusLabel->setText("保存失败: " + reply->errorString());
QMessageBox::warning(this, "保存失败", "映射配置保存失败:" + reply->errorString());
reply->deleteLater();
return;
}
QByteArray data = reply->readAll();
reply->deleteLater();
QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isObject() && doc.object()["success"].toBool()) {
m_statusLabel->setText("映射配置已保存");
QMessageBox::information(this, "保存成功", "人脸ID映射配置已保存到服务器!\n\n签到时将自动使用真实学号。");
emit mappingSaved();
} else {
m_statusLabel->setText("保存失败");
QMessageBox::warning(this, "保存失败", "服务器返回错误");
}
}
void FaceManageDialog::fetchAllStudentIds()
{
QString url = m_serverUrl + "/api/all_student_ids";
qDebug() << "[FaceManage] Fetching all student IDs from:" << url;
QNetworkRequest request(url);
QNetworkReply *reply = m_networkManager->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
onAllStudentIdsReceived(reply);
});
}
void FaceManageDialog::onAllStudentIdsReceived(QNetworkReply *reply)
{
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[FaceManage] Failed to fetch student IDs:" << reply->errorString();
reply->deleteLater();
return;
}
QByteArray data = reply->readAll();
reply->deleteLater();
QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) {
return;
}
QJsonArray idsArray = doc.object()["student_ids"].toArray();
qDebug() << "[FaceManage] Received" << idsArray.size() << "student IDs from records";
// 找出没有照片的学生ID
int col = m_photoItems.size() % 4;
int row = m_photoItems.size() / 4;
int maxCols = 4;
int addedCount = 0;
for (const QJsonValue &value : idsArray) {
QString studentId = value.toString();
// 跳过已经有照片项的
if (m_photoItems.contains(studentId)) {
continue;
}
// 创建无照片的项
FacePhotoItem *item = new FacePhotoItem(studentId, false, m_contentWidget);
item->setStudentId(studentId);
// 连接信号
connect(item, &FacePhotoItem::studentIdChanged, this, [this](const QString &faceId, const QString &newStudentId) {
m_idMapping[faceId] = newStudentId;
});
connect(item, &FacePhotoItem::deleteRequested, this, &FaceManageDialog::onDeleteRequested);
m_photoItems[studentId] = item;
m_gridLayout->addWidget(item, row, col);
col++;
if (col >= maxCols) {
col = 0;
row++;
}
addedCount++;
}
if (addedCount > 0) {
qDebug() << "[FaceManage] Added" << addedCount << "items without photos";
}
m_statusLabel->setText(QString("共 %1 条人脸信息").arg(m_photoItems.size()));
m_refreshButton->setEnabled(true);
}
void FaceManageDialog::onDeleteRequested(const QString &studentId)
{
QMessageBox msgBox(this);
msgBox.setWindowTitle("确认删除");
msgBox.setText(QString("确定要删除 ID: %1 的人脸信息吗?").arg(studentId));
msgBox.setInformativeText("请选择是否同时删除该学生的所有考勤记录:");
QPushButton *deleteAllBtn = msgBox.addButton("删除人脸和考勤记录", QMessageBox::DestructiveRole);
QPushButton *deleteFaceOnlyBtn = msgBox.addButton("仅删除人脸信息", QMessageBox::AcceptRole);
QPushButton *cancelBtn = msgBox.addButton("取消", QMessageBox::RejectRole);
msgBox.setDefaultButton(cancelBtn);
msgBox.exec();
if (msgBox.clickedButton() == deleteAllBtn) {
deleteFace(studentId, true);
} else if (msgBox.clickedButton() == deleteFaceOnlyBtn) {
deleteFace(studentId, false);
}
}
void FaceManageDialog::deleteFace(const QString &studentId, bool deleteRecords)
{
QString url = m_serverUrl + "/api/face/" + studentId;
if (deleteRecords) {
url += "?delete_records=true";
}
qDebug() << "[FaceManage] Deleting face:" << url;
m_statusLabel->setText("正在删除...");
QNetworkRequest request(url);
QNetworkReply *reply = m_networkManager->deleteResource(request);
reply->setProperty("studentId", studentId);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
onFaceDeleted(reply);
});
}
void FaceManageDialog::onFaceDeleted(QNetworkReply *reply)
{
QString studentId = reply->property("studentId").toString();
if (reply->error() != QNetworkReply::NoError) {
m_statusLabel->setText("删除失败: " + reply->errorString());
QMessageBox::warning(this, "删除失败", "删除人脸信息失败:" + reply->errorString());
reply->deleteLater();
return;
}
QByteArray data = reply->readAll();
reply->deleteLater();
QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isObject() && doc.object()["success"].toBool()) {
int recordsDeleted = doc.object()["records_deleted"].toInt();
// 从界面中移除该项
if (m_photoItems.contains(studentId)) {
FacePhotoItem *item = m_photoItems.take(studentId);
m_gridLayout->removeWidget(item);
delete item;
}
// 从映射中移除
m_idMapping.remove(studentId);
QString message = QString("已删除 ID: %1 的人脸信息").arg(studentId);
if (recordsDeleted > 0) {
message += QString("\n同时删除了 %1 条考勤记录").arg(recordsDeleted);
}
m_statusLabel->setText(QString("共 %1 条人脸信息").arg(m_photoItems.size()));
QMessageBox::information(this, "删除成功", message);
} else {
m_statusLabel->setText("删除失败");
QMessageBox::warning(this, "删除失败", "服务器返回错误");
}
}
+1589
View File
File diff suppressed because it is too large Load Diff
+472
View File
@@ -0,0 +1,472 @@
#include "MappingConfigDialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMessageBox>
#include <QLabel>
#include <QGroupBox>
MappingConfigDialog::MappingConfigDialog(QWidget *parent)
: QDialog(parent)
{
setupUI();
setWindowTitle("名称映射配置");
resize(700, 550);
}
MappingConfigDialog::~MappingConfigDialog()
{
}
void MappingConfigDialog::setupUI()
{
QVBoxLayout *mainLayout = new QVBoxLayout(this);
mainLayout->setSpacing(16);
mainLayout->setContentsMargins(20, 20, 20, 20);
// 创建选项卡
m_tabWidget = new QTabWidget();
m_tabWidget->setStyleSheet(R"(
QTabWidget::pane {
border: 1px solid #DCDFE6;
border-radius: 4px;
background: white;
}
QTabBar::tab {
padding: 8px 20px;
margin-right: 4px;
border: 1px solid #DCDFE6;
border-bottom: none;
border-radius: 4px 4px 0 0;
background: #F5F7FA;
}
QTabBar::tab:selected {
background: white;
border-bottom: 1px solid white;
}
)");
// ============= 学号-姓名映射选项卡 =============
QWidget *studentTab = new QWidget();
QVBoxLayout *studentLayout = new QVBoxLayout(studentTab);
studentLayout->setSpacing(12);
// 说明标签
QLabel *studentDesc = new QLabel(
"💡 提示:双击单元格可直接编辑内容。修改完成后点击「保存到服务器」同步数据。");
studentDesc->setWordWrap(true);
studentDesc->setStyleSheet("color: #909399; padding: 8px; background: #F5F7FA; border-radius: 4px;");
studentLayout->addWidget(studentDesc);
// 学号表格 - 启用编辑
m_studentTable = new QTableWidget(0, 2);
m_studentTable->setHorizontalHeaderLabels(QStringList() << "学号" << "真实姓名");
m_studentTable->horizontalHeader()->setStretchLastSection(true);
m_studentTable->setColumnWidth(0, 150);
m_studentTable->setSelectionBehavior(QAbstractItemView::SelectRows);
m_studentTable->setSelectionMode(QAbstractItemView::SingleSelection);
m_studentTable->setAlternatingRowColors(true);
// 允许双击编辑
m_studentTable->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
m_studentTable->setStyleSheet(R"(
QTableWidget {
border: 1px solid #EBEEF5;
border-radius: 4px;
gridline-color: #EBEEF5;
}
QTableWidget::item {
padding: 8px;
}
QTableWidget::item:selected {
background-color: #ECF5FF;
color: #409EFF;
}
QHeaderView::section {
background-color: #F5F7FA;
padding: 10px;
border: none;
border-bottom: 1px solid #EBEEF5;
font-weight: bold;
}
)");
studentLayout->addWidget(m_studentTable);
// 按钮区
QHBoxLayout *studentBtnLayout = new QHBoxLayout();
m_addStudentBtn = new QPushButton("➕ 添加新行");
m_addStudentBtn->setStyleSheet(R"(
QPushButton {
background-color: #67C23A;
color: white;
padding: 8px 20px;
border-radius: 4px;
border: none;
font-weight: bold;
}
QPushButton:hover { background-color: #85CE61; }
)");
connect(m_addStudentBtn, &QPushButton::clicked, this, &MappingConfigDialog::onAddStudent);
studentBtnLayout->addWidget(m_addStudentBtn);
m_deleteStudentBtn = new QPushButton("🗑️ 删除选中");
m_deleteStudentBtn->setStyleSheet(R"(
QPushButton {
background-color: #F56C6C;
color: white;
padding: 8px 20px;
border-radius: 4px;
border: none;
font-weight: bold;
}
QPushButton:hover { background-color: #F78989; }
)");
connect(m_deleteStudentBtn, &QPushButton::clicked, this, &MappingConfigDialog::onDeleteStudent);
studentBtnLayout->addWidget(m_deleteStudentBtn);
studentBtnLayout->addStretch();
studentLayout->addLayout(studentBtnLayout);
m_tabWidget->addTab(studentTab, "📋 学号-姓名映射");
// ============= 班级编号-名称映射选项卡 =============
QWidget *classTab = new QWidget();
QVBoxLayout *classLayout = new QVBoxLayout(classTab);
classLayout->setSpacing(12);
// 说明标签
QLabel *classDesc = new QLabel(
"💡 提示:双击单元格可直接编辑内容。班级编号对应STM32设备上定义的CLASS_ID。");
classDesc->setWordWrap(true);
classDesc->setStyleSheet("color: #909399; padding: 8px; background: #F5F7FA; border-radius: 4px;");
classLayout->addWidget(classDesc);
// 班级表格 - 启用编辑
m_classTable = new QTableWidget(0, 2);
m_classTable->setHorizontalHeaderLabels(QStringList() << "班级编号" << "班级名称");
m_classTable->horizontalHeader()->setStretchLastSection(true);
m_classTable->setColumnWidth(0, 150);
m_classTable->setSelectionBehavior(QAbstractItemView::SelectRows);
m_classTable->setSelectionMode(QAbstractItemView::SingleSelection);
m_classTable->setAlternatingRowColors(true);
// 允许双击编辑
m_classTable->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
m_classTable->setStyleSheet(R"(
QTableWidget {
border: 1px solid #EBEEF5;
border-radius: 4px;
gridline-color: #EBEEF5;
}
QTableWidget::item {
padding: 8px;
}
QTableWidget::item:selected {
background-color: #ECF5FF;
color: #409EFF;
}
QHeaderView::section {
background-color: #F5F7FA;
padding: 10px;
border: none;
border-bottom: 1px solid #EBEEF5;
font-weight: bold;
}
)");
classLayout->addWidget(m_classTable);
// 按钮区
QHBoxLayout *classBtnLayout = new QHBoxLayout();
m_addClassBtn = new QPushButton("➕ 添加新行");
m_addClassBtn->setStyleSheet(R"(
QPushButton {
background-color: #67C23A;
color: white;
padding: 8px 20px;
border-radius: 4px;
border: none;
font-weight: bold;
}
QPushButton:hover { background-color: #85CE61; }
)");
connect(m_addClassBtn, &QPushButton::clicked, this, &MappingConfigDialog::onAddClass);
classBtnLayout->addWidget(m_addClassBtn);
m_deleteClassBtn = new QPushButton("🗑️ 删除选中");
m_deleteClassBtn->setStyleSheet(R"(
QPushButton {
background-color: #F56C6C;
color: white;
padding: 8px 20px;
border-radius: 4px;
border: none;
font-weight: bold;
}
QPushButton:hover { background-color: #F78989; }
)");
connect(m_deleteClassBtn, &QPushButton::clicked, this, &MappingConfigDialog::onDeleteClass);
classBtnLayout->addWidget(m_deleteClassBtn);
classBtnLayout->addStretch();
classLayout->addLayout(classBtnLayout);
m_tabWidget->addTab(classTab, "🏫 班级编号-名称映射");
mainLayout->addWidget(m_tabWidget);
// ============= 状态标签 =============
m_statusLabel = new QLabel("准备就绪");
m_statusLabel->setStyleSheet("color: #909399; padding: 4px;");
mainLayout->addWidget(m_statusLabel);
// ============= 底部按钮区 =============
QHBoxLayout *buttonLayout = new QHBoxLayout();
m_syncFromServerBtn = new QPushButton("🔄 从服务器加载");
m_syncFromServerBtn->setStyleSheet(R"(
QPushButton {
background-color: #409EFF;
color: white;
padding: 10px 24px;
border-radius: 4px;
border: none;
font-weight: bold;
}
QPushButton:hover { background-color: #66B1FF; }
)");
connect(m_syncFromServerBtn, &QPushButton::clicked, this, &MappingConfigDialog::onSyncFromServer);
buttonLayout->addWidget(m_syncFromServerBtn);
buttonLayout->addStretch();
m_saveBtn = new QPushButton("💾 保存到服务器");
m_saveBtn->setStyleSheet(R"(
QPushButton {
background-color: #67C23A;
color: white;
padding: 10px 24px;
border-radius: 4px;
border: none;
font-weight: bold;
}
QPushButton:hover { background-color: #85CE61; }
)");
connect(m_saveBtn, &QPushButton::clicked, this, &MappingConfigDialog::onSave);
buttonLayout->addWidget(m_saveBtn);
m_cancelBtn = new QPushButton("取消");
m_cancelBtn->setStyleSheet(R"(
QPushButton {
background-color: #909399;
color: white;
padding: 10px 24px;
border-radius: 4px;
border: none;
}
QPushButton:hover { background-color: #A6A9AD; }
)");
connect(m_cancelBtn, &QPushButton::clicked, this, &QDialog::reject);
buttonLayout->addWidget(m_cancelBtn);
mainLayout->addLayout(buttonLayout);
}
void MappingConfigDialog::setStudentNameMap(const QMap<QString, QString> &map)
{
m_studentNameMap = map;
updateStudentTable();
}
void MappingConfigDialog::setClassNameMap(const QMap<QString, QString> &map)
{
m_classNameMap = map;
updateClassTable();
}
void MappingConfigDialog::updateStudentTable()
{
m_studentTable->setRowCount(0);
QList<QString> keys = m_studentNameMap.keys();
// 按学号排序
std::sort(keys.begin(), keys.end(), [](const QString &a, const QString &b) {
return a.toInt() < b.toInt();
});
for (const QString &key : keys) {
int row = m_studentTable->rowCount();
m_studentTable->insertRow(row);
QTableWidgetItem *idItem = new QTableWidgetItem(key);
QTableWidgetItem *nameItem = new QTableWidgetItem(m_studentNameMap[key]);
m_studentTable->setItem(row, 0, idItem);
m_studentTable->setItem(row, 1, nameItem);
}
m_statusLabel->setText(QString("已加载 %1 条学号映射").arg(m_studentNameMap.size()));
}
void MappingConfigDialog::updateClassTable()
{
m_classTable->setRowCount(0);
QList<QString> keys = m_classNameMap.keys();
// 按编号排序
std::sort(keys.begin(), keys.end(), [](const QString &a, const QString &b) {
return a.toInt() < b.toInt();
});
for (const QString &key : keys) {
int row = m_classTable->rowCount();
m_classTable->insertRow(row);
QTableWidgetItem *idItem = new QTableWidgetItem(key);
QTableWidgetItem *nameItem = new QTableWidgetItem(m_classNameMap[key]);
m_classTable->setItem(row, 0, idItem);
m_classTable->setItem(row, 1, nameItem);
}
}
void MappingConfigDialog::collectDataFromTables()
{
// 从学号表格收集数据
m_studentNameMap.clear();
for (int row = 0; row < m_studentTable->rowCount(); ++row) {
QTableWidgetItem *idItem = m_studentTable->item(row, 0);
QTableWidgetItem *nameItem = m_studentTable->item(row, 1);
if (idItem && nameItem) {
QString id = idItem->text().trimmed();
QString name = nameItem->text().trimmed();
if (!id.isEmpty() && !name.isEmpty()) {
m_studentNameMap[id] = name;
}
}
}
// 从班级表格收集数据
m_classNameMap.clear();
for (int row = 0; row < m_classTable->rowCount(); ++row) {
QTableWidgetItem *idItem = m_classTable->item(row, 0);
QTableWidgetItem *nameItem = m_classTable->item(row, 1);
if (idItem && nameItem) {
QString id = idItem->text().trimmed();
QString name = nameItem->text().trimmed();
if (!id.isEmpty() && !name.isEmpty()) {
m_classNameMap[id] = name;
}
}
}
}
void MappingConfigDialog::onAddStudent()
{
int row = m_studentTable->rowCount();
m_studentTable->insertRow(row);
// 创建可编辑的空项
QTableWidgetItem *idItem = new QTableWidgetItem("");
QTableWidgetItem *nameItem = new QTableWidgetItem("");
m_studentTable->setItem(row, 0, idItem);
m_studentTable->setItem(row, 1, nameItem);
// 自动选中新行并开始编辑
m_studentTable->selectRow(row);
m_studentTable->editItem(idItem);
m_statusLabel->setText("已添加新行,请输入学号和姓名");
}
void MappingConfigDialog::onDeleteStudent()
{
int row = m_studentTable->currentRow();
if (row < 0) {
QMessageBox::warning(this, "提示", "请先选择要删除的行!");
return;
}
QTableWidgetItem *idItem = m_studentTable->item(row, 0);
QString id = idItem ? idItem->text() : "";
m_studentTable->removeRow(row);
m_statusLabel->setText(QString("已删除学号 %1 的映射").arg(id));
}
void MappingConfigDialog::onAddClass()
{
int row = m_classTable->rowCount();
m_classTable->insertRow(row);
// 创建可编辑的空项
QTableWidgetItem *idItem = new QTableWidgetItem("");
QTableWidgetItem *nameItem = new QTableWidgetItem("");
m_classTable->setItem(row, 0, idItem);
m_classTable->setItem(row, 1, nameItem);
// 自动选中新行并开始编辑
m_classTable->selectRow(row);
m_classTable->editItem(idItem);
m_statusLabel->setText("已添加新行,请输入班级编号和名称");
}
void MappingConfigDialog::onDeleteClass()
{
int row = m_classTable->currentRow();
if (row < 0) {
QMessageBox::warning(this, "提示", "请先选择要删除的行!");
return;
}
QTableWidgetItem *idItem = m_classTable->item(row, 0);
QString id = idItem ? idItem->text() : "";
m_classTable->removeRow(row);
m_statusLabel->setText(QString("已删除班级编号 %1 的映射").arg(id));
}
void MappingConfigDialog::onSave()
{
// 先从表格收集数据
collectDataFromTables();
if (m_studentNameMap.isEmpty() && m_classNameMap.isEmpty()) {
QMessageBox::StandardButton reply = QMessageBox::question(
this, "确认",
"映射表为空,确定要保存吗?",
QMessageBox::Yes | QMessageBox::No
);
if (reply != QMessageBox::Yes) {
return;
}
}
m_statusLabel->setText("正在保存到服务器...");
emit mappingChanged();
accept();
}
void MappingConfigDialog::onSyncToServer()
{
collectDataFromTables();
m_statusLabel->setText("正在同步到服务器...");
emit mappingChanged();
}
void MappingConfigDialog::onSyncFromServer()
{
m_statusLabel->setText("正在从服务器加载...");
emit requestSyncFromServer();
}
void MappingConfigDialog::setStatus(const QString &status)
{
m_statusLabel->setText(status);
}
+674
View File
@@ -0,0 +1,674 @@
#include "NetworkApiManager.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QUrlQuery>
#include <QSettings>
#include <QDebug>
#include <QCryptographicHash>
#include <QMessageAuthenticationCode>
// AES加密所需(使用QCA或OpenSSL)
// 注:Qt没有内置AES,需要使用QtCrypto(QCA)或直接调用OpenSSL
// 这里提供基于Qt内置功能的简化实现
NetworkApiManager::NetworkApiManager(QObject *parent)
: QObject(parent)
, m_networkManager(new QNetworkAccessManager(this))
, m_encryptionEnabled(true) // ✅ 启用加密通信
{
connect(m_networkManager, &QNetworkAccessManager::finished,
this, &NetworkApiManager::onReplyFinished);
// 统一服务器地址
m_serverUrl = "http://27.106.113.205:8082";
qDebug() << "[CRYPTO] 加密通信已启用 (AES-128-CBC)";
}
NetworkApiManager::~NetworkApiManager()
{
}
void NetworkApiManager::setServerUrl(const QString &url)
{
m_serverUrl = url;
// 保存到配置
QSettings settings;
settings.setValue("server/url", url);
settings.sync();
}
QString NetworkApiManager::buildApiUrl(const QString &endpoint, const QMap<QString, QString> &params)
{
QString url = m_serverUrl + "/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();
}
return url;
}
void NetworkApiManager::fetchRecords(int limit, int offset)
{
QMap<QString, QString> params;
params["limit"] = QString::number(limit);
params["offset"] = QString::number(offset);
QString url = buildApiUrl("/records", params);
qDebug() << "Fetching records from:" << url;
QNetworkRequest request(url);
// 如果启用加密,添加请求头
if (m_encryptionEnabled) {
request.setRawHeader("X-Encrypted-Response", "true");
qDebug() << "[CRYPTO] 请求加密响应 (X-Encrypted-Response: true)";
}
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("requestType", "fetchRecords");
}
void NetworkApiManager::fetchStatistics()
{
QString url = buildApiUrl("/statistics");
qDebug() << "Fetching statistics from:" << url;
QNetworkRequest request(url);
// 如果启用加密,添加请求头
if (m_encryptionEnabled) {
request.setRawHeader("X-Encrypted-Response", "true");
qDebug() << "[CRYPTO] 请求加密响应 (X-Encrypted-Response: true)";
}
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("requestType", "fetchStatistics");
}
void NetworkApiManager::clearAllRecords()
{
QString url = buildApiUrl("/clear");
qDebug() << "Clearing records at:" << url;
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = m_networkManager->post(request, QByteArray());
reply->setProperty("requestType", "clearRecords");
}
void NetworkApiManager::deleteRecord(const QString &timestamp, const QString &studentId)
{
QMap<QString, QString> params;
params["timestamp"] = timestamp;
params["student_id"] = studentId;
QString url = buildApiUrl("/record", params);
qDebug() << "Deleting record:" << url;
QNetworkRequest request(url);
// 使用自定义DELETE方法
QNetworkReply *reply = m_networkManager->deleteResource(request);
reply->setProperty("requestType", "deleteRecord");
}
void NetworkApiManager::uploadTimeSlots(const QVector<TimeSlot> &timeSlots)
{
QString url = buildApiUrl("/timeslots");
qDebug() << "Uploading time slots to:" << url;
// 构建JSON数据
QJsonArray timeSlotsArray;
for (const TimeSlot &slot : timeSlots) {
QJsonObject slotObj;
slotObj["name"] = slot.name;
slotObj["start_time"] = slot.startTime.toString("HH:mm");
slotObj["end_time"] = slot.endTime.toString("HH:mm");
slotObj["enabled"] = slot.enabled;
timeSlotsArray.append(slotObj);
}
QJsonObject jsonObj;
jsonObj["time_slots"] = timeSlotsArray;
QJsonDocument doc(jsonObj);
QByteArray jsonData = doc.toJson();
qDebug() << "Time slots JSON:" << QString::fromUtf8(jsonData);
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = m_networkManager->post(request, jsonData);
reply->setProperty("requestType", "uploadTimeSlots");
}
void NetworkApiManager::checkHealth()
{
QString url = buildApiUrl("/health");
QNetworkRequest request(url);
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("requestType", "checkHealth");
}
void NetworkApiManager::onReplyFinished(QNetworkReply *reply)
{
QString requestType = reply->property("requestType").toString();
// 检查网络错误
if (reply->error() != QNetworkReply::NoError) {
QString errorMsg = QString("网络错误: %1").arg(reply->errorString());
qWarning() << errorMsg;
emit errorOccurred(errorMsg);
reply->deleteLater();
return;
}
// 读取响应数据
QByteArray responseData = reply->readAll();
// 如果启用了加密,先解密响应数据
if (m_encryptionEnabled && (requestType == "fetchRecords" || requestType == "fetchStatistics")) {
qDebug() << "========== 加密响应,开始解密 ==========";
qDebug() << "加密数据长度:" << responseData.size();
QByteArray decryptedData = aesDecrypt(responseData);
if (decryptedData.isEmpty()) {
qWarning() << "解密失败!";
emit errorOccurred("服务器响应解密失败");
reply->deleteLater();
return;
}
qDebug() << "✓ 解密成功,数据长度:" << decryptedData.size();
responseData = decryptedData;
}
// 调试:打印原始响应数据
qDebug() << "========== 服务器响应 ==========";
qDebug() << "请求类型:" << requestType;
qDebug() << "响应数据:" << QString::fromUtf8(responseData);
qDebug() << "数据长度:" << responseData.size();
qDebug() << "================================";
// 根据请求类型处理响应(其余代码保持不变)
if (requestType == "fetchRecords") {
QVector<AttendanceRecord> records = parseRecordsJson(responseData);
qDebug() << "解析到" << records.size() << "条记录";
emit recordsFetched(records);
} else if (requestType == "fetchStatistics") {
QJsonDocument doc = QJsonDocument::fromJson(responseData);
if (doc.isNull() || !doc.isObject()) {
qWarning() << "统计数据JSON解析失败!";
emit errorOccurred("统计数据格式错误");
reply->deleteLater();
return;
}
QJsonObject obj = doc.object();
int total = obj["total"].toInt();
int today = obj["today"].toInt();
QString serverTime = obj["server_time"].toString();
qDebug() << "统计信息: total=" << total << "today=" << today;
emit statisticsFetched(total, today, serverTime);
} else if (requestType == "clearRecords") {
QJsonDocument doc = QJsonDocument::fromJson(responseData);
if (doc.isNull() || !doc.isObject()) {
qWarning() << "清空记录响应JSON解析失败!";
emit errorOccurred("服务器响应格式错误");
reply->deleteLater();
return;
}
QJsonObject obj = doc.object();
bool success = obj["success"].toBool();
QString message = obj["message"].toString();
qDebug() << "清空记录结果: success=" << success << "message=" << message;
emit recordsCleared(success);
} else if (requestType == "deleteRecord") {
QJsonDocument doc = QJsonDocument::fromJson(responseData);
if (doc.isNull() || !doc.isObject()) {
qWarning() << "删除记录响应JSON解析失败!";
emit errorOccurred("服务器响应格式错误");
reply->deleteLater();
return;
}
QJsonObject obj = doc.object();
bool success = obj["success"].toBool();
QString message = obj["message"].toString();
qDebug() << "删除记录结果: success=" << success << "message=" << message;
emit recordDeleted(success);
} else if (requestType == "uploadTimeSlots") {
QJsonDocument doc = QJsonDocument::fromJson(responseData);
if (doc.isNull() || !doc.isObject()) {
qWarning() << "上传时间段响应JSON解析失败!";
emit errorOccurred("服务器响应格式错误");
emit timeSlotsUploaded(false);
reply->deleteLater();
return;
}
QJsonObject obj = doc.object();
bool success = obj["success"].toBool();
QString message = obj["message"].toString();
qDebug() << "上传时间段结果: success=" << success << "message=" << message;
emit timeSlotsUploaded(success);
} else if (requestType == "checkHealth") {
QJsonDocument doc = QJsonDocument::fromJson(responseData);
if (doc.isNull() || !doc.isObject()) {
qWarning() << "健康检查响应JSON解析失败!";
emit healthChecked(false);
reply->deleteLater();
return;
}
QJsonObject obj = doc.object();
bool healthy = (obj["status"].toString() == "healthy");
qDebug() << "健康检查: healthy=" << healthy;
emit healthChecked(healthy);
} else if (requestType == "uploadMappingConfig") {
QJsonDocument doc = QJsonDocument::fromJson(responseData);
if (doc.isNull() || !doc.isObject()) {
qWarning() << "上传映射配置响应JSON解析失败!";
emit mappingConfigUploaded(false);
reply->deleteLater();
return;
}
QJsonObject obj = doc.object();
bool success = obj["success"].toBool();
QString message = obj["message"].toString();
qDebug() << "上传映射配置结果: success=" << success << "message=" << message;
emit mappingConfigUploaded(success);
} else if (requestType == "fetchMappingConfig") {
QJsonDocument doc = QJsonDocument::fromJson(responseData);
if (doc.isNull() || !doc.isObject()) {
qWarning() << "获取映射配置响应JSON解析失败!";
emit mappingConfigFetched(QMap<QString, QString>(), QMap<QString, QString>());
reply->deleteLater();
return;
}
QJsonObject obj = doc.object();
QMap<QString, QString> studentNameMap;
QMap<QString, QString> classNameMap;
// 解析学号-姓名映射
QJsonObject studentMapObj = obj["student_name_map"].toObject();
for (auto it = studentMapObj.begin(); it != studentMapObj.end(); ++it) {
studentNameMap[it.key()] = it.value().toString();
}
// 解析班级编号-名称映射
QJsonObject classMapObj = obj["class_name_map"].toObject();
for (auto it = classMapObj.begin(); it != classMapObj.end(); ++it) {
classNameMap[it.key()] = it.value().toString();
}
qDebug() << "获取映射配置: 学生映射=" << studentNameMap.size() << "条, 班级映射=" << classNameMap.size() << "条";
emit mappingConfigFetched(studentNameMap, classNameMap);
} else if (requestType == "fetchFacePhotos") {
QJsonDocument doc = QJsonDocument::fromJson(responseData);
if (doc.isNull() || !doc.isObject()) {
qWarning() << "获取人脸照片列表响应JSON解析失败!";
emit facePhotosFetched(QJsonArray());
reply->deleteLater();
return;
}
QJsonArray photos = doc.object()["photos"].toArray();
qDebug() << "获取人脸照片列表:" << photos.size() << "张";
emit facePhotosFetched(photos);
} else if (requestType == "fetchFacePhoto") {
QString faceId = reply->property("faceId").toString();
qDebug() << "获取人脸照片:" << faceId << ", 大小:" << responseData.size() << "bytes";
emit facePhotoFetched(faceId, responseData);
} else if (requestType == "uploadFaceIdMapping") {
QJsonDocument doc = QJsonDocument::fromJson(responseData);
bool success = false;
if (doc.isObject()) {
success = doc.object()["success"].toBool();
}
qDebug() << "上传人脸ID映射结果:" << success;
emit faceIdMappingUploaded(success);
} else if (requestType == "fetchFaceIdMapping") {
QJsonDocument doc = QJsonDocument::fromJson(responseData);
QMap<QString, QString> mapping;
if (doc.isObject()) {
QJsonObject mappingsObj = doc.object()["mappings"].toObject();
for (auto it = mappingsObj.begin(); it != mappingsObj.end(); ++it) {
mapping[it.key()] = it.value().toString();
}
}
qDebug() << "获取人脸ID映射:" << mapping.size() << "条";
emit faceIdMappingFetched(mapping);
}
reply->deleteLater();
}
QVector<AttendanceRecord> NetworkApiManager::parseRecordsJson(const QByteArray &json)
{
QVector<AttendanceRecord> records;
qDebug() << "开始解析JSON,数据长度:" << json.size();
// 检查JSON是否以[开头
if (json.isEmpty() || json.at(0) != '[') {
qWarning() << "JSON不是以[开头!";
qWarning() << "前100字符:" << QString::fromUtf8(json).left(100);
return records;
}
// 检查JSON是否有非法字符
for (int i = 0; i < qMin(json.size(), 1000); i++) {
unsigned char c = json.at(i);
if (c < 32 && c != '\n' && c != '\t' && c != '\r') {
qWarning() << "发现非法控制字符在位置" << i << ":" << (int)c;
}
}
QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(json, &parseError);
if (parseError.error != QJsonParseError::NoError) {
qWarning() << "JSON解析错误:" << parseError.errorString();
qWarning() << "错误位置:" << parseError.offset;
qWarning() << "错误附近内容:" << QString::fromUtf8(json).mid(parseError.offset - 50, 100);
return records;
}
if (!doc.isArray()) {
qWarning() << "无效的JSON格式 - 期望数组";
qWarning() << "实际收到:" << QString::fromUtf8(json).left(200);
return records;
}
QJsonArray array = doc.array();
qDebug() << "JSON数组包含" << array.size() << "个元素";
for (const QJsonValue &value : array) {
if (!value.isObject()) {
qWarning() << "跳过非对象元素";
continue;
}
QJsonObject obj = value.toObject();
// 读取字段并清理空白字符(包括\r\n)
QString timestamp = obj["timestamp"].toString().trimmed();
QString studentId = obj["student_id"].toString().trimmed();
QString name = obj["name"].toString().trimmed();
float confidence = obj["confidence"].toDouble();
QString status = obj["status"].toString().trimmed(); // 清理\r\n等空白字符
QString classId = obj["class_id"].toString().trimmed(); // 班级编号
if (classId.isEmpty()) classId = "0"; // 兼容旧数据
// 验证数据完整性
if (timestamp.isEmpty() || studentId.isEmpty()) {
qWarning() << "跳过不完整的记录: timestamp=" << timestamp << "studentId=" << studentId;
continue;
}
// 跳过错误的时间戳(以"0000-"开头的)
if (timestamp.startsWith("0000-")) {
qDebug() << "跳过错误的时间戳:" << timestamp << "学号:" << studentId;
continue;
}
AttendanceRecord record(timestamp, studentId, name, confidence, status, classId);
records.append(record);
}
qDebug() << "成功解析" << records.size() << "条有效记录";
return records;
}
// ============= AES-128-CBC解密实现 =============
// 注:需要链接OpenSSL库
#ifdef Q_OS_WIN
#include <openssl/evp.h>
#include <openssl/aes.h>
#else
#include <openssl/evp.h>
#include <openssl/aes.h>
#endif
QByteArray NetworkApiManager::removePKCS7Padding(const QByteArray &data)
{
if (data.isEmpty()) {
return QByteArray();
}
// 获取最后一个字节作为填充长度
unsigned char paddingLen = (unsigned char)data.at(data.size() - 1);
// 验证填充长度
if (paddingLen < 1 || paddingLen > 16 || paddingLen > data.size()) {
qWarning() << "[CRYPTO] Invalid padding length:" << paddingLen;
return data;
}
// 验证所有填充字节都相同
for (int i = data.size() - paddingLen; i < data.size(); i++) {
if ((unsigned char)data.at(i) != paddingLen) {
qWarning() << "[CRYPTO] Invalid padding at position" << i;
return data;
}
}
// 移除填充
return data.left(data.size() - paddingLen);
}
QByteArray NetworkApiManager::aesDecrypt(const QByteArray &encryptedBase64)
{
// 与STM32和服务器相同的密钥和IV
static const unsigned char AES_KEY[16] = {
0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c
};
static const unsigned char AES_IV[16] = {
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f
};
// 1. Base64解码
QByteArray encryptedData = QByteArray::fromBase64(encryptedBase64);
if (encryptedData.isEmpty() || encryptedData.size() % 16 != 0) {
qWarning() << "[CRYPTO] Invalid encrypted data length:" << encryptedData.size();
return QByteArray();
}
qDebug() << "[CRYPTO] Encrypted data length:" << encryptedData.size();
// 2. 使用OpenSSL进行AES-128-CBC解密
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
if (!ctx) {
qWarning() << "[CRYPTO] Failed to create context";
return QByteArray();
}
// 初始化解密
if (EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, AES_KEY, AES_IV) != 1) {
qWarning() << "[CRYPTO] DecryptInit failed";
EVP_CIPHER_CTX_free(ctx);
return QByteArray();
}
// 禁用自动填充验证(手动处理PKCS#7)
EVP_CIPHER_CTX_set_padding(ctx, 0);
// 准备输出缓冲区
QByteArray plaintext;
plaintext.resize(encryptedData.size() + 16);
int len = 0;
int plaintextLen = 0;
// 解密
if (EVP_DecryptUpdate(ctx, (unsigned char*)plaintext.data(), &len,
(unsigned char*)encryptedData.constData(),
encryptedData.size()) != 1) {
qWarning() << "[CRYPTO] DecryptUpdate failed";
EVP_CIPHER_CTX_free(ctx);
return QByteArray();
}
plaintextLen = len;
// 完成解密
if (EVP_DecryptFinal_ex(ctx, (unsigned char*)plaintext.data() + len, &len) != 1) {
qWarning() << "[CRYPTO] DecryptFinal failed";
EVP_CIPHER_CTX_free(ctx);
return QByteArray();
}
plaintextLen += len;
EVP_CIPHER_CTX_free(ctx);
plaintext.resize(plaintextLen);
// 3. 移除PKCS#7填充
QByteArray result = removePKCS7Padding(plaintext);
qDebug() << "[CRYPTO] ✓ Decryption successful, length:" << result.size();
return result;
}
// ============= 映射配置上传(加密) =============
void NetworkApiManager::uploadMappingConfig(const QMap<QString, QString> &studentNameMap,
const QMap<QString, QString> &classNameMap)
{
QString url = buildApiUrl("/mapping_config");
qDebug() << "Uploading mapping config to:" << url;
// 构建JSON数据
QJsonObject studentMapObj;
for (auto it = studentNameMap.begin(); it != studentNameMap.end(); ++it) {
studentMapObj[it.key()] = it.value();
}
QJsonObject classMapObj;
for (auto it = classNameMap.begin(); it != classNameMap.end(); ++it) {
classMapObj[it.key()] = it.value();
}
QJsonObject jsonObj;
jsonObj["student_name_map"] = studentMapObj;
jsonObj["class_name_map"] = classMapObj;
QJsonDocument doc(jsonObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
qDebug() << "Mapping config JSON:" << QString::fromUtf8(jsonData);
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = m_networkManager->post(request, jsonData);
reply->setProperty("requestType", "uploadMappingConfig");
}
// ============= 映射配置下载 =============
void NetworkApiManager::fetchMappingConfig()
{
QString url = buildApiUrl("/mapping_config");
qDebug() << "Fetching mapping config from:" << url;
QNetworkRequest request(url);
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("requestType", "fetchMappingConfig");
}
// ============= 人脸照片管理API =============
void NetworkApiManager::fetchFacePhotos()
{
QString url = buildApiUrl("/face_photos");
qDebug() << "Fetching face photos list from:" << url;
QNetworkRequest request(url);
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("requestType", "fetchFacePhotos");
}
void NetworkApiManager::fetchFacePhoto(const QString &faceId)
{
QString url = m_serverUrl + "/api/face_photo/" + faceId;
qDebug() << "Fetching face photo:" << url;
QNetworkRequest request(url);
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("requestType", "fetchFacePhoto");
reply->setProperty("faceId", faceId);
}
void NetworkApiManager::uploadFaceIdMapping(const QMap<QString, QString> &mapping)
{
QString url = buildApiUrl("/face_id_mapping");
qDebug() << "Uploading face ID mapping to:" << url;
// 构建JSON数据
QJsonObject mappingsObj;
for (auto it = mapping.begin(); it != mapping.end(); ++it) {
mappingsObj[it.key()] = it.value();
}
QJsonObject jsonObj;
jsonObj["mappings"] = mappingsObj;
QJsonDocument doc(jsonObj);
QByteArray jsonData = doc.toJson(QJsonDocument::Compact);
qDebug() << "Face ID mapping JSON:" << QString::fromUtf8(jsonData);
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QNetworkReply *reply = m_networkManager->post(request, jsonData);
reply->setProperty("requestType", "uploadFaceIdMapping");
}
void NetworkApiManager::fetchFaceIdMapping()
{
QString url = buildApiUrl("/face_id_mapping");
qDebug() << "Fetching face ID mapping from:" << url;
QNetworkRequest request(url);
QNetworkReply *reply = m_networkManager->get(request);
reply->setProperty("requestType", "fetchFaceIdMapping");
}
+134
View File
@@ -0,0 +1,134 @@
#include "ServerConfigDialog.h"
#include "NetworkApiManager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFormLayout>
#include <QMessageBox>
#include <QLabel>
ServerConfigDialog::ServerConfigDialog(const QString &currentUrl, QWidget *parent)
: QDialog(parent)
, m_serverUrl(currentUrl)
{
// 设置窗口标志,使对话框相对于父窗口居中
setWindowModality(Qt::WindowModal);
setupUI();
m_urlEdit->setText(currentUrl);
}
void ServerConfigDialog::setupUI()
{
setWindowTitle("服务器配置");
resize(500, 200);
QVBoxLayout *mainLayout = new QVBoxLayout(this);
// 说明文字
QLabel *infoLabel = new QLabel(
"配置考勤服务器地址,确保服务器已启动并可访问。\n"
"格式: http://IP地址:端口 (例如: http://192.168.1.100:8082)");
infoLabel->setStyleSheet("color: #666; padding: 10px; background-color: #f0f0f0; border-radius: 5px;");
mainLayout->addWidget(infoLabel);
// 服务器地址输入
QFormLayout *formLayout = new QFormLayout();
m_urlEdit = new QLineEdit();
m_urlEdit->setPlaceholderText("http://192.168.1.100:8082");
formLayout->addRow("服务器地址:", m_urlEdit);
mainLayout->addLayout(formLayout);
// 测试连接按钮和状态
QHBoxLayout *testLayout = new QHBoxLayout();
m_testButton = new QPushButton("测试连接");
m_statusLabel = new QLabel("");
testLayout->addWidget(m_testButton);
testLayout->addWidget(m_statusLabel);
testLayout->addStretch();
mainLayout->addLayout(testLayout);
// 底部按钮
QHBoxLayout *buttonLayout = new QHBoxLayout();
QPushButton *saveButton = new QPushButton("保存");
QPushButton *cancelButton = new QPushButton("取消");
saveButton->setStyleSheet("QPushButton { background-color: #4CAF50; color: white; padding: 8px 20px; }");
cancelButton->setStyleSheet("QPushButton { padding: 8px 20px; }");
buttonLayout->addStretch();
buttonLayout->addWidget(saveButton);
buttonLayout->addWidget(cancelButton);
mainLayout->addLayout(buttonLayout);
// 连接信号
connect(m_testButton, &QPushButton::clicked, this, &ServerConfigDialog::onTestConnection);
connect(saveButton, &QPushButton::clicked, this, &ServerConfigDialog::onSave);
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
}
void ServerConfigDialog::onTestConnection()
{
QString url = m_urlEdit->text().trimmed();
if (url.isEmpty()) {
m_statusLabel->setText("❌ 请输入服务器地址");
m_statusLabel->setStyleSheet("color: red;");
return;
}
m_statusLabel->setText("⏳ 测试中...");
m_statusLabel->setStyleSheet("color: orange;");
m_testButton->setEnabled(false);
// 创建临时网络管理器测试连接
NetworkApiManager *tempManager = new NetworkApiManager(this);
tempManager->setServerUrl(url);
connect(tempManager, &NetworkApiManager::healthChecked, this, [this](bool healthy) {
if (healthy) {
m_statusLabel->setText("✓ 连接成功");
m_statusLabel->setStyleSheet("color: green;");
} else {
m_statusLabel->setText("❌ 服务器无响应");
m_statusLabel->setStyleSheet("color: red;");
}
m_testButton->setEnabled(true);
});
connect(tempManager, &NetworkApiManager::errorOccurred, this, [this](const QString &error) {
m_statusLabel->setText("❌ " + error);
m_statusLabel->setStyleSheet("color: red;");
m_testButton->setEnabled(true);
});
tempManager->checkHealth();
}
void ServerConfigDialog::onSave()
{
QString url = m_urlEdit->text().trimmed();
if (url.isEmpty()) {
QMessageBox::warning(this, "错误", "请输入服务器地址!");
return;
}
// 验证URL格式
if (!url.startsWith("http://") && !url.startsWith("https://")) {
QMessageBox::warning(this, "错误", "服务器地址必须以 http:// 或 https:// 开头!");
return;
}
m_serverUrl = url;
accept();
}
QString ServerConfigDialog::getServerUrl() const
{
return m_serverUrl;
}
+383
View File
@@ -0,0 +1,383 @@
#include "SettingsDialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMessageBox>
#include <QLabel>
#include <QFormLayout>
#include <QGroupBox>
#include <QMoveEvent>
// ============= 对话框位置限制过滤器(本地实现) =============
class LocalDialogPositionFilter : public QObject
{
public:
explicit LocalDialogPositionFilter(QObject *parent = nullptr) : QObject(parent) {}
protected:
bool eventFilter(QObject *obj, QEvent *event) override
{
if (event->type() == QEvent::Move) {
QDialog *dialog = qobject_cast<QDialog*>(obj);
if (dialog && dialog->parentWidget()) {
QWidget *parent = dialog->parentWidget();
QRect parentRect = parent->geometry();
QRect dialogRect = dialog->frameGeometry();
int x = dialog->x();
int y = dialog->y();
if (x < parentRect.x()) x = parentRect.x();
if (x + dialogRect.width() > parentRect.x() + parentRect.width()) {
x = parentRect.x() + parentRect.width() - dialogRect.width();
}
if (y < parentRect.y()) y = parentRect.y();
if (y + dialogRect.height() > parentRect.y() + parentRect.height()) {
y = parentRect.y() + parentRect.height() - dialogRect.height();
}
if (x != dialog->x() || y != dialog->y()) {
dialog->move(x, y);
return true;
}
}
}
return QObject::eventFilter(obj, event);
}
};
SettingsDialog::SettingsDialog(QWidget *parent)
: QDialog(parent)
, m_apiManager(nullptr)
{
// 设置窗口标志,使对话框相对于父窗口居中
setWindowModality(Qt::WindowModal);
// 创建网络API管理器
m_apiManager = new NetworkApiManager(this);
// 连接上传时间段的响应信号
connect(m_apiManager, &NetworkApiManager::timeSlotsUploaded,
this, &SettingsDialog::onTimeSlotsUploaded);
connect(m_apiManager, &NetworkApiManager::errorOccurred,
this, [this](const QString &error) {
QMessageBox::warning(this, "网络错误",
QString("同步时间段设置到服务器失败:\n%1\n\n设置已保存到本地,但STM32设备可能未同步。").arg(error));
});
setupUI();
// 初始化时从配置加载数据
m_tempTimeSlots = TimeSlotConfig::instance().getTimeSlots();
refreshTimeSlots(); // 显示加载的数据
}
SettingsDialog::~SettingsDialog()
{
}
void SettingsDialog::setupUI()
{
setWindowTitle("签到时间段设置");
resize(700, 500);
QVBoxLayout *mainLayout = new QVBoxLayout(this);
// 说明文字
QLabel *infoLabel = new QLabel(
"配置签到时间段,只有在设定的时间段内才能进行签到。\n"
"✓ 设置保存后会自动同步到服务器,STM32设备会自动获取最新配置。");
infoLabel->setStyleSheet("color: #666; padding: 10px; background-color: #f0f0f0; border-radius: 5px;");
mainLayout->addWidget(infoLabel);
// 时间段表格
m_tableWidget = new QTableWidget(0, 4);
QStringList headers;
headers << "时段名称" << "开始时间" << "结束时间" << "状态";
m_tableWidget->setHorizontalHeaderLabels(headers);
m_tableWidget->setSelectionBehavior(QAbstractItemView::SelectRows);
m_tableWidget->setSelectionMode(QAbstractItemView::SingleSelection);
m_tableWidget->horizontalHeader()->setStretchLastSection(true);
m_tableWidget->setColumnWidth(0, 200);
m_tableWidget->setColumnWidth(1, 150);
m_tableWidget->setColumnWidth(2, 150);
mainLayout->addWidget(m_tableWidget);
// 按钮区域
QHBoxLayout *buttonLayout = new QHBoxLayout();
m_addButton = new QPushButton("添加时段");
m_editButton = new QPushButton("编辑时段");
m_deleteButton = new QPushButton("删除时段");
m_resetButton = new QPushButton("恢复默认");
buttonLayout->addWidget(m_addButton);
buttonLayout->addWidget(m_editButton);
buttonLayout->addWidget(m_deleteButton);
buttonLayout->addStretch();
buttonLayout->addWidget(m_resetButton);
mainLayout->addLayout(buttonLayout);
// 底部按钮
QHBoxLayout *bottomLayout = new QHBoxLayout();
m_saveButton = new QPushButton("保存");
m_cancelButton = new QPushButton("取消");
m_saveButton->setStyleSheet("QPushButton { background-color: #4CAF50; color: white; padding: 8px 20px; }");
m_cancelButton->setStyleSheet("QPushButton { padding: 8px 20px; }");
bottomLayout->addStretch();
bottomLayout->addWidget(m_saveButton);
bottomLayout->addWidget(m_cancelButton);
mainLayout->addLayout(bottomLayout);
// 连接信号槽
connect(m_addButton, &QPushButton::clicked, this, &SettingsDialog::onAddTimeSlot);
connect(m_editButton, &QPushButton::clicked, this, &SettingsDialog::onEditTimeSlot);
connect(m_deleteButton, &QPushButton::clicked, this, &SettingsDialog::onDeleteTimeSlot);
connect(m_resetButton, &QPushButton::clicked, this, &SettingsDialog::onResetToDefault);
connect(m_saveButton, &QPushButton::clicked, this, &SettingsDialog::onSave);
connect(m_cancelButton, &QPushButton::clicked, this, &SettingsDialog::onCancel);
connect(m_tableWidget, &QTableWidget::doubleClicked, this, &SettingsDialog::onEditTimeSlot);
}
void SettingsDialog::loadTimeSlots()
{
// 只在初始化时从配置加载,之后只刷新表格显示
// 不要重新加载,否则会覆盖临时修改
// m_tempTimeSlots = TimeSlotConfig::instance().getTimeSlots();
m_tableWidget->setRowCount(0);
for (int i = 0; i < m_tempTimeSlots.size(); ++i) {
const TimeSlot &slot = m_tempTimeSlots[i];
int row = m_tableWidget->rowCount();
m_tableWidget->insertRow(row);
m_tableWidget->setItem(row, 0, new QTableWidgetItem(slot.name));
m_tableWidget->setItem(row, 1, new QTableWidgetItem(slot.startTime.toString("HH:mm")));
m_tableWidget->setItem(row, 2, new QTableWidgetItem(slot.endTime.toString("HH:mm")));
QTableWidgetItem *statusItem = new QTableWidgetItem(slot.enabled ? "启用" : "禁用");
statusItem->setForeground(slot.enabled ? QBrush(Qt::darkGreen) : QBrush(Qt::red));
m_tableWidget->setItem(row, 3, statusItem);
}
}
void SettingsDialog::refreshTimeSlots()
{
// 新增:专门用于刷新表格显示,不改变m_tempTimeSlots
m_tableWidget->setRowCount(0);
for (int i = 0; i < m_tempTimeSlots.size(); ++i) {
const TimeSlot &slot = m_tempTimeSlots[i];
int row = m_tableWidget->rowCount();
m_tableWidget->insertRow(row);
m_tableWidget->setItem(row, 0, new QTableWidgetItem(slot.name));
m_tableWidget->setItem(row, 1, new QTableWidgetItem(slot.startTime.toString("HH:mm")));
m_tableWidget->setItem(row, 2, new QTableWidgetItem(slot.endTime.toString("HH:mm")));
QTableWidgetItem *statusItem = new QTableWidgetItem(slot.enabled ? "启用" : "禁用");
statusItem->setForeground(slot.enabled ? QBrush(Qt::darkGreen) : QBrush(Qt::red));
m_tableWidget->setItem(row, 3, statusItem);
}
}
void SettingsDialog::saveTimeSlots()
{
TimeSlotConfig::instance().setTimeSlots(m_tempTimeSlots);
}
void SettingsDialog::onAddTimeSlot()
{
TimeSlotEditDialog dialog(this);
// 安装事件过滤器,限制对话框在父窗口内移动
LocalDialogPositionFilter *filter = new LocalDialogPositionFilter(&dialog);
dialog.installEventFilter(filter);
if (dialog.exec() == QDialog::Accepted) {
TimeSlot newSlot = dialog.getTimeSlot();
m_tempTimeSlots.append(newSlot);
refreshTimeSlots(); // 使用 refreshTimeSlots 而不是 loadTimeSlots
}
}
void SettingsDialog::onEditTimeSlot()
{
int row = m_tableWidget->currentRow();
if (row < 0) {
QMessageBox::warning(this, "提示", "请先选择要编辑的时段!");
return;
}
TimeSlotEditDialog dialog(this, &m_tempTimeSlots[row]);
// 安装事件过滤器,限制对话框在父窗口内移动
LocalDialogPositionFilter *filter = new LocalDialogPositionFilter(&dialog);
dialog.installEventFilter(filter);
if (dialog.exec() == QDialog::Accepted) {
m_tempTimeSlots[row] = dialog.getTimeSlot();
refreshTimeSlots(); // 使用 refreshTimeSlots 而不是 loadTimeSlots
}
}
void SettingsDialog::onDeleteTimeSlot()
{
int row = m_tableWidget->currentRow();
if (row < 0) {
QMessageBox::warning(this, "提示", "请先选择要删除的时段!");
return;
}
QMessageBox::StandardButton reply = QMessageBox::question(
this,
"确认删除",
QString("确定要删除时段 \"%1\" 吗?").arg(m_tempTimeSlots[row].name),
QMessageBox::Yes | QMessageBox::No
);
if (reply == QMessageBox::Yes) {
m_tempTimeSlots.removeAt(row);
refreshTimeSlots(); // 使用 refreshTimeSlots 而不是 loadTimeSlots
}
}
void SettingsDialog::onResetToDefault()
{
QMessageBox::StandardButton reply = QMessageBox::question(
this,
"确认重置",
"确定要恢复默认时间段设置吗?这将清除所有自定义设置。",
QMessageBox::Yes | QMessageBox::No
);
if (reply == QMessageBox::Yes) {
TimeSlotConfig::instance().resetToDefault();
m_tempTimeSlots = TimeSlotConfig::instance().getTimeSlots();
refreshTimeSlots(); // 使用 refreshTimeSlots 而不是 loadTimeSlots
}
}
void SettingsDialog::onSave()
{
if (m_tempTimeSlots.isEmpty()) {
QMessageBox::warning(this, "警告", "至少需要设置一个签到时段!");
return;
}
// 保存到本地配置文件
saveTimeSlots();
// 上传到服务器,由服务器同步到STM32设备
m_saveButton->setEnabled(false);
m_saveButton->setText("正在同步...");
m_apiManager->uploadTimeSlots(m_tempTimeSlots);
}
void SettingsDialog::onTimeSlotsUploaded(bool success)
{
m_saveButton->setEnabled(true);
m_saveButton->setText("保存");
if (success) {
QMessageBox::information(this, "成功",
"时间段设置已保存并成功同步到服务器!\n\n"
"STM32设备将在下次连接时自动获取最新配置。");
accept();
} else {
QMessageBox::warning(this, "部分成功",
"时间段设置已保存到本地,但同步到服务器失败。\n\n"
"请检查网络连接或稍后重试。\n"
"STM32设备可能需要手动配置。");
}
}
void SettingsDialog::onCancel()
{
reject();
}
// ============= TimeSlotEditDialog =============
TimeSlotEditDialog::TimeSlotEditDialog(QWidget *parent, const TimeSlot *slot)
: QDialog(parent)
{
// 设置窗口标志,使对话框相对于父窗口居中
setWindowModality(Qt::WindowModal);
setupUI();
if (slot) {
setWindowTitle("编辑时间段");
m_nameEdit->setText(slot->name);
m_startTimeEdit->setTime(slot->startTime);
m_endTimeEdit->setTime(slot->endTime);
m_enabledCheckBox->setChecked(slot->enabled);
} else {
setWindowTitle("添加时间段");
m_startTimeEdit->setTime(QTime(8, 0));
m_endTimeEdit->setTime(QTime(9, 0));
m_enabledCheckBox->setChecked(true);
}
}
void TimeSlotEditDialog::setupUI()
{
resize(400, 250);
QVBoxLayout *mainLayout = new QVBoxLayout(this);
QFormLayout *formLayout = new QFormLayout();
m_nameEdit = new QLineEdit();
m_nameEdit->setPlaceholderText("例如:上午签到");
m_startTimeEdit = new QTimeEdit();
m_startTimeEdit->setDisplayFormat("HH:mm");
m_endTimeEdit = new QTimeEdit();
m_endTimeEdit->setDisplayFormat("HH:mm");
m_enabledCheckBox = new QCheckBox("启用此时段");
formLayout->addRow("时段名称:", m_nameEdit);
formLayout->addRow("开始时间:", m_startTimeEdit);
formLayout->addRow("结束时间:", m_endTimeEdit);
formLayout->addRow("", m_enabledCheckBox);
mainLayout->addLayout(formLayout);
// 按钮
QHBoxLayout *buttonLayout = new QHBoxLayout();
QPushButton *okButton = new QPushButton("确定");
QPushButton *cancelButton = new QPushButton("取消");
okButton->setStyleSheet("QPushButton { background-color: #4CAF50; color: white; padding: 8px 20px; }");
cancelButton->setStyleSheet("QPushButton { padding: 8px 20px; }");
buttonLayout->addStretch();
buttonLayout->addWidget(okButton);
buttonLayout->addWidget(cancelButton);
mainLayout->addLayout(buttonLayout);
connect(okButton, &QPushButton::clicked, this, &QDialog::accept);
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
}
TimeSlot TimeSlotEditDialog::getTimeSlot() const
{
TimeSlot slot;
slot.name = m_nameEdit->text().trimmed();
slot.startTime = m_startTimeEdit->time();
slot.endTime = m_endTimeEdit->time();
slot.enabled = m_enabledCheckBox->isChecked();
return slot;
}
+298
View File
@@ -0,0 +1,298 @@
#include "StatisticsDialog.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QGroupBox>
#include <QFileDialog>
#include <QMessageBox>
#include <QFile>
#include <QTextStream>
#include <QDateTime>
StatisticsDialog::StatisticsDialog(const QVector<AttendanceRecord> &records, QWidget *parent)
: QDialog(parent)
, m_records(records)
{
// 设置窗口标志,使对话框相对于父窗口居中
setWindowModality(Qt::WindowModal);
setupUI();
calculateStatistics();
updateTable();
}
StatisticsDialog::~StatisticsDialog()
{
}
void StatisticsDialog::setupUI()
{
setWindowTitle("考勤统计报表");
resize(900, 600);
QVBoxLayout *mainLayout = new QVBoxLayout(this);
// === 日期范围选择区域 ===
QGroupBox *dateGroup = new QGroupBox("统计范围");
QHBoxLayout *dateLayout = new QHBoxLayout(dateGroup);
dateLayout->addWidget(new QLabel("开始日期:"));
m_startDateEdit = new QDateEdit();
m_startDateEdit->setCalendarPopup(true);
m_startDateEdit->setDate(QDate::currentDate().addDays(-30));
dateLayout->addWidget(m_startDateEdit);
dateLayout->addWidget(new QLabel("结束日期:"));
m_endDateEdit = new QDateEdit();
m_endDateEdit->setCalendarPopup(true);
m_endDateEdit->setDate(QDate::currentDate());
dateLayout->addWidget(m_endDateEdit);
dateLayout->addWidget(new QLabel("快速选择:"));
m_quickSelectCombo = new QComboBox();
m_quickSelectCombo->addItems({"今天", "最近7天", "最近30天", "本月", "全部"});
m_quickSelectCombo->setCurrentIndex(2);
dateLayout->addWidget(m_quickSelectCombo);
dateLayout->addStretch();
mainLayout->addWidget(dateGroup);
// === 汇总信息 ===
m_summaryLabel = new QLabel();
m_summaryLabel->setStyleSheet("padding: 10px; background-color: #e3f2fd; border-radius: 5px;");
QFont summaryFont;
summaryFont.setPointSize(10);
m_summaryLabel->setFont(summaryFont);
mainLayout->addWidget(m_summaryLabel);
// === 统计表格 ===
m_tableWidget = new QTableWidget(0, 6);
QStringList headers;
headers << "学号" << "姓名" << "总签到次数" << "今日签到" << "最后签到时间" << "平均置信度";
m_tableWidget->setHorizontalHeaderLabels(headers);
m_tableWidget->setSelectionBehavior(QAbstractItemView::SelectRows);
m_tableWidget->setAlternatingRowColors(true);
m_tableWidget->horizontalHeader()->setStretchLastSection(true);
m_tableWidget->setColumnWidth(0, 100);
m_tableWidget->setColumnWidth(1, 120);
m_tableWidget->setColumnWidth(2, 120);
m_tableWidget->setColumnWidth(3, 100);
m_tableWidget->setColumnWidth(4, 180);
mainLayout->addWidget(m_tableWidget);
// === 底部按钮 ===
QHBoxLayout *buttonLayout = new QHBoxLayout();
m_refreshButton = new QPushButton("刷新统计");
m_exportButton = new QPushButton("导出报表");
QPushButton *closeButton = new QPushButton("关闭");
m_exportButton->setStyleSheet("QPushButton { background-color: #2196F3; color: white; padding: 8px 20px; }");
closeButton->setStyleSheet("QPushButton { padding: 8px 20px; }");
buttonLayout->addWidget(m_refreshButton);
buttonLayout->addWidget(m_exportButton);
buttonLayout->addStretch();
buttonLayout->addWidget(closeButton);
mainLayout->addLayout(buttonLayout);
// 连接信号槽
connect(m_startDateEdit, &QDateEdit::dateChanged, this, &StatisticsDialog::onDateRangeChanged);
connect(m_endDateEdit, &QDateEdit::dateChanged, this, &StatisticsDialog::onDateRangeChanged);
connect(m_quickSelectCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &StatisticsDialog::onDateRangeChanged);
connect(m_exportButton, &QPushButton::clicked, this, &StatisticsDialog::onExportReport);
connect(m_refreshButton, &QPushButton::clicked, this, &StatisticsDialog::onRefresh);
connect(closeButton, &QPushButton::clicked, this, &QDialog::accept);
}
void StatisticsDialog::calculateStatistics()
{
m_statsMap.clear();
QDate startDate = m_startDateEdit->date();
QDate endDate = m_endDateEdit->date();
QDate today = QDate::currentDate();
for (const AttendanceRecord &record : m_records) {
QDate recordDate = record.getDateTime().date();
// 日期过滤
if (recordDate < startDate || recordDate > endDate) {
continue;
}
QString studentId = record.getStudentId();
if (!m_statsMap.contains(studentId)) {
StudentStats stats;
stats.studentId = studentId;
stats.name = record.getName();
stats.totalCount = 0;
stats.todayCount = 0;
stats.avgConfidence = 0.0;
m_statsMap[studentId] = stats;
}
StudentStats &stats = m_statsMap[studentId];
stats.totalCount++;
if (recordDate == today) {
stats.todayCount++;
}
// 更新最后签到时间
if (!stats.lastCheckIn.isValid() || record.getDateTime() > stats.lastCheckIn) {
stats.lastCheckIn = record.getDateTime();
}
// 累加置信度(稍后计算平均值)
stats.avgConfidence += record.getConfidence();
}
// 计算平均置信度
for (auto it = m_statsMap.begin(); it != m_statsMap.end(); ++it) {
if (it->totalCount > 0) {
it->avgConfidence /= it->totalCount;
}
}
}
void StatisticsDialog::updateTable()
{
m_tableWidget->setRowCount(0);
// 更新汇总信息
int totalStudents = m_statsMap.size();
int totalRecords = 0;
for (const auto &stats : m_statsMap) {
totalRecords += stats.totalCount;
}
m_summaryLabel->setText(QString(
"📊 统计汇总:共 %1 名学生,总签到记录 %2 次 | "
"统计时间:%3 至 %4")
.arg(totalStudents)
.arg(totalRecords)
.arg(m_startDateEdit->date().toString("yyyy-MM-dd"))
.arg(m_endDateEdit->date().toString("yyyy-MM-dd")));
// 填充表格
for (auto it = m_statsMap.begin(); it != m_statsMap.end(); ++it) {
const StudentStats &stats = it.value();
int row = m_tableWidget->rowCount();
m_tableWidget->insertRow(row);
m_tableWidget->setItem(row, 0, new QTableWidgetItem(stats.studentId));
m_tableWidget->setItem(row, 1, new QTableWidgetItem(stats.name));
QTableWidgetItem *totalItem = new QTableWidgetItem(QString::number(stats.totalCount));
totalItem->setTextAlignment(Qt::AlignCenter);
m_tableWidget->setItem(row, 2, totalItem);
QTableWidgetItem *todayItem = new QTableWidgetItem(QString::number(stats.todayCount));
todayItem->setTextAlignment(Qt::AlignCenter);
m_tableWidget->setItem(row, 3, todayItem);
QString lastCheckInStr = stats.lastCheckIn.isValid()
? stats.lastCheckIn.toString("yyyy-MM-dd HH:mm:ss")
: "无";
m_tableWidget->setItem(row, 4, new QTableWidgetItem(lastCheckInStr));
QTableWidgetItem *confItem = new QTableWidgetItem(
QString::number(stats.avgConfidence * 100, 'f', 2) + "%");
confItem->setTextAlignment(Qt::AlignCenter);
m_tableWidget->setItem(row, 5, confItem);
}
}
void StatisticsDialog::onDateRangeChanged()
{
// 处理快速选择
QString quickSelect = m_quickSelectCombo->currentText();
QDate today = QDate::currentDate();
if (quickSelect == "今天") {
m_startDateEdit->setDate(today);
m_endDateEdit->setDate(today);
} else if (quickSelect == "最近7天") {
m_startDateEdit->setDate(today.addDays(-7));
m_endDateEdit->setDate(today);
} else if (quickSelect == "最近30天") {
m_startDateEdit->setDate(today.addDays(-30));
m_endDateEdit->setDate(today);
} else if (quickSelect == "本月") {
m_startDateEdit->setDate(QDate(today.year(), today.month(), 1));
m_endDateEdit->setDate(today);
} else if (quickSelect == "全部") {
// 找到最早的记录日期
QDate earliest = today;
for (const AttendanceRecord &record : m_records) {
QDate recordDate = record.getDateTime().date();
if (recordDate < earliest) {
earliest = recordDate;
}
}
m_startDateEdit->setDate(earliest);
m_endDateEdit->setDate(today);
}
calculateStatistics();
updateTable();
}
void StatisticsDialog::onExportReport()
{
QString fileName = QFileDialog::getSaveFileName(
this,
"导出统计报表",
QString("attendance_statistics_%1.csv")
.arg(QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss")),
"CSV文件 (*.csv)");
if (fileName.isEmpty()) {
return;
}
QFile file(fileName);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::critical(this, "错误", "无法创建导出文件!");
return;
}
QTextStream out(&file);
out.setCodec("UTF-8");
// 写入标题
out << QString("考勤统计报表 - 统计时间:%1 至 %2\n")
.arg(m_startDateEdit->date().toString("yyyy-MM-dd"))
.arg(m_endDateEdit->date().toString("yyyy-MM-dd"));
out << "\n";
// 写入表头
out << "学号,姓名,总签到次数,今日签到,最后签到时间,平均置信度\n";
// 写入数据
for (auto it = m_statsMap.begin(); it != m_statsMap.end(); ++it) {
const StudentStats &stats = it.value();
out << stats.studentId << ","
<< stats.name << ","
<< stats.totalCount << ","
<< stats.todayCount << ","
<< (stats.lastCheckIn.isValid() ? stats.lastCheckIn.toString("yyyy-MM-dd HH:mm:ss") : "无") << ","
<< QString::number(stats.avgConfidence, 'f', 4) << "\n";
}
file.close();
QMessageBox::information(this, "导出成功",
QString("统计报表已导出到:\n%1").arg(fileName));
}
void StatisticsDialog::onRefresh()
{
calculateStatistics();
updateTable();
}
+163
View File
@@ -0,0 +1,163 @@
#include "TimeSlotConfig.h"
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QStandardPaths>
#include <QDir>
#include <QDebug>
TimeSlotConfig& TimeSlotConfig::instance()
{
static TimeSlotConfig instance;
return instance;
}
TimeSlotConfig::TimeSlotConfig()
{
// 配置文件路径
QString configPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir().mkpath(configPath);
m_configFilePath = configPath + "/timeslot_config.json";
// 加载配置或使用默认值
if (QFile::exists(m_configFilePath)) {
loadFromFile();
} else {
initDefaultTimeSlots();
saveToFile();
}
}
TimeSlotConfig::~TimeSlotConfig()
{
saveToFile();
}
void TimeSlotConfig::initDefaultTimeSlots()
{
m_timeSlots.clear();
// 默认时间段(对应STM32端的配置)
m_timeSlots.append(TimeSlot("上午签到", QTime(8, 0), QTime(9, 30), true));
m_timeSlots.append(TimeSlot("中午签退", QTime(12, 0), QTime(13, 0), true));
m_timeSlots.append(TimeSlot("下午签到", QTime(14, 0), QTime(15, 0), true));
m_timeSlots.append(TimeSlot("晚上签退", QTime(17, 0), QTime(18, 30), true));
}
void TimeSlotConfig::setTimeSlots(const QVector<TimeSlot> &timeSlots)
{
m_timeSlots = timeSlots;
saveToFile();
}
void TimeSlotConfig::addTimeSlot(const TimeSlot &timeSlot)
{
m_timeSlots.append(timeSlot);
saveToFile();
}
void TimeSlotConfig::removeTimeSlot(int index)
{
if (index >= 0 && index < m_timeSlots.size()) {
m_timeSlots.removeAt(index);
saveToFile();
}
}
void TimeSlotConfig::updateTimeSlot(int index, const TimeSlot &timeSlot)
{
if (index >= 0 && index < m_timeSlots.size()) {
m_timeSlots[index] = timeSlot;
saveToFile();
}
}
bool TimeSlotConfig::isValidTime(const QTime &time) const
{
for (const TimeSlot &slot : m_timeSlots) {
if (slot.isInRange(time)) {
return true;
}
}
return false;
}
QString TimeSlotConfig::getCurrentTimeSlotName(const QTime &time) const
{
for (const TimeSlot &slot : m_timeSlots) {
if (slot.isInRange(time)) {
return slot.name;
}
}
return "非签到时段";
}
void TimeSlotConfig::saveToFile()
{
QJsonArray slotsArray;
for (const TimeSlot &slot : m_timeSlots) {
QJsonObject obj;
obj["name"] = slot.name;
obj["startTime"] = slot.startTime.toString("HH:mm");
obj["endTime"] = slot.endTime.toString("HH:mm");
obj["enabled"] = slot.enabled;
slotsArray.append(obj);
}
QJsonObject root;
root["timeSlots"] = slotsArray;
QJsonDocument doc(root);
QFile file(m_configFilePath);
if (file.open(QIODevice::WriteOnly)) {
file.write(doc.toJson());
file.close();
qDebug() << "时间段配置已保存:" << m_configFilePath;
} else {
qWarning() << "无法保存时间段配置:" << m_configFilePath;
}
}
void TimeSlotConfig::loadFromFile()
{
QFile file(m_configFilePath);
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "无法加载时间段配置:" << m_configFilePath;
initDefaultTimeSlots();
return;
}
QByteArray data = file.readAll();
file.close();
QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) {
qWarning() << "时间段配置格式错误";
initDefaultTimeSlots();
return;
}
QJsonObject root = doc.object();
QJsonArray slotsArray = root["timeSlots"].toArray();
m_timeSlots.clear();
for (const QJsonValue &value : slotsArray) {
QJsonObject obj = value.toObject();
TimeSlot slot;
slot.name = obj["name"].toString();
slot.startTime = QTime::fromString(obj["startTime"].toString(), "HH:mm");
slot.endTime = QTime::fromString(obj["endTime"].toString(), "HH:mm");
slot.enabled = obj["enabled"].toBool();
m_timeSlots.append(slot);
}
qDebug() << "已加载" << m_timeSlots.size() << "个时间段配置";
}
void TimeSlotConfig::resetToDefault()
{
initDefaultTimeSlots();
saveToFile();
}
+21
View File
@@ -0,0 +1,21 @@
#include "MainWindow.h"
#include <QApplication>
#include <QDebug>
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
// 设置应用程序信息
QApplication::setApplicationName("人脸识别考勤系统");
QApplication::setApplicationVersion("1.0.0");
QApplication::setOrganizationName("STM32 Face Recognition");
// 创建并显示主窗口
MainWindow window;
window.show();
qDebug() << "考勤查看器启动成功!";
return app.exec();
}