diff --git a/Hardware/esp8266.c b/Hardware/esp8266.c index 6f650b0..8ed8ca5 100644 --- a/Hardware/esp8266.c +++ b/Hardware/esp8266.c @@ -506,8 +506,6 @@ uint8_t ESP8266_HttpGet(const char* path, char* response_buffer, uint16_t buffer return ESP8266_FAIL; } -// ========== 兼容旧版本的函数 ========== - /** * @brief 查询学生在指定时段是否已签到(服务器端查重) * @param student_id 学号 @@ -624,34 +622,3 @@ uint8_t ESP8266_DownloadTodayRecords(const char* slot_start, const char* slot_en DEBUG_PRINT("[ESP8266] Downloaded %d records: %s\r\n", count, student_ids); return count; } - -uint8_t ESP8266_ConnectTCPServer(const char* ip, const char* port) -{ - // 重定向到HTTP版本 - return ESP8266_ConnectHTTPServer(ip, port); -} - -uint8_t ESP8266_SendData(char* Data) -{ - char cmd_buffer[32]; - uint16_t data_len = strlen(Data); - - DEBUG_PRINT("[ESP8266] Sending %d bytes (legacy mode)\r\n", data_len); - - sprintf(cmd_buffer, "AT+CIPSEND=%d", data_len); - ESP8266_SendCmd(cmd_buffer); - - uint32_t start_time = Get_Time_ms(); - Serial_ClearRxBuffer(); - - while(Get_Time_ms() - start_time < 1000) { - if (strstr(Serial_GetRxBuffer(), ">") != NULL) { - Serial_SendString(Data); - Delay_ms(50); - return ESP8266_SUCCESS; - } - Delay_ms(10); - } - - return ESP8266_FAIL; -} diff --git a/Hardware/esp8266.h b/Hardware/esp8266.h index 33a2f48..1bfca52 100644 --- a/Hardware/esp8266.h +++ b/Hardware/esp8266.h @@ -91,8 +91,4 @@ uint8_t ESP8266_CheckDuplicate(const char* student_id, const char* slot_start, c */ uint8_t ESP8266_DownloadTodayRecords(const char* slot_start, const char* slot_end, char* student_ids, uint16_t buffer_size); -// 兼容旧版本(已废弃,建议使用HTTP版本) -uint8_t ESP8266_ConnectTCPServer(const char* ip, const char* port); -uint8_t ESP8266_SendData(char* Data); - #endif diff --git a/Hardware/esp8266_new.c b/Hardware/esp8266_new.c deleted file mode 100644 index db4b952..0000000 --- a/Hardware/esp8266_new.c +++ /dev/null @@ -1,243 +0,0 @@ -/* - * ESP8266 WiFi模块驱动(支持HTTP加密传输) - * 新增功能: - * 1. HTTP POST请求支持 - * 2. 加密数据传输 - * 3. 自动重连机制 - */ - -#include "esp8266.h" -#include "Serial.h" -#include "Delay.h" -#include -#include - -// 外部配置(在main.c中定义) -extern const char* WIFI_SSID; -extern const char* WIFI_PASSWORD; -extern const char* HTTP_SERVER_IP; -extern const char* HTTP_SERVER_PORT; -extern const char* HTTP_API_TOKEN; - -// 内部函数声明 -static uint8_t ESP8266_SendCommand(const char* cmd, const char* expected, uint16_t timeout); -static void ESP8266_ClearBuffer(void); - -/** - * @brief 发送AT命令并等待响应 - */ -static uint8_t ESP8266_SendCommand(const char* cmd, const char* expected, uint16_t timeout) -{ - Serial_SendString(cmd); - Serial_SendString("\r\n"); - - Delay_ms(timeout); - - if (expected == NULL) { - return ESP8266_SUCCESS; - } - - // 检查是否收到期望的响应 - char* response = Serial_GetRxData(); - if (strstr(response, expected) != NULL) { - return ESP8266_SUCCESS; - } - - return ESP8266_FAIL; -} - -static void ESP8266_ClearBuffer(void) -{ - Serial_ClearRxBuffer(); -} - -uint8_t ESP8266_Setup(void) -{ - ESP8266_ClearBuffer(); - - // 测试AT指令 - if (ESP8266_SendCommand("AT", "OK", 1000) == ESP8266_FAIL) { - return ESP8266_FAIL; - } - - // 设置为STA模式(客户端模式) - if (ESP8266_SendCommand("AT+CWMODE=1", "OK", 1000) == ESP8266_FAIL) { - return ESP8266_FAIL; - } - - // 关闭回显 - ESP8266_SendCommand("ATE0", "OK", 500); - - // 设置单连接模式 - if (ESP8266_SendCommand("AT+CIPMUX=0", "OK", 1000) == ESP8266_FAIL) { - return ESP8266_FAIL; - } - - return ESP8266_SUCCESS; -} - -uint8_t ESP8266_ConnectWiFi(const char* ssid, const char* password) -{ - char cmd[128]; - - ESP8266_ClearBuffer(); - - // 断开之前的WiFi连接 - ESP8266_SendCommand("AT+CWQAP", "OK", 1000); - Delay_ms(1000); - - // 连接到WiFi - sprintf(cmd, "AT+CWJAP=\"%s\",\"%s\"", ssid, password); - - // WiFi连接可能需要较长时间 - if (ESP8266_SendCommand(cmd, "OK", 10000) == ESP8266_FAIL) { - return ESP8266_FAIL; - } - - Delay_ms(2000); - return ESP8266_SUCCESS; -} - -uint8_t ESP8266_ConnectHTTPServer(const char* ip, const char* port) -{ - char cmd[64]; - - ESP8266_ClearBuffer(); - - // 断开之前的TCP连接 - ESP8266_SendCommand("AT+CIPCLOSE", "OK", 1000); - Delay_ms(500); - - // 建立TCP连接到HTTP服务器 - sprintf(cmd, "AT+CIPSTART=\"TCP\",\"%s\",%s", ip, port); - - if (ESP8266_SendCommand(cmd, "OK", 5000) == ESP8266_FAIL) { - return ESP8266_FAIL; - } - - Delay_ms(1000); - return ESP8266_SUCCESS; -} - -uint8_t ESP8266_SendEncryptedData(const char* encrypted_data) -{ - char http_request[1024]; - char cmd[32]; - uint32_t content_length = strlen(encrypted_data); - - // 构建HTTP POST请求 - sprintf(http_request, - "POST /api/v1/attendance/checkins HTTP/1.1\r\n" - "Host: %s:%s\r\n" - "Content-Type: application/octet-stream\r\n" - "X-Api-Token: %s\r\n" - "X-Encrypted: AES128-CBC\r\n" - "Content-Length: %u\r\n" - "Connection: keep-alive\r\n" - "\r\n" - "%s", - HTTP_SERVER_IP, - HTTP_SERVER_PORT, - HTTP_API_TOKEN, - content_length, - encrypted_data - ); - - uint32_t request_len = strlen(http_request); - - // 发送数据长度命令 - sprintf(cmd, "AT+CIPSEND=%u", request_len); - - ESP8266_ClearBuffer(); - - if (ESP8266_SendCommand(cmd, ">", 2000) == ESP8266_FAIL) { - return ESP8266_FAIL; - } - - // 发送HTTP请求 - Serial_SendString(http_request); - - // 等待发送完成(不阻塞太久) - Delay_ms(100); - - // 可选:等待服务器响应(检查是否有"200 OK") - // 但为了不阻塞,建议不等待 - - return ESP8266_SUCCESS; -} - -uint8_t ESP8266_CheckConnection(void) -{ - ESP8266_ClearBuffer(); - - if (ESP8266_SendCommand("AT+CIPSTATUS", "OK", 1000) == ESP8266_FAIL) { - return ESP8266_FAIL; - } - - // 检查响应中是否包含"STATUS:3"(已连接) - char* response = Serial_GetRxData(); - if (strstr(response, "STATUS:3") != NULL || - strstr(response, "STATUS:4") != NULL) { - return ESP8266_SUCCESS; - } - - return ESP8266_FAIL; -} - -uint8_t ESP8266_GetNetworkTime(ESP8266_DateTime* datetime) -{ - // 启用SNTP - if (ESP8266_SendCommand("AT+CIPSNTPCFG=1,8,\"pool.ntp.org\"", "OK", 2000) == ESP8266_FAIL) { - return ESP8266_FAIL; - } - - Delay_ms(3000); // 等待时间同步 - - ESP8266_ClearBuffer(); - - // 获取时间 - if (ESP8266_SendCommand("AT+CIPSNTPTIME?", "OK", 1000) == ESP8266_FAIL) { - return ESP8266_FAIL; - } - - // 解析响应(格式:+CIPSNTPTIME:Thu Jan 01 08:00:00 2024) - char* response = Serial_GetRxData(); - char* time_str = strstr(response, "+CIPSNTPTIME:"); - - if (time_str != NULL) { - // 简单解析(这里只是示例,实际需要更完善的解析) - sscanf(time_str, "+CIPSNTPTIME:%*s %*s %d %d:%d:%d %d", - &datetime->day, &datetime->hour, - &datetime->minute, &datetime->second, &datetime->year); - - return ESP8266_SUCCESS; - } - - return ESP8266_FAIL; -} - -// ========== 兼容旧版本的函数(已废弃) ========== - -uint8_t ESP8266_ConnectTCPServer(const char* ip, const char* port) -{ - // 重定向到HTTP版本 - return ESP8266_ConnectHTTPServer(ip, port); -} - -uint8_t ESP8266_SendData(char* Data) -{ - // 警告:此函数已废弃,请使用ESP8266_SendEncryptedData - // 为了兼容,这里保留基本实现 - char cmd[32]; - - sprintf(cmd, "AT+CIPSEND=%d", strlen(Data)); - - if (ESP8266_SendCommand(cmd, ">", 2000) == ESP8266_FAIL) { - return ESP8266_FAIL; - } - - Serial_SendString(Data); - Delay_ms(50); - - return ESP8266_SUCCESS; -} diff --git a/Hardware/esp8266_new.h b/Hardware/esp8266_new.h deleted file mode 100644 index 0c994e7..0000000 --- a/Hardware/esp8266_new.h +++ /dev/null @@ -1,70 +0,0 @@ -#ifndef __ESP8266_H -#define __ESP8266_H - -#include "stm32f10x.h" - -// 定义布尔类型,方便返回状态 -#define ESP8266_SUCCESS 1 -#define ESP8266_FAIL 0 - -// 时间结构体 -typedef struct { - uint16_t year; - uint8_t month; - uint8_t day; - uint8_t hour; - uint8_t minute; - uint8_t second; -} ESP8266_DateTime; - -/* ---- 公共函数接口 --- -*/ - -/** - * @brief 对ESP8266模块进行基础设置 - * @retval 1 表示成功, 0 表示失败 - */ -uint8_t ESP8266_Setup(void); - -/** - * @brief 命令ESP8266连接到指定的WiFi网络 - * @param ssid 要连接的WiFi名称 (SSID) - * @param password WiFi的密码 - * @retval 1 表示成功, 0 表示失败 - */ -uint8_t ESP8266_ConnectWiFi(const char* ssid, const char* password); - -/** - * @brief 命令ESP8266连接到HTTP服务器(新增) - * @param ip 服务器的IP地址 - * @param port 服务器的端口号 - * @retval 1 表示成功, 0 表示失败 - */ -uint8_t ESP8266_ConnectHTTPServer(const char* ip, const char* port); - -/** - * @brief 通过ESP8266发送HTTP POST请求(新增 - 加密版本) - * @param encrypted_data 加密后的Base64数据 - * @retval 1 表示成功, 0 表示失败 - */ -uint8_t ESP8266_SendEncryptedData(const char* encrypted_data); - -/** - * @brief 检查TCP连接是否正常 - * @retval 1 表示已连接, 0 表示未连接 - */ -uint8_t ESP8266_CheckConnection(void); - -/** - * @brief 从网络获取当前时间(NTP/SNTP) - * @param datetime 时间结构体指针,用于存储获取的时间 - * @retval 1 表示成功, 0 表示失败 - */ -uint8_t ESP8266_GetNetworkTime(ESP8266_DateTime* datetime); - -// 兼容旧版本(已废弃,建议使用HTTP版本) -uint8_t ESP8266_ConnectTCPServer(const char* ip, const char* port); -uint8_t ESP8266_SendData(char* Data); - -#endif diff --git a/QT/.gitignore b/QT/.gitignore deleted file mode 100644 index bcc1bca..0000000 --- a/QT/.gitignore +++ /dev/null @@ -1,59 +0,0 @@ -# Build directories -build/ -build-*/ -cmake-build-*/ -out/ -dist/ - -# CMake generated files -CMakeCache.txt -CMakeFiles/ -cmake_install.cmake -install_manifest.txt -compile_commands.json -CTestTestfile.cmake -DartConfiguration.tcl - -# Qt Creator local settings -.qtcreator/ -*.user -*.user.* - -# Qt generated artifacts -*_autogen/ -*.qmlc -*.jsc -qrc_*.cpp -moc_*.cpp -moc_*.h -ui_*.h - -# Compiled binaries and libraries -*.exe -*.dll -*.so -*.dylib -*.a -*.lib -*.o -*.obj -*.pdb -*.ilk - -# Android build outputs -android-build/ -*.apk -*.aab - -# Logs and temporary files -*.log -*.tmp -*.temp -*.bak -*.swp -*~ - -# OS files -.DS_Store -Thumbs.db -desktop.ini diff --git a/QT/CMakeLists.txt b/QT/CMakeLists.txt deleted file mode 100644 index 3b7a7ed..0000000 --- a/QT/CMakeLists.txt +++ /dev/null @@ -1,145 +0,0 @@ -cmake_minimum_required(VERSION 3.16) - -project(rewrite_qt VERSION 1.0.0 LANGUAGES CXX) - -set(REWRITE_QT_DISPLAY_NAME "人脸识别考勤系统") -set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") -set(REWRITE_QT_OUTPUT_DIR "${CMAKE_CURRENT_SOURCE_DIR}/dist") -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${REWRITE_QT_OUTPUT_DIR}") -set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${REWRITE_QT_OUTPUT_DIR}") -set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${REWRITE_QT_OUTPUT_DIR}") -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOMOC_COMPILER_PREDEFINES OFF) -set(CMAKE_AUTORCC ON) - -file(TO_CMAKE_PATH "$ENV{LOCALAPPDATA}" REWRITE_QT_LOCALAPPDATA) -if(REWRITE_QT_LOCALAPPDATA) - set(REWRITE_QT_AUTOGEN_DIR "${REWRITE_QT_LOCALAPPDATA}/rewrite_qt_autogen") -else() - set(REWRITE_QT_AUTOGEN_DIR "${CMAKE_BINARY_DIR}/autogen_ascii") -endif() -file(MAKE_DIRECTORY "${REWRITE_QT_AUTOGEN_DIR}") - -find_package(Qt6 6.2 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2 Network) - -get_target_property(REWRITE_QT_QMAKE_EXECUTABLE Qt6::qmake IMPORTED_LOCATION) -if(REWRITE_QT_QMAKE_EXECUTABLE) - execute_process( - COMMAND "${REWRITE_QT_QMAKE_EXECUTABLE}" -query QT_INSTALL_QML - OUTPUT_VARIABLE REWRITE_QT_INSTALL_QML - OUTPUT_STRIP_TRAILING_WHITESPACE - ) -endif() - -qt_standard_project_setup() - -set(REWRITE_QT_APP_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/favicon.ico") -set(REWRITE_QT_ICON_RESOURCES - assets/favicon.ico - assets/favicon-256x256.png - assets/favicon-192x192.png - assets/favicon-128x128.png - assets/favicon-64x64.png - assets/favicon-48x48.png - assets/favicon-32x32.png - assets/favicon-16x16.png -) - -qt_add_executable(apprewrite_qt - main.cpp - src/AppController.cpp - src/ApiClient.cpp - src/CryptoUtils.cpp - src/RecordListModel.cpp - src/StatisticsListModel.cpp - src/TimeSlotListModel.cpp - src/MappingListModel.cpp - src/FaceListModel.cpp -) - -if(REWRITE_QT_INSTALL_QML) - target_compile_definitions(apprewrite_qt PRIVATE - REWRITE_QT_INSTALL_QML="${REWRITE_QT_INSTALL_QML}" - ) -endif() - -if(WIN32 AND EXISTS "${REWRITE_QT_APP_ICON}") - set(REWRITE_QT_WIN32_ICON_RC "${CMAKE_CURRENT_BINARY_DIR}/app_icon.rc") - configure_file(cmake/app_icon.rc.in "${REWRITE_QT_WIN32_ICON_RC}" @ONLY) - target_sources(apprewrite_qt PRIVATE "${REWRITE_QT_WIN32_ICON_RC}") -endif() - -if(ANDROID) - set_target_properties(apprewrite_qt PROPERTIES - QT_ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android" - QT_ANDROID_APP_NAME "${REWRITE_QT_DISPLAY_NAME}" - QT_ANDROID_APP_ICON "@mipmap/ic_launcher" - ) -endif() - -target_include_directories(apprewrite_qt PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR}/src -) - -set(REWRITE_QT_RESOURCE_FILES - qml/Main.qml -) - -foreach(REWRITE_QT_ICON_RESOURCE IN LISTS REWRITE_QT_ICON_RESOURCES) - if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${REWRITE_QT_ICON_RESOURCE}") - list(APPEND REWRITE_QT_RESOURCE_FILES "${REWRITE_QT_ICON_RESOURCE}") - endif() -endforeach() - -qt_add_resources(apprewrite_qt "qml_resources" - PREFIX "/" - FILES - ${REWRITE_QT_RESOURCE_FILES} -) - -target_link_libraries(apprewrite_qt - PRIVATE - Qt6::Core - Qt6::Gui - Qt6::Qml - Qt6::Quick - Qt6::QuickControls2 - Qt6::Network -) - -set_target_properties(apprewrite_qt PROPERTIES - AUTOGEN_BUILD_DIR "${REWRITE_QT_AUTOGEN_DIR}" - QT_QML_MODULE_NO_IMPORT_SCAN TRUE - MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} - MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR} - MACOSX_BUNDLE TRUE - WIN32_EXECUTABLE TRUE -) - -include(GNUInstallDirs) - -if(UNIX AND NOT APPLE AND NOT ANDROID) - set(REWRITE_QT_DESKTOP_FILE "${CMAKE_CURRENT_BINARY_DIR}/rewrite_qt.desktop") - configure_file(cmake/rewrite_qt.desktop.in "${REWRITE_QT_DESKTOP_FILE}" @ONLY) - install(FILES "${REWRITE_QT_DESKTOP_FILE}" - DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications" - ) - - foreach(REWRITE_QT_ICON_SIZE IN ITEMS 16 32 48 64 128 192 256) - set(REWRITE_QT_LINUX_ICON "${CMAKE_CURRENT_SOURCE_DIR}/assets/favicon-${REWRITE_QT_ICON_SIZE}x${REWRITE_QT_ICON_SIZE}.png") - if(EXISTS "${REWRITE_QT_LINUX_ICON}") - install(FILES "${REWRITE_QT_LINUX_ICON}" - DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/${REWRITE_QT_ICON_SIZE}x${REWRITE_QT_ICON_SIZE}/apps" - RENAME rewrite_qt.png - ) - endif() - endforeach() -endif() - -install(TARGETS apprewrite_qt - BUNDLE DESTINATION . - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} - LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} -) diff --git a/QT/LICENSE b/QT/LICENSE deleted file mode 100644 index c46d1f4..0000000 --- a/QT/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Jitianye - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/QT/README.md b/QT/README.md deleted file mode 100644 index 64434bf..0000000 --- a/QT/README.md +++ /dev/null @@ -1,157 +0,0 @@ -# Qt 管理端 README - -这是人脸识别考勤系统的 Qt 管理端,支持 Windows 桌面端和 Android 端。更完整的说明见: - -```text -../docs/04_Qt管理端文档.md -../docs/00_项目总文档.md -``` - -## 1. 功能概览 - -Qt 管理端用于连接考勤服务端,完成以下工作: - -1. 查看考勤记录。 -2. 按班级、学号、姓名和日期筛选记录。 -3. 导出考勤记录 CSV。 -4. 查看学生维度的统计分析。 -5. 导出统计 CSV。 -6. 配置服务端地址。 -7. 新增、编辑、删除、清空签到时间段。 -8. 配置学号到姓名、班级编号到班级名称的显示映射。 -9. 查看 K210 上传的人脸照片。 -10. 为 K210 face_id 绑定真实学号。 -11. 删除人脸信息,并可选择删除关联考勤记录。 - -## 2. 当前重要行为 - -### 2.1 默认服务端地址 - -```text -http://127.0.0.1:8082 -``` - -启动后会优先读取本地保存的 `server/url`。 - -### 2.2 默认签到时间段 - -当前默认签到时间段为空: - -```json -{ - "time_slots": [] -} -``` - -第一次使用时,需要在“配置中心”里新增时间段并保存同步到服务端。旧版本本地保存的四个默认时间段会被自动迁移为空配置。 - -### 2.3 API Token - -Qt 每个请求都会带: - -```text -X-Api-Token: -``` - -默认 Token 写在 `src/ApiClient.cpp`。可以通过环境变量覆盖: - -```powershell -$env:ATTENDANCE_API_TOKEN="你的Token" -``` - -### 2.4 响应加密 - -服务端支持通过请求头 `X-Encrypted-Response: true` 返回 AES 加密响应,Qt 代码中保留了解密能力。 - -当前普通记录和统计请求没有默认开启这个请求头,实际按 JSON 明文解析。 - -## 3. 技术栈 - -| 分类 | 内容 | -| --- | --- | -| 语言 | C++17、QML | -| UI | Qt Quick / Qt Quick Controls 2 | -| Qt 模块 | Core、Gui、Qml、Quick、QuickControls2、Network | -| 构建 | CMake 3.16+ | -| 平台 | Windows 桌面端、Android | - -## 4. 目录结构 - -```text -QT/ -├── android/ # Android 打包配置和图标 -├── assets/ # 桌面端图标 -├── cmake/ # 平台模板 -├── qml/Main.qml # 主界面 -├── src/ -│ ├── ApiClient.* # HTTP API 客户端 -│ ├── AppController.* # 业务逻辑中枢 -│ ├── AttendanceTypes.h # 数据结构 -│ ├── CryptoUtils.* # AES 解密兼容工具 -│ └── *ListModel.* # QML 列表模型 -├── CMakeLists.txt -├── LICENSE -├── README.md -└── main.cpp -``` - -## 5. 构建环境 - -基础要求: - -1. Qt 6.2 或更高。 -2. CMake 3.16 或更高。 -3. 支持 C++17 的编译器。 - -Windows 推荐使用 Qt 6.x MinGW 64-bit Kit。 - -Android 还需要: - -1. Qt for Android。 -2. Android SDK。 -3. Android NDK。 -4. JDK。 -5. Android 平台版本,例如 `android-35`。 - -## 6. Qt Creator 构建 - -1. 打开 Qt Creator。 -2. 打开 `QT/CMakeLists.txt`。 -3. 选择 Desktop Qt 或 Android Kit。 -4. 等待 CMake 配置完成。 -5. 构建目标 `apprewrite_qt`。 -6. 桌面端直接运行;Android 端部署到设备或模拟器。 - -## 7. 命令行构建示例 - -Windows 示例: - -```powershell -cd D:\University\graduation\code\Reconstruct_Qt\QT -cmake -S . -B build\Desktop -DCMAKE_PREFIX_PATH=D:\Qt\6.10.0\mingw_64 -cmake --build build\Desktop -``` - -Qt 安装路径需要按本机实际情况修改。 - -## 8. 本地数据 - -Qt 会保存这些本地数据: - -| 数据 | 保存方式 | -| --- | --- | -| 服务端地址 | `QSettings`,键名 `server/url` | -| 时间段配置 | `QStandardPaths::AppDataLocation/timeslot_config.json` | -| 学生/班级映射 | `QStandardPaths::AppDataLocation/mapping_config.json` | -| 人脸照片缓存 | `QStandardPaths::AppDataLocation/face_photos/*.jpg` | - -## 9. 常见问题 - -| 现象 | 处理 | -| --- | --- | -| 测试连接失败 | 检查服务端是否启动、地址是否正确、Token 是否一致 | -| 没有时间段 | 这是当前默认行为,需要手动新增并保存 | -| 识别成功但没有记录 | 检查 STM32 是否在时间段内、是否成功加密上传 | -| 人脸照片不显示 | 检查 K210 是否上传照片,服务端 `faces/` 是否有图片 | -| Android 布局拥挤 | 当前 QML 已做窄屏自适应,若仍异常请检查实际设备宽度 | - diff --git a/QT/android/gradle.properties b/QT/android/gradle.properties deleted file mode 100644 index b383ead..0000000 --- a/QT/android/gradle.properties +++ /dev/null @@ -1,2 +0,0 @@ -android.overridePathCheck=true -android.useAndroidX=true diff --git a/QT/android/res/mipmap-hdpi/ic_launcher.png b/QT/android/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index cb9bd6e..0000000 Binary files a/QT/android/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/QT/android/res/mipmap-mdpi/ic_launcher.png b/QT/android/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 09409d5..0000000 Binary files a/QT/android/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/QT/android/res/mipmap-xhdpi/ic_launcher.png b/QT/android/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 0e1210c..0000000 Binary files a/QT/android/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/QT/android/res/mipmap-xxhdpi/ic_launcher.png b/QT/android/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index b3651fb..0000000 Binary files a/QT/android/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/QT/android/res/mipmap-xxxhdpi/ic_launcher.png b/QT/android/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index f05ced8..0000000 Binary files a/QT/android/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/QT/assets/favicon-128x128.png b/QT/assets/favicon-128x128.png deleted file mode 100644 index 808a71a..0000000 Binary files a/QT/assets/favicon-128x128.png and /dev/null differ diff --git a/QT/assets/favicon-16x16.png b/QT/assets/favicon-16x16.png deleted file mode 100644 index 8b0b1b5..0000000 Binary files a/QT/assets/favicon-16x16.png and /dev/null differ diff --git a/QT/assets/favicon-192x192.png b/QT/assets/favicon-192x192.png deleted file mode 100644 index eaed9c4..0000000 Binary files a/QT/assets/favicon-192x192.png and /dev/null differ diff --git a/QT/assets/favicon-256x256.png b/QT/assets/favicon-256x256.png deleted file mode 100644 index 2407c25..0000000 Binary files a/QT/assets/favicon-256x256.png and /dev/null differ diff --git a/QT/assets/favicon-32x32.png b/QT/assets/favicon-32x32.png deleted file mode 100644 index 59d5a42..0000000 Binary files a/QT/assets/favicon-32x32.png and /dev/null differ diff --git a/QT/assets/favicon-48x48.png b/QT/assets/favicon-48x48.png deleted file mode 100644 index 54ec421..0000000 Binary files a/QT/assets/favicon-48x48.png and /dev/null differ diff --git a/QT/assets/favicon-64x64.png b/QT/assets/favicon-64x64.png deleted file mode 100644 index e979620..0000000 Binary files a/QT/assets/favicon-64x64.png and /dev/null differ diff --git a/QT/assets/favicon.ico b/QT/assets/favicon.ico deleted file mode 100644 index 69458ba..0000000 Binary files a/QT/assets/favicon.ico and /dev/null differ diff --git a/QT/cmake/app_icon.rc.in b/QT/cmake/app_icon.rc.in deleted file mode 100644 index b754112..0000000 --- a/QT/cmake/app_icon.rc.in +++ /dev/null @@ -1 +0,0 @@ -IDI_ICON1 ICON "@REWRITE_QT_APP_ICON@" diff --git a/QT/cmake/rewrite_qt.desktop.in b/QT/cmake/rewrite_qt.desktop.in deleted file mode 100644 index 35224cd..0000000 --- a/QT/cmake/rewrite_qt.desktop.in +++ /dev/null @@ -1,7 +0,0 @@ -[Desktop Entry] -Type=Application -Name=人脸识别考勤系统 -Exec=apprewrite_qt -Icon=rewrite_qt -Terminal=false -Categories=Utility; diff --git a/QT/main.cpp b/QT/main.cpp deleted file mode 100644 index 101ba11..0000000 --- a/QT/main.cpp +++ /dev/null @@ -1,63 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include "src/AppController.h" - -int main(int argc, char *argv[]) -{ - QGuiApplication app(argc, argv); - QIcon appIcon(QStringLiteral(":/assets/favicon.ico")); - appIcon.addFile(QStringLiteral(":/assets/favicon-256x256.png")); - appIcon.addFile(QStringLiteral(":/assets/favicon-192x192.png")); - appIcon.addFile(QStringLiteral(":/assets/favicon-128x128.png")); - appIcon.addFile(QStringLiteral(":/assets/favicon-64x64.png")); - appIcon.addFile(QStringLiteral(":/assets/favicon-48x48.png")); - appIcon.addFile(QStringLiteral(":/assets/favicon-32x32.png")); - appIcon.addFile(QStringLiteral(":/assets/favicon-16x16.png")); - app.setWindowIcon(appIcon); - - QCoreApplication::setApplicationName("人脸识别考勤系统"); - QCoreApplication::setApplicationVersion("3.0.0"); - QCoreApplication::setOrganizationName("STM32 Face Recognition"); - QQuickStyle::setStyle("Basic"); - -#if defined(Q_OS_ANDROID) - QQuickWindow::setTextRenderType(QQuickWindow::NativeTextRendering); -#endif - - AppController controller; - - QQmlApplicationEngine engine; - const QString qmlImportPath = QLibraryInfo::path(QLibraryInfo::QmlImportsPath); - if (!qmlImportPath.isEmpty()) { - engine.addImportPath(qmlImportPath); - } -#if defined(REWRITE_QT_INSTALL_QML) - const QString qtInstallQmlPath = QStringLiteral(REWRITE_QT_INSTALL_QML); - if (QDir(qtInstallQmlPath).exists()) { - engine.addImportPath(qtInstallQmlPath); - } -#endif - engine.rootContext()->setContextProperty("appController", &controller); - - const QUrl url(QStringLiteral("qrc:/qml/Main.qml")); - QObject::connect( - &engine, - &QQmlApplicationEngine::objectCreated, - &app, - [url](QObject *obj, const QUrl &objUrl) { - if (!obj && url == objUrl) { - QCoreApplication::exit(-1); - } - }, - Qt::QueuedConnection); - - engine.load(url); - return app.exec(); -} diff --git a/QT/qml/Main.qml b/QT/qml/Main.qml deleted file mode 100644 index eec6dc6..0000000 --- a/QT/qml/Main.qml +++ /dev/null @@ -1,2946 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import QtQuick.Dialogs - -ApplicationWindow { - id: window - width: 1560 - height: 920 - minimumWidth: Qt.platform.os === "android" ? 360 : 900 - minimumHeight: Qt.platform.os === "android" ? 560 : 620 - visible: true - title: "人脸识别考勤系统" - color: "#f6f7f9" - - readonly property color ink900: "#182230" - readonly property color ink700: "#475467" - readonly property color ink500: "#667085" - readonly property color lineColor: "#d8dde5" - readonly property color shellColor: "#f6f7f9" - readonly property color panelColor: "#f9fafb" - readonly property color panelStrong: "#ffffff" - readonly property color accentColor: "#b6543a" - readonly property color accentDeep: "#8e3f29" - readonly property color accentSoft: "#f4d6c8" - readonly property color tealColor: "#1f6f78" - readonly property color tealSoft: "#d7ecec" - readonly property color goldColor: "#aa7a1f" - readonly property color goldSoft: "#f5e8c6" - readonly property color successColor: "#1f7a4d" - readonly property color dangerColor: "#b9382f" - readonly property string appFontFamily: Qt.platform.os === "android" ? "sans-serif" : "Microsoft YaHei UI" - readonly property int appBoldWeight: Qt.platform.os === "android" ? Font.Medium : Font.Bold - - property string statisticsPreset: "最近30天" - property int editingTimeSlotIndex: -1 - property string pendingDeleteFaceId: "" - property string toastMessage: "" - property bool toastVisible: false - property bool resizingWindow: false - property int currentPageIndex: 0 - property bool timeSlotsExpanded: false - property bool mappingsExpanded: false - readonly property bool androidLayout: Qt.platform.os === "android" - readonly property bool compactLayout: width < 1460 - readonly property bool denseLayout: width < 1380 - readonly property bool narrowLayout: width < 1280 - readonly property bool slimLayout: width < 1040 - readonly property bool phoneLayout: width < (androidLayout ? 640 : 560) - readonly property int sidebarWidth: slimLayout ? 0 : (compactLayout ? 212 : 240) - - font.family: appFontFamily - - onWidthChanged: { - resizingWindow = true; - resizeSettledTimer.restart(); - } - - onHeightChanged: { - resizingWindow = true; - resizeSettledTimer.restart(); - } - - function switchPage(index) { - currentPageIndex = Math.max(0, Math.min(4, index)); - } - - Timer { - id: resizeSettledTimer - interval: 160 - repeat: false - onTriggered: resizingWindow = false - } - - component AppCard: Rectangle { - radius: 8 - color: panelStrong - border.color: lineColor - } - - component AppTextField: TextField { - id: control - implicitHeight: 42 - leftPadding: 14 - rightPadding: 14 - topPadding: 10 - bottomPadding: 10 - color: ink900 - selectedTextColor: "#ffffff" - selectionColor: accentColor - placeholderTextColor: "#98a2b3" - - background: Rectangle { - radius: 8 - color: "#ffffff" - border.width: control.activeFocus ? 2 : 1 - border.color: control.activeFocus ? accentColor : "#d6cec4" - } - } - - component AppComboBox: ComboBox { - id: control - implicitHeight: 42 - leftPadding: 14 - rightPadding: 32 - font.pixelSize: 13 - - delegate: ItemDelegate { - width: ListView.view.width - text: modelData - highlighted: control.highlightedIndex === index - } - - contentItem: Text { - leftPadding: 0 - rightPadding: 0 - text: control.displayText - font: control.font - color: ink900 - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } - - indicator: Label { - x: control.width - width - 12 - y: (control.height - height) / 2 - text: "⌄" - color: ink500 - font.pixelSize: 16 - } - - background: Rectangle { - radius: 8 - color: "#ffffff" - border.width: control.activeFocus ? 2 : 1 - border.color: control.activeFocus ? accentColor : "#d6cec4" - } - - popup: Popup { - y: control.height + 6 - width: control.width - padding: 6 - - background: Rectangle { - radius: 8 - color: "#ffffff" - border.color: lineColor - } - - contentItem: ListView { - implicitHeight: contentHeight - model: control.popup.visible ? control.delegateModel : null - currentIndex: control.highlightedIndex - clip: true - } - } - } - - component PrimaryButton: Button { - id: control - property color fillColor: accentColor - property color labelColor: "#fff7f2" - hoverEnabled: true - implicitHeight: 42 - implicitWidth: 112 - - background: Rectangle { - radius: 8 - color: control.down ? Qt.darker(control.fillColor, 1.12) : control.hovered ? Qt.lighter(control.fillColor, 1.06) : control.fillColor - } - - contentItem: Label { - width: control.availableWidth - text: control.text - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - color: control.labelColor - font.pixelSize: 13 - font.weight: appBoldWeight - elide: Text.ElideRight - } - } - - component SecondaryButton: Button { - id: control - property color fillColor: "#ffffff" - property color strokeColor: "#d6cec4" - property color labelColor: ink900 - hoverEnabled: true - implicitHeight: 42 - implicitWidth: 96 - - background: Rectangle { - radius: 8 - color: control.down ? "#efe6db" : control.hovered ? "#f7f1ea" : control.fillColor - border.color: control.hovered ? accentSoft : control.strokeColor - } - - contentItem: Label { - width: control.availableWidth - text: control.text - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - color: control.labelColor - font.pixelSize: 13 - font.weight: appBoldWeight - elide: Text.ElideRight - } - } - - component DangerButton: PrimaryButton { - fillColor: dangerColor - labelColor: "#fff5f4" - } - - component AppCheckBox: CheckBox { - id: control - spacing: 10 - indicator: Rectangle { - implicitWidth: 20 - implicitHeight: 20 - radius: 6 - color: control.checked ? accentColor : "#ffffff" - border.width: 1 - border.color: control.checked ? accentColor : "#cfc6bc" - - Rectangle { - anchors.centerIn: parent - width: 8 - height: 8 - radius: 4 - color: "#fff7f2" - visible: control.checked - } - } - - contentItem: Text { - text: control.text - color: ink700 - font: control.font - verticalAlignment: Text.AlignVCenter - leftPadding: control.indicator.width + control.spacing - } - } - - component AppScrollBar: ScrollBar { - id: control - implicitWidth: 12 - policy: ScrollBar.AsNeeded - - contentItem: Rectangle { - implicitWidth: 8 - radius: 4 - color: control.pressed ? accentColor : "#cfbeb1" - opacity: control.active ? 0.9 : 0.55 - } - - background: Item {} - } - - component EmptyState: Item { - id: emptyState - property string title: "" - property string description: "" - property string badgeText: "" - property color accent: accentColor - readonly property bool tightState: phoneLayout || height < 190 - - implicitWidth: phoneLayout ? 280 : 320 - implicitHeight: phoneLayout ? 154 : 220 - width: Math.min(implicitWidth, parent ? Math.max(180, parent.width - 28) : implicitWidth) - height: Math.min(implicitHeight, parent ? Math.max(126, parent.height - 20) : implicitHeight) - z: 20 - - Rectangle { - anchors.fill: parent - radius: 8 - color: "#fffaf4" - border.color: "#e7ddd2" - - Column { - anchors.centerIn: parent - spacing: emptyState.tightState ? 8 : 12 - width: parent.width - 32 - - Rectangle { - width: emptyState.tightState ? 52 : 64 - height: width - radius: 8 - color: Qt.tint("#ffffff", Qt.rgba(accent.r, accent.g, accent.b, 0.16)) - anchors.horizontalCenter: parent.horizontalCenter - - Label { - anchors.centerIn: parent - text: badgeText - color: accent - font.pixelSize: emptyState.tightState ? 18 : 22 - font.weight: appBoldWeight - } - } - - Label { - width: parent.width - text: title - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - color: ink900 - font.pixelSize: emptyState.tightState ? 18 : 20 - font.weight: appBoldWeight - } - - Label { - width: parent.width - visible: !emptyState.tightState || parent.width > 260 - text: description - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - color: ink500 - font.pixelSize: emptyState.tightState ? 12 : 13 - } - } - } - } - - component InfoChip: Rectangle { - property string textValue: "" - property color tintColor: accentColor - - implicitHeight: 30 - implicitWidth: chipLabel.implicitWidth + 18 - radius: 15 - color: Qt.tint("#ffffff", Qt.rgba(tintColor.r, tintColor.g, tintColor.b, 0.16)) - border.color: Qt.tint("#ffffff", Qt.rgba(tintColor.r, tintColor.g, tintColor.b, 0.34)) - - Label { - id: chipLabel - anchors.centerIn: parent - text: parent.textValue - color: parent.tintColor - font.pixelSize: 11 - font.weight: appBoldWeight - } - } - - component SectionHeader: Item { - id: sectionHeader - property string title: "" - property string description: "" - property color accent: accentColor - - implicitHeight: descriptionLabel.visible ? (phoneLayout ? 64 : 52) : 32 - implicitWidth: 300 - - RowLayout { - anchors.fill: parent - spacing: 12 - - Rectangle { - Layout.preferredWidth: 6 - Layout.fillHeight: true - radius: 3 - color: sectionHeader.accent - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - - Label { - Layout.fillWidth: true - text: sectionHeader.title - font.pixelSize: phoneLayout ? 20 : 22 - font.weight: appBoldWeight - color: ink900 - wrapMode: Text.WordWrap - } - - Label { - id: descriptionLabel - Layout.fillWidth: true - visible: text.length > 0 - text: sectionHeader.description - color: ink500 - font.pixelSize: 12 - wrapMode: Text.WordWrap - } - } - } - } - - component SegmentTabButton: TabButton { - id: control - implicitHeight: 40 - padding: 0 - - background: Rectangle { - radius: 8 - color: control.checked ? tealColor : "#ffffff" - border.width: 1 - border.color: control.checked ? tealColor : "#d6cec4" - } - - contentItem: Label { - text: control.text - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - color: control.checked ? "#f6fbfb" : ink700 - font.pixelSize: 13 - font.weight: appBoldWeight - elide: Text.ElideRight - } - } - - component MobileNavTabButton: TabButton { - id: control - implicitHeight: 42 - padding: 0 - - background: Rectangle { - radius: 8 - color: control.checked ? tealColor : panelStrong - border.width: 1 - border.color: control.checked ? tealColor : lineColor - } - - contentItem: Label { - text: control.text - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - color: control.checked ? "#f6fbfb" : ink700 - font.pixelSize: phoneLayout ? 12 : 13 - font.weight: appBoldWeight - elide: Text.ElideRight - } - } - - function applyRecordFilters() { - appController.applyFilters(classFilter.currentText, studentFilter.currentText, nameFilter.text, dateFilter.currentIndex, specificDateField.text); - } - - function applyStatisticsPreset() { - const today = new Date(); - let start = new Date(today); - let end = new Date(today); - - if (statisticsPreset === "今天") { - start = new Date(today); - } else if (statisticsPreset === "最近7天") { - start.setDate(start.getDate() - 7); - } else if (statisticsPreset === "最近30天") { - start.setDate(start.getDate() - 30); - } else if (statisticsPreset === "本月") { - start = new Date(today.getFullYear(), today.getMonth(), 1); - } - - statsStartField.text = Qt.formatDate(start, "yyyy-MM-dd"); - statsEndField.text = Qt.formatDate(end, "yyyy-MM-dd"); - appController.updateStatisticsRange(statsStartField.text, statsEndField.text); - } - - function openTimeSlotDialog(index) { - editingTimeSlotIndex = index; - if (index >= 0) { - const item = appController.timeSlotModel.get(index); - timeSlotNameField.text = item.name; - timeSlotStartField.text = item.startTime; - timeSlotEndField.text = item.endTime; - timeSlotEnabledBox.checked = item.enabled; - timeSlotDialog.title = "编辑时间段"; - } else { - timeSlotNameField.text = ""; - timeSlotStartField.text = "08:00"; - timeSlotEndField.text = "09:30"; - timeSlotEnabledBox.checked = true; - timeSlotDialog.title = "新增时间段"; - } - timeSlotDialog.open(); - } - - function saveTimeSlotDialog() { - if (editingTimeSlotIndex >= 0) { - appController.timeSlotModel.updateTimeSlot(editingTimeSlotIndex, timeSlotNameField.text, timeSlotStartField.text, timeSlotEndField.text, timeSlotEnabledBox.checked); - } else { - appController.timeSlotModel.addTimeSlot(timeSlotNameField.text, timeSlotStartField.text, timeSlotEndField.text, timeSlotEnabledBox.checked); - } - appController.saveTimeSlots(); - } - - Component.onCompleted: { - applyRecordFilters(); - applyStatisticsPreset(); - } - - Connections { - target: appController - - function onStatusMessageChanged() { - if (!appController.statusMessage || appController.statusMessage.length === 0) { - return; - } - toastMessage = appController.statusMessage; - toastVisible = true; - toastTimer.restart(); - } - } - - Timer { - id: toastTimer - interval: 2600 - repeat: false - onTriggered: toastVisible = false - } - - background: Rectangle { - color: shellColor - } - - FileDialog { - id: recordExportDialog - fileMode: FileDialog.SaveFile - nameFilters: ["CSV 文件 (*.csv)"] - currentFile: "attendance_export.csv" - onAccepted: appController.exportFilteredRecords(selectedFile) - } - - FileDialog { - id: statisticsExportDialog - fileMode: FileDialog.SaveFile - nameFilters: ["CSV 文件 (*.csv)"] - currentFile: "attendance_statistics.csv" - onAccepted: appController.exportStatistics(selectedFile) - } - - Dialog { - id: clearDialog - title: "确认清空" - modal: true - anchors.centerIn: Overlay.overlay - width: 440 - standardButtons: Dialog.Yes | Dialog.No - - background: Rectangle { - radius: 20 - color: panelStrong - border.color: lineColor - } - - enter: Transition { - ParallelAnimation { - NumberAnimation { - property: "opacity" - from: 0 - to: 1 - duration: 150 - } - NumberAnimation { - property: "scale" - from: 0.96 - to: 1.0 - duration: 180 - easing.type: Easing.OutCubic - } - } - } - - exit: Transition { - ParallelAnimation { - NumberAnimation { - property: "opacity" - from: 1 - to: 0 - duration: 120 - } - NumberAnimation { - property: "scale" - from: 1.0 - to: 0.98 - duration: 120 - } - } - } - - contentItem: ColumnLayout { - spacing: 14 - - Label { - text: "确定要清空服务器上的所有签到记录吗?" - font.pixelSize: 20 - font.weight: appBoldWeight - color: ink900 - } - - Label { - text: "该操作不可恢复,建议先导出数据备份。" - wrapMode: Text.WordWrap - color: ink700 - } - } - - onAccepted: appController.clearAllRecords() - } - - Dialog { - id: timeSlotDialog - modal: true - anchors.centerIn: Overlay.overlay - width: 420 - standardButtons: Dialog.Save | Dialog.Cancel - - background: Rectangle { - radius: 20 - color: panelStrong - border.color: lineColor - } - - enter: Transition { - ParallelAnimation { - NumberAnimation { - property: "opacity" - from: 0 - to: 1 - duration: 150 - } - NumberAnimation { - property: "scale" - from: 0.96 - to: 1.0 - duration: 180 - easing.type: Easing.OutCubic - } - } - } - - exit: Transition { - ParallelAnimation { - NumberAnimation { - property: "opacity" - from: 1 - to: 0 - duration: 120 - } - NumberAnimation { - property: "scale" - from: 1.0 - to: 0.98 - duration: 120 - } - } - } - - contentItem: ColumnLayout { - spacing: 12 - - AppTextField { - id: timeSlotNameField - Layout.fillWidth: true - placeholderText: "时段名称" - } - - AppTextField { - id: timeSlotStartField - Layout.fillWidth: true - placeholderText: "开始时间,例如 08:00" - } - - AppTextField { - id: timeSlotEndField - Layout.fillWidth: true - placeholderText: "结束时间,例如 09:30" - } - - AppCheckBox { - id: timeSlotEnabledBox - text: "启用此时段" - } - } - - onAccepted: saveTimeSlotDialog() - } - - Dialog { - id: deleteFaceDialog - modal: true - anchors.centerIn: Overlay.overlay - width: 430 - title: "删除人脸信息" - standardButtons: Dialog.Yes | Dialog.No - - background: Rectangle { - radius: 20 - color: panelStrong - border.color: lineColor - } - - enter: Transition { - ParallelAnimation { - NumberAnimation { - property: "opacity" - from: 0 - to: 1 - duration: 150 - } - NumberAnimation { - property: "scale" - from: 0.96 - to: 1.0 - duration: 180 - easing.type: Easing.OutCubic - } - } - } - - exit: Transition { - ParallelAnimation { - NumberAnimation { - property: "opacity" - from: 1 - to: 0 - duration: 120 - } - NumberAnimation { - property: "scale" - from: 1.0 - to: 0.98 - duration: 120 - } - } - } - - contentItem: ColumnLayout { - spacing: 12 - - Label { - text: "确定删除 ID 为 " + pendingDeleteFaceId + " 的人脸信息吗?" - wrapMode: Text.WordWrap - font.pixelSize: 18 - font.weight: appBoldWeight - color: ink900 - } - - AppCheckBox { - id: deleteFaceRecordsCheck - text: "同时删除该学生的所有考勤记录" - checked: false - } - } - - onAccepted: appController.deleteFace(pendingDeleteFaceId, deleteFaceRecordsCheck.checked) - } - - Rectangle { - anchors.fill: parent - anchors.margins: slimLayout ? 8 : 16 - radius: 12 - color: "#ffffff" - border.color: "#dbd6ce" - - RowLayout { - anchors.fill: parent - anchors.margins: slimLayout ? 10 : 16 - spacing: slimLayout ? 0 : 16 - - Rectangle { - visible: !slimLayout - Layout.preferredWidth: visible ? sidebarWidth : 0 - Layout.maximumWidth: visible ? sidebarWidth : 0 - Layout.fillHeight: true - radius: 12 - color: "#15323d" - - ColumnLayout { - anchors.fill: parent - anchors.margins: 18 - spacing: 16 - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: compactLayout ? 74 : 86 - radius: 8 - color: "#214654" - - Label { - anchors.centerIn: parent - width: parent.width - 18 - text: "人脸识别考勤系统" - horizontalAlignment: Text.AlignHCenter - wrapMode: Text.WordWrap - font.pixelSize: compactLayout ? 17 : 19 - font.weight: appBoldWeight - color: "#ffffff" - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 8 - - Repeater { - model: [ - { - title: "考勤记录", - hint: "查看筛选与导出", - icon: "01" - }, - { - title: "统计分析", - hint: "按时间统计趋势", - icon: "02" - }, - { - title: "配置中心", - hint: "时间段与映射设置", - icon: "03" - }, - { - title: "人脸管理", - hint: "照片与学号绑定", - icon: "04" - }, - { - title: "关于", - hint: "系统说明", - icon: "05" - } - ] - - delegate: Rectangle { - id: navButton - required property var modelData - required property int index - readonly property bool selected: window.currentPageIndex === navButton.index - Layout.fillWidth: true - Layout.preferredHeight: 74 - radius: 8 - color: selected ? "#eef7f7" : navMouse.containsMouse ? "#214957" : "#173743" - border.width: 1 - border.color: selected ? "#9fd4d7" : navMouse.containsMouse ? "#3b6470" : "#254a55" - - Behavior on color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - Behavior on border.color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - - Rectangle { - anchors.right: parent.right - anchors.rightMargin: 6 - anchors.verticalCenter: parent.verticalCenter - width: 4 - height: navButton.selected ? 42 : 0 - radius: 2 - color: tealColor - opacity: navButton.selected ? 1 : 0 - - Behavior on height { - enabled: !resizingWindow - NumberAnimation { - duration: 150 - } - } - Behavior on opacity { - enabled: !resizingWindow - NumberAnimation { - duration: 120 - } - } - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 24 - spacing: compactLayout ? 10 : 12 - - Rectangle { - Layout.preferredWidth: 38 - Layout.preferredHeight: 38 - radius: 8 - color: navButton.selected ? tealSoft : "#2a515d" - - Label { - anchors.centerIn: parent - text: modelData.icon - color: navButton.selected ? tealColor : "#d6e4e9" - font.weight: appBoldWeight - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - - Label { - Layout.fillWidth: true - text: modelData.title - color: navButton.selected ? ink900 : "#f4fbff" - font.pixelSize: compactLayout ? 14 : 15 - font.weight: appBoldWeight - horizontalAlignment: Text.AlignLeft - } - - Label { - Layout.fillWidth: true - text: modelData.hint - visible: !denseLayout - color: navButton.selected ? ink500 : "#adc4cc" - font.pixelSize: compactLayout ? 10 : 11 - horizontalAlignment: Text.AlignLeft - } - } - } - - MouseArea { - id: navMouse - anchors.fill: parent - z: 20 - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - preventStealing: true - onPressed: window.switchPage(navButton.index) - onClicked: window.switchPage(navButton.index) - } - } - } - } - - Item { - Layout.fillHeight: true - } - - PrimaryButton { - Layout.fillWidth: true - text: "刷新全部数据" - onClicked: appController.refreshAll() - } - } - } - - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - radius: 26 - color: "transparent" - - ColumnLayout { - anchors.fill: parent - spacing: 16 - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: phoneLayout ? 108 : (slimLayout ? 96 : 142) - radius: 8 - color: panelStrong - border.color: lineColor - clip: true - - RowLayout { - anchors.fill: parent - anchors.margins: slimLayout ? 14 : 20 - spacing: 18 - - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - spacing: 6 - - Label { - Layout.fillWidth: true - text: ["考勤记录总览", "统计分析面板", "配置与同步中心", "人脸资料管理", "关于"][window.currentPageIndex] - font.pixelSize: slimLayout ? 20 : (compactLayout ? 24 : 28) - font.weight: appBoldWeight - color: ink900 - elide: Text.ElideRight - } - - Label { - visible: !slimLayout - text: ["筛选记录、导出数据并快速查看考勤状态。", "按时间区间查看签到情况,导出统计报表。", "集中管理时间段、映射配置和连接信息。", "同步照片、调整学号映射并执行删除操作。", "了解系统用途、功能范围和运行信息。"][window.currentPageIndex] - color: ink500 - font.pixelSize: compactLayout ? 12 : 13 - } - - Flow { - visible: !slimLayout - Layout.fillWidth: true - spacing: 8 - - InfoChip { - textValue: ["实时筛选", "区间统计", "配置同步", "人脸映射", "系统说明"][window.currentPageIndex] - tintColor: [accentColor, goldColor, tealColor, accentColor, tealColor][window.currentPageIndex] - } - - InfoChip { - textValue: compactLayout ? "紧凑布局" : "桌面布局" - tintColor: tealColor - } - } - } - - Flow { - Layout.alignment: Qt.AlignRight | Qt.AlignVCenter - Layout.preferredWidth: implicitWidth - spacing: compactLayout ? 8 : 10 - - PrimaryButton { - visible: window.currentPageIndex === 0 - text: "导出当前结果" - onClicked: recordExportDialog.open() - } - - SecondaryButton { - visible: window.currentPageIndex === 1 - text: "导出统计报表" - onClicked: statisticsExportDialog.open() - } - - SecondaryButton { - visible: window.currentPageIndex === 2 - text: "测试连接" - onClicked: appController.testConnection() - } - - SecondaryButton { - visible: window.currentPageIndex === 3 - text: "刷新人脸库" - onClicked: appController.refreshFaces() - } - } - } - } - - TabBar { - id: navigationBar - visible: slimLayout - Layout.fillWidth: true - Layout.preferredHeight: visible ? 42 : 0 - spacing: 6 - padding: 0 - property real tabWidth: (width - spacing * 4) / 5 - currentIndex: window.currentPageIndex - onCurrentIndexChanged: window.switchPage(currentIndex) - background: Item {} - - MobileNavTabButton { - width: navigationBar.tabWidth - text: "考勤记录" - } - MobileNavTabButton { - width: navigationBar.tabWidth - text: "统计分析" - } - MobileNavTabButton { - width: navigationBar.tabWidth - text: "配置中心" - } - MobileNavTabButton { - width: navigationBar.tabWidth - text: "人脸管理" - } - MobileNavTabButton { - width: navigationBar.tabWidth - text: "关于" - } - } - - StackLayout { - id: pageLayout - Layout.fillWidth: true - Layout.fillHeight: true - currentIndex: window.currentPageIndex - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - visible: StackLayout.isCurrentItem - opacity: StackLayout.isCurrentItem ? 1 : 0 - scale: resizingWindow || StackLayout.isCurrentItem ? 1 : 0.985 - Behavior on opacity { - enabled: !resizingWindow - NumberAnimation { - duration: 140 - } - } - Behavior on scale { - enabled: !resizingWindow - NumberAnimation { - duration: 180 - easing.type: Easing.OutCubic - } - } - - ColumnLayout { - anchors.fill: parent - spacing: 14 - - GridLayout { - Layout.fillWidth: true - columns: slimLayout ? 3 : (compactLayout ? 1 : 3) - rowSpacing: 10 - columnSpacing: compactLayout ? 10 : 14 - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 82 - radius: 8 - color: panelStrong - border.color: lineColor - clip: true - - Column { - anchors.fill: parent - anchors.topMargin: slimLayout ? 12 : 18 - anchors.bottomMargin: slimLayout ? 12 : 18 - anchors.leftMargin: slimLayout ? 34 : 44 - anchors.rightMargin: slimLayout ? 12 : 18 - spacing: 6 - Label { - text: "总记录数" - color: ink500 - } - Label { - text: appController.totalRecords - font.pixelSize: slimLayout ? 22 : (compactLayout ? 30 : 34) - font.weight: appBoldWeight - color: ink900 - } - } - - Rectangle { - anchors.left: parent.left - anchors.leftMargin: slimLayout ? 12 : 18 - anchors.verticalCenter: parent.verticalCenter - width: 6 - height: Math.min(parent.height - 18, 64) - radius: 3 - color: accentColor - } - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 82 - radius: 8 - color: panelStrong - border.color: lineColor - clip: true - - Column { - anchors.fill: parent - anchors.topMargin: slimLayout ? 12 : 18 - anchors.bottomMargin: slimLayout ? 12 : 18 - anchors.leftMargin: slimLayout ? 34 : 44 - anchors.rightMargin: slimLayout ? 12 : 18 - spacing: 6 - Label { - text: "今日签到" - color: ink500 - } - Label { - text: appController.todayRecords - font.pixelSize: slimLayout ? 22 : (compactLayout ? 30 : 34) - font.weight: appBoldWeight - color: tealColor - } - } - - Rectangle { - anchors.left: parent.left - anchors.leftMargin: slimLayout ? 12 : 18 - anchors.verticalCenter: parent.verticalCenter - width: 6 - height: Math.min(parent.height - 18, 64) - radius: 3 - color: tealColor - } - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 82 - radius: 8 - color: panelStrong - border.color: lineColor - clip: true - - Column { - anchors.fill: parent - anchors.topMargin: slimLayout ? 12 : 18 - anchors.bottomMargin: slimLayout ? 12 : 18 - anchors.leftMargin: slimLayout ? 34 : 44 - anchors.rightMargin: slimLayout ? 12 : 18 - spacing: 6 - Label { - text: "筛选结果" - color: ink500 - } - Label { - text: appController.filteredCount + " / " + appController.loadedRecords - font.pixelSize: slimLayout ? 20 : (compactLayout ? 26 : 30) - font.weight: appBoldWeight - color: goldColor - } - } - - Rectangle { - anchors.left: parent.left - anchors.leftMargin: slimLayout ? 12 : 18 - anchors.verticalCenter: parent.verticalCenter - width: 6 - height: Math.min(parent.height - 18, 64) - radius: 3 - color: goldColor - } - } - } - - Rectangle { - Layout.fillWidth: true - radius: 8 - color: panelStrong - border.color: lineColor - Layout.preferredHeight: phoneLayout ? (dateFilter.currentIndex === 4 ? 300 : 236) : (slimLayout ? (dateFilter.currentIndex === 4 ? 300 : 220) : 98) - clip: true - - GridLayout { - anchors.fill: parent - anchors.margins: slimLayout ? 12 : 14 - columns: slimLayout ? 2 : (dateFilter.currentIndex === 4 ? 6 : 5) - columnSpacing: 12 - rowSpacing: 12 - - Column { - Layout.fillWidth: true - Layout.columnSpan: 1 - Layout.preferredWidth: slimLayout ? 1 : 170 - Layout.minimumWidth: slimLayout ? 0 : 140 - spacing: 6 - Label { - text: "班级" - color: ink700 - font.weight: appBoldWeight - font.pixelSize: 12 - } - AppComboBox { - id: classFilter - width: parent.width - model: appController.classOptions - onCurrentTextChanged: applyRecordFilters() - } - } - - Column { - Layout.fillWidth: true - Layout.columnSpan: 1 - Layout.preferredWidth: slimLayout ? 1 : 170 - Layout.minimumWidth: slimLayout ? 0 : 140 - spacing: 6 - Label { - text: "学号" - color: ink700 - font.weight: appBoldWeight - font.pixelSize: 12 - } - AppComboBox { - id: studentFilter - width: parent.width - model: appController.studentOptions - onCurrentTextChanged: applyRecordFilters() - } - } - - Column { - Layout.fillWidth: true - Layout.columnSpan: 1 - Layout.preferredWidth: slimLayout ? 1 : 230 - Layout.minimumWidth: slimLayout ? 0 : 180 - spacing: 6 - Label { - text: "姓名" - color: ink700 - font.weight: appBoldWeight - font.pixelSize: 12 - } - AppTextField { - id: nameFilter - width: parent.width - placeholderText: "输入姓名关键字" - onTextChanged: applyRecordFilters() - } - } - - Column { - Layout.fillWidth: true - Layout.columnSpan: 1 - Layout.preferredWidth: slimLayout ? 1 : 170 - Layout.minimumWidth: slimLayout ? 0 : 140 - spacing: 6 - Label { - text: "日期" - color: ink700 - font.weight: appBoldWeight - font.pixelSize: 12 - } - AppComboBox { - id: dateFilter - width: parent.width - model: ["全部时间", "今天", "最近7天", "最近30天", "指定日期"] - currentIndex: 0 - onCurrentIndexChanged: applyRecordFilters() - } - } - - Column { - Layout.fillWidth: true - Layout.columnSpan: 1 - Layout.preferredWidth: slimLayout ? 1 : 170 - Layout.minimumWidth: slimLayout ? 0 : 140 - width: dateFilter.currentIndex === 4 ? implicitWidth : 0 - height: dateFilter.currentIndex === 4 ? implicitHeight : 0 - visible: dateFilter.currentIndex === 4 - spacing: 6 - Label { - text: "指定日期" - color: ink700 - font.weight: appBoldWeight - font.pixelSize: 12 - } - AppTextField { - id: specificDateField - width: parent.width - text: Qt.formatDate(new Date(), "yyyy-MM-dd") - placeholderText: "yyyy-MM-dd" - onTextChanged: if (visible) - applyRecordFilters() - } - } - - Item { - Layout.columnSpan: slimLayout ? 2 : 1 - Layout.fillWidth: slimLayout - Layout.preferredWidth: slimLayout ? 1 : (dateFilter.currentIndex === 4 ? 300 : 330) - Layout.minimumWidth: slimLayout ? 0 : 280 - Layout.preferredHeight: slimLayout ? 42 : 64 - Layout.alignment: Qt.AlignBottom - - GridLayout { - anchors.bottom: parent.bottom - anchors.right: parent.right - width: parent.width - columns: 2 - columnSpacing: 8 - rowSpacing: 10 - - SecondaryButton { - Layout.fillWidth: true - Layout.preferredWidth: 1 - text: "刷新" - onClicked: appController.refreshRecords() - } - - DangerButton { - Layout.fillWidth: true - Layout.preferredWidth: 1 - text: "清空记录" - onClicked: clearDialog.open() - } - } - } - } - } - - Rectangle { - id: recordTableCard - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumHeight: slimLayout ? 220 : 0 - radius: 8 - color: panelStrong - border.color: lineColor - property real timeColWidth: phoneLayout ? 112 : Math.max(168, Math.min(220, width * 0.21)) - property real classColWidth: phoneLayout ? 0 : Math.max(88, Math.min(124, width * 0.11)) - property real studentColWidth: phoneLayout ? 64 : Math.max(86, Math.min(116, width * 0.10)) - property real nameColWidth: phoneLayout ? 88 : Math.max(118, Math.min(158, width * 0.14)) - property real confidenceColWidth: phoneLayout ? 0 : Math.max(96, Math.min(126, width * 0.11)) - property real statusColWidth: phoneLayout ? 64 : Math.max(84, Math.min(102, width * 0.09)) - property bool showClassColumn: !denseLayout - property bool showConfidenceColumn: !narrowLayout - - ColumnLayout { - anchors.fill: parent - anchors.margins: 16 - spacing: 8 - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 40 - radius: 8 - color: "#eef2f6" - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - spacing: 12 - - Label { - text: "签到时间" - Layout.preferredWidth: recordTableCard.timeColWidth - font.weight: appBoldWeight - color: ink900 - } - Label { - text: "班级" - visible: recordTableCard.showClassColumn - Layout.preferredWidth: recordTableCard.classColWidth - font.weight: appBoldWeight - color: ink900 - } - Label { - text: "学号" - Layout.preferredWidth: recordTableCard.studentColWidth - font.weight: appBoldWeight - color: ink900 - } - Label { - text: "姓名" - Layout.preferredWidth: recordTableCard.nameColWidth - font.weight: appBoldWeight - color: ink900 - } - Label { - text: "置信度" - visible: recordTableCard.showConfidenceColumn - Layout.preferredWidth: recordTableCard.confidenceColWidth - font.weight: appBoldWeight - color: ink900 - } - Label { - text: "状态" - Layout.preferredWidth: recordTableCard.statusColWidth - font.weight: appBoldWeight - color: ink900 - } - Item { - Layout.fillWidth: true - } - } - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - ListView { - id: recordListView - anchors.fill: parent - clip: true - spacing: 4 - model: appController.recordModel - - delegate: Rectangle { - width: ListView.view.width - height: 44 - property bool hovered: recordHoverHandler.hovered - radius: 8 - color: hovered ? "#f6f9fc" : (index % 2 === 0 ? "#ffffff" : "#fafbfc") - border.color: hovered ? "#cbd5e1" : "#edf0f3" - - Behavior on color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - Behavior on border.color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - - HoverHandler { - id: recordHoverHandler - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - spacing: 12 - - Label { - text: model.timestamp - Layout.preferredWidth: recordTableCard.timeColWidth - color: ink900 - elide: Text.ElideRight - } - Label { - text: model.className - visible: recordTableCard.showClassColumn - Layout.preferredWidth: recordTableCard.classColWidth - color: ink700 - } - Label { - text: model.studentId - Layout.preferredWidth: recordTableCard.studentColWidth - color: ink700 - elide: Text.ElideRight - } - Label { - text: model.name - Layout.preferredWidth: recordTableCard.nameColWidth - color: ink900 - font.weight: appBoldWeight - elide: Text.ElideRight - } - Label { - text: model.confidenceText - visible: recordTableCard.showConfidenceColumn - Layout.preferredWidth: recordTableCard.confidenceColWidth - color: ink700 - } - Rectangle { - Layout.preferredWidth: recordTableCard.statusColWidth - Layout.preferredHeight: 26 - radius: 8 - color: model.success ? "#dbeedb" : "#f6d9d4" - - Label { - anchors.centerIn: parent - text: model.statusText - color: model.success ? successColor : dangerColor - font.weight: appBoldWeight - } - } - Item { - Layout.fillWidth: true - } - } - } - - ScrollBar.vertical: AppScrollBar {} - } - - EmptyState { - anchors.centerIn: parent - visible: recordListView.count === 0 - title: "没有匹配记录" - description: "试着放宽筛选条件,或者先点击顶部按钮刷新最新数据。" - badgeText: "00" - accent: accentColor - } - } - } - } - } - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - visible: StackLayout.isCurrentItem - opacity: StackLayout.isCurrentItem ? 1 : 0 - scale: resizingWindow || StackLayout.isCurrentItem ? 1 : 0.985 - Behavior on opacity { - enabled: !resizingWindow - NumberAnimation { - duration: 140 - } - } - Behavior on scale { - enabled: !resizingWindow - NumberAnimation { - duration: 180 - easing.type: Easing.OutCubic - } - } - - ColumnLayout { - anchors.fill: parent - spacing: 14 - - Rectangle { - Layout.fillWidth: true - radius: 8 - color: panelStrong - border.color: lineColor - Layout.preferredHeight: phoneLayout ? 150 : (slimLayout ? 150 : 78) - clip: true - - GridLayout { - id: statisticsFilterGrid - anchors.fill: parent - anchors.margins: slimLayout ? 12 : 18 - columns: slimLayout ? 2 : 4 - rowSpacing: 10 - columnSpacing: 12 - - AppComboBox { - id: quickStatisticsFilter - Layout.fillWidth: true - Layout.preferredWidth: 1 - model: ["今天", "最近7天", "最近30天", "本月"] - currentIndex: 2 - onCurrentTextChanged: { - statisticsPreset = currentText; - applyStatisticsPreset(); - } - } - - AppTextField { - id: statsStartField - Layout.fillWidth: true - Layout.preferredWidth: 1 - placeholderText: "开始日期" - onEditingFinished: appController.updateStatisticsRange(text, statsEndField.text) - } - - AppTextField { - id: statsEndField - Layout.fillWidth: true - Layout.preferredWidth: 1 - placeholderText: "结束日期" - onEditingFinished: appController.updateStatisticsRange(statsStartField.text, text) - } - - SecondaryButton { - Layout.fillWidth: true - Layout.preferredWidth: 1 - text: "刷新统计" - onClicked: appController.updateStatisticsRange(statsStartField.text, statsEndField.text) - } - } - } - - GridLayout { - Layout.fillWidth: true - columns: phoneLayout ? 2 : (slimLayout ? 3 : (compactLayout ? 1 : 3)) - rowSpacing: 10 - columnSpacing: 14 - - Rectangle { - Layout.fillWidth: true - Layout.preferredWidth: 1 - Layout.preferredHeight: phoneLayout ? 68 : (slimLayout ? 74 : 100) - radius: 8 - color: panelStrong - border.color: lineColor - clip: true - - Column { - anchors.fill: parent - anchors.topMargin: slimLayout ? 12 : 18 - anchors.bottomMargin: slimLayout ? 12 : 18 - anchors.leftMargin: slimLayout ? 34 : 44 - anchors.rightMargin: slimLayout ? 12 : 18 - spacing: 6 - Label { - text: "统计学生数" - color: ink500 - } - Label { - text: appController.statisticsStudentCount - font.pixelSize: slimLayout ? 22 : (compactLayout ? 28 : 32) - font.weight: appBoldWeight - color: ink900 - } - } - - Rectangle { - anchors.left: parent.left - anchors.leftMargin: slimLayout ? 12 : 18 - anchors.verticalCenter: parent.verticalCenter - width: 6 - height: Math.min(parent.height - 28, 64) - radius: 3 - color: accentColor - } - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredWidth: 1 - Layout.preferredHeight: phoneLayout ? 68 : (slimLayout ? 74 : 100) - radius: 8 - color: panelStrong - border.color: lineColor - clip: true - - Column { - anchors.fill: parent - anchors.topMargin: slimLayout ? 12 : 18 - anchors.bottomMargin: slimLayout ? 12 : 18 - anchors.leftMargin: slimLayout ? 34 : 44 - anchors.rightMargin: slimLayout ? 12 : 18 - spacing: 6 - Label { - text: "统计记录数" - color: ink500 - } - Label { - text: appController.statisticsRecordCount - font.pixelSize: slimLayout ? 22 : (compactLayout ? 28 : 32) - font.weight: appBoldWeight - color: tealColor - } - } - - Rectangle { - anchors.left: parent.left - anchors.leftMargin: slimLayout ? 12 : 18 - anchors.verticalCenter: parent.verticalCenter - width: 6 - height: Math.min(parent.height - 28, 64) - radius: 3 - color: tealColor - } - } - - Rectangle { - Layout.columnSpan: phoneLayout ? 2 : 1 - Layout.fillWidth: true - Layout.preferredWidth: 1 - Layout.preferredHeight: phoneLayout ? 68 : (slimLayout ? 74 : 100) - radius: 8 - color: panelStrong - border.color: lineColor - clip: true - - Column { - anchors.fill: parent - anchors.topMargin: slimLayout ? 12 : 18 - anchors.bottomMargin: slimLayout ? 12 : 18 - anchors.leftMargin: slimLayout ? 34 : 44 - anchors.rightMargin: slimLayout ? 12 : 18 - spacing: 6 - Label { - text: "统计范围" - color: ink500 - } - Label { - text: appController.statisticsRangeText - font.pixelSize: slimLayout ? 14 : (compactLayout ? 18 : 20) - font.weight: appBoldWeight - color: goldColor - elide: Text.ElideRight - } - } - - Rectangle { - anchors.left: parent.left - anchors.leftMargin: slimLayout ? 12 : 18 - anchors.verticalCenter: parent.verticalCenter - width: 6 - height: Math.min(parent.height - 28, 64) - radius: 3 - color: goldColor - } - } - } - - Rectangle { - id: statisticsTableCard - Layout.fillWidth: true - Layout.fillHeight: true - Layout.minimumHeight: slimLayout ? 220 : 0 - radius: 8 - color: panelStrong - border.color: lineColor - property real studentColWidth: phoneLayout ? 68 : Math.max(90, Math.min(128, width * 0.12)) - property real nameColWidth: phoneLayout ? 96 : Math.max(128, Math.min(188, width * 0.17)) - property real totalColWidth: phoneLayout ? 92 : Math.max(96, Math.min(124, width * 0.12)) - property real todayColWidth: Math.max(92, Math.min(110, width * 0.10)) - property real lastColWidth: Math.max(180, Math.min(240, width * 0.20)) - property real avgColWidth: Math.max(98, Math.min(126, width * 0.11)) - property bool showTodayColumn: !denseLayout - property bool showLastColumn: !narrowLayout - property bool showAverageColumn: !phoneLayout - - ColumnLayout { - anchors.fill: parent - anchors.margins: 16 - spacing: 8 - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 40 - radius: 8 - color: "#eef2f6" - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - spacing: 12 - - Label { - text: "学号" - Layout.preferredWidth: statisticsTableCard.studentColWidth - font.weight: appBoldWeight - } - Label { - text: "姓名" - Layout.preferredWidth: statisticsTableCard.nameColWidth - font.weight: appBoldWeight - } - Label { - text: "总签到次数" - Layout.preferredWidth: statisticsTableCard.totalColWidth - font.weight: appBoldWeight - } - Label { - text: "今日签到" - visible: statisticsTableCard.showTodayColumn - Layout.preferredWidth: statisticsTableCard.todayColWidth - font.weight: appBoldWeight - } - Label { - text: "最后签到时间" - visible: statisticsTableCard.showLastColumn - Layout.preferredWidth: statisticsTableCard.lastColWidth - font.weight: appBoldWeight - } - Label { - text: "平均置信度" - visible: statisticsTableCard.showAverageColumn - Layout.preferredWidth: statisticsTableCard.avgColWidth - font.weight: appBoldWeight - } - Item { - Layout.fillWidth: true - } - } - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - ListView { - id: statisticsListView - anchors.fill: parent - clip: true - spacing: 8 - model: appController.statisticsModel - - delegate: Rectangle { - width: ListView.view.width - height: 52 - property bool hovered: statisticsHoverHandler.hovered - radius: 16 - color: hovered ? "#f7f5e8" : (index % 2 === 0 ? "#ffffff" : "#f8f3ec") - border.color: hovered ? "#e4d6aa" : (index % 2 === 0 ? "#ece4da" : "#e7ded3") - - Behavior on color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - Behavior on border.color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - - HoverHandler { - id: statisticsHoverHandler - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - spacing: 12 - - Label { - text: model.studentId - Layout.preferredWidth: statisticsTableCard.studentColWidth - color: ink700 - } - Label { - text: model.name - Layout.preferredWidth: statisticsTableCard.nameColWidth - color: ink900 - font.weight: appBoldWeight - } - Label { - text: model.totalCount - Layout.preferredWidth: statisticsTableCard.totalColWidth - color: ink700 - } - Label { - text: model.todayCount - visible: statisticsTableCard.showTodayColumn - Layout.preferredWidth: statisticsTableCard.todayColWidth - color: ink700 - } - Label { - text: model.lastCheckIn - visible: statisticsTableCard.showLastColumn - Layout.preferredWidth: statisticsTableCard.lastColWidth - color: ink700 - } - Label { - text: model.averageConfidenceText - visible: statisticsTableCard.showAverageColumn - Layout.preferredWidth: statisticsTableCard.avgColWidth - color: tealColor - font.weight: appBoldWeight - } - Item { - Layout.fillWidth: true - } - } - } - - ScrollBar.vertical: AppScrollBar {} - } - - EmptyState { - anchors.centerIn: parent - visible: statisticsListView.count === 0 - title: "统计范围内没有数据" - description: "可以切换时间范围,或者先返回记录页检查同步是否正常。" - badgeText: "Σ" - accent: goldColor - } - } - } - } - } - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - visible: StackLayout.isCurrentItem - opacity: StackLayout.isCurrentItem ? 1 : 0 - scale: resizingWindow || StackLayout.isCurrentItem ? 1 : 0.985 - Behavior on opacity { - enabled: !resizingWindow - NumberAnimation { - duration: 140 - } - } - Behavior on scale { - enabled: !resizingWindow - NumberAnimation { - duration: 180 - easing.type: Easing.OutCubic - } - } - - ScrollView { - id: configScrollView - anchors.fill: parent - clip: true - contentWidth: availableWidth - contentHeight: configContentLayout.implicitHeight - - ColumnLayout { - id: configContentLayout - width: configScrollView.availableWidth - spacing: 14 - - Rectangle { - Layout.fillWidth: true - radius: 8 - color: panelStrong - border.color: lineColor - Layout.preferredHeight: serverConfigGrid.implicitHeight + (slimLayout ? 24 : 36) - clip: true - - GridLayout { - id: serverConfigGrid - anchors.fill: parent - anchors.margins: slimLayout ? 12 : 18 - columns: slimLayout ? 1 : 4 - rowSpacing: 12 - columnSpacing: 12 - - Label { - Layout.alignment: Qt.AlignVCenter - text: "服务器地址" - font.weight: appBoldWeight - color: ink700 - } - AppTextField { - id: serverUrlField - Layout.fillWidth: true - Layout.preferredWidth: slimLayout ? 0 : 520 - text: appController.serverUrl - placeholderText: "http://IP:端口" - } - - SecondaryButton { - Layout.fillWidth: slimLayout - text: "保存地址" - onClicked: appController.serverUrl = serverUrlField.text - } - - PrimaryButton { - Layout.fillWidth: slimLayout - text: "测试连接" - fillColor: tealColor - onClicked: appController.testConnection() - } - } - } - - Rectangle { - Layout.fillWidth: true - radius: 8 - color: panelStrong - border.color: lineColor - Layout.preferredHeight: timeSlotCardLayout.implicitHeight + 36 - clip: true - - ColumnLayout { - id: timeSlotCardLayout - anchors.fill: parent - anchors.margins: 18 - spacing: 12 - - ColumnLayout { - Layout.fillWidth: true - spacing: 12 - - SectionHeader { - Layout.fillWidth: true - title: "时间段配置" - description: "维护签到与签退时段,并同步到服务端。" - accent: accentColor - } - GridLayout { - Layout.fillWidth: true - columns: slimLayout ? 3 : 5 - columnSpacing: 8 - rowSpacing: 8 - - SecondaryButton { - Layout.fillWidth: true - text: timeSlotsExpanded ? "收起" : "展开" - onClicked: timeSlotsExpanded = !timeSlotsExpanded - } - SecondaryButton { - Layout.fillWidth: true - visible: timeSlotsExpanded - text: "新增时段" - onClicked: openTimeSlotDialog(-1) - } - SecondaryButton { - Layout.fillWidth: true - visible: timeSlotsExpanded - text: "清空时段" - onClicked: { - appController.timeSlotModel.resetToDefault(); - appController.saveTimeSlots(); - } - } - SecondaryButton { - Layout.fillWidth: true - visible: timeSlotsExpanded - text: slimLayout ? "同步" : "从服务器同步" - onClicked: appController.reloadTimeSlotsFromServer() - } - PrimaryButton { - Layout.fillWidth: true - visible: timeSlotsExpanded - text: slimLayout ? "保存" : "保存时间段" - onClicked: appController.saveTimeSlots() - } - } - } - - Rectangle { - visible: timeSlotsExpanded && !phoneLayout - Layout.fillWidth: true - Layout.preferredHeight: visible ? 42 : 0 - radius: 8 - color: "#efe7dd" - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - Label { - text: "名称" - Layout.preferredWidth: 220 - font.weight: appBoldWeight - } - Label { - text: "开始" - Layout.preferredWidth: 120 - font.weight: appBoldWeight - } - Label { - text: "结束" - Layout.preferredWidth: 120 - font.weight: appBoldWeight - } - Label { - text: "状态" - Layout.preferredWidth: 100 - font.weight: appBoldWeight - } - Item { - Layout.fillWidth: true - } - } - } - - ListView { - id: timeSlotListView - visible: timeSlotsExpanded - Layout.fillWidth: true - Layout.preferredHeight: visible ? Math.max(phoneLayout ? 96 : 52, contentHeight) : 0 - clip: false - interactive: false - spacing: 8 - model: appController.timeSlotModel - - delegate: Rectangle { - width: ListView.view.width - height: phoneLayout ? 96 : 52 - property bool hovered: timeSlotHoverHandler.hovered - radius: 16 - color: hovered ? "#f9f1ea" : (index % 2 === 0 ? "#ffffff" : "#f8f3ec") - border.color: hovered ? "#e5cfc1" : (index % 2 === 0 ? "#ece4da" : "#e7ded3") - - Behavior on color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - Behavior on border.color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - - HoverHandler { - id: timeSlotHoverHandler - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - visible: !phoneLayout - spacing: 12 - - Label { - text: model.name - Layout.preferredWidth: 220 - } - Label { - text: model.startTime - Layout.preferredWidth: 120 - } - Label { - text: model.endTime - Layout.preferredWidth: 120 - } - Label { - text: model.enabled ? "启用" : "禁用" - Layout.preferredWidth: 100 - color: model.enabled ? successColor : goldColor - font.weight: appBoldWeight - } - Item { - Layout.fillWidth: true - } - SecondaryButton { - text: "编辑" - onClicked: openTimeSlotDialog(index) - } - SecondaryButton { - text: "删除" - onClicked: { - appController.timeSlotModel.removeTimeSlot(index); - appController.saveTimeSlots(); - } - } - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 12 - visible: phoneLayout - spacing: 8 - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - Label { - text: model.name - Layout.fillWidth: true - color: ink900 - font.pixelSize: 15 - font.weight: appBoldWeight - elide: Text.ElideRight - } - Label { - text: model.enabled ? "启用" : "禁用" - color: model.enabled ? successColor : goldColor - font.pixelSize: 12 - font.weight: appBoldWeight - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - Label { - text: model.startTime + " - " + model.endTime - Layout.fillWidth: true - color: ink700 - font.pixelSize: 13 - } - SecondaryButton { - Layout.preferredWidth: 76 - text: "编辑" - onClicked: openTimeSlotDialog(index) - } - SecondaryButton { - Layout.preferredWidth: 76 - text: "删除" - onClicked: { - appController.timeSlotModel.removeTimeSlot(index); - appController.saveTimeSlots(); - } - } - } - } - } - } - } - } - - Rectangle { - Layout.fillWidth: true - radius: 8 - color: panelStrong - border.color: lineColor - Layout.preferredHeight: mappingCardLayout.implicitHeight + 36 - clip: true - - ColumnLayout { - id: mappingCardLayout - anchors.fill: parent - anchors.margins: 18 - spacing: 12 - - ColumnLayout { - Layout.fillWidth: true - spacing: 12 - SectionHeader { - Layout.fillWidth: true - title: "名称映射配置" - description: "让学号和班级编号显示为更清晰的真实名称。" - accent: tealColor - } - GridLayout { - Layout.fillWidth: true - columns: phoneLayout ? 3 : 3 - columnSpacing: 8 - rowSpacing: 8 - - SecondaryButton { - Layout.fillWidth: true - text: mappingsExpanded ? "收起" : "展开" - onClicked: mappingsExpanded = !mappingsExpanded - } - SecondaryButton { - Layout.fillWidth: true - visible: mappingsExpanded - text: phoneLayout ? "加载" : "从服务器加载" - onClicked: appController.loadMappingsFromServer() - } - PrimaryButton { - Layout.fillWidth: true - visible: mappingsExpanded - text: "保存映射" - fillColor: tealColor - onClicked: appController.saveMappings() - } - } - } - - TabBar { - id: mappingTabs - visible: mappingsExpanded - Layout.fillWidth: true - Layout.preferredHeight: visible ? 42 : 0 - spacing: 8 - padding: 0 - background: Rectangle { - color: "transparent" - } - - SegmentTabButton { - width: (mappingTabs.width - mappingTabs.spacing) / 2 - text: "学号 → 姓名" - } - SegmentTabButton { - width: (mappingTabs.width - mappingTabs.spacing) / 2 - text: "班级编号 → 名称" - } - } - - StackLayout { - visible: mappingsExpanded - Layout.fillWidth: true - Layout.preferredHeight: visible ? Math.max(phoneLayout ? 112 : 64, mappingTabs.currentIndex === 0 ? studentMappingListView.contentHeight : classMappingListView.contentHeight) : 0 - currentIndex: mappingTabs.currentIndex - - Rectangle { - color: "transparent" - - ListView { - id: studentMappingListView - anchors.fill: parent - clip: false - interactive: false - spacing: 8 - model: appController.studentMappingModel - - header: RowLayout { - width: ListView.view.width - spacing: 12 - - SecondaryButton { - text: "新增映射" - onClicked: appController.studentMappingModel.addEntry("", "") - } - - Item { - Layout.fillWidth: true - } - } - - delegate: Rectangle { - width: ListView.view.width - height: phoneLayout ? 112 : 56 - property bool hovered: studentMappingHoverHandler.hovered - radius: 16 - color: hovered ? "#f9f0ea" : (index % 2 === 0 ? "#ffffff" : "#f8f3ec") - border.color: hovered ? "#e5cfc1" : (index % 2 === 0 ? "#ece4da" : "#e7ded3") - - Behavior on color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - Behavior on border.color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - - HoverHandler { - id: studentMappingHoverHandler - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - visible: !phoneLayout - spacing: 12 - - AppTextField { - id: studentKeyField - Layout.preferredWidth: 220 - text: model.entryKey - placeholderText: "学号" - onEditingFinished: appController.studentMappingModel.updateEntry(index, text, studentValueField.text) - } - - AppTextField { - id: studentValueField - Layout.preferredWidth: 240 - text: model.entryValue - placeholderText: "真实姓名" - onEditingFinished: appController.studentMappingModel.updateEntry(index, studentKeyField.text, text) - } - - Item { - Layout.fillWidth: true - } - SecondaryButton { - text: "删除" - onClicked: appController.studentMappingModel.removeEntry(index) - } - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 12 - visible: phoneLayout - spacing: 8 - - AppTextField { - id: studentKeyFieldPhone - Layout.fillWidth: true - text: model.entryKey - placeholderText: "学号" - onEditingFinished: appController.studentMappingModel.updateEntry(index, text, studentValueFieldPhone.text) - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - AppTextField { - id: studentValueFieldPhone - Layout.fillWidth: true - text: model.entryValue - placeholderText: "真实姓名" - onEditingFinished: appController.studentMappingModel.updateEntry(index, studentKeyFieldPhone.text, text) - } - - SecondaryButton { - Layout.preferredWidth: 76 - text: "删除" - onClicked: appController.studentMappingModel.removeEntry(index) - } - } - } - } - } - } - - Rectangle { - color: "transparent" - - ListView { - id: classMappingListView - anchors.fill: parent - clip: false - interactive: false - spacing: 8 - model: appController.classMappingModel - - header: RowLayout { - width: ListView.view.width - spacing: 12 - - SecondaryButton { - text: "新增映射" - onClicked: appController.classMappingModel.addEntry("", "") - } - - Item { - Layout.fillWidth: true - } - } - - delegate: Rectangle { - width: ListView.view.width - height: phoneLayout ? 112 : 56 - property bool hovered: classMappingHoverHandler.hovered - radius: 16 - color: hovered ? "#eef5f4" : (index % 2 === 0 ? "#ffffff" : "#f8f3ec") - border.color: hovered ? "#c7dddd" : (index % 2 === 0 ? "#ece4da" : "#e7ded3") - - Behavior on color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - Behavior on border.color { - enabled: !resizingWindow - ColorAnimation { - duration: 120 - } - } - - HoverHandler { - id: classMappingHoverHandler - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 12 - anchors.rightMargin: 12 - visible: !phoneLayout - spacing: 12 - - AppTextField { - id: classKeyField - Layout.preferredWidth: 220 - text: model.entryKey - placeholderText: "班级编号" - onEditingFinished: appController.classMappingModel.updateEntry(index, text, classValueField.text) - } - - AppTextField { - id: classValueField - Layout.preferredWidth: 260 - text: model.entryValue - placeholderText: "班级名称" - onEditingFinished: appController.classMappingModel.updateEntry(index, classKeyField.text, text) - } - - Item { - Layout.fillWidth: true - } - SecondaryButton { - text: "删除" - onClicked: appController.classMappingModel.removeEntry(index) - } - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 12 - visible: phoneLayout - spacing: 8 - - AppTextField { - id: classKeyFieldPhone - Layout.fillWidth: true - text: model.entryKey - placeholderText: "班级编号" - onEditingFinished: appController.classMappingModel.updateEntry(index, text, classValueFieldPhone.text) - } - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - AppTextField { - id: classValueFieldPhone - Layout.fillWidth: true - text: model.entryValue - placeholderText: "班级名称" - onEditingFinished: appController.classMappingModel.updateEntry(index, classKeyFieldPhone.text, text) - } - - SecondaryButton { - Layout.preferredWidth: 76 - text: "删除" - onClicked: appController.classMappingModel.removeEntry(index) - } - } - } - } - } - } - } - } - } - - Item { - Layout.fillWidth: true - Layout.preferredHeight: 14 - } - } - } - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - visible: StackLayout.isCurrentItem - opacity: StackLayout.isCurrentItem ? 1 : 0 - scale: resizingWindow || StackLayout.isCurrentItem ? 1 : 0.985 - Behavior on opacity { - enabled: !resizingWindow - NumberAnimation { - duration: 140 - } - } - Behavior on scale { - enabled: !resizingWindow - NumberAnimation { - duration: 180 - easing.type: Easing.OutCubic - } - } - - ColumnLayout { - anchors.fill: parent - spacing: 14 - - Rectangle { - Layout.fillWidth: true - radius: 8 - color: panelStrong - border.color: lineColor - Layout.preferredHeight: phoneLayout ? 170 : (slimLayout ? 132 : 88) - clip: true - - GridLayout { - anchors.fill: parent - anchors.margins: slimLayout ? 12 : 18 - columns: phoneLayout ? 1 : 2 - rowSpacing: 12 - columnSpacing: 12 - - SectionHeader { - Layout.fillWidth: true - title: "K210 人脸照片与学号映射" - description: "同步照片状态,维护人脸与真实学号的对应关系。" - accent: goldColor - } - - Flow { - Layout.fillWidth: phoneLayout - Layout.alignment: phoneLayout ? Qt.AlignLeft : (Qt.AlignRight | Qt.AlignVCenter) - spacing: 8 - - SecondaryButton { - text: "刷新" - onClicked: appController.refreshFaces() - } - PrimaryButton { - text: "保存映射" - onClicked: appController.saveFaceMappings() - } - } - } - } - - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - radius: 8 - color: panelStrong - border.color: lineColor - - GridView { - id: faceGridView - anchors.fill: parent - anchors.margins: 16 - clip: true - property int cardColumns: phoneLayout ? 1 : Math.max(1, Math.floor(width / (compactLayout ? 236 : 260))) - cellWidth: Math.max(phoneLayout ? 280 : 240, Math.floor(width / cardColumns)) - cellHeight: phoneLayout ? 392 : 352 - model: appController.faceModel - - delegate: Rectangle { - width: Math.min(phoneLayout ? 300 : 244, GridView.view.cellWidth - 18) - height: phoneLayout ? 368 : 328 - property bool hovered: faceHoverHandler.hovered - radius: 8 - color: hovered ? "#fff6ef" : "#ffffff" - border.color: hovered ? "#e6c8b8" : "#e8ddd0" - - Behavior on color { - enabled: !resizingWindow - ColorAnimation { - duration: 140 - } - } - Behavior on border.color { - enabled: !resizingWindow - ColorAnimation { - duration: 140 - } - } - - HoverHandler { - id: faceHoverHandler - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 14 - spacing: 10 - - Rectangle { - Layout.alignment: Qt.AlignHCenter - Layout.fillWidth: true - Layout.preferredHeight: phoneLayout ? 140 : (compactLayout ? 152 : 168) - radius: 16 - color: model.hasPhoto ? "#eef3f5" : goldSoft - border.color: model.hasPhoto ? "#dbe4ea" : "#eadbb0" - - Image { - anchors.fill: parent - anchors.margins: 8 - fillMode: Image.PreserveAspectFit - source: model.photoUrl - visible: model.hasPhoto - } - - Label { - anchors.centerIn: parent - visible: !model.hasPhoto - text: "无照片" - color: goldColor - font.weight: appBoldWeight - } - } - - Label { - text: "ID: " + model.faceId - color: ink900 - font.pixelSize: compactLayout ? 15 : 16 - font.weight: appBoldWeight - } - - AppTextField { - Layout.fillWidth: true - text: model.studentId - placeholderText: narrowLayout ? "学号" : "输入真实学号" - onEditingFinished: appController.faceModel.updateStudentId(index, text) - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 34 - radius: 12 - color: model.hasPhoto ? "#e6f4ec" : "#f9ebcf" - - Label { - anchors.centerIn: parent - text: model.hasPhoto ? "照片已同步" : "仅检测到记录,无照片" - color: model.hasPhoto ? successColor : goldColor - font.pixelSize: narrowLayout ? 11 : 12 - font.weight: appBoldWeight - } - } - - Item { - Layout.fillHeight: true - } - - DangerButton { - Layout.fillWidth: true - text: "删除该人脸" - onClicked: { - pendingDeleteFaceId = model.faceId; - deleteFaceRecordsCheck.checked = false; - deleteFaceDialog.open(); - } - } - } - } - - ScrollBar.vertical: AppScrollBar {} - } - - EmptyState { - anchors.centerIn: parent - visible: faceGridView.count === 0 - title: "还没有可管理的人脸数据" - description: "等 K210 上传照片或服务端同步学号后,这里会自动显示对应卡片。" - badgeText: "人" - accent: tealColor - } - } - } - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - visible: StackLayout.isCurrentItem - opacity: StackLayout.isCurrentItem ? 1 : 0 - scale: resizingWindow || StackLayout.isCurrentItem ? 1 : 0.985 - Behavior on opacity { - enabled: !resizingWindow - NumberAnimation { - duration: 140 - } - } - Behavior on scale { - enabled: !resizingWindow - NumberAnimation { - duration: 180 - easing.type: Easing.OutCubic - } - } - - Rectangle { - anchors.fill: parent - radius: 8 - color: panelStrong - border.color: lineColor - clip: true - - ColumnLayout { - anchors.fill: parent - anchors.margins: phoneLayout ? 16 : 24 - spacing: phoneLayout ? 12 : 18 - - SectionHeader { - Layout.fillWidth: true - title: "人脸识别考勤系统" - description: "用于连接考勤服务端,集中查看签到记录、统计考勤情况、维护签到时段、管理名称映射与人脸照片绑定。" - accent: tealColor - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: lineColor - } - - Label { - Layout.fillWidth: true - text: "本客户端基于 Qt Quick/QML 构建,面向人脸识别考勤场景。它把记录查询、统计导出、服务连接配置、时间段配置、学生与班级名称映射、人脸资料管理整合在一个窗口中,减少多窗口切换。" - wrapMode: Text.WordWrap - color: ink700 - font.pixelSize: 14 - lineHeight: 1.25 - } - - Label { - Layout.fillWidth: true - text: "数据通过服务端 API 同步;记录和统计结果可导出为 CSV,配置项可保存后同步到服务端。" - wrapMode: Text.WordWrap - color: ink700 - font.pixelSize: 14 - lineHeight: 1.25 - } - - Item { - Layout.fillHeight: true - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: phoneLayout ? 188 : 164 - radius: 8 - color: "#f8fafb" - border.color: lineColor - - ColumnLayout { - anchors.fill: parent - anchors.margins: 16 - spacing: 8 - - Label { - Layout.fillWidth: true - text: "毕业设计说明" - color: ink900 - font.pixelSize: 16 - font.weight: appBoldWeight - } - - TextArea { - Layout.fillWidth: true - Layout.fillHeight: true - text: "本系统为纪田野的个人毕业设计作品,仅用于毕业设计展示、学习研究与系统功能验证。\n\nCopyright © 2026 纪田野。保留所有权利。\n\n仓库地址:https://git.yzfh.eu.cc/yzfh/qt.git\n\n演示账号:jtybishe 密码:jtybishe" - readOnly: true - selectByMouse: true - selectByKeyboard: true - wrapMode: TextEdit.WrapAnywhere - color: ink700 - font.pixelSize: 12 - leftPadding: 0 - rightPadding: 0 - topPadding: 0 - bottomPadding: 0 - background: Rectangle { - color: "transparent" - } - } - } - } - } - } - } - } - - Item { - Layout.fillWidth: true - Layout.preferredHeight: 0 - } - } - } - } - } - - Rectangle { - width: Math.min(420, Math.max(220, toastLabel.implicitWidth + 44)) - height: 52 - radius: 16 - color: "#16323d" - border.color: "#264754" - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.rightMargin: 30 - anchors.bottomMargin: 28 - opacity: toastVisible ? 0.98 : 0 - visible: opacity > 0 - y: toastVisible ? parent.height - height - 28 : parent.height - height - 10 - - Behavior on opacity { - enabled: !resizingWindow - NumberAnimation { - duration: 180 - } - } - Behavior on y { - enabled: !resizingWindow - NumberAnimation { - duration: 180 - easing.type: Easing.OutCubic - } - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: 16 - anchors.rightMargin: 16 - spacing: 10 - - Rectangle { - Layout.preferredWidth: 10 - Layout.preferredHeight: 10 - radius: 5 - color: accentColor - } - - Label { - id: toastLabel - text: toastMessage - color: "#f8fafc" - elide: Label.ElideRight - Layout.fillWidth: true - } - } - } -} diff --git a/QT/src/ApiClient.cpp b/QT/src/ApiClient.cpp deleted file mode 100644 index 1c25f59..0000000 --- a/QT/src/ApiClient.cpp +++ /dev/null @@ -1,673 +0,0 @@ -#include "ApiClient.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "CryptoUtils.h" - -namespace { -constexpr int NetworkTimeoutMs = 5000; -constexpr auto DefaultApiToken = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV"; -} - -ApiClient::ApiClient(QObject *parent) - : QObject(parent) - , m_serverUrl("http://127.0.0.1:8082") -{ -} - -void ApiClient::setServerUrl(const QString &url) -{ - const QString trimmed = url.trimmed(); - if (!trimmed.isEmpty()) { - m_serverUrl = trimmed; - } -} - -QString ApiClient::serverUrl() const -{ - return m_serverUrl; -} - -QString ApiClient::buildUrl(const QString &endpoint, const QMap ¶ms) const -{ - QString baseUrl = m_serverUrl.trimmed(); - while (baseUrl.endsWith('/')) { - baseUrl.chop(1); - } - QString url = baseUrl + "/api" + endpoint; - if (!params.isEmpty()) { - QUrlQuery query; - for (auto it = params.begin(); it != params.end(); ++it) { - query.addQueryItem(it.key(), it.value()); - } - url += "?" + query.toString(QUrl::FullyEncoded); - } - return url; -} - -QNetworkRequest ApiClient::createRequest(const QString &url, bool encryptedResponse, bool jsonBody) const -{ - QNetworkRequest request{QUrl(url)}; - if (encryptedResponse) { - request.setRawHeader("X-Encrypted-Response", "true"); - } - const QString apiToken = QProcessEnvironment::systemEnvironment() - .value(QStringLiteral("ATTENDANCE_API_TOKEN"), - QString::fromLatin1(DefaultApiToken)); - request.setRawHeader("X-Api-Token", apiToken.toUtf8()); - request.setRawHeader("Accept", "application/json"); - if (jsonBody) { - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - } - request.setTransferTimeout(NetworkTimeoutMs); - return request; -} - -void ApiClient::fetchSyncState(qulonglong lastVersion) -{ - const QString url = buildUrl("/v1/sync/state", { - {"client", "qt"}, - {"last_version", QString::number(lastVersion)} - }); - qInfo() << "[ApiClient] GET sync state" << url; - - QNetworkReply *reply = m_networkManager.get(createRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("同步状态加载失败"), reply); - reply->deleteLater(); - return; - } - - const QJsonObject root = QJsonDocument::fromJson(data).object(); - const qulonglong version = static_cast(root.value("version").toDouble(0)); - const bool changed = root.value("changed").toBool(false); - if (!changed) { - emit syncStateFetched(version, false); - reply->deleteLater(); - return; - } - - const QJsonObject state = root.value("state").toObject(); - - if (state.contains("records")) { - emit recordsFetched(parseRecords(QJsonDocument(state.value("records").toArray()).toJson(QJsonDocument::Compact))); - } - - if (state.contains("statistics")) { - const QJsonObject object = state.value("statistics").toObject(); - emit statisticsFetched(object.value("total").toInt(), - object.value("today").toInt(), - object.value("server_time").toString()); - } - - if (state.contains("time_slots")) { - QVector timeSlots; - const QJsonArray array = state.value("time_slots").toArray(); - for (const QJsonValue &value : array) { - const QJsonObject object = value.toObject(); - timeSlots.push_back({ - object.value("name").toString(), - QTime::fromString(object.value("start_time").toString(), "HH:mm"), - QTime::fromString(object.value("end_time").toString(), "HH:mm"), - object.value("enabled").toBool(true) - }); - } - emit timeSlotsFetched(timeSlots); - } - - if (state.contains("mapping_config")) { - const QJsonObject mappingConfig = state.value("mapping_config").toObject(); - QMap studentMap; - QMap classMap; - - const QJsonObject studentObject = mappingConfig.value("student_name_map").toObject(); - for (auto it = studentObject.begin(); it != studentObject.end(); ++it) { - studentMap[it.key()] = it.value().toString(); - } - - const QJsonObject classObject = mappingConfig.value("class_name_map").toObject(); - for (auto it = classObject.begin(); it != classObject.end(); ++it) { - classMap[it.key()] = it.value().toString(); - } - emit mappingConfigFetched(studentMap, classMap); - } - - if (state.contains("face_mappings")) { - const QJsonObject mappings = state.value("face_mappings").toObject(); - QMap mapping; - for (auto it = mappings.begin(); it != mappings.end(); ++it) { - mapping[it.key()] = it.value().toString(); - } - emit faceIdMappingFetched(mapping); - } - - if (state.contains("face_photos")) { - QStringList ids; - const QJsonArray photos = state.value("face_photos").toArray(); - for (const QJsonValue &value : photos) { - ids.push_back(value.toObject().value("id").toString()); - } - emit facePhotosFetched(ids); - } - - if (state.contains("all_student_ids")) { - QStringList ids; - const QJsonArray array = state.value("all_student_ids").toArray(); - for (const QJsonValue &value : array) { - ids.push_back(value.toString()); - } - emit allStudentIdsFetched(ids); - } - - emit syncStateFetched(version, true); - reply->deleteLater(); - }); -} - -QByteArray ApiClient::maybeDecrypt(const QByteArray &data, bool encrypted) const -{ - if (!encrypted) { - return data; - } - const QByteArray trimmed = data.trimmed(); - if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - return trimmed; - } - return CryptoUtils::decryptAes128CbcBase64(trimmed); -} - -bool ApiClient::isConnectionError(QNetworkReply::NetworkError error) const -{ - switch (error) { - case QNetworkReply::ConnectionRefusedError: - case QNetworkReply::RemoteHostClosedError: - case QNetworkReply::HostNotFoundError: - case QNetworkReply::TimeoutError: - case QNetworkReply::OperationCanceledError: - case QNetworkReply::SslHandshakeFailedError: - case QNetworkReply::TemporaryNetworkFailureError: - case QNetworkReply::NetworkSessionFailedError: - case QNetworkReply::BackgroundRequestNotAllowedError: - case QNetworkReply::TooManyRedirectsError: - case QNetworkReply::InsecureRedirectError: - case QNetworkReply::UnknownNetworkError: - case QNetworkReply::UnknownProxyError: - case QNetworkReply::UnknownContentError: - case QNetworkReply::ProtocolFailure: - case QNetworkReply::UnknownServerError: - return true; - default: - return false; - } -} - -void ApiClient::emitNetworkError(const QString &prefix, QNetworkReply *reply) -{ - emit networkError(prefix); - if (isConnectionError(reply->error())) { - emit connectionUnavailable(prefix); - } -} - -QVector ApiClient::parseRecords(const QByteArray &data) const -{ - QVector records; - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isArray()) { - return records; - } - - const QJsonArray array = doc.array(); - records.reserve(array.size()); - for (const QJsonValue &value : array) { - const QJsonObject object = value.toObject(); - AttendanceRecord record; - record.timestamp = object.value("timestamp").toString().trimmed(); - record.studentId = object.value("student_id").toString().trimmed(); - record.originalName = object.value("name").toString().trimmed(); - record.confidence = object.value("confidence").toDouble(); - const QJsonValue statusValue = object.value("status"); - if (statusValue.isBool()) { - record.status = statusValue.toBool() ? QStringLiteral("success") : QStringLiteral("failed"); - } else if (statusValue.isDouble()) { - record.status = QString::number(statusValue.toInt()); - } else { - record.status = statusValue.toString().trimmed(); - } - record.classId = object.value("class_id").toString().trimmed(); - if (record.classId.isEmpty()) { - record.classId = "0"; - } - if (!record.timestamp.startsWith("0000-") && !record.timestamp.isEmpty() && !record.studentId.isEmpty()) { - records.push_back(record); - } - } - return records; -} - -void ApiClient::fetchRecords(int limit, int offset) -{ - const QString url = buildUrl("/v1/attendance/records", { - {"limit", QString::number(limit)}, - {"offset", QString::number(offset)} - }); - qInfo() << "[ApiClient] GET" << url; - QNetworkReply *reply = m_networkManager.get(createRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray rawData = reply->readAll(); - const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - qInfo() << "[ApiClient] records status=" << statusCode - << "error=" << reply->error() - << "bytes=" << rawData.size() - << "body=" << rawData.left(200); - if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("加载记录失败"), reply); - reply->deleteLater(); - return; - } - - const QByteArray data = maybeDecrypt(rawData, false); - if (data.isEmpty()) { - emit networkError("记录数据为空"); - reply->deleteLater(); - return; - } - - QJsonParseError jsonError{}; - const QJsonDocument doc = QJsonDocument::fromJson(data, &jsonError); - if (jsonError.error != QJsonParseError::NoError || !doc.isArray()) { - emit networkError(QStringLiteral("记录数据解析失败: %1").arg(jsonError.errorString())); - reply->deleteLater(); - return; - } - - const QVector records = parseRecords(data); - qInfo() << "[ApiClient] parsed records=" << records.size(); - emit recordsFetched(records); - reply->deleteLater(); - }); -} - -void ApiClient::fetchStatistics() -{ - const QString url = buildUrl("/v1/attendance/statistics"); - qInfo() << "[ApiClient] GET" << url; - QNetworkReply *reply = m_networkManager.get(createRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray rawData = reply->readAll(); - const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - qInfo() << "[ApiClient] statistics status=" << statusCode - << "error=" << reply->error() - << "bytes=" << rawData.size() - << "body=" << rawData.left(200); - if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("加载统计失败"), reply); - reply->deleteLater(); - return; - } - - const QByteArray data = maybeDecrypt(rawData, false); - if (data.isEmpty()) { - emit networkError("统计数据为空"); - reply->deleteLater(); - return; - } - - const QJsonDocument doc = QJsonDocument::fromJson(data); - const QJsonObject object = doc.object(); - emit statisticsFetched(object.value("total").toInt(), - object.value("today").toInt(), - object.value("server_time").toString()); - reply->deleteLater(); - }); -} - -void ApiClient::clearAllRecords() -{ - QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/attendance/records/clear"), false, true), QByteArray()); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - if (isConnectionError(reply->error())) { - emit connectionUnavailable(QStringLiteral("清空失败: %1").arg(reply->errorString())); - } - emit recordsCleared(false, reply->errorString()); - reply->deleteLater(); - return; - } - - const QJsonObject object = QJsonDocument::fromJson(data).object(); - emit recordsCleared(object.value("success").toBool(), object.value("message").toString()); - reply->deleteLater(); - }); -} - -void ApiClient::deleteRecord(const QString ×tamp, const QString &studentId) -{ - QNetworkReply *reply = m_networkManager.deleteResource(createRequest(buildUrl("/v1/attendance/records", { - {"timestamp", timestamp}, - {"student_id", studentId} - }))); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - if (isConnectionError(reply->error())) { - emit connectionUnavailable(QStringLiteral("记录删除失败: %1").arg(reply->errorString())); - } - emit recordDeleted(false, reply->errorString()); - reply->deleteLater(); - return; - } - - const QJsonObject object = QJsonDocument::fromJson(data).object(); - emit recordDeleted(object.value("success").toBool(), object.value("message").toString()); - reply->deleteLater(); - }); -} - -void ApiClient::fetchTimeSlots() -{ - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/time-slots"))); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("加载时间段失败"), reply); - reply->deleteLater(); - return; - } - - QVector timeSlots; - const QJsonObject root = QJsonDocument::fromJson(data).object(); - const QJsonArray array = root.value("time_slots").toArray(); - for (const QJsonValue &value : array) { - const QJsonObject object = value.toObject(); - timeSlots.push_back({ - object.value("name").toString(), - QTime::fromString(object.value("start_time").toString(), "HH:mm"), - QTime::fromString(object.value("end_time").toString(), "HH:mm"), - object.value("enabled").toBool(true) - }); - } - emit timeSlotsFetched(timeSlots); - reply->deleteLater(); - }); -} - -void ApiClient::uploadTimeSlots(const QVector &timeSlots) -{ - QJsonArray array; - for (const TimeSlot &slot : timeSlots) { - QJsonObject object; - object["name"] = slot.name; - object["start_time"] = slot.startTime.toString("HH:mm"); - object["end_time"] = slot.endTime.toString("HH:mm"); - object["enabled"] = slot.enabled; - array.append(object); - } - - QJsonObject payload; - payload["time_slots"] = array; - const QByteArray body = QJsonDocument(payload).toJson(QJsonDocument::Compact); - qInfo() << "[ApiClient] POST" << buildUrl("/v1/config/time-slots") - << "bytes=" << body.size() - << "body=" << body.left(300); - QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/time-slots"), false, true), body); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - if (isConnectionError(reply->error())) { - emit connectionUnavailable(QStringLiteral("时间段同步失败: %1").arg(reply->errorString())); - } - emit timeSlotsUploaded(false, reply->errorString()); - reply->deleteLater(); - return; - } - const QJsonObject object = QJsonDocument::fromJson(data).object(); - emit timeSlotsUploaded(object.value("success").toBool(), object.value("message").toString()); - reply->deleteLater(); - }); -} - -void ApiClient::fetchMappingConfig() -{ - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/config/mappings"))); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("加载映射配置失败"), reply); - reply->deleteLater(); - return; - } - - const QJsonObject root = QJsonDocument::fromJson(data).object(); - QMap studentMap; - QMap classMap; - - const QJsonObject studentObject = root.value("student_name_map").toObject(); - for (auto it = studentObject.begin(); it != studentObject.end(); ++it) { - studentMap[it.key()] = it.value().toString(); - } - - const QJsonObject classObject = root.value("class_name_map").toObject(); - for (auto it = classObject.begin(); it != classObject.end(); ++it) { - classMap[it.key()] = it.value().toString(); - } - - emit mappingConfigFetched(studentMap, classMap); - reply->deleteLater(); - }); -} - -void ApiClient::uploadMappingConfig(const QMap &studentNameMap, - const QMap &classNameMap) -{ - QJsonObject studentObject; - for (auto it = studentNameMap.begin(); it != studentNameMap.end(); ++it) { - studentObject[it.key()] = it.value(); - } - - QJsonObject classObject; - for (auto it = classNameMap.begin(); it != classNameMap.end(); ++it) { - classObject[it.key()] = it.value(); - } - - QJsonObject payload; - payload["student_name_map"] = studentObject; - payload["class_name_map"] = classObject; - - QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/config/mappings"), false, true), - QJsonDocument(payload).toJson(QJsonDocument::Compact)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - if (isConnectionError(reply->error())) { - emit connectionUnavailable(QStringLiteral("映射保存失败: %1").arg(reply->errorString())); - } - emit mappingConfigUploaded(false, reply->errorString()); - reply->deleteLater(); - return; - } - const QJsonObject object = QJsonDocument::fromJson(data).object(); - emit mappingConfigUploaded(object.value("success").toBool(), object.value("message").toString()); - reply->deleteLater(); - }); -} - -void ApiClient::fetchFaceIdMapping() -{ - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/mappings"))); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("加载人脸映射失败"), reply); - reply->deleteLater(); - return; - } - - const QJsonObject mappings = QJsonDocument::fromJson(data).object().value("mappings").toObject(); - QMap mapping; - for (auto it = mappings.begin(); it != mappings.end(); ++it) { - mapping[it.key()] = it.value().toString(); - } - emit faceIdMappingFetched(mapping); - reply->deleteLater(); - }); -} - -void ApiClient::uploadFaceIdMapping(const QMap &mapping) -{ - QJsonObject mappings; - for (auto it = mapping.begin(); it != mapping.end(); ++it) { - if (!it.value().trimmed().isEmpty()) { - mappings[it.key()] = it.value(); - } - } - - QJsonObject payload; - payload["mappings"] = mappings; - QNetworkReply *reply = m_networkManager.post(createRequest(buildUrl("/v1/faces/mappings"), false, true), - QJsonDocument(payload).toJson(QJsonDocument::Compact)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - if (isConnectionError(reply->error())) { - emit connectionUnavailable(QStringLiteral("人脸映射保存失败: %1").arg(reply->errorString())); - } - emit faceIdMappingUploaded(false, reply->errorString()); - reply->deleteLater(); - return; - } - const QJsonObject object = QJsonDocument::fromJson(data).object(); - emit faceIdMappingUploaded(object.value("success").toBool(), object.value("message").toString()); - reply->deleteLater(); - }); -} - -void ApiClient::fetchFacePhotos() -{ - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/photos"))); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("加载照片列表失败"), reply); - reply->deleteLater(); - return; - } - - QStringList ids; - const QJsonArray photos = QJsonDocument::fromJson(data).object().value("photos").toArray(); - for (const QJsonValue &value : photos) { - ids.push_back(value.toObject().value("id").toString()); - } - emit facePhotosFetched(ids); - reply->deleteLater(); - }); -} - -void ApiClient::fetchFacePhoto(const QString &faceId) -{ - if (faceId.trimmed().isEmpty()) { - emit facePhotoFetched(faceId, {}, false, QStringLiteral("人脸ID为空")); - return; - } - - const QString encodedId = QString::fromUtf8(QUrl::toPercentEncoding(faceId.trimmed())); - QNetworkRequest request = createRequest(buildUrl(QStringLiteral("/v1/faces/photos/%1").arg(encodedId))); - request.setRawHeader("Accept", "image/jpeg,*/*"); - - QNetworkReply *reply = m_networkManager.get(request); - connect(reply, &QNetworkReply::finished, this, [this, reply, faceId]() { - const QByteArray data = reply->readAll(); - const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (reply->error() != QNetworkReply::NoError || statusCode >= 400 || data.isEmpty()) { - const QString message = reply->error() != QNetworkReply::NoError - ? reply->errorString() - : QStringLiteral("HTTP %1").arg(statusCode); - emit facePhotoFetched(faceId, {}, false, message); - reply->deleteLater(); - return; - } - - emit facePhotoFetched(faceId, data, true, QString()); - reply->deleteLater(); - }); -} - -void ApiClient::fetchAllStudentIds() -{ - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/faces/student-ids"))); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - emitNetworkError(QStringLiteral("加载学生ID失败"), reply); - reply->deleteLater(); - return; - } - - QStringList ids; - const QJsonArray array = QJsonDocument::fromJson(data).object().value("student_ids").toArray(); - for (const QJsonValue &value : array) { - ids.push_back(value.toString()); - } - emit allStudentIdsFetched(ids); - reply->deleteLater(); - }); -} - -void ApiClient::deleteFace(const QString &studentId, bool deleteRecords) -{ - QString baseUrl = m_serverUrl.trimmed(); - while (baseUrl.endsWith('/')) { - baseUrl.chop(1); - } - QString url = baseUrl + "/api/v1/faces/" + QString::fromLatin1(QUrl::toPercentEncoding(studentId)); - if (deleteRecords) { - url += "?delete_records=true"; - } - QNetworkReply *reply = m_networkManager.deleteResource(createRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply, studentId]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - if (isConnectionError(reply->error())) { - emit connectionUnavailable(QStringLiteral("删除失败: %1").arg(reply->errorString())); - } - emit faceDeleted(false, studentId, 0, reply->errorString()); - reply->deleteLater(); - return; - } - - const QJsonObject object = QJsonDocument::fromJson(data).object(); - emit faceDeleted(object.value("success").toBool(), - studentId, - object.value("records_deleted").toInt(), - object.value("message").toString()); - reply->deleteLater(); - }); -} - -void ApiClient::checkHealth() -{ - QNetworkReply *reply = m_networkManager.get(createRequest(buildUrl("/v1/system/health"))); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QByteArray data = reply->readAll(); - if (reply->error() != QNetworkReply::NoError) { - emit healthChecked(false, reply->errorString()); - reply->deleteLater(); - return; - } - const QJsonObject object = QJsonDocument::fromJson(data).object(); - const bool healthy = object.value("status").toString() == "healthy"; - emit healthChecked(healthy, healthy ? QStringLiteral("连接成功") : QStringLiteral("服务器无响应")); - reply->deleteLater(); - }); -} diff --git a/QT/src/ApiClient.h b/QT/src/ApiClient.h deleted file mode 100644 index 926865e..0000000 --- a/QT/src/ApiClient.h +++ /dev/null @@ -1,75 +0,0 @@ -#ifndef API_CLIENT_H -#define API_CLIENT_H - -#include -#include -#include -#include -#include -#include -#include -#include -#include "AttendanceTypes.h" - -class ApiClient : public QObject -{ - Q_OBJECT - -public: - explicit ApiClient(QObject *parent = nullptr); - - void setServerUrl(const QString &url); - QString serverUrl() const; - - void fetchSyncState(qulonglong lastVersion); - void fetchRecords(int limit = 1000, int offset = 0); - void fetchStatistics(); - void clearAllRecords(); - void deleteRecord(const QString ×tamp, const QString &studentId); - void fetchTimeSlots(); - void uploadTimeSlots(const QVector &timeSlots); - void fetchMappingConfig(); - void uploadMappingConfig(const QMap &studentNameMap, - const QMap &classNameMap); - void fetchFaceIdMapping(); - void uploadFaceIdMapping(const QMap &mapping); - void fetchFacePhotos(); - void fetchFacePhoto(const QString &faceId); - void fetchAllStudentIds(); - void deleteFace(const QString &studentId, bool deleteRecords); - void checkHealth(); - -signals: - void recordsFetched(const QVector &records); - void statisticsFetched(int total, int today, const QString &serverTime); - void recordsCleared(bool success, const QString &message); - void recordDeleted(bool success, const QString &message); - void timeSlotsFetched(const QVector &timeSlots); - void timeSlotsUploaded(bool success, const QString &message); - void mappingConfigFetched(const QMap &studentNameMap, - const QMap &classNameMap); - void mappingConfigUploaded(bool success, const QString &message); - void faceIdMappingFetched(const QMap &mapping); - void faceIdMappingUploaded(bool success, const QString &message); - void facePhotosFetched(const QStringList &photoIds); - void facePhotoFetched(const QString &faceId, const QByteArray &data, bool success, const QString &message); - void allStudentIdsFetched(const QStringList &studentIds); - void faceDeleted(bool success, const QString &studentId, int recordsDeleted, const QString &message); - void healthChecked(bool healthy, const QString &message); - void connectionUnavailable(const QString &message); - void networkError(const QString &message); - void syncStateFetched(qulonglong version, bool changed); - -private: - QString buildUrl(const QString &endpoint, const QMap ¶ms = {}) const; - QNetworkRequest createRequest(const QString &url, bool encryptedResponse = false, bool jsonBody = false) const; - QVector parseRecords(const QByteArray &data) const; - QByteArray maybeDecrypt(const QByteArray &data, bool encrypted) const; - void emitNetworkError(const QString &prefix, QNetworkReply *reply); - bool isConnectionError(QNetworkReply::NetworkError error) const; - - QNetworkAccessManager m_networkManager; - QString m_serverUrl; -}; - -#endif diff --git a/QT/src/AppController.cpp b/QT/src/AppController.cpp deleted file mode 100644 index 62539e9..0000000 --- a/QT/src/AppController.cpp +++ /dev/null @@ -1,851 +0,0 @@ -#include "AppController.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { -constexpr int ReconnectIntervalMs = 5000; - -QDate parseDate(const QString &value, const QDate &fallback) -{ - const QDate date = QDate::fromString(value, Qt::ISODate); - return date.isValid() ? date : fallback; -} - -bool isSuccessStatus(const QString &status) -{ - const QString normalized = status.trimmed().toLower(); - return normalized == "success" - || normalized == "succeeded" - || normalized == "ok" - || normalized == "pass" - || normalized == "passed" - || normalized == "present" - || normalized == "recognized" - || normalized == "verified" - || normalized == "valid" - || normalized == "200" - || normalized == "true" - || normalized == "1" - || normalized == QStringLiteral("成功") - || normalized == QStringLiteral("签到成功") - || normalized == QStringLiteral("正常"); -} - -QString csvCell(const QString &value) -{ - QString escaped = value; - const bool needsQuotes = escaped.contains(',') - || escaped.contains('"') - || escaped.contains('\n') - || escaped.contains('\r'); - escaped.replace("\"", "\"\""); - return needsQuotes ? QStringLiteral("\"%1\"").arg(escaped) : escaped; -} - -QString conciseStatusMessage(const QString &message) -{ - if (message.contains(QStringLiteral("Error transferring"), Qt::CaseInsensitive) - || message.contains(QStringLiteral("Connection refused"), Qt::CaseInsensitive) - || message.contains(QStringLiteral("Host not found"), Qt::CaseInsensitive) - || message.contains(QStringLiteral("Network unreachable"), Qt::CaseInsensitive) - || message.contains(QStringLiteral("timed out"), Qt::CaseInsensitive)) { - return QStringLiteral("服务器未连接,稍后重试"); - } - return message; -} - -QString safeFileName(QString value) -{ - value = value.trimmed(); - for (int i = 0; i < value.size(); ++i) { - QChar ch = value.at(i); - if (!ch.isLetterOrNumber() && ch != '_' && ch != '-' && ch != '.') { - value[i] = '_'; - } - } - return value.isEmpty() ? QStringLiteral("unknown") : value; -} - -QVector legacyDefaultTimeSlots() -{ - return { - {"上午签到", QTime(8, 0), QTime(9, 30), true}, - {"中午签退", QTime(12, 0), QTime(13, 0), true}, - {"下午签到", QTime(14, 0), QTime(15, 0), true}, - {"晚上签退", QTime(17, 0), QTime(18, 30), true} - }; -} - -bool isLegacyDefaultTimeSlots(const QVector &timeSlots) -{ - const QVector legacy = legacyDefaultTimeSlots(); - if (timeSlots.size() != legacy.size()) { - return false; - } - - for (int i = 0; i < legacy.size(); ++i) { - if (timeSlots.at(i).name != legacy.at(i).name - || timeSlots.at(i).startTime != legacy.at(i).startTime - || timeSlots.at(i).endTime != legacy.at(i).endTime - || timeSlots.at(i).enabled != legacy.at(i).enabled) { - return false; - } - } - - return true; -} - -} - -AppController::AppController(QObject *parent) - : QObject(parent) -{ - loadLocalState(); - m_apiClient.setServerUrl(m_serverUrl); - m_reconnectTimer.setSingleShot(true); - - connect(&m_reconnectTimer, &QTimer::timeout, this, [this]() { - runReconnectCheck(); - }); - - connect(&m_apiClient, &ApiClient::recordsFetched, this, [this](const QVector &records) { - m_allRecords = records; - updateFilterOptions(); - rebuildFilteredRecords(); - rebuildStatistics(); - setStatusMessage(QStringLiteral("已加载 %1 条记录").arg(records.size())); - emit summaryChanged(); - }); - - connect(&m_apiClient, &ApiClient::statisticsFetched, this, [this](int total, int today, const QString &) { - m_totalRecords = total; - m_todayRecords = today; - emit summaryChanged(); - }); - - connect(&m_apiClient, &ApiClient::syncStateFetched, this, [this](qulonglong version, bool changed) { - if (version > 0) { - m_qtSyncVersion = version; - } - setStatusMessage(changed - ? QStringLiteral("同步完成,版本 %1").arg(version) - : QStringLiteral("数据已是最新,版本 %1").arg(version)); - }); - - connect(&m_apiClient, &ApiClient::recordsCleared, this, [this](bool success, const QString &message) { - setStatusMessage(success ? QStringLiteral("已清空全部记录") : QStringLiteral("清空失败: %1").arg(message)); - if (success) { - refreshAll(); - } - }); - - connect(&m_apiClient, &ApiClient::recordDeleted, this, [this](bool success, const QString &message) { - setStatusMessage(success ? QStringLiteral("记录删除成功") : QStringLiteral("记录删除失败: %1").arg(message)); - if (success) { - refreshRecords(); - } - }); - - connect(&m_apiClient, &ApiClient::timeSlotsFetched, this, [this](const QVector &timeSlots) { - m_timeSlotModel.setTimeSlots(timeSlots); - saveTimeSlotsToLocal(); - setStatusMessage(timeSlots.isEmpty() - ? QStringLiteral("已从服务器同步空时间段配置") - : QStringLiteral("已从服务器同步时间段")); - }); - - connect(&m_apiClient, &ApiClient::timeSlotsUploaded, this, [this](bool success, const QString &message) { - setStatusMessage(success ? QStringLiteral("时间段已保存并同步") : QStringLiteral("时间段同步失败: %1").arg(message)); - }); - - connect(&m_apiClient, &ApiClient::mappingConfigFetched, this, - [this](const QMap &studentNameMap, const QMap &classNameMap) { - m_studentMappingModel.setMap(studentNameMap); - m_classMappingModel.setMap(classNameMap); - saveMappingsToLocal(); - rebuildFilteredRecords(); - rebuildStatistics(); - setStatusMessage("映射配置已同步"); - }); - - connect(&m_apiClient, &ApiClient::mappingConfigUploaded, this, [this](bool success, const QString &message) { - setStatusMessage(success ? QStringLiteral("映射配置已保存") : QStringLiteral("映射保存失败: %1").arg(message)); - }); - - connect(&m_apiClient, &ApiClient::faceIdMappingFetched, this, [this](const QMap &mapping) { - m_faceIdMapping = mapping; - mergeFaceItems(); - }); - - connect(&m_apiClient, &ApiClient::faceIdMappingUploaded, this, [this](bool success, const QString &message) { - setStatusMessage(success ? QStringLiteral("人脸映射已保存") : QStringLiteral("人脸映射保存失败: %1").arg(message)); - }); - - connect(&m_apiClient, &ApiClient::facePhotosFetched, this, [this](const QStringList &photoIds) { - m_facePhotoIds = photoIds; - m_facePhotoLocalUrls.clear(); - mergeFaceItems(); - for (const QString &id : m_facePhotoIds) { - m_apiClient.fetchFacePhoto(id); - } - }); - - connect(&m_apiClient, &ApiClient::facePhotoFetched, this, - [this](const QString &faceId, const QByteArray &data, bool success, const QString &message) { - if (!success) { - qWarning() << "[FacePhoto] load failed:" << faceId << message; - return; - } - - const QString path = localFacePhotoPath(faceId); - QFile file(path); - if (!file.open(QIODevice::WriteOnly)) { - qWarning() << "[FacePhoto] cache open failed:" << path; - return; - } - - file.write(data); - file.close(); - m_facePhotoLocalUrls.insert(faceId, QUrl::fromLocalFile(path).toString()); - mergeFaceItems(); - }); - - connect(&m_apiClient, &ApiClient::allStudentIdsFetched, this, [this](const QStringList &studentIds) { - m_allStudentIds = studentIds; - mergeFaceItems(); - }); - - connect(&m_apiClient, &ApiClient::faceDeleted, this, [this](bool success, const QString &studentId, int recordsDeleted, const QString &message) { - if (success) { - setStatusMessage(recordsDeleted > 0 - ? QStringLiteral("已删除 %1,并清除 %2 条记录").arg(studentId).arg(recordsDeleted) - : QStringLiteral("已删除 %1").arg(studentId)); - refreshFaces(); - refreshRecords(); - } else { - setStatusMessage(QStringLiteral("删除失败: %1").arg(message)); - } - }); - - connect(&m_apiClient, &ApiClient::healthChecked, this, [this](bool healthy, const QString &message) { - const bool wasReconnecting = m_reconnecting; - m_reconnectCheckInFlight = false; - if (healthy) { - stopReconnect(); - setStatusMessage(wasReconnecting ? QStringLiteral("服务器已恢复连接,正在同步数据") : QStringLiteral("服务器连接正常")); - if (wasReconnecting) { - refreshAll(); - refreshFaces(); - } - } else { - startReconnect(message); - } - }); - - connect(&m_apiClient, &ApiClient::networkError, this, [this](const QString &message) { - setStatusMessage(message); - }); - - connect(&m_apiClient, &ApiClient::connectionUnavailable, this, [this](const QString &message) { - startReconnect(message); - }); - - m_reconnecting = true; - emit reconnectStateChanged(); - runReconnectCheck(); -} - -RecordListModel *AppController::recordModel() { return &m_recordModel; } -StatisticsListModel *AppController::statisticsModel() { return &m_statisticsModel; } -TimeSlotListModel *AppController::timeSlotModel() { return &m_timeSlotModel; } -MappingListModel *AppController::studentMappingModel() { return &m_studentMappingModel; } -MappingListModel *AppController::classMappingModel() { return &m_classMappingModel; } -FaceListModel *AppController::faceModel() { return &m_faceModel; } - -QStringList AppController::classOptions() const { return m_classOptions; } -QStringList AppController::studentOptions() const { return m_studentOptions; } -QString AppController::serverUrl() const { return m_serverUrl; } -QString AppController::statusMessage() const { return m_statusMessage; } -bool AppController::reconnecting() const { return m_reconnecting; } -int AppController::reconnectAttempt() const { return m_reconnectAttempt; } -int AppController::totalRecords() const { return m_totalRecords; } -int AppController::todayRecords() const { return m_todayRecords; } -int AppController::filteredCount() const { return m_filteredRecords.size(); } -int AppController::loadedRecords() const { return m_allRecords.size(); } -int AppController::statisticsStudentCount() const { return m_statisticsStudentCount; } -int AppController::statisticsRecordCount() const { return m_statisticsRecordCount; } - -QString AppController::statisticsRangeText() const -{ - return QString("%1 至 %2") - .arg(m_statisticsStartDate.toString("yyyy-MM-dd")) - .arg(m_statisticsEndDate.toString("yyyy-MM-dd")); -} - -void AppController::setServerUrl(const QString &url) -{ - const QString trimmed = url.trimmed(); - if (trimmed.isEmpty() || trimmed == m_serverUrl) { - return; - } - - m_serverUrl = trimmed; - m_qtSyncVersion = 0; - m_apiClient.setServerUrl(trimmed); - saveServerUrl(); - emit serverUrlChanged(); - stopReconnect(); - setStatusMessage("服务器地址已更新,正在检查连接"); - m_reconnecting = true; - emit reconnectStateChanged(); - runReconnectCheck(); -} - -void AppController::refreshAll() -{ - m_apiClient.fetchSyncState(m_qtSyncVersion); -} - -void AppController::refreshRecords() -{ - m_apiClient.fetchRecords(1000, 0); -} - -void AppController::applyFilters(const QString &classId, - const QString &studentId, - const QString &nameFilter, - int dateFilterIndex, - const QString &specificDate) -{ - m_selectedClassId = classId.isEmpty() ? QStringLiteral("全部") : classId; - m_selectedStudentId = studentId.isEmpty() ? QStringLiteral("全部") : studentId; - m_nameFilter = nameFilter.trimmed(); - m_dateFilterIndex = dateFilterIndex; - m_specificDate = parseDate(specificDate, QDate::currentDate()); - rebuildFilteredRecords(); -} - -void AppController::updateStatisticsRange(const QString &startDate, const QString &endDate) -{ - m_statisticsStartDate = parseDate(startDate, QDate::currentDate().addDays(-30)); - m_statisticsEndDate = parseDate(endDate, QDate::currentDate()); - rebuildStatistics(); -} - -bool AppController::exportFilteredRecords(const QString &fileUrl) -{ - const QString filePath = QUrl(fileUrl).toLocalFile(); - if (filePath.isEmpty()) { - setStatusMessage("导出路径无效"); - return false; - } - - QFile file(filePath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - setStatusMessage("无法创建导出文件"); - return false; - } - - QTextStream out(&file); - out.setEncoding(QStringConverter::Utf8); - out << "# 考勤记录导出\n"; - out << "# 导出时间: " << QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss") << "\n"; - out << "# 记录总数: " << m_filteredRecords.size() << "\n"; - out << "签到时间,班级编号,班级名称,学号,姓名,置信度,状态\n"; - - for (const AttendanceRecord &record : m_filteredRecords) { - out << csvCell(record.timestamp) << "," - << csvCell(record.classId) << "," - << csvCell(mappedClassName(record)) << "," - << csvCell(record.studentId) << "," - << csvCell(mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record)) << "," - << QString::number(record.confidence, 'f', 4) << "," - << csvCell(record.status) << "\n"; - } - - setStatusMessage(QStringLiteral("已导出 %1 条记录").arg(m_filteredRecords.size())); - return true; -} - -bool AppController::exportStatistics(const QString &fileUrl) -{ - const QString filePath = QUrl(fileUrl).toLocalFile(); - if (filePath.isEmpty()) { - setStatusMessage("统计导出路径无效"); - return false; - } - - QFile file(filePath); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - setStatusMessage("无法创建统计文件"); - return false; - } - - QTextStream out(&file); - out.setEncoding(QStringConverter::Utf8); - out << "考勤统计报表," << statisticsRangeText() << "\n"; - out << "学号,姓名,总签到次数,今日签到,最后签到时间,平均置信度\n"; - - for (int row = 0; row < m_statisticsModel.rowCount(); ++row) { - const QModelIndex index = m_statisticsModel.index(row); - out << csvCell(m_statisticsModel.data(index, StatisticsListModel::StudentIdRole).toString()) << "," - << csvCell(m_statisticsModel.data(index, StatisticsListModel::NameRole).toString()) << "," - << m_statisticsModel.data(index, StatisticsListModel::TotalCountRole).toInt() << "," - << m_statisticsModel.data(index, StatisticsListModel::TodayCountRole).toInt() << "," - << csvCell(m_statisticsModel.data(index, StatisticsListModel::LastCheckInRole).toString()) << "," - << QString::number(m_statisticsModel.data(index, StatisticsListModel::AverageConfidenceRole).toDouble(), 'f', 4) << "\n"; - } - - setStatusMessage("统计报表已导出"); - return true; -} - -void AppController::clearAllRecords() -{ - m_apiClient.clearAllRecords(); -} - -void AppController::saveTimeSlots() -{ - saveTimeSlotsToLocal(); - m_apiClient.uploadTimeSlots(m_timeSlotModel.timeSlots()); -} - -void AppController::reloadTimeSlotsFromServer() -{ - m_apiClient.fetchTimeSlots(); -} - -void AppController::loadMappingsFromServer() -{ - m_apiClient.fetchMappingConfig(); -} - -void AppController::saveMappings() -{ - saveMappingsToLocal(); - m_apiClient.uploadMappingConfig(m_studentMappingModel.toMap(), m_classMappingModel.toMap()); - rebuildFilteredRecords(); - rebuildStatistics(); -} - -void AppController::refreshFaces() -{ - m_apiClient.fetchFaceIdMapping(); - m_apiClient.fetchFacePhotos(); - m_apiClient.fetchAllStudentIds(); -} - -void AppController::saveFaceMappings() -{ - QMap mapping; - const QVector items = m_faceModel.items(); - for (const FaceListModel::Item &item : items) { - const QString faceId = item.faceId.trimmed(); - const QString studentId = item.studentId.trimmed(); - if (faceId.isEmpty() || studentId.isEmpty()) { - continue; - } - - if (m_faceIdMapping.contains(faceId) || faceId != studentId) { - mapping[faceId] = studentId; - } - } - m_faceIdMapping = mapping; - m_apiClient.uploadFaceIdMapping(mapping); -} - -void AppController::deleteFace(const QString &studentId, bool deleteRecords) -{ - m_apiClient.deleteFace(studentId, deleteRecords); -} - -void AppController::testConnection() -{ - setStatusMessage("正在检查服务器连接"); - m_apiClient.checkHealth(); -} - -void AppController::loadLocalState() -{ - QSettings settings; - m_serverUrl = settings.value("server/url", "http://127.0.0.1:8082").toString(); - m_qtSyncVersion = 0; - loadMappingsFromLocal(); - loadTimeSlotsFromLocal(); -} - -void AppController::saveServerUrl() -{ - QSettings settings; - settings.setValue("server/url", m_serverUrl); -} - -void AppController::saveMappingsToLocal() -{ - const QString path = localSettingsPath("mapping_config.json"); - QFile file(path); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - return; - } - - QJsonObject root; - QJsonObject studentObject; - const QMap studentMap = m_studentMappingModel.toMap(); - for (auto it = studentMap.begin(); it != studentMap.end(); ++it) { - studentObject[it.key()] = it.value(); - } - - QJsonObject classObject; - const QMap classMap = m_classMappingModel.toMap(); - for (auto it = classMap.begin(); it != classMap.end(); ++it) { - classObject[it.key()] = it.value(); - } - - root["student_name_map"] = studentObject; - root["class_name_map"] = classObject; - file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); -} - -void AppController::loadMappingsFromLocal() -{ - QFile file(localSettingsPath("mapping_config.json")); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - m_studentMappingModel.clear(); - m_classMappingModel.clear(); - return; - } - - const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object(); - QMap studentMap; - const QJsonObject studentObject = root.value("student_name_map").toObject(); - for (auto it = studentObject.begin(); it != studentObject.end(); ++it) { - studentMap[it.key()] = it.value().toString(); - } - - QMap classMap; - const QJsonObject classObject = root.value("class_name_map").toObject(); - for (auto it = classObject.begin(); it != classObject.end(); ++it) { - classMap[it.key()] = it.value().toString(); - } - - m_studentMappingModel.setMap(studentMap); - m_classMappingModel.setMap(classMap); -} - -void AppController::saveTimeSlotsToLocal() -{ - QFile file(localSettingsPath("timeslot_config.json")); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - return; - } - - QJsonArray array; - for (const TimeSlot &slot : m_timeSlotModel.timeSlots()) { - QJsonObject object; - object["name"] = slot.name; - object["start_time"] = slot.startTime.toString("HH:mm"); - object["end_time"] = slot.endTime.toString("HH:mm"); - object["enabled"] = slot.enabled; - array.append(object); - } - - QJsonObject root; - root["time_slots"] = array; - file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); -} - -void AppController::loadTimeSlotsFromLocal() -{ - QFile file(localSettingsPath("timeslot_config.json")); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - m_timeSlotModel.setTimeSlots({}); - return; - } - - QVector timeSlots; - const QJsonArray array = QJsonDocument::fromJson(file.readAll()).object().value("time_slots").toArray(); - for (const QJsonValue &value : array) { - const QJsonObject object = value.toObject(); - timeSlots.push_back({ - object.value("name").toString(), - QTime::fromString(object.value("start_time").toString(), "HH:mm"), - QTime::fromString(object.value("end_time").toString(), "HH:mm"), - object.value("enabled").toBool(true) - }); - } - - if (isLegacyDefaultTimeSlots(timeSlots)) { - timeSlots.clear(); - } - - m_timeSlotModel.setTimeSlots(timeSlots); - if (timeSlots.isEmpty()) { - saveTimeSlotsToLocal(); - } -} - -QString AppController::localSettingsPath(const QString &fileName) const -{ - const QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); - QDir().mkpath(dirPath); - return dirPath + "/" + fileName; -} - -QString AppController::localFacePhotoPath(const QString &faceId) const -{ - const QString dirPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/face_photos"; - QDir().mkpath(dirPath); - return dirPath + "/" + safeFileName(faceId) + ".jpg"; -} - -void AppController::updateFilterOptions() -{ - QSet classIds; - QSet studentIds; - - for (const AttendanceRecord &record : m_allRecords) { - classIds.insert(record.classId); - studentIds.insert(record.studentId); - } - - m_classOptions = {"全部"}; - QStringList classValues = classIds.values(); - std::sort(classValues.begin(), classValues.end()); - m_classOptions.append(classValues); - - m_studentOptions = {"全部"}; - QStringList studentValues = studentIds.values(); - std::sort(studentValues.begin(), studentValues.end()); - m_studentOptions.append(studentValues); - - emit filtersChanged(); -} - -void AppController::rebuildFilteredRecords() -{ - QVector items; - m_filteredRecords.clear(); - const QDate today = QDate::currentDate(); - - for (const AttendanceRecord &record : m_allRecords) { - if (m_selectedClassId != "全部" && record.classId != m_selectedClassId) { - continue; - } - - if (m_selectedStudentId != "全部" && record.studentId != m_selectedStudentId) { - continue; - } - - const QString displayName = mappedStudentName(record); - const QString candidateName = displayName.isEmpty() ? record.originalName : displayName; - if (!m_nameFilter.isEmpty() && !candidateName.contains(m_nameFilter, Qt::CaseInsensitive)) { - continue; - } - - const QDate recordDate = record.dateTime().date(); - if (m_dateFilterIndex == 1 && recordDate != today) { - continue; - } - if (m_dateFilterIndex == 2 && recordDate.daysTo(today) > 7) { - continue; - } - if (m_dateFilterIndex == 3 && recordDate.daysTo(today) > 30) { - continue; - } - if (m_dateFilterIndex == 4 && recordDate != m_specificDate) { - continue; - } - - m_filteredRecords.push_back(record); - } - - std::sort(m_filteredRecords.begin(), m_filteredRecords.end(), [](const AttendanceRecord &left, const AttendanceRecord &right) { - return left.timestamp > right.timestamp; - }); - - items.reserve(m_filteredRecords.size()); - for (const AttendanceRecord &record : m_filteredRecords) { - items.push_back({ - record.timestamp, - record.classId, - mappedClassName(record), - record.studentId, - mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record), - record.confidence, - record.status, - isSuccessStatus(record.status) - }); - } - - m_recordModel.setItems(items); - emit summaryChanged(); -} - -void AppController::rebuildStatistics() -{ - QMap statisticsMap; - const QDate today = QDate::currentDate(); - - for (const AttendanceRecord &record : m_allRecords) { - const QDate date = record.dateTime().date(); - if (date < m_statisticsStartDate || date > m_statisticsEndDate) { - continue; - } - - StudentStatistics &stats = statisticsMap[record.studentId]; - stats.studentId = record.studentId; - stats.name = mappedStudentName(record).isEmpty() ? record.originalName : mappedStudentName(record); - stats.totalCount += 1; - stats.averageConfidence += record.confidence; - if (date == today) { - stats.todayCount += 1; - } - if (!stats.lastCheckIn.isValid() || record.dateTime() > stats.lastCheckIn) { - stats.lastCheckIn = record.dateTime(); - } - } - - QVector items; - items.reserve(statisticsMap.size()); - m_statisticsStudentCount = statisticsMap.size(); - m_statisticsRecordCount = 0; - - for (auto it = statisticsMap.begin(); it != statisticsMap.end(); ++it) { - StudentStatistics stats = it.value(); - m_statisticsRecordCount += stats.totalCount; - if (stats.totalCount > 0) { - stats.averageConfidence /= static_cast(stats.totalCount); - } - items.push_back({ - stats.studentId, - stats.name, - stats.totalCount, - stats.todayCount, - stats.lastCheckIn.isValid() ? stats.lastCheckIn.toString("yyyy-MM-dd HH:mm:ss") : QStringLiteral("无"), - stats.averageConfidence - }); - } - - std::sort(items.begin(), items.end(), [](const StatisticsListModel::Item &left, const StatisticsListModel::Item &right) { - return left.studentId < right.studentId; - }); - - m_statisticsModel.setItems(items); - emit statisticsChanged(); -} - -QString AppController::mappedStudentName(const AttendanceRecord &record) const -{ - return m_studentMappingModel.toMap().value(record.studentId); -} - -QString AppController::mappedClassName(const AttendanceRecord &record) const -{ - return m_classMappingModel.toMap().value(record.classId, record.classId); -} - -void AppController::mergeFaceItems() -{ - QSet allIds; - for (const QString &id : m_facePhotoIds) { - allIds.insert(id); - } - for (const QString &id : m_allStudentIds) { - allIds.insert(id); - } - for (auto it = m_faceIdMapping.cbegin(); it != m_faceIdMapping.cend(); ++it) { - allIds.insert(it.key()); - } - - QStringList sortedIds = allIds.values(); - std::sort(sortedIds.begin(), sortedIds.end()); - - QVector items; - items.reserve(sortedIds.size()); - for (const QString &id : sortedIds) { - const QString photoUrl = m_facePhotoLocalUrls.value(id); - const bool hasPhoto = !photoUrl.isEmpty(); - items.push_back({ - id, - m_faceIdMapping.value(id, id), - hasPhoto, - photoUrl - }); - } - - m_faceModel.setItems(items); -} - -void AppController::setStatusMessage(const QString &message) -{ - const QString displayMessage = conciseStatusMessage(message); - if (displayMessage == m_statusMessage) { - return; - } - m_statusMessage = displayMessage; - emit statusMessageChanged(); -} - -void AppController::startReconnect(const QString &reason) -{ - Q_UNUSED(reason) - - if (!m_reconnecting) { - m_reconnecting = true; - m_reconnectAttempt = 0; - emit reconnectStateChanged(); - } - - if (m_reconnectCheckInFlight || m_reconnectTimer.isActive()) { - setStatusMessage(QStringLiteral("服务器未连接,稍后重试")); - return; - } - - setStatusMessage(QStringLiteral("服务器未连接,稍后重试")); - scheduleReconnect(); -} - -void AppController::stopReconnect() -{ - m_reconnectTimer.stop(); - m_reconnectCheckInFlight = false; - if (!m_reconnecting && m_reconnectAttempt == 0) { - return; - } - - m_reconnecting = false; - m_reconnectAttempt = 0; - emit reconnectStateChanged(); -} - -void AppController::scheduleReconnect() -{ - m_reconnectTimer.start(ReconnectIntervalMs); -} - -void AppController::runReconnectCheck() -{ - if (!m_reconnecting || m_reconnectCheckInFlight) { - return; - } - - m_reconnectCheckInFlight = true; - ++m_reconnectAttempt; - emit reconnectStateChanged(); - setStatusMessage(QStringLiteral("正在重连服务器")); - m_apiClient.checkHealth(); -} diff --git a/QT/src/AppController.h b/QT/src/AppController.h deleted file mode 100644 index c1d9796..0000000 --- a/QT/src/AppController.h +++ /dev/null @@ -1,155 +0,0 @@ -#ifndef APP_CONTROLLER_H -#define APP_CONTROLLER_H - -#include -#include -#include -#include -#include -#include -#include -#include "ApiClient.h" -#include "FaceListModel.h" -#include "MappingListModel.h" -#include "RecordListModel.h" -#include "StatisticsListModel.h" -#include "TimeSlotListModel.h" - -class AppController : public QObject -{ - Q_OBJECT - Q_PROPERTY(RecordListModel* recordModel READ recordModel CONSTANT) - Q_PROPERTY(StatisticsListModel* statisticsModel READ statisticsModel CONSTANT) - Q_PROPERTY(TimeSlotListModel* timeSlotModel READ timeSlotModel CONSTANT) - Q_PROPERTY(MappingListModel* studentMappingModel READ studentMappingModel CONSTANT) - Q_PROPERTY(MappingListModel* classMappingModel READ classMappingModel CONSTANT) - Q_PROPERTY(FaceListModel* faceModel READ faceModel CONSTANT) - Q_PROPERTY(QStringList classOptions READ classOptions NOTIFY filtersChanged) - Q_PROPERTY(QStringList studentOptions READ studentOptions NOTIFY filtersChanged) - Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged) - Q_PROPERTY(QString statusMessage READ statusMessage NOTIFY statusMessageChanged) - Q_PROPERTY(bool reconnecting READ reconnecting NOTIFY reconnectStateChanged) - Q_PROPERTY(int reconnectAttempt READ reconnectAttempt NOTIFY reconnectStateChanged) - Q_PROPERTY(int totalRecords READ totalRecords NOTIFY summaryChanged) - Q_PROPERTY(int todayRecords READ todayRecords NOTIFY summaryChanged) - Q_PROPERTY(int filteredCount READ filteredCount NOTIFY summaryChanged) - Q_PROPERTY(int loadedRecords READ loadedRecords NOTIFY summaryChanged) - Q_PROPERTY(int statisticsStudentCount READ statisticsStudentCount NOTIFY statisticsChanged) - Q_PROPERTY(int statisticsRecordCount READ statisticsRecordCount NOTIFY statisticsChanged) - Q_PROPERTY(QString statisticsRangeText READ statisticsRangeText NOTIFY statisticsChanged) - -public: - explicit AppController(QObject *parent = nullptr); - - RecordListModel *recordModel(); - StatisticsListModel *statisticsModel(); - TimeSlotListModel *timeSlotModel(); - MappingListModel *studentMappingModel(); - MappingListModel *classMappingModel(); - FaceListModel *faceModel(); - - QStringList classOptions() const; - QStringList studentOptions() const; - QString serverUrl() const; - QString statusMessage() const; - bool reconnecting() const; - int reconnectAttempt() const; - int totalRecords() const; - int todayRecords() const; - int filteredCount() const; - int loadedRecords() const; - int statisticsStudentCount() const; - int statisticsRecordCount() const; - QString statisticsRangeText() const; - - void setServerUrl(const QString &url); - - Q_INVOKABLE void refreshAll(); - Q_INVOKABLE void refreshRecords(); - Q_INVOKABLE void applyFilters(const QString &classId, - const QString &studentId, - const QString &nameFilter, - int dateFilterIndex, - const QString &specificDate); - Q_INVOKABLE void updateStatisticsRange(const QString &startDate, const QString &endDate); - Q_INVOKABLE bool exportFilteredRecords(const QString &fileUrl); - Q_INVOKABLE bool exportStatistics(const QString &fileUrl); - Q_INVOKABLE void clearAllRecords(); - Q_INVOKABLE void saveTimeSlots(); - Q_INVOKABLE void reloadTimeSlotsFromServer(); - Q_INVOKABLE void loadMappingsFromServer(); - Q_INVOKABLE void saveMappings(); - Q_INVOKABLE void refreshFaces(); - Q_INVOKABLE void saveFaceMappings(); - Q_INVOKABLE void deleteFace(const QString &studentId, bool deleteRecords); - Q_INVOKABLE void testConnection(); - -signals: - void filtersChanged(); - void serverUrlChanged(); - void statusMessageChanged(); - void reconnectStateChanged(); - void summaryChanged(); - void statisticsChanged(); - -private: - void loadLocalState(); - void saveServerUrl(); - void saveMappingsToLocal(); - void loadMappingsFromLocal(); - void saveTimeSlotsToLocal(); - void loadTimeSlotsFromLocal(); - QString localSettingsPath(const QString &fileName) const; - void updateFilterOptions(); - void rebuildFilteredRecords(); - void rebuildStatistics(); - QString mappedStudentName(const AttendanceRecord &record) const; - QString mappedClassName(const AttendanceRecord &record) const; - QString localFacePhotoPath(const QString &faceId) const; - void mergeFaceItems(); - void setStatusMessage(const QString &message); - void startReconnect(const QString &reason); - void stopReconnect(); - void scheduleReconnect(); - void runReconnectCheck(); - - ApiClient m_apiClient; - QTimer m_reconnectTimer; - RecordListModel m_recordModel; - StatisticsListModel m_statisticsModel; - TimeSlotListModel m_timeSlotModel; - MappingListModel m_studentMappingModel; - MappingListModel m_classMappingModel; - FaceListModel m_faceModel; - - QVector m_allRecords; - QVector m_filteredRecords; - QStringList m_classOptions; - QStringList m_studentOptions; - QString m_serverUrl; - QString m_statusMessage; - bool m_reconnecting = false; - bool m_reconnectCheckInFlight = false; - int m_reconnectAttempt = 0; - int m_totalRecords = 0; - int m_todayRecords = 0; - - QString m_selectedClassId = "全部"; - QString m_selectedStudentId = "全部"; - QString m_nameFilter; - int m_dateFilterIndex = 0; - QDate m_specificDate = QDate::currentDate(); - - QDate m_statisticsStartDate = QDate::currentDate().addDays(-30); - QDate m_statisticsEndDate = QDate::currentDate(); - int m_statisticsStudentCount = 0; - int m_statisticsRecordCount = 0; - qulonglong m_qtSyncVersion = 0; - - QMap m_faceIdMapping; - QHash m_facePhotoLocalUrls; - QStringList m_facePhotoIds; - QStringList m_allStudentIds; -}; - -#endif diff --git a/QT/src/AttendanceTypes.h b/QT/src/AttendanceTypes.h deleted file mode 100644 index 63d2c6f..0000000 --- a/QT/src/AttendanceTypes.h +++ /dev/null @@ -1,54 +0,0 @@ -#ifndef ATTENDANCE_TYPES_H -#define ATTENDANCE_TYPES_H - -#include -#include -#include -#include -#include - -struct AttendanceRecord -{ - QString timestamp; - QString studentId; - QString originalName; - double confidence = 0.0; - QString status; - QString classId = "0"; - - QDateTime dateTime() const - { - return QDateTime::fromString(timestamp, "yyyy-MM-dd HH:mm:ss"); - } -}; - -struct StudentStatistics -{ - QString studentId; - QString name; - int totalCount = 0; - int todayCount = 0; - QDateTime lastCheckIn; - double averageConfidence = 0.0; -}; - -struct TimeSlot -{ - QString name; - QTime startTime; - QTime endTime; - bool enabled = true; -}; - -inline QVector defaultTimeSlots() -{ - return {}; -} - -Q_DECLARE_METATYPE(AttendanceRecord) -Q_DECLARE_METATYPE(StudentStatistics) -Q_DECLARE_METATYPE(TimeSlot) -Q_DECLARE_METATYPE(QVector) -Q_DECLARE_METATYPE(QVector) - -#endif diff --git a/QT/src/CryptoUtils.cpp b/QT/src/CryptoUtils.cpp deleted file mode 100644 index fcad252..0000000 --- a/QT/src/CryptoUtils.cpp +++ /dev/null @@ -1,48 +0,0 @@ -#include "CryptoUtils.h" -#include "crypto/tiny_aes.hpp" - -namespace { -QByteArray removePkcs7Padding(const QByteArray &data) -{ - if (data.isEmpty()) { - return {}; - } - - const int padding = static_cast(data.back()); - if (padding < 1 || padding > AES_BLOCKLEN || padding > data.size()) { - return data; - } - - for (int i = data.size() - padding; i < data.size(); ++i) { - if (static_cast(data.at(i)) != padding) { - return data; - } - } - - return data.left(data.size() - padding); -} -} - -QByteArray CryptoUtils::decryptAes128CbcBase64(const QByteArray &encryptedBase64) -{ - static const uint8_t key[AES_KEYLEN] = { - 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, - 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c - }; - - static const uint8_t iv[AES_BLOCKLEN] = { - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f - }; - - const QByteArray encrypted = QByteArray::fromBase64(encryptedBase64); - if (encrypted.isEmpty() || (encrypted.size() % AES_BLOCKLEN) != 0) { - return {}; - } - - QByteArray buffer = encrypted; - AES_ctx ctx; - AES_init_ctx_iv(&ctx, key, iv); - AES_CBC_decrypt_buffer(&ctx, reinterpret_cast(buffer.data()), static_cast(buffer.size())); - return removePkcs7Padding(buffer); -} diff --git a/QT/src/CryptoUtils.h b/QT/src/CryptoUtils.h deleted file mode 100644 index 3763751..0000000 --- a/QT/src/CryptoUtils.h +++ /dev/null @@ -1,12 +0,0 @@ -#ifndef CRYPTO_UTILS_H -#define CRYPTO_UTILS_H - -#include - -class CryptoUtils -{ -public: - static QByteArray decryptAes128CbcBase64(const QByteArray &encryptedBase64); -}; - -#endif diff --git a/QT/src/FaceListModel.cpp b/QT/src/FaceListModel.cpp deleted file mode 100644 index 91d7ab1..0000000 --- a/QT/src/FaceListModel.cpp +++ /dev/null @@ -1,85 +0,0 @@ -#include "FaceListModel.h" - -FaceListModel::FaceListModel(QObject *parent) - : QAbstractListModel(parent) -{ -} - -int FaceListModel::rowCount(const QModelIndex &parent) const -{ - if (parent.isValid()) { - return 0; - } - return m_items.size(); -} - -QVariant FaceListModel::data(const QModelIndex &index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { - return {}; - } - - const Item &item = m_items.at(index.row()); - switch (role) { - case FaceIdRole: return item.faceId; - case StudentIdRole: return item.studentId; - case HasPhotoRole: return item.hasPhoto; - case PhotoUrlRole: return item.photoUrl; - default: return {}; - } -} - -bool FaceListModel::setData(const QModelIndex &index, const QVariant &value, int role) -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { - return false; - } - - if (role == StudentIdRole) { - m_items[index.row()].studentId = value.toString().trimmed(); - emit dataChanged(index, index, {role}); - return true; - } - - return false; -} - -Qt::ItemFlags FaceListModel::flags(const QModelIndex &index) const -{ - if (!index.isValid()) { - return Qt::NoItemFlags; - } - return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; -} - -QHash FaceListModel::roleNames() const -{ - return { - {FaceIdRole, "faceId"}, - {StudentIdRole, "studentId"}, - {HasPhotoRole, "hasPhoto"}, - {PhotoUrlRole, "photoUrl"} - }; -} - -void FaceListModel::setItems(const QVector &items) -{ - beginResetModel(); - m_items = items; - endResetModel(); -} - -QVector FaceListModel::items() const -{ - return m_items; -} - -void FaceListModel::updateStudentId(int index, const QString &studentId) -{ - if (index < 0 || index >= m_items.size()) { - return; - } - - m_items[index].studentId = studentId.trimmed(); - emit dataChanged(this->index(index), this->index(index), {StudentIdRole}); -} diff --git a/QT/src/FaceListModel.h b/QT/src/FaceListModel.h deleted file mode 100644 index f6d3b7e..0000000 --- a/QT/src/FaceListModel.h +++ /dev/null @@ -1,47 +0,0 @@ -#ifndef FACE_LIST_MODEL_H -#define FACE_LIST_MODEL_H - -#include -#include -#include -#include -#include -#include -#include - -class FaceListModel : public QAbstractListModel -{ - Q_OBJECT - -public: - enum Roles { - FaceIdRole = Qt::UserRole + 1, - StudentIdRole, - HasPhotoRole, - PhotoUrlRole - }; - - explicit FaceListModel(QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role) override; - Qt::ItemFlags flags(const QModelIndex &index) const override; - QHash roleNames() const override; - - struct Item { - QString faceId; - QString studentId; - bool hasPhoto = false; - QString photoUrl; - }; - - void setItems(const QVector &items); - QVector items() const; - Q_INVOKABLE void updateStudentId(int index, const QString &studentId); - -private: - QVector m_items; -}; - -#endif diff --git a/QT/src/MappingListModel.cpp b/QT/src/MappingListModel.cpp deleted file mode 100644 index 1ff47f6..0000000 --- a/QT/src/MappingListModel.cpp +++ /dev/null @@ -1,132 +0,0 @@ -#include "MappingListModel.h" -#include - -MappingListModel::MappingListModel(QObject *parent) - : QAbstractListModel(parent) -{ -} - -int MappingListModel::rowCount(const QModelIndex &parent) const -{ - if (parent.isValid()) { - return 0; - } - return m_items.size(); -} - -QVariant MappingListModel::data(const QModelIndex &index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { - return {}; - } - - const Item &item = m_items.at(index.row()); - switch (role) { - case KeyRole: return item.key; - case ValueRole: return item.value; - default: return {}; - } -} - -bool MappingListModel::setData(const QModelIndex &index, const QVariant &value, int role) -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { - return false; - } - - Item &item = m_items[index.row()]; - switch (role) { - case KeyRole: item.key = value.toString().trimmed(); break; - case ValueRole: item.value = value.toString().trimmed(); break; - default: return false; - } - - emit dataChanged(index, index, {role}); - return true; -} - -Qt::ItemFlags MappingListModel::flags(const QModelIndex &index) const -{ - if (!index.isValid()) { - return Qt::NoItemFlags; - } - return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; -} - -QHash MappingListModel::roleNames() const -{ - return { - {KeyRole, "entryKey"}, - {ValueRole, "entryValue"} - }; -} - -void MappingListModel::setMap(const QMap &map) -{ - QVector items; - items.reserve(map.size()); - for (auto it = map.begin(); it != map.end(); ++it) { - items.push_back({it.key(), it.value()}); - } - - std::sort(items.begin(), items.end(), [](const Item &left, const Item &right) { - bool leftNumeric = false; - bool rightNumeric = false; - int leftValue = left.key.toInt(&leftNumeric); - int rightValue = right.key.toInt(&rightNumeric); - if (leftNumeric && rightNumeric) { - return leftValue < rightValue; - } - return left.key < right.key; - }); - - beginResetModel(); - m_items = items; - endResetModel(); -} - -QMap MappingListModel::toMap() const -{ - QMap map; - for (const Item &item : m_items) { - if (!item.key.trimmed().isEmpty()) { - map[item.key.trimmed()] = item.value.trimmed(); - } - } - return map; -} - -void MappingListModel::addEntry(const QString &key, const QString &value) -{ - beginInsertRows(QModelIndex(), m_items.size(), m_items.size()); - m_items.push_back({key.trimmed(), value.trimmed()}); - endInsertRows(); -} - -void MappingListModel::updateEntry(int index, const QString &key, const QString &value) -{ - if (index < 0 || index >= m_items.size()) { - return; - } - - m_items[index] = {key.trimmed(), value.trimmed()}; - emit dataChanged(this->index(index), this->index(index)); -} - -void MappingListModel::removeEntry(int index) -{ - if (index < 0 || index >= m_items.size()) { - return; - } - - beginRemoveRows(QModelIndex(), index, index); - m_items.removeAt(index); - endRemoveRows(); -} - -void MappingListModel::clear() -{ - beginResetModel(); - m_items.clear(); - endResetModel(); -} diff --git a/QT/src/MappingListModel.h b/QT/src/MappingListModel.h deleted file mode 100644 index 2a4a5c7..0000000 --- a/QT/src/MappingListModel.h +++ /dev/null @@ -1,48 +0,0 @@ -#ifndef MAPPING_LIST_MODEL_H -#define MAPPING_LIST_MODEL_H - -#include -#include -#include -#include -#include -#include -#include -#include - -class MappingListModel : public QAbstractListModel -{ - Q_OBJECT - -public: - enum Roles { - KeyRole = Qt::UserRole + 1, - ValueRole - }; - - explicit MappingListModel(QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role) override; - Qt::ItemFlags flags(const QModelIndex &index) const override; - QHash roleNames() const override; - - void setMap(const QMap &map); - QMap toMap() const; - - Q_INVOKABLE void addEntry(const QString &key, const QString &value); - Q_INVOKABLE void updateEntry(int index, const QString &key, const QString &value); - Q_INVOKABLE void removeEntry(int index); - Q_INVOKABLE void clear(); - -private: - struct Item { - QString key; - QString value; - }; - - QVector m_items; -}; - -#endif diff --git a/QT/src/RecordListModel.cpp b/QT/src/RecordListModel.cpp deleted file mode 100644 index 3850932..0000000 --- a/QT/src/RecordListModel.cpp +++ /dev/null @@ -1,59 +0,0 @@ -#include "RecordListModel.h" - -RecordListModel::RecordListModel(QObject *parent) - : QAbstractListModel(parent) -{ -} - -int RecordListModel::rowCount(const QModelIndex &parent) const -{ - if (parent.isValid()) { - return 0; - } - return m_items.size(); -} - -QVariant RecordListModel::data(const QModelIndex &index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { - return {}; - } - - const Item &item = m_items.at(index.row()); - switch (role) { - case TimestampRole: return item.timestamp; - case ClassIdRole: return item.classId; - case ClassNameRole: return item.className; - case StudentIdRole: return item.studentId; - case NameRole: return item.name; - case ConfidenceRole: return item.confidence; - case ConfidenceTextRole: return QString::number(item.confidence * 100.0, 'f', 2) + "%"; - case StatusRole: return item.status; - case StatusTextRole: return item.success ? QStringLiteral("成功") : QStringLiteral("失败"); - case SuccessRole: return item.success; - default: return {}; - } -} - -QHash RecordListModel::roleNames() const -{ - return { - {TimestampRole, "timestamp"}, - {ClassIdRole, "classId"}, - {ClassNameRole, "className"}, - {StudentIdRole, "studentId"}, - {NameRole, "name"}, - {ConfidenceRole, "confidence"}, - {ConfidenceTextRole, "confidenceText"}, - {StatusRole, "status"}, - {StatusTextRole, "statusText"}, - {SuccessRole, "success"} - }; -} - -void RecordListModel::setItems(const QVector &items) -{ - beginResetModel(); - m_items = items; - endResetModel(); -} diff --git a/QT/src/RecordListModel.h b/QT/src/RecordListModel.h deleted file mode 100644 index d367d5b..0000000 --- a/QT/src/RecordListModel.h +++ /dev/null @@ -1,53 +0,0 @@ -#ifndef RECORD_LIST_MODEL_H -#define RECORD_LIST_MODEL_H - -#include -#include -#include -#include -#include -#include -#include - -class RecordListModel : public QAbstractListModel -{ - Q_OBJECT - -public: - enum Roles { - TimestampRole = Qt::UserRole + 1, - ClassIdRole, - ClassNameRole, - StudentIdRole, - NameRole, - ConfidenceRole, - ConfidenceTextRole, - StatusRole, - StatusTextRole, - SuccessRole - }; - - explicit RecordListModel(QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - - struct Item { - QString timestamp; - QString classId; - QString className; - QString studentId; - QString name; - double confidence = 0.0; - QString status; - bool success = false; - }; - - void setItems(const QVector &items); - -private: - QVector m_items; -}; - -#endif diff --git a/QT/src/StatisticsListModel.cpp b/QT/src/StatisticsListModel.cpp deleted file mode 100644 index bd10986..0000000 --- a/QT/src/StatisticsListModel.cpp +++ /dev/null @@ -1,53 +0,0 @@ -#include "StatisticsListModel.h" - -StatisticsListModel::StatisticsListModel(QObject *parent) - : QAbstractListModel(parent) -{ -} - -int StatisticsListModel::rowCount(const QModelIndex &parent) const -{ - if (parent.isValid()) { - return 0; - } - return m_items.size(); -} - -QVariant StatisticsListModel::data(const QModelIndex &index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { - return {}; - } - - const Item &item = m_items.at(index.row()); - switch (role) { - case StudentIdRole: return item.studentId; - case NameRole: return item.name; - case TotalCountRole: return item.totalCount; - case TodayCountRole: return item.todayCount; - case LastCheckInRole: return item.lastCheckIn; - case AverageConfidenceRole: return item.averageConfidence; - case AverageConfidenceTextRole: return QString::number(item.averageConfidence * 100.0, 'f', 2) + "%"; - default: return {}; - } -} - -QHash StatisticsListModel::roleNames() const -{ - return { - {StudentIdRole, "studentId"}, - {NameRole, "name"}, - {TotalCountRole, "totalCount"}, - {TodayCountRole, "todayCount"}, - {LastCheckInRole, "lastCheckIn"}, - {AverageConfidenceRole, "averageConfidence"}, - {AverageConfidenceTextRole, "averageConfidenceText"} - }; -} - -void StatisticsListModel::setItems(const QVector &items) -{ - beginResetModel(); - m_items = items; - endResetModel(); -} diff --git a/QT/src/StatisticsListModel.h b/QT/src/StatisticsListModel.h deleted file mode 100644 index 0fff392..0000000 --- a/QT/src/StatisticsListModel.h +++ /dev/null @@ -1,48 +0,0 @@ -#ifndef STATISTICS_LIST_MODEL_H -#define STATISTICS_LIST_MODEL_H - -#include -#include -#include -#include -#include -#include -#include - -class StatisticsListModel : public QAbstractListModel -{ - Q_OBJECT - -public: - enum Roles { - StudentIdRole = Qt::UserRole + 1, - NameRole, - TotalCountRole, - TodayCountRole, - LastCheckInRole, - AverageConfidenceRole, - AverageConfidenceTextRole - }; - - explicit StatisticsListModel(QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - - struct Item { - QString studentId; - QString name; - int totalCount = 0; - int todayCount = 0; - QString lastCheckIn; - double averageConfidence = 0.0; - }; - - void setItems(const QVector &items); - -private: - QVector m_items; -}; - -#endif diff --git a/QT/src/TimeSlotListModel.cpp b/QT/src/TimeSlotListModel.cpp deleted file mode 100644 index 98e7919..0000000 --- a/QT/src/TimeSlotListModel.cpp +++ /dev/null @@ -1,146 +0,0 @@ -#include "TimeSlotListModel.h" -#include - -TimeSlotListModel::TimeSlotListModel(QObject *parent) - : QAbstractListModel(parent) -{ -} - -int TimeSlotListModel::rowCount(const QModelIndex &parent) const -{ - if (parent.isValid()) { - return 0; - } - return m_timeSlots.size(); -} - -QVariant TimeSlotListModel::data(const QModelIndex &index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_timeSlots.size()) { - return {}; - } - - const TimeSlot &slot = m_timeSlots.at(index.row()); - switch (role) { - case NameRole: return slot.name; - case StartTimeRole: return slot.startTime.toString("HH:mm"); - case EndTimeRole: return slot.endTime.toString("HH:mm"); - case EnabledRole: return slot.enabled; - default: return {}; - } -} - -bool TimeSlotListModel::setData(const QModelIndex &index, const QVariant &value, int role) -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_timeSlots.size()) { - return false; - } - - TimeSlot &slot = m_timeSlots[index.row()]; - switch (role) { - case NameRole: slot.name = value.toString(); break; - case StartTimeRole: slot.startTime = QTime::fromString(value.toString(), "HH:mm"); break; - case EndTimeRole: slot.endTime = QTime::fromString(value.toString(), "HH:mm"); break; - case EnabledRole: slot.enabled = value.toBool(); break; - default: return false; - } - - emit dataChanged(index, index, {role}); - return true; -} - -Qt::ItemFlags TimeSlotListModel::flags(const QModelIndex &index) const -{ - if (!index.isValid()) { - return Qt::NoItemFlags; - } - return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; -} - -QHash TimeSlotListModel::roleNames() const -{ - return { - {NameRole, "name"}, - {StartTimeRole, "startTime"}, - {EndTimeRole, "endTime"}, - {EnabledRole, "enabled"} - }; -} - -void TimeSlotListModel::setTimeSlots(const QVector &timeSlots) -{ - beginResetModel(); - m_timeSlots = timeSlots; - endResetModel(); -} - -QVector TimeSlotListModel::timeSlots() const -{ - return m_timeSlots; -} - -QJsonArray TimeSlotListModel::toJsonArray() const -{ - QJsonArray array; - for (const TimeSlot &slot : m_timeSlots) { - QJsonObject object; - object["name"] = slot.name; - object["start_time"] = slot.startTime.toString("HH:mm"); - object["end_time"] = slot.endTime.toString("HH:mm"); - object["enabled"] = slot.enabled; - array.append(object); - } - return array; -} - -QVariantMap TimeSlotListModel::get(int index) const -{ - if (index < 0 || index >= m_timeSlots.size()) { - return {}; - } - - const TimeSlot &slot = m_timeSlots.at(index); - return { - {"name", slot.name}, - {"startTime", slot.startTime.toString("HH:mm")}, - {"endTime", slot.endTime.toString("HH:mm")}, - {"enabled", slot.enabled} - }; -} - -void TimeSlotListModel::addTimeSlot(const QString &name, const QString &startTime, const QString &endTime, bool enabled) -{ - beginInsertRows(QModelIndex(), m_timeSlots.size(), m_timeSlots.size()); - m_timeSlots.push_back({name.trimmed(), QTime::fromString(startTime, "HH:mm"), QTime::fromString(endTime, "HH:mm"), enabled}); - endInsertRows(); -} - -void TimeSlotListModel::updateTimeSlot(int index, const QString &name, const QString &startTime, const QString &endTime, bool enabled) -{ - if (index < 0 || index >= m_timeSlots.size()) { - return; - } - - TimeSlot &slot = m_timeSlots[index]; - slot.name = name.trimmed(); - slot.startTime = QTime::fromString(startTime, "HH:mm"); - slot.endTime = QTime::fromString(endTime, "HH:mm"); - slot.enabled = enabled; - emit dataChanged(this->index(index), this->index(index)); -} - -void TimeSlotListModel::removeTimeSlot(int index) -{ - if (index < 0 || index >= m_timeSlots.size()) { - return; - } - - beginRemoveRows(QModelIndex(), index, index); - m_timeSlots.removeAt(index); - endRemoveRows(); -} - -void TimeSlotListModel::resetToDefault() -{ - setTimeSlots(defaultTimeSlots()); -} diff --git a/QT/src/TimeSlotListModel.h b/QT/src/TimeSlotListModel.h deleted file mode 100644 index 1c5e07e..0000000 --- a/QT/src/TimeSlotListModel.h +++ /dev/null @@ -1,48 +0,0 @@ -#ifndef TIMESLOT_LIST_MODEL_H -#define TIMESLOT_LIST_MODEL_H - -#include -#include -#include -#include -#include -#include -#include -#include -#include "AttendanceTypes.h" - -class TimeSlotListModel : public QAbstractListModel -{ - Q_OBJECT - -public: - enum Roles { - NameRole = Qt::UserRole + 1, - StartTimeRole, - EndTimeRole, - EnabledRole - }; - - explicit TimeSlotListModel(QObject *parent = nullptr); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role) override; - Qt::ItemFlags flags(const QModelIndex &index) const override; - QHash roleNames() const override; - - void setTimeSlots(const QVector &timeSlots); - QVector timeSlots() const; - QJsonArray toJsonArray() const; - - Q_INVOKABLE QVariantMap get(int index) const; - Q_INVOKABLE void addTimeSlot(const QString &name, const QString &startTime, const QString &endTime, bool enabled); - Q_INVOKABLE void updateTimeSlot(int index, const QString &name, const QString &startTime, const QString &endTime, bool enabled); - Q_INVOKABLE void removeTimeSlot(int index); - Q_INVOKABLE void resetToDefault(); - -private: - QVector m_timeSlots; -}; - -#endif diff --git a/QT/src/crypto/tiny_aes.hpp b/QT/src/crypto/tiny_aes.hpp deleted file mode 100644 index dfb2da5..0000000 --- a/QT/src/crypto/tiny_aes.hpp +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Tiny AES128 C++ 移植版本 - * 与STM32端使用相同的AES实现,保证兼容性 - */ - -#ifndef _TINY_AES_HPP_ -#define _TINY_AES_HPP_ - -#include -#include - -#define AES_BLOCKLEN 16 -#define AES_KEYLEN 16 -#define AES_keyExpSize 176 - -struct AES_ctx -{ - uint8_t RoundKey[AES_keyExpSize]; - uint8_t Iv[AES_BLOCKLEN]; -}; - -static const uint8_t sbox[256] = { - 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, - 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, - 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, - 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, - 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, - 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, - 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, - 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, - 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, - 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, - 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, - 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, - 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, - 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, - 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, - 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16 -}; - -static const uint8_t rsbox[256] = { - 0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb, - 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, - 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, - 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25, - 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, - 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, - 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06, - 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, - 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, - 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e, - 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, - 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, - 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f, - 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, - 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, - 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d -}; - -static const uint8_t Rcon[11] = { - 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36 -}; - -#define getSBoxValue(num) (sbox[(num)]) -#define getSBoxInvert(num) (rsbox[(num)]) - -inline void KeyExpansion(uint8_t *RoundKey, const uint8_t *Key) -{ - unsigned i, j, k; - uint8_t tempa[4]; - for (i = 0; i < 4; ++i) { - RoundKey[(i * 4) + 0] = Key[(i * 4) + 0]; - RoundKey[(i * 4) + 1] = Key[(i * 4) + 1]; - RoundKey[(i * 4) + 2] = Key[(i * 4) + 2]; - RoundKey[(i * 4) + 3] = Key[(i * 4) + 3]; - } - - for (i = 4; i < 44; ++i) { - k = (i - 1) * 4; - tempa[0] = RoundKey[k + 0]; - tempa[1] = RoundKey[k + 1]; - tempa[2] = RoundKey[k + 2]; - tempa[3] = RoundKey[k + 3]; - - if (i % 4 == 0) { - const uint8_t u8tmp = tempa[0]; - tempa[0] = tempa[1]; - tempa[1] = tempa[2]; - tempa[2] = tempa[3]; - tempa[3] = u8tmp; - - tempa[0] = getSBoxValue(tempa[0]); - tempa[1] = getSBoxValue(tempa[1]); - tempa[2] = getSBoxValue(tempa[2]); - tempa[3] = getSBoxValue(tempa[3]); - tempa[0] ^= Rcon[i / 4]; - } - - j = i * 4; - k = (i - 4) * 4; - RoundKey[j + 0] = RoundKey[k + 0] ^ tempa[0]; - RoundKey[j + 1] = RoundKey[k + 1] ^ tempa[1]; - RoundKey[j + 2] = RoundKey[k + 2] ^ tempa[2]; - RoundKey[j + 3] = RoundKey[k + 3] ^ tempa[3]; - } -} - -static void AddRoundKey(uint8_t round, uint8_t *state, const uint8_t *RoundKey) -{ - uint8_t i, j; - for (i = 0; i < 4; ++i) { - for (j = 0; j < 4; ++j) { - state[i * 4 + j] ^= RoundKey[(round * 16) + (i * 4 + j)]; - } - } -} - -static void InvSubBytes(uint8_t *state) -{ - for (uint8_t i = 0; i < 16; ++i) { - state[i] = getSBoxInvert(state[i]); - } -} - -static void InvShiftRows(uint8_t *state) -{ - uint8_t temp; - temp = state[13]; - state[13] = state[9]; - state[9] = state[5]; - state[5] = state[1]; - state[1] = temp; - - temp = state[2]; - state[2] = state[10]; - state[10] = temp; - temp = state[6]; - state[6] = state[14]; - state[14] = temp; - - temp = state[3]; - state[3] = state[7]; - state[7] = state[11]; - state[11] = state[15]; - state[15] = temp; -} - -static uint8_t xtime(uint8_t x) -{ - return ((x << 1) ^ (((x >> 7) & 1U) * 0x1bU)); -} - -static uint8_t Multiply(uint8_t x, uint8_t y) -{ - return (((y & 1U) * x) ^ - (((y >> 1U) & 1U) * xtime(x)) ^ - (((y >> 2U) & 1U) * xtime(xtime(x))) ^ - (((y >> 3U) & 1U) * xtime(xtime(xtime(x)))) ^ - (((y >> 4U) & 1U) * xtime(xtime(xtime(xtime(x)))))); -} - -static void InvMixColumns(uint8_t *state) -{ - int i; - uint8_t a, b, c, d; - for (i = 0; i < 4; ++i) { - a = state[i * 4 + 0]; - b = state[i * 4 + 1]; - c = state[i * 4 + 2]; - d = state[i * 4 + 3]; - - state[i * 4 + 0] = Multiply(a, 0x0eU) ^ Multiply(b, 0x0bU) ^ Multiply(c, 0x0dU) ^ Multiply(d, 0x09U); - state[i * 4 + 1] = Multiply(a, 0x09U) ^ Multiply(b, 0x0eU) ^ Multiply(c, 0x0bU) ^ Multiply(d, 0x0dU); - state[i * 4 + 2] = Multiply(a, 0x0dU) ^ Multiply(b, 0x09U) ^ Multiply(c, 0x0eU) ^ Multiply(d, 0x0bU); - state[i * 4 + 3] = Multiply(a, 0x0bU) ^ Multiply(b, 0x0dU) ^ Multiply(c, 0x09U) ^ Multiply(d, 0x0eU); - } -} - -inline void InvCipher(uint8_t *state, const uint8_t *RoundKey) -{ - uint8_t round = 0; - AddRoundKey(10, state, RoundKey); - - for (round = 9; ; --round) { - InvShiftRows(state); - InvSubBytes(state); - AddRoundKey(round, state, RoundKey); - if (round == 0) { - break; - } - InvMixColumns(state); - } -} - -inline void AES_init_ctx_iv(struct AES_ctx *ctx, const uint8_t *key, const uint8_t *iv) -{ - KeyExpansion(ctx->RoundKey, key); - memcpy(ctx->Iv, iv, AES_BLOCKLEN); -} - -inline void AES_CBC_decrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t length) -{ - size_t i; - uint8_t storeNextIv[AES_BLOCKLEN]; - - for (i = 0; i < length; i += AES_BLOCKLEN) { - memcpy(storeNextIv, buf + i, AES_BLOCKLEN); - InvCipher(buf + i, ctx->RoundKey); - - for (uint8_t j = 0; j < AES_BLOCKLEN; ++j) { - buf[i + j] ^= ctx->Iv[j]; - } - memcpy(ctx->Iv, storeNextIv, AES_BLOCKLEN); - } -} - -#endif diff --git a/docs/00_项目总文档.md b/docs/00_项目总文档.md deleted file mode 100644 index f40a3ab..0000000 --- a/docs/00_项目总文档.md +++ /dev/null @@ -1,357 +0,0 @@ -# 人脸识别考勤系统项目总文档 - -## 1. 这是什么项目 - -本项目是一个多端协同的人脸识别考勤系统。它不是只有一个程序,而是由四个主要部分组成: - -| 部分 | 所在目录 | 主要职责 | 可以把它理解成 | -| --- | --- | --- | --- | -| STM32 固件端 | `User/`、`Hardware/`、`System/`、`Library/`、`Start/` | 接收 K210 识别结果、判断是否允许签到、加密上传记录、控制 OLED/蜂鸣器/指示灯/补光灯 | 考勤机主控板 | -| K210 识别端 | `k210/` | 摄像头采集、人脸检测、人脸特征比对、添加/删除本地人脸、上传人脸照片、同步人脸映射 | 摄像头和 AI 识别模块 | -| C++ 服务端 | `server/` | 提供 HTTP API、保存考勤记录、保存配置、管理照片和同步状态 | 中心服务器 | -| Qt 管理端 | `QT/` | Windows/Android 图形管理界面,用于查看记录、统计、配置时间段、管理映射和照片 | 管理后台 | - -整个系统的目标是: - -1. K210 看到人脸并识别出一个人。 -2. K210 通过串口把识别结果发给 STM32。 -3. STM32 判断当前是否在允许签到的时间段内,并检查是否重复签到。 -4. 如果允许签到,STM32 将签到数据用 AES-128-CBC 加密后通过 ESP8266 发给服务端。 -5. 服务端保存数据,并提供给 Qt 管理端查看、统计、导出和管理。 -6. Qt 管理端修改的配置会同步给服务端,再由 STM32 或 K210 拉取。 - -## 2. 新手先理解几个概念 - -### 2.1 人脸 ID 和学号不是同一个东西 - -K210 本地添加人脸时,会生成一个原始人脸编号,例如 `ID:1`、`ID:2`。这个编号只表示 K210 本地第几个录入的人脸。 - -真实考勤需要的是学号,例如 `20240001`。所以项目里有一个“人脸 ID 到学号”的映射: - -```json -{ - "mappings": { - "1": "20240001", - "2": "20240002" - } -} -``` - -含义是:K210 本地人脸 `1` 对应真实学号 `20240001`。 - -### 2.2 签到时间段默认是空的 - -当前代码中,系统启动时不再自动创建“上午签到”“中午签退”“下午签到”“晚上签退”这四个默认时间段。 - -也就是说,新部署时: - -```json -{ - "time_slots": [] -} -``` - -如果没有配置时间段,STM32 会认为当前不在任何考勤时段内,识别到人脸也不会允许签到。需要在 Qt 管理端“配置中心”里新增时间段并保存同步。 - -### 2.3 服务端是数据中心 - -当前服务端以 SQLite 数据库为主,数据库文件是: - -```text -attendance.db -``` - -服务端还会使用以下本地资源: - -| 文件或目录 | 作用 | -| --- | --- | -| `attendance.db` | 主数据库,保存记录、配置、映射、同步状态 | -| `faces/` | 人脸照片目录,保存 K210 上传的 `.jpg` | -| `backups/` | 数据库备份目录 | -| `attendance_database.csv` | 旧版本记录文件,首次初始化时可导入 | -| `timeslots_config.json` | 旧版本时间段配置,首次初始化时可导入 | -| `mapping_config.json` | 旧版本姓名/班级映射,首次初始化时可导入 | -| `face_id_mapping.json` | 旧版本人脸映射,首次初始化时可导入 | -| `pending_delete.json` | 旧版本待删除人脸列表,首次初始化时可导入 | -| `sync_request.json` | 旧版本同步请求标志,首次初始化时可导入 | -| `sync_version.txt` | 旧版本同步版本号,首次初始化时可导入 | - -### 2.4 同步版本号是什么 - -服务端维护一个全局同步版本号 `sync_version`。当考勤记录、时间段、映射、人脸照片、删除任务等数据发生变化时,服务端会把版本号加一。 - -设备同步时会带上自己上次同步到的版本: - -```text -GET /api/v1/sync/state?client=stm32&last_version=3 -``` - -如果服务端当前版本还是 `3`,会返回 `changed:false`。如果服务端当前版本是 `4` 或更高,会返回 `changed:true` 并带上该设备需要的数据。 - -这样做的好处是:设备不用不停下载所有数据,只要版本没变就不用重新处理。 - -## 3. 总体架构 - -```mermaid -flowchart LR - Camera["摄像头"] --> K210["K210 识别端"] - K210 -->|"UART1: ID, NAME, CONF"| STM32["STM32 主控"] - STM32 -->|"USART1 AT 指令"| ESP1["ESP8266"] - ESP1 -->|"HTTP + AES 签到"| Server["C++ 服务端 8082"] - K210 -->|"UART2 AT 指令"| ESP2["K210 侧 ESP8266"] - ESP2 -->|"HTTP 上传照片/同步映射"| Server - Qt["Qt 管理端 Windows/Android"] -->|"HTTP API"| Server - Server --> SQLite["attendance.db"] - Server --> Faces["faces/*.jpg"] -``` - -## 4. 数据流说明 - -### 4.1 正常签到流程 - -1. K210 摄像头采集画面。 -2. K210 使用三个 KPU 模型执行人脸检测、关键点定位和特征提取。 -3. K210 将当前人脸特征与 SD 卡中的 `recordftr.txt` 做比对。 -4. 如果分数大于阈值,K210 认为识别成功。 -5. K210 通过 UART1 发送给 STM32: - -```text -ID:20240001,NAME:20240001,CONF:0.95 -``` - -6. STM32 解析串口数据。 -7. STM32 检查置信度是否超过 `0.75`。 -8. STM32 检查当前时间是否落在已启用的时间段内。 -9. STM32 检查本地缓存、今日记录缓存和服务端查重结果,避免同一个学生在同一时段重复签到。 -10. STM32 生成 JSON: - -```json -{ - "student_id": "20240001", - "name": "20240001", - "confidence": 0.95, - "status": "success", - "class_id": "1" -} -``` - -11. STM32 使用 AES-128-CBC 加密并做 Base64 编码。 -12. STM32 通过 ESP8266 发送: - -```text -POST /api/v1/attendance/checkins -X-Api-Token: ... -X-Encrypted: AES128-CBC -``` - -13. 服务端解密、校验字段、写入 SQLite。 -14. Qt 管理端刷新后可以看到新记录。 - -### 4.2 新增人脸流程 - -1. 在 K210 正常识别界面短按按键,进入添加人脸模式。 -2. 摄像头检测到陌生人脸后,再短按确认。 -3. K210 将人脸特征保存到 SD 卡 `recordftr.txt`。 -4. K210 将对应照片保存到 `/sd/face_photos/.jpg`。 -5. K210 将照片 ID 放入上传队列。 -6. 网络任务空闲时,K210 通过 ESP8266 上传照片到服务端: - -```text -POST /api/v1/faces/photos/ -``` - -7. Qt 管理端进入“人脸管理”,刷新后可以看到照片。 -8. 管理员为该 face_id 填写真实学号并保存映射。 -9. 服务端保存映射并触发同步。 -10. K210 拉取同步状态后,把本地人脸名称从 `ID:` 更新为真实学号。 - -### 4.3 删除人脸流程 - -1. Qt 管理端删除某个学生或人脸。 -2. 服务端删除照片、映射,按需要删除考勤记录。 -3. 服务端写入待删除任务 `pending_delete`,并提升同步版本。 -4. K210 定期检查同步请求或拉取统一同步状态。 -5. K210 在本地删除对应特征和照片。 -6. K210 向服务端发送 ACK: - -```text -POST /api/v1/sync/ack -``` - -7. 服务端移除待删除任务。 - -## 5. 目录结构 - -```text -Reconstruct_Qt/ -├── docs/ # 项目文档 -├── server/ -│ ├── server_encrypted.cpp # C++ HTTP 服务端,当前推荐版本 -│ ├── regression_api_tests.ps1 # Windows API 回归检查脚本 -│ └── tiny_aes.hpp # AES 辅助代码 -├── QT/ -│ ├── CMakeLists.txt # Qt 客户端构建配置 -│ ├── main.cpp # Qt 程序入口 -│ ├── qml/Main.qml # Qt 主界面 -│ ├── src/ # Qt C++ 业务逻辑、网络客户端、模型 -│ ├── android/ # Android 打包配置和图标 -│ └── assets/ # 桌面端图标 -├── k210/ -│ ├── main.py # K210 主程序 -│ └── view_attendance.py # 旧版/辅助查看本地记录脚本 -├── User/ -│ └── main.c # STM32 主循环 -├── Hardware/ # STM32 外设驱动与业务模块 -├── System/ # STM32 延时、调试等基础模块 -├── Library/ # STM32 标准外设库 -├── Start/ # 启动文件和芯片头文件 -├── Project.uvprojx # Keil 工程文件 -├── Project.uvoptx # Keil 选项文件 -└── 连接图.md # 硬件接线表 -``` - -## 6. 默认配置总览 - -| 配置项 | 当前值 | 所在位置 | -| --- | --- | --- | -| 服务端端口 | `8082` | `server/server_encrypted.cpp` | -| 服务端默认 API Token | `atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV` | 服务端、Qt、STM32、K210 | -| 服务端默认 CORS 来源 | `http://localhost` | `server/server_encrypted.cpp` | -| STM32 WiFi SSID | `yzfh` | `User/main.c` | -| STM32 WiFi 密码 | `yzfhroot` | `User/main.c` | -| STM32 服务端 IP | `27.106.113.205` | `User/main.c` | -| STM32 班级编号 | `1` | `User/main.c` | -| K210 WiFi SSID | `yzfh` | `k210/main.py` | -| K210 WiFi 密码 | `yzfhroot` | `k210/main.py` | -| K210 服务端 IP | `27.106.113.205` | `k210/main.py` | -| Qt 默认服务端地址 | `http://127.0.0.1:8082` | `QT/src/ApiClient.cpp`、`QT/src/AppController.cpp` | -| 默认签到时间段 | 空数组 | Qt、服务端、STM32 | -| AES 算法 | AES-128-CBC + PKCS#7 + Base64 | `Hardware/Crypto.c`、`server/server_encrypted.cpp`、`QT/src/CryptoUtils.cpp` | - -注意:这些配置里包含真实网络信息和默认 Token。如果要部署到公网,建议改成自己的值,并用环境变量覆盖服务端和 Qt 的 Token。 - -## 7. 硬件连接概览 - -完整连接表在根目录 `连接图.md`。常用连接如下: - -| 模块 | STM32 引脚 | 功能 | -| --- | --- | --- | -| ESP8266 | PA9 / PA10 | USART1,STM32 通过 AT 指令访问 WiFi 和 HTTP | -| K210 | PB10 / PB11 | USART3,K210 把识别结果发给 STM32 | -| OLED | PB8 / PB9 | I2C 显示 | -| 调试串口 | PA2 / PA3 | USART2 调试输出 | -| 成功 LED | PB4 | 高电平点亮 | -| 蜂鸣器 | PB5 | 低电平触发 | -| 光敏传感器 | PB0 | ADC 输入 | -| 补光灯 | PB1 | 高电平点亮 | -| DS1302 CLK/DAT/RST | PA4 / PA5 / PA6 | 软件模拟时序 | - -K210 侧: - -| 模块 | K210 引脚 | 功能 | -| --- | --- | --- | -| STM32 UART | 24 / 25 | UART1,发送识别结果给 STM32 | -| ESP8266 UART | 34 / 35 | UART2,上传照片和同步配置 | -| 按键 | 16 | 短按添加,长按删除,超长按删除全部 | -| RGB LED | 12 / 13 / 14 | 状态提示 | - -## 8. 加密与鉴权 - -### 8.1 API Token - -除少数公开接口外,服务端要求请求头中带 Token: - -```text -X-Api-Token: atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV -``` - -服务端可用环境变量覆盖: - -```powershell -$env:ATTENDANCE_API_TOKEN="你的新Token" -``` - -Qt 端也会读取同名环境变量;STM32 和 K210 目前是写在代码里的常量,需要改代码后重新烧录或上传脚本。 - -### 8.2 AES 参数 - -三端必须保持完全一致: - -| 参数 | 值 | -| --- | --- | -| 算法 | AES-128-CBC | -| 填充 | PKCS#7 | -| 编码 | Base64 | -| Key | `2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c` | -| IV | `00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f` | - -STM32 上传签到使用加密请求。Qt 当前普通记录和统计请求使用 JSON 明文响应,代码仍保留了解密能力。 - -## 9. 推荐启动顺序 - -1. 启动服务端,确认 `http://服务器IP:8082/api/v1/system/health` 返回健康状态。 -2. 启动 Qt 管理端,在配置中心填写服务端地址并点击“测试连接”。 -3. 在 Qt 管理端新增签到时间段并保存。 -4. 烧录并启动 STM32,确认 OLED 显示系统就绪。 -5. 启动 K210,确认摄像头画面、模型加载和 WiFi 连接正常。 -6. 在 K210 添加人脸,等待照片上传到服务端。 -7. 在 Qt 管理端为照片对应的人脸 ID 填写真实学号并保存映射。 -8. K210 同步映射后,再让同一人进行识别签到。 -9. 在 Qt 管理端查看考勤记录和统计结果。 - -## 10. 一键理解各端关系 - -| 你想做的事 | 应该操作哪里 | 背后发生什么 | -| --- | --- | --- | -| 改服务端地址 | Qt 配置中心、STM32/K210 代码常量 | 各端 HTTP 请求指向新服务器 | -| 设置允许签到的时间 | Qt 配置中心 | Qt 保存到服务端,STM32 定期下载 | -| 给人脸绑定学号 | Qt 人脸管理 | 服务端保存映射,K210 同步后更新本地人脸名称 | -| 添加人脸 | K210 按键短按 | K210 保存特征和照片,照片上传服务端 | -| 删除人脸 | Qt 人脸管理或 K210 删除模式 | Qt 删除会生成服务端同步任务;K210 本地删除只影响本地 | -| 查看记录 | Qt 考勤记录页 | Qt 从服务端读取最近记录 | -| 导出报表 | Qt 记录页或统计页 | Qt 把当前加载/筛选结果写成 CSV | - -## 11. 新手常见问题 - -### 11.1 为什么识别成功但没有签到成功 - -常见原因: - -1. 没有配置签到时间段。 -2. 当前时间不在任何启用的时间段内。 -3. STM32 时间不正确,DS1302 失效且网络对时失败。 -4. 同一学生在当前时间段已经签过。 -5. K210 发给 STM32 的 ID 不是想要的真实学号。 -6. 置信度低于 `0.75`。 -7. STM32 无法连接服务端,记录暂存在本地等待同步。 - -### 11.2 为什么 Qt 看不到 K210 的照片 - -可能原因: - -1. K210 侧 ESP8266 没连上 WiFi。 -2. K210 服务端 IP 或 Token 不正确。 -3. K210 本地照片保存在 `/sd/face_photos`,但还没轮到网络队列上传。 -4. 服务端 `faces/` 目录没有写入权限。 -5. Qt 配置的服务端地址不是 K210 上传照片的那个服务端。 - -### 11.3 为什么删除后 K210 还认识这个人 - -Qt 删除会先在服务端生成待删除任务,K210 需要同步后才会删除本地 SD 卡中的特征。可以等待 K210 定期同步,也可以通过服务端同步请求接口触发。 - -### 11.4 为什么改了 Token 后有的端还能用,有的端不能用 - -服务端和 Qt 可以读取环境变量 `ATTENDANCE_API_TOKEN`。STM32 与 K210 的 Token 写在代码里,必须同步修改并重新烧录或上传脚本。 - -## 12. 文档索引 - -| 文档 | 内容 | -| --- | --- | -| `docs/00_项目总文档.md` | 总体介绍、架构、运行顺序、概念说明 | -| `docs/01_STM32固件端文档.md` | STM32 固件、硬件模块、签到判断、加密上传 | -| `docs/02_K210识别端文档.md` | K210 人脸识别、按键操作、照片上传、同步 | -| `docs/03_服务端文档.md` | 服务端 API、SQLite、同步版本、部署安全 | -| `docs/04_Qt管理端文档.md` | Qt 界面功能、数据模型、本地缓存、PC/Android 适配 | -| `docs/05_项目审查问题清单.md` | 当前实现状态、风险点、维护清单 | -| `docs/06_后端接口Windows测试命令.md` | Windows 下手动测试和回归检查命令 | - diff --git a/docs/01_STM32固件端文档.md b/docs/01_STM32固件端文档.md deleted file mode 100644 index 4030e3e..0000000 --- a/docs/01_STM32固件端文档.md +++ /dev/null @@ -1,337 +0,0 @@ -# STM32 固件端文档 - -## 1. STM32 在系统中做什么 - -STM32 是整套考勤机的主控。它不做人脸识别,人脸识别由 K210 完成。STM32 负责把 K210 的识别结果变成“是否允许签到”的业务判断,并控制硬件反馈。 - -它主要做这些事: - -1. 初始化 OLED、蜂鸣器、LED、串口、RTC、光敏传感器、补光灯等硬件。 -2. 接收 K210 通过 USART3 发来的识别结果。 -3. 判断识别置信度是否足够。 -4. 判断当前时间是否在服务端配置的签到时间段内。 -5. 判断当前学生在本时间段是否已经签过。 -6. 生成签到记录。 -7. 使用 AES-128-CBC 加密签到 JSON。 -8. 通过 ESP8266 发送 HTTP 请求到服务端。 -9. 网络异常时把记录留在本地,后续自动重试。 - -## 2. 相关文件 - -| 文件 | 作用 | -| --- | --- | -| `User/main.c` | 主程序入口、初始化流程、主循环、联网和同步调度 | -| `Hardware/Attendance.c` | 考勤业务逻辑:时间段、重复判断、本地记录、同步状态 | -| `Hardware/Attendance.h` | 考勤数据结构和函数声明 | -| `Hardware/K210_USART.c` | 解析 K210 串口识别结果 | -| `Hardware/esp8266.c` | ESP8266 AT 指令、HTTP GET/POST、对时、查重 | -| `Hardware/Crypto.c` | AES-128-CBC 加密和 Base64 编码 | -| `Hardware/DS1302.c` | DS1302 RTC 时钟读写 | -| `Hardware/LightSensor.c` | 光敏电阻 ADC 和补光灯控制 | -| `Hardware/Buzzer_LED.c` | 蜂鸣器、成功 LED、状态 LED | -| `Hardware/OLED.c` | OLED 显示 | -| `System/Delay.c` | 延时和毫秒计时 | -| `System/Debug.c` | 调试串口输出 | -| `Project.uvprojx` | Keil 工程文件 | - -## 3. 硬件连接 - -| 外设 | STM32 引脚 | 说明 | -| --- | --- | --- | -| ESP8266 TX/RX | PA10 / PA9 | USART1,波特率 115200 | -| K210 TX/RX | PB11 / PB10 | USART3,波特率 115200 | -| 调试串口 | PA3 / PA2 | USART2,波特率 115200 | -| OLED SCL/SDA | PB8 / PB9 | I2C | -| 成功 LED | PB4 | 高电平点亮 | -| 蜂鸣器 | PB5 | 低电平触发 | -| 光敏传感器 | PB0 | ADC1 通道 8 | -| 补光灯 | PB1 | 高电平点亮 | -| DS1302 CLK/DAT/RST | PA4 / PA5 / PA6 | 软件 GPIO 时序 | -| 板载状态 LED | PC13 | 低电平点亮,初始化中亮,就绪后灭 | - -## 4. 代码中的主要配置 - -位置:`User/main.c` - -| 配置 | 当前值 | 说明 | -| --- | --- | --- | -| `WIFI_SSID` | `yzfh` | ESP8266 要连接的 WiFi | -| `WIFI_PASSWORD` | `yzfhroot` | WiFi 密码 | -| `HTTP_SERVER_IP` | `27.106.113.205` | 服务端 IP | -| `HTTP_SERVER_PORT` | `8082` | 服务端 HTTP 端口 | -| `HTTP_API_TOKEN` | `atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV` | API Token | -| `CLASS_ID` | `1` | 当前 STM32 设备所属班级编号 | -| `TIME_SYNC_INTERVAL_MS` | `3600000` | 每小时对时一次 | -| `TIMESLOT_DOWNLOAD_INTERVAL_MS` | `30000` | 每 30 秒检查一次时间段配置 | -| `WIFI_RECONNECT_INTERVAL_MS` | `30000` | WiFi 断开后每 30 秒重连 | -| `RECONNECT_INTERVAL_MS` | `5000` | 服务端连接不可用后每 5 秒尝试重连 | -| `MIN_CONFIDENCE_THRESHOLD` | `0.75` | 最低识别置信度 | -| `MAX_ATTENDANCE_RECORDS` | `100` | STM32 本地最多缓存 100 条记录 | -| `MAX_TIME_SLOT_COUNT` | `10` | 最多支持 10 个签到时间段 | - -## 5. 启动流程 - -STM32 上电后按下面顺序执行: - -1. 初始化调试串口。 -2. 初始化延时模块。 -3. 初始化 OLED。 -4. 初始化蜂鸣器和 LED。 -5. 初始化 USART1,用于连接 ESP8266。 -6. 初始化 USART3,用于接收 K210 数据。 -7. 初始化考勤模块。 -8. 初始化 AES 加密模块。 -9. 初始化 DS1302,并尝试读取时间。 -10. 初始化光敏传感器和补光灯。 -11. 初始化 ESP8266。 -12. 连接 WiFi。 -13. 从服务端获取当前时间。 -14. 连接 HTTP 服务端。 -15. 下载签到时间段配置。 -16. 下载今日当前时段已签到记录。 -17. 进入主循环。 - -如果 WiFi 或服务端连接失败,程序不会停机,而是进入离线或重连状态。后续主循环会自动重试。 - -## 6. 主循环每次做什么 - -`User/main.c` 的主循环大致如下: - -```c -while (1) { - ProcessBeep(); - UpdateCurrentTime(); - CheckServerConnection(); - SyncPendingRecords(); - UpdateTimeSlotConfig(); - LightSensor_Update(); - - if (K210_GetRxFlag()) { - Attendance_ProcessFaceResult(&K210_FaceResult); - } -} -``` - -逐项解释: - -| 函数 | 作用 | -| --- | --- | -| `ProcessBeep` | 非阻塞处理蜂鸣器和成功 LED | -| `UpdateCurrentTime` | 从 DS1302 或软件时钟维护当前时间,并定期网络对时 | -| `CheckServerConnection` | 检查当前 HTTP/TCP 连接状态,连接不可用时按需重连 | -| `SyncPendingRecords` | 把本地未同步记录加密上传 | -| `UpdateTimeSlotConfig` | 定期下载服务端签到时间段 | -| `LightSensor_Update` | 根据环境光自动开关补光灯 | -| `K210_GetRxFlag` | 看是否收到 K210 的新识别结果 | - -注意:当前 STM32 HTTP 请求和服务端响应都使用 `Connection: close`,所以它不是长期保持一个 TCP 长连接,而是在每次请求前检查连接状态,并在需要时重新建立连接。 - -## 7. K210 发给 STM32 的数据格式 - -K210 通过 UART1 发送文本,STM32 通过 USART3 接收。 - -识别成功: - -```text -ID:20240001,NAME:Zhang_San,CONF:0.95 -``` - -字段说明: - -| 字段 | 含义 | -| --- | --- | -| `ID` | 学号或 K210 同步后的身份编号 | -| `NAME` | 显示名,K210 代码中会用下划线代替空格 | -| `CONF` | 置信度,范围一般是 0.0 到 1.0 | - -未识别: - -```text -UNKNOWN -``` - -STM32 收到 `UNKNOWN` 后不会签到。 - -## 8. 签到判断逻辑 - -STM32 对一次识别结果的判断顺序如下: - -1. 判断 K210 是否识别成功。 -2. 判断置信度是否低于 `0.75`。 -3. 判断当前是否在启用的时间段内。 -4. 判断当前时段本地记录中是否已经有该学生。 -5. 判断从服务端下载的今日缓存中是否已经有该学生。 -6. 如果服务端在线,再调用服务端查重接口确认。 -7. 通过所有检查后,生成一条本地考勤记录。 -8. 如果服务端在线,后续同步任务会加密上传。 - -当前默认没有任何时间段。必须由 Qt 管理端新增并保存时间段,否则第 3 步会失败,OLED 会显示不在签到时间。 - -## 9. 时间段同步 - -STM32 调用: - -```text -GET /api/v1/sync/state?client=stm32&last_version=<本地版本> -``` - -服务端返回示例: - -```json -{ - "version": 5, - "changed": true, - "client": "stm32", - "server_time": "2026-05-01 10:00:00", - "state": { - "time_slots": [ - { - "name": "上午签到", - "start_time": "08:00", - "end_time": "09:30", - "enabled": true - } - ] - } -} -``` - -如果 `time_slots` 是空数组,STM32 会清空本地时间段配置。 - -## 10. 今日记录缓存 - -STM32 为了减少重复签到,会在开机连接服务端后下载今日当前时间段的已签到学号: - -```text -GET /api/v1/attendance/today?slot_start=08:00&slot_end=09:30 -``` - -返回: - -```json -{ - "date": "2026-05-01", - "records": ["20240001", "20240002"] -} -``` - -下载后,STM32 会把这些学号放进缓存。之后如果同一个学生在同一个时段再次识别,会被判定为重复签到。 - -## 11. 加密上传 - -STM32 上传签到记录时使用: - -```text -POST /api/v1/attendance/checkins -X-Api-Token: -X-Encrypted: AES128-CBC -Content-Type: application/octet-stream -``` - -请求体是 Base64 编码后的 AES 密文。明文 JSON 类似: - -```json -{ - "student_id": "20240001", - "name": "Zhang San", - "confidence": 0.95, - "status": "success", - "class_id": "1" -} -``` - -服务端解密成功后写入 `attendance.db`。 - -## 12. 本地记录和同步状态 - -STM32 的本地记录结构体: - -```c -typedef struct { - char student_id[20]; - char student_name[32]; - float confidence; - uint32_t timestamp; - uint8_t time_slot; - SyncStatus sync_status; - uint8_t retry_count; - uint32_t last_retry_time; -} AttendanceRecord; -``` - -同步状态: - -| 状态 | 含义 | -| --- | --- | -| `SYNC_PENDING` | 新记录,等待上传 | -| `SYNC_SYNCING` | 正在上传 | -| `SYNC_SYNCED` | 上传成功 | -| `SYNC_FAILED` | 上传失败,后续重试 | - -## 13. OLED 和声光反馈 - -| 情况 | OLED | 蜂鸣器/LED | -| --- | --- | --- | -| 初始化中 | 显示 ESP/WiFi/HTTP 状态 | PC13 状态灯亮 | -| 服务端在线 | `System Ready / Online Mode` | 成功提示 | -| WiFi 在线但服务端断开 | `System Ready / Reconnecting..` | 后台重连 | -| WiFi 正在重连 | `System Ready / WiFi retry...` | 后台重连 | -| ESP8266 不可用 | `System Ready / Offline Mode` | 错误提示 | -| 签到成功 | `Check-in OK!` | 成功提示 | -| 重复签到 | `Already Signed!` | 错误提示 | -| 置信度低 | `Low Confidence` | 错误提示 | -| 不在时间段 | `Not Check Time` | 错误提示 | - -## 14. 编译与烧录 - -使用 Keil 打开: - -```text -Project.uvprojx -``` - -一般步骤: - -1. 安装 Keil MDK 和 STM32F10x 支持包。 -2. 打开 `Project.uvprojx`。 -3. 确认芯片型号和启动文件与实际开发板一致。 -4. 修改 `User/main.c` 中的 WiFi、服务端 IP、班级编号和 Token。 -5. 编译工程。 -6. 使用 ST-Link 或串口 Bootloader 烧录。 -7. 打开调试串口查看日志。 - -## 15. 调试建议 - -### 15.1 先看调试串口 - -调试串口会输出: - -1. 硬件初始化状态。 -2. ESP8266 AT 指令结果。 -3. WiFi 连接结果。 -4. HTTP 服务端连接结果。 -5. 时间同步结果。 -6. 时间段下载结果。 -7. K210 原始数据和解析结果。 -8. 加密上传结果。 - -### 15.2 常见问题 - -| 现象 | 可能原因 | 处理 | -| --- | --- | --- | -| 一直显示 WiFi retry | SSID/密码错误、ESP8266 没接好、供电不足 | 检查 `WIFI_SSID`、`WIFI_PASSWORD`、供电和串口 | -| HTTP connection failed | 服务端 IP/端口错误、防火墙拦截、服务端没启动 | 用电脑访问健康检查接口 | -| Not Check Time | 没有配置时间段,或当前时间不在时间段内 | Qt 新增时间段并保存,检查 DS1302/NTP | -| Already Signed | 当前时段已经签过 | 换时间段或清空记录 | -| Low Confidence | K210 比对分数偏低 | 调整光线、重新录入人脸 | -| Qt 看不到记录 | STM32 未同步成功或服务端地址不一致 | 看服务端日志和 Qt 服务端地址 | - -## 16. 修改配置时要同步修改哪里 - -| 你修改了什么 | 需要同步修改 | -| --- | --- | -| 服务端 IP | `User/main.c`、`k210/main.py`、Qt 配置中心 | -| API Token | 服务端环境变量或代码、`User/main.c`、`k210/main.py`、Qt 环境变量 | -| AES Key/IV | `Hardware/Crypto.c`、`server/server_encrypted.cpp`、`QT/src/CryptoUtils.cpp` | -| 班级编号 | 每块 STM32 对应修改 `CLASS_ID` | -| WiFi | `User/main.c` 和 `k210/main.py` | diff --git a/docs/02_K210识别端文档.md b/docs/02_K210识别端文档.md deleted file mode 100644 index bc9981f..0000000 --- a/docs/02_K210识别端文档.md +++ /dev/null @@ -1,293 +0,0 @@ -# K210 识别端文档 - -## 1. K210 在系统中做什么 - -K210 负责“看见人脸并判断是谁”。它不直接决定能不能签到,最终签到判断由 STM32 完成。 - -K210 的职责包括: - -1. 初始化摄像头和 LCD。 -2. 加载人脸检测、关键点检测、特征提取三个 KPU 模型。 -3. 从 SD 卡读取已经录入的人脸特征。 -4. 实时检测画面中的人脸。 -5. 将识别结果通过 UART1 发给 STM32。 -6. 通过按键添加或删除本地人脸。 -7. 保存新增人脸照片到 SD 卡。 -8. 通过 K210 侧 ESP8266 上传照片到服务端。 -9. 从服务端同步人脸 ID 到学号的映射。 -10. 从服务端同步待删除任务,并删除本地特征。 - -## 2. 相关文件 - -| 文件 | 作用 | -| --- | --- | -| `k210/main.py` | K210 主程序,包含识别、添加、删除、同步、上传照片 | -| `k210/view_attendance.py` | 辅助脚本,用于查看旧版本地记录文件 | - -K210 程序依赖 SD 卡上的模型文件: - -```text -/sd/FaceDetection.smodel -/sd/FaceLandmarkDetection.smodel -/sd/FeatureExtraction.smodel -``` - -如果模型文件不存在或路径错误,程序会在加载 KPU 模型时报错。 - -## 3. 代码中的主要配置 - -位置:`k210/main.py` - -| 配置 | 当前值 | 说明 | -| --- | --- | --- | -| `WIFI_SSID` | `yzfh` | K210 侧 ESP8266 要连接的 WiFi | -| `WIFI_PASSWORD` | `yzfhroot` | WiFi 密码 | -| `SERVER_IP` | `27.106.113.205` | 服务端 IP | -| `SERVER_PORT` | `8082` | 服务端端口 | -| `API_TOKEN` | `atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV` | 服务端鉴权 Token | -| `SYNC_CHECK_INTERVAL_MS` | `50000` | 每 50 秒检查一次服务端同步请求 | -| `NETWORK_FRAME_INTERVAL` | `300` | 每 300 帧处理一次网络任务 | -| `LOCAL_FACE_PHOTO_DIR` | `/sd/face_photos` | 本地照片缓存目录 | -| `CAMERA_HMIRROR` | `1` | 水平镜像 | -| `CAMERA_VFLIP` | `1` | 垂直翻转 | - -## 4. 硬件连接 - -| 功能 | K210 引脚 | 说明 | -| --- | --- | --- | -| 发给 STM32 | 24 | UART1 TX | -| 收 STM32 | 25 | UART1 RX | -| 发给 ESP8266 | 34 | UART2 TX | -| 收 ESP8266 | 35 | UART2 RX | -| 蓝/绿/红 LED | 12 / 13 / 14 | 状态提示 | -| 按键 | 16 | 添加、删除、删除全部 | - -UART 参数: - -```text -115200, 8 data bits, no parity, 1 stop bit -``` - -## 5. SD 卡文件 - -| 文件或目录 | 作用 | -| --- | --- | -| `/sd/recordftr.txt` | 人脸特征库,每行一个人脸 | -| `/sd/face_mapping.txt` | face_id 到学号的本地映射 | -| `/sd/sync_version.txt` | K210 已同步到的服务端版本号 | -| `/sd/face_photos/` | 新增人脸时保存的本地照片 | -| `/sd/FaceDetection.smodel` | 人脸检测模型 | -| `/sd/FaceLandmarkDetection.smodel` | 人脸关键点模型 | -| `/sd/FeatureExtraction.smodel` | 人脸特征提取模型 | - -`recordftr.txt` 的一行大致是: - -```text -ID:1#[0.0123, -0.0456, ...] -``` - -或同步映射后: - -```text -20240001#[0.0123, -0.0456, ...] -``` - -代码中已经不再用 `eval` 解析特征,而是只解析安全的浮点数组文本。 - -## 6. 启动流程 - -K210 启动后主要执行: - -1. 设置堆内存到 1MB。 -2. 初始化 UART1 和 UART2。 -3. 初始化 K210 侧 ESP8266,尝试连接 WiFi。 -4. 加载本地映射 `/sd/face_mapping.txt`。 -5. 加载本地同步版本 `/sd/sync_version.txt`。 -6. 加载三个 KPU 模型。 -7. 初始化 LCD 和摄像头。 -8. 初始化按键中断。 -9. 加载本地人脸特征库 `/sd/recordftr.txt`。 -10. 扫描本地照片目录,把未上传照片加入队列。 -11. 如果 WiFi 可用,执行一次统一同步。 -12. 进入主循环。 - -## 7. 人脸识别流程 - -每一帧画面大致按下面处理: - -1. 摄像头采集图像。 -2. `task_fd` 做人脸检测。 -3. `task_ld` 做关键点定位。 -4. 根据关键点做仿射变换,把人脸对齐到 128x128。 -5. `task_fe` 提取人脸特征。 -6. 与 `peoples` 中的本地特征逐个比对。 -7. 如果最高分大于 `85`,认为识别成功。 -8. 如果识别成功,LCD 画绿色框;陌生人画红色框。 -9. 每隔 `SEND_INTERVAL_MS = 1000` 毫秒,向 STM32 发送一次状态变化。 - -发给 STM32 的格式: - -```text -ID:<学号或face_id>,NAME:<显示名>,CONF:<置信度> -``` - -示例: - -```text -ID:20240001,NAME:20240001,CONF:0.95 -``` - -如果陌生人: - -```text -UNKNOWN -``` - -## 8. 按键操作 - -| 操作 | 程序状态 | 作用 | -| --- | --- | --- | -| 短按 | 正常识别界面 | 进入添加人脸模式 | -| 长按约 2 秒 | 正常识别界面 | 进入删除人脸模式 | -| 超长按约 5 秒 | 正常识别界面 | 进入删除全部人脸模式 | -| 添加模式中短按 | 检测到陌生人脸时 | 确认添加 | -| 添加模式中长按 | 添加模式 | 退出添加模式 | -| 删除模式中短按 | 删除模式 | 切换候选项 | -| 删除模式中长按 | 删除模式 | 确认删除当前项 | - -## 9. 添加人脸流程 - -1. 正常识别界面短按按键。 -2. LCD 显示 `Add Face Mode`。 -3. 摄像头检测到陌生人脸后显示 `Ready`。 -4. 再短按确认添加。 -5. K210 分配一个未使用的 `face_id`,例如 `1`。 -6. 特征写入 `/sd/recordftr.txt`,名称为 `ID:1`。 -7. 当前画面压缩为 JPEG。 -8. JPEG 保存到 `/sd/face_photos/1.jpg`。 -9. `1` 加入照片上传队列。 -10. 后续网络任务会上传到服务端。 - -服务端接收接口: - -```text -POST /api/v1/faces/photos/1 -``` - -## 10. 人脸映射同步 - -新增人脸后,K210 只知道它是 `ID:1`,不知道真实学号。管理员需要在 Qt 管理端把 `1` 映射成学号: - -```json -{ - "mappings": { - "1": "20240001" - } -} -``` - -K210 同步后会: - -1. 拉取服务端 `face_mappings`。 -2. 修改本地 `face_mapping.txt`。 -3. 修改 `recordftr.txt` 中对应人脸的名称。 -4. 后续发送给 STM32 的 ID 就会变成真实学号。 - -## 11. 照片上传队列 - -K210 不会在识别每一帧时都做网络请求,因为网络请求会阻塞画面。它采用“分时网络任务”: - -1. 新照片先保存到 SD 卡。 -2. 只把 face_id 放入 `photo_upload_queue`。 -3. 每隔一定帧数,主循环处理一个网络任务。 -4. 如果上传失败,重新放回队列。 - -这样可以减少卡顿,并且即使网络临时不可用,照片也不会丢,因为原始 JPEG 还在 SD 卡。 - -## 12. 服务端同步 - -K210 主要使用统一同步接口: - -```text -GET /api/v1/sync/state?client=k210&last_version=<本地版本> -``` - -如果有变化,服务端返回 K210 需要的数据: - -```json -{ - "version": 5, - "changed": true, - "state": { - "face_mappings": { - "1": "20240001" - }, - "pending_delete": ["1"], - "delete_tasks": [ - { - "task_id": "delete-1", - "face_id": "1" - } - ] - } -} -``` - -K210 会先处理删除任务,再应用映射。删除成功后发送 ACK: - -```json -{ - "client": "k210", - "version": 5, - "deleted": ["1"], - "task_ids": ["delete-1"] -} -``` - -如果 ACK 失败,K210 不会推进本地版本号,下一次还会重试。 - -## 13. 删除人脸 - -K210 本地删除有两种来源: - -### 13.1 K210 按键本地删除 - -这只会删除 K210 本地 `recordftr.txt` 和本地照片,不会自动删除服务端记录。适合现场维护。 - -### 13.2 Qt 管理端远程删除 - -Qt 删除会在服务端创建待删除任务。K210 同步后删除本地人脸,并向服务端确认。适合后台统一管理。 - -## 14. K210 常用接口 - -| 方法 | 路径 | 用途 | -| --- | --- | --- | -| `POST` | `/api/v1/faces/photos/` | 上传 JPEG 照片 | -| `GET` | `/api/v1/faces/photos` | 查询服务端已有照片 | -| `GET` | `/api/v1/faces/mappings` | 拉取人脸 ID 到学号映射 | -| `GET` | `/api/v1/faces/pending-delete` | 拉取待删除列表 | -| `POST` | `/api/v1/faces/delete-ack` | 旧式删除确认 | -| `GET` | `/api/v1/sync/state?client=k210&last_version=N` | 推荐统一同步 | -| `POST` | `/api/v1/sync/ack` | 推荐统一同步确认 | -| `GET` | `/api/v1/sync/request` | 查询 Qt/服务端是否请求同步 | -| `POST` | `/api/v1/sync/request/ack` | 确认收到同步请求 | - -## 15. 常见问题 - -| 现象 | 可能原因 | 处理 | -| --- | --- | --- | -| 模型加载失败 | SD 卡缺少 `.smodel` 文件 | 把三个模型放到 `/sd/` 根目录 | -| WiFi 连接失败 | SSID/密码错误、ESP8266 未启动、波特率不匹配 | 看串口日志,代码会尝试多个波特率 | -| 添加人脸失败 | 没检测到陌生人脸、SD 卡不可写、照片保存失败 | 检查光照、SD 卡和日志 | -| Qt 看不到照片 | 上传队列未完成、服务端地址不一致、Token 错误 | 看 K210 `[PHOTO]`、`[NET]`、`[ESP]` 日志 | -| 删除后仍识别 | K210 尚未同步或 ACK 失败 | 等待同步,检查 `/api/v1/sync/state` | -| 发给 STM32 的还是 `ID:1` | 映射没有保存或 K210 未同步 | Qt 保存映射后触发同步 | - -## 16. 修改配置时要注意 - -1. K210 的服务端 IP、端口、Token 必须和服务端一致。 -2. K210 的 WiFi 配置和 STM32 可以相同,也可以不同,但都要能访问服务端。 -3. K210 侧照片上传使用自己的 ESP8266,不依赖 STM32 的 ESP8266。 -4. K210 本地 SD 卡数据很重要,删除或格式化 SD 卡会丢失本地人脸特征。 -5. 如果重新录入人脸,原 face_id 可能变化,需要重新在 Qt 管理端设置映射。 - diff --git a/docs/03_服务端文档.md b/docs/03_服务端文档.md deleted file mode 100644 index 286babd..0000000 --- a/docs/03_服务端文档.md +++ /dev/null @@ -1,657 +0,0 @@ -# 服务端文档 - -## 1. 服务端的作用 - -服务端是整个系统的数据中心。STM32、K210 和 Qt 管理端都通过 HTTP 与它通信。 - -它负责: - -1. 接收 STM32 加密上传的签到记录。 -2. 解密、校验并保存记录。 -3. 提供考勤记录和统计数据给 Qt。 -4. 保存签到时间段配置。 -5. 保存学生姓名映射和班级名称映射。 -6. 保存 K210 上传的人脸照片。 -7. 保存人脸 ID 到真实学号的映射。 -8. 维护待删除人脸任务。 -9. 维护全局同步版本号。 -10. 提供健康检查、存储状态和时间接口。 - -当前推荐服务端文件: - -```text -server/server_encrypted.cpp -``` - -## 2. 技术栈 - -| 项 | 内容 | -| --- | --- | -| 语言 | C++ | -| HTTP 实现 | 原生 socket + 每连接一个线程 | -| 数据库 | SQLite | -| 加密库 | OpenSSL EVP AES-128-CBC | -| 图片存储 | 本地 `faces/*.jpg` | -| 默认端口 | `8082` | -| 默认数据库 | `attendance.db` | - -## 3. 运行时文件 - -服务端启动后主要依赖当前工作目录下的文件。在哪个目录运行,就在哪个目录读写这些文件。 - -| 文件或目录 | 当前用途 | -| --- | --- | -| `attendance.db` | 主 SQLite 数据库 | -| `faces/` | 人脸照片目录 | -| `backups/` | 数据库备份目录 | -| `attendance_database.csv` | 旧版 CSV 记录,首次初始化时可导入 | -| `timeslots_config.json` | 旧版时间段配置,首次初始化时可导入 | -| `mapping_config.json` | 旧版姓名/班级映射,首次初始化时可导入 | -| `face_id_mapping.json` | 旧版人脸映射,首次初始化时可导入 | -| `pending_delete.json` | 旧版待删除列表,首次初始化时可导入 | -| `sync_request.json` | 旧版同步请求标志,首次初始化时可导入 | -| `sync_version.txt` | 旧版同步版本号,首次初始化时可导入 | - -首次启动时,如果 SQLite 表中没有数据,服务端会尝试从这些旧文件导入;导入后主要读写 SQLite。 - -## 4. SQLite 表结构 - -服务端启动时会创建这些表: - -### 4.1 `records` - -保存考勤记录。 - -| 字段 | 类型 | 含义 | -| --- | --- | --- | -| `id` | INTEGER PRIMARY KEY AUTOINCREMENT | 自增主键 | -| `timestamp` | TEXT | 签到时间 | -| `student_id` | TEXT | 学号 | -| `name` | TEXT | 名称 | -| `confidence` | TEXT | 置信度 | -| `status` | TEXT | 状态,例如 `success` | -| `class_id` | TEXT | 班级编号 | - -索引: - -```sql -idx_records_student_time(student_id,timestamp) -idx_records_timestamp(timestamp) -``` - -### 4.2 `kv_store` - -保存配置型数据。 - -| key | value 内容 | -| --- | --- | -| `time_slots` | 时间段 JSON | -| `mapping_config` | 学生姓名和班级名称映射 | -| `sync_request` | K210 同步请求标志 | -| `sync_version` | 全局同步版本号 | - -### 4.3 `face_mappings` - -保存 K210 原始 face_id 到真实学号的映射。 - -| 字段 | 含义 | -| --- | --- | -| `face_id` | K210 本地人脸编号 | -| `student_id` | 真实学号 | - -### 4.4 `pending_delete` - -保存等待 K210 删除的任务。 - -| 字段 | 含义 | -| --- | --- | -| `task_id` | 删除任务 ID | -| `face_id` | K210 需要删除的人脸 ID | -| `student_id` | 对应学生 ID | -| `created_at` | 创建时间 | -| `status` | 状态,默认 `pending` | -| `ack_at` | 确认时间 | - -### 4.5 `device_sync_state` - -保存各客户端同步状态。 - -| 字段 | 含义 | -| --- | --- | -| `client` | 客户端名,例如 `qt`、`stm32`、`k210` | -| `last_seen_version` | 客户端最近看到的服务端版本 | -| `last_ack_version` | 客户端最近确认的版本 | -| `last_seen_at` | 最近访问时间 | - -## 5. 默认配置 - -| 配置 | 当前值 | 说明 | -| --- | --- | --- | -| `HTTP_PORT` | `8082` | HTTP 监听端口 | -| `ATTENDANCE_BIND_HOST` | `0.0.0.0` | 默认监听地址,可用环境变量覆盖 | -| `DEFAULT_API_TOKEN` | `atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV` | 默认 API Token | -| `DEFAULT_CORS_ORIGIN` | `http://localhost` | 默认 CORS 来源 | -| `MAX_JSON_BODY_SIZE` | `64 KB` | 普通 JSON 请求体上限 | -| `MAX_PHOTO_BODY_SIZE` | `512 KB` | 照片上传请求体上限 | -| `DEFAULT_TIMESLOTS_JSON` | `{"time_slots":[]}` | 默认时间段为空 | - -可以通过环境变量覆盖: - -```powershell -$env:ATTENDANCE_BIND_HOST="0.0.0.0" -$env:ATTENDANCE_API_TOKEN="你的Token" -$env:ATTENDANCE_CORS_ORIGIN="http://localhost" -``` - -## 6. 鉴权规则 - -服务端对大多数接口要求请求头: - -```text -X-Api-Token: -``` - -允许不带 Token 的情况: - -1. `OPTIONS` 预检请求。 -2. `GET /api/health`。 - -新接口和旧接口都使用同样的鉴权逻辑。 - -## 7. 路由兼容关系 - -服务端内部仍有一些旧路径,例如 `/api/records`、`/api/timeslots`。为了让接口更清晰,代码提供了 `/api/v1/...` 新路径,并在服务端内部转换到旧路径。 - -| 新路径 | 内部兼容路径 | 说明 | -| --- | --- | --- | -| `/api/v1/system/health` | `/api/health` | 健康检查 | -| `/api/v1/system/time` | `/api/time` | 获取服务器时间 | -| `/api/v1/attendance/checkins` | `/api/checkin` | 加密签到上传 | -| `/api/v1/attendance/records` | `/api/records` | 获取/删除考勤记录 | -| `/api/v1/attendance/records/clear` | `/api/clear` | 清空记录 | -| `/api/v1/attendance/statistics` | `/api/statistics` | 获取统计 | -| `/api/v1/attendance/duplicates` | `/api/check_duplicate` | 查重 | -| `/api/v1/attendance/today` | `/api/today_records` | 今日某时间段记录 | -| `/api/v1/config/time-slots` | `/api/timeslots` | 时间段 | -| `/api/v1/config/mappings` | `/api/mapping_config` | 学生/班级映射 | -| `/api/v1/faces/mappings` | `/api/face_id_mapping` | 人脸映射 | -| `/api/v1/faces/photos` | `/api/face_photos` | 照片列表 | -| `/api/v1/faces/photos/` | `/api/face_photo/` 或 `/api/face_photo_simple/` | 下载/上传照片 | -| `/api/v1/faces/student-ids` | `/api/all_student_ids` | 全部学生 ID | -| `/api/v1/faces/pending-delete` | `/api/pending_delete` | 待删除任务 | -| `/api/v1/faces/delete-ack` | `/api/confirm_delete` | 旧式删除确认 | -| `/api/v1/faces/` | `/api/face/` | 删除人脸 | -| `/api/v1/sync/request` | `/api/sync_request` | 同步请求标志 | -| `/api/v1/sync/request/ack` | `/api/sync_ack` | 同步请求确认 | -| `/api/v1/sync/trigger` | `/api/trigger_sync` | 触发同步 | - -文档推荐使用 `/api/v1/...`,旧路径主要为了兼容旧代码。 - -## 8. 核心 API - -以下示例默认服务端地址是: - -```text -http://127.0.0.1:8082 -``` - -### 8.1 健康检查 - -```text -GET /api/v1/system/health -``` - -返回示例: - -```json -{ - "status": "healthy", - "time": "2026-05-01 10:00:00", - "storage": "sqlite", - "database": "attendance.db", - "database_ok": true, - "quick_check": "ok", - "sync_version": 1 -} -``` - -### 8.2 存储状态 - -```text -GET /api/v1/system/storage -``` - -用于检查 SQLite、记录数量、照片数量、待删除任务数量等。 - -### 8.3 获取服务器时间 - -```text -GET /api/v1/system/time -``` - -返回: - -```json -{ - "epoch": 1777600800, - "server_time": "2026-05-01 10:00:00" -} -``` - -STM32 使用 `epoch` 转成 UTC+8 时间。 - -### 8.4 加密签到上传 - -```text -POST /api/v1/attendance/checkins -X-Encrypted: AES128-CBC -X-Api-Token: -``` - -请求体是 Base64 字符串。解密后的 JSON: - -```json -{ - "student_id": "20240001", - "name": "张三", - "confidence": "0.95", - "status": "success", - "class_id": "1" -} -``` - -字段校验: - -| 字段 | 要求 | -| --- | --- | -| `student_id` | 必填,安全 ID,长度不超过 32 | -| `name` | 可选,长度不超过 64 | -| `confidence` | 必须是 0.0 到 1.0 | -| `status` | 只能是 `success`、`failed`、`late` | -| `class_id` | 可选,安全 ID,长度不超过 32,默认 `0` | - -成功返回: - -```json -{ - "success": true, - "message": "签到成功", - "timestamp": "2026-05-01 10:00:00" -} -``` - -### 8.5 获取考勤记录 - -```text -GET /api/v1/attendance/records?limit=1000&offset=0 -``` - -`limit` 范围是 1 到 5000,`offset` 范围是 0 到 1000000。 - -返回数组: - -```json -[ - { - "timestamp": "2026-05-01 08:10:00", - "student_id": "20240001", - "name": "张三", - "confidence": 0.95, - "status": "success", - "class_id": "1" - } -] -``` - -如果请求头带: - -```text -X-Encrypted-Response: true -``` - -服务端会把响应 JSON 加密后以 Base64 返回。Qt 当前普通记录请求没有启用这个请求头。 - -### 8.6 获取统计 - -```text -GET /api/v1/attendance/statistics -``` - -返回: - -```json -{ - "total": 120, - "today": 8, - "server_time": "2026-05-01 10:00:00" -} -``` - -### 8.7 清空记录 - -```text -POST /api/v1/attendance/records/clear -``` - -成功后会删除 `records` 表中的所有记录,并提升同步版本。 - -### 8.8 删除单条记录 - -```text -DELETE /api/v1/attendance/records?timestamp=2026-05-01%2008%3A10%3A00&student_id=20240001 -``` - -成功后提升同步版本。 - -### 8.9 时间段配置 - -获取: - -```text -GET /api/v1/config/time-slots -``` - -保存: - -```text -POST /api/v1/config/time-slots -Content-Type: application/json -``` - -请求体: - -```json -{ - "time_slots": [ - { - "name": "上午签到", - "start_time": "08:00", - "end_time": "09:30", - "enabled": true - } - ] -} -``` - -当前默认值是: - -```json -{ - "time_slots": [] -} -``` - -服务端还包含旧默认四时段的迁移判断:如果数据库里保存的是旧的四个默认时段,会自动改为空数组。 - -### 8.10 重复签到检查 - -```text -GET /api/v1/attendance/duplicates?student_id=20240001&slot_start=08:00&slot_end=09:30 -``` - -用于 STM32 在线时补充查重。 - -### 8.11 今日某时段已签到记录 - -```text -GET /api/v1/attendance/today?slot_start=08:00&slot_end=09:30 -``` - -返回: - -```json -{ - "date": "2026-05-01", - "records": ["20240001", "20240002"] -} -``` - -STM32 开机后用它构建本地今日缓存。 - -### 8.12 学生姓名和班级名称映射 - -```text -GET /api/v1/config/mappings -POST /api/v1/config/mappings -``` - -格式: - -```json -{ - "student_name_map": { - "20240001": "张三" - }, - "class_name_map": { - "1": "软件工程一班" - } -} -``` - -这个映射主要给 Qt 显示使用,不改变 STM32 签到上传的原始学号。 - -### 8.13 人脸照片 - -照片列表: - -```text -GET /api/v1/faces/photos -``` - -返回: - -```json -{ - "photos": [ - { - "id": "1", - "filename": "1.jpg" - } - ] -} -``` - -上传照片: - -```text -POST /api/v1/faces/photos/1 -Content-Type: image/jpeg -``` - -下载照片: - -```text -GET /api/v1/faces/photos/1 -``` - -成功时返回 `image/jpeg`。 - -### 8.14 人脸 ID 映射 - -```text -GET /api/v1/faces/mappings -POST /api/v1/faces/mappings -``` - -格式: - -```json -{ - "mappings": { - "1": "20240001" - } -} -``` - -保存映射后服务端会: - -1. 写入 `face_mappings`。 -2. 尝试按映射重命名照片。 -3. 提升同步版本。 -4. 设置 K210 同步请求标志。 - -### 8.15 获取全部学生 ID - -```text -GET /api/v1/faces/student-ids -``` - -从 `records` 表中提取所有出现过的学生 ID。 - -### 8.16 删除人脸 - -```text -DELETE /api/v1/faces/20240001 -DELETE /api/v1/faces/20240001?delete_records=true -``` - -服务端会: - -1. 删除相关照片。 -2. 删除相关映射。 -3. 如果 `delete_records=true`,删除该学生相关考勤记录。 -4. 创建 K210 待删除任务。 -5. 提升同步版本。 -6. 设置同步请求标志。 - -返回示例: - -```json -{ - "success": true, - "photo_deleted": true, - "mapping_deleted": true, - "records_deleted": 3, - "k210_pending": true, - "sync_requested": true -} -``` - -## 9. 统一同步接口 - -### 9.1 获取版本 - -```text -GET /api/v1/sync/version -``` - -返回: - -```json -{ - "version": 6, - "server_time": "2026-05-01 10:00:00" -} -``` - -### 9.2 获取同步状态 - -```text -GET /api/v1/sync/state?client=&last_version= -``` - -`client` 可取: - -| client | 服务端返回内容 | -| --- | --- | -| `stm32` | `time_slots` | -| `k210` | `face_mappings`、`pending_delete`、`delete_tasks` | -| `qt` | 记录、统计、时间段、映射、照片列表、学生 ID、人脸映射 | -| `all` 或空 | 尽可能返回全部 | - -如果没有变化: - -```json -{ - "version": 6, - "changed": false, - "client": "stm32", - "server_time": "2026-05-01 10:00:00" -} -``` - -如果有变化: - -```json -{ - "version": 7, - "changed": true, - "client": "k210", - "server_time": "2026-05-01 10:00:00", - "state": { - "face_mappings": {}, - "pending_delete": [], - "delete_tasks": [] - } -} -``` - -### 9.3 同步确认 - -```text -POST /api/v1/sync/ack -``` - -请求体: - -```json -{ - "client": "k210", - "version": 7, - "deleted": ["1"], - "task_ids": ["task-1"] -} -``` - -用途: - -1. 记录客户端已经确认的版本。 -2. 移除已经完成的删除任务。 -3. 必要时再次提升同步版本。 - -## 10. 编译与运行 - -### 10.1 Windows MinGW 示例 - -如果已经装好 MinGW、OpenSSL、SQLite: - -```powershell -g++ -o server_encrypted.exe server_encrypted.cpp -lws2_32 -lcrypto -lssl -lsqlite3 -std=c++11 -.\server_encrypted.exe -``` - -### 10.2 Linux 示例 - -```bash -g++ -o server_encrypted server_encrypted.cpp -lcrypto -lssl -lsqlite3 -std=c++11 -pthread -./server_encrypted -``` - -### 10.3 运行目录建议 - -建议在 `server/` 目录下运行,这样数据库、照片目录和备份目录都会生成在 `server/` 下面: - -```powershell -cd D:\University\graduation\code\Reconstruct_Qt\server -.\server_encrypted.exe -``` - -## 11. 安全注意事项 - -1. 默认 Token 已写在代码里,不适合直接暴露公网。 -2. 当前是 HTTP 明文传输,公网部署建议放到 HTTPS 反向代理后面。 -3. AES Key/IV 是固定值,演示可以,生产环境不建议固定写死。 -4. SQLite 数据库和照片目录需要定期备份。 -5. `ATTENDANCE_CORS_ORIGIN` 不建议设置成任意来源。 -6. 服务器运行目录要限制写权限,避免误删数据。 - -## 12. 常见问题 - -| 现象 | 可能原因 | 处理 | -| --- | --- | --- | -| 401 Unauthorized | 没带 `X-Api-Token` 或 Token 不一致 | 检查四端 Token | -| Qt 连接失败 | 服务端没启动、地址错、防火墙拦截 | 访问 `/api/v1/system/health` | -| STM32 解密失败 | AES Key/IV 不一致或请求体不是 Base64 密文 | 对照三端 AES 参数 | -| 时间段总是旧数据 | SQLite 中已有旧配置 | 当前代码会清理旧默认四时段;自定义旧配置需手动保存为空 | -| K210 删除不同步 | K210 未拉取同步状态或 ACK 失败 | 看 `/api/v1/sync/state?client=k210&last_version=0` | -| 照片上传失败 | 图片超过 512KB、face_id 非法、目录不可写 | 检查服务端日志和 `faces/` 权限 | diff --git a/docs/04_Qt管理端文档.md b/docs/04_Qt管理端文档.md deleted file mode 100644 index e8c3cd8..0000000 --- a/docs/04_Qt管理端文档.md +++ /dev/null @@ -1,436 +0,0 @@ -# Qt 管理端文档 - -## 1. Qt 管理端是什么 - -Qt 管理端是整个系统的图形化后台。它支持 Windows 桌面端和 Android 端,主要用于: - -1. 查看考勤记录。 -2. 按班级、学号、姓名、日期筛选记录。 -3. 导出记录 CSV。 -4. 查看统计分析。 -5. 导出统计 CSV。 -6. 配置服务端地址。 -7. 配置签到时间段。 -8. 配置学号到姓名、班级编号到班级名的显示映射。 -9. 查看 K210 上传的人脸照片。 -10. 给 K210 face_id 绑定真实学号。 -11. 删除人脸信息,并可选择删除相关考勤记录。 - -## 2. 相关文件 - -| 文件 | 作用 | -| --- | --- | -| `QT/main.cpp` | Qt 程序入口,设置应用信息、图标、QML 引擎 | -| `QT/qml/Main.qml` | 主界面,包括所有页面和对话框 | -| `QT/src/AppController.*` | QML 与 C++ 业务逻辑的中枢 | -| `QT/src/ApiClient.*` | HTTP API 客户端 | -| `QT/src/AttendanceTypes.h` | 考勤记录、统计、时间段结构体 | -| `QT/src/CryptoUtils.*` | AES 解密工具 | -| `QT/src/RecordListModel.*` | 考勤记录模型 | -| `QT/src/StatisticsListModel.*` | 统计模型 | -| `QT/src/TimeSlotListModel.*` | 时间段模型 | -| `QT/src/MappingListModel.*` | 姓名/班级映射模型 | -| `QT/src/FaceListModel.*` | 人脸管理模型 | -| `QT/CMakeLists.txt` | Qt/CMake 构建配置 | -| `QT/android/` | Android Gradle 配置和图标 | -| `QT/assets/` | 桌面端图标 | - -## 3. 技术栈 - -| 分类 | 内容 | -| --- | --- | -| 语言 | C++17、QML | -| UI | Qt Quick、Qt Quick Controls 2 | -| Qt 模块 | Core、Gui、Qml、Quick、QuickControls2、Network | -| 构建系统 | CMake 3.16+ | -| 样式 | `QQuickStyle::setStyle("Basic")` | -| Android 文字渲染 | `NativeTextRendering` | -| 本地配置 | `QSettings`、`QStandardPaths::AppDataLocation` | - -## 4. 应用基础信息 - -位置:`QT/main.cpp` - -| 项 | 值 | -| --- | --- | -| ApplicationName | `人脸识别考勤系统` | -| ApplicationVersion | `3.0.0` | -| OrganizationName | `STM32 Face Recognition` | -| 主 QML | `qrc:/qml/Main.qml` | -| 默认窗口大小 | `1560 x 920` | -| PC 最小窗口 | `900 x 620` | -| Android 最小窗口 | `360 x 560` | - -## 5. 页面结构 - -Qt 管理端是单窗口应用,共 5 个页面: - -| 页面 | 作用 | -| --- | --- | -| 考勤记录 | 查看、筛选、导出、清空记录 | -| 统计分析 | 按时间范围统计学生签到情况 | -| 配置中心 | 服务端地址、签到时间段、名称映射 | -| 人脸管理 | 查看照片、绑定学号、删除人脸 | -| 关于 | 系统说明、版本和作者信息 | - -PC 宽屏显示左侧导航栏;窄屏和 Android 显示顶部/底部更紧凑的导航布局。 - -## 6. 网络访问规则 - -Qt 端的服务端地址默认是: - -```text -http://127.0.0.1:8082 -``` - -用户可在“配置中心”修改服务端地址。保存后写入本地 `QSettings`: - -```text -server/url -``` - -`ApiClient::buildUrl` 会在地址后自动拼接 `/api`,例如: - -```text -服务端地址: http://127.0.0.1:8082 -传入 endpoint: /v1/system/health -实际请求: http://127.0.0.1:8082/api/v1/system/health -``` - -所有请求都会带: - -```text -X-Api-Token: -Accept: application/json -``` - -Token 默认值写在 `QT/src/ApiClient.cpp`,也可以通过环境变量覆盖: - -```powershell -$env:ATTENDANCE_API_TOKEN="你的Token" -``` - -网络超时: - -```text -5000 ms -``` - -## 7. 启动流程 - -Qt 启动后: - -1. `main.cpp` 创建 `QGuiApplication`。 -2. 设置应用图标、应用名、版本号、组织名。 -3. Android 下启用原生文字渲染。 -4. 创建 `AppController`。 -5. `AppController` 读取本地服务端地址、映射配置、时间段配置。 -6. `ApiClient` 设置当前服务端地址。 -7. 启动自动重连状态并执行健康检查。 -8. QML 载入 `Main.qml`。 -9. QML 通过上下文对象 `appController` 调用 C++。 - -## 8. 自动重连 - -如果健康检查失败,`AppController` 会进入重连状态。 - -相关参数: - -```text -ReconnectIntervalMs = 5000 -``` - -重连成功后,如果之前处于重连中,会调用: - -1. `refreshAll()` -2. `refreshFaces()` - -这样服务端恢复后,记录、配置、照片和映射会重新同步。 - -## 9. 考勤记录页 - -功能: - -1. 显示服务端记录。 -2. 显示总记录数、今日记录数、当前筛选结果数。 -3. 按班级筛选。 -4. 按学号筛选。 -5. 按姓名关键字筛选。 -6. 按日期范围筛选。 -7. 导出当前筛选结果。 -8. 清空服务端所有记录。 - -记录加载默认请求: - -```text -GET /api/v1/attendance/records?limit=1000&offset=0 -``` - -注意:当前 Qt 统计和筛选主要基于已加载的记录。如果服务端超过 1000 条,界面统计不一定覆盖全部历史记录。 - -## 10. 筛选规则 - -筛选在 Qt 本地完成。 - -支持: - -| 筛选项 | 说明 | -| --- | --- | -| 班级 | `class_id` 匹配 | -| 学号 | `student_id` 匹配 | -| 姓名 | 映射姓名或原始 name 包含关键字 | -| 日期 | 全部、今天、最近 7 天、最近 30 天、指定日期 | - -姓名显示优先级: - -1. 如果学生映射里有该学号,显示映射姓名。 -2. 否则显示服务端记录中的 `name`。 -3. 如果都没有,则显示原始学号。 - -班级显示优先级: - -1. 如果班级映射里有该 `class_id`,显示映射班级名。 -2. 否则显示 `class_id`。 - -## 11. 记录导出 - -导出 CSV 文件时会写入: - -```text -# 考勤记录导出 -# 导出时间: yyyy-MM-dd HH:mm:ss -# 记录总数: N -签到时间,班级编号,班级名称,学号,姓名,置信度,状态 -``` - -字段会做 CSV 转义,包含逗号、双引号、换行时会自动加引号并转义。 - -## 12. 统计分析页 - -统计页基于当前加载记录在本地计算。 - -支持时间范围: - -1. 今天。 -2. 最近 7 天。 -3. 最近 30 天。 -4. 本月。 -5. 自定义开始和结束日期。 - -统计指标: - -| 指标 | 含义 | -| --- | --- | -| 统计学生数 | 当前范围内出现过的学生数量 | -| 统计记录数 | 当前范围内记录数量 | -| 总签到次数 | 每个学生在范围内的记录数量 | -| 今日签到 | 每个学生今天的记录数量 | -| 最后签到时间 | 每个学生最新一条记录时间 | -| 平均置信度 | 每个学生记录的平均 confidence | - -导出字段: - -```text -考勤统计报表,<范围> -学号,姓名,总签到次数,今日签到,最后签到时间,平均置信度 -``` - -## 13. 配置中心 - -配置中心包含三块: - -1. 服务端地址。 -2. 时间段配置。 -3. 名称映射配置。 - -### 13.1 服务端地址 - -字段示例: - -```text -http://27.106.113.205:8082 -``` - -按钮: - -| 按钮 | 作用 | -| --- | --- | -| 保存地址 | 写入 `QSettings`,并立即切换 `ApiClient` 的服务端 | -| 测试连接 | 请求 `/api/v1/system/health` | - -### 13.2 时间段配置 - -时间段格式: - -```json -{ - "name": "上午签到", - "start_time": "08:00", - "end_time": "09:30", - "enabled": true -} -``` - -当前默认时间段为空。第一次使用时需要手动新增。 - -按钮: - -| 按钮 | 作用 | -| --- | --- | -| 展开/收起 | 展示或隐藏时间段列表 | -| 新增时段 | 打开新增对话框 | -| 清空时段 | 清空本地时间段并保存同步到服务端 | -| 从服务器同步/同步 | 从服务端读取时间段;服务端为空数组时也会清空本地时间段 | -| 保存时间段/保存 | 保存到本地并上传服务端 | - -本地文件: - -```text -timeslot_config.json -``` - -如果本地文件中刚好是旧版本四个默认时间段,当前代码会自动迁移为空配置。 - -### 13.3 名称映射配置 - -学生映射: - -```json -{ - "20240001": "张三" -} -``` - -班级映射: - -```json -{ - "1": "软件工程一班" -} -``` - -本地文件: - -```text -mapping_config.json -``` - -## 14. 人脸管理页 - -人脸管理页把三类数据合并展示: - -1. 服务端照片列表。 -2. 服务端已有学生 ID。 -3. 服务端 face_id 到学生 ID 映射。 - -如果服务端已经把照片从原始 face_id 文件名重命名为学号文件名,Qt 仍会把已有映射中的原始 face_id 展示出来,保存时保留这类真实映射,并跳过未编辑的“ID 等于学号”的默认占位项。 - -用户可以: - -1. 刷新人脸库。 -2. 为每张照片填写真实学号。 -3. 保存映射。 -4. 删除人脸。 -5. 删除人脸时选择是否同时删除该学生的考勤记录。 - -照片下载流程: - -1. Qt 获取照片 ID 列表。 -2. 对每个 ID 请求 `/api/v1/faces/photos/`。 -3. 下载成功后保存到本地缓存目录: - -```text -/face_photos/.jpg -``` - -4. QML 使用本地 file URL 显示图片。 - -## 15. 关于页面 - -关于页显示: - -1. 系统用途。 -2. 功能范围。 -3. 毕业设计说明。 -4. 作者和版权信息。 -5. 仓库地址。 -6. 演示账号信息。 - -这些文本在 `QT/qml/Main.qml` 中。 - -## 16. PC 和 Android 适配 - -当前 QML 中有这些布局断点: - -| 属性 | 条件 | 用途 | -| --- | --- | --- | -| `compactLayout` | `width < 1460` | PC 紧凑布局 | -| `denseLayout` | `width < 1380` | 更密集显示 | -| `narrowLayout` | `width < 1280` | 表格列裁剪 | -| `slimLayout` | `width < 1040` | 隐藏侧边栏,使用移动导航 | -| `phoneLayout` | PC 小于 560,Android 小于 640 | 手机布局 | - -Android 特殊处理: - -1. 最小窗口宽度是 `360`。 -2. 最小窗口高度是 `560`。 -3. 字体使用 `sans-serif`。 -4. 粗体权重降低为 `Font.Medium`。 -5. 使用原生文字渲染,减少中文粗体重影。 - -配置页卡片现在使用内容自适应高度,避免按钮遮挡。 - -## 17. 本地持久化 - -Qt 使用两类本地持久化。 - -### 17.1 QSettings - -保存服务端地址: - -```text -server/url -``` - -### 17.2 AppDataLocation 文件 - -| 文件 | 内容 | -| --- | --- | -| `timeslot_config.json` | 时间段配置 | -| `mapping_config.json` | 学生姓名和班级名称映射 | -| `face_photos/*.jpg` | 下载后的人脸照片缓存 | - -## 18. 构建方式 - -### 18.1 Qt Creator - -1. 打开 Qt Creator。 -2. 打开 `QT/CMakeLists.txt`。 -3. 选择 Desktop Qt 6.x 或 Android Kit。 -4. 等待 CMake 配置完成。 -5. 构建 `apprewrite_qt`。 -6. 桌面端直接运行;Android 端部署到设备或模拟器。 - -### 18.2 命令行示例 - -Windows: - -```powershell -cd D:\University\graduation\code\Reconstruct_Qt\QT -cmake -S . -B build\Desktop -DCMAKE_PREFIX_PATH=D:\Qt\6.10.0\mingw_64 -cmake --build build\Desktop -``` - -实际路径要按本机 Qt 安装位置调整。 - -## 19. 常见问题 - -| 现象 | 可能原因 | 处理 | -| --- | --- | --- | -| 测试连接失败 | 服务端没启动、地址错、Token 不一致 | 检查服务端健康接口和 `ATTENDANCE_API_TOKEN` | -| 页面按钮重叠 | 旧版固定高度导致内容被裁切 | 当前已改为自适应高度 | -| Android 文字发虚/重影 | 字体渲染方式问题 | 当前 Android 使用原生渲染和中等粗体 | -| 人脸照片不显示 | 下载失败、本地缓存路径无效、服务端无照片 | 刷新人脸库,看服务端 `faces/` | -| 统计数量少于服务端总量 | Qt 默认只拉取 1000 条记录 | 增加分页或服务端统计接口 | -| 时间段为空导致无法签到 | 当前默认就是空配置 | 在配置中心新增时间段并保存 | -| 保存映射后 K210 没变化 | K210 尚未同步或 WiFi 不通 | 等待同步或检查 K210 日志 | diff --git a/docs/05_项目审查问题清单.md b/docs/05_项目审查问题清单.md deleted file mode 100644 index 128536a..0000000 --- a/docs/05_项目审查问题清单.md +++ /dev/null @@ -1,283 +0,0 @@ -# 项目维护、验收与风险清单 - -## 1. 文档目的 - -这份文档用于回答三个问题: - -1. 项目目前实现了什么。 -2. 验收或答辩时可以从哪些方面说明。 -3. 后续维护时有哪些风险和改进方向。 - -它不是单纯的 bug 列表,而是把功能完整性、稳定性、安全性、易用性和可维护性都列出来。 - -## 2. 当前实现状态总览 - -| 模块 | 当前状态 | 说明 | -| --- | --- | --- | -| STM32 主控 | 已实现 | 接收 K210、考勤判断、加密上传、断线重试、时间段同步 | -| K210 识别 | 已实现 | 人脸检测、特征比对、添加/删除、照片上传、映射同步 | -| 服务端 | 已实现 | SQLite 存储、HTTP API、Token、AES 解密、统一同步 | -| Qt 管理端 | 已实现 | 记录、统计、配置、人脸管理、PC/Android 响应式界面 | -| 默认时间段 | 已改为空 | 需要管理员在 Qt 中新增 | -| 数据导出 | 已实现 | Qt 可导出记录和统计 CSV | -| 自动化 API 回归 | 已有脚本 | `server/regression_api_tests.ps1` | - -## 3. 功能验收清单 - -### 3.1 服务端验收 - -| 检查项 | 通过标准 | -| --- | --- | -| 服务可启动 | 控制台显示监听 `8082` | -| 健康检查 | `/api/v1/system/health` 返回 `healthy` | -| SQLite 初始化 | 生成或打开 `attendance.db` | -| 时间接口 | `/api/v1/system/time` 返回 `epoch` 和 `server_time` | -| Token 鉴权 | 不带 Token 访问受保护接口返回 401 | -| 时间段默认 | `/api/v1/config/time-slots` 返回 `{"time_slots":[]}` | -| 记录接口 | `/api/v1/attendance/records` 返回 JSON 数组 | -| 统计接口 | `/api/v1/attendance/statistics` 返回 `total`、`today` | -| 照片接口 | 可列出、上传、下载 JPEG | -| 同步接口 | `/api/v1/sync/state` 按客户端返回不同数据 | - -### 3.2 Qt 管理端验收 - -| 检查项 | 通过标准 | -| --- | --- | -| 启动界面 | Windows 和 Android 都能打开主界面 | -| 服务地址 | 可保存地址并测试连接 | -| 配置页布局 | PC 和 Android 不出现按钮遮挡 | -| 时间段 | 默认为空,可新增、保存、同步、清空 | -| 映射 | 可新增学生姓名和班级名称映射 | -| 记录页 | 能加载、筛选和导出记录 | -| 统计页 | 能按日期范围统计并导出 | -| 人脸管理 | 能刷新照片、绑定学号、保存映射、删除人脸 | -| 自动重连 | 服务端恢复后能重新同步 | - -### 3.3 STM32 验收 - -| 检查项 | 通过标准 | -| --- | --- | -| 硬件初始化 | OLED、蜂鸣器、LED、串口正常 | -| ESP8266 | 能连接 WiFi 和服务端 | -| 时间同步 | 能从服务端获取时间或读取 DS1302 | -| 时间段同步 | 能拉取服务端配置,空配置会清空本地时间段 | -| K210 接收 | 能解析 `ID:...,NAME:...,CONF:...` | -| 签到判断 | 置信度、时间段、重复签到逻辑生效 | -| 加密上传 | 服务端能收到并解密保存记录 | -| 断线重试 | 网络断开时记录保留,恢复后同步 | - -### 3.4 K210 验收 - -| 检查项 | 通过标准 | -| --- | --- | -| 模型加载 | 三个 `.smodel` 正常加载 | -| 摄像头 | LCD 能显示画面,方向正确 | -| 人脸识别 | 已录入人脸能识别,陌生人显示陌生 | -| 添加人脸 | 短按进入添加,确认后写入 SD 卡 | -| 删除人脸 | 长按进入删除,可删除单个或全部 | -| 照片上传 | 新增人脸照片能上传到服务端 | -| 映射同步 | Qt 保存映射后 K210 本地名称更新 | -| 删除同步 | Qt 删除后 K210 能删除本地特征并 ACK | - -## 4. 数据一致性清单 - -### 4.1 Token 一致 - -这些地方必须一致: - -1. 服务端 `ATTENDANCE_API_TOKEN` 或默认 Token。 -2. Qt 环境变量或默认 Token。 -3. `User/main.c` 的 `HTTP_API_TOKEN`。 -4. `k210/main.py` 的 `API_TOKEN`。 - -### 4.2 服务端地址一致 - -这些地方要指向同一个服务端: - -1. Qt 配置中心的服务端地址。 -2. `User/main.c` 的 `HTTP_SERVER_IP` 和 `HTTP_SERVER_PORT`。 -3. `k210/main.py` 的 `SERVER_IP` 和 `SERVER_PORT`。 - -### 4.3 AES 参数一致 - -这些文件必须一致: - -1. `Hardware/Crypto.c` -2. `server/server_encrypted.cpp` -3. `QT/src/CryptoUtils.cpp` - -参数: - -```text -Key: 2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c -IV : 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f -``` - -### 4.4 时间段一致 - -Qt 保存时间段后: - -1. 服务端 `time_slots` 更新。 -2. 服务端同步版本提升。 -3. STM32 下一次同步拉取到新时间段。 -4. STM32 使用新的时间段判断签到。 - -### 4.5 人脸映射一致 - -Qt 保存人脸映射后: - -1. 服务端保存 `face_mappings`。 -2. 服务端触发 K210 同步请求。 -3. K210 同步后更新 `face_mapping.txt`。 -4. K210 更新 `recordftr.txt`。 -5. K210 发给 STM32 的 ID 变成真实学号。 - -## 5. 当前已知限制 - -### 5.1 Qt 记录统计基于当前加载记录 - -Qt 默认请求: - -```text -limit=1000&offset=0 -``` - -如果服务端已有超过 1000 条记录,Qt 本地筛选和统计只覆盖当前加载的这 1000 条。服务端 `/statistics` 的总数和今日数仍来自数据库。 - -改进方向: - -1. 增加分页。 -2. 增加服务端按条件查询。 -3. 增加服务端按日期统计接口。 - -### 5.2 时间输入缺少强校验 - -Qt 时间段输入框允许手动输入文本。当前主要依赖 `QTime::fromString("HH:mm")` 解析。 - -改进方向: - -1. 使用时间选择控件。 -2. 保存前校验格式。 -3. 检查开始时间必须早于结束时间。 -4. 检查时间段重叠。 - -### 5.3 K210 JSON 解析仍偏轻量 - -K210 MicroPython 环境中很多 JSON 解析是手写字符串处理。简单数据够用,但复杂转义字符支持有限。 - -改进方向: - -1. 尽量让服务端返回简单结构。 -2. 避免映射 key/value 中出现引号、逗号等复杂字符。 -3. 如果固件环境支持,使用 JSON 库。 - -### 5.4 STM32 本地缓存容量有限 - -STM32 本地记录最多 100 条,今日学生缓存最多 50 个 ID。 - -改进方向: - -1. 根据实际班级人数扩大缓存。 -2. 增加外部 Flash 或 SD 卡存储。 -3. 对已同步记录做更明确的淘汰策略。 - -### 5.5 服务端是单文件原生 HTTP 实现 - -服务端用 socket 手写 HTTP,演示和毕业设计足够直观,但在生产环境上不如成熟 Web 框架。 - -改进方向: - -1. 引入成熟 HTTP 库。 -2. 增加结构化日志。 -3. 增加配置文件。 -4. 增加单元测试。 - -## 6. 安全风险和建议 - -| 风险 | 当前状态 | 建议 | -| --- | --- | --- | -| 默认 Token 写在代码中 | 存在 | 部署时用环境变量,并替换 STM32/K210 常量 | -| HTTP 明文传输 | 存在 | 公网部署加 HTTPS 反向代理 | -| AES Key/IV 固定 | 存在 | 演示可接受,生产应使用更安全的密钥管理 | -| 管理接口权限粗粒度 | 存在 | 增加用户登录、角色权限 | -| 清空记录接口风险高 | 存在 | 增加二次确认、审计日志 | -| 删除人脸会影响多端 | 已有同步任务 | 保留日志,避免误删 | - -## 7. 答辩或展示可以讲的重点 - -### 7.1 多端协同 - -项目不是单独做一个 UI 或一个识别脚本,而是把嵌入式主控、AI 识别、服务端、桌面/移动管理端串起来。 - -### 7.2 安全传输 - -STM32 上传签到数据不是明文,而是 AES-128-CBC 加密后 Base64 传输。服务端负责解密和校验。 - -### 7.3 同步版本机制 - -服务端维护统一版本号,STM32、K210、Qt 都可以按版本拉取自己需要的数据,减少无意义请求。 - -### 7.4 断线容错 - -STM32 在网络异常时仍能运行,并保留待同步记录;K210 在照片上传失败时保存本地照片并重新排队。 - -### 7.5 跨平台管理端 - -Qt 管理端支持 PC 和 Android,配置页已做响应式布局,避免窄屏按钮遮挡。 - -### 7.6 数据可视化和导出 - -Qt 提供记录筛选、统计分析和 CSV 导出,方便后续汇总考勤数据。 - -## 8. 回归测试建议 - -### 8.1 每次改服务端后 - -1. 启动服务端。 -2. 运行 `server/regression_api_tests.ps1`。 -3. 手动测试上传时间段为空数组。 -4. 手动测试保存人脸映射后同步版本是否变化。 -5. 手动测试删除人脸后 `pending_delete` 是否出现任务。 - -### 8.2 每次改 Qt 后 - -1. 检查 PC 宽屏。 -2. 检查 PC 窄屏。 -3. 检查 Android 手机宽度。 -4. 检查配置中心展开/收起。 -5. 检查时间段为空时页面是否正常。 -6. 检查保存映射和刷新照片。 - -### 8.3 每次改 STM32 后 - -1. 看调试串口启动日志。 -2. 确认 ESP8266 能连 WiFi。 -3. 确认能下载时间段。 -4. 确认空时间段时不会签到。 -5. 配置时间段后再确认能签到。 -6. 断开服务端再恢复,检查待同步记录上传。 - -### 8.4 每次改 K210 后 - -1. 确认模型加载。 -2. 确认添加人脸写入 SD 卡。 -3. 确认照片进入上传队列。 -4. 确认服务端能看到照片。 -5. 确认 Qt 保存映射后 K210 能同步。 -6. 确认 Qt 删除后 K210 能删除并 ACK。 - -## 9. 建议后续优化路线 - -优先级从高到低: - -1. 增加 Qt 时间输入校验和重叠检测。 -2. 给服务端增加配置文件,减少硬编码。 -3. 给服务端增加更完整的自动化测试。 -4. 给 Qt 增加记录分页。 -5. 给服务端增加按条件查询和统计接口。 -6. 把 Token 和 AES 密钥改成部署时配置。 -7. 增加 HTTPS 部署说明。 -8. 给 STM32 增加更大的离线缓存。 -9. 给 K210 使用更稳健的 JSON 解析。 -10. 增加服务端日志文件轮转。 - diff --git a/docs/06_后端接口Windows测试命令.md b/docs/06_后端接口Windows测试命令.md deleted file mode 100644 index 9b9f051..0000000 --- a/docs/06_后端接口Windows测试命令.md +++ /dev/null @@ -1,546 +0,0 @@ -# 后端接口 Windows 测试命令 - -## 1. 使用前准备 - -本文档用于在 Windows PowerShell 中手动测试服务端接口。 - -默认服务端地址: - -```powershell -$BaseUrl = "http://127.0.0.1:8082" -``` - -默认 Token: - -```powershell -$ApiToken = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV" -``` - -建议先在 PowerShell 中设置: - -```powershell -$BaseUrl = "http://127.0.0.1:8082" -$ApiToken = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV" -$Headers = @{ - "X-Api-Token" = $ApiToken - "Accept" = "application/json" -} -``` - -如果服务端运行在公网 IP,例如 `27.106.113.205`: - -```powershell -$BaseUrl = "http://27.106.113.205:8082" -``` - -## 2. 自动化回归脚本 - -项目已经提供基础回归脚本: - -```powershell -cd D:\University\graduation\code\Reconstruct_Qt\server -.\regression_api_tests.ps1 -BaseUrl "http://127.0.0.1:8082" -ApiToken "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV" -``` - -脚本会检查: - -1. 健康检查。 -2. 存储状态。 -3. 时间接口。 -4. 同步版本。 -5. K210/STM32 同步状态。 -6. 记录和统计。 -7. 时间段。 -8. 查重。 -9. 今日记录。 -10. 映射。 -11. 照片。 -12. 学生 ID。 -13. 待删除任务。 -14. 同步请求。 - -## 3. 基础连通性 - -### 3.1 健康检查 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/system/health" -``` - -不需要 Token。正常应返回 `status = healthy`。 - -### 3.2 带 Token 健康检查 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/system/health" -Headers $Headers -``` - -### 3.3 端口连通 - -```powershell -Test-NetConnection -ComputerName 127.0.0.1 -Port 8082 -``` - -公网服务器: - -```powershell -Test-NetConnection -ComputerName 27.106.113.205 -Port 8082 -``` - -### 3.4 检查端口占用 - -```powershell -netstat -ano | findstr :8082 -``` - -## 4. 系统接口 - -### 4.1 获取服务器时间 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/system/time" -Headers $Headers -``` - -返回应包含: - -```text -epoch -server_time -``` - -### 4.2 查看存储状态 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/system/storage" -Headers $Headers -``` - -用于确认 SQLite、记录数、照片数、待删除任务等。 - -### 4.3 不带 Token 测试鉴权 - -```powershell -try { - Invoke-RestMethod -Uri "$BaseUrl/api/v1/system/storage" -} catch { - $_.Exception.Response.StatusCode.value__ -} -``` - -正常应该得到 `401`。 - -## 5. 同步接口 - -### 5.1 查看当前同步版本 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/sync/version" -Headers $Headers -``` - -### 5.2 Qt 同步状态 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/sync/state?client=qt&last_version=0" -Headers $Headers -``` - -返回内容包含 Qt 需要的数据,例如记录、统计、时间段、映射、照片和学生 ID。 - -### 5.3 STM32 同步状态 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/sync/state?client=stm32&last_version=0" -Headers $Headers -``` - -重点看: - -```text -state.time_slots -``` - -当前默认应为空数组,除非已经在 Qt 中保存过时间段。 - -### 5.4 K210 同步状态 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/sync/state?client=k210&last_version=0" -Headers $Headers -``` - -重点看: - -```text -state.face_mappings -state.pending_delete -state.delete_tasks -``` - -### 5.5 K210 同步 ACK - -```powershell -$Body = @{ - client = "k210" - version = 1 - deleted = @() - task_ids = @() -} - -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/sync/ack" ` - -Method POST ` - -Headers $Headers ` - -ContentType "application/json" ` - -Body ($Body | ConvertTo-Json -Compress) -``` - -## 6. 考勤记录接口 - -### 6.1 获取记录 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/attendance/records?limit=10&offset=0" -Headers $Headers -``` - -### 6.2 获取统计 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/attendance/statistics" -Headers $Headers -``` - -### 6.3 删除单条记录 - -先准备参数: - -```powershell -$Timestamp = [uri]::EscapeDataString("2026-05-01 08:10:00") -$StudentId = [uri]::EscapeDataString("20240001") -``` - -删除: - -```powershell -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/attendance/records?timestamp=$Timestamp&student_id=$StudentId" ` - -Method DELETE ` - -Headers $Headers -``` - -### 6.4 清空全部记录 - -危险操作,会删除全部考勤记录: - -```powershell -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/attendance/records/clear" ` - -Method POST ` - -Headers $Headers ` - -ContentType "application/json" -``` - -## 7. 时间段接口 - -### 7.1 获取时间段 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/config/time-slots" -Headers $Headers -``` - -新部署默认应为: - -```json -{ - "time_slots": [] -} -``` - -### 7.2 保存空时间段 - -```powershell -$Body = @{ - time_slots = @() -} - -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/config/time-slots" ` - -Method POST ` - -Headers $Headers ` - -ContentType "application/json" ` - -Body ($Body | ConvertTo-Json -Depth 5 -Compress) -``` - -### 7.3 保存一个时间段 - -```powershell -$Body = @{ - time_slots = @( - @{ - name = "上午签到" - start_time = "08:00" - end_time = "09:30" - enabled = $true - } - ) -} - -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/config/time-slots" ` - -Method POST ` - -Headers $Headers ` - -ContentType "application/json" ` - -Body ($Body | ConvertTo-Json -Depth 5 -Compress) -``` - -### 7.4 检查重复签到 - -```powershell -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/attendance/duplicates?student_id=20240001&slot_start=08:00&slot_end=09:30" ` - -Headers $Headers -``` - -### 7.5 获取今日某时间段记录 - -```powershell -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/attendance/today?slot_start=08:00&slot_end=09:30" ` - -Headers $Headers -``` - -## 8. 名称映射接口 - -### 8.1 获取映射 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/config/mappings" -Headers $Headers -``` - -### 8.2 保存映射 - -```powershell -$Body = @{ - student_name_map = @{ - "20240001" = "张三" - } - class_name_map = @{ - "1" = "软件工程一班" - } -} - -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/config/mappings" ` - -Method POST ` - -Headers $Headers ` - -ContentType "application/json" ` - -Body ($Body | ConvertTo-Json -Depth 10 -Compress) -``` - -## 9. 人脸照片和映射接口 - -### 9.1 获取照片列表 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/faces/photos" -Headers $Headers -``` - -### 9.2 下载照片 - -```powershell -$FaceId = "1" -Invoke-WebRequest ` - -Uri "$BaseUrl/api/v1/faces/photos/$FaceId" ` - -Headers $Headers ` - -OutFile ".\face_$FaceId.jpg" -``` - -如果服务端没有该照片,会返回 404。 - -### 9.3 上传测试照片 - -假设当前目录有 `test.jpg`: - -```powershell -$FaceId = "test001" -$PhotoBytes = [System.IO.File]::ReadAllBytes((Resolve-Path ".\test.jpg")) - -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/faces/photos/$FaceId" ` - -Method POST ` - -Headers $Headers ` - -ContentType "image/jpeg" ` - -Body $PhotoBytes -``` - -### 9.4 获取人脸映射 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/faces/mappings" -Headers $Headers -``` - -### 9.5 保存人脸映射 - -```powershell -$Body = @{ - mappings = @{ - "1" = "20240001" - } -} - -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/faces/mappings" ` - -Method POST ` - -Headers $Headers ` - -ContentType "application/json" ` - -Body ($Body | ConvertTo-Json -Depth 5 -Compress) -``` - -保存后应提升同步版本,并触发 K210 同步请求。 - -### 9.6 获取全部学生 ID - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/faces/student-ids" -Headers $Headers -``` - -### 9.7 删除人脸 - -只删除人脸相关信息: - -```powershell -$StudentId = [uri]::EscapeDataString("20240001") -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/faces/$StudentId" ` - -Method DELETE ` - -Headers $Headers -``` - -删除人脸并删除该学生考勤记录: - -```powershell -$StudentId = [uri]::EscapeDataString("20240001") -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/faces/$StudentId?delete_records=true" ` - -Method DELETE ` - -Headers $Headers -``` - -## 10. K210 删除同步接口 - -### 10.1 获取待删除任务 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/faces/pending-delete" -Headers $Headers -``` - -返回可能包含: - -```text -pending_delete -delete_tasks -``` - -### 10.2 旧式删除确认 - -```powershell -$Body = @{ - face_id = "1" -} - -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/faces/delete-ack" ` - -Method POST ` - -Headers $Headers ` - -ContentType "application/json" ` - -Body ($Body | ConvertTo-Json -Compress) -``` - -### 10.3 查询同步请求标志 - -```powershell -Invoke-RestMethod -Uri "$BaseUrl/api/v1/sync/request" -Headers $Headers -``` - -### 10.4 确认同步请求 - -```powershell -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/sync/request/ack" ` - -Method POST ` - -Headers $Headers ` - -ContentType "application/json" ` - -Body '{"ack":true}' -``` - -### 10.5 触发 K210 同步 - -```powershell -Invoke-RestMethod ` - -Uri "$BaseUrl/api/v1/sync/trigger" ` - -Method POST ` - -Headers $Headers ` - -ContentType "application/json" -``` - -## 11. 加密签到接口说明 - -STM32 实际上传的是 AES-128-CBC 加密后的 Base64。手动构造比较麻烦,通常不建议用 PowerShell 直接手写。 - -服务端要求: - -```text -POST /api/v1/attendance/checkins -X-Api-Token: -X-Encrypted: AES128-CBC -``` - -明文格式应是: - -```json -{ - "student_id": "20240001", - "name": "张三", - "confidence": "0.95", - "status": "success", - "class_id": "1" -} -``` - -如果只是测试记录列表、统计、配置和同步,不需要手动构造加密签到。 - -## 12. 常见错误 - -### 12.1 401 Unauthorized - -原因:Token 缺失或不一致。 - -检查: - -```powershell -$Headers -``` - -### 12.2 404 Not Found - -原因: - -1. 路径写错。 -2. 服务端还没启动新版本。 -3. 照片 ID 不存在。 - -### 12.3 Invoke-RestMethod 连接失败 - -检查服务端是否启动: - -```powershell -Test-NetConnection -ComputerName 127.0.0.1 -Port 8082 -``` - -### 12.4 Qt 能访问,本机命令不能访问 - -可能 Qt 用的是公网地址,而 PowerShell `$BaseUrl` 还是 `127.0.0.1`。统一服务端地址。 - -### 12.5 STM32/K210 不能访问,电脑能访问 - -可能原因: - -1. STM32/K210 配置的是公网 IP,本机测的是本地地址。 -2. WiFi 与服务器不在同一网络。 -3. 路由器或服务器防火墙阻止端口。 -4. Token 写错。 - diff --git a/k210/main.py b/k210/main.py deleted file mode 100644 index 7a10c5a..0000000 --- a/k210/main.py +++ /dev/null @@ -1,1832 +0,0 @@ -# ==================================================================================== -# K210 V11 (分时网络通信) -# 功能更新: -# - 继承V10所有功能 -# - 【新功能】使用分时处理网络通信,每帧只做少量网络工作,避免长时间阻塞 -# ==================================================================================== - -import sensor, image, lcd -import KPU as kpu -import time -from Maix import FPIOA, GPIO, utils -import gc -from fpioa_manager import fm -import utime -from machine import UART -import os - -# --- 增加堆内存大小 --- -# K210有8MB SRAM,默认堆较小,增加到512KB用于图像压缩 -utils.gc_heap_size(1024 * 1024) -gc.collect() -print("[BOOT] Heap size set to 1MB") - -# --- 配置参数(宏定义)--- -SYNC_CHECK_INTERVAL_MS = 50000 # 检查同步请求的时间间隔(毫秒) -NETWORK_FRAME_INTERVAL = 300 # 每N帧检查一次网络任务(约1秒) - -# --- 分时网络任务队列 --- -photo_upload_queue = [] # 照片上传队列 [face_id, ...],JPEG先保存在SD卡 -network_task_pending = False # 是否有网络任务待处理 -frames_since_network = 0 # 距离上次网络操作的帧数 - -# --- 0. ESP8266 WiFi配置 --- -WIFI_SSID = "yzfh" # 修改为你的WiFi名称 -WIFI_PASSWORD = "yzfhroot" # 修改为你的WiFi密码 -SERVER_IP = "27.106.113.205" # 修改为服务器IP地址 -SERVER_PORT = 8082 # 服务器HTTP端口 -API_TOKEN = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV" # 必须与服务端 ATTENDANCE_API_TOKEN 一致 -ESP_DEBUG = True # 打印ESP8266 AT响应,便于排查WiFi连接问题 -ESP_BAUD_RATES = (115200, 9600, 74880, 57600, 38400) -ESP_UART_BAUD = 115200 -ESP_BOOT_WAIT_MS = 6000 # 等待ESP8266上电启动和自动连网 -LOCAL_FACE_PHOTO_DIR = "/sd/face_photos" # 本地人脸照片缓存目录 - -# 摄像头安装方向:当前实物画面上下左右都反,水平镜像+垂直翻转等价于旋转180度。 -CAMERA_HMIRROR = 1 -CAMERA_VFLIP = 1 - -# --- 1. UART通信初始化 --- -# UART1: 连接STM32(原有功能) -fm.register(24, fm.fpioa.UART1_TX, force=True) -fm.register(25, fm.fpioa.UART1_RX, force=True) -uart = UART(UART.UART1, 115200, 8, 0, 1, timeout=1000, read_buf_len=4096) - -# UART2: 连接新ESP8266(照片上传功能) -fm.register(34, fm.fpioa.UART2_TX, force=True) -fm.register(35, fm.fpioa.UART2_RX, force=True) -uart2 = UART(UART.UART2, 115200, 8, 0, 1, timeout=2000, read_buf_len=4096) - -# --- ESP8266 WiFi通信函数(优化版 - 减少阻塞)--- -def esp_reopen_uart(baud): - """按指定波特率重新打开ESP8266串口""" - global uart2, ESP_UART_BAUD - ESP_UART_BAUD = baud - try: - uart2.deinit() - except: - pass - uart2 = UART(UART.UART2, baud, 8, 0, 1, timeout=2000, read_buf_len=4096) - utime.sleep_ms(100) - -def esp_clear_rx(): - """清空ESP8266串口残留数据""" - while uart2.any(): - uart2.read() - utime.sleep_ms(10) - -def esp_log_response(cmd, response): - if ESP_DEBUG: - text = response.replace('\r\n', ' ').strip() if response else "(空)" - print("[ESP] {} => {}".format(cmd, text[:180])) - -def esp_send_cmd(cmd, timeout=1000, expected=None): - """发送AT命令到ESP8266并等待响应""" - esp_clear_rx() - uart2.write(cmd + "\r\n") - utime.sleep_ms(50) - start = utime.ticks_ms() - response = "" - if expected is None: - expected = ("OK", "ERROR", "FAIL", "ready") - while utime.ticks_diff(utime.ticks_ms(), start) < timeout: - if uart2.any(): - data = uart2.read() - if data: - response += data.decode('utf-8', 'ignore') - if any(token in response for token in expected): - break - utime.sleep_ms(5) - esp_log_response(cmd, response) - return response - -def esp_probe_uart(): - """探测ESP8266实际AT波特率""" - print("[ESP] Probing UART baud rate...") - for baud in ESP_BAUD_RATES: - print("[ESP] Try baud:", baud) - esp_reopen_uart(baud) - for _ in range(2): - resp = esp_send_cmd("AT", 1500) - if "OK" in resp: - print("[ESP] UART baud detected:", baud) - return True - utime.sleep_ms(300) - esp_reopen_uart(115200) - print("[ESP] No AT response on tested baud rates") - return False - -def esp_wait_boot(): - """等待ESP8266启动完成,避免K210太早发送AT命令""" - print("[ESP] Waiting for module boot...") - start = utime.ticks_ms() - boot_log = "" - while utime.ticks_diff(utime.ticks_ms(), start) < ESP_BOOT_WAIT_MS: - if uart2.any(): - data = uart2.read() - if data: - boot_log += data.decode('utf-8', 'ignore') - utime.sleep_ms(100) - if ESP_DEBUG and boot_log: - print("[ESP] Boot log:", boot_log.replace('\r\n', ' ')[:180]) - esp_clear_rx() - -def esp_check_wifi_status(): - """检查WiFi连接状态(快速检查)""" - resp = esp_send_cmd("AT+CWJAP?", 1500) - return WIFI_SSID in resp - -def esp_connect_wifi(): - """连接WiFi(单次尝试)""" - print("[ESP] Connecting to WiFi:", WIFI_SSID) - - resp = esp_send_cmd("ATE0", 1000) - - resp = esp_send_cmd("AT+CWMODE_CUR=1", 1500) - if "OK" not in resp: - resp = esp_send_cmd("AT+CWMODE=1", 1500) - - if "OK" not in resp and "no change" not in resp: - print("[ESP] Set mode failed") - return False - - if esp_check_wifi_status(): - print("[ESP] WiFi already connected!") - return True - - # 连接WiFi。部分路由器获取IP较慢,等待时间不能太短。 - cmd = 'AT+CWJAP_CUR="{}","{}"'.format(WIFI_SSID, WIFI_PASSWORD) - resp = esp_send_cmd(cmd, 20000, ("WIFI GOT IP", "OK", "FAIL", "ERROR")) - - if "OK" not in resp and "CONNECTED" not in resp and "WIFI GOT IP" not in resp: - cmd = 'AT+CWJAP="{}","{}"'.format(WIFI_SSID, WIFI_PASSWORD) - resp = esp_send_cmd(cmd, 20000, ("WIFI GOT IP", "OK", "FAIL", "ERROR")) - - if "OK" in resp or "CONNECTED" in resp or "WIFI GOT IP" in resp: - utime.sleep_ms(1000) - status = esp_send_cmd("AT+CWJAP?", 1500) - ip = esp_send_cmd("AT+CIFSR", 1500) - if WIFI_SSID not in status and "STAIP" not in ip: - print("[ESP] WiFi command returned OK, but no station IP found") - return False - print("[ESP] WiFi connected!") - return True - - print("[ESP] WiFi connect failed") - return False - -def esp_init_wifi(max_retries=5): - """初始化ESP8266并连接WiFi(减少重试次数)""" - print("[ESP] Initializing...") - esp_wait_boot() - esp_clear_rx() - - # 测试AT响应 - for i in range(max_retries): - resp = esp_send_cmd("AT", 2500) - if "OK" in resp: - print("[ESP] AT OK") - break - print("[ESP] AT retry {}/{}".format(i+1, max_retries)) - utime.sleep_ms(500) - else: - if not esp_probe_uart(): - print("[ESP] ESP8266 not responding!") - return False - - # 连接WiFi - for i in range(max_retries): - if esp_connect_wifi(): - return True - utime.sleep_ms(1000) - - return False - -def esp_ensure_wifi(): - """确保WiFi已连接(快速检查)""" - global wifi_connected - if not wifi_connected: - wifi_connected = esp_init_wifi(1) - return wifi_connected - return wifi_connected - -def ensure_local_photo_dir(): - """确保SD卡本地照片目录存在""" - try: - os.listdir(LOCAL_FACE_PHOTO_DIR) - return True - except: - try: - os.mkdir(LOCAL_FACE_PHOTO_DIR) - print("[PHOTO] Created local dir:", LOCAL_FACE_PHOTO_DIR) - return True - except Exception as e: - print("[PHOTO] Create local dir failed:", e) - return False - -def local_face_photo_path(face_id): - return "{}/{}.jpg".format(LOCAL_FACE_PHOTO_DIR, face_id) - -def save_local_face_photo(face_id, jpeg_data): - """先把人脸照片保存到SD卡,保证本地特征和照片一致""" - if not ensure_local_photo_dir(): - return False - path = local_face_photo_path(face_id) - try: - with open(path, "wb") as f: - f.write(jpeg_data) - print("[PHOTO] Saved local photo:", path) - return True - except Exception as e: - print("[PHOTO] Save local photo failed:", e) - return False - -def load_local_face_photo(face_id): - path = local_face_photo_path(face_id) - try: - with open(path, "rb") as f: - return f.read() - except Exception as e: - print("[PHOTO] Read local photo failed {}: {}".format(path, e)) - return None - -def remove_photo_from_queue(face_id): - global photo_upload_queue - face_id = str(face_id) - photo_upload_queue = [x for x in photo_upload_queue if x != face_id] - -def delete_local_face_photo(face_id): - face_id = str(face_id) - remove_photo_from_queue(face_id) - path = local_face_photo_path(face_id) - try: - os.remove(path) - print("[PHOTO] Deleted local photo:", path) - return True - except: - return False - -def delete_all_local_face_photos(): - if not ensure_local_photo_dir(): - return - try: - for name in os.listdir(LOCAL_FACE_PHOTO_DIR): - if name.endswith(".jpg"): - try: - os.remove("{}/{}".format(LOCAL_FACE_PHOTO_DIR, name)) - except: - pass - photo_upload_queue[:] = [] - print("[PHOTO] Deleted all local face photos") - except Exception as e: - print("[PHOTO] Delete all local photos failed:", e) - -def esp_send_data(data, timeout=1500): - """发送数据并等待SEND OK(优化超时)""" - cmd = "AT+CIPSEND={}".format(len(data)) - uart2.write(cmd + "\r\n") - utime.sleep_ms(50) - - # 等待 > 提示符(缩短超时) - start = utime.ticks_ms() - resp = "" - got_prompt = False - while utime.ticks_diff(utime.ticks_ms(), start) < 1000: - if uart2.any(): - resp += uart2.read().decode('utf-8', 'ignore') - if ">" in resp: - got_prompt = True - break - if "ERROR" in resp or "link is not" in resp: - return False - utime.sleep_ms(5) - - if not got_prompt: - return False - - # 发送数据 - uart2.write(data) - - # 等待 SEND OK(缩短超时) - start = utime.ticks_ms() - resp = "" - while utime.ticks_diff(utime.ticks_ms(), start) < timeout: - if uart2.any(): - resp += uart2.read().decode('utf-8', 'ignore') - if "SEND OK" in resp: - return True - if "SEND FAIL" in resp or "ERROR" in resp: - return False - utime.sleep_ms(10) - - return False - -def esp_http_post_photo(face_id, jpeg_data, max_retries=2): - """通过ESP8266发送HTTP POST请求上传照片""" - # 直接使用face_id作为照片ID(不加时间戳) - print("[ESP] Uploading photo for face ID:", face_id, "size:", len(jpeg_data)) - - for attempt in range(max_retries): - # 确保WiFi连接 - if not esp_ensure_wifi(): - print("[ESP] No WiFi, retry {}/{}".format(attempt+1, max_retries)) - utime.sleep_ms(2000) - continue - - # 关闭之前的连接 - esp_send_cmd("AT+CIPCLOSE", 500) - utime.sleep_ms(100) - - # 设置单连接模式 - esp_send_cmd("AT+CIPMUX=0", 500) - - # 建立TCP连接 - cmd = 'AT+CIPSTART="TCP","{}",{}'.format(SERVER_IP, SERVER_PORT) - resp = esp_send_cmd(cmd, 3000) - if "OK" not in resp and "CONNECT" not in resp: - print("[ESP] TCP failed, retry {}/{}".format(attempt+1, max_retries)) - utime.sleep_ms(1000) - continue - - print("[ESP] TCP connected, sending data...") - - # 使用face_id作为文件名 - http_header = "POST /api/v1/faces/photos/{} HTTP/1.1\r\n".format(face_id) - http_header += "Host: {}:{}\r\n".format(SERVER_IP, SERVER_PORT) - http_header += "Content-Type: image/jpeg\r\n" - http_header += "X-Api-Token: {}\r\n".format(API_TOKEN) - http_header += "Content-Length: {}\r\n".format(len(jpeg_data)) - http_header += "Connection: close\r\n\r\n" - - # 发送HTTP头 - if not esp_send_data(http_header): - print("[ESP] Send header failed") - esp_send_cmd("AT+CIPCLOSE", 500) - continue - - # 分块发送JPEG数据 - CHUNK_SIZE = 1024 - offset = 0 - send_ok = True - - while offset < len(jpeg_data): - chunk = jpeg_data[offset:offset + CHUNK_SIZE] - if not esp_send_data(chunk): - print("[ESP] Send chunk failed at", offset) - send_ok = False - break - offset += CHUNK_SIZE - print("[ESP] Sent {}/{}".format(min(offset, len(jpeg_data)), len(jpeg_data))) - - if not send_ok: - esp_send_cmd("AT+CIPCLOSE", 500) - utime.sleep_ms(500) - continue - - # 等待服务器响应 - print("[ESP] Waiting for response...") - resp = "" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < 3000: - if uart2.any(): - data = uart2.read() - if data: - resp += data.decode('utf-8', 'ignore') - if "200" in resp or "success" in resp or "HTTP/1.1" in resp: - print("[ESP] Got response!") - break - utime.sleep_ms(50) - - esp_send_cmd("AT+CIPCLOSE", 300) - - # 数据发送成功就算成功 - if send_ok: - print("[ESP] Photo uploaded successfully!") - return True - else: - print("[ESP] Response:", resp[:100] if resp else "(empty)") - print("[ESP] Retry {}/{}".format(attempt+1, max_retries)) - utime.sleep_ms(1000) - - print("[ESP] Upload failed after {} retries".format(max_retries)) - return False - -def capture_and_upload_face(face_id, img): - """拍摄人脸照片并加入上传队列(非阻塞)""" - try: - # 先复制图像,避免修改原图 - img_copy = img.copy() - - # 转换为RGB565格式(如果需要)后压缩为JPEG - try: - jpeg_img = img_copy.compress(quality=50) # 降低质量减小体积 - jpeg_data = jpeg_img.to_bytes() - except Exception as e1: - print("[PHOTO] Compress method 1 failed:", e1) - # 尝试另一种方式 - try: - jpeg_data = img_copy.to_bytes() # 直接尝试转换 - except Exception as e2: - print("[PHOTO] Compress method 2 failed:", e2) - return False - - print("[PHOTO] JPEG size:", len(jpeg_data), "bytes") - - if len(jpeg_data) < 100: - print("[PHOTO] JPEG too small, skip upload") - return False - - # 先保存本地,再加入上传队列(非阻塞)。上传失败也不会丢照片。 - if not save_local_face_photo(face_id, jpeg_data): - return False - - queue_photo_upload(face_id) - print("[PHOTO] Face {} saved locally and queued for upload".format(face_id)) - return True - - except Exception as e: - print("[PHOTO] Error:", e) - return False - -# 初始化WiFi连接(启动时尝试连接) -wifi_connected = False -print("[BOOT] Starting ESP8266 WiFi init...") -try: - wifi_connected = esp_init_wifi() - print("[BOOT] WiFi status:", "connected" if wifi_connected else "not connected") -except Exception as e: - print("[BOOT] ESP8266 init error:", e) - -# --- 人脸ID到真实学号的映射表 --- -face_id_mapping = {} # 格式: {"1": "20210001", "2": "20210002"} -MAPPING_FILE = "/sd/face_mapping.txt" # SD卡上的映射文件 -SYNC_VERSION_FILE = "/sd/sync_version.txt" # 本地已应用的服务端同步版本 -RECORD_PATH = "/sd/recordftr.txt" # 人脸特征记录文件路径 -sync_last_version = 0 - -def load_local_mapping(): - """从SD卡加载本地映射配置""" - global face_id_mapping - face_id_mapping.clear() - try: - with open(MAPPING_FILE, "r") as f: - for line in f: - line = line.strip() - if '=' in line: - parts = line.split('=') - if len(parts) == 2: - face_id_mapping[parts[0]] = parts[1] - print("[LOCAL] Loaded {} mappings from SD".format(len(face_id_mapping))) - except: - print("[LOCAL] No local mapping file") - -def save_local_mapping(): - """保存映射配置到SD卡""" - try: - with open(MAPPING_FILE, "w") as f: - for k, v in face_id_mapping.items(): - f.write("{}={}\n".format(k, v)) - print("[LOCAL] Saved {} mappings to SD".format(len(face_id_mapping))) - return True - except Exception as e: - print("[LOCAL] Save error:", e) - return False - -def load_sync_version(): - """从SD卡加载本地已同步版本""" - global sync_last_version - try: - with open(SYNC_VERSION_FILE, "r") as f: - value = f.read().strip() - sync_last_version = int(value) if value else 0 - except: - sync_last_version = 0 - print("[LOCAL] Sync version:", sync_last_version) - -def save_sync_version(version): - """保存本地已同步版本""" - global sync_last_version - sync_last_version = version - try: - with open(SYNC_VERSION_FILE, "w") as f: - f.write(str(version)) - except Exception as e: - print("[LOCAL] Save sync version error:", e) - -def update_face_record(face_id, new_student_id): - """更新SD卡中人脸记录的学号""" - global face_id_mapping, peoples - - # 更新内存中的映射表 - face_id_mapping[face_id] = new_student_id - save_local_mapping() - - # 先打印当前peoples列表中的名称,用于调试 - print("[UPDATE] 当前peoples列表:") - for i, p in enumerate(peoples): - print("[UPDATE] [{}] {}".format(i, p.name)) - - # 更新 recordftr.txt 中的名称 - try: - new_lines = [] - updated = False - with open(record_path, "r") as f: - for line in f: - parts = line.strip().split('#', 1) - if len(parts) == 2: - name = parts[0] - feature = parts[1] - - # 打印当前处理的记录 - print("[UPDATE] 检查记录: name='{}', face_id='{}'".format(name, face_id)) - - # 检查是否是要更新的ID(支持多种格式匹配) - should_update = False - - # 格式1: ID:X(原始格式)- 匹配 ID:1, ID:2 等 - if name == "ID:{}".format(face_id): - should_update = True - print("[UPDATE] 匹配格式1: ID:X") - - # 格式2: 名称中包含 :X - if ':' in name: - current_id = name.split(':')[1] - if current_id == face_id: - should_update = True - print("[UPDATE] 匹配格式2: :X") - - # 格式3: 直接匹配face_id本身 - if name == face_id: - should_update = True - print("[UPDATE] 匹配格式3: 直接匹配") - - if should_update: - # 更新为新学号 - new_name = new_student_id - new_lines.append(new_name + '#' + feature + '\n') - updated = True - print("[UPDATE] {} -> {}".format(name, new_name)) - continue - - new_lines.append(line) - - if updated: - with open(record_path, "w") as f: - for line in new_lines: - f.write(line) - # 重新加载人脸数据 - load_faces() - print("[UPDATE] Face record updated successfully") - return True - else: - print("[UPDATE] Face ID {} not found in records".format(face_id)) - except Exception as e: - print("[UPDATE] Error:", e) - return False - -def esp_http_get(url_path, timeout=8000): - """发送HTTP GET请求并返回响应内容""" - if not wifi_connected: - print("[HTTP_GET] WiFi未连接") - return None - - print("[HTTP_GET] 关闭旧连接...") - esp_send_cmd("AT+CIPCLOSE", 500) - utime.sleep_ms(300) - esp_send_cmd("AT+CIPMUX=0", 500) - utime.sleep_ms(100) - - # 建立TCP连接 - print("[HTTP_GET] 建立TCP连接: {}:{}".format(SERVER_IP, SERVER_PORT)) - cmd = 'AT+CIPSTART="TCP","{}",{}'.format(SERVER_IP, SERVER_PORT) - resp = esp_send_cmd(cmd, 5000) - print("[HTTP_GET] TCP响应: {}".format(resp.replace('\r\n', ' ')[:80] if resp else "(空)")) - - if "OK" not in resp and "CONNECT" not in resp: - print("[HTTP_GET] TCP连接失败") - return None - - print("[HTTP_GET] TCP连接成功") - utime.sleep_ms(200) - - # 构建HTTP GET请求 - http_request = "GET {} HTTP/1.1\r\nHost: {}:{}\r\nX-Api-Token: {}\r\nConnection: close\r\n\r\n".format( - url_path, SERVER_IP, SERVER_PORT, API_TOKEN) - - # 使用AT+CIPSEND发送 - print("[HTTP_GET] 准备发送 {} bytes".format(len(http_request))) - cmd = "AT+CIPSEND={}".format(len(http_request)) - uart2.write(cmd + "\r\n") - utime.sleep_ms(100) - - # 等待 > 提示符 - start = utime.ticks_ms() - prompt_resp = "" - while utime.ticks_diff(utime.ticks_ms(), start) < 2000: - if uart2.any(): - data = uart2.read() - if data: - prompt_resp += data.decode('utf-8', 'ignore') - if ">" in prompt_resp: - break - utime.sleep_ms(10) - - if ">" not in prompt_resp: - print("[HTTP_GET] 未收到>提示符: {}".format(prompt_resp[:50])) - esp_send_cmd("AT+CIPCLOSE", 300) - return None - - print("[HTTP_GET] 收到>提示符,发送请求...") - - # 发送HTTP请求 - uart2.write(http_request) - - # 等待SEND OK - start = utime.ticks_ms() - send_resp = "" - while utime.ticks_diff(utime.ticks_ms(), start) < 3000: - if uart2.any(): - data = uart2.read() - if data: - send_resp += data.decode('utf-8', 'ignore') - if "SEND OK" in send_resp: - print("[HTTP_GET] SEND OK") - break - utime.sleep_ms(10) - - if "SEND OK" not in send_resp: - print("[HTTP_GET] 发送失败: {}".format(send_resp[:50])) - esp_send_cmd("AT+CIPCLOSE", 300) - return None - - # 等待服务器响应(关键:要等足够长的时间) - print("[HTTP_GET] 等待服务器响应...") - resp = send_resp # 继续累积数据 - start = utime.ticks_ms() - - while utime.ticks_diff(utime.ticks_ms(), start) < timeout: - if uart2.any(): - data = uart2.read() - if data: - try: - resp += data.decode('utf-8', 'ignore') - print("[HTTP_GET] 累积数据: {} bytes".format(len(resp))) - except: - pass - # 检查是否收到完整响应(有JSON且连接关闭) - if "}" in resp and "{" in resp: - if "CLOSED" in resp: - print("[HTTP_GET] 连接已关闭,数据完整") - break - # 继续等一下看有没有更多数据 - utime.sleep_ms(500) - if uart2.any(): - extra = uart2.read() - if extra: - resp += extra.decode('utf-8', 'ignore') - if "CLOSED" in resp or utime.ticks_diff(utime.ticks_ms(), start) > 3000: - break - utime.sleep_ms(30) - - esp_send_cmd("AT+CIPCLOSE", 300) - - print("[HTTP_GET] 原始响应长度: {}".format(len(resp))) - if len(resp) > 0: - print("[HTTP_GET] 响应片段: {}".format(resp[:300].replace('\r\n', '\\n'))) - - # 提取JSON - json_start = resp.find('{"') - if json_start != -1: - json_end = resp.rfind('}') - if json_end != -1 and json_end > json_start: - result = resp[json_start:json_end+1] - print("[HTTP_GET] 提取JSON成功: {}".format(result[:100])) - return result - - print("[HTTP_GET] 未找到JSON") - return None - -def sync_face_id_mapping(): - """从服务器同步人脸ID映射配置并更新本地记录""" - global face_id_mapping - - if not wifi_connected: - print("[SYNC] WiFi not connected, skip sync") - return False - - try: - print("[SYNC] ========== 开始同步映射配置 ==========") - print("[SYNC] 请求: GET /api/v1/faces/mappings") - response = esp_http_get("/api/v1/faces/mappings", 2000) - - if response is None: - print("[SYNC] 服务器无响应") - return False - - print("[SYNC] 收到响应: {}".format(response[:150] if len(response) > 150 else response)) - - # 简单解析JSON - mappings_start = response.find('"mappings":{') - if mappings_start == -1: - print("[SYNC] 响应中无mappings字段") - return False - - mappings_start += len('"mappings":{') - mappings_end = response.find('}', mappings_start) - if mappings_end == -1: - return False - - mappings_str = response[mappings_start:mappings_end] - print("[SYNC] 解析映射字符串: {}".format(mappings_str)) - - # 解析键值对 - server_mappings = {} - parts = mappings_str.split(',') - for part in parts: - part = part.strip() - if ':' in part: - kv = part.split(':') - if len(kv) == 2: - key = kv[0].strip().strip('"') - value = kv[1].strip().strip('"') - if key and value: - server_mappings[key] = value - - print("[SYNC] 服务器映射数量: {}".format(len(server_mappings))) - for k, v in server_mappings.items(): - print("[SYNC] 人脸ID: {} -> 学号: {}".format(k, v)) - - if len(server_mappings) == 0: - print("[SYNC] 服务器无映射配置") - return True - - # 对比本地映射 - print("[SYNC] 本地映射数量: {}".format(len(face_id_mapping))) - for k, v in face_id_mapping.items(): - print("[SYNC] 本地: {} -> {}".format(k, v)) - - # 更新本地人脸记录文件(recordftr.txt) - updated_count = 0 - for face_id, student_id in server_mappings.items(): - print("[SYNC] 检查是否需要更新: ID:{} -> {}".format(face_id, student_id)) - if update_face_record(face_id, student_id): - updated_count += 1 - - # 更新内存中的映射表 - face_id_mapping = server_mappings - save_local_mapping() - - print("[SYNC] ========== 同步完成,更新了 {} 条记录 ==========".format(updated_count)) - return True - - except Exception as e: - print("[SYNC] 同步出错:", e) - return False - return False - -def get_display_name(face_name): - """获取用于显示的名称(直接使用人脸记录中的名称)""" - return face_name - -# 加载本地映射配置 -load_local_mapping() -load_sync_version() - -# 启动时同步将在所有函数定义后执行(见文件末尾) - -# --- 定时同步配置 --- -last_sync_time = utime.ticks_ms() -sync_in_progress = False # 同步状态标志 -sync_step = 0 # 同步步骤(分步执行避免长时间阻塞) - -# --- 远程删除人脸功能 --- -def delete_face_by_id(face_id): - """根据人脸ID删除本地人脸数据""" - global peoples - - # 构建所有可能匹配的模式 - target_patterns = [ - "ID:{}".format(face_id), # 原始格式 ID:5 - face_id # 直接匹配 (可能是学号如 20210001) - ] - - # 如果face_id在映射表中,也添加映射后的学号作为匹配模式 - if face_id in face_id_mapping: - target_patterns.append(face_id_mapping[face_id]) - - # 反向查找:如果face_id是学号,查找对应的原始ID - for orig_id, student_id in face_id_mapping.items(): - if student_id == face_id: - target_patterns.append("ID:{}".format(orig_id)) - target_patterns.append(orig_id) - - print("[DELETE] Looking for patterns:", target_patterns) - - deleted_index = -1 - for i, p in enumerate(peoples): - for pattern in target_patterns: - if p.name == pattern: - deleted_index = i - break - if deleted_index >= 0: - break - - if deleted_index >= 0: - deleted_name = peoples[deleted_index].name - peoples.pop(deleted_index) - try: - with open(record_path, "w") as f: - for p in peoples: - f.write(p.name + '#' + str(p.feature) + "\n") - print("[DELETE] 删除人脸成功: {}".format(deleted_name)) - - # 同时删除本地照片,避免下次启动补传已删除的人脸照片 - delete_local_face_photo(face_id) - if deleted_name.startswith("ID:"): - delete_local_face_photo(deleted_name.split(":", 1)[1]) - else: - delete_local_face_photo(deleted_name) - for pattern in target_patterns: - if pattern.startswith("ID:"): - delete_local_face_photo(pattern.split(":", 1)[1]) - else: - delete_local_face_photo(pattern) - - # 同时删除映射 - if face_id in face_id_mapping: - del face_id_mapping[face_id] - save_local_mapping() - - # 重新加载 - load_faces() - return True - except Exception as e: - print("[DELETE] 重写文件失败:", e) - load_faces() - return False - else: - print("[DELETE] 未找到人脸ID: {}".format(face_id)) - return False - -def esp_http_post_json(url_path, json_data, timeout=2000): - """发送HTTP POST请求(优化版)""" - if not wifi_connected: - return None - - esp_send_cmd("AT+CIPCLOSE", 300) - utime.sleep_ms(50) - esp_send_cmd("AT+CIPMUX=0", 300) - - cmd = 'AT+CIPSTART="TCP","{}",{}'.format(SERVER_IP, SERVER_PORT) - resp = esp_send_cmd(cmd, 2000) - if "OK" not in resp and "CONNECT" not in resp: - return None - - http_request = "POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/json\r\nX-Api-Token: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}".format( - url_path, SERVER_IP, SERVER_PORT, API_TOKEN, len(json_data), json_data) - - if not esp_send_data(http_request, 1500): - esp_send_cmd("AT+CIPCLOSE", 300) - return None - - resp = "" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < timeout: - if uart2.any(): - data = uart2.read() - if data: - resp += data.decode('utf-8', 'ignore') - if "success" in resp or "}" in resp: - break - utime.sleep_ms(10) - - esp_send_cmd("AT+CIPCLOSE", 300) - return resp - -def sync_pending_delete(): - """从服务器同步待删除的人脸列表""" - if not wifi_connected: - print("[DELETE_SYNC] WiFi未连接,跳过") - return False - - try: - print("[DELETE_SYNC] ========== 开始同步删除列表 ==========") - print("[DELETE_SYNC] 请求: GET /api/v1/faces/pending-delete") - response = esp_http_get("/api/v1/faces/pending-delete", 8000) - - if response is None: - print("[DELETE_SYNC] 服务器无响应") - return False - - print("[DELETE_SYNC] 收到响应: {}".format(response)) - - # 解析JSON - start = response.find('"pending_delete":[') - if start == -1: - print("[DELETE_SYNC] 响应中无pending_delete字段") - return True - - start += len('"pending_delete":[') - end = response.find(']', start) - if end == -1: - return False - - arr_str = response[start:end] - print("[DELETE_SYNC] 待删除列表字符串: '{}'".format(arr_str)) - - if not arr_str.strip(): - print("[DELETE_SYNC] 无待删除的人脸") - return True - - # 解析数组 - ids_to_delete = [] - parts = arr_str.split(',') - for part in parts: - part = part.strip().strip('"') - if part: - ids_to_delete.append(part) - - print("[DELETE_SYNC] 待删除人脸ID列表: {}".format(ids_to_delete)) - - if not ids_to_delete: - print("[DELETE_SYNC] 解析后无待删除项") - return True - - # 显示当前本地人脸列表 - print("[DELETE_SYNC] 当前本地人脸数量: {}".format(len(peoples))) - for i, p in enumerate(peoples): - print("[DELETE_SYNC] [{}] {}".format(i, p.name)) - - # 执行删除(每次最多处理一个,避免长时间阻塞) - face_id = ids_to_delete[0] - print("[DELETE_SYNC] 正在删除: {}".format(face_id)) - - delete_result = delete_face_by_id(face_id) - print("[DELETE_SYNC] 删除结果: {}".format("成功" if delete_result else "失败")) - - if delete_result: - # 只有本地特征真正删除成功后才确认,避免服务端过早清掉待删除任务 - print("[DELETE_SYNC] 发送确认: POST /api/v1/faces/delete-ack") - confirm_result = esp_http_post_confirm(face_id) - print("[DELETE_SYNC] 确认结果: {}".format("成功" if confirm_result else "失败")) - else: - print("[DELETE_SYNC] 本地未删除成功,暂不确认,等待下次同步重试") - - # 显示删除后的人脸列表 - print("[DELETE_SYNC] 删除后本地人脸数量: {}".format(len(peoples))) - for i, p in enumerate(peoples): - print("[DELETE_SYNC] [{}] {}".format(i, p.name)) - - print("[DELETE_SYNC] ========== 删除同步完成 ==========") - return True - - except Exception as e: - print("[DELETE_SYNC] 同步出错:", e) - return False - -def esp_http_post_confirm(face_id): - """发送删除确认请求""" - if not wifi_connected: - return False - - try: - esp_send_cmd("AT+CIPCLOSE", 500) - utime.sleep_ms(200) - esp_send_cmd("AT+CIPMUX=0", 500) - - # 建立TCP连接 - cmd = 'AT+CIPSTART="TCP","{}",{}'.format(SERVER_IP, SERVER_PORT) - resp = esp_send_cmd(cmd, 3000) - if "OK" not in resp and "CONNECT" not in resp: - print("[CONFIRM] TCP连接失败") - return False - - # 构建POST请求 - json_data = '{{"face_id":"{}"}}'.format(face_id) - http_request = "POST /api/v1/faces/delete-ack HTTP/1.1\r\n" - http_request += "Host: {}:{}\r\n".format(SERVER_IP, SERVER_PORT) - http_request += "Content-Type: application/json\r\n" - http_request += "X-Api-Token: {}\r\n".format(API_TOKEN) - http_request += "Content-Length: {}\r\n".format(len(json_data)) - http_request += "Connection: close\r\n\r\n" - http_request += json_data - - # 发送请求 - cmd = "AT+CIPSEND={}".format(len(http_request)) - uart2.write(cmd + "\r\n") - utime.sleep_ms(100) - - # 等待>提示符 - start = utime.ticks_ms() - prompt = "" - while utime.ticks_diff(utime.ticks_ms(), start) < 2000: - if uart2.any(): - prompt += uart2.read().decode('utf-8', 'ignore') - if ">" in prompt: - break - utime.sleep_ms(10) - - if ">" not in prompt: - esp_send_cmd("AT+CIPCLOSE", 300) - return False - - # 发送数据 - uart2.write(http_request) - - # 等待响应 - resp = "" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < 5000: - if uart2.any(): - data = uart2.read() - if data: - resp += data.decode('utf-8', 'ignore') - if "success" in resp: - print("[CONFIRM] 服务器确认成功") - esp_send_cmd("AT+CIPCLOSE", 300) - return True - utime.sleep_ms(20) - - esp_send_cmd("AT+CIPCLOSE", 300) - print("[CONFIRM] 响应: {}".format(resp[:100] if resp else "(空)")) - return "success" in resp - - except Exception as e: - print("[CONFIRM] 错误:", e) - return False - -def json_get_int(text, key, default=0): - marker = '"{}":'.format(key) - start = text.find(marker) - if start == -1: - return default - start += len(marker) - while start < len(text) and text[start] == ' ': - start += 1 - end = start - while end < len(text) and text[end].isdigit(): - end += 1 - try: - return int(text[start:end]) - except: - return default - -def extract_json_block(text, key, open_ch, close_ch): - marker = '"{}":'.format(key) - pos = text.find(marker) - if pos == -1: - return "" - start = text.find(open_ch, pos + len(marker)) - if start == -1: - return "" - depth = 0 - for i in range(start, len(text)): - ch = text[i] - if ch == open_ch: - depth += 1 - elif ch == close_ch: - depth -= 1 - if depth == 0: - return text[start:i + 1] - return "" - -def parse_string_array_block(arr_str): - values = [] - pos = 0 - while pos < len(arr_str): - start = arr_str.find('"', pos) - if start == -1: - break - end = arr_str.find('"', start + 1) - if end == -1: - break - values.append(arr_str[start + 1:end]) - pos = end + 1 - return values - -def parse_delete_tasks(tasks_str): - tasks = [] - pos = 0 - while pos < len(tasks_str): - obj_start = tasks_str.find('{', pos) - if obj_start == -1: - break - obj_end = tasks_str.find('}', obj_start) - if obj_end == -1: - break - obj = tasks_str[obj_start:obj_end + 1] - task_id = json_get_string(obj, "task_id", "") - face_id = json_get_string(obj, "face_id", "") - if face_id: - tasks.append({"task_id": task_id, "face_id": face_id}) - pos = obj_end + 1 - return tasks - -def json_get_string(text, key, default=""): - marker = '"{}":"'.format(key) - start = text.find(marker) - if start == -1: - return default - start += len(marker) - end = text.find('"', start) - if end == -1: - return default - return text[start:end] - -def parse_photo_ids(response): - """解析服务端 /api/v1/faces/photos 返回的照片ID列表""" - ids = {} - pos = 0 - marker = '"id":"' - while pos < len(response): - start = response.find(marker, pos) - if start == -1: - break - start += len(marker) - end = response.find('"', start) - if end == -1: - break - photo_id = response[start:end] - if photo_id: - ids[photo_id] = 1 - pos = end + 1 - return ids - -def parse_mapping_object(obj_str): - mappings = {} - if len(obj_str) < 2: - return mappings - inner = obj_str[1:-1].strip() - if not inner: - return mappings - parts = inner.split(',') - for part in parts: - if ':' not in part: - continue - kv = part.split(':', 1) - key = kv[0].strip().strip('"') - value = kv[1].strip().strip('"') - if key and value: - mappings[key] = value - return mappings - -def apply_face_mappings(server_mappings): - global face_id_mapping - print("[SYNC_STATE] 服务器映射数量: {}".format(len(server_mappings))) - updated_count = 0 - for face_id, student_id in server_mappings.items(): - print("[SYNC_STATE] 映射: ID:{} -> {}".format(face_id, student_id)) - if update_face_record(face_id, student_id): - updated_count += 1 - face_id_mapping = server_mappings - save_local_mapping() - print("[SYNC_STATE] 映射应用完成,更新 {} 条".format(updated_count)) - -def sync_unified_state(): - """按服务端统一版本号同步K210所需状态""" - global sync_last_version - if not wifi_connected: - print("[SYNC_STATE] WiFi未连接,跳过") - return False - - path = "/api/v1/sync/state?client=k210&last_version={}".format(sync_last_version) - print("[SYNC_STATE] GET {}".format(path)) - response = esp_http_get(path, 8000) - if response is None: - print("[SYNC_STATE] 服务器无响应") - return False - - server_version = json_get_int(response, "version", sync_last_version) - changed = ('"changed":true' in response) - print("[SYNC_STATE] local={}, server={}, changed={}".format(sync_last_version, server_version, changed)) - - if not changed: - save_sync_version(server_version) - return True - - tasks_arr = extract_json_block(response, "delete_tasks", '[', ']') - delete_tasks = parse_delete_tasks(tasks_arr) - pending_arr = extract_json_block(response, "pending_delete", '[', ']') - pending_delete = parse_string_array_block(pending_arr) - if not delete_tasks: - delete_tasks = [{"task_id": "", "face_id": face_id} for face_id in pending_delete] - deleted_ids = [] - deleted_task_ids = [] - delete_failed = False - print("[SYNC_STATE] 待删除任务: {}".format(delete_tasks)) - - for task in delete_tasks: - face_id = task["face_id"] - if delete_face_by_id(face_id): - deleted_ids.append(face_id) - if task["task_id"]: - deleted_task_ids.append(task["task_id"]) - else: - delete_failed = True - - if deleted_ids: - deleted_json = ",".join(['"{}"'.format(x) for x in deleted_ids]) - task_json = ",".join(['"{}"'.format(x) for x in deleted_task_ids]) - ack_body = '{{"client":"k210","version":{},"deleted":[{}],"task_ids":[{}]}}'.format(server_version, deleted_json, task_json) - ack_resp = esp_http_post_json("/api/v1/sync/ack", ack_body, 3000) - print("[SYNC_STATE] ACK响应:", ack_resp[:120] if ack_resp else "(空)") - ack_compact = ack_resp.replace(" ", "").replace("\r", "").replace("\n", "") if ack_resp else "" - ack_ok = '"success":true' in ack_compact - if not ack_ok: - delete_failed = True - print("[SYNC_STATE] 删除ACK失败,暂不推进本地版本,等待下次重试") - - mappings_obj = extract_json_block(response, "face_mappings", '{', '}') - server_mappings = parse_mapping_object(mappings_obj) - apply_face_mappings(server_mappings) - - if not delete_failed: - save_sync_version(server_version) - else: - print("[SYNC_STATE] 有删除失败项,暂不推进本地版本,等待下次重试") - - return not delete_failed - -# --- K210时间管理 --- -k210_base_timestamp = 0 # 基准时间戳(Unix时间戳) -k210_base_ticks = 0 # 基准时刻的ticks - -def set_base_time(unix_timestamp): - """设置K210的基准时间""" - global k210_base_timestamp, k210_base_ticks - k210_base_timestamp = unix_timestamp - k210_base_ticks = utime.ticks_ms() - print("[TIME] Base time set:", unix_timestamp) - -def get_current_timestamp(): - """获取当前Unix时间戳""" - if k210_base_timestamp == 0: - # 如果还没设置基准时间,使用运行时间 - return utime.ticks_ms() // 1000 - else: - # 计算当前时间戳 - elapsed_seconds = (utime.ticks_ms() - k210_base_ticks) // 1000 - return k210_base_timestamp + elapsed_seconds - -# --- 2. KPU模型和硬件初始化 --- -try: - task_fd = kpu.load("/sd/FaceDetection.smodel") - task_ld = kpu.load("/sd/FaceLandmarkDetection.smodel") - task_fe = kpu.load("/sd/FeatureExtraction.smodel") -except Exception as e: - print("Error loading KPU models:", e) - while True: - utime.sleep_ms(1000) - -clock = time.clock() -fm.register(12, fm.fpioa.GPIO0) -fm.register(13, fm.fpioa.GPIO1) -fm.register(14, fm.fpioa.GPIO2) -LED_B = GPIO(GPIO.GPIO0, GPIO.OUT, value=1) -LED_G = GPIO(GPIO.GPIO1, GPIO.OUT, value=1) -LED_R = GPIO(GPIO.GPIO2, GPIO.OUT, value=1) -key_pin = 16 -fm.register(key_pin, fm.fpioa.GPIOHS0) -KEY = GPIO(GPIO.GPIOHS0, GPIO.IN) -lcd.init() -sensor.reset() -sensor.set_pixformat(sensor.RGB565) -sensor.set_framesize(sensor.QVGA) -sensor.set_hmirror(CAMERA_HMIRROR) -sensor.set_vflip(CAMERA_VFLIP) -sensor.run(1) -anchor = (1.889, 2.5245, 2.9465, 3.94056, 3.99987, 5.3658, 5.155437, 6.92275, 6.718375, 9.01025) -dst_point = [(44, 59), (84, 59), (64, 82), (47, 105), (81, 105)] -kpu.init_yolo2(task_fd, 0.5, 0.3, 5, anchor) -img_face = image.Image(size=(128, 128)) -img_face.pix_to_ai() - -# --- 3. 按键中断处理 --- -key_Flag = 0 -key_pressed = 0 # 0=无, 1=短按, 2=长按(2秒), 3=超长按(5秒删除全部) -t_key = 0 -def fun(KEY): - global key_Flag, key_pressed, t_key - utime.sleep_ms(10) - if KEY.value() == 0: - if key_Flag == 0: - key_Flag = 1 - t_key = utime.ticks_ms() - else: - if key_Flag == 1: - press_duration = utime.ticks_diff(utime.ticks_ms(), t_key) - if press_duration > 5000: - key_pressed = 3 # 超长按5秒:删除全部人脸 - elif press_duration > 2000: - key_pressed = 2 # 长按2秒:删除模式 - else: - key_pressed = 1 # 短按:添加人脸 - key_Flag = 0 -KEY.irq(fun, GPIO.IRQ_BOTH) - -def delete_all_faces(): - """删除全部人脸数据""" - global peoples, face_id_mapping - try: - # 清空人脸列表 - peoples = [] - # 清空记录文件 - with open(record_path, "w") as f: - pass # 写入空文件 - # 清空映射 - face_id_mapping = {} - save_local_mapping() - delete_all_local_face_photos() - print("[DELETE_ALL] 已删除全部人脸数据") - return True - except Exception as e: - print("[DELETE_ALL] 删除失败:", e) - load_faces() - return False - -# --- 4. 数据库和人脸管理函数 (已包含之前修复的bug) --- -class People: - def __init__(self, name="", feature=[]): self.name = name; self.feature = feature - def feature_max(self, feature): - if not self.feature: return 0 - return kpu.face_compare(self.feature, feature) -peoples = [] -record_path = "/sd/recordftr.txt" - -def parse_feature_vector(text): - """只解析浮点数组文本,避免执行SD卡中的任意代码。""" - allowed = "0123456789+-eE.,[] \t\r\n" - for ch in text: - if ch not in allowed: - raise ValueError("invalid char in feature") - - text = text.strip() - if not text.startswith("[") or not text.endswith("]"): - raise ValueError("feature must be list") - - inner = text[1:-1].strip() - if not inner: - raise ValueError("empty feature") - - values = [] - for token in inner.split(","): - token = token.strip() - if not token: - raise ValueError("empty number") - values.append(float(token)) - if len(values) > 512: - raise ValueError("feature too long") - - if len(values) < 64: - raise ValueError("feature too short") - return values - -def load_faces(): - global peoples; peoples = [] - try: - with open(record_path, "r") as f: - for line in f: - # 使用 maxsplit=1 参数,只按第一个 '#' 分割,防止特征数据中的'#'导致错误 - parts = line.strip().split('#', 1) - if len(parts) == 2 and parts[1]: - try: peoples.append(People(parts[0], parse_feature_vector(parts[1]))) - except Exception as e_eval: print("Feature parse error on line for {}: {}".format(parts[0], e_eval)) - print("成功加载 %d 条人脸数据" % len(peoples)) - except Exception as e: print("读取人脸数据库失败:", e) - -def collect_used_face_ids(): - """收集本地已经占用的K210原始face_id,避免新增时覆盖旧人脸。""" - used = {} - - for p in peoples: - name = str(p.name) - if name.startswith("ID:") and name.split(":", 1)[1].isdigit(): - used[name.split(":", 1)[1]] = 1 - elif name.isdigit() and name in face_id_mapping: - used[name] = 1 - - for face_id in face_id_mapping.keys(): - face_id = str(face_id) - if face_id.isdigit(): - used[face_id] = 1 - - if ensure_local_photo_dir(): - try: - for filename in os.listdir(LOCAL_FACE_PHOTO_DIR): - if filename.endswith(".jpg"): - face_id = filename[:-4] - if face_id.isdigit(): - used[face_id] = 1 - except Exception as e: - print("[ADD] 扫描本地照片ID失败:", e) - - return used - -def allocate_next_face_id(): - used = collect_used_face_ids() - next_id = 1 - while str(next_id) in used: - next_id += 1 - print("[ADD] 已占用face_id: {}, 新face_id: {}".format(sorted(used.keys()), next_id)) - return next_id - -def add_face(feature, face_img=None): - new_id_num = allocate_next_face_id() - new_name = "ID:{}".format(new_id_num) - peoples.append(People(new_name, feature)) - try: - if face_img is not None: - print("[PHOTO] 正在保存人脸照片并加入上传队列...") - if not capture_and_upload_face(new_id_num, face_img): - print("[PHOTO] 本地照片保存失败,取消添加人脸") - peoples.pop() - return False - - with open(record_path, "a") as f: f.write(new_name + '#' + str(feature) + "\n") - print("添加成功:", new_name) - - return True - except Exception as e: - print("写入文件失败:", e) - delete_local_face_photo(new_id_num) - peoples.pop() - return False -def delete_face_by_index(idx): - if not (0 <= idx < len(peoples)): return False - deleted_name = peoples[idx].name - peoples.pop(idx) - try: - with open(record_path, "w") as f: - for p in peoples: f.write(p.name + '#' + str(p.feature) + "\n") - if deleted_name.startswith("ID:"): - delete_local_face_photo(deleted_name.split(":", 1)[1]) - else: - delete_local_face_photo(deleted_name) - print("删除 %s 成功" % deleted_name); return True - except Exception as e: print("重写文件失败:", e); load_faces(); return False - -# --- 5. 支持多张人脸检测的函数 (保持不变) --- -def detect_and_recognize_faces(img): - codes = kpu.run_yolo2(task_fd, img) - if not codes: return [] - results = [] - for item in codes: - face_cut = img.cut(item.x(), item.y(), item.w(), item.h()) - face_cut_128 = face_cut.resize(128, 128); face_cut_128.pix_to_ai() - fmap = kpu.forward(task_ld, face_cut_128) - plist = fmap[:] - le=(item.x()+int(plist[0]*item.w()-10), item.y()+int(plist[1]*item.h())); re=(item.x()+int(plist[2]*item.w()), item.y()+int(plist[3]*item.h())); nose=(item.x()+int(plist[4]*item.w()), item.y()+int(plist[5]*item.h())); lm=(item.x()+int(plist[6]*item.w()), item.y()+int(plist[7]*item.h())); rm=(item.x()+int(plist[8]*item.w()), item.y()+int(plist[9]*item.h())) - src_point = [le, re, nose, lm, rm] - T = image.get_affine_transform(src_point, dst_point) - image.warp_affine_ai(img, img_face, T) - del(face_cut); del(face_cut_128) - fmap = kpu.forward(task_fe, img_face) - feature = kpu.face_encode(fmap[:]) - max_score = 0; match_index = -1 - for i, p in enumerate(peoples): - score = p.feature_max(feature) - if score > max_score: max_score = score; match_index = i - result_item = {'score': max_score, 'rect': item.rect(), 'feature': feature} - if max_score > 85: result_item['state'] = peoples[match_index].name - else: result_item['state'] = "stranger" - results.append(result_item) - return results - -# --- 6. 主程序启动前准备 - 添加发送逻辑所需变量 --- -load_faces() -last_send_time = 0 -SEND_INTERVAL_MS = 1000 -last_sent_data = "" # 改为记录完整数据而不是state -last_saved_id = "" # ✨ 添加:初始化上次保存的学号 - -# --- 分时网络:照片上传队列操作 --- -def queue_photo_upload(face_id): - """将本地照片ID添加到上传队列(非阻塞)""" - global photo_upload_queue - face_id = str(face_id) - if face_id in photo_upload_queue: - print("[QUEUE] 照片已在队列中: ID={}".format(face_id)) - return - photo_upload_queue.append(face_id) - print("[QUEUE] 照片已加入队列: ID={},队列长度: {}".format(face_id, len(photo_upload_queue))) - -def queue_local_photos_for_upload(): - """启动时扫描SD卡照片,补传可能遗漏到服务器的照片""" - if not ensure_local_photo_dir(): - return - - server_photo_ids = None - if wifi_connected: - print("[QUEUE] 正在获取服务端照片列表,用于判断缺失照片...") - response = esp_http_get("/api/v1/faces/photos", 8000) - if response: - server_photo_ids = parse_photo_ids(response) - print("[QUEUE] 服务端已有照片: {} 张".format(len(server_photo_ids))) - else: - print("[QUEUE] 服务端照片列表获取失败,将本地照片全部加入补传队列") - - queued = 0 - skipped = 0 - try: - for name in os.listdir(LOCAL_FACE_PHOTO_DIR): - if name.endswith(".jpg"): - face_id = name[:-4] - if face_id: - if server_photo_ids is not None: - server_has_photo = face_id in server_photo_ids - if not server_has_photo and face_id in face_id_mapping: - server_has_photo = face_id_mapping[face_id] in server_photo_ids - if not server_has_photo: - for orig_id, student_id in face_id_mapping.items(): - if student_id == face_id and orig_id in server_photo_ids: - server_has_photo = True - break - if server_has_photo: - skipped += 1 - continue - queue_photo_upload(face_id) - queued += 1 - print("[QUEUE] 本地照片补传扫描完成: queued={}, skipped={}".format(queued, skipped)) - except Exception as e: - print("[QUEUE] 扫描本地照片失败:", e) - -def do_network_task(): - """执行一个网络任务(每次只做一件事)""" - global photo_upload_queue, last_sync_time - - # 优先处理照片上传 - if len(photo_upload_queue) > 0: - face_id = photo_upload_queue.pop(0) - jpeg_data = load_local_face_photo(face_id) - if jpeg_data is None or len(jpeg_data) < 100: - print("[NET] 本地照片无效,跳过上传: ID={}".format(face_id)) - return True - - print("[NET] 上传照片: ID={}".format(face_id)) - try: - if esp_http_post_photo(face_id, jpeg_data): - print("[NET] 照片上传完成") - else: - print("[NET] 照片上传失败,保留队列等待重试") - queue_photo_upload(face_id) - except Exception as e: - print("[NET] 上传错误:", e) - queue_photo_upload(face_id) - return True - - return False - -# 启动时扫描本地照片,补传可能遗漏到服务器的照片 -queue_local_photos_for_upload() - -# 启动时执行一次同步 -print("[BOOT] 启动同步...") -if wifi_connected: - try: - sync_unified_state() - except Exception as e: - print("[BOOT] 统一同步错误:", e) - print("[BOOT] 同步完成") -else: - print("[BOOT] WiFi未连接,跳过同步") - -# QT触发同步标志 -sync_requested = False -last_sync_check_time = 0 - -def check_sync_request(): - """检查服务器是否有同步请求""" - global sync_requested - if not wifi_connected: - return False - - try: - response = esp_http_get("/api/v1/sync/request", 2000) - if response and '"sync":true' in response: - print("[SYNC] 收到服务器同步请求") - sync_requested = True - # 确认收到请求 - esp_http_post_json("/api/v1/sync/request/ack", '{"ack":true}', 1000) - return True - except Exception as e: - print("[SYNC] 检查同步请求错误:", e) - return False - -def trigger_sync(): - """执行一次完整同步(由QT端触发)""" - global sync_requested - if not wifi_connected: - print("[SYNC] WiFi未连接,无法同步") - return False - - print("[SYNC] ========== QT触发同步开始 ==========") - try: - sync_unified_state() - except Exception as e: - print("[SYNC] 统一同步错误:", e) - - print("[SYNC] ========== QT触发同步完成 ==========") - sync_requested = False - return True - -# --- 7. 主循环 (状态机) --- -while True: - clock.tick() - # ------------------ 模式0:人脸识别与智能通信 (多人脸 & 无脸不发送) ------------------ - if key_pressed == 0: - img = sensor.snapshot() - detected_faces = detect_and_recognize_faces(img) - current_state = "" - recognized_ids = [] - if not detected_faces: - LED_G.value(1); LED_R.value(1) - else: - has_stranger = False; has_known_person = False - for face in detected_faces: - rect = face['rect'] - img.draw_rectangle(rect) - if face['state'] == "stranger": - img.draw_string(rect[0], rect[1], "Stranger", color=(255, 0, 0), scale=2) - recognized_ids.append("0"); has_stranger = True - else: - # 使用映射后的学号显示 - display_name = get_display_name(face['state']) - img.draw_string(rect[0], rect[1], display_name, color=(0, 255, 0), scale=2) - recognized_ids.append(face['state']); has_known_person = True - if has_known_person: LED_G.value(0); LED_R.value(1) - elif has_stranger: LED_G.value(1); LED_R.value(0) - recognized_ids.sort(); current_state = ",".join(recognized_ids) - time_now = utime.ticks_ms() - if utime.ticks_diff(time_now, last_send_time) > SEND_INTERVAL_MS: - if detected_faces: - # 发送第一个识别到的人脸数据(可以改为发送得分最高的) - best_face = max(detected_faces, key=lambda x: x['score']) - - if best_face['state'] == "stranger": - # 陌生人,发送UNKNOWN - msg_to_send = "UNKNOWN\r\n" - - # 陌生人不保存到SD卡 - last_saved_id = "" # 重置保存记录 - else: - # 已识别用户,发送完整信息(STM32期望的格式) - student_id = best_face['state'] # 例如 "ID:2" - student_name = best_face['state'].replace(":", "_") # 将 "ID:2" 转为 "ID_2" - confidence = best_face['score'] / 100.0 - - # 构造STM32期望的格式:ID:2,NAME:ID_2,CONF:0.95 - msg_to_send = "ID:{},NAME:{},CONF:{:.2f}\r\n".format( - student_id.split(':')[1] if ':' in student_id else student_id, - student_name, - confidence - ) - - if msg_to_send != last_sent_data: - uart.write(msg_to_send) - print("[UART SEND] -> {}".format(msg_to_send.strip())) - last_sent_data = msg_to_send - last_send_time = time_now - else: - # 没有检测到人脸 - if last_sent_data != "": - print("[STATE CHANGE] Faces disappeared. No data sent.") - last_sent_data = "" - last_saved_id = "" # 重置保存记录 - last_send_time = time_now - lcd.display(img) - - # ------------------ 【核心修改】模式1:添加人脸 (添加了实时框) ------------------ - elif key_pressed == 1: - key_pressed = 0 - last_sent_data = "" # 修改变量名 - last_saved_id = "" # 重置保存记录 - last_send_time = 0 - print("进入添加人脸模式...") - while True: - img = sensor.snapshot() - img.draw_string(0, 0, "Add Face Mode", color=(0, 0, 255), scale=2) - img.draw_string(0, 24, "Click to Confirm", color=(0, 0, 255), scale=2) - detected_faces = detect_and_recognize_faces(img) - best_face = None - if detected_faces: - # 选出最清晰(得分最高)的脸进行操作 - best_face = max(detected_faces, key=lambda x: x['score']) - - if best_face: - rect = best_face['rect'] # 获取目标人脸的框 - if best_face['state'] != "stranger": - # 【新】如果是已存在的用户,画一个橙色的框 - img.draw_rectangle(rect, color=(255, 128, 0), thickness=2) - img.draw_string(rect[0], rect[1], "Exists!", color=(255, 128, 0), scale=2) - else: - # 【新】如果是可以添加的陌生人,画一个绿色的框 - img.draw_rectangle(rect, color=(0, 255, 0), thickness=2) - img.draw_string(rect[0], rect[1], "Ready", color=(0, 255, 0), scale=2) - - # 按键确认逻辑 (保持不变) - if key_pressed == 1: - key_pressed = 0 - if best_face: - if best_face['state'] != "stranger": - img.draw_string(best_face['rect'][0], best_face['rect'][1] + 24, "Already exists!", color=(255, 0, 0), scale=2); LED_R.value(0) - else: - if add_face(best_face['feature'], img): - img.draw_string(best_face['rect'][0], best_face['rect'][1] + 24, "Add OK!", color=(0, 255, 0), scale=2); LED_G.value(0) - else: - img.draw_string(best_face['rect'][0], best_face['rect'][1] + 24, "Add Failed!", color=(255, 0, 0), scale=2); LED_R.value(0) - else: - img.draw_string(10, 100, "No Face!", color=(255, 0, 0), scale=2); LED_R.value(0) - lcd.display(img); utime.sleep_ms(2000); LED_R.value(1); LED_G.value(1); break - if key_pressed == 2: - key_pressed = 0; break # 长按退出 - lcd.display(img) - print("退出添加人脸模式") - - # ------------------ 模式2:删除人脸 (保持不变) ------------------ - elif key_pressed == 2: - key_pressed = 0 - last_sent_data = "" # 修改变量名 - last_saved_id = "" # 重置保存记录 - last_send_time = 0 - print("进入删除人脸模式...") - select_index = -1 - while True: - img = image.Image(); img.clear() - img.draw_string(10, 10, "Delete Mode", color=(255, 0, 0), scale=2); img.draw_string(10, 40, "Click=Next, Long Press=Confirm", scale=1.8) - if key_pressed == 1: - key_pressed = 0; select_index += 1 - if select_index >= len(peoples): select_index = -1 - if select_index == -1: img.draw_string(50, 100, "-> Cancel", color=(0, 255, 0), scale=2) - else: img.draw_string(50, 100, "-> Del: {}".format(peoples[select_index].name), color=(255, 128, 0), scale=2) - if key_pressed == 2: - key_pressed = 0 - if select_index != -1: - if delete_face_by_index(select_index): - img.clear(); img.draw_string(50, 100, "Delete Success!", color=(0, 255, 0), scale=2); LED_G.value(0) - else: - img.clear(); img.draw_string(50, 100, "Delete Failed!", color=(255, 0, 0), scale=2); LED_R.value(0) - else: - img.clear(); img.draw_string(50, 100, "Canceled", color=(255, 255, 0), scale=2) - lcd.display(img); utime.sleep_ms(2000); LED_G.value(1); LED_R.value(1); load_faces(); break - lcd.display(img) - print("退出删除人脸模式") - - # ------------------ 模式3:删除全部人脸 (长按5秒) ------------------ - elif key_pressed == 3: - key_pressed = 0 - print("进入删除全部人脸模式...") - img = image.Image(); img.clear() - img.draw_string(10, 10, "DELETE ALL FACES?", color=(255, 0, 0), scale=2) - img.draw_string(10, 50, "Count: {}".format(len(peoples)), color=(255, 255, 0), scale=2) - img.draw_string(10, 90, "Click=Confirm", color=(255, 128, 0), scale=1.8) - img.draw_string(10, 120, "Long Press=Cancel", color=(0, 255, 0), scale=1.8) - lcd.display(img) - LED_R.value(0) # 红灯警告 - - # 等待用户确认 - confirm_timeout = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), confirm_timeout) < 10000: # 10秒超时 - if key_pressed == 1: # 短按确认删除 - key_pressed = 0 - img.clear() - img.draw_string(30, 80, "Deleting...", color=(255, 0, 0), scale=2) - lcd.display(img) - if delete_all_faces(): - img.clear() - img.draw_string(20, 80, "All Deleted!", color=(0, 255, 0), scale=2) - LED_R.value(1); LED_G.value(0) - else: - img.clear() - img.draw_string(20, 80, "Delete Failed!", color=(255, 0, 0), scale=2) - lcd.display(img) - utime.sleep_ms(2000) - break - elif key_pressed == 2 or key_pressed == 3: # 长按取消 - key_pressed = 0 - img.clear() - img.draw_string(50, 80, "Canceled", color=(255, 255, 0), scale=2) - lcd.display(img) - utime.sleep_ms(1500) - break - utime.sleep_ms(50) - - LED_R.value(1); LED_G.value(1) - print("退出删除全部人脸模式") - - # 分时网络处理:每N帧检查一次网络任务(只处理照片上传和同步请求) - frames_since_network += 1 - if frames_since_network >= NETWORK_FRAME_INTERVAL: - frames_since_network = 0 - # 优先处理照片上传队列 - if len(photo_upload_queue) > 0: - do_network_task() - # 检查是否有同步请求待执行 - elif sync_requested: - trigger_sync() - # 定期检查服务器是否有新的同步请求 - elif wifi_connected and utime.ticks_diff(utime.ticks_ms(), last_sync_check_time) > SYNC_CHECK_INTERVAL_MS: - last_sync_check_time = utime.ticks_ms() - check_sync_request() - - gc.collect() - diff --git a/k210/view_attendance.py b/k210/view_attendance.py deleted file mode 100644 index fd4f258..0000000 --- a/k210/view_attendance.py +++ /dev/null @@ -1,50 +0,0 @@ -# K210考勤数据查看工具 -# 用于读取和显示SD卡中的考勤记录 - -import os - -ATTENDANCE_FILE = "/sd/attendance_records.csv" - -def view_attendance_records(limit=20): - """查看最近的考勤记录""" - try: - if ATTENDANCE_FILE not in os.listdir("/sd"): - print("No attendance file found!") - return - - with open(ATTENDANCE_FILE, "r") as f: - lines = f.readlines() - - print("=" * 50) - print("Attendance Records (Total: {})".format(len(lines) - 1)) - print("=" * 50) - - # 显示表头 - if len(lines) > 0: - print(lines[0].strip()) - print("-" * 50) - - # 显示最近的记录 - start = max(1, len(lines) - limit) - for i in range(start, len(lines)): - print(lines[i].strip()) - - print("=" * 50) - - except Exception as e: - print("Error reading file:", e) - -def clear_attendance_records(): - """清空考勤记录(保留表头)""" - try: - with open(ATTENDANCE_FILE, "w") as f: - f.write("Timestamp,Student_ID,Name,Confidence,Status\n") - print("Attendance records cleared!") - except Exception as e: - print("Error clearing file:", e) - -# 显示最近20条记录 -view_attendance_records(20) - -# 如果需要清空记录,取消下面这行的注释 -# clear_attendance_records() diff --git a/server/regression_api_tests.ps1 b/server/regression_api_tests.ps1 deleted file mode 100644 index 3d95abc..0000000 --- a/server/regression_api_tests.ps1 +++ /dev/null @@ -1,62 +0,0 @@ -param( - [string]$BaseUrl = "http://127.0.0.1:8082", - [string]$ApiToken = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV" -) - -$ErrorActionPreference = "Stop" -$headers = @{ - "X-Api-Token" = $ApiToken - "Accept" = "application/json" -} - -function Invoke-ApiCheck { - param( - [string]$Name, - [string]$Path, - [string]$Method = "GET", - [object]$Body = $null - ) - - $uri = "$BaseUrl$Path" - $requestHeaders = @{} + $headers - $params = @{ - Uri = $uri - Method = $Method - Headers = $requestHeaders - TimeoutSec = 8 - } - - if ($null -ne $Body) { - $params.ContentType = "application/json" - $params.Body = ($Body | ConvertTo-Json -Depth 10 -Compress) - } - - try { - $response = Invoke-RestMethod @params - Write-Host "[PASS] $Name" - return $response - } catch { - Write-Host "[FAIL] $Name - $($_.Exception.Message)" - throw - } -} - -Invoke-ApiCheck "health" "/api/v1/system/health" | Out-Null -Invoke-ApiCheck "storage" "/api/v1/system/storage" | Out-Null -Invoke-ApiCheck "time" "/api/v1/system/time" | Out-Null -Invoke-ApiCheck "sync version" "/api/v1/sync/version" | Out-Null -Invoke-ApiCheck "sync state k210" "/api/v1/sync/state?client=k210&last_version=0" | Out-Null -Invoke-ApiCheck "sync state stm32" "/api/v1/sync/state?client=stm32&last_version=0" | Out-Null -Invoke-ApiCheck "records" "/api/v1/attendance/records?limit=10&offset=0" | Out-Null -Invoke-ApiCheck "statistics" "/api/v1/attendance/statistics" | Out-Null -Invoke-ApiCheck "time slots" "/api/v1/config/time-slots" | Out-Null -Invoke-ApiCheck "duplicate check" "/api/v1/attendance/duplicates?student_id=1&slot_start=00:00&slot_end=23:59" | Out-Null -Invoke-ApiCheck "today records" "/api/v1/attendance/today?slot_start=00:00&slot_end=23:59" | Out-Null -Invoke-ApiCheck "mapping config" "/api/v1/config/mappings" | Out-Null -Invoke-ApiCheck "face photos" "/api/v1/faces/photos" | Out-Null -Invoke-ApiCheck "face mappings" "/api/v1/faces/mappings" | Out-Null -Invoke-ApiCheck "student ids" "/api/v1/faces/student-ids" | Out-Null -Invoke-ApiCheck "pending deletes" "/api/v1/faces/pending-delete" | Out-Null -Invoke-ApiCheck "sync request" "/api/v1/sync/request" | Out-Null - -Write-Host "All regression API checks passed." diff --git a/server/server_encrypted.cpp b/server/server_encrypted.cpp deleted file mode 100644 index 36b9df5..0000000 --- a/server/server_encrypted.cpp +++ /dev/null @@ -1,2861 +0,0 @@ -/* - * 增强版考勤服务器 - 支持AES加密数据 - * - * 功能: - * 1. HTTP端口8082 - 接收STM32加密签到数据 - * 2. HTTP API - 提供Qt客户端接口 - * 3. AES-128-CBC解密支持 - * - * 编译:g++ -o server_encrypted.exe server_encrypted.cpp -lws2_32 -lcrypto -lssl -lsqlite3 -std=c++11 - */ - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef _WIN32 - #include - #include - #include - #pragma comment(lib, "ws2_32.lib") - typedef int socklen_t; -#else - #include - #include - #include - #include - #include - #include - #include - #define SOCKET int - #define INVALID_SOCKET -1 - #define SOCKET_ERROR -1 - #define closesocket(s) close(s) - #define _mkdir(d) mkdir(d, 0755) -#endif - -// OpenSSL库用于AES解密和Base64 -#include -#include -#include -#include -#include - -const int HTTP_PORT = 8082; -const int BUFFER_SIZE = 4096; -const std::string SQLITE_DB_FILE = "attendance.db"; -const std::string LOG_FILE = "attendance.log"; -const std::string DB_FILE = "attendance_database.csv"; -const std::string TIMESLOTS_FILE = "timeslots_config.json"; // 时间段配置文件 -const std::string MAPPING_CONFIG_FILE = "mapping_config.json"; // 映射配置文件 -const std::string FACE_ID_MAPPING_FILE = "face_id_mapping.json"; // 人脸ID映射文件 -const std::string FACES_DIR = "faces"; // 人脸照片目录 -const std::string PENDING_DELETE_FILE = "pending_delete.json"; // 待删除人脸列表 -const std::string SYNC_REQUEST_FILE = "sync_request.json"; // K210同步请求标志文件 -const std::string SYNC_VERSION_FILE = "sync_version.txt"; // 全局同步版本号 -const std::string DB_BACKUP_DIR = "backups"; -const size_t MAX_JSON_BODY_SIZE = 64 * 1024; -const size_t MAX_PHOTO_BODY_SIZE = 512 * 1024; -const char* DEFAULT_API_TOKEN = "atk_nKfVWLhMjL0RGSfRll3R_sdJS6IrIjuV"; -const char* DEFAULT_CORS_ORIGIN = "http://localhost"; -const char* DEFAULT_TIMESLOTS_JSON = - "{\"time_slots\":[]}"; -const char* LEGACY_DEFAULT_TIMESLOTS_JSON = - "{\"time_slots\":[" - "{\"name\":\"上午签到\",\"start_time\":\"08:00\",\"end_time\":\"09:30\",\"enabled\":true}," - "{\"name\":\"中午签退\",\"start_time\":\"12:00\",\"end_time\":\"13:00\",\"enabled\":true}," - "{\"name\":\"下午签到\",\"start_time\":\"14:00\",\"end_time\":\"15:00\",\"enabled\":true}," - "{\"name\":\"晚上签退\",\"start_time\":\"17:00\",\"end_time\":\"18:30\",\"enabled\":true}" - "]}"; - -std::string getEnvOrDefault(const char* name, const char* fallback) { - const char* value = std::getenv(name); - if (value && value[0] != '\0') { - return std::string(value); - } - return std::string(fallback); -} - -const std::string& apiToken() { - static const std::string token = getEnvOrDefault("ATTENDANCE_API_TOKEN", DEFAULT_API_TOKEN); - return token; -} - -const std::string& allowedCorsOrigin() { - static const std::string origin = getEnvOrDefault("ATTENDANCE_CORS_ORIGIN", DEFAULT_CORS_ORIGIN); - return origin; -} - -const std::string& bindAddress() { - static const std::string address = getEnvOrDefault("ATTENDANCE_BIND_HOST", "0.0.0.0"); - return address; -} - -std::string readFaceIdMapping(); -std::string getPendingDeleteList(); -std::string getFacePhotosList(); -std::string getAllStudentIds(); -bool setSyncRequest(bool sync); -bool removeFromPendingDelete(const std::string& face_id); -bool updateDeviceSyncState(const std::string& client, unsigned long long seen_version, unsigned long long ack_version); -std::string getDeviceSyncSummary(); -bool initializeSqliteStorage(); -bool saveAttendanceRecord(const std::string& timestamp, const std::string& student_id, - const std::string& name, const std::string& confidence, - const std::string& status, const std::string& class_id); -std::string readMappingConfig(); -bool saveMappingConfig(const std::string& json_data); -std::map parseFaceIdMappings(const std::string& json_data); - -std::string jsonEscape(const std::string& value) { - std::ostringstream out; - for (unsigned char c : value) { - switch (c) { - case '\"': out << "\\\""; break; - case '\\': out << "\\\\"; break; - case '\b': out << "\\b"; break; - case '\f': out << "\\f"; break; - case '\n': out << "\\n"; break; - case '\r': out << "\\r"; break; - case '\t': out << "\\t"; break; - default: - if (c < 0x20) { - const char* hex = "0123456789abcdef"; - out << "\\u00" << hex[(c >> 4) & 0x0f] << hex[c & 0x0f]; - } else { - out << static_cast(c); - } - break; - } - } - return out.str(); -} - -std::string jsonWithoutWhitespace(const std::string& value) { - std::string compact; - compact.reserve(value.size()); - for (char ch : value) { - if (!std::isspace(static_cast(ch))) { - compact.push_back(ch); - } - } - return compact; -} - -std::string csvEscape(const std::string& value) { - bool needs_quotes = false; - for (char c : value) { - if (c == ',' || c == '"' || c == '\r' || c == '\n') { - needs_quotes = true; - break; - } - } - if (!needs_quotes) { - return value; - } - - std::string escaped = "\""; - for (char c : value) { - if (c == '"') { - escaped += "\"\""; - } else { - escaped += c; - } - } - escaped += "\""; - return escaped; -} - -std::vector parseCsvLine(const std::string& line) { - std::vector fields; - std::string current; - bool in_quotes = false; - - for (size_t i = 0; i < line.size(); ++i) { - char c = line[i]; - if (in_quotes) { - if (c == '"' && i + 1 < line.size() && line[i + 1] == '"') { - current += '"'; - ++i; - } else if (c == '"') { - in_quotes = false; - } else { - current += c; - } - } else if (c == '"') { - in_quotes = true; - } else if (c == ',') { - fields.push_back(current); - current.clear(); - } else { - current += c; - } - } - - fields.push_back(current); - return fields; -} - -bool parseIntInRange(const std::string& value, int min_value, int max_value, int& output) { - if (value.empty()) { - return false; - } - - try { - size_t pos = 0; - int parsed = std::stoi(value, &pos); - if (pos != value.size() || parsed < min_value || parsed > max_value) { - return false; - } - output = parsed; - return true; - } catch (...) { - return false; - } -} - -bool parseTimeParts(const std::string& value, int& hour, int& minute) { - if (value.size() != 5 || value[2] != ':' - || !std::isdigit(static_cast(value[0])) - || !std::isdigit(static_cast(value[1])) - || !std::isdigit(static_cast(value[3])) - || !std::isdigit(static_cast(value[4]))) { - return false; - } - - int parsed_hour = (value[0] - '0') * 10 + (value[1] - '0'); - int parsed_minute = (value[3] - '0') * 10 + (value[4] - '0'); - if (parsed_hour < 0 || parsed_hour > 23 || parsed_minute < 0 || parsed_minute > 59) { - return false; - } - hour = parsed_hour; - minute = parsed_minute; - return true; -} - -bool isSafeId(const std::string& value) { - if (value.empty() || value.size() > 64) { - return false; - } - for (unsigned char c : value) { - if (!(std::isalnum(c) || c == '_' || c == '-')) { - return false; - } - } - return true; -} - -bool buildFacePhotoPath(const std::string& face_id, std::string& filepath) { - if (!isSafeId(face_id)) { - return false; - } - filepath = FACES_DIR + "/" + face_id + ".jpg"; - return true; -} - -bool getHeaderValue(const std::string& request, const std::string& header_name, std::string& output) { - size_t headers_end = request.find("\r\n\r\n"); - std::string headers = headers_end == std::string::npos ? request : request.substr(0, headers_end); - std::istringstream stream(headers); - std::string line; - - std::string expected_key = header_name; - std::transform(expected_key.begin(), expected_key.end(), expected_key.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - - while (std::getline(stream, line)) { - if (!line.empty() && line.back() == '\r') { - line.pop_back(); - } - - size_t colon = line.find(':'); - if (colon == std::string::npos) { - continue; - } - - std::string key = line.substr(0, colon); - std::string value = line.substr(colon + 1); - key.erase(std::remove_if(key.begin(), key.end(), - [](unsigned char c) { return std::isspace(c); }), key.end()); - std::transform(key.begin(), key.end(), key.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - - if (key != expected_key) { - continue; - } - - size_t first = value.find_first_not_of(" \t"); - if (first == std::string::npos) { - output.clear(); - } else { - value.erase(0, first); - size_t last = value.find_last_not_of(" \t"); - if (last != std::string::npos) { - value.erase(last + 1); - } - output = value; - } - return true; - } - - return false; -} - -bool hasHeaderToken(const std::string& request, const std::string& header_name, const std::string& expected) { - size_t headers_end = request.find("\r\n\r\n"); - std::string headers = headers_end == std::string::npos ? request : request.substr(0, headers_end); - std::istringstream stream(headers); - std::string line; - - while (std::getline(stream, line)) { - if (!line.empty() && line.back() == '\r') { - line.pop_back(); - } - size_t colon = line.find(':'); - if (colon == std::string::npos) { - continue; - } - std::string key = line.substr(0, colon); - std::string value = line.substr(colon + 1); - key.erase(std::remove_if(key.begin(), key.end(), - [](unsigned char c) { return std::isspace(c); }), key.end()); - size_t first = value.find_first_not_of(" \t"); - if (first == std::string::npos) { - value.clear(); - } else { - value.erase(0, first); - size_t last = value.find_last_not_of(" \t"); - if (last != std::string::npos) { - value.erase(last + 1); - } - } - - std::transform(key.begin(), key.end(), key.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - std::string expected_key = header_name; - std::transform(expected_key.begin(), expected_key.end(), expected_key.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - - if (key == expected_key && value == expected) { - return true; - } - } - return false; -} - -bool isPublicEndpoint(const std::string& method, const std::string& endpoint) { - return method == "OPTIONS" || (method == "GET" && endpoint == "/api/health"); -} - -std::string normalizeApiEndpoint(const std::string& method, const std::string& endpoint) { - if (endpoint.find("/api/v1/") != 0) { - return endpoint; - } - - if (endpoint == "/api/v1/system/health") return "/api/health"; - if (endpoint == "/api/v1/system/time") return "/api/time"; - - if (method == "POST" && endpoint == "/api/v1/attendance/checkins") return "/api/checkin"; - if (method == "GET" && endpoint == "/api/v1/attendance/records") return "/api/records"; - if (method == "DELETE" && endpoint == "/api/v1/attendance/records") return "/api/record"; - if (method == "POST" && endpoint == "/api/v1/attendance/records/clear") return "/api/clear"; - if (method == "GET" && endpoint == "/api/v1/attendance/statistics") return "/api/statistics"; - if (method == "GET" && endpoint == "/api/v1/attendance/duplicates") return "/api/check_duplicate"; - if (method == "GET" && endpoint == "/api/v1/attendance/today") return "/api/today_records"; - - if (endpoint == "/api/v1/config/time-slots") return "/api/timeslots"; - if (endpoint == "/api/v1/config/mappings") return "/api/mapping_config"; - - if (endpoint == "/api/v1/faces/mappings") return "/api/face_id_mapping"; - if (endpoint == "/api/v1/faces/photos") return "/api/face_photos"; - if (endpoint.find("/api/v1/faces/photos/") == 0) { - const std::string id = endpoint.substr(std::string("/api/v1/faces/photos/").size()); - return method == "POST" ? "/api/face_photo_simple/" + id : "/api/face_photo/" + id; - } - if (endpoint == "/api/v1/faces/student-ids") return "/api/all_student_ids"; - if (endpoint == "/api/v1/faces/pending-delete") return "/api/pending_delete"; - if (method == "POST" && endpoint == "/api/v1/faces/delete-ack") return "/api/confirm_delete"; - if (method == "DELETE" && endpoint.find("/api/v1/faces/") == 0) { - return "/api/face/" + endpoint.substr(std::string("/api/v1/faces/").size()); - } - - if (endpoint == "/api/v1/sync/request") return "/api/sync_request"; - if (endpoint == "/api/v1/sync/request/ack") return "/api/sync_ack"; - if (endpoint == "/api/v1/sync/trigger") return "/api/trigger_sync"; - - return endpoint; -} - -bool isAuthorizedRequest(const std::string& request, const std::string& method, const std::string& endpoint) { - if (isPublicEndpoint(method, endpoint)) { - return true; - } - return hasHeaderToken(request, "X-Api-Token", apiToken()); -} - -std::string makeJsonError(const std::string& message) { - return "{\"success\":false,\"message\":\"" + jsonEscape(message) + "\"}"; -} - -// ⚠️ 重要:密钥和IV必须与STM32端完全一致! -static const unsigned char MY_AES_KEY[16] = { - 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, - 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c -}; - -static const unsigned char MY_AES_IV[16] = { - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f -}; - -// ============= Base64解码 ============= -std::string base64_decode(const std::string& encoded_string) { - BIO *bio, *b64; - int decode_len = encoded_string.length(); - char* buffer = new char[decode_len]; - - bio = BIO_new_mem_buf(encoded_string.c_str(), -1); - b64 = BIO_new(BIO_f_base64()); - bio = BIO_push(b64, bio); - - BIO_set_flags(bio, BIO_FLAGS_BASE64_NO_NL); - int length = BIO_read(bio, buffer, decode_len); - BIO_free_all(bio); - - std::string result(buffer, length); - delete[] buffer; - - return result; -} - -// ============= 添加PKCS#7填充 ============= -std::string add_pkcs7_padding(const std::string& data) { - size_t padding_len = 16 - (data.length() % 16); - std::string result = data; - for (size_t i = 0; i < padding_len; i++) { - result += (char)padding_len; - } - return result; -} - -// ============= 手动移除PKCS#7填充 ============= -std::string remove_pkcs7_padding(const std::string& data) { - if (data.empty()) return ""; - - // 获取最后一个字节(填充长度) - unsigned char padding_len = (unsigned char)data[data.length() - 1]; - - // 验证填充长度 - if (padding_len < 1 || padding_len > 16 || padding_len > data.length()) { - std::cerr << "[CRYPTO] Invalid padding length: " << (int)padding_len << std::endl; - return data; // 返回原数据 - } - - // 验证所有填充字节都相同 - for (size_t i = data.length() - padding_len; i < data.length(); i++) { - if ((unsigned char)data[i] != padding_len) { - std::cerr << "[CRYPTO] Invalid padding at position " << i << std::endl; - return data; // 返回原数据 - } - } - - // 移除填充 - return data.substr(0, data.length() - padding_len); -} - -// ============= AES-CBC加密 (用于响应Qt客户端) ============= -std::string aes_encrypt_cbc(const std::string& plaintext) { - // 添加PKCS#7填充 - std::string padded = add_pkcs7_padding(plaintext); - - EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); - if (!ctx) { - std::cerr << "[CRYPTO] Failed to create context" << std::endl; - return ""; - } - - // 初始化加密 - if (EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, MY_AES_KEY, MY_AES_IV) != 1) { - std::cerr << "[CRYPTO] EncryptInit failed" << std::endl; - EVP_CIPHER_CTX_free(ctx); - return ""; - } - - // 禁用自动填充(我们已手动添加) - EVP_CIPHER_CTX_set_padding(ctx, 0); - - // 加密数据 - std::vector ciphertext(padded.length() + 16); - int len; - int ciphertext_len = 0; - - if (EVP_EncryptUpdate(ctx, ciphertext.data(), &len, - (unsigned char*)padded.c_str(), - padded.length()) != 1) { - std::cerr << "[CRYPTO] EncryptUpdate failed" << std::endl; - EVP_CIPHER_CTX_free(ctx); - return ""; - } - ciphertext_len = len; - - // 完成加密 - if (EVP_EncryptFinal_ex(ctx, ciphertext.data() + len, &len) != 1) { - std::cerr << "[CRYPTO] EncryptFinal failed" << std::endl; - EVP_CIPHER_CTX_free(ctx); - return ""; - } - ciphertext_len += len; - - EVP_CIPHER_CTX_free(ctx); - - return std::string((char*)ciphertext.data(), ciphertext_len); -} - -// ============= AES-CBC解密 (使用OpenSSL标准库) ============= -std::string aes_decrypt_cbc(const std::string& encrypted_data) { - if (encrypted_data.empty() || encrypted_data.length() % 16 != 0) { - std::cerr << "[CRYPTO] Invalid encrypted data length: " << encrypted_data.length() << std::endl; - return ""; - } - - EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); - if (!ctx) { - std::cerr << "[CRYPTO] Failed to create context" << std::endl; - return ""; - } - - // 初始化解密(使用标准AES-128-CBC) - if (EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, MY_AES_KEY, MY_AES_IV) != 1) { - std::cerr << "[CRYPTO] DecryptInit failed" << std::endl; - EVP_CIPHER_CTX_free(ctx); - return ""; - } - - // 禁用自动填充,我们手动处理PKCS#7 - EVP_CIPHER_CTX_set_padding(ctx, 0); - - // 解密数据 - std::vector plaintext(encrypted_data.length() + 16); - int len; - int plaintext_len = 0; - - if (EVP_DecryptUpdate(ctx, plaintext.data(), &len, - (unsigned char*)encrypted_data.c_str(), - encrypted_data.length()) != 1) { - std::cerr << "[CRYPTO] DecryptUpdate failed" << std::endl; - EVP_CIPHER_CTX_free(ctx); - return ""; - } - plaintext_len = len; - - // 完成解密 - if (EVP_DecryptFinal_ex(ctx, plaintext.data() + len, &len) != 1) { - std::cerr << "[CRYPTO] DecryptFinal failed" << std::endl; - EVP_CIPHER_CTX_free(ctx); - return ""; - } - plaintext_len += len; - - EVP_CIPHER_CTX_free(ctx); - - std::string result((char*)plaintext.data(), plaintext_len); - - // 手动移除PKCS#7填充 - result = remove_pkcs7_padding(result); - - return result; -} - -// ============= 十六进制输出辅助函数 ============= -void print_hex(const std::string& label, const unsigned char* data, size_t len) { - std::cout << label << " (" << len << " bytes): "; - for (size_t i = 0; i < len && i < 32; i++) { - printf("%02x ", data[i]); - } - if (len > 32) std::cout << "..."; - std::cout << std::endl; -} - -// ============= Base64编码 ============= -std::string base64_encode(const std::string& input) { - BIO *bio, *b64; - BUF_MEM *bufferPtr; - - b64 = BIO_new(BIO_f_base64()); - bio = BIO_new(BIO_s_mem()); - bio = BIO_push(b64, bio); - - BIO_set_flags(bio, BIO_FLAGS_BASE64_NO_NL); - BIO_write(bio, input.c_str(), input.length()); - BIO_flush(bio); - BIO_get_mem_ptr(bio, &bufferPtr); - - std::string result(bufferPtr->data, bufferPtr->length); - BIO_free_all(bio); - - return result; -} - -// ============= 加密JSON数据(用于响应Qt客户端)============= -std::string encryptJSON(const std::string& json_data) { - std::cout << "[CRYPTO] Encrypting response..." << std::endl; - - // AES加密 - std::string encrypted = aes_encrypt_cbc(json_data); - if (encrypted.empty()) { - std::cerr << "[CRYPTO] Encryption failed!" << std::endl; - return ""; - } - - // Base64编码 - std::string encrypted_base64 = base64_encode(encrypted); - - std::cout << "[CRYPTO] ✓ Response encrypted" << std::endl; - return encrypted_base64; -} - -// ============= 解密JSON数据 ============= -std::string decryptJSON(const std::string& encrypted_base64) { - try { - std::cout << "[CRYPTO] Decrypting data..." << std::endl; - std::cout << "[CRYPTO] Base64 input: " << encrypted_base64 << std::endl; - std::cout << "[CRYPTO] Base64 length: " << encrypted_base64.length() << std::endl; - - // 1. Base64解码 - std::string encrypted_binary = base64_decode(encrypted_base64); - std::cout << "[CRYPTO] Binary length: " << encrypted_binary.length() << std::endl; - print_hex("[CRYPTO] Encrypted binary", (unsigned char*)encrypted_binary.c_str(), encrypted_binary.length()); - - // 调试:打印密钥和IV - print_hex("[CRYPTO] Using KEY", MY_AES_KEY, 16); - print_hex("[CRYPTO] Using IV", MY_AES_IV, 16); - - // 2. AES解密 - std::string decrypted = aes_decrypt_cbc(encrypted_binary); - - if (decrypted.empty()) { - std::cerr << "[CRYPTO] Decryption failed!" << std::endl; - return ""; - } - - std::cout << "[CRYPTO] ✓ Decryption successful" << std::endl; - std::cout << "[CRYPTO] Decrypted length: " << decrypted.length() << std::endl; - print_hex("[CRYPTO] Decrypted hex", (unsigned char*)decrypted.c_str(), decrypted.length()); - std::cout << "[CRYPTO] Decrypted text: " << decrypted << std::endl; - - return decrypted; - - } catch (const std::exception& e) { - std::cerr << "[CRYPTO] Exception: " << e.what() << std::endl; - return ""; - } -} - -// ============= 工具函数 ============= -std::string getCurrentTimestamp() { - time_t now = time(0); - tm ltm; -#ifdef _WIN32 - localtime_s(<m, &now); -#else - localtime_r(&now, <m); -#endif - char buffer[80]; - strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", <m); - return std::string(buffer); -} - -// 清理字符串中的控制字符(\r, \n等) -std::string cleanString(const std::string& str) { - std::string result; - result.reserve(str.length()); - for (char c : str) { - // 只保留可打印字符和空格,过滤掉\r\n\t等控制字符 - if (c >= 32 && c <= 126) { - result += c; - } - } - return result; -} - -std::string urlDecode(const std::string& str) { - std::string result; - for (size_t i = 0; i < str.length(); ++i) { - if (str[i] == '%' && i + 2 < str.length()) { - int value; - std::istringstream is(str.substr(i + 1, 2)); - if (is >> std::hex >> value) { - result += static_cast(value); - i += 2; - } else { - result += str[i]; - } - } else if (str[i] == '+') { - result += ' '; - } else { - result += str[i]; - } - } - return result; -} - -std::map parseQueryParams(const std::string& query) { - std::map params; - std::istringstream stream(query); - std::string pair; - - while (std::getline(stream, pair, '&')) { - size_t pos = pair.find('='); - if (pos != std::string::npos) { - std::string key = urlDecode(pair.substr(0, pos)); - std::string value = urlDecode(pair.substr(pos + 1)); - params[key] = value; - } - } - return params; -} - -bool parseJsonStringLiteral(const std::string& json, size_t& pos, std::string& output) { - if (pos >= json.size() || json[pos] != '"') return false; - pos++; - output.clear(); - - while (pos < json.size()) { - char c = json[pos++]; - if (c == '"') { - return true; - } - if (c != '\\') { - output += c; - continue; - } - if (pos >= json.size()) return false; - char esc = json[pos++]; - switch (esc) { - case '"': output += '"'; break; - case '\\': output += '\\'; break; - case '/': output += '/'; break; - case 'b': output += '\b'; break; - case 'f': output += '\f'; break; - case 'n': output += '\n'; break; - case 'r': output += '\r'; break; - case 't': output += '\t'; break; - case 'u': - // 当前接口字段只需要保留UTF-8原文;遇到unicode转义时不执行错误拆分。 - output += "\\u"; - for (int i = 0; i < 4 && pos < json.size(); ++i) { - output += json[pos++]; - } - break; - default: - output += esc; - break; - } - } - - return false; -} - -// 解析签到接口使用的扁平JSON对象,支持字符串转义、逗号和冒号。 -std::map parseJSON(const std::string& json) { - std::map result; - size_t pos = json.find('{'); - if (pos == std::string::npos) return result; - pos++; - - while (pos < json.size()) { - while (pos < json.size() && std::isspace(static_cast(json[pos]))) pos++; - if (pos >= json.size() || json[pos] == '}') break; - - std::string key; - if (!parseJsonStringLiteral(json, pos, key)) { - result.clear(); - return result; - } - - while (pos < json.size() && std::isspace(static_cast(json[pos]))) pos++; - if (pos >= json.size() || json[pos] != ':') { - result.clear(); - return result; - } - pos++; - while (pos < json.size() && std::isspace(static_cast(json[pos]))) pos++; - - std::string value; - if (pos < json.size() && json[pos] == '"') { - if (!parseJsonStringLiteral(json, pos, value)) { - result.clear(); - return result; - } - } else { - size_t start = pos; - while (pos < json.size() && json[pos] != ',' && json[pos] != '}') pos++; - value = json.substr(start, pos - start); - size_t first = value.find_first_not_of(" \t\r\n"); - size_t last = value.find_last_not_of(" \t\r\n"); - value = first == std::string::npos ? "" : value.substr(first, last - first + 1); - } - - result[key] = value; - while (pos < json.size() && std::isspace(static_cast(json[pos]))) pos++; - if (pos < json.size() && json[pos] == ',') pos++; - } - - return result; -} - -// ============= SQLite存储层 ============= -sqlite3* openSqliteDb() { - sqlite3* db = nullptr; - if (sqlite3_open(SQLITE_DB_FILE.c_str(), &db) != SQLITE_OK) { - std::cerr << "[SQLITE] Open failed: " << (db ? sqlite3_errmsg(db) : "unknown") << std::endl; - if (db) sqlite3_close(db); - return nullptr; - } - sqlite3_busy_timeout(db, 3000); - return db; -} - -bool sqliteExec(sqlite3* db, const std::string& sql) { - char* err = nullptr; - if (sqlite3_exec(db, sql.c_str(), nullptr, nullptr, &err) != SQLITE_OK) { - std::cerr << "[SQLITE] Exec failed: " << (err ? err : sqlite3_errmsg(db)) << std::endl; - sqlite3_free(err); - return false; - } - return true; -} - -std::string sqliteColumnText(sqlite3_stmt* stmt, int col) { - const unsigned char* text = sqlite3_column_text(stmt, col); - return text ? reinterpret_cast(text) : ""; -} - -bool sqliteBindText(sqlite3_stmt* stmt, int index, const std::string& value) { - return sqlite3_bind_text(stmt, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK; -} - -bool sqliteTableHasRows(sqlite3* db, const std::string& table) { - std::string sql = "SELECT 1 FROM " + table + " LIMIT 1"; - sqlite3_stmt* stmt = nullptr; - bool has_rows = false; - if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK) { - has_rows = sqlite3_step(stmt) == SQLITE_ROW; - } - sqlite3_finalize(stmt); - return has_rows; -} - -bool sqliteTableHasColumn(sqlite3* db, const std::string& table, const std::string& column) { - sqlite3_stmt* stmt = nullptr; - bool found = false; - std::string sql = "PRAGMA table_info(" + table + ")"; - if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK) { - while (sqlite3_step(stmt) == SQLITE_ROW) { - if (sqliteColumnText(stmt, 1) == column) { - found = true; - break; - } - } - } - sqlite3_finalize(stmt); - return found; -} - -bool copyFileBinary(const std::string& src, const std::string& dst) { - std::ifstream in(src.c_str(), std::ios::binary); - if (!in.is_open()) return false; - std::ofstream out(dst.c_str(), std::ios::binary); - if (!out.is_open()) return false; - out << in.rdbuf(); - return out.good(); -} - -bool backupSqliteDatabaseOnStartup() { - std::ifstream check(SQLITE_DB_FILE.c_str(), std::ios::binary); - if (!check.is_open()) { - return true; - } - check.close(); - - _mkdir(DB_BACKUP_DIR.c_str()); - - std::string ts = getCurrentTimestamp(); - std::replace(ts.begin(), ts.end(), ':', '-'); - std::replace(ts.begin(), ts.end(), ' ', '_'); - const std::string backup_path = DB_BACKUP_DIR + "/attendance_" + ts + ".db"; - - if (!copyFileBinary(SQLITE_DB_FILE, backup_path)) { - std::cerr << "[BACKUP] Failed to create SQLite backup: " << backup_path << std::endl; - return false; - } - - std::cout << "[BACKUP] SQLite backup created: " << backup_path << std::endl; - return true; -} - -std::string readWholeFileOrDefault(const std::string& path, const std::string& fallback) { - std::ifstream file(path.c_str(), std::ios::binary); - if (!file.is_open()) return fallback; - std::stringstream buffer; - buffer << file.rdbuf(); - std::string content = buffer.str(); - return content.empty() ? fallback : content; -} - -bool sqliteSetKv(sqlite3* db, const std::string& key, const std::string& value) { - sqlite3_stmt* stmt = nullptr; - const char* sql = "REPLACE INTO kv_store(key,value) VALUES(?,?)"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) return false; - sqliteBindText(stmt, 1, key); - sqliteBindText(stmt, 2, value); - bool ok = sqlite3_step(stmt) == SQLITE_DONE; - sqlite3_finalize(stmt); - return ok; -} - -std::string sqliteGetKv(sqlite3* db, const std::string& key, const std::string& fallback) { - sqlite3_stmt* stmt = nullptr; - std::string value = fallback; - const char* sql = "SELECT value FROM kv_store WHERE key=?"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { - sqliteBindText(stmt, 1, key); - if (sqlite3_step(stmt) == SQLITE_ROW) { - value = sqliteColumnText(stmt, 0); - } - } - sqlite3_finalize(stmt); - return value; -} - -bool sqliteSetKvValue(const std::string& key, const std::string& value) { - sqlite3* db = openSqliteDb(); - if (!db) return false; - bool ok = sqliteSetKv(db, key, value); - sqlite3_close(db); - return ok; -} - -std::string sqliteGetKvValue(const std::string& key, const std::string& fallback) { - sqlite3* db = openSqliteDb(); - if (!db) return fallback; - std::string value = sqliteGetKv(db, key, fallback); - sqlite3_close(db); - return value; -} - -int sqliteCountRows(sqlite3* db, const std::string& table) { - sqlite3_stmt* stmt = nullptr; - std::string sql = "SELECT COUNT(*) FROM " + table; - int count = 0; - if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK - && sqlite3_step(stmt) == SQLITE_ROW) { - count = sqlite3_column_int(stmt, 0); - } - sqlite3_finalize(stmt); - return count; -} - -bool sqliteQuickCheck(std::string& message) { - sqlite3* db = openSqliteDb(); - if (!db) { - message = "open_failed"; - return false; - } - sqlite3_stmt* stmt = nullptr; - bool ok = false; - message = "unknown"; - if (sqlite3_prepare_v2(db, "PRAGMA quick_check", -1, &stmt, nullptr) == SQLITE_OK - && sqlite3_step(stmt) == SQLITE_ROW) { - message = sqliteColumnText(stmt, 0); - ok = message == "ok"; - } - sqlite3_finalize(stmt); - sqlite3_close(db); - return ok; -} - -int countFacePhotoFiles() { - int count = 0; -#ifdef _WIN32 - WIN32_FIND_DATAA findData; - std::string searchPath = FACES_DIR + "\\*.jpg"; - HANDLE hFind = FindFirstFileA(searchPath.c_str(), &findData); - if (hFind != INVALID_HANDLE_VALUE) { - do { - count++; - } while (FindNextFileA(hFind, &findData)); - FindClose(hFind); - } -#else - DIR* dir = opendir(FACES_DIR.c_str()); - if (dir) { - struct dirent* entry; - while ((entry = readdir(dir)) != NULL) { - std::string name = entry->d_name; - if (name.size() > 4 && name.substr(name.size() - 4) == ".jpg") { - count++; - } - } - closedir(dir); - } -#endif - return count; -} - -bool sqliteInsertRecord(sqlite3* db, const std::string& timestamp, const std::string& student_id, - const std::string& name, const std::string& confidence, - const std::string& status, const std::string& class_id) { - sqlite3_stmt* stmt = nullptr; - const char* sql = - "INSERT INTO records(timestamp,student_id,name,confidence,status,class_id) " - "VALUES(?,?,?,?,?,?)"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) != SQLITE_OK) return false; - sqliteBindText(stmt, 1, timestamp); - sqliteBindText(stmt, 2, student_id); - sqliteBindText(stmt, 3, name); - sqliteBindText(stmt, 4, confidence); - sqliteBindText(stmt, 5, status); - sqliteBindText(stmt, 6, class_id.empty() ? "0" : class_id); - bool ok = sqlite3_step(stmt) == SQLITE_DONE; - sqlite3_finalize(stmt); - return ok; -} - -bool saveAttendanceRecord(const std::string& timestamp, const std::string& student_id, - const std::string& name, const std::string& confidence, - const std::string& status, const std::string& class_id) { - sqlite3* db = openSqliteDb(); - if (!db) return false; - bool ok = sqliteInsertRecord(db, timestamp, student_id, name, confidence, status, class_id); - sqlite3_close(db); - return ok; -} - -std::vector parseJsonStringList(const std::string& json, const std::string& key) { - std::vector values; - std::string marker = "\"" + key + "\":["; - size_t start = json.find(marker); - if (start == std::string::npos) return values; - start = json.find('[', start); - size_t end = json.find(']', start); - if (start == std::string::npos || end == std::string::npos) return values; - std::string arr = json.substr(start + 1, end - start - 1); - std::istringstream ss(arr); - std::string token; - while (std::getline(ss, token, ',')) { - token.erase(std::remove(token.begin(), token.end(), '"'), token.end()); - token.erase(std::remove_if(token.begin(), token.end(), - [](unsigned char c) { return std::isspace(c); }), token.end()); - if (!token.empty()) values.push_back(token); - } - return values; -} - -bool initializeSqliteStorage() { - sqlite3* db = openSqliteDb(); - if (!db) return false; - - bool ok = sqliteExec(db, - "PRAGMA journal_mode=WAL;" - "CREATE TABLE IF NOT EXISTS records(" - "id INTEGER PRIMARY KEY AUTOINCREMENT," - "timestamp TEXT NOT NULL," - "student_id TEXT NOT NULL," - "name TEXT," - "confidence TEXT," - "status TEXT," - "class_id TEXT DEFAULT '0'" - ");" - "CREATE INDEX IF NOT EXISTS idx_records_student_time ON records(student_id,timestamp);" - "CREATE INDEX IF NOT EXISTS idx_records_timestamp ON records(timestamp);" - "CREATE TABLE IF NOT EXISTS kv_store(" - "key TEXT PRIMARY KEY," - "value TEXT NOT NULL" - ");" - "CREATE TABLE IF NOT EXISTS face_mappings(" - "face_id TEXT PRIMARY KEY," - "student_id TEXT NOT NULL" - ");" - "CREATE TABLE IF NOT EXISTS pending_delete(" - "task_id TEXT PRIMARY KEY," - "face_id TEXT NOT NULL," - "student_id TEXT," - "created_at TEXT NOT NULL," - "status TEXT NOT NULL DEFAULT 'pending'," - "ack_at TEXT" - ");" - "CREATE INDEX IF NOT EXISTS idx_pending_delete_face ON pending_delete(face_id);" - "CREATE TABLE IF NOT EXISTS device_sync_state(" - "client TEXT PRIMARY KEY," - "last_seen_version INTEGER NOT NULL DEFAULT 0," - "last_ack_version INTEGER NOT NULL DEFAULT 0," - "last_seen_at TEXT NOT NULL" - ");"); - - if (!ok) { - sqlite3_close(db); - return false; - } - - if (!sqliteTableHasColumn(db, "pending_delete", "task_id")) { - sqliteExec(db, "ALTER TABLE pending_delete ADD COLUMN task_id TEXT"); - } - if (!sqliteTableHasColumn(db, "pending_delete", "student_id")) { - sqliteExec(db, "ALTER TABLE pending_delete ADD COLUMN student_id TEXT"); - } - if (!sqliteTableHasColumn(db, "pending_delete", "created_at")) { - sqliteExec(db, "ALTER TABLE pending_delete ADD COLUMN created_at TEXT"); - } - if (!sqliteTableHasColumn(db, "pending_delete", "status")) { - sqliteExec(db, "ALTER TABLE pending_delete ADD COLUMN status TEXT DEFAULT 'pending'"); - } - if (!sqliteTableHasColumn(db, "pending_delete", "ack_at")) { - sqliteExec(db, "ALTER TABLE pending_delete ADD COLUMN ack_at TEXT"); - } - sqliteExec(db, "UPDATE pending_delete SET task_id='legacy-' || face_id WHERE task_id IS NULL OR task_id=''"); - sqliteExec(db, "UPDATE pending_delete SET created_at='" + getCurrentTimestamp() + "' WHERE created_at IS NULL OR created_at=''"); - sqliteExec(db, "UPDATE pending_delete SET status='pending' WHERE status IS NULL OR status=''"); - - if (!sqliteTableHasRows(db, "records")) { - std::ifstream csv(DB_FILE.c_str()); - if (csv.is_open()) { - std::string line; - std::getline(csv, line); - int imported = 0; - sqliteExec(db, "BEGIN"); - while (std::getline(csv, line)) { - if (line.empty()) continue; - std::vector fields = parseCsvLine(line); - std::string timestamp = fields.size() > 0 ? fields[0] : ""; - std::string student_id = fields.size() > 1 ? fields[1] : ""; - std::string name = fields.size() > 2 ? fields[2] : ""; - std::string confidence = fields.size() > 3 ? fields[3] : "0"; - std::string status = fields.size() > 4 ? fields[4] : ""; - std::string class_id = fields.size() > 5 ? fields[5] : "0"; - if (!timestamp.empty() && !student_id.empty() - && sqliteInsertRecord(db, timestamp, student_id, name, confidence, status, class_id)) { - imported++; - } - } - sqliteExec(db, "COMMIT"); - std::cout << "[SQLITE] Imported records from CSV: " << imported << std::endl; - } - } - - if (sqliteGetKv(db, "time_slots", "").empty()) { - sqliteSetKv(db, "time_slots", readWholeFileOrDefault(TIMESLOTS_FILE, DEFAULT_TIMESLOTS_JSON)); - } - if (sqliteGetKv(db, "mapping_config", "").empty()) { - sqliteSetKv(db, "mapping_config", readWholeFileOrDefault(MAPPING_CONFIG_FILE, "{\"student_name_map\":{},\"class_name_map\":{}}")); - } - if (sqliteGetKv(db, "sync_request", "").empty()) { - sqliteSetKv(db, "sync_request", readWholeFileOrDefault(SYNC_REQUEST_FILE, "{\"sync\":false}")); - } - if (sqliteGetKv(db, "sync_version", "").empty()) { - sqliteSetKv(db, "sync_version", readWholeFileOrDefault(SYNC_VERSION_FILE, "1")); - } - - if (!sqliteTableHasRows(db, "face_mappings")) { - std::map mappings = parseFaceIdMappings( - readWholeFileOrDefault(FACE_ID_MAPPING_FILE, "{\"mappings\":{}}")); - sqlite3_stmt* stmt = nullptr; - const char* sql = "REPLACE INTO face_mappings(face_id,student_id) VALUES(?,?)"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { - for (const auto& m : mappings) { - sqlite3_reset(stmt); - sqlite3_clear_bindings(stmt); - sqliteBindText(stmt, 1, m.first); - sqliteBindText(stmt, 2, m.second); - sqlite3_step(stmt); - } - } - sqlite3_finalize(stmt); - std::cout << "[SQLITE] Imported face mappings: " << mappings.size() << std::endl; - } - - if (!sqliteTableHasRows(db, "pending_delete")) { - std::vector pending = parseJsonStringList( - readWholeFileOrDefault(PENDING_DELETE_FILE, "{\"pending_delete\":[]}"), "pending_delete"); - sqlite3_stmt* stmt = nullptr; - const char* sql = "REPLACE INTO pending_delete(task_id,face_id,student_id,created_at,status) VALUES(?,?,?,?,?)"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { - for (const auto& id : pending) { - sqlite3_reset(stmt); - sqlite3_clear_bindings(stmt); - sqliteBindText(stmt, 1, "legacy-" + id); - sqliteBindText(stmt, 2, id); - sqliteBindText(stmt, 3, id); - sqliteBindText(stmt, 4, getCurrentTimestamp()); - sqliteBindText(stmt, 5, "pending"); - sqlite3_step(stmt); - } - } - sqlite3_finalize(stmt); - std::cout << "[SQLITE] Imported pending deletes: " << pending.size() << std::endl; - } - - _mkdir(FACES_DIR.c_str()); - sqlite3_close(db); - std::cout << "[SQLITE] Storage ready: " << SQLITE_DB_FILE << std::endl; - return true; -} - -// ============= 数据库操作 ============= -std::string readDatabase(int limit = -1, int offset = 0) { - sqlite3* db = openSqliteDb(); - if (!db) return "[]"; - - std::ostringstream json; - json << "["; - sqlite3_stmt* stmt = nullptr; - std::string sql = "SELECT timestamp,student_id,name,confidence,status,class_id FROM records ORDER BY id DESC"; - if (limit > 0) { - sql += " LIMIT ? OFFSET ?"; - } - - bool first = true; - if (sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK) { - if (limit > 0) { - sqlite3_bind_int(stmt, 1, limit); - sqlite3_bind_int(stmt, 2, offset); - } - while (sqlite3_step(stmt) == SQLITE_ROW) { - if (!first) json << ","; - first = false; - std::string conf = sqliteColumnText(stmt, 3); - if (conf.empty()) conf = "0"; - std::string class_id = sqliteColumnText(stmt, 5); - if (class_id.empty()) class_id = "0"; - json << "{" - << "\"timestamp\":\"" << jsonEscape(sqliteColumnText(stmt, 0)) << "\"," - << "\"student_id\":\"" << jsonEscape(sqliteColumnText(stmt, 1)) << "\"," - << "\"name\":\"" << jsonEscape(sqliteColumnText(stmt, 2)) << "\"," - << "\"confidence\":" << conf << "," - << "\"status\":\"" << jsonEscape(sqliteColumnText(stmt, 4)) << "\"," - << "\"class_id\":\"" << jsonEscape(class_id) << "\"" - << "}"; - } - } - sqlite3_finalize(stmt); - sqlite3_close(db); - - json << "]"; - return json.str(); -} - -std::string getStatistics() { - sqlite3* db = openSqliteDb(); - if (!db) return "{\"total\":0,\"today\":0}"; - int total = 0; - int today = 0; - std::string todayDate = getCurrentTimestamp().substr(0, 10); - sqlite3_stmt* stmt = nullptr; - if (sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM records", -1, &stmt, nullptr) == SQLITE_OK - && sqlite3_step(stmt) == SQLITE_ROW) { - total = sqlite3_column_int(stmt, 0); - } - sqlite3_finalize(stmt); - stmt = nullptr; - if (sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM records WHERE substr(timestamp,1,10)=?", -1, &stmt, nullptr) == SQLITE_OK) { - sqliteBindText(stmt, 1, todayDate); - if (sqlite3_step(stmt) == SQLITE_ROW) { - today = sqlite3_column_int(stmt, 0); - } - } - sqlite3_finalize(stmt); - sqlite3_close(db); - - std::ostringstream json; - json << "{" - << "\"total\":" << total << "," - << "\"today\":" << today << "," - << "\"server_time\":\"" << getCurrentTimestamp() << "\"" - << "}"; - return json.str(); -} - -bool clearDatabase() { - sqlite3* db = openSqliteDb(); - if (!db) return false; - bool ok = sqliteExec(db, "DELETE FROM records"); - sqlite3_close(db); - return ok; -} - -// ============= 时间段配置管理 ============= -bool saveTimeSlots(const std::string& json_data) { - std::cout << "[TIMESLOTS] Attempting to save " << json_data.size() << " bytes" << std::endl; - - if (json_data.empty()) { - std::cerr << "[TIMESLOTS] ERROR: json_data is empty!" << std::endl; - return false; - } - - if (!sqliteSetKvValue("time_slots", json_data)) { - std::cerr << "[TIMESLOTS] Failed to save configuration to SQLite" << std::endl; - return false; - } - std::cout << "[TIMESLOTS] ✓ Configuration saved successfully to SQLite" << std::endl; - std::cout << "[TIMESLOTS] Preview: " << json_data.substr(0, 100) << "..." << std::endl; - return true; -} - -std::string readTimeSlots() { - std::string content = sqliteGetKvValue("time_slots", DEFAULT_TIMESLOTS_JSON); - if (jsonWithoutWhitespace(content) == jsonWithoutWhitespace(LEGACY_DEFAULT_TIMESLOTS_JSON)) { - content = DEFAULT_TIMESLOTS_JSON; - sqliteSetKvValue("time_slots", content); - std::cout << "[TIMESLOTS] Legacy default configuration cleared" << std::endl; - } - std::cout << "[TIMESLOTS] Read " << content.size() << " bytes from SQLite" << std::endl; - return content; -} - -bool saveMappingConfig(const std::string& json_data) { - if (json_data.empty()) return false; - return sqliteSetKvValue("mapping_config", json_data); -} - -std::string readMappingConfig() { - return sqliteGetKvValue("mapping_config", "{\"student_name_map\":{},\"class_name_map\":{}}"); -} - -unsigned long long readSyncVersion() { - unsigned long long version = 1; - std::string value = sqliteGetKvValue("sync_version", "1"); - std::istringstream ss(value); - if (!(ss >> version)) { - version = 1; - } - if (version == 0) version = 1; - return version; -} - -unsigned long long bumpSyncVersion(const std::string& reason) { - unsigned long long version = readSyncVersion() + 1; - sqliteSetKvValue("sync_version", std::to_string(version)); - std::cout << "[SYNC] Version bumped to " << version << " (" << reason << ")" << std::endl; - return version; -} - -unsigned long long parseVersionParam(const std::map& params) { - auto it = params.find("last_version"); - if (it == params.end()) return 0; - unsigned long long value = 0; - for (char c : it->second) { - if (!std::isdigit(static_cast(c))) return 0; - value = value * 10 + static_cast(c - '0'); - } - return value; -} - -size_t findMatchingJsonEnd(const std::string& text, size_t start) { - if (start >= text.size()) return std::string::npos; - const char open = text[start]; - const char close = (open == '{') ? '}' : ']'; - int depth = 0; - bool in_string = false; - bool escape = false; - - for (size_t i = start; i < text.size(); ++i) { - char c = text[i]; - if (escape) { - escape = false; - continue; - } - if (c == '\\' && in_string) { - escape = true; - continue; - } - if (c == '"') { - in_string = !in_string; - continue; - } - if (in_string) continue; - if (c == open) depth++; - if (c == close) { - depth--; - if (depth == 0) return i; - } - } - return std::string::npos; -} - -std::string extractJsonMemberValue(const std::string& json, const std::string& key, const std::string& fallback) { - std::string marker = "\"" + key + "\":"; - size_t pos = json.find(marker); - if (pos == std::string::npos) return fallback; - pos += marker.size(); - while (pos < json.size() && std::isspace(static_cast(json[pos]))) pos++; - if (pos >= json.size()) return fallback; - - if (json[pos] == '{' || json[pos] == '[') { - size_t end = findMatchingJsonEnd(json, pos); - if (end != std::string::npos) { - return json.substr(pos, end - pos + 1); - } - } - - size_t end = json.find_first_of(",}", pos); - if (end != std::string::npos) { - return json.substr(pos, end - pos); - } - return fallback; -} - -std::string extractJsonStringValue(const std::string& json, const std::string& key, const std::string& fallback = "") { - std::string marker = "\"" + key + "\":\""; - size_t pos = json.find(marker); - if (pos == std::string::npos) return fallback; - pos += marker.size(); - size_t end = json.find("\"", pos); - if (end == std::string::npos) return fallback; - return json.substr(pos, end - pos); -} - -std::vector extractJsonStringArray(const std::string& json, const std::string& key) { - std::vector values; - std::string arr = extractJsonMemberValue(json, key, "[]"); - if (arr.size() < 2) return values; - size_t pos = 0; - while (pos < arr.size()) { - size_t start = arr.find("\"", pos); - if (start == std::string::npos) break; - size_t end = arr.find("\"", start + 1); - if (end == std::string::npos) break; - values.push_back(arr.substr(start + 1, end - start - 1)); - pos = end + 1; - } - return values; -} - -unsigned long long jsonNumberValue(const std::string& json, const std::string& key, unsigned long long fallback) { - std::string marker = "\"" + key + "\":"; - size_t pos = json.find(marker); - if (pos == std::string::npos) return fallback; - pos += marker.size(); - while (pos < json.size() && std::isspace(static_cast(json[pos]))) pos++; - unsigned long long value = 0; - bool has_digit = false; - while (pos < json.size() && std::isdigit(static_cast(json[pos]))) { - has_digit = true; - value = value * 10ULL + static_cast(json[pos] - '0'); - pos++; - } - return has_digit ? value : fallback; -} - -std::string makeSyncStateResponse(const std::string& client, unsigned long long last_version) { - const unsigned long long version = readSyncVersion(); - const bool changed = last_version < version; - updateDeviceSyncState(client.empty() ? "all" : client, version, 0); - - std::ostringstream json; - json << "{\"version\":" << version - << ",\"changed\":" << (changed ? "true" : "false") - << ",\"client\":\"" << jsonEscape(client) << "\"" - << ",\"server_time\":\"" << jsonEscape(getCurrentTimestamp()) << "\""; - - if (changed) { - json << ",\"state\":{"; - bool first = true; - - if (client == "k210" || client == "qt" || client == "all" || client.empty()) { - const std::string mappings = extractJsonMemberValue(readFaceIdMapping(), "mappings", "{}"); - const std::string pending_json = getPendingDeleteList(); - const std::string pending = extractJsonMemberValue(pending_json, "pending_delete", "[]"); - const std::string delete_tasks = extractJsonMemberValue(pending_json, "delete_tasks", "[]"); - json << "\"face_mappings\":" << mappings - << ",\"pending_delete\":" << pending - << ",\"delete_tasks\":" << delete_tasks; - first = false; - } - - if (client == "stm32" || client == "qt" || client == "all" || client.empty()) { - if (!first) json << ","; - const std::string slots = extractJsonMemberValue(readTimeSlots(), "time_slots", "[]"); - json << "\"time_slots\":" << slots; - first = false; - } - - if (client == "qt" || client == "all" || client.empty()) { - if (!first) json << ","; - std::string mapping_config = readMappingConfig(); - json << "\"mapping_config\":" << mapping_config - << ",\"face_photos\":" << extractJsonMemberValue(getFacePhotosList(), "photos", "[]") - << ",\"all_student_ids\":" << extractJsonMemberValue(getAllStudentIds(), "student_ids", "[]") - << ",\"records\":" << readDatabase(1000, 0) - << ",\"statistics\":" << getStatistics(); - } - - json << "}"; - } - - json << "}"; - return json.str(); -} - -std::string makeSyncAckResponse(const std::string& request_body) { - const std::string client = extractJsonStringValue(request_body, "client", "unknown"); - const unsigned long long ack_version = static_cast(jsonNumberValue(request_body, "version", 0)); - const std::vector deleted = extractJsonStringArray(request_body, "deleted"); - const std::vector task_ids = extractJsonStringArray(request_body, "task_ids"); - int confirmed_delete_count = 0; - - for (const std::string& task_id : task_ids) { - if (!task_id.empty() && removeFromPendingDelete(task_id)) { - confirmed_delete_count++; - } - } - - for (const std::string& id : deleted) { - if (!id.empty() && removeFromPendingDelete(id)) { - confirmed_delete_count++; - } - } - - updateDeviceSyncState(client, readSyncVersion(), ack_version); - - if (confirmed_delete_count > 0) { - bumpSyncVersion("sync_ack_delete"); - } - - std::ostringstream json; - json << "{\"success\":true," - << "\"client\":\"" << jsonEscape(client) << "\"," - << "\"version\":" << readSyncVersion() << "," - << "\"confirmed_delete_count\":" << confirmed_delete_count << "}"; - return json.str(); -} - -std::string makeSyncVersionResponse() { - std::ostringstream json; - json << "{" - << "\"version\":" << readSyncVersion() << "," - << "\"server_time\":\"" << jsonEscape(getCurrentTimestamp()) << "\"" - << "}"; - return json.str(); -} - -std::string makeStorageStatusResponse() { - std::string quick_check_message; - const bool quick_ok = sqliteQuickCheck(quick_check_message); - sqlite3* db = openSqliteDb(); - - int records = 0; - int face_mappings = 0; - int pending_deletes = 0; - int face_photos = countFacePhotoFiles(); - if (db) { - records = sqliteCountRows(db, "records"); - face_mappings = sqliteCountRows(db, "face_mappings"); - pending_deletes = sqliteCountRows(db, "pending_delete"); - sqlite3_close(db); - } - - std::ostringstream json; - json << "{" - << "\"storage\":\"sqlite\"," - << "\"database\":\"" << jsonEscape(SQLITE_DB_FILE) << "\"," - << "\"ok\":" << (quick_ok ? "true" : "false") << "," - << "\"quick_check\":\"" << jsonEscape(quick_check_message) << "\"," - << "\"records\":" << records << "," - << "\"face_mappings\":" << face_mappings << "," - << "\"face_photos\":" << face_photos << "," - << "\"pending_deletes\":" << pending_deletes << "," - << "\"sync_version\":" << readSyncVersion() << "," - << "\"device_sync_state\":" << getDeviceSyncSummary() << "," - << "\"server_time\":\"" << jsonEscape(getCurrentTimestamp()) << "\"" - << "}"; - return json.str(); -} - -std::string makeHealthResponse() { - std::string quick_check_message; - const bool quick_ok = sqliteQuickCheck(quick_check_message); - std::ostringstream json; - json << "{" - << "\"status\":\"" << (quick_ok ? "healthy" : "degraded") << "\"," - << "\"time\":\"" << jsonEscape(getCurrentTimestamp()) << "\"," - << "\"storage\":\"sqlite\"," - << "\"database\":\"" << jsonEscape(SQLITE_DB_FILE) << "\"," - << "\"database_ok\":" << (quick_ok ? "true" : "false") << "," - << "\"quick_check\":\"" << jsonEscape(quick_check_message) << "\"," - << "\"sync_version\":" << readSyncVersion() - << "}"; - return json.str(); -} - -// ============= 检查重复签到 ============= -// 参数: student_id - 学号, slot_start - 时段开始时间(HH:MM), slot_end - 时段结束时间(HH:MM) -// 返回: JSON格式 {"duplicate": true/false, "record_time": "..."} -std::string checkDuplicate(const std::string& student_id, const std::string& slot_start, const std::string& slot_end) { - std::string todayDate = getCurrentTimestamp().substr(0, 10); - int start_hour = 0, start_minute = 0, end_hour = 23, end_minute = 59; - parseTimeParts(slot_start, start_hour, start_minute); - parseTimeParts(slot_end, end_hour, end_minute); - int start_total_minutes = start_hour * 60 + start_minute; - int end_total_minutes = end_hour * 60 + end_minute; - - sqlite3* db = openSqliteDb(); - if (!db) return "{\"duplicate\":false}"; - sqlite3_stmt* stmt = nullptr; - const char* sql = "SELECT timestamp FROM records WHERE student_id=? AND substr(timestamp,1,10)=? ORDER BY id DESC"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { - sqliteBindText(stmt, 1, student_id); - sqliteBindText(stmt, 2, todayDate); - while (sqlite3_step(stmt) == SQLITE_ROW) { - std::string timestamp = sqliteColumnText(stmt, 0); - if (timestamp.length() < 16) continue; - int record_hour = 0; - int record_minute = 0; - if (!parseIntInRange(timestamp.substr(11, 2), 0, 23, record_hour) - || !parseIntInRange(timestamp.substr(14, 2), 0, 59, record_minute)) continue; - int record_total_minutes = record_hour * 60 + record_minute; - if (record_total_minutes >= start_total_minutes && record_total_minutes <= end_total_minutes) { - sqlite3_finalize(stmt); - sqlite3_close(db); - std::cout << "[CHECK] Duplicate found: " << student_id << " at " << timestamp << std::endl; - return "{\"duplicate\":true,\"record_time\":\"" + jsonEscape(timestamp) + "\"}"; - } - } - } - sqlite3_finalize(stmt); - sqlite3_close(db); - return "{\"duplicate\":false}"; -} - -// ============= 删除记录 ============= -bool deleteRecord(const std::string& timestamp, const std::string& student_id) { - sqlite3* db = openSqliteDb(); - if (!db) return false; - sqlite3_stmt* stmt = nullptr; - const char* sql = "DELETE FROM records WHERE timestamp=? AND student_id=?"; - bool ok = false; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { - sqliteBindText(stmt, 1, timestamp); - sqliteBindText(stmt, 2, student_id); - ok = sqlite3_step(stmt) == SQLITE_DONE && sqlite3_changes(db) > 0; - } - sqlite3_finalize(stmt); - sqlite3_close(db); - return ok; -} - -// ============= 人脸照片管理函数 ============= - -// 保存人脸ID映射配置 -bool saveFaceIdMapping(const std::string& json_data) { - std::cout << "[FACE_MAPPING] Saving " << json_data.size() << " bytes" << std::endl; - std::map mappings = parseFaceIdMappings(json_data); - sqlite3* db = openSqliteDb(); - if (!db) return false; - bool ok = sqliteExec(db, "BEGIN") && sqliteExec(db, "DELETE FROM face_mappings"); - sqlite3_stmt* stmt = nullptr; - const char* sql = "REPLACE INTO face_mappings(face_id,student_id) VALUES(?,?)"; - if (ok && sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { - for (const auto& m : mappings) { - sqlite3_reset(stmt); - sqlite3_clear_bindings(stmt); - sqliteBindText(stmt, 1, m.first); - sqliteBindText(stmt, 2, m.second); - if (sqlite3_step(stmt) != SQLITE_DONE) { - ok = false; - break; - } - } - } else { - ok = false; - } - sqlite3_finalize(stmt); - sqliteExec(db, ok ? "COMMIT" : "ROLLBACK"); - sqlite3_close(db); - return ok; -} - -std::map parseFaceIdMappings(const std::string& json_data) { - // 解析JSON: {"mappings":{"1":"20210001","2":"20210002"}} - std::map mappings; - size_t mappings_start = json_data.find("\"mappings\":{"); - if (mappings_start == std::string::npos) return mappings; - - mappings_start += 12; // 跳过 "mappings":{ - size_t mappings_end = json_data.find("}", mappings_start); - if (mappings_end == std::string::npos) return mappings; - - std::string mappings_str = json_data.substr(mappings_start, mappings_end - mappings_start); - - // 解析每个映射对 - size_t pos = 0; - while (pos < mappings_str.length()) { - // 找到键 - size_t key_start = mappings_str.find("\"", pos); - if (key_start == std::string::npos) break; - size_t key_end = mappings_str.find("\"", key_start + 1); - if (key_end == std::string::npos) break; - - // 找到值 - size_t val_start = mappings_str.find("\"", key_end + 1); - if (val_start == std::string::npos) break; - size_t val_end = mappings_str.find("\"", val_start + 1); - if (val_end == std::string::npos) break; - - std::string key = mappings_str.substr(key_start + 1, key_end - key_start - 1); - std::string value = mappings_str.substr(val_start + 1, val_end - val_start - 1); - - if (!key.empty() && !value.empty()) { - mappings[key] = value; - } - - pos = val_end + 1; - } - - return mappings; -} - -std::map loadFaceIdMappings() { - std::map mappings; - sqlite3* db = openSqliteDb(); - if (!db) return mappings; - sqlite3_stmt* stmt = nullptr; - if (sqlite3_prepare_v2(db, "SELECT face_id,student_id FROM face_mappings ORDER BY face_id", -1, &stmt, nullptr) == SQLITE_OK) { - while (sqlite3_step(stmt) == SQLITE_ROW) { - mappings[sqliteColumnText(stmt, 0)] = sqliteColumnText(stmt, 1); - } - } - sqlite3_finalize(stmt); - sqlite3_close(db); - return mappings; -} - -std::set resolveK210FaceIdsForDelete(const std::string& requested_id) { - std::set ids; - bool matched_mapping = false; - - const auto mappings = loadFaceIdMappings(); - for (const auto& m : mappings) { - if (m.first == requested_id || m.second == requested_id) { - ids.insert(m.first); - matched_mapping = true; - } - } - if (!matched_mapping) { - ids.insert(requested_id); - } - return ids; -} - -std::set resolveStudentIdsForDelete(const std::string& requested_id) { - std::set ids; - ids.insert(requested_id); - - const auto mappings = loadFaceIdMappings(); - for (const auto& m : mappings) { - if (m.first == requested_id || m.second == requested_id) { - ids.insert(m.second); - } - } - return ids; -} - -// 根据映射重命名照片文件 -void renameFacePhotos(const std::string& json_data) { - std::cout << "[FACE_PHOTO] Renaming photos based on mapping..." << std::endl; - const std::map mappings = parseFaceIdMappings(json_data); - - // 遍历faces目录,重命名匹配的照片 - std::vector> rename_list; - -#ifdef _WIN32 - WIN32_FIND_DATAA findData; - std::string searchPath = FACES_DIR + "\\*.jpg"; - HANDLE hFind = FindFirstFileA(searchPath.c_str(), &findData); - if (hFind != INVALID_HANDLE_VALUE) { - do { - std::string filename = findData.cFileName; - std::string base_name = filename.substr(0, filename.length() - 4); // 去掉.jpg - - // 检查是否以某个face_id开头 - for (const auto& m : mappings) { - // 匹配 "face_id.jpg" 或 "face_id_xxx.jpg" - if (base_name == m.first || - (base_name.find(m.first + "_") == 0)) { - // 直接使用学号作为新文件名(不保留时间戳) - std::string new_filename = m.second + ".jpg"; - - if (filename != new_filename) { - rename_list.push_back({filename, new_filename}); - } - break; - } - } - } while (FindNextFileA(hFind, &findData)); - FindClose(hFind); - } -#else - DIR* dir = opendir(FACES_DIR.c_str()); - if (dir) { - struct dirent* entry; - while ((entry = readdir(dir)) != NULL) { - std::string filename = entry->d_name; - if (filename.size() > 4 && filename.substr(filename.size() - 4) == ".jpg") { - std::string base_name = filename.substr(0, filename.length() - 4); - - for (const auto& m : mappings) { - if (base_name == m.first || - (base_name.find(m.first + "_") == 0)) { - std::string new_filename = m.second + ".jpg"; - - if (filename != new_filename) { - rename_list.push_back({filename, new_filename}); - } - break; - } - } - } - } - closedir(dir); - } -#endif - - // 执行重命名 - for (const auto& r : rename_list) { - std::string old_id = r.first.substr(0, r.first.size() - 4); - std::string new_id = r.second.substr(0, r.second.size() - 4); - std::string old_path; - std::string new_path; - if (!buildFacePhotoPath(old_id, old_path) || !buildFacePhotoPath(new_id, new_path)) { - std::cerr << "[FACE_PHOTO] Skip unsafe rename: " << r.first << " -> " << r.second << std::endl; - continue; - } - - std::remove(new_path.c_str()); - - if (std::rename(old_path.c_str(), new_path.c_str()) == 0) { - std::cout << "[FACE_PHOTO] Renamed: " << r.first << " -> " << r.second << std::endl; - } else { - std::cerr << "[FACE_PHOTO] Rename failed: " << r.first << std::endl; - } - } - - std::cout << "[FACE_PHOTO] Renamed " << rename_list.size() << " photos" << std::endl; -} - -// 读取人脸ID映射配置 -std::string readFaceIdMapping() { - std::map mappings = loadFaceIdMappings(); - std::ostringstream json; - json << "{\"mappings\":{"; - bool first = true; - for (const auto& m : mappings) { - if (!first) json << ","; - json << "\"" << jsonEscape(m.first) << "\":\"" << jsonEscape(m.second) << "\""; - first = false; - } - json << "}}"; - return json.str(); -} - -// 获取人脸照片列表 -std::string getFacePhotosList() { - std::ostringstream json; - json << "{\"photos\":["; - - std::vector photos; - -#ifdef _WIN32 - WIN32_FIND_DATAA findData; - std::string searchPath = FACES_DIR + "\\*.jpg"; - HANDLE hFind = FindFirstFileA(searchPath.c_str(), &findData); - if (hFind != INVALID_HANDLE_VALUE) { - do { - photos.push_back(findData.cFileName); - } while (FindNextFileA(hFind, &findData)); - FindClose(hFind); - } -#else - DIR* dir = opendir(FACES_DIR.c_str()); - if (dir) { - struct dirent* entry; - while ((entry = readdir(dir)) != NULL) { - std::string name = entry->d_name; - if (name.size() > 4 && name.substr(name.size() - 4) == ".jpg") { - photos.push_back(name); - } - } - closedir(dir); - } -#endif - - std::sort(photos.begin(), photos.end()); - - for (size_t i = 0; i < photos.size(); ++i) { - if (i > 0) json << ","; - std::string id = photos[i].substr(0, photos[i].size() - 4); - json << "{\"id\":\"" << jsonEscape(id) << "\",\"filename\":\"" << jsonEscape(photos[i]) << "\"}"; - } - - json << "]}"; - return json.str(); -} - -// 保存人脸照片 -bool saveFacePhoto(const std::string& face_id, const std::string& photo_data) { - _mkdir(FACES_DIR.c_str()); - std::string filepath; - if (!buildFacePhotoPath(face_id, filepath)) { - std::cerr << "[FACE_PHOTO] Invalid face_id: " << face_id << std::endl; - return false; - } - std::ofstream file(filepath, std::ios::binary); - if (!file.is_open()) { - std::cerr << "[FACE_PHOTO] Failed to create: " << filepath << std::endl; - return false; - } - file.write(photo_data.c_str(), photo_data.size()); - file.close(); - std::cout << "[FACE_PHOTO] Saved: " << filepath << " (" << photo_data.size() << " bytes)" << std::endl; - return true; -} - -// 读取人脸照片 -std::string readFacePhoto(const std::string& face_id) { - std::string filepath; - if (!buildFacePhotoPath(face_id, filepath)) return ""; - std::ifstream file(filepath, std::ios::binary); - if (!file.is_open()) return ""; - std::string content((std::istreambuf_iterator(file)), std::istreambuf_iterator()); - file.close(); - return content; -} - -// 删除人脸照片 -bool deleteFacePhoto(const std::string& face_id) { - std::string filepath; - if (!buildFacePhotoPath(face_id, filepath)) return false; - if (std::remove(filepath.c_str()) == 0) { - std::cout << "[FACE_PHOTO] Deleted: " << filepath << std::endl; - return true; - } - return false; -} - -// 从人脸ID映射中删除指定ID -bool removeFaceIdFromMapping(const std::string& face_id) { - sqlite3* db = openSqliteDb(); - if (!db) return false; - sqlite3_stmt* stmt = nullptr; - bool ok = false; - if (sqlite3_prepare_v2(db, "DELETE FROM face_mappings WHERE face_id=?", -1, &stmt, nullptr) == SQLITE_OK) { - sqliteBindText(stmt, 1, face_id); - ok = sqlite3_step(stmt) == SQLITE_DONE && sqlite3_changes(db) > 0; - } - sqlite3_finalize(stmt); - sqlite3_close(db); - if (ok) std::cout << "[FACE_MAPPING] Removed face_id: " << face_id << std::endl; - return ok; -} - -// 删除指定学生ID的所有考勤记录 -int deleteRecordsByStudentId(const std::string& student_id) { - sqlite3* db = openSqliteDb(); - if (!db) return 0; - sqlite3_stmt* stmt = nullptr; - int deleted_count = 0; - if (sqlite3_prepare_v2(db, "DELETE FROM records WHERE student_id=?", -1, &stmt, nullptr) == SQLITE_OK) { - sqliteBindText(stmt, 1, student_id); - if (sqlite3_step(stmt) == SQLITE_DONE) { - deleted_count = sqlite3_changes(db); - } - } - sqlite3_finalize(stmt); - sqlite3_close(db); - return deleted_count; -} - -// 获取所有学生ID(从考勤记录提取,包括没有照片的) -std::string getAllStudentIds() { - std::set student_ids; - - sqlite3* db = openSqliteDb(); - if (db) { - sqlite3_stmt* stmt = nullptr; - if (sqlite3_prepare_v2(db, "SELECT DISTINCT student_id FROM records WHERE student_id<>'' ORDER BY student_id", -1, &stmt, nullptr) == SQLITE_OK) { - while (sqlite3_step(stmt) == SQLITE_ROW) { - student_ids.insert(sqliteColumnText(stmt, 0)); - } - } - sqlite3_finalize(stmt); - sqlite3_close(db); - } - - // 构建JSON响应 - std::ostringstream json; - json << "{\"student_ids\":["; - - bool first = true; - for (const auto& id : student_ids) { - if (!first) json << ","; - json << "\"" << jsonEscape(id) << "\""; - first = false; - } - - json << "]}"; - return json.str(); -} - -bool updateDeviceSyncState(const std::string& client, unsigned long long seen_version, unsigned long long ack_version) { - if (client.empty()) return false; - sqlite3* db = openSqliteDb(); - if (!db) return false; - sqlite3_stmt* stmt = nullptr; - bool ok = false; - const char* sql = - "INSERT INTO device_sync_state(client,last_seen_version,last_ack_version,last_seen_at) " - "VALUES(?,?,?,?) " - "ON CONFLICT(client) DO UPDATE SET " - "last_seen_version=MAX(device_sync_state.last_seen_version, excluded.last_seen_version)," - "last_ack_version=MAX(device_sync_state.last_ack_version, excluded.last_ack_version)," - "last_seen_at=excluded.last_seen_at"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { - sqliteBindText(stmt, 1, client); - sqlite3_bind_int64(stmt, 2, static_cast(seen_version)); - sqlite3_bind_int64(stmt, 3, static_cast(ack_version)); - sqliteBindText(stmt, 4, getCurrentTimestamp()); - ok = sqlite3_step(stmt) == SQLITE_DONE; - } - sqlite3_finalize(stmt); - sqlite3_close(db); - return ok; -} - -std::string getDeviceSyncSummary() { - sqlite3* db = openSqliteDb(); - if (!db) return "[]"; - sqlite3_stmt* stmt = nullptr; - std::ostringstream json; - json << "["; - bool first = true; - const char* sql = "SELECT client,last_seen_version,last_ack_version,last_seen_at FROM device_sync_state ORDER BY client"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { - while (sqlite3_step(stmt) == SQLITE_ROW) { - if (!first) json << ","; - json << "{" - << "\"client\":\"" << jsonEscape(sqliteColumnText(stmt, 0)) << "\"," - << "\"last_seen_version\":" << sqlite3_column_int64(stmt, 1) << "," - << "\"last_ack_version\":" << sqlite3_column_int64(stmt, 2) << "," - << "\"last_seen_at\":\"" << jsonEscape(sqliteColumnText(stmt, 3)) << "\"" - << "}"; - first = false; - } - } - sqlite3_finalize(stmt); - sqlite3_close(db); - json << "]"; - return json.str(); -} - -// 添加人脸ID到待删除列表 -bool addToPendingDelete(const std::string& face_id, const std::string& student_id) { - sqlite3* db = openSqliteDb(); - if (!db) return false; - sqlite3_stmt* stmt = nullptr; - bool ok = false; - std::string task_id = face_id + "-" + std::to_string(static_cast(time(0))); - if (sqlite3_prepare_v2(db, - "REPLACE INTO pending_delete(task_id,face_id,student_id,created_at,status) VALUES(?,?,?,?,?)", - -1, &stmt, nullptr) == SQLITE_OK) { - sqliteBindText(stmt, 1, task_id); - sqliteBindText(stmt, 2, face_id); - sqliteBindText(stmt, 3, student_id); - sqliteBindText(stmt, 4, getCurrentTimestamp()); - sqliteBindText(stmt, 5, "pending"); - ok = sqlite3_step(stmt) == SQLITE_DONE; - } - sqlite3_finalize(stmt); - sqlite3_close(db); - std::cout << "[PENDING_DELETE] Added: " << face_id << " task=" << task_id << std::endl; - return ok; -} - -// 获取待删除列表 -std::string getPendingDeleteList() { - sqlite3* db = openSqliteDb(); - if (!db) return "{\"pending_delete\":[],\"delete_tasks\":[]}"; - sqlite3_stmt* stmt = nullptr; - std::vector> tasks; - if (sqlite3_prepare_v2(db, - "SELECT task_id,face_id,student_id,created_at,status,COALESCE(ack_at,'') FROM pending_delete WHERE status<>'acked' ORDER BY created_at,face_id", - -1, &stmt, nullptr) == SQLITE_OK) { - while (sqlite3_step(stmt) == SQLITE_ROW) { - std::map row; - row["task_id"] = sqliteColumnText(stmt, 0); - row["face_id"] = sqliteColumnText(stmt, 1); - row["student_id"] = sqliteColumnText(stmt, 2); - row["created_at"] = sqliteColumnText(stmt, 3); - row["status"] = sqliteColumnText(stmt, 4); - row["ack_at"] = sqliteColumnText(stmt, 5); - tasks.push_back(row); - } - } - sqlite3_finalize(stmt); - sqlite3_close(db); - - std::ostringstream json; - json << "{\"pending_delete\":["; - bool first = true; - for (const auto& task : tasks) { - if (!first) json << ","; - json << "\"" << jsonEscape(task.at("face_id")) << "\""; - first = false; - } - json << "],\"delete_tasks\":["; - first = true; - for (const auto& task : tasks) { - if (!first) json << ","; - json << "{" - << "\"task_id\":\"" << jsonEscape(task.at("task_id")) << "\"," - << "\"face_id\":\"" << jsonEscape(task.at("face_id")) << "\"," - << "\"student_id\":\"" << jsonEscape(task.at("student_id")) << "\"," - << "\"created_at\":\"" << jsonEscape(task.at("created_at")) << "\"," - << "\"status\":\"" << jsonEscape(task.at("status")) << "\"" - << "}"; - first = false; - } - json << "]}"; - return json.str(); -} - -// 从待删除列表中移除指定ID(K210确认删除后调用) -bool removeFromPendingDelete(const std::string& face_id_or_task_id) { - sqlite3* db = openSqliteDb(); - if (!db) return false; - sqlite3_stmt* stmt = nullptr; - bool ok = false; - if (sqlite3_prepare_v2(db, - "UPDATE pending_delete SET status='acked',ack_at=? WHERE (task_id=? OR face_id=?) AND status<>'acked'", - -1, &stmt, nullptr) == SQLITE_OK) { - sqliteBindText(stmt, 1, getCurrentTimestamp()); - sqliteBindText(stmt, 2, face_id_or_task_id); - sqliteBindText(stmt, 3, face_id_or_task_id); - ok = sqlite3_step(stmt) == SQLITE_DONE && sqlite3_changes(db) > 0; - } - sqlite3_finalize(stmt); - sqlite3_close(db); - std::cout << "[PENDING_DELETE] Acked: " << face_id_or_task_id << std::endl; - return ok; -} - -// ============= K210同步请求管理 ============= -// 设置同步请求标志(QT端调用) -bool setSyncRequest(bool sync) { - if (!sqliteSetKvValue("sync_request", std::string("{\"sync\":") + (sync ? "true" : "false") + "}")) { - return false; - } - std::cout << "[SYNC] Sync request set to: " << (sync ? "true" : "false") << std::endl; - return true; -} - -// 获取同步请求状态(K210轮询调用) -std::string getSyncRequest() { - return sqliteGetKvValue("sync_request", "{\"sync\":false}"); -} - -// 清除同步请求标志(K210确认后调用) -bool clearSyncRequest() { - return setSyncRequest(false); -} - -// 根据K210的face_id查询真实学号 -std::string getMappedStudentId(const std::string& face_id) { - std::cout << "[MAPPING] Looking up face_id: " << face_id << std::endl; - - const auto mappings = loadFaceIdMappings(); - auto it = mappings.find(face_id); - if (it != mappings.end() && !it->second.empty()) { - std::cout << "[MAPPING] ✓ Face ID " << face_id << " -> Student ID " << it->second << std::endl; - return it->second; - } - - std::cout << "[MAPPING] No mapping found for face_id: " << face_id << std::endl; - return face_id; // 没找到映射,返回原ID -} - -// ============= HTTP请求处理 ============= -void handleHttpRequest(SOCKET client_socket) { - char buffer[BUFFER_SIZE] = {0}; - int bytes = recv(client_socket, buffer, BUFFER_SIZE - 1, 0); - - if (bytes <= 0) { - closesocket(client_socket); - return; - } - - std::string request(buffer, bytes); - std::istringstream stream(request); - std::string method, path, version; - stream >> method >> path >> version; - - std::cout << "[HTTP] " << method << " " << path << std::endl; - - std::string response_body; - std::string content_type = "application/json"; - int status_code = 200; - std::string status_text = "OK"; - auto sendResponse = [&]() { - std::ostringstream response; - response << "HTTP/1.1 " << status_code << " " << status_text << "\r\n"; - response << "Content-Type: " << content_type << "; charset=utf-8\r\n"; - response << "Content-Length: " << response_body.length() << "\r\n"; - response << "Access-Control-Allow-Origin: " << allowedCorsOrigin() << "\r\n"; - response << "Access-Control-Allow-Headers: Content-Type, X-Api-Token, X-Encrypted-Response, X-Encrypted\r\n"; - response << "Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS\r\n"; - response << "Connection: close\r\n"; - response << "\r\n"; - response << response_body; - - std::string resp_str = response.str(); - send(client_socket, resp_str.c_str(), resp_str.length(), 0); - closesocket(client_socket); - }; - - std::string endpoint = path; - std::string query; - size_t q_pos = path.find('?'); - if (q_pos != std::string::npos) { - endpoint = path.substr(0, q_pos); - query = path.substr(q_pos + 1); - } - const std::string original_endpoint = endpoint; - endpoint = normalizeApiEndpoint(method, endpoint); - if (endpoint != original_endpoint) { - std::cout << "[HTTP] Route alias: " << original_endpoint << " -> " << endpoint << std::endl; - } - - if (!isAuthorizedRequest(request, method, endpoint)) { - response_body = makeJsonError("Unauthorized"); - status_code = 401; - status_text = "Unauthorized"; - sendResponse(); - return; - } - - // 提取请求体 - std::string request_body; - if (method == "POST") { - size_t content_length = 0; - std::string cl_str; - if (getHeaderValue(request, "Content-Length", cl_str)) { - int parsed_content_length = 0; - if (!parseIntInRange(cl_str, 0, static_cast(MAX_PHOTO_BODY_SIZE), parsed_content_length)) { - response_body = makeJsonError("Invalid Content-Length"); - status_code = 400; - status_text = "Bad Request"; - sendResponse(); - return; - } - content_length = static_cast(parsed_content_length); - } - - const bool is_photo_upload = endpoint.find("/api/face_photo") == 0; - const size_t max_body_size = is_photo_upload ? MAX_PHOTO_BODY_SIZE : MAX_JSON_BODY_SIZE; - if (content_length > max_body_size) { - response_body = makeJsonError("Request body too large"); - status_code = 413; - status_text = "Payload Too Large"; - sendResponse(); - return; - } - - size_t header_end = request.find("\r\n\r\n"); - if (header_end != std::string::npos) { - size_t body_start = header_end + 4; - size_t current_body_size = request.size() - body_start; - - if (current_body_size >= content_length) { - request_body = request.substr(body_start, content_length); - } else { - request_body = request.substr(body_start); - size_t remaining = content_length - current_body_size; - - while (remaining > 0) { - char extra_buffer[BUFFER_SIZE] = {0}; - int extra_bytes = recv(client_socket, extra_buffer, - static_cast(std::min(remaining, (size_t)BUFFER_SIZE)), 0); - if (extra_bytes <= 0) break; - - request_body.append(extra_buffer, extra_bytes); - remaining -= extra_bytes; - } - } - } - - std::cout << "[HTTP] POST body size: " << request_body.size() - << " bytes (expected: " << content_length << ")" << std::endl; - - if (request_body.size() != content_length) { - response_body = makeJsonError("Incomplete request body"); - status_code = 400; - status_text = "Bad Request"; - sendResponse(); - return; - } - } - - // ============= 路由处理 ============= - - if (method == "OPTIONS") { - response_body = "{}"; - - } else if (method == "GET" && endpoint == "/api/v1/sync/version") { - response_body = makeSyncVersionResponse(); - std::cout << "[SYNC] Version fetched: " << readSyncVersion() << std::endl; - - } else if (method == "GET" && (endpoint == "/api/sync/state" || endpoint == "/api/v1/sync/state")) { - auto params = parseQueryParams(query); - const std::string client = params.count("client") ? params["client"] : "all"; - const unsigned long long last_version = parseVersionParam(params); - response_body = makeSyncStateResponse(client, last_version); - std::cout << "[SYNC] State fetched: client=" << client - << ", last_version=" << last_version - << ", current_version=" << readSyncVersion() << std::endl; - - } else if (method == "POST" && (endpoint == "/api/sync/ack" || endpoint == "/api/v1/sync/ack")) { - response_body = makeSyncAckResponse(request_body); - std::cout << "[SYNC] Ack received: " << request_body << std::endl; - - // 新增:处理加密签到数据 - } else if (method == "POST" && endpoint == "/api/checkin") { - std::cout << "[CHECKIN] Processing encrypted checkin data..." << std::endl; - - // 检查是否是加密数据 - if (request.find("X-Encrypted: AES128-CBC") != std::string::npos) { - std::cout << "[CHECKIN] Encrypted data detected" << std::endl; - - // 解密数据 - std::string decrypted_json = decryptJSON(request_body); - - if (decrypted_json.empty()) { - response_body = "{\"success\":false,\"message\":\"解密失败\"}"; - status_code = 400; - status_text = "Bad Request"; - } else { - // 解析JSON - auto json_data = parseJSON(decrypted_json); - - if (json_data.find("student_id") != json_data.end()) { - // 构建考勤记录 - std::string timestamp = getCurrentTimestamp(); - std::string student_id = json_data["student_id"]; // K210已发送真实学号 - std::string name = json_data.count("name") ? json_data["name"] : ""; - std::string confidence = json_data.count("confidence") ? json_data["confidence"] : "0"; - std::string status = json_data.count("status") ? json_data["status"] : "success"; - std::string class_id = json_data.count("class_id") ? json_data["class_id"] : "0"; - char* conf_end = nullptr; - const double conf_value = std::strtod(confidence.c_str(), &conf_end); - - if (!isSafeId(student_id) || student_id.size() > 32 || name.size() > 64 || - class_id.size() > 32 || conf_end == confidence.c_str() || *conf_end != '\0' || - conf_value < 0.0 || conf_value > 1.0 || - (status != "success" && status != "failed" && status != "late")) { - response_body = makeJsonError("Invalid checkin fields"); - status_code = 400; - status_text = "Bad Request"; - sendResponse(); - return; - } - - std::cout << "[CHECKIN] Student ID: " << student_id << std::endl; - - // 保存到SQLite(包含班级编号) - if (saveAttendanceRecord(timestamp, student_id, name, confidence, status, class_id)) { - response_body = "{\"success\":true,\"message\":\"签到成功\",\"timestamp\":\"" - + jsonEscape(timestamp) + "\"}"; - bumpSyncVersion("checkin"); - std::cout << "[CHECKIN] ✓ " << name << " (ID:" << student_id << ", Class:" << class_id << ")" << std::endl; - } else { - response_body = "{\"success\":false,\"message\":\"数据库写入失败\"}"; - status_code = 500; - status_text = "Internal Server Error"; - } - } else { - response_body = "{\"success\":false,\"message\":\"缺少必要字段\"}"; - status_code = 400; - status_text = "Bad Request"; - } - } - } else { - response_body = "{\"success\":false,\"message\":\"未加密数据\"}"; - status_code = 400; - status_text = "Bad Request"; - } - } - // API端点:返回加密数据 - else if (method == "GET" && endpoint == "/api/records") { - auto params = parseQueryParams(query); - int limit = 100; - int offset = 0; - if (params.count("limit") && !parseIntInRange(params["limit"], 1, 5000, limit)) { - response_body = makeJsonError("Invalid limit"); - status_code = 400; - status_text = "Bad Request"; - } else if (params.count("offset") && !parseIntInRange(params["offset"], 0, 1000000, offset)) { - response_body = makeJsonError("Invalid offset"); - status_code = 400; - status_text = "Bad Request"; - } else { - - std::string json_data = readDatabase(limit, offset); - - if (hasHeaderToken(request, "X-Encrypted-Response", "true")) { - response_body = encryptJSON(json_data); - if (response_body.empty()) { - response_body = makeJsonError("Encryption failed"); - status_code = 500; - status_text = "Internal Server Error"; - } - } else { - response_body = json_data; - } - } - - } else if (method == "GET" && endpoint == "/api/statistics") { - std::string json_data = getStatistics(); - - if (hasHeaderToken(request, "X-Encrypted-Response", "true")) { - response_body = encryptJSON(json_data); - if (response_body.empty()) { - response_body = makeJsonError("Encryption failed"); - status_code = 500; - status_text = "Internal Server Error"; - } - } else { - response_body = json_data; - } - - } else if (method == "POST" && endpoint == "/api/clear") { - if (clearDatabase()) { - response_body = "{\"success\":true,\"message\":\"Database cleared\"}"; - bumpSyncVersion("clear_records"); - } else { - response_body = "{\"success\":false,\"message\":\"Failed to clear database\"}"; - status_code = 500; - status_text = "Internal Server Error"; - } - - } else if (method == "DELETE" && endpoint == "/api/record") { - auto params = parseQueryParams(query); - if (params.count("timestamp") && params.count("student_id")) { - std::string timestamp = params["timestamp"]; - std::string student_id = params["student_id"]; - - if (deleteRecord(timestamp, student_id)) { - response_body = "{\"success\":true,\"message\":\"Record deleted\"}"; - bumpSyncVersion("delete_record"); - } else { - response_body = "{\"success\":false,\"message\":\"Record not found\"}"; - status_code = 404; - status_text = "Not Found"; - } - } else { - response_body = "{\"success\":false,\"message\":\"Missing parameters\"}"; - status_code = 400; - status_text = "Bad Request"; - } - - } else if (method == "GET" && endpoint == "/api/health") { - response_body = makeHealthResponse(); - - } else if (method == "GET" && endpoint == "/api/v1/system/storage") { - response_body = makeStorageStatusResponse(); - - } else if (method == "GET" && endpoint == "/api/time") { - // 返回服务器当前Unix时间戳(UTC秒)和可读时间 - std::ostringstream json; - json << "{" - << "\"epoch\":" << static_cast(time(0)) << "," - << "\"server_time\":\"" << getCurrentTimestamp() << "\"" - << "}"; - response_body = json.str(); - - } else if (method == "POST" && endpoint == "/api/timeslots") { - // 上传时间段配置: POST /api/timeslots - std::cout << "[TIMESLOTS] Received upload request, data size: " << request_body.size() << " bytes" << std::endl; - - if (saveTimeSlots(request_body)) { - response_body = "{\"success\":true,\"message\":\"时间段配置已更新\"}"; - bumpSyncVersion("timeslots"); - std::cout << "[TIMESLOTS] ✓ Configuration uploaded successfully" << std::endl; - } else { - response_body = "{\"success\":false,\"message\":\"保存时间段配置失败\"}"; - status_code = 500; - status_text = "Internal Server Error"; - } - - } else if (method == "GET" && endpoint == "/api/timeslots") { - // 获取时间段配置: GET /api/timeslots - std::cout << "[TIMESLOTS] Reading configuration" << std::endl; - response_body = readTimeSlots(); - - } else if (method == "GET" && endpoint == "/api/check_duplicate") { - // 检查重复签到: GET /api/check_duplicate?student_id=xxx&slot_start=08:00&slot_end=09:30 - auto params = parseQueryParams(query); - if (params.count("student_id") && params.count("slot_start") && params.count("slot_end")) { - response_body = checkDuplicate(params["student_id"], params["slot_start"], params["slot_end"]); - std::cout << "[CHECK] student_id=" << params["student_id"] - << ", slot=" << params["slot_start"] << "-" << params["slot_end"] - << ", result=" << response_body << std::endl; - } else { - response_body = "{\"error\":\"Missing parameters: student_id, slot_start, slot_end\"}"; - status_code = 400; - status_text = "Bad Request"; - } - - } else if (method == "GET" && endpoint == "/api/today_records") { - // 获取今日已签到记录(供STM32开机下载) - // GET /api/today_records?slot_start=08:00&slot_end=09:30 - auto params = parseQueryParams(query); - std::string slot_start = params.count("slot_start") ? params["slot_start"] : "00:00"; - std::string slot_end = params.count("slot_end") ? params["slot_end"] : "23:59"; - - std::string todayDate = getCurrentTimestamp().substr(0, 10); - - int start_hour = 0, start_minute = 0, end_hour = 23, end_minute = 59; - parseTimeParts(slot_start, start_hour, start_minute); - parseTimeParts(slot_end, end_hour, end_minute); - - std::ostringstream json; - json << "{\"date\":\"" << jsonEscape(todayDate) << "\",\"records\":["; - - sqlite3* db = openSqliteDb(); - if (db) { - sqlite3_stmt* stmt = nullptr; - bool first = true; - const char* sql = "SELECT timestamp,student_id FROM records WHERE substr(timestamp,1,10)=? ORDER BY id ASC"; - if (sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr) == SQLITE_OK) { - sqliteBindText(stmt, 1, todayDate); - while (sqlite3_step(stmt) == SQLITE_ROW) { - std::string timestamp = sqliteColumnText(stmt, 0); - if (timestamp.length() < 16) continue; - int record_hour = 0; - int record_minute = 0; - if (!parseIntInRange(timestamp.substr(11, 2), 0, 23, record_hour) - || !parseIntInRange(timestamp.substr(14, 2), 0, 59, record_minute)) continue; - int record_total = record_hour * 60 + record_minute; - int start_total = start_hour * 60 + start_minute; - int end_total = end_hour * 60 + end_minute; - - if (record_total < start_total || record_total > end_total) continue; - if (!first) json << ","; - json << "\"" << jsonEscape(sqliteColumnText(stmt, 1)) << "\""; - first = false; - } - } - sqlite3_finalize(stmt); - sqlite3_close(db); - } - - json << "]}"; - response_body = json.str(); - std::cout << "[TODAY] Returning today's records: " << response_body << std::endl; - - } else if (method == "POST" && endpoint == "/api/mapping_config") { - // 上传映射配置: POST /api/mapping_config - std::cout << "[MAPPING] Received upload request, data size: " << request_body.size() << " bytes" << std::endl; - - if (saveMappingConfig(request_body)) { - response_body = "{\"success\":true,\"message\":\"映射配置已保存\"}"; - bumpSyncVersion("mapping_config"); - std::cout << "[MAPPING] ✓ Configuration saved successfully" << std::endl; - } else { - response_body = "{\"success\":false,\"message\":\"保存映射配置失败\"}"; - status_code = 500; - status_text = "Internal Server Error"; - } - - } else if (method == "GET" && endpoint == "/api/mapping_config") { - // 获取映射配置: GET /api/mapping_config - std::cout << "[MAPPING] Reading configuration" << std::endl; - - response_body = readMappingConfig(); - std::cout << "[MAPPING] Returning config: " << response_body.size() << " bytes" << std::endl; - - // ============= 人脸照片管理 API ============= - } else if (method == "GET" && endpoint == "/api/face_photos") { - // 获取人脸照片列表 - _mkdir(FACES_DIR.c_str()); - response_body = getFacePhotosList(); - std::cout << "[FACE_PHOTO] Photos list fetched" << std::endl; - - } else if (method == "GET" && endpoint.find("/api/face_photo/") == 0) { - // 获取指定人脸照片: GET /api/face_photo/ - std::string face_id = urlDecode(endpoint.substr(16)); - std::string photo_data = readFacePhoto(face_id); - - if (!photo_data.empty()) { - response_body = photo_data; - content_type = "image/jpeg"; - std::cout << "[FACE_PHOTO] Photo fetched for id: " << face_id << std::endl; - } else { - response_body = "{\"error\":\"Photo not found\"}"; - status_code = 404; - status_text = "Not Found"; - } - - } else if (method == "POST" && endpoint.find("/api/face_photo_simple/") == 0) { - // 简化版人脸照片上传: POST /api/face_photo_simple/ - std::string face_id = urlDecode(endpoint.substr(23)); - std::cout << "[FACE_PHOTO] Simple upload for face_id: " << face_id << ", size: " << request_body.size() << " bytes" << std::endl; - - if (!isSafeId(face_id)) { - response_body = "{\"success\":false,\"message\":\"Invalid face_id\"}"; - status_code = 400; - status_text = "Bad Request"; - } else if (!request_body.empty()) { - if (saveFacePhoto(face_id, request_body)) { - response_body = "{\"success\":true,\"message\":\"Photo saved\",\"face_id\":\"" + jsonEscape(face_id) + "\"}"; - bumpSyncVersion("face_photo"); - } else { - response_body = "{\"success\":false,\"message\":\"Failed to save photo\"}"; - status_code = 500; - status_text = "Internal Server Error"; - } - } else { - response_body = "{\"success\":false,\"message\":\"Invalid face_id or empty data\"}"; - status_code = 400; - status_text = "Bad Request"; - } - - } else if (method == "POST" && endpoint == "/api/face_id_mapping") { - // 保存人脸ID映射 - std::cout << "[FACE_MAPPING] Saving mapping, size: " << request_body.size() << " bytes" << std::endl; - if (saveFaceIdMapping(request_body)) { - // 根据新映射重命名照片文件 - renameFacePhotos(request_body); - // 自动触发K210同步 - bumpSyncVersion("face_id_mapping"); - setSyncRequest(true); - std::cout << "[FACE_MAPPING] Auto-triggered K210 sync" << std::endl; - response_body = "{\"success\":true,\"message\":\"Face ID mapping saved, K210 sync triggered\"}"; - } else { - response_body = "{\"success\":false,\"message\":\"Failed to save mapping\"}"; - status_code = 500; - status_text = "Internal Server Error"; - } - - } else if (method == "GET" && endpoint == "/api/face_id_mapping") { - // 获取人脸ID映射 - response_body = readFaceIdMapping(); - std::cout << "[FACE_MAPPING] Mapping fetched" << std::endl; - - } else if (method == "GET" && endpoint == "/api/all_student_ids") { - // 获取所有学生ID(从考勤记录提取) - response_body = getAllStudentIds(); - std::cout << "[STUDENT_IDS] All student IDs fetched" << std::endl; - - } else if (method == "DELETE" && endpoint.find("/api/face/") == 0) { - // 删除人脸信息: DELETE /api/face/?delete_records=true - std::string student_id = urlDecode(endpoint.substr(10)); // 去掉 "/api/face/" - auto params = parseQueryParams(query); - bool delete_records = (params.count("delete_records") && params["delete_records"] == "true"); - - std::cout << "[DELETE_FACE] Deleting face for student_id: " << student_id - << ", delete_records: " << (delete_records ? "yes" : "no") << std::endl; - - if (!isSafeId(student_id)) { - response_body = "{\"success\":false,\"message\":\"Invalid student_id\"}"; - status_code = 400; - status_text = "Bad Request"; - } else { - const std::set k210_face_ids = resolveK210FaceIdsForDelete(student_id); - const std::set student_ids = resolveStudentIdsForDelete(student_id); - bool photo_deleted = deleteFacePhoto(student_id); - bool mapping_deleted = removeFaceIdFromMapping(student_id); - int records_deleted = 0; - - if (delete_records) { - for (const auto& id : student_ids) { - records_deleted += deleteRecordsByStudentId(id); - } - } - - // 添加K210原始face_id到待删除列表,等待K210同步删除本地特征 - bool pending_added = false; - for (const auto& face_id : k210_face_ids) { - if (face_id != student_id) { - photo_deleted = deleteFacePhoto(face_id) || photo_deleted; - mapping_deleted = removeFaceIdFromMapping(face_id) || mapping_deleted; - } - pending_added = addToPendingDelete(face_id, student_id) || pending_added; - } - for (const auto& id : student_ids) { - photo_deleted = deleteFacePhoto(id) || photo_deleted; - } - bumpSyncVersion("delete_face"); - setSyncRequest(true); - - std::ostringstream result; - result << "{\"success\":true," - << "\"photo_deleted\":" << (photo_deleted ? "true" : "false") << "," - << "\"mapping_deleted\":" << (mapping_deleted ? "true" : "false") << "," - << "\"records_deleted\":" << records_deleted << "," - << "\"k210_pending\":" << (pending_added ? "true" : "false") << "," - << "\"sync_requested\":true}"; - response_body = result.str(); - - std::cout << "[DELETE_FACE] Result: " << response_body << std::endl; - } - - } else if (method == "GET" && endpoint == "/api/pending_delete") { - // K210查询待删除列表 - response_body = getPendingDeleteList(); - std::cout << "[PENDING_DELETE] List fetched" << std::endl; - - } else if (method == "POST" && endpoint == "/api/confirm_delete") { - // K210确认删除完成,从待删除列表移除 - // 请求体格式: {"face_id":"xxx","task_id":"xxx"} - auto ack_json = parseJSON(request_body); - std::string ack_id = ack_json.count("task_id") ? ack_json["task_id"] : ""; - if (ack_id.empty() && ack_json.count("face_id")) { - ack_id = ack_json["face_id"]; - } - - if (!ack_id.empty() && removeFromPendingDelete(ack_id)) { - response_body = "{\"success\":true,\"message\":\"Confirmed delete: " + jsonEscape(ack_id) + "\"}"; - bumpSyncVersion("confirm_delete"); - std::cout << "[PENDING_DELETE] K210 confirmed delete: " << ack_id << std::endl; - } else { - response_body = "{\"success\":false,\"message\":\"Invalid face_id or not in pending list\"}"; - status_code = 400; - status_text = "Bad Request"; - } - - // ============= K210同步请求 API ============= - } else if (method == "GET" && endpoint == "/api/sync_request") { - // K210查询是否需要同步 - response_body = getSyncRequest(); - std::cout << "[SYNC] K210 checking sync request: " << response_body << std::endl; - - } else if (method == "POST" && endpoint == "/api/sync_ack") { - // K210确认收到同步请求,清除标志 - if (clearSyncRequest()) { - response_body = "{\"success\":true,\"message\":\"Sync acknowledged\"}"; - std::cout << "[SYNC] K210 acknowledged sync request" << std::endl; - } else { - response_body = "{\"success\":false,\"message\":\"Failed to clear sync request\"}"; - status_code = 500; - status_text = "Internal Server Error"; - } - - } else if (method == "POST" && endpoint == "/api/trigger_sync") { - // QT端触发K210同步 - if (setSyncRequest(true)) { - response_body = "{\"success\":true,\"message\":\"Sync request sent to K210\"}"; - std::cout << "[SYNC] QT triggered K210 sync" << std::endl; - } else { - response_body = "{\"success\":false,\"message\":\"Failed to set sync request\"}"; - status_code = 500; - status_text = "Internal Server Error"; - } - - } else { - response_body = "{\"error\":\"Not Found\"}"; - status_code = 404; - status_text = "Not Found"; - } - - sendResponse(); -} - -// ============= HTTP服务器线程 ============= -void httpServerThread() { - SOCKET server_socket = socket(AF_INET, SOCK_STREAM, 0); - if (server_socket == INVALID_SOCKET) { - std::cerr << "[HTTP] Failed to create socket" << std::endl; - return; - } - - int opt = 1; - setsockopt(server_socket, SOL_SOCKET, SO_REUSEADDR, (char*)&opt, sizeof(opt)); - - sockaddr_in server_addr; - server_addr.sin_family = AF_INET; - const std::string bind_host = bindAddress(); - if (bind_host == "0.0.0.0" || bind_host.empty()) { - server_addr.sin_addr.s_addr = INADDR_ANY; - } else { - if (inet_pton(AF_INET, bind_host.c_str(), &server_addr.sin_addr) != 1) { - std::cerr << "[HTTP] Invalid ATTENDANCE_BIND_HOST: " << bind_host << std::endl; - closesocket(server_socket); - return; - } - } - server_addr.sin_port = htons(HTTP_PORT); - - if (bind(server_socket, (sockaddr*)&server_addr, sizeof(server_addr)) == SOCKET_ERROR) { - std::cerr << "[HTTP] Bind failed" << std::endl; - closesocket(server_socket); - return; - } - - listen(server_socket, 10); - std::cout << "[HTTP] Server listening on " << bind_host << ":" << HTTP_PORT << std::endl; - - while (true) { - sockaddr_in client_addr; - socklen_t client_len = sizeof(client_addr); - SOCKET client_socket = accept(server_socket, (sockaddr*)&client_addr, &client_len); - - if (client_socket != INVALID_SOCKET) { - std::thread(handleHttpRequest, client_socket).detach(); - } - } -} - -int main() { -#ifdef _WIN32 - WSADATA wsa; - WSAStartup(MAKEWORD(2, 2), &wsa); -#endif - - std::cout << "========================================" << std::endl; - std::cout << " Face Recognition Attendance Server" << std::endl; - std::cout << " with AES-128-CBC Encryption" << std::endl; - std::cout << "========================================" << std::endl; - std::cout << "HTTP Port " << HTTP_PORT << " - Encrypted API" << std::endl; - std::cout << "Database: " << SQLITE_DB_FILE << std::endl; - std::cout << "----------------------------------------" << std::endl; - - if (!backupSqliteDatabaseOnStartup()) { - std::cerr << "[BACKUP] Startup backup failed, stop to avoid unsafe writes" << std::endl; - return 1; - } - - // 初始化SQLite,并首次导入旧CSV/JSON配置 - if (!initializeSqliteStorage()) { - std::cerr << "[SQLITE] Failed to initialize storage" << std::endl; - return 1; - } - std::cout << "[SYNC] Current version: " << readSyncVersion() << std::endl; - - // 启动HTTP服务器 - std::thread http_thread(httpServerThread); - http_thread.join(); - -#ifdef _WIN32 - WSACleanup(); -#endif - - return 0; -} diff --git a/server/tiny_aes.hpp b/server/tiny_aes.hpp deleted file mode 100644 index f285859..0000000 --- a/server/tiny_aes.hpp +++ /dev/null @@ -1,291 +0,0 @@ -/* - * Tiny AES128 C++ 移植版本 - * 与STM32端使用相同的AES实现,保证兼容性 - */ - -#ifndef _TINY_AES_HPP_ -#define _TINY_AES_HPP_ - -#include -#include - -#define AES_BLOCKLEN 16 -#define AES_KEYLEN 16 -#define AES_keyExpSize 176 - -struct AES_ctx -{ - uint8_t RoundKey[AES_keyExpSize]; - uint8_t Iv[AES_BLOCKLEN]; -}; - -// S-box -static const uint8_t sbox[256] = { - 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, - 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, - 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, - 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, - 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, - 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, - 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, - 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, - 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, - 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, - 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, - 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, - 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, - 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, - 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, - 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16 -}; - -// Inverse S-box -static const uint8_t rsbox[256] = { - 0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb, - 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, - 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, - 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25, - 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, - 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, - 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06, - 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, - 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, - 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e, - 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, - 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, - 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f, - 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, - 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, - 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d -}; - -// Rcon -static const uint8_t Rcon[11] = { - 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36 -}; - -#define getSBoxValue(num) (sbox[(num)]) -#define getSBoxInvert(num) (rsbox[(num)]) - -// 导出这些函数供外部使用 -void KeyExpansion(uint8_t* RoundKey, const uint8_t* Key); -void InvCipher(uint8_t* state, const uint8_t* RoundKey); - -inline void KeyExpansion(uint8_t* RoundKey, const uint8_t* Key) -{ - unsigned i, j, k; - uint8_t tempa[4]; - - for (i = 0; i < 4; ++i) { - RoundKey[(i * 4) + 0] = Key[(i * 4) + 0]; - RoundKey[(i * 4) + 1] = Key[(i * 4) + 1]; - RoundKey[(i * 4) + 2] = Key[(i * 4) + 2]; - RoundKey[(i * 4) + 3] = Key[(i * 4) + 3]; - } - - for (i = 4; i < 44; ++i) { - k = (i - 1) * 4; - tempa[0] = RoundKey[k + 0]; - tempa[1] = RoundKey[k + 1]; - tempa[2] = RoundKey[k + 2]; - tempa[3] = RoundKey[k + 3]; - - if (i % 4 == 0) { - const uint8_t u8tmp = tempa[0]; - tempa[0] = tempa[1]; - tempa[1] = tempa[2]; - tempa[2] = tempa[3]; - tempa[3] = u8tmp; - - tempa[0] = getSBoxValue(tempa[0]); - tempa[1] = getSBoxValue(tempa[1]); - tempa[2] = getSBoxValue(tempa[2]); - tempa[3] = getSBoxValue(tempa[3]); - - tempa[0] = tempa[0] ^ Rcon[i / 4]; - } - - j = i * 4; k = (i - 4) * 4; - RoundKey[j + 0] = RoundKey[k + 0] ^ tempa[0]; - RoundKey[j + 1] = RoundKey[k + 1] ^ tempa[1]; - RoundKey[j + 2] = RoundKey[k + 2] ^ tempa[2]; - RoundKey[j + 3] = RoundKey[k + 3] ^ tempa[3]; - } -} - -static void AddRoundKey(uint8_t round, uint8_t* state, const uint8_t* RoundKey) -{ - uint8_t i, j; - for (i = 0; i < 4; ++i) { - for (j = 0; j < 4; ++j) { - state[i * 4 + j] ^= RoundKey[(round * 16) + (i * 4 + j)]; - } - } -} - -static void SubBytes(uint8_t* state) -{ - uint8_t i; - for (i = 0; i < 16; ++i) { - state[i] = getSBoxValue(state[i]); - } -} - -static void InvSubBytes(uint8_t* state) -{ - uint8_t i; - for (i = 0; i < 16; ++i) { - state[i] = getSBoxInvert(state[i]); - } -} - -static void ShiftRows(uint8_t* state) -{ - uint8_t temp; - temp = state[1]; - state[1] = state[5]; - state[5] = state[9]; - state[9] = state[13]; - state[13] = temp; - - temp = state[2]; - state[2] = state[10]; - state[10] = temp; - temp = state[6]; - state[6] = state[14]; - state[14] = temp; - - temp = state[15]; - state[15] = state[11]; - state[11] = state[7]; - state[7] = state[3]; - state[3] = temp; -} - -static void InvShiftRows(uint8_t* state) -{ - uint8_t temp; - temp = state[13]; - state[13] = state[9]; - state[9] = state[5]; - state[5] = state[1]; - state[1] = temp; - - temp = state[2]; - state[2] = state[10]; - state[10] = temp; - temp = state[6]; - state[6] = state[14]; - state[14] = temp; - - temp = state[3]; - state[3] = state[7]; - state[7] = state[11]; - state[11] = state[15]; - state[15] = temp; -} - -static uint8_t xtime(uint8_t x) -{ - return ((x << 1) ^ (((x >> 7) & 1) * 0x1b)); -} - -static void MixColumns(uint8_t* state) -{ - uint8_t i; - uint8_t Tmp, Tm, t; - for (i = 0; i < 4; ++i) { - t = state[i * 4 + 0]; - Tmp = state[i * 4 + 0] ^ state[i * 4 + 1] ^ state[i * 4 + 2] ^ state[i * 4 + 3]; - Tm = state[i * 4 + 0] ^ state[i * 4 + 1]; Tm = xtime(Tm); state[i * 4 + 0] ^= Tm ^ Tmp; - Tm = state[i * 4 + 1] ^ state[i * 4 + 2]; Tm = xtime(Tm); state[i * 4 + 1] ^= Tm ^ Tmp; - Tm = state[i * 4 + 2] ^ state[i * 4 + 3]; Tm = xtime(Tm); state[i * 4 + 2] ^= Tm ^ Tmp; - Tm = state[i * 4 + 3] ^ t; Tm = xtime(Tm); state[i * 4 + 3] ^= Tm ^ Tmp; - } -} - -static uint8_t Multiply(uint8_t x, uint8_t y) -{ - return (((y & 1) * x) ^ - ((y >> 1 & 1) * xtime(x)) ^ - ((y >> 2 & 1) * xtime(xtime(x))) ^ - ((y >> 3 & 1) * xtime(xtime(xtime(x)))) ^ - ((y >> 4 & 1) * xtime(xtime(xtime(xtime(x)))))); -} - -static void InvMixColumns(uint8_t* state) -{ - int i; - uint8_t a, b, c, d; - for (i = 0; i < 4; ++i) { - a = state[i * 4 + 0]; - b = state[i * 4 + 1]; - c = state[i * 4 + 2]; - d = state[i * 4 + 3]; - - state[i * 4 + 0] = Multiply(a, 0x0e) ^ Multiply(b, 0x0b) ^ Multiply(c, 0x0d) ^ Multiply(d, 0x09); - state[i * 4 + 1] = Multiply(a, 0x09) ^ Multiply(b, 0x0e) ^ Multiply(c, 0x0b) ^ Multiply(d, 0x0d); - state[i * 4 + 2] = Multiply(a, 0x0d) ^ Multiply(b, 0x09) ^ Multiply(c, 0x0e) ^ Multiply(d, 0x0b); - state[i * 4 + 3] = Multiply(a, 0x0b) ^ Multiply(b, 0x0d) ^ Multiply(c, 0x09) ^ Multiply(d, 0x0e); - } -} - -static void Cipher(uint8_t* state, const uint8_t* RoundKey) -{ - uint8_t round = 0; - - AddRoundKey(0, state, RoundKey); - - for (round = 1; ; ++round) { - SubBytes(state); - ShiftRows(state); - if (round == 10) { - break; - } - MixColumns(state); - AddRoundKey(round, state, RoundKey); - } - AddRoundKey(10, state, RoundKey); -} - -inline void InvCipher(uint8_t* state, const uint8_t* RoundKey) -{ - uint8_t round = 0; - - AddRoundKey(10, state, RoundKey); - - for (round = 9; ; --round) { - InvShiftRows(state); - InvSubBytes(state); - AddRoundKey(round, state, RoundKey); - if (round == 0) { - break; - } - InvMixColumns(state); - } -} - -inline void AES_init_ctx_iv(struct AES_ctx* ctx, const uint8_t* key, const uint8_t* iv) -{ - KeyExpansion(ctx->RoundKey, key); - memcpy(ctx->Iv, iv, AES_BLOCKLEN); -} - -inline void AES_CBC_decrypt_buffer(struct AES_ctx* ctx, uint8_t* buf, size_t length) -{ - size_t i; - uint8_t storeNextIv[AES_BLOCKLEN]; - - for (i = 0; i < length; i += AES_BLOCKLEN) { - memcpy(storeNextIv, buf + i, AES_BLOCKLEN); - InvCipher(buf + i, ctx->RoundKey); - - uint8_t j; - for (j = 0; j < AES_BLOCKLEN; ++j) { - buf[i + j] ^= ctx->Iv[j]; - } - memcpy(ctx->Iv, storeNextIv, AES_BLOCKLEN); - } -} - -#endif // _TINY_AES_HPP_