commit fe6e4e428a720a445b6ab1afe3490e5aac35056a Author: 纪田野 Date: Fri May 1 10:51:39 2026 +0800 初始化 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ff9d2a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AttendanceViewer.pro b/AttendanceViewer.pro new file mode 100644 index 0000000..956d3f9 --- /dev/null +++ b/AttendanceViewer.pro @@ -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\\\" diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..044136a --- /dev/null +++ b/CMakeLists.txt @@ -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 +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7d66d8 --- /dev/null +++ b/README.md @@ -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. 享受实时查看考勤数据的便利!🚀 diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..f6d9a98 --- /dev/null +++ b/build.bat @@ -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 diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..8a57927 --- /dev/null +++ b/build.sh @@ -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 diff --git a/include/AttendanceRecord.h b/include/AttendanceRecord.h new file mode 100644 index 0000000..a8daeec --- /dev/null +++ b/include/AttendanceRecord.h @@ -0,0 +1,53 @@ +#ifndef ATTENDANCERECORD_H +#define ATTENDANCERECORD_H + +#include +#include + +/** + * @brief 考勤记录数据模型 + * + * 对应CSV文件格式:Timestamp,Student_ID,Name,Confidence,Status,Class_ID + */ +class AttendanceRecord +{ +public: + AttendanceRecord(); + AttendanceRecord(const QString ×tamp, 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 ×tamp); + 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 diff --git a/include/FaceManageDialog.h b/include/FaceManageDialog.h new file mode 100644 index 0000000..c7ca95f --- /dev/null +++ b/include/FaceManageDialog.h @@ -0,0 +1,98 @@ +#ifndef FACEMANAGEDIALOG_H +#define FACEMANAGEDIALOG_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @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 m_photoItems; // faceId -> item + QMap m_idMapping; // faceId -> studentId + int m_pendingPhotoCount; +}; + +#endif // FACEMANAGEDIALOG_H diff --git a/include/MainWindow.h b/include/MainWindow.h new file mode 100644 index 0000000..56d3906 --- /dev/null +++ b/include/MainWindow.h @@ -0,0 +1,135 @@ +#ifndef MAINWINDOW_H +#define MAINWINDOW_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#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 &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 &studentNameMap, + const QMap &classNameMap); + void onMappingConfigUploaded(bool success); + +private: + void setupUI(); // 设置UI界面 + void setupMenuBar(); // 设置菜单栏 + void setupConnections(); // 设置信号槽连接 + void updateTable(const QVector &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 m_allRecords; // 所有记录 + QVector m_filteredRecords; // 筛选后的记录 + QSet m_selectedRecordKeys; // 保存选中记录的唯一键(timestamp+studentId) + QMap m_studentNameMap; // 学号 -> 真实姓名 映射 + QMap 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 diff --git a/include/MappingConfigDialog.h b/include/MappingConfigDialog.h new file mode 100644 index 0000000..1d96345 --- /dev/null +++ b/include/MappingConfigDialog.h @@ -0,0 +1,89 @@ +#ifndef MAPPINGCONFIGDIALOG_H +#define MAPPINGCONFIGDIALOG_H + +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief 映射配置对话框 + * + * 用于配置: + * 1. 学号 -> 真实姓名 的映射 + * 2. 班级编号 -> 真实班级名称 的映射 + * + * 特性: + * - 双击单元格可直接编辑(类似Excel) + * - 自动从服务器加载已有映射 + * - 一键保存到服务器 + */ +class MappingConfigDialog : public QDialog +{ + Q_OBJECT + +public: + explicit MappingConfigDialog(QWidget *parent = nullptr); + ~MappingConfigDialog(); + + // 获取映射数据 + QMap getStudentNameMap() const { return m_studentNameMap; } + QMap getClassNameMap() const { return m_classNameMap; } + + // 设置映射数据(用于从服务器加载后更新) + void setStudentNameMap(const QMap &map); + void setClassNameMap(const QMap &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 m_studentNameMap; // 学号 -> 姓名 + QMap m_classNameMap; // 班级编号 -> 班级名称 +}; + +#endif // MAPPINGCONFIGDIALOG_H diff --git a/include/NetworkApiManager.h b/include/NetworkApiManager.h new file mode 100644 index 0000000..e1040ae --- /dev/null +++ b/include/NetworkApiManager.h @@ -0,0 +1,89 @@ +#ifndef NETWORKAPIMANAGER_H +#define NETWORKAPIMANAGER_H + +#include +#include +#include +#include +#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 ×tamp, const QString &studentId); + void uploadTimeSlots(const QVector &timeSlots); + void checkHealth(); + + // 映射配置API + void uploadMappingConfig(const QMap &studentNameMap, + const QMap &classNameMap); + void fetchMappingConfig(); + + // 人脸照片管理API + void fetchFacePhotos(); + void fetchFacePhoto(const QString &faceId); + void uploadFaceIdMapping(const QMap &mapping); + void fetchFaceIdMapping(); + +signals: + // 响应信号 + void recordsFetched(const QVector &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 &studentNameMap, + const QMap &classNameMap); + + // 人脸照片管理信号 + void facePhotosFetched(const QJsonArray &photos); + void facePhotoFetched(const QString &faceId, const QByteArray &photoData); + void faceIdMappingUploaded(bool success); + void faceIdMappingFetched(const QMap &mapping); + +private slots: + void onReplyFinished(QNetworkReply *reply); + +private: + QNetworkAccessManager *m_networkManager; + QString m_serverUrl; + bool m_encryptionEnabled; // 是否启用加密通信 + + QString buildApiUrl(const QString &endpoint, const QMap ¶ms = QMap()); + QVector parseRecordsJson(const QByteArray &json); + + // AES加密/解密功能 + QByteArray aesDecrypt(const QByteArray &encryptedData); + QByteArray removePKCS7Padding(const QByteArray &data); +}; + +#endif // NETWORKAPIMANAGER_H diff --git a/include/ServerConfigDialog.h b/include/ServerConfigDialog.h new file mode 100644 index 0000000..aad4043 --- /dev/null +++ b/include/ServerConfigDialog.h @@ -0,0 +1,33 @@ +#ifndef SERVERCONFIGDIALOG_H +#define SERVERCONFIGDIALOG_H + +#include +#include +#include +#include + +/** + * @brief 服务器配置对话框 + */ +class ServerConfigDialog : public QDialog +{ + Q_OBJECT + +public: + explicit ServerConfigDialog(const QString ¤tUrl, 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 diff --git a/include/SettingsDialog.h b/include/SettingsDialog.h new file mode 100644 index 0000000..acec839 --- /dev/null +++ b/include/SettingsDialog.h @@ -0,0 +1,75 @@ +#ifndef SETTINGSDIALOG_H +#define SETTINGSDIALOG_H + +#include +#include +#include +#include +#include +#include +#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 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 diff --git a/include/StatisticsDialog.h b/include/StatisticsDialog.h new file mode 100644 index 0000000..7658f0a --- /dev/null +++ b/include/StatisticsDialog.h @@ -0,0 +1,58 @@ +#ifndef STATISTICSDIALOG_H +#define STATISTICSDIALOG_H + +#include +#include +#include +#include +#include +#include +#include +#include "AttendanceRecord.h" + +/** + * @brief 统计报表对话框 + */ +class StatisticsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit StatisticsDialog(const QVector &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 m_records; + QMap m_statsMap; +}; + +#endif // STATISTICSDIALOG_H diff --git a/include/TimeSlotConfig.h b/include/TimeSlotConfig.h new file mode 100644 index 0000000..a866884 --- /dev/null +++ b/include/TimeSlotConfig.h @@ -0,0 +1,92 @@ +#ifndef TIMESLOTCONFIG_H +#define TIMESLOTCONFIG_H + +#include +#include +#include +#include + +/** + * @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 getTimeSlots() const { return m_timeSlots; } + + // 设置时间段 + void setTimeSlots(const QVector &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 m_timeSlots; + QString m_configFilePath; + + void initDefaultTimeSlots(); +}; + +#endif // TIMESLOTCONFIG_H diff --git a/src/AttendanceRecord.cpp b/src/AttendanceRecord.cpp new file mode 100644 index 0000000..5186d19 --- /dev/null +++ b/src/AttendanceRecord.cpp @@ -0,0 +1,59 @@ +#include "AttendanceRecord.h" +#include + +AttendanceRecord::AttendanceRecord() + : m_confidence(0.0f) + , m_classId("0") +{ +} + +AttendanceRecord::AttendanceRecord(const QString ×tamp, 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 ×tamp) +{ + 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); +} diff --git a/src/FaceManageDialog.cpp b/src/FaceManageDialog.cpp new file mode 100644 index 0000000..8c68a9c --- /dev/null +++ b/src/FaceManageDialog.cpp @@ -0,0 +1,657 @@ +#include "FaceManageDialog.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ============= 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, "删除失败", "服务器返回错误"); + } +} diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp new file mode 100644 index 0000000..c6b10f0 --- /dev/null +++ b/src/MainWindow.cpp @@ -0,0 +1,1589 @@ +#include "MainWindow.h" +#include "SettingsDialog.h" +#include "StatisticsDialog.h" +#include "ServerConfigDialog.h" +#include "MappingConfigDialog.h" +#include "FaceManageDialog.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ============= DialogPositionFilter 实现 ============= + +bool DialogPositionFilter::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::Move) { + QDialog *dialog = qobject_cast(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); +} + +// ============= MainWindow 实现 ============= + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) + , m_tableWidget(nullptr) + , m_refreshButton(nullptr) + , m_exportButton(nullptr) + , m_exportSelectedButton(nullptr) + , m_deleteSelectedButton(nullptr) + , m_clearFilterButton(nullptr) + , m_settingsButton(nullptr) + , m_statsButton(nullptr) + , m_clearRecordsButton(nullptr) + , m_selectAllCheckBox(nullptr) + , m_unselectAllButton(nullptr) + , m_totalLabel(nullptr) + , m_todayLabel(nullptr) + , m_statusLabel(nullptr) + , m_connectionLabel(nullptr) + , m_selectedCountLabel(nullptr) + , m_studentFilterCombo(nullptr) + , m_nameFilterEdit(nullptr) + , m_dateFilterCombo(nullptr) + , m_specificDateEdit(nullptr) + , m_enableSpecificDateCheckBox(nullptr) + , m_apiManager(nullptr) + , m_autoRefreshTimer(nullptr) +{ + // 创建网络API管理器 + m_apiManager = new NetworkApiManager(this); + + // 加载本地映射配置 + loadMappingConfigFromSettings(); + + setupMenuBar(); + setupUI(); + setupConnections(); + + // 首次加载数据 + loadAttendanceData(); + + // 设置自动刷新定时器(10分钟) + m_autoRefreshTimer = new QTimer(this); + connect(m_autoRefreshTimer, &QTimer::timeout, this, &MainWindow::refreshData); + m_autoRefreshTimer->start(600000); // 600000毫秒 = 10分钟自动刷新 +} + +MainWindow::~MainWindow() +{ +} + +void MainWindow::setupMenuBar() +{ + QMenuBar *menuBar = new QMenuBar(this); + setMenuBar(menuBar); + + // 文件菜单 + QMenu *fileMenu = menuBar->addMenu("文件"); + + QAction *exportAction = fileMenu->addAction("导出数据..."); + connect(exportAction, &QAction::triggered, this, &MainWindow::exportToExcel); + + fileMenu->addSeparator(); + + QAction *exitAction = fileMenu->addAction("退出"); + connect(exitAction, &QAction::triggered, this, &QMainWindow::close); + + // 管理菜单 + QMenu *manageMenu = menuBar->addMenu("管理"); + + QAction *settingsAction = manageMenu->addAction("时间段设置..."); + connect(settingsAction, &QAction::triggered, this, &MainWindow::onOpenSettings); + + QAction *mappingAction = manageMenu->addAction("名称映射配置..."); + connect(mappingAction, &QAction::triggered, this, &MainWindow::onOpenMappingConfig); + + QAction *faceManageAction = manageMenu->addAction("人脸管理..."); + connect(faceManageAction, &QAction::triggered, this, &MainWindow::onOpenFaceManage); + + QAction *statsAction = manageMenu->addAction("统计报表..."); + connect(statsAction, &QAction::triggered, this, &MainWindow::onShowStatistics); + + manageMenu->addSeparator(); + + QAction *clearAction = manageMenu->addAction("清空签到记录..."); + connect(clearAction, &QAction::triggered, this, &MainWindow::onClearAllRecords); + + // 帮助菜单 + QMenu *helpMenu = menuBar->addMenu("帮助"); + + QAction *aboutAction = helpMenu->addAction("关于"); + connect(aboutAction, &QAction::triggered, this, &MainWindow::onAbout); +} + +void MainWindow::setupUI() +{ + setWindowTitle("人脸识别考勤管理端"); + resize(1600, 900); + setMinimumSize(1400, 800); + + // ============= 全局样式 ============= + setStyleSheet(R"( + QWidget { + font-family: "Microsoft YaHei UI", "PingFang SC", "Segoe UI", sans-serif; + font-size: 14px; + color: #303133; + } + + QMainWindow { + background-color: #F5F7FA; + } + + /* 输入框和下拉框 */ + QLineEdit, QComboBox, QDateEdit { + padding: 8px 12px; + border: 1px solid #DCDFE6; + border-radius: 4px; + background-color: white; + min-height: 20px; + color: #303133; + } + QLineEdit:focus, QComboBox:focus, QDateEdit:focus { + border: 1px solid #409EFF; + } + QLineEdit::placeholder { + color: #C0C4CC; + } + + /* 下拉框箭头 */ + QComboBox::drop-down { + border: none; + width: 20px; + } + + /* 复选框美化 */ + QCheckBox { + color: #606266; + spacing: 8px; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 3px; + border: 1px solid #DCDFE6; + background-color: white; + } + QCheckBox::indicator:hover { + border-color: #409EFF; + } + QCheckBox::indicator:checked { + background-color: #409EFF; + border-color: #409EFF; + } + + /* 表格样式 */ + QTableWidget { + background-color: #FFFFFF; + alternate-background-color: #FAFAFA; + gridline-color: transparent; + selection-background-color: #ECF5FF; + selection-color: #409EFF; + border: none; + border-radius: 8px; + } + + QTableWidget::item { + padding: 8px; + border-bottom: 1px solid #F0F2F5; + } + + QHeaderView::section { + background-color: #FAFAFA; + border: none; + border-bottom: 2px solid #EBEEF5; + padding: 12px 8px; + font-weight: 600; + color: #909399; + font-size: 13px; + } + + QTableWidget::item:selected { + background-color: #ECF5FF; + } + )"); + + QWidget *centralWidget = new QWidget(this); + centralWidget->setStyleSheet("background-color: #F5F7FA;"); + setCentralWidget(centralWidget); + + QVBoxLayout *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->setSpacing(0); + mainLayout->setContentsMargins(0, 0, 0, 0); + + // ============= 内容区域 ============= + QWidget *contentWidget = new QWidget(); + contentWidget->setStyleSheet("background-color: #F5F7FA; border: none;"); + QVBoxLayout *contentLayout = new QVBoxLayout(contentWidget); + contentLayout->setSpacing(20); + contentLayout->setContentsMargins(24, 24, 24, 24); + + // ============= B. 统计概览卡片区 ============= + QWidget *statsContainer = new QWidget(); + statsContainer->setStyleSheet("background-color: transparent; border: none;"); + QHBoxLayout *statsLayout = new QHBoxLayout(statsContainer); + statsLayout->setSpacing(20); + statsLayout->setContentsMargins(0, 0, 0, 0); + + // 创建统计卡片的辅助函数 + auto createStatCard = [](const QString &iconColor, const QString &label, QLabel **valueLabel) -> QWidget* { + QWidget *card = new QWidget(); + card->setStyleSheet(QString(R"( + QWidget { + background-color: #FFFFFF; + border-radius: 8px; + border: 1px solid #EBEEF5; + } + )")); + + QVBoxLayout *cardLayout = new QVBoxLayout(card); + cardLayout->setSpacing(0); + cardLayout->setContentsMargins(0, 0, 0, 0); + + QWidget *contentWidget = new QWidget(); + contentWidget->setStyleSheet("background: transparent; border: none;"); + QVBoxLayout *layout = new QVBoxLayout(contentWidget); + layout->setSpacing(12); + layout->setContentsMargins(20, 20, 20, 20); + + QHBoxLayout *topLayout = new QHBoxLayout(); + + QLabel *colorBar = new QLabel(); + colorBar->setFixedSize(4, 40); + colorBar->setStyleSheet(QString("background-color: %1; border-radius: 2px;").arg(iconColor)); + topLayout->addWidget(colorBar); + + topLayout->addSpacing(12); + + QVBoxLayout *textLayout = new QVBoxLayout(); + textLayout->setSpacing(4); + + QLabel *labelWidget = new QLabel(label); + labelWidget->setStyleSheet("color: #909399; font-size: 13px; font-weight: normal; background: transparent; border: none;"); + textLayout->addWidget(labelWidget); + + *valueLabel = new QLabel("0"); + (*valueLabel)->setStyleSheet("color: #303133; font-size: 28px; font-weight: bold; background: transparent; border: none;"); + textLayout->addWidget(*valueLabel); + + topLayout->addLayout(textLayout); + topLayout->addStretch(); + + layout->addLayout(topLayout); + cardLayout->addWidget(contentWidget); + + return card; + }; + + QWidget *totalCard = createStatCard("#409EFF", "总记录数", &m_totalLabel); + QWidget *todayCard = createStatCard("#67C23A", "今日签到", &m_todayLabel); + + QWidget *statusCard = new QWidget(); + statusCard->setStyleSheet(R"( + QWidget { + background-color: #FFFFFF; + border-radius: 8px; + border: 1px solid #EBEEF5; + } + )"); + + QVBoxLayout *statusCardLayout = new QVBoxLayout(statusCard); + statusCardLayout->setSpacing(0); + statusCardLayout->setContentsMargins(0, 0, 0, 0); + + QWidget *statusContent = new QWidget(); + statusContent->setStyleSheet("background: transparent; border: none;"); + QVBoxLayout *statusLayout = new QVBoxLayout(statusContent); + statusLayout->setSpacing(12); + statusLayout->setContentsMargins(20, 20, 20, 20); + + QHBoxLayout *statusTopLayout = new QHBoxLayout(); + QLabel *statusColorBar = new QLabel(); + statusColorBar->setFixedSize(4, 40); + statusColorBar->setStyleSheet("background-color: #909399; border-radius: 2px;"); + statusTopLayout->addWidget(statusColorBar); + statusTopLayout->addSpacing(12); + + QVBoxLayout *statusTextLayout = new QVBoxLayout(); + statusTextLayout->setSpacing(4); + + QLabel *statusLabelWidget = new QLabel("系统状态"); + statusLabelWidget->setStyleSheet("color: #909399; font-size: 13px; background: transparent; border: none;"); + statusTextLayout->addWidget(statusLabelWidget); + + m_statusLabel = new QLabel("就绪"); + m_statusLabel->setStyleSheet("color: #303133; font-size: 16px; font-weight: 600; background: transparent; border: none;"); + statusTextLayout->addWidget(m_statusLabel); + + statusTopLayout->addLayout(statusTextLayout); + statusTopLayout->addStretch(); + statusLayout->addLayout(statusTopLayout); + + statusCardLayout->addWidget(statusContent); + + statsLayout->addWidget(totalCard, 1); + statsLayout->addWidget(todayCard, 1); + statsLayout->addWidget(statusCard, 1); + + contentLayout->addWidget(statsContainer); + + // ============= C. 筛选与操作工具栏 ============= + QWidget *toolbarWidget = new QWidget(); + toolbarWidget->setStyleSheet(R"( + QWidget { + background-color: #FFFFFF; + border-radius: 8px; + border: 1px solid #EBEEF5; + } + )"); + + QWidget *toolbarContent = new QWidget(); + toolbarContent->setStyleSheet("background: transparent; border: none;"); + QVBoxLayout *toolbarMainLayout = new QVBoxLayout(toolbarWidget); + toolbarMainLayout->setContentsMargins(0, 0, 0, 0); + toolbarMainLayout->addWidget(toolbarContent); + + QHBoxLayout *toolbarLayout = new QHBoxLayout(toolbarContent); + toolbarLayout->setContentsMargins(20, 16, 20, 16); + toolbarLayout->setSpacing(12); + + // 左侧筛选组 - 班级筛选 + QLabel *filterLabelClass = new QLabel("班级:"); + filterLabelClass->setStyleSheet("color: #606266; font-weight: 600; background: transparent; border: none;"); + toolbarLayout->addWidget(filterLabelClass); + + m_classFilterCombo = new QComboBox(); + m_classFilterCombo->setMinimumWidth(100); + m_classFilterCombo->addItem("全部班级"); + toolbarLayout->addWidget(m_classFilterCombo); + + toolbarLayout->addSpacing(8); + + // 学号筛选 + QLabel *filterLabel1 = new QLabel("学号:"); + filterLabel1->setStyleSheet("color: #606266; font-weight: 600; background: transparent; border: none;"); + toolbarLayout->addWidget(filterLabel1); + + m_studentFilterCombo = new QComboBox(); + m_studentFilterCombo->setMinimumWidth(120); + m_studentFilterCombo->addItem("全部学号"); + toolbarLayout->addWidget(m_studentFilterCombo); + + toolbarLayout->addSpacing(8); + + QLabel *filterLabel2 = new QLabel("姓名:"); + filterLabel2->setStyleSheet("color: #606266; font-weight: 600; background: transparent; border: none;"); + toolbarLayout->addWidget(filterLabel2); + + m_nameFilterEdit = new QLineEdit(); + m_nameFilterEdit->setPlaceholderText("输入姓名..."); + m_nameFilterEdit->setMinimumWidth(140); + toolbarLayout->addWidget(m_nameFilterEdit); + + toolbarLayout->addSpacing(8); + + QLabel *filterLabel3 = new QLabel("日期:"); + filterLabel3->setStyleSheet("color: #606266; font-weight: 600; background: transparent; border: none;"); + toolbarLayout->addWidget(filterLabel3); + + m_dateFilterCombo = new QComboBox(); + m_dateFilterCombo->setMinimumWidth(130); + m_dateFilterCombo->addItem("全部时间"); + m_dateFilterCombo->addItem("今天"); + m_dateFilterCombo->addItem("最近7天"); + m_dateFilterCombo->addItem("最近30天"); + m_dateFilterCombo->setCurrentIndex(1); // 默认选中"今天" + toolbarLayout->addWidget(m_dateFilterCombo); + + m_specificDateEdit = new QDateEdit(); + m_specificDateEdit->setCalendarPopup(true); + m_specificDateEdit->setDate(QDate::currentDate()); + m_specificDateEdit->setDisplayFormat("yyyy-MM-dd"); + m_specificDateEdit->setMinimumWidth(130); + m_specificDateEdit->setVisible(false); + toolbarLayout->addWidget(m_specificDateEdit); + + m_enableSpecificDateCheckBox = new QCheckBox(); + m_enableSpecificDateCheckBox->setVisible(false); + + m_clearFilterButton = new QPushButton("清除筛选"); + m_clearFilterButton->setStyleSheet(R"( + QPushButton { + background-color: white; + color: #606266; + border: 1px solid #DCDFE6; + border-radius: 4px; + padding: 9px 20px; + font-weight: 600; + min-width: 90px; + } + QPushButton:hover { + color: #409EFF; + border-color: #409EFF; + background-color: #ECF5FF; + } + )"); + toolbarLayout->addWidget(m_clearFilterButton); + + toolbarLayout->addStretch(); + + // 右侧操作组(使用Qt内置图标) + m_refreshButton = new QPushButton("刷新数据"); + m_refreshButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload)); + m_refreshButton->setObjectName("btnRefresh"); + m_refreshButton->setStyleSheet(R"( + QPushButton { + background-color: #409EFF; + color: white; + border-radius: 4px; + padding: 9px 20px; + border: none; + font-weight: 600; + min-width: 100px; + } + QPushButton:hover { + background-color: #66B1FF; + } + QPushButton:pressed { + background-color: #3A8EE6; + } + )"); + toolbarLayout->addWidget(m_refreshButton); + + m_exportButton = new QPushButton("导出"); + m_exportButton->setIcon(style()->standardIcon(QStyle::SP_DialogSaveButton)); + m_exportButton->setStyleSheet(R"( + QPushButton { + background-color: white; + color: #606266; + border: 1px solid #DCDFE6; + border-radius: 4px; + padding: 9px 20px; + font-weight: 600; + min-width: 90px; + } + QPushButton:hover { + color: #409EFF; + border-color: #409EFF; + background-color: #ECF5FF; + } + )"); + toolbarLayout->addWidget(m_exportButton); + + m_deleteSelectedButton = new QPushButton("批量删除"); + m_deleteSelectedButton->setIcon(style()->standardIcon(QStyle::SP_TrashIcon)); + m_deleteSelectedButton->setObjectName("btnDelete"); + m_deleteSelectedButton->setEnabled(false); // 默认禁用 + m_deleteSelectedButton->setStyleSheet(R"( + QPushButton { + background-color: #FEF0F0; + color: #F56C6C; + border: 1px solid #FAB6B6; + border-radius: 4px; + padding: 9px 20px; + font-weight: 600; + min-width: 100px; + } + QPushButton:hover:enabled { + background-color: #F56C6C; + color: white; + } + QPushButton:disabled { + background-color: #FFF; + color: #C0C4CC; + border-color: #EBEEF5; + } + )"); + toolbarLayout->addWidget(m_deleteSelectedButton); + + contentLayout->addWidget(toolbarWidget); + + // ============= 全选控制栏 ============= + QWidget *selectAllWidget = new QWidget(); + selectAllWidget->setStyleSheet("background-color: transparent; border: none;"); + QHBoxLayout *selectAllLayout = new QHBoxLayout(selectAllWidget); + selectAllLayout->setContentsMargins(0, 0, 0, 0); + + m_selectAllCheckBox = new QCheckBox("全选"); + m_selectAllCheckBox->setStyleSheet(R"( + QCheckBox { + color: #606266; + font-weight: 600; + background: transparent; + border: none; + } + QCheckBox::indicator { + width: 18px; + height: 18px; + border-radius: 3px; + border: 1px solid #DCDFE6; + background-color: white; + } + QCheckBox::indicator:hover { + border-color: #409EFF; + } + QCheckBox::indicator:checked { + background-color: #409EFF; + border-color: #409EFF; + } + )"); + selectAllLayout->addWidget(m_selectAllCheckBox); + + m_unselectAllButton = new QPushButton("取消选中"); + m_unselectAllButton->setStyleSheet(R"( + QPushButton { + background-color: white; + color: #606266; + border: 1px solid #DCDFE6; + border-radius: 4px; + padding: 6px 16px; + font-weight: 600; + min-width: 80px; + } + QPushButton:hover { + color: #409EFF; + border-color: #409EFF; + background-color: #ECF5FF; + } + )"); + selectAllLayout->addWidget(m_unselectAllButton); + + m_selectedCountLabel = new QLabel("已选中: 0 条"); + m_selectedCountLabel->setStyleSheet(R"( + color: #909399; + font-size: 13px; + background: transparent; + border: none; + margin-left: 12px; + )"); + selectAllLayout->addWidget(m_selectedCountLabel); + + selectAllLayout->addStretch(); + + QLabel *autoRefreshLabel = new QLabel("自动刷新: 每10分钟"); + autoRefreshLabel->setStyleSheet(R"( + color: #67C23A; + font-size: 12px; + background: transparent; + border: none; + )"); + selectAllLayout->addWidget(autoRefreshLabel); + + contentLayout->addWidget(selectAllWidget); + + // ============= D. 数据表格 ============= + m_tableWidget = new QTableWidget(0, 7); + QStringList headers; + headers << "" << "签到时间" << "班级" << "学号" << "姓名" << "置信度" << "状态"; + m_tableWidget->setHorizontalHeaderLabels(headers); + + m_tableWidget->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_tableWidget->setSelectionBehavior(QAbstractItemView::SelectRows); + m_tableWidget->setSelectionMode(QAbstractItemView::SingleSelection); + m_tableWidget->setAlternatingRowColors(true); + m_tableWidget->horizontalHeader()->setStretchLastSection(true); + m_tableWidget->verticalHeader()->setVisible(false); + m_tableWidget->setShowGrid(false); + + // 设置列宽 + m_tableWidget->setColumnWidth(0, 50); // 复选框 + m_tableWidget->setColumnWidth(1, 180); // 签到时间 + m_tableWidget->setColumnWidth(2, 80); // 班级 + m_tableWidget->setColumnWidth(3, 100); // 学号 + m_tableWidget->setColumnWidth(4, 100); // 姓名 + m_tableWidget->setColumnWidth(5, 150); // 置信度 + m_tableWidget->setColumnWidth(6, 100); // 状态 + + // 设置行高为45px(关键修复) + m_tableWidget->verticalHeader()->setDefaultSectionSize(45); + + m_tableWidget->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter); + m_tableWidget->setMinimumHeight(400); + + contentLayout->addWidget(m_tableWidget, 1); + + mainLayout->addWidget(contentWidget); + + // 隐藏不需要的组件 + m_exportSelectedButton = new QPushButton(); + m_exportSelectedButton->setVisible(false); + + m_settingsButton = new QPushButton(); + m_settingsButton->setVisible(false); + + m_statsButton = new QPushButton(); + m_statsButton->setVisible(false); + + m_clearRecordsButton = new QPushButton(); + m_clearRecordsButton->setVisible(false); +} + +void MainWindow::setupConnections() +{ + // 按钮连接 + connect(m_refreshButton, &QPushButton::clicked, this, &MainWindow::refreshData); + connect(m_exportButton, &QPushButton::clicked, this, &MainWindow::exportToExcel); + connect(m_exportSelectedButton, &QPushButton::clicked, this, &MainWindow::exportSelectedRecords); + connect(m_deleteSelectedButton, &QPushButton::clicked, this, &MainWindow::deleteSelectedRecords); + connect(m_clearFilterButton, &QPushButton::clicked, this, &MainWindow::clearFilter); + connect(m_settingsButton, &QPushButton::clicked, this, &MainWindow::onOpenSettings); + connect(m_statsButton, &QPushButton::clicked, this, &MainWindow::onShowStatistics); + connect(m_clearRecordsButton, &QPushButton::clicked, this, &MainWindow::onClearAllRecords); + + // 全选复选框 + connect(m_selectAllCheckBox, &QCheckBox::stateChanged, this, &MainWindow::toggleSelectAll); + + // 取消选中按钮 + connect(m_unselectAllButton, &QPushButton::clicked, this, &MainWindow::unselectAll); + + // 筛选连接 + connect(m_classFilterCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &MainWindow::filterData); + connect(m_studentFilterCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &MainWindow::filterData); + connect(m_nameFilterEdit, &QLineEdit::textChanged, this, &MainWindow::filterData); + connect(m_dateFilterCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &MainWindow::filterData); + + // 特定日期筛选连接 + connect(m_enableSpecificDateCheckBox, &QCheckBox::stateChanged, this, [this](int state) { + m_specificDateEdit->setEnabled(state == Qt::Checked); + if (state == Qt::Checked) { + m_dateFilterCombo->setEnabled(false); // 禁用日期范围选择 + } else { + m_dateFilterCombo->setEnabled(true); // 启用日期范围选择 + } + filterData(); + }); + connect(m_specificDateEdit, &QDateEdit::dateChanged, this, &MainWindow::filterData); + + // 网络API连接 + connect(m_apiManager, &NetworkApiManager::recordsFetched, + this, &MainWindow::onRecordsFetched); + connect(m_apiManager, &NetworkApiManager::statisticsFetched, + this, &MainWindow::onStatisticsFetched); + connect(m_apiManager, &NetworkApiManager::recordsCleared, + this, &MainWindow::onRecordsCleared); + connect(m_apiManager, &NetworkApiManager::recordDeleted, + this, &MainWindow::onRecordDeleted); + connect(m_apiManager, &NetworkApiManager::errorOccurred, + this, &MainWindow::onNetworkError); + connect(m_apiManager, &NetworkApiManager::healthChecked, + this, &MainWindow::showConnectionStatus); + + // 映射配置信号连接 + connect(m_apiManager, &NetworkApiManager::mappingConfigFetched, + this, &MainWindow::onMappingConfigFetched); + connect(m_apiManager, &NetworkApiManager::mappingConfigUploaded, + this, &MainWindow::onMappingConfigUploaded); +} + +void MainWindow::loadAttendanceData() +{ + m_statusLabel->setText("状态: 加载中..."); + m_apiManager->fetchRecords(1000, 0); // 获取最新1000条记录 + m_apiManager->fetchStatistics(); // 获取统计信息 +} + +void MainWindow::refreshData() +{ + qDebug() << "刷新数据..."; + loadAttendanceData(); +} + +void MainWindow::onRecordsFetched(const QVector &records) +{ + qDebug() << "收到" << records.size() << "条记录"; + m_allRecords = records; + + // 更新班级筛选下拉框 + QSet classIds; + for (const AttendanceRecord &record : m_allRecords) { + classIds.insert(record.getClassId()); + } + + m_classFilterCombo->clear(); + m_classFilterCombo->addItem("全部"); + QStringList sortedClassIds = classIds.values(); + sortedClassIds.sort(); + m_classFilterCombo->addItems(sortedClassIds); + + // 更新学号筛选下拉框 + QSet studentIds; + for (const AttendanceRecord &record : m_allRecords) { + studentIds.insert(record.getStudentId()); + } + + m_studentFilterCombo->clear(); + m_studentFilterCombo->addItem("全部"); + QStringList sortedIds = studentIds.values(); + sortedIds.sort(); + m_studentFilterCombo->addItems(sortedIds); + + // 应用筛选 + filterData(); + + m_statusLabel->setText(QString("状态: ✓ 已加载 %1 条记录").arg(m_allRecords.size())); + m_statusLabel->setStyleSheet("color: green;"); + showConnectionStatus(true); +} + +void MainWindow::onStatisticsFetched(int total, int today, const QString &serverTime) +{ + updateStatistics(total, today); + qDebug() << "服务器时间:" << serverTime; +} + +void MainWindow::onRecordsCleared(bool success) +{ + if (success) { + QMessageBox::information(this, "成功", "所有签到记录已清空!"); + refreshData(); + } else { + QMessageBox::critical(this, "失败", "清空记录失败,请检查服务器!"); + } +} + +void MainWindow::onRecordDeleted(bool success) +{ + if (success) { + m_statusLabel->setText("状态: ✓ 记录已删除"); + m_statusLabel->setStyleSheet("color: green;"); + refreshData(); // 刷新数据 + } else { + QMessageBox::warning(this, "失败", "删除记录失败,请重试!"); + } +} + +void MainWindow::onNetworkError(const QString &error) +{ + qWarning() << "网络错误:" << error; + m_statusLabel->setText("状态: " + error); + showConnectionStatus(false); + + // 不要弹出错误对话框,只在状态栏显示 + // 因为可能只是某个请求失败,数据可能已经加载成功了 +} + +void MainWindow::filterData() +{ + m_filteredRecords.clear(); + + QString selectedClassId = m_classFilterCombo->currentText(); + QString selectedStudentId = m_studentFilterCombo->currentText(); + QString nameFilter = m_nameFilterEdit->text().trimmed(); + QString dateFilter = m_dateFilterCombo->currentText(); + + QDateTime now = QDateTime::currentDateTime(); + QDate today = now.date(); + + for (const AttendanceRecord &record : m_allRecords) { + // 班级筛选 + if (selectedClassId != "全部" && record.getClassId() != selectedClassId) { + continue; + } + + // 学号筛选 + if (selectedStudentId != "全部" && record.getStudentId() != selectedStudentId) { + continue; + } + + // 姓名筛选 + if (!nameFilter.isEmpty() && !record.getName().contains(nameFilter, Qt::CaseInsensitive)) { + continue; + } + + // 日期筛选 + QDate recordDate = record.getDateTime().date(); + + // 如果启用了特定日期筛选 + if (m_enableSpecificDateCheckBox->isChecked()) { + QDate specificDate = m_specificDateEdit->date(); + if (recordDate != specificDate) { + continue; + } + } else if (dateFilter != "全部") { + // 使用日期范围筛选 + if (dateFilter == "今天" && recordDate != today) { + continue; + } else if (dateFilter == "最近7天" && recordDate.daysTo(today) > 7) { + continue; + } else if (dateFilter == "最近30天" && recordDate.daysTo(today) > 30) { + continue; + } + } + + m_filteredRecords.append(record); + } + + updateTable(m_filteredRecords); +} + +void MainWindow::updateTable(const QVector &records) +{ + qDebug() << "更新表格,显示" << records.size() << "条记录"; + + // 在重建表格前,保存当前选中的记录 + m_selectedRecordKeys.clear(); + for (int row = 0; row < m_tableWidget->rowCount(); ++row) { + QWidget *widget = m_tableWidget->cellWidget(row, 0); + if (!widget) continue; + QCheckBox *checkBox = widget->findChild(); + + if (checkBox && checkBox->isChecked()) { + QTableWidgetItem *timestampItem = m_tableWidget->item(row, 1); + QTableWidgetItem *studentIdItem = m_tableWidget->item(row, 2); + if (timestampItem && studentIdItem) { + QString timestamp = timestampItem->text(); + QString studentId = studentIdItem->text(); + QString key = timestamp + "|" + studentId; + m_selectedRecordKeys.insert(key); + } + } + } + + // 暂时阻止表格更新,提高性能 + m_tableWidget->setUpdatesEnabled(false); + m_tableWidget->setRowCount(0); + + // 倒序显示(最新的在最上面) + for (int i = records.size() - 1; i >= 0; --i) { + const AttendanceRecord &record = records[i]; + + int row = m_tableWidget->rowCount(); + m_tableWidget->insertRow(row); + + // 第0列:复选框 + QCheckBox *checkBox = new QCheckBox(); + QWidget *checkBoxWidget = new QWidget(); + QHBoxLayout *checkBoxLayout = new QHBoxLayout(checkBoxWidget); + checkBoxLayout->addWidget(checkBox); + checkBoxLayout->setAlignment(Qt::AlignCenter); + checkBoxLayout->setContentsMargins(0, 0, 0, 0); + m_tableWidget->setCellWidget(row, 0, checkBoxWidget); + + // 使用更轻量级的信号连接方式,避免每次都遍历整个表格 + connect(checkBox, &QCheckBox::stateChanged, this, [this]() { + // 使用定时器延迟更新,避免频繁计算 + static QTimer *updateTimer = nullptr; + if (!updateTimer) { + updateTimer = new QTimer(this); + updateTimer->setSingleShot(true); + updateTimer->setInterval(100); // 100ms延迟 + connect(updateTimer, &QTimer::timeout, this, [this]() { + int selectedCount = 0; + for (int r = 0; r < m_tableWidget->rowCount(); ++r) { + QWidget *widget = m_tableWidget->cellWidget(r, 0); + if (!widget) continue; + QCheckBox *cb = widget->findChild(); + if (cb && cb->isChecked()) { + selectedCount++; + } + } + m_selectedCountLabel->setText(QString("已选中: %1 条").arg(selectedCount)); + + // 更新批量删除按钮的启用状态 + m_deleteSelectedButton->setEnabled(selectedCount > 0); + + // 更新全选复选框的状态 + if (selectedCount == 0) { + m_selectAllCheckBox->blockSignals(true); + m_selectAllCheckBox->setChecked(false); + m_selectAllCheckBox->blockSignals(false); + } else if (selectedCount == m_tableWidget->rowCount()) { + m_selectAllCheckBox->blockSignals(true); + m_selectAllCheckBox->setChecked(true); + m_selectAllCheckBox->blockSignals(false); + } + }); + } + updateTimer->start(); + }); + + // 第1列:签到时间 + m_tableWidget->setItem(row, 1, new QTableWidgetItem(record.getTimestamp())); + + // 第2列:班级(应用映射) + QString displayClassName = getMappedClassName(record.getClassId()); + QTableWidgetItem *classItem = new QTableWidgetItem(displayClassName); + classItem->setTextAlignment(Qt::AlignCenter); + m_tableWidget->setItem(row, 2, classItem); + + // 第3列:学号 + m_tableWidget->setItem(row, 3, new QTableWidgetItem(record.getStudentId())); + + // 第4列:姓名(应用映射) + QString displayName = getMappedStudentName(record.getStudentId(), record.getName()); + m_tableWidget->setItem(row, 4, new QTableWidgetItem(displayName)); + + // 第5列:置信度 + QString confidenceStr = QString::number(record.getConfidence() * 100, 'f', 2) + "%"; + QTableWidgetItem *confItem = new QTableWidgetItem(confidenceStr); + confItem->setTextAlignment(Qt::AlignCenter); + m_tableWidget->setItem(row, 5, confItem); + + // 第6列:状态 + QTableWidgetItem *statusItem = new QTableWidgetItem(record.getStatus()); + statusItem->setTextAlignment(Qt::AlignCenter); + if (record.getStatus() == "success") { + statusItem->setForeground(QBrush(Qt::darkGreen)); + statusItem->setText("✓ 成功"); + } else { + statusItem->setForeground(QBrush(Qt::red)); + statusItem->setText("✗ 失败"); + } + m_tableWidget->setItem(row, 6, statusItem); + + // 恢复选中状态 + QString key = record.getTimestamp() + "|" + record.getStudentId(); + if (m_selectedRecordKeys.contains(key)) { + checkBox->blockSignals(true); + checkBox->setChecked(true); + checkBox->blockSignals(false); + } + } + + // 恢复表格更新 + m_tableWidget->setUpdatesEnabled(true); + + // 更新选中计数(不触发信号) + int selectedCount = 0; + for (int r = 0; r < m_tableWidget->rowCount(); ++r) { + QWidget *widget = m_tableWidget->cellWidget(r, 0); + if (!widget) continue; + QCheckBox *cb = widget->findChild(); + if (cb && cb->isChecked()) { + selectedCount++; + } + } + m_selectedCountLabel->setText(QString("已选中: %1 条").arg(selectedCount)); + + // 更新全选复选框的状态 + m_selectAllCheckBox->blockSignals(true); + if (selectedCount == 0) { + m_selectAllCheckBox->setChecked(false); + } else if (selectedCount == m_tableWidget->rowCount() && m_tableWidget->rowCount() > 0) { + m_selectAllCheckBox->setChecked(true); + } + m_selectAllCheckBox->blockSignals(false); + + // 如果有筛选,显示筛选后的数量 + if (m_filteredRecords.size() != m_allRecords.size()) { + m_statusLabel->setText(QString("状态: 显示 %1/%2 条记录") + .arg(m_filteredRecords.size()) + .arg(m_allRecords.size())); + m_statusLabel->setStyleSheet("color: blue;"); + } else if (!m_allRecords.isEmpty()) { + m_statusLabel->setText(QString("状态: ✓ 已加载 %1 条记录").arg(m_allRecords.size())); + m_statusLabel->setStyleSheet("color: green;"); + } +} + +void MainWindow::updateStatistics(int total, int today) +{ + m_totalLabel->setText(QString("总记录数: %1").arg(total)); + m_todayLabel->setText(QString("今日签到: %1").arg(today)); +} + +void MainWindow::showConnectionStatus(bool connected) +{ + // 不再显示连接状态,函数保留以避免破坏其他调用 + Q_UNUSED(connected); +} + +void MainWindow::exportToExcel() +{ + if (m_filteredRecords.isEmpty()) { + QMessageBox::information(this, "提示", "没有数据可导出!"); + return; + } + + QString defaultFileName = QString("attendance_export_%1.csv") + .arg(QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss")); + + QString fileName = QFileDialog::getSaveFileName( + this, + QString("导出考勤记录 (共 %1 条筛选结果)").arg(m_filteredRecords.size()), + defaultFileName, + "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 << "# 考勤记录导出\n"; + out << "# 导出时间: " << QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss") << "\n"; + out << "# 记录总数: " << m_filteredRecords.size() << "\n"; + out << "#\n"; + out << "签到时间,学号,姓名,置信度,状态\n"; + + for (const AttendanceRecord &record : m_filteredRecords) { + out << record.getTimestamp() << "," + << record.getStudentId() << "," + << record.getName() << "," + << QString::number(record.getConfidence(), 'f', 4) << "," + << record.getStatus() << "\n"; + } + + file.close(); + + QMessageBox::information(this, "导出成功", + QString("✓ 成功导出 %1 条筛选记录到:\n\n%2\n\n" + "提示:此文件包含当前筛选条件的所有结果") + .arg(m_filteredRecords.size()) + .arg(fileName)); +} + +void MainWindow::clearFilter() +{ + m_classFilterCombo->setCurrentIndex(0); + m_studentFilterCombo->setCurrentIndex(0); + m_nameFilterEdit->clear(); + m_dateFilterCombo->setCurrentIndex(0); + m_enableSpecificDateCheckBox->setChecked(false); // 取消特定日期筛选 + m_specificDateEdit->setDate(QDate::currentDate()); // 重置为今天 + filterData(); +} + +void MainWindow::deleteSelectedRecord() +{ + int currentRow = m_tableWidget->currentRow(); + if (currentRow < 0) { + QMessageBox::information(this, "提示", "请先选择要删除的记录!"); + return; + } + + // 获取选中记录的信息 + QString timestamp = m_tableWidget->item(currentRow, 1)->text(); + QString studentId = m_tableWidget->item(currentRow, 2)->text(); + QString name = m_tableWidget->item(currentRow, 3)->text(); + + // 确认删除 + QMessageBox::StandardButton reply = QMessageBox::question( + this, + "确认删除", + QString("确定要删除以下记录吗?\n\n" + "时间: %1\n" + "学号: %2\n" + "姓名: %3") + .arg(timestamp) + .arg(studentId) + .arg(name), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No + ); + + if (reply == QMessageBox::Yes) { + m_statusLabel->setText("状态: 正在删除..."); + m_apiManager->deleteRecord(timestamp, studentId); + } +} + +void MainWindow::toggleSelectAll() +{ + bool checked = m_selectAllCheckBox->isChecked(); + + // 设置所有行的复选框状态 + for (int row = 0; row < m_tableWidget->rowCount(); ++row) { + QWidget *widget = m_tableWidget->cellWidget(row, 0); + QCheckBox *checkBox = widget->findChild(); + if (checkBox) { + checkBox->setChecked(checked); + } + } + + // 更新选中计数 + if (checked) { + m_selectedCountLabel->setText(QString("已选中: %1 条").arg(m_tableWidget->rowCount())); + } else { + m_selectedCountLabel->setText("已选中: 0 条"); + } +} + +void MainWindow::unselectAll() +{ + // 取消所有行的复选框状态 + for (int row = 0; row < m_tableWidget->rowCount(); ++row) { + QWidget *widget = m_tableWidget->cellWidget(row, 0); + QCheckBox *checkBox = widget->findChild(); + if (checkBox) { + checkBox->setChecked(false); + } + } + + // 更新选中计数 + m_selectedCountLabel->setText("已选中: 0 条"); + + // 重置全选复选框状态 + m_selectAllCheckBox->blockSignals(true); + m_selectAllCheckBox->setChecked(false); + m_selectAllCheckBox->blockSignals(false); +} + +void MainWindow::deleteSelectedRecords() +{ + // 获取所有选中的记录 + QVector> selectedRecords; // + + for (int row = 0; row < m_tableWidget->rowCount(); ++row) { + QWidget *widget = m_tableWidget->cellWidget(row, 0); + if (!widget) continue; // 跳过空widget + + QCheckBox *checkBox = widget->findChild(); + + if (checkBox && checkBox->isChecked()) { + QTableWidgetItem *timestampItem = m_tableWidget->item(row, 1); + QTableWidgetItem *studentIdItem = m_tableWidget->item(row, 2); + + // 检查表格项是否存在 + if (timestampItem && studentIdItem) { + QString timestamp = timestampItem->text(); + QString studentId = studentIdItem->text(); + selectedRecords.append(qMakePair(timestamp, studentId)); + } + } + } + + if (selectedRecords.isEmpty()) { + QMessageBox::information(this, "提示", "请先选择要删除的记录!"); + return; + } + + // 确认删除 + QMessageBox::StandardButton reply = QMessageBox::warning( + this, + "确认批量删除", + QString("确定要删除选中的 %1 条记录吗?\n\n此操作不可恢复!") + .arg(selectedRecords.size()), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No + ); + + if (reply == QMessageBox::Yes) { + m_statusLabel->setText(QString("状态: 正在删除 %1 条记录...").arg(selectedRecords.size())); + + // 批量删除(逐个调用API) + int deletedCount = 0; + for (const auto &record : selectedRecords) { + m_apiManager->deleteRecord(record.first, record.second); + deletedCount++; + } + + QMessageBox::information(this, "删除完成", + QString("已发送 %1 条删除请求,请稍候刷新查看结果。").arg(deletedCount)); + + // 延迟刷新数据 + QTimer::singleShot(1000, this, &MainWindow::refreshData); + } +} + +void MainWindow::exportSelectedRecords() +{ + // 获取所有选中的记录 + QVector selectedRecords; + + for (int row = 0; row < m_tableWidget->rowCount(); ++row) { + QWidget *widget = m_tableWidget->cellWidget(row, 0); + if (!widget) continue; // 跳过空widget + + QCheckBox *checkBox = widget->findChild(); + + if (checkBox && checkBox->isChecked()) { + QTableWidgetItem *timestampItem = m_tableWidget->item(row, 1); + QTableWidgetItem *studentIdItem = m_tableWidget->item(row, 2); + + // 检查表格项是否存在 + if (timestampItem && studentIdItem) { + QString timestamp = timestampItem->text(); + QString studentId = studentIdItem->text(); + + // 从筛选后的记录中找到对应的完整记录 + for (const AttendanceRecord &record : m_filteredRecords) { + if (record.getTimestamp() == timestamp && record.getStudentId() == studentId) { + selectedRecords.append(record); + break; + } + } + } + } + } + + if (selectedRecords.isEmpty()) { + QMessageBox::information(this, "提示", "请先选择要导出的记录!"); + return; + } + + QString defaultFileName = QString("attendance_selected_%1.csv") + .arg(QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss")); + + QString fileName = QFileDialog::getSaveFileName( + this, + QString("导出选中的记录 (共 %1 条)").arg(selectedRecords.size()), + defaultFileName, + "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 << "# 考勤记录导出(选中记录)\n"; + out << "# 导出时间: " << QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss") << "\n"; + out << "# 记录总数: " << selectedRecords.size() << "\n"; + out << "#\n"; + out << "签到时间,学号,姓名,置信度,状态\n"; + + for (const AttendanceRecord &record : selectedRecords) { + out << record.getTimestamp() << "," + << record.getStudentId() << "," + << record.getName() << "," + << QString::number(record.getConfidence(), 'f', 4) << "," + << record.getStatus() << "\n"; + } + + file.close(); + + QMessageBox::information(this, "导出成功", + QString("✓ 成功导出 %1 条选中记录到:\n\n%2") + .arg(selectedRecords.size()) + .arg(fileName)); +} + +void MainWindow::onClearAllRecords() +{ + QMessageBox::StandardButton reply = QMessageBox::warning( + this, + "⚠️ 危险操作", + "确定要清空服务器上的所有签到记录吗?\n\n" + "此操作将删除服务器CSV文件中的所有数据,此操作不可恢复!\n\n" + "建议:在清空前先导出数据备份。", + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No + ); + + if (reply == QMessageBox::Yes) { + QMessageBox::StandardButton confirm = QMessageBox::question( + this, + "最后确认", + "请再次确认:确定要清空所有记录吗?", + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No + ); + + if (confirm == QMessageBox::Yes) { + m_apiManager->clearAllRecords(); + } + } +} + +void MainWindow::onOpenSettings() +{ + SettingsDialog dialog(this); + + // 安装事件过滤器,限制对话框在父窗口内移动 + DialogPositionFilter *filter = new DialogPositionFilter(&dialog); + dialog.installEventFilter(filter); + + if (dialog.exec() == QDialog::Accepted) { + QMessageBox::information( + this, + "提示", + "时间段设置已保存!\n\n" + "注意:如需应用到STM32设备,请重新配置STM32端的时间段设置。" + ); + } +} + +void MainWindow::onOpenMappingConfig() +{ + MappingConfigDialog *dialog = new MappingConfigDialog(this); + + // 从签到记录中收集所有学号,确保都显示在映射表中 + QMap combinedStudentMap = m_studentNameMap; + for (const AttendanceRecord &record : m_allRecords) { + QString studentId = record.getStudentId(); + if (!combinedStudentMap.contains(studentId)) { + combinedStudentMap[studentId] = ""; // 没有映射的学号,姓名为空白 + } + } + + // 从签到记录中收集所有班级编号,确保都显示在映射表中 + QMap combinedClassMap = m_classNameMap; + for (const AttendanceRecord &record : m_allRecords) { + QString classId = record.getClassId(); + if (!combinedClassMap.contains(classId)) { + combinedClassMap[classId] = ""; // 没有映射的班级编号,名称为空白 + } + } + + // 设置当前映射数据(包含所有出现过的学号和班级) + dialog->setStudentNameMap(combinedStudentMap); + dialog->setClassNameMap(combinedClassMap); + + // 连接信号:请求从服务器加载 + connect(dialog, &MappingConfigDialog::requestSyncFromServer, this, [this, dialog]() { + // 从服务器获取最新配置 + m_apiManager->fetchMappingConfig(); + dialog->setStatus("正在从服务器加载..."); + }); + + // 自动从服务器加载最新配置 + m_apiManager->fetchMappingConfig(); + + if (dialog->exec() == QDialog::Accepted) { + // 从表格收集数据 + dialog->collectDataFromTables(); + + // 获取修改后的映射数据 + m_studentNameMap = dialog->getStudentNameMap(); + m_classNameMap = dialog->getClassNameMap(); + + // 保存到本地设置 + saveMappingConfigToSettings(); + + // 同步到服务器 + m_apiManager->uploadMappingConfig(m_studentNameMap, m_classNameMap); + + // 刷新表格显示 + filterData(); + + m_statusLabel->setText("状态: 映射配置已保存"); + } + + delete dialog; +} + +void MainWindow::onOpenFaceManage() +{ + FaceManageDialog *dialog = new FaceManageDialog(this); + + // 设置服务器地址 + dialog->setServerUrl(m_apiManager->getServerUrl()); + + // 连接保存信号 + connect(dialog, &FaceManageDialog::mappingSaved, this, [this]() { + m_statusLabel->setText("状态: 人脸ID映射已更新"); + }); + + dialog->exec(); + + delete dialog; +} + +void MainWindow::onShowStatistics() +{ + if (m_allRecords.isEmpty()) { + QMessageBox::information(this, "提示", "暂无签到记录,无法生成统计报表!"); + return; + } + + StatisticsDialog dialog(m_allRecords, this); + + // 安装事件过滤器,限制对话框在父窗口内移动 + DialogPositionFilter *filter = new DialogPositionFilter(&dialog); + dialog.installEventFilter(filter); + + dialog.exec(); +} + +void MainWindow::onAbout() +{ + QMessageBox::about( + this, + "关于 - 人脸识别考勤系统", + "

人脸识别考勤系统 - 网络管理端

" + "

版本: 2.0.0 (网络版)

" + "

基于: Qt 5.15 + C++17

" + "
" + "

主要功能:

" + "
    " + "
  • ✓ 通过网络API实时查看签到记录
  • " + "
  • ✓ 多条件筛选和搜索
  • " + "
  • ✓ 自定义签到时间段
  • " + "
  • ✓ 统计报表生成
  • " + "
  • ✓ 数据导出备份
  • " + "
  • ✓ 远程记录管理
  • " + "
  • ✓ 服务器配置管理
  • " + "
" + "
" + "

网络架构:

" + "

服务器地址: http://192.168.1.100:8082

" + "

客户端通过HTTP API连接服务器

" + "

支持远程访问,无需在同一台电脑

" + "
" + "

硬件支持:

" + "

STM32F103C8T6 + K210 + ESP8266

" + "
" + "

© 2026 STM32 Face Recognition Project

" + ); +} + +// ============= 映射配置相关函数 ============= + +QString MainWindow::getMappedStudentName(const QString &studentId, const QString &originalName) const +{ + Q_UNUSED(originalName); + if (m_studentNameMap.contains(studentId)) { + return m_studentNameMap[studentId]; + } + return ""; // 如果没有映射,返回空白 +} + +QString MainWindow::getMappedClassName(const QString &classId) const +{ + if (m_classNameMap.contains(classId)) { + return m_classNameMap[classId]; + } + return classId; // 如果没有映射,返回原始编号 +} + +void MainWindow::loadMappingConfigFromSettings() +{ + QSettings settings; + + // 加载学号-姓名映射 + int studentCount = settings.beginReadArray("studentNameMap"); + for (int i = 0; i < studentCount; ++i) { + settings.setArrayIndex(i); + QString id = settings.value("id").toString(); + QString name = settings.value("name").toString(); + m_studentNameMap[id] = name; + } + settings.endArray(); + + // 加载班级编号-名称映射 + int classCount = settings.beginReadArray("classNameMap"); + for (int i = 0; i < classCount; ++i) { + settings.setArrayIndex(i); + QString id = settings.value("id").toString(); + QString name = settings.value("name").toString(); + m_classNameMap[id] = name; + } + settings.endArray(); + + qDebug() << "Loaded mapping config: students=" << m_studentNameMap.size() + << ", classes=" << m_classNameMap.size(); +} + +void MainWindow::saveMappingConfigToSettings() +{ + QSettings settings; + + // 保存学号-姓名映射 + settings.beginWriteArray("studentNameMap"); + int i = 0; + for (auto it = m_studentNameMap.begin(); it != m_studentNameMap.end(); ++it, ++i) { + settings.setArrayIndex(i); + settings.setValue("id", it.key()); + settings.setValue("name", it.value()); + } + settings.endArray(); + + // 保存班级编号-名称映射 + settings.beginWriteArray("classNameMap"); + i = 0; + for (auto it = m_classNameMap.begin(); it != m_classNameMap.end(); ++it, ++i) { + settings.setArrayIndex(i); + settings.setValue("id", it.key()); + settings.setValue("name", it.value()); + } + settings.endArray(); + + settings.sync(); + qDebug() << "Saved mapping config: students=" << m_studentNameMap.size() + << ", classes=" << m_classNameMap.size(); +} + +void MainWindow::onMappingConfigFetched(const QMap &studentNameMap, + const QMap &classNameMap) +{ + qDebug() << "Received mapping config from server"; + + // 合并服务器配置(服务器优先) + for (auto it = studentNameMap.begin(); it != studentNameMap.end(); ++it) { + m_studentNameMap[it.key()] = it.value(); + } + for (auto it = classNameMap.begin(); it != classNameMap.end(); ++it) { + m_classNameMap[it.key()] = it.value(); + } + + // 保存到本地 + saveMappingConfigToSettings(); + + // 刷新表格显示 + filterData(); + + m_statusLabel->setText("状态: 映射配置已同步"); +} + +void MainWindow::onMappingConfigUploaded(bool success) +{ + if (success) { + qDebug() << "Mapping config uploaded successfully"; + } else { + qWarning() << "Failed to upload mapping config"; + QMessageBox::warning(this, "警告", "映射配置上传到服务器失败!"); + } +} diff --git a/src/MappingConfigDialog.cpp b/src/MappingConfigDialog.cpp new file mode 100644 index 0000000..363068e --- /dev/null +++ b/src/MappingConfigDialog.cpp @@ -0,0 +1,472 @@ +#include "MappingConfigDialog.h" +#include +#include +#include +#include +#include +#include + +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 &map) +{ + m_studentNameMap = map; + updateStudentTable(); +} + +void MappingConfigDialog::setClassNameMap(const QMap &map) +{ + m_classNameMap = map; + updateClassTable(); +} + +void MappingConfigDialog::updateStudentTable() +{ + m_studentTable->setRowCount(0); + + QList 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 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); +} diff --git a/src/NetworkApiManager.cpp b/src/NetworkApiManager.cpp new file mode 100644 index 0000000..19369e5 --- /dev/null +++ b/src/NetworkApiManager.cpp @@ -0,0 +1,674 @@ +#include "NetworkApiManager.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 ¶ms) +{ + 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 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 ×tamp, const QString &studentId) +{ + QMap 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 &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 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(), QMap()); + reply->deleteLater(); + return; + } + + QJsonObject obj = doc.object(); + QMap studentNameMap; + QMap 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 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 NetworkApiManager::parseRecordsJson(const QByteArray &json) +{ + QVector 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 +#include +#else +#include +#include +#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 &studentNameMap, + const QMap &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 &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"); +} diff --git a/src/ServerConfigDialog.cpp b/src/ServerConfigDialog.cpp new file mode 100644 index 0000000..78d539c --- /dev/null +++ b/src/ServerConfigDialog.cpp @@ -0,0 +1,134 @@ +#include "ServerConfigDialog.h" +#include "NetworkApiManager.h" +#include +#include +#include +#include +#include + +ServerConfigDialog::ServerConfigDialog(const QString ¤tUrl, 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; +} diff --git a/src/SettingsDialog.cpp b/src/SettingsDialog.cpp new file mode 100644 index 0000000..34b5615 --- /dev/null +++ b/src/SettingsDialog.cpp @@ -0,0 +1,383 @@ +#include "SettingsDialog.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// ============= 对话框位置限制过滤器(本地实现) ============= +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(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; +} diff --git a/src/StatisticsDialog.cpp b/src/StatisticsDialog.cpp new file mode 100644 index 0000000..9a430f6 --- /dev/null +++ b/src/StatisticsDialog.cpp @@ -0,0 +1,298 @@ +#include "StatisticsDialog.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +StatisticsDialog::StatisticsDialog(const QVector &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::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(); +} diff --git a/src/TimeSlotConfig.cpp b/src/TimeSlotConfig.cpp new file mode 100644 index 0000000..84830ad --- /dev/null +++ b/src/TimeSlotConfig.cpp @@ -0,0 +1,163 @@ +#include "TimeSlotConfig.h" +#include +#include +#include +#include +#include +#include +#include + +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 &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(); +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..e0b62a8 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,21 @@ +#include "MainWindow.h" +#include +#include + +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(); +}