commit fca1ea21d2ea74817570ca5e6861861af172c372 Author: 纪田野 Date: Mon May 4 02:48:33 2026 +0800 Initial QT project upload diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c44d276 --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# 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/ +qtcreator-cache-backup-*/ +*.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 +*~ + +# Local config and runtime data +.env +.env.* +*.db +*.db-shm +*.db-wal +faces/ +face_photos/ +backups/ +logs/ + +# OS files +.DS_Store +Thumbs.db +desktop.ini diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..c0b598c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,146 @@ +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/StudentProfileListModel.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/LICENSE b/LICENSE new file mode 100644 index 0000000..c46d1f4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jitianye + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..072939e --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# Qt 管理端 README + +这是人脸识别考勤系统的 Qt 管理端,支持 Windows 桌面端和 Android 端。更完整的说明见: + +```text +../docs/04_Qt管理端文档.md +../docs/00_项目总文档.md +``` + +## 1. 功能概览 + +Qt 管理端用于连接考勤服务端,完成以下工作: + +1. 查看考勤记录。 +2. 按班级、学号、姓名和日期筛选记录。 +3. 导出考勤记录 CSV。 +4. 清空服务端考勤记录。 +5. 查看学生维度的统计分析。 +6. 导出统计 CSV。 +7. 配置服务端地址和 API Token。 +8. 新增、编辑、删除、清空签到时间段。 +9. 配置学号到姓名、学号到班级编号、班级编号到班级名称的显示映射。 +10. 查看 K210 上传的人脸照片。 +11. 为 K210 face_id 绑定真实学号。 +12. 删除人脸信息,并可选择删除关联考勤记录。 + +## 2. 当前重要行为 + +### 2.1 默认服务端地址 + +```text +http://127.0.0.1:8082 +``` + +启动后会优先读取本地保存的 `server/url`,API Token 会优先读取 `server/apiToken`,没有本地值时再使用环境变量。 + +### 2.2 默认签到时间段 + +当前默认签到时间段为空: + +```json +{ + "time_slots": [] +} +``` + +第一次使用时,需要在“配置中心”里新增时间段并保存同步到服务端;当前以服务端 SQLite 中的时间段配置为准。 + +### 2.3 API Token + +Qt 每个请求都会带: + +```text +X-Api-Token: +``` + +API Token 不写入源码。可以通过“配置中心”保存,也可以通过环境变量提供: + +```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 +├── clear_qt_cache.ps1 # 清理 Qt Creator/CMake 本地缓存 +├── 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\thesis\code\QT +cmake -S . -B build\Desktop -DCMAKE_PREFIX_PATH=D:\Qt\6.10.0\mingw_64 +cmake --build build\Desktop +``` + +Qt 安装路径需要按本机实际情况修改。 + +## 8. 迁移后清理缓存 + +项目移动到新目录后,Qt Creator 可能仍沿用旧的 `.qtcreator`、`build` 或 CMake 缓存。可在 Qt 工程目录运行: + +```powershell +cd D:\University\graduation\thesis\code\QT +powershell -ExecutionPolicy Bypass -File .\clear_qt_cache.ps1 +``` + +该脚本只清理当前项目目录内的 Qt/CMake 缓存,不清理 Qt Creator 全局配置,也不会备份。 + +## 9. 配置中心 + +服务端配置区包含“服务器地址”和“API Token”两个输入框: + +| 按钮 | 作用 | +| --- | --- | +| 保存 | 同时保存服务器地址和 API Token 到 `QSettings`,并切换 `ApiClient` 配置 | +| 测试连接 | 使用当前配置请求 `/api/v1/system/health` | + +## 10. 本地数据 + +Qt 会保存这些本地数据: + +| 数据 | 保存方式 | +| --- | --- | +| 服务端地址 | `QSettings`,键名 `server/url` | +| API Token | `QSettings`,键名 `server/apiToken` | +| 时间段配置 | `QStandardPaths::AppDataLocation/timeslot_config.json` | +| 学生/班级映射 | `QStandardPaths::AppDataLocation/mapping_config.json` | +| 人脸照片缓存 | `QStandardPaths::AppDataLocation/face_photos/*.jpg` | + +## 11. 常见问题 + +| 现象 | 处理 | +| --- | --- | +| 测试连接失败 | 检查服务端是否启动、地址是否正确、Token 是否一致 | +| 迁移目录后 Qt Creator 报错或构建目录不对 | 关闭 Qt Creator 后运行 `clear_qt_cache.ps1`,再重新打开 `CMakeLists.txt` | +| 没有时间段 | 这是当前默认行为,需要手动新增并保存 | +| 识别成功但没有记录 | 检查 STM32 是否在时间段内、是否成功加密上传 | +| 人脸照片不显示 | 检查 K210 是否上传照片,服务端 `faces/` 是否有图片 | +| Android 布局拥挤 | 当前 QML 已做窄屏自适应,若仍异常请检查实际设备宽度 | diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..b383ead --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +android.overridePathCheck=true +android.useAndroidX=true diff --git a/android/res/mipmap-hdpi/ic_launcher.png b/android/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..cb9bd6e Binary files /dev/null and b/android/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/res/mipmap-mdpi/ic_launcher.png b/android/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..09409d5 Binary files /dev/null and b/android/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/res/mipmap-xhdpi/ic_launcher.png b/android/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..0e1210c Binary files /dev/null and b/android/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/res/mipmap-xxhdpi/ic_launcher.png b/android/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..b3651fb Binary files /dev/null and b/android/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/res/mipmap-xxxhdpi/ic_launcher.png b/android/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f05ced8 Binary files /dev/null and b/android/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/favicon-128x128.png b/assets/favicon-128x128.png new file mode 100644 index 0000000..808a71a Binary files /dev/null and b/assets/favicon-128x128.png differ diff --git a/assets/favicon-16x16.png b/assets/favicon-16x16.png new file mode 100644 index 0000000..8b0b1b5 Binary files /dev/null and b/assets/favicon-16x16.png differ diff --git a/assets/favicon-192x192.png b/assets/favicon-192x192.png new file mode 100644 index 0000000..eaed9c4 Binary files /dev/null and b/assets/favicon-192x192.png differ diff --git a/assets/favicon-256x256.png b/assets/favicon-256x256.png new file mode 100644 index 0000000..2407c25 Binary files /dev/null and b/assets/favicon-256x256.png differ diff --git a/assets/favicon-32x32.png b/assets/favicon-32x32.png new file mode 100644 index 0000000..59d5a42 Binary files /dev/null and b/assets/favicon-32x32.png differ diff --git a/assets/favicon-48x48.png b/assets/favicon-48x48.png new file mode 100644 index 0000000..54ec421 Binary files /dev/null and b/assets/favicon-48x48.png differ diff --git a/assets/favicon-64x64.png b/assets/favicon-64x64.png new file mode 100644 index 0000000..e979620 Binary files /dev/null and b/assets/favicon-64x64.png differ diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000..69458ba Binary files /dev/null and b/assets/favicon.ico differ diff --git a/clear_qt_cache.ps1 b/clear_qt_cache.ps1 new file mode 100644 index 0000000..4575211 --- /dev/null +++ b/clear_qt_cache.ps1 @@ -0,0 +1,133 @@ +#requires -version 5.1 +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [string]$ProjectDir, + [switch]$Force +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' + +function Resolve-FullPath { + param([Parameter(Mandatory = $true)][string]$Path) + + return [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $Path).Path) +} + +function Assert-InsideDirectory { + param( + [Parameter(Mandatory = $true)][string]$Parent, + [Parameter(Mandatory = $true)][string]$Child + ) + + $parentFull = [IO.Path]::GetFullPath($Parent).TrimEnd([char[]]@('\', '/')) + $childFull = [IO.Path]::GetFullPath($Child).TrimEnd([char[]]@('\', '/')) + $parentPrefix = $parentFull + [IO.Path]::DirectorySeparatorChar + + if ($childFull -eq $parentFull -or -not $childFull.StartsWith($parentPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to remove path outside project: $childFull" + } +} + +function Stop-IfBuildToolsAreRunning { + $names = @('qtcreator', 'cmake', 'ninja', 'jom', 'mingw32-make', 'make') + $running = Get-Process -ErrorAction SilentlyContinue | + Where-Object { $names -contains $_.ProcessName.ToLowerInvariant() } | + Sort-Object ProcessName, Id + + if ($running -and -not $Force) { + $summary = ($running | ForEach-Object { "$($_.ProcessName)#$($_.Id)" }) -join ', ' + throw "Qt Creator or build tools are running: $summary. Close them first, or rerun with -Force if you are sure." + } +} + +function Get-CacheTargets { + param([Parameter(Mandatory = $true)][string]$ProjectPath) + + $literalNames = @( + '.qtcreator', + '.cmake', + '.qt', + '.qtc_clangd', + 'build', + 'out', + 'CMakeCache.txt', + 'CMakeFiles', + 'cmake_install.cmake', + 'compile_commands.json', + 'CTestTestfile.cmake', + 'DartConfiguration.tcl', + 'install_manifest.txt', + 'Makefile', + 'CMakeUserPresets.json' + ) + + $wildcardFilters = @( + 'build-*', + 'cmake-build-*', + '*.user', + '*.user.*', + '*.autosave' + ) + + $items = New-Object 'System.Collections.Generic.List[System.IO.FileSystemInfo]' + + foreach ($name in $literalNames) { + $path = Join-Path $ProjectPath $name + if (Test-Path -LiteralPath $path) { + $items.Add((Get-Item -LiteralPath $path -Force)) + } + } + + foreach ($filter in $wildcardFilters) { + Get-ChildItem -LiteralPath $ProjectPath -Force -Filter $filter -ErrorAction SilentlyContinue | + ForEach-Object { $items.Add($_) } + } + + $seen = @{} + foreach ($item in $items) { + if (-not $seen.ContainsKey($item.FullName)) { + $seen[$item.FullName] = $true + $item + } + } +} + +if ([string]::IsNullOrWhiteSpace($ProjectDir)) { + if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) { + $ProjectDir = $PSScriptRoot + } elseif (-not [string]::IsNullOrWhiteSpace($PSCommandPath)) { + $ProjectDir = Split-Path -Parent $PSCommandPath + } else { + $ProjectDir = (Get-Location).Path + } +} + +$projectPath = Resolve-FullPath -Path $ProjectDir + +if (-not (Test-Path -LiteralPath (Join-Path $projectPath 'CMakeLists.txt'))) { + Write-Warning "CMakeLists.txt was not found in '$projectPath'. Use -ProjectDir to point at the Qt project root." +} + +Stop-IfBuildToolsAreRunning + +Write-Host "Project: $projectPath" +Write-Host 'Mode: delete current project Qt/CMake cache directly.' + +$targets = @(Get-CacheTargets -ProjectPath $projectPath) + +if ($targets.Count -eq 0) { + Write-Host 'No cache files found.' + exit 0 +} + +foreach ($target in $targets) { + Assert-InsideDirectory -Parent $projectPath -Child $target.FullName + + if ($PSCmdlet.ShouldProcess($target.FullName, 'Remove Qt/CMake cache')) { + Remove-Item -LiteralPath $target.FullName -Recurse -Force + Write-Host "Removed: $($target.FullName)" + } +} + +Write-Host 'Done.' diff --git a/cmake/app_icon.rc.in b/cmake/app_icon.rc.in new file mode 100644 index 0000000..b754112 --- /dev/null +++ b/cmake/app_icon.rc.in @@ -0,0 +1 @@ +IDI_ICON1 ICON "@REWRITE_QT_APP_ICON@" diff --git a/cmake/rewrite_qt.desktop.in b/cmake/rewrite_qt.desktop.in new file mode 100644 index 0000000..35224cd --- /dev/null +++ b/cmake/rewrite_qt.desktop.in @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Application +Name=人脸识别考勤系统 +Exec=apprewrite_qt +Icon=rewrite_qt +Terminal=false +Categories=Utility; diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..101ba11 --- /dev/null +++ b/main.cpp @@ -0,0 +1,63 @@ +#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/qml/Main.qml b/qml/Main.qml new file mode 100644 index 0000000..e2bd743 --- /dev/null +++ b/qml/Main.qml @@ -0,0 +1,3191 @@ +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 + property bool mappingsDirty: 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(); + } + + function classComboIndex(classId) { + var display = appController.classDisplayForId(classId); + var options = appController.classSelectionOptions; + for (var i = 0; i < options.length; ++i) { + if (options[i] === display) { + return i; + } + } + return options.length > 0 ? 0 : -1; + } + + function optionIndex(options, value) { + for (var i = 0; i < options.length; ++i) { + if (options[i] === value) { + return i; + } + } + return 0; + } + + function markMappingsDirty() { + mappingsDirty = true; + } + + function commitStudentProfile(index, studentId, studentName, classDisplay) { + appController.updateStudentProfile(index, studentId, studentName, appController.classIdFromDisplay(classDisplay)); + markMappingsDirty(); + } + + function studentProfileMatches(searchText, classFilter, bindingFilter, studentId, studentName, classId, className, faceIds) { + var normalizedSearch = searchText ? searchText.trim().toLowerCase() : ""; + var classFilterId = appController.classIdFromDisplay(classFilter); + if (classFilter && classFilter !== "全部" && classId !== classFilterId) { + return false; + } + if (bindingFilter === "已绑定" && faceIds.length === 0) { + return false; + } + if (bindingFilter === "未绑定" && faceIds.length > 0) { + return false; + } + if (normalizedSearch.length === 0) { + return true; + } + var haystack = (studentId + " " + studentName + " " + classId + " " + className + " " + faceIds).toLowerCase(); + return haystack.indexOf(normalizedSearch) >= 0; + } + + Component.onCompleted: { + applyRecordFilters(); + applyStatisticsPreset(); + } + + Connections { + target: appController + + function onStatusMessageChanged() { + if (!appController.statusMessage || appController.statusMessage.length === 0) { + return; + } + if (appController.statusMessage.indexOf("映射配置已保存") >= 0 + || appController.statusMessage.indexOf("映射配置已同步") >= 0) { + mappingsDirty = false; + } + 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 : 8 + 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 : 360 + Layout.columnSpan: slimLayout ? 1 : 2 + text: appController.serverUrl + placeholderText: "http://IP:端口" + } + + Label { + Layout.alignment: Qt.AlignVCenter + text: "API Token" + font.weight: appBoldWeight + color: ink700 + } + + AppTextField { + id: apiTokenField + Layout.fillWidth: true + Layout.preferredWidth: slimLayout ? 0 : 300 + Layout.columnSpan: slimLayout ? 1 : 2 + text: appController.apiToken + placeholderText: "服务端访问令牌" + echoMode: TextInput.Password + passwordCharacter: "*" + } + + SecondaryButton { + Layout.fillWidth: slimLayout + text: "保存" + onClicked: appController.saveServerConfig(serverUrlField.text, apiTokenField.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 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + SectionHeader { + Layout.fillWidth: true + title: "时间段配置" + description: "维护签到与签退时段,并同步到服务端。" + accent: accentColor + } + + SecondaryButton { + Layout.preferredWidth: phoneLayout ? 76 : 88 + Layout.alignment: Qt.AlignTop + text: timeSlotsExpanded ? "收起" : "展开" + onClicked: timeSlotsExpanded = !timeSlotsExpanded + } + } + GridLayout { + Layout.fillWidth: true + columns: slimLayout ? 2 : 4 + columnSpacing: 8 + rowSpacing: 8 + + 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 + RowLayout { + Layout.fillWidth: true + spacing: 12 + + SectionHeader { + Layout.fillWidth: true + title: "名称映射配置" + description: "让学号和班级编号显示为更清晰的真实名称。" + accent: tealColor + } + + SecondaryButton { + Layout.preferredWidth: phoneLayout ? 76 : 88 + Layout.alignment: Qt.AlignTop + text: mappingsExpanded ? "收起" : "展开" + onClicked: mappingsExpanded = !mappingsExpanded + } + } + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: 8 + rowSpacing: 8 + + 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 ? 168 : 74, mappingTabs.currentIndex === 0 ? studentProfileListView.contentHeight : classMappingListView.contentHeight) : 0 + currentIndex: mappingTabs.currentIndex + + Rectangle { + color: "transparent" + + ListView { + id: studentProfileListView + anchors.fill: parent + clip: false + interactive: false + spacing: 8 + model: appController.studentProfileModel + + header: ColumnLayout { + width: ListView.view.width + spacing: 8 + + GridLayout { + Layout.fillWidth: true + columns: phoneLayout ? 1 : 5 + columnSpacing: 8 + rowSpacing: 8 + + AppTextField { + Layout.fillWidth: true + placeholderText: "搜索学号、姓名、班级或人脸 ID" + text: appController.studentProfileSearchText + onTextEdited: appController.studentProfileSearchText = text + } + + AppComboBox { + Layout.fillWidth: true + model: appController.studentProfileClassFilterOptions + currentIndex: optionIndex(model, appController.studentProfileClassFilter) + onActivated: appController.studentProfileClassFilter = currentText + } + + AppComboBox { + Layout.fillWidth: true + model: ["全部", "已绑定", "未绑定"] + currentIndex: optionIndex(model, appController.studentProfileBindingFilter) + onActivated: appController.studentProfileBindingFilter = currentText + } + + SecondaryButton { + Layout.fillWidth: true + text: "新增学生" + onClicked: { + appController.addStudentProfile(); + markMappingsDirty(); + } + } + + Label { + Layout.alignment: Qt.AlignVCenter + text: mappingsDirty ? "有未保存改动" : "配置已保存" + color: mappingsDirty ? goldColor : successColor + font.pixelSize: 12 + font.weight: appBoldWeight + } + } + } + + delegate: Rectangle { + id: studentProfileRow + width: ListView.view.width + height: visible ? (phoneLayout ? 168 : 64) : 0 + visible: studentProfileMatches(appController.studentProfileSearchText, + appController.studentProfileClassFilter, + appController.studentProfileBindingFilter, + model.studentId, + model.studentName, + model.classId, + model.className, + model.faceIds) + property int rowIndex: index + property string rowClassId: model.classId + property bool hovered: studentProfileHoverHandler.hovered + radius: 8 + 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: studentProfileHoverHandler + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + visible: !phoneLayout + spacing: 10 + + AppTextField { + id: profileStudentIdField + Layout.preferredWidth: 160 + text: model.studentId + placeholderText: "学号" + onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, text, profileStudentNameField.text, profileClassCombo.currentText) + } + + AppTextField { + id: profileStudentNameField + Layout.preferredWidth: 150 + text: model.studentName + placeholderText: "姓名" + onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdField.text, text, profileClassCombo.currentText) + } + + AppComboBox { + id: profileClassCombo + Layout.preferredWidth: 220 + model: appController.classSelectionOptions + Component.onCompleted: currentIndex = classComboIndex(studentProfileRow.rowClassId) + onModelChanged: currentIndex = classComboIndex(studentProfileRow.rowClassId) + onActivated: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdField.text, profileStudentNameField.text, currentText) + } + + Label { + Layout.preferredWidth: 160 + text: model.faceIds.length > 0 ? model.faceIds : "未绑定人脸" + color: model.faceIds.length > 0 ? successColor : ink500 + font.pixelSize: 12 + elide: Text.ElideRight + } + + Item { + Layout.fillWidth: true + } + SecondaryButton { + text: "删除" + onClicked: { + appController.removeStudentProfile(studentProfileRow.rowIndex); + markMappingsDirty(); + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + visible: phoneLayout + spacing: 8 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + AppTextField { + id: profileStudentIdFieldPhone + Layout.fillWidth: true + text: model.studentId + placeholderText: "学号" + onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, text, profileStudentNameFieldPhone.text, profileClassComboPhone.currentText) + } + + AppTextField { + id: profileStudentNameFieldPhone + Layout.fillWidth: true + text: model.studentName + placeholderText: "姓名" + onEditingFinished: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdFieldPhone.text, text, profileClassComboPhone.currentText) + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + AppComboBox { + id: profileClassComboPhone + Layout.fillWidth: true + model: appController.classSelectionOptions + Component.onCompleted: currentIndex = classComboIndex(studentProfileRow.rowClassId) + onModelChanged: currentIndex = classComboIndex(studentProfileRow.rowClassId) + onActivated: commitStudentProfile(studentProfileRow.rowIndex, profileStudentIdFieldPhone.text, profileStudentNameFieldPhone.text, currentText) + } + + SecondaryButton { + Layout.preferredWidth: 76 + text: "删除" + onClicked: { + appController.removeStudentProfile(studentProfileRow.rowIndex); + markMappingsDirty(); + } + } + } + + Label { + Layout.fillWidth: true + text: model.faceIds.length > 0 ? "绑定人脸: " + model.faceIds : "未绑定人脸" + color: model.faceIds.length > 0 ? successColor : ink500 + font.pixelSize: 12 + elide: Text.ElideRight + } + } + } + } + } + + 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("", ""); + markMappingsDirty(); + } + } + + Item { + Layout.fillWidth: true + } + } + + delegate: Rectangle { + width: ListView.view.width + height: phoneLayout ? 112 : 56 + property bool hovered: classMappingHoverHandler.hovered + radius: 8 + 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); + markMappingsDirty(); + } + } + + AppTextField { + id: classValueField + Layout.preferredWidth: 260 + text: model.entryValue + placeholderText: "班级名称" + onEditingFinished: { + appController.classMappingModel.updateEntry(index, classKeyField.text, text); + markMappingsDirty(); + } + } + + Item { + Layout.fillWidth: true + } + SecondaryButton { + text: "删除" + onClicked: { + appController.classMappingModel.removeEntry(index); + markMappingsDirty(); + } + } + } + + 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); + markMappingsDirty(); + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + AppTextField { + id: classValueFieldPhone + Layout.fillWidth: true + text: model.entryValue + placeholderText: "班级名称" + onEditingFinished: { + appController.classMappingModel.updateEntry(index, classKeyFieldPhone.text, text); + markMappingsDirty(); + } + } + + SecondaryButton { + Layout.preferredWidth: 76 + text: "删除" + onClicked: { + appController.classMappingModel.removeEntry(index); + markMappingsDirty(); + } + } + } + } + } + } + } + } + } + } + + 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 ? 462 : 424 + model: appController.faceModel + + delegate: Rectangle { + width: Math.min(phoneLayout ? 300 : 244, GridView.view.cellWidth - 18) + height: phoneLayout ? 438 : 400 + 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 ? 128 : (compactLayout ? 132 : 148) + radius: 8 + 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.updateFaceStudentId(index, text) + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Label { + Layout.fillWidth: true + text: model.studentName.length > 0 ? "姓名: " + model.studentName : "姓名: 未建档" + color: model.studentName.length > 0 ? ink700 : dangerColor + font.pixelSize: 12 + elide: Text.ElideRight + } + + Label { + Layout.fillWidth: true + text: model.className.length > 0 ? "班级: " + model.className : "班级: 未设置" + color: model.className.length > 0 ? ink700 : ink500 + font.pixelSize: 12 + elide: Text.ElideRight + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 34 + radius: 8 + color: model.studentId.length === 0 ? "#f9ebcf" : (model.studentName.length > 0 ? "#e6f4ec" : "#fce8e5") + + Label { + anchors.centerIn: parent + text: model.studentId.length === 0 ? "未绑定学生" : (model.studentName.length > 0 ? "已绑定学生档案" : "学号未建档") + color: model.studentId.length === 0 ? goldColor : (model.studentName.length > 0 ? successColor : dangerColor) + font.pixelSize: narrowLayout ? 11 : 12 + font.weight: appBoldWeight + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + radius: 8 + color: model.hasPhoto ? "#eef5f4" : "#f9ebcf" + + Label { + anchors.centerIn: parent + text: model.hasPhoto ? "照片已同步" : "无照片" + color: model.hasPhoto ? tealColor : 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 + + ScrollView { + id: aboutScrollView + anchors.fill: parent + anchors.margins: phoneLayout ? 16 : 24 + clip: true + contentWidth: availableWidth + contentHeight: aboutContentLayout.height + + ColumnLayout { + id: aboutContentLayout + width: aboutScrollView.availableWidth + height: Math.max(aboutScrollView.availableHeight, implicitHeight) + 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 + Layout.minimumHeight: 0 + Layout.preferredHeight: 0 + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: thesisInfoTitle.implicitHeight + thesisInfoBody.paintedHeight + thesisInfoLayout.spacing + 32 + Layout.minimumHeight: phoneLayout ? 204 : 184 + radius: 8 + color: "#f8fafb" + border.color: lineColor + clip: false + + ColumnLayout { + id: thesisInfoLayout + anchors.fill: parent + anchors.margins: 16 + spacing: 8 + + Label { + id: thesisInfoTitle + Layout.fillWidth: true + text: "毕业设计说明" + color: ink900 + font.pixelSize: 16 + font.weight: appBoldWeight + } + + Label { + id: thesisInfoBody + Layout.fillWidth: true + Layout.preferredHeight: paintedHeight + text: "本系统为纪田野的个人毕业设计作品,仅用于毕业设计展示、学习研究与系统功能验证。\n\nCopyright © 2026 纪田野。保留所有权利。\n\n仓库地址:https://git.yzfh.eu.cc/yzfh/qt.git\n\n演示账号:jtybishe 密码:jtybishe" + wrapMode: Text.WrapAnywhere + color: ink700 + font.pixelSize: 12 + lineHeight: 1.25 + } + } + } + } + + ScrollBar.vertical: AppScrollBar {} + } + } + } + } + + 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/src/ApiClient.cpp b/src/ApiClient.cpp new file mode 100644 index 0000000..dcb8b48 --- /dev/null +++ b/src/ApiClient.cpp @@ -0,0 +1,704 @@ +#include "ApiClient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "CryptoUtils.h" + +namespace { +constexpr int NetworkTimeoutMs = 5000; +} + +ApiClient::ApiClient(QObject *parent) + : QObject(parent) + , m_serverUrl("http://127.0.0.1:8082") + , m_apiToken(QProcessEnvironment::systemEnvironment() + .value(QStringLiteral("ATTENDANCE_API_TOKEN"))) +{ +} + +void ApiClient::setServerUrl(const QString &url) +{ + const QString trimmed = url.trimmed(); + if (!trimmed.isEmpty()) { + m_serverUrl = trimmed; + } +} + +QString ApiClient::serverUrl() const +{ + return m_serverUrl; +} + +void ApiClient::setApiToken(const QString &token) +{ + const QString trimmed = token.trimmed(); + m_apiToken = trimmed.isEmpty() + ? QProcessEnvironment::systemEnvironment() + .value(QStringLiteral("ATTENDANCE_API_TOKEN")) + : trimmed; +} + +QString ApiClient::apiToken() const +{ + return m_apiToken; +} + +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"); + } + request.setRawHeader("X-Api-Token", m_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 studentClassMap; + 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 studentClassObject = mappingConfig.value("student_class_map").toObject(); + for (auto it = studentClassObject.begin(); it != studentClassObject.end(); ++it) { + studentClassMap[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, studentClassMap, 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 studentClassMap; + 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 studentClassObject = root.value("student_class_map").toObject(); + for (auto it = studentClassObject.begin(); it != studentClassObject.end(); ++it) { + studentClassMap[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, studentClassMap, classMap); + reply->deleteLater(); + }); +} + +void ApiClient::uploadMappingConfig(const QMap &studentNameMap, + const QMap &studentClassMap, + const QMap &classNameMap) +{ + QJsonObject studentObject; + for (auto it = studentNameMap.begin(); it != studentNameMap.end(); ++it) { + studentObject[it.key()] = it.value(); + } + + QJsonObject studentClassObject; + for (auto it = studentClassMap.begin(); it != studentClassMap.end(); ++it) { + studentClassObject[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["student_class_map"] = studentClassObject; + 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/src/ApiClient.h b/src/ApiClient.h new file mode 100644 index 0000000..9d3b7fc --- /dev/null +++ b/src/ApiClient.h @@ -0,0 +1,80 @@ +#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 setApiToken(const QString &token); + QString apiToken() 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 &studentClassMap, + 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 &studentClassMap, + 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; + QString m_apiToken; +}; + +#endif diff --git a/src/AppController.cpp b/src/AppController.cpp new file mode 100644 index 0000000..a6a6fec --- /dev/null +++ b/src/AppController.cpp @@ -0,0 +1,1331 @@ +#include "AppController.h" + +#include +#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; +} + +QString displayClassOption(const QString &classId, const QString &className) +{ + const QString trimmedId = classId.trimmed(); + const QString trimmedName = className.trimmed(); + if (trimmedId.isEmpty()) { + return {}; + } + if (trimmedName.isEmpty() || trimmedName == trimmedId) { + return trimmedId; + } + return QStringLiteral("%1 - %2").arg(trimmedId, trimmedName); +} + +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_apiClient.setApiToken(m_apiToken); + m_reconnectTimer.setSingleShot(true); + + connect(&m_reconnectTimer, &QTimer::timeout, this, [this]() { + runReconnectCheck(); + }); + + auto refreshDerivedMappingViews = [this]() { + rebuildStudentProfiles(); + mergeFaceItems(); + updateFilterOptions(); + rebuildFilteredRecords(); + rebuildStatistics(); + }; + connect(&m_classMappingModel, &QAbstractItemModel::modelReset, this, refreshDerivedMappingViews); + connect(&m_classMappingModel, &QAbstractItemModel::dataChanged, this, refreshDerivedMappingViews); + connect(&m_classMappingModel, &QAbstractItemModel::rowsInserted, this, refreshDerivedMappingViews); + connect(&m_classMappingModel, &QAbstractItemModel::rowsRemoved, this, refreshDerivedMappingViews); + + 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 &studentClassMap, + const QMap &classNameMap) { + m_studentMappingModel.setMap(studentNameMap); + m_studentClassMappingModel.setMap(studentClassMap); + m_classMappingModel.setMap(classNameMap); + rebuildStudentProfiles(); + saveMappingsToLocal(); + updateFilterOptions(); + 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(); + rebuildStudentProfiles(); + }); + + 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::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; } +StudentProfileListModel *AppController::studentProfileModel() { return &m_studentProfileModel; } +MappingListModel *AppController::studentMappingModel() { return &m_studentMappingModel; } +MappingListModel *AppController::studentClassMappingModel() { return &m_studentClassMappingModel; } +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; } +QStringList AppController::classSelectionOptions() const +{ + QStringList options; + const QMap classMap = m_classMappingModel.toMap(); + for (auto it = classMap.cbegin(); it != classMap.cend(); ++it) { + const QString option = displayClassOption(it.key(), it.value()); + if (!option.isEmpty()) { + options.push_back(option); + } + } + return options; +} +QString AppController::serverUrl() const { return m_serverUrl; } +QString AppController::apiToken() const { return m_apiToken; } +QString AppController::studentProfileSearchText() const { return m_studentProfileSearchText; } +QString AppController::studentProfileClassFilter() const { return m_studentProfileClassFilter; } +QString AppController::studentProfileBindingFilter() const { return m_studentProfileBindingFilter; } +QStringList AppController::studentProfileClassFilterOptions() const +{ + QStringList options = {QStringLiteral("全部")}; + options.append(classSelectionOptions()); + return options; +} +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::setApiToken(const QString &token) +{ + const QString trimmed = token.trimmed(); + if (trimmed.isEmpty() || trimmed == m_apiToken) { + return; + } + + m_apiToken = trimmed; + m_apiClient.setApiToken(trimmed); + saveApiToken(); + emit apiTokenChanged(); + stopReconnect(); + setStatusMessage("API Token 已更新,正在检查连接"); + m_reconnecting = true; + emit reconnectStateChanged(); + runReconnectCheck(); +} + +void AppController::saveServerConfig(const QString &url, const QString &token) +{ + const QString trimmedUrl = url.trimmed(); + const QString trimmedToken = token.trimmed(); + bool changed = false; + + if (!trimmedUrl.isEmpty() && trimmedUrl != m_serverUrl) { + m_serverUrl = trimmedUrl; + m_qtSyncVersion = 0; + m_apiClient.setServerUrl(trimmedUrl); + saveServerUrl(); + emit serverUrlChanged(); + changed = true; + } + + if (!trimmedToken.isEmpty() && trimmedToken != m_apiToken) { + m_apiToken = trimmedToken; + m_apiClient.setApiToken(trimmedToken); + saveApiToken(); + emit apiTokenChanged(); + changed = true; + } + + if (!changed) { + setStatusMessage("服务器配置未变化"); + return; + } + + stopReconnect(); + setStatusMessage("服务器配置已保存,正在检查连接"); + m_reconnecting = true; + emit reconnectStateChanged(); + runReconnectCheck(); +} + +void AppController::setStudentProfileSearchText(const QString &text) +{ + const QString trimmed = text.trimmed(); + if (trimmed == m_studentProfileSearchText) { + return; + } + m_studentProfileSearchText = trimmed; + emit studentProfileFilterChanged(); +} + +void AppController::setStudentProfileClassFilter(const QString &classFilter) +{ + const QString normalized = classFilter.trimmed().isEmpty() ? QStringLiteral("全部") : classFilter.trimmed(); + if (normalized == m_studentProfileClassFilter) { + return; + } + m_studentProfileClassFilter = normalized; + emit studentProfileFilterChanged(); +} + +void AppController::setStudentProfileBindingFilter(const QString &bindingFilter) +{ + const QString normalized = bindingFilter.trimmed().isEmpty() ? QStringLiteral("全部") : bindingFilter.trimmed(); + if (normalized == m_studentProfileBindingFilter) { + return; + } + m_studentProfileBindingFilter = normalized; + emit studentProfileFilterChanged(); +} + +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(effectiveClassId(record)) << "," + << 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() +{ + QString validationMessage; + if (!validateMappings(&validationMessage)) { + setStatusMessage(validationMessage); + return; + } + + applyStudentProfilesToMappings(); + if (!validateFaceMappings(&validationMessage)) { + setStatusMessage(validationMessage); + rebuildStudentProfiles(); + mergeFaceItems(); + return; + } + + saveMappingsToLocal(); + m_apiClient.uploadMappingConfig(m_studentMappingModel.toMap(), + m_studentClassMappingModel.toMap(), + m_classMappingModel.toMap()); + m_apiClient.uploadFaceIdMapping(m_faceIdMapping); + rebuildStudentProfiles(); + mergeFaceItems(); + updateFilterOptions(); + rebuildFilteredRecords(); + rebuildStatistics(); +} + +void AppController::addStudentProfile() +{ + m_studentProfileModel.addProfile({}, {}, {}); + emit studentProfilesChanged(); +} + +void AppController::updateStudentProfile(int index, + const QString &studentId, + const QString &studentName, + const QString &classId) +{ + const QVector profiles = m_studentProfileModel.profiles(); + const QString oldStudentId = (index >= 0 && index < profiles.size()) + ? profiles.at(index).studentId.trimmed() + : QString(); + const QString newStudentId = studentId.trimmed(); + + m_studentProfileModel.updateProfile(index, studentId, studentName, classId); + if (!oldStudentId.isEmpty() && !newStudentId.isEmpty() && oldStudentId != newStudentId) { + bool changed = false; + for (auto it = m_faceIdMapping.begin(); it != m_faceIdMapping.end(); ++it) { + if (it.value().trimmed() == oldStudentId) { + it.value() = newStudentId; + changed = true; + } + } + if (changed) { + mergeFaceItems(); + } + } + emit studentProfilesChanged(); +} + +void AppController::removeStudentProfile(int index) +{ + const QVector profiles = m_studentProfileModel.profiles(); + const QString removedStudentId = (index >= 0 && index < profiles.size()) + ? profiles.at(index).studentId.trimmed() + : QString(); + + m_studentProfileModel.removeProfile(index); + if (!removedStudentId.isEmpty()) { + bool changed = false; + for (auto it = m_faceIdMapping.begin(); it != m_faceIdMapping.end(); ++it) { + if (it.value().trimmed() == removedStudentId) { + it.value().clear(); + changed = true; + } + } + if (changed) { + mergeFaceItems(); + } + } + emit studentProfilesChanged(); +} + +QString AppController::classDisplayForId(const QString &classId) const +{ + const QString trimmed = classId.trimmed(); + if (trimmed.isEmpty()) { + return {}; + } + return displayClassOption(trimmed, m_classMappingModel.toMap().value(trimmed)); +} + +QString AppController::classIdFromDisplay(const QString &display) const +{ + const QString trimmed = display.trimmed(); + const int separator = trimmed.indexOf(QStringLiteral(" - ")); + return separator > 0 ? trimmed.left(separator).trimmed() : trimmed; +} + +void AppController::refreshFaces() +{ + m_apiClient.fetchFaceIdMapping(); + m_apiClient.fetchFacePhotos(); +} + +void AppController::updateFaceStudentId(int index, const QString &studentId) +{ + const QVector items = m_faceModel.items(); + if (index < 0 || index >= items.size()) { + return; + } + + const QString faceId = items.at(index).faceId.trimmed(); + const QString trimmedStudentId = studentId.trimmed(); + if (!faceId.isEmpty()) { + if (trimmedStudentId.isEmpty()) { + m_faceIdMapping.remove(faceId); + } else { + m_faceIdMapping[faceId] = trimmedStudentId; + } + } + + m_faceModel.updateStudentId(index, studentId); + mergeFaceItems(); + rebuildStudentProfiles(); +} + +void AppController::saveFaceMappings() +{ + QString validationMessage; + if (!validateFaceMappings(&validationMessage)) { + setStatusMessage(validationMessage); + mergeFaceItems(); + return; + } + + 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; + } + + mapping[faceId] = studentId; + } + m_faceIdMapping = mapping; + mergeFaceItems(); + rebuildStudentProfiles(); + 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_apiToken = settings.value("server/apiToken", + QProcessEnvironment::systemEnvironment() + .value(QStringLiteral("ATTENDANCE_API_TOKEN"))).toString(); + m_qtSyncVersion = 0; + loadMappingsFromLocal(); + loadTimeSlotsFromLocal(); +} + +void AppController::saveServerUrl() +{ + QSettings settings; + settings.setValue("server/url", m_serverUrl); +} + +void AppController::saveApiToken() +{ + QSettings settings; + settings.setValue("server/apiToken", m_apiToken); +} + +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(); + } + + QJsonObject studentClassObject; + const QMap studentClassMap = m_studentClassMappingModel.toMap(); + for (auto it = studentClassMap.begin(); it != studentClassMap.end(); ++it) { + studentClassObject[it.key()] = it.value(); + } + + root["student_name_map"] = studentObject; + root["student_class_map"] = studentClassObject; + 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_studentClassMappingModel.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(); + } + + QMap studentClassMap; + const QJsonObject studentClassObject = root.value("student_class_map").toObject(); + for (auto it = studentClassObject.begin(); it != studentClassObject.end(); ++it) { + studentClassMap[it.key()] = it.value().toString(); + } + + m_studentMappingModel.setMap(studentMap); + m_studentClassMappingModel.setMap(studentClassMap); + m_classMappingModel.setMap(classMap); + rebuildStudentProfiles(); +} + +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(effectiveClassId(record)); + 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::rebuildStudentProfiles() +{ + const QMap studentNameMap = m_studentMappingModel.toMap(); + const QMap studentClassMap = m_studentClassMappingModel.toMap(); + const QMap classNameMap = m_classMappingModel.toMap(); + + QSet studentIds; + for (auto it = studentNameMap.cbegin(); it != studentNameMap.cend(); ++it) { + studentIds.insert(it.key()); + } + for (auto it = studentClassMap.cbegin(); it != studentClassMap.cend(); ++it) { + studentIds.insert(it.key()); + } + for (auto it = m_faceIdMapping.cbegin(); it != m_faceIdMapping.cend(); ++it) { + if (!it.value().trimmed().isEmpty()) { + studentIds.insert(it.value().trimmed()); + } + } + for (const AttendanceRecord &record : m_allRecords) { + if (!record.studentId.trimmed().isEmpty()) { + studentIds.insert(record.studentId.trimmed()); + } + } + + QStringList sortedIds = studentIds.values(); + std::sort(sortedIds.begin(), sortedIds.end(), [](const QString &left, const QString &right) { + bool leftNumeric = false; + bool rightNumeric = false; + const int leftValue = left.toInt(&leftNumeric); + const int rightValue = right.toInt(&rightNumeric); + if (leftNumeric && rightNumeric) { + return leftValue < rightValue; + } + return left < right; + }); + + QVector items; + items.reserve(sortedIds.size()); + for (const QString &studentId : sortedIds) { + const QString classId = studentClassMap.value(studentId); + const QString className = classNameMap.value(classId, classId); + const QString faceIds = faceBindingTextForStudent(studentId); + const QString bindingStatus = faceIds.isEmpty() ? QStringLiteral("未绑定") : QStringLiteral("已绑定"); + + items.push_back({ + studentId, + studentNameMap.value(studentId), + classId, + className, + faceIds, + bindingStatus + }); + } + + m_studentProfileModel.setProfiles(items); + emit studentProfilesChanged(); +} + +void AppController::applyStudentProfilesToMappings() +{ + QMap studentNameMap; + QMap studentClassMap; + const QVector profiles = m_studentProfileModel.profiles(); + for (const StudentProfileListModel::Item &profile : profiles) { + const QString studentId = profile.studentId.trimmed(); + if (studentId.isEmpty()) { + continue; + } + studentNameMap[studentId] = profile.studentName.trimmed(); + studentClassMap[studentId] = profile.classId.trimmed(); + } + + m_studentMappingModel.setMap(studentNameMap); + m_studentClassMappingModel.setMap(studentClassMap); +} + +bool AppController::validateMappings(QString *message) const +{ + const QMap classMap = m_classMappingModel.toMap(); + for (auto it = classMap.cbegin(); it != classMap.cend(); ++it) { + if (it.value().trimmed().isEmpty()) { + if (message) { + *message = QStringLiteral("班级 %1 的名称不能为空").arg(it.key()); + } + return false; + } + } + + QSet studentIds; + const QVector profiles = m_studentProfileModel.profiles(); + for (int i = 0; i < profiles.size(); ++i) { + const StudentProfileListModel::Item &profile = profiles.at(i); + const QString rowText = QStringLiteral("第 %1 行").arg(i + 1); + const QString studentId = profile.studentId.trimmed(); + const QString studentName = profile.studentName.trimmed(); + const QString classId = profile.classId.trimmed(); + + if (studentId.isEmpty() && studentName.isEmpty() && classId.isEmpty()) { + continue; + } + if (studentId.isEmpty()) { + if (message) { + *message = rowText + QStringLiteral("学生档案的学号不能为空"); + } + return false; + } + if (studentName.isEmpty()) { + if (message) { + *message = QStringLiteral("学号 %1 的姓名不能为空").arg(studentId); + } + return false; + } + if (classId.isEmpty()) { + if (message) { + *message = QStringLiteral("学号 %1 的班级不能为空").arg(studentId); + } + return false; + } + if (!classMap.contains(classId)) { + if (message) { + *message = QStringLiteral("学号 %1 选择的班级 %2 不存在").arg(studentId, classId); + } + return false; + } + if (studentIds.contains(studentId)) { + if (message) { + *message = QStringLiteral("学号 %1 重复,请合并后再保存").arg(studentId); + } + return false; + } + studentIds.insert(studentId); + } + + return true; +} + +bool AppController::validateFaceMappings(QString *message) const +{ + const QMap studentMap = m_studentMappingModel.toMap(); + QSet faceIds; + 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()) { + if (message) { + *message = QStringLiteral("存在空的人脸编号,无法保存"); + } + return false; + } + if (faceIds.contains(faceId)) { + if (message) { + *message = QStringLiteral("人脸编号 %1 重复,无法保存").arg(faceId); + } + return false; + } + faceIds.insert(faceId); + if (studentId.isEmpty()) { + continue; + } + if (!studentMap.contains(studentId)) { + if (message) { + *message = QStringLiteral("人脸 %1 绑定的学号 %2 未建立学生档案").arg(faceId, studentId); + } + return false; + } + } + + return true; +} + +QString AppController::faceBindingTextForStudent(const QString &studentId) const +{ + QStringList faceIds; + const QString trimmedStudentId = studentId.trimmed(); + for (auto it = m_faceIdMapping.cbegin(); it != m_faceIdMapping.cend(); ++it) { + if (it.value().trimmed() == trimmedStudentId) { + faceIds.push_back(it.key()); + } + } + std::sort(faceIds.begin(), faceIds.end()); + return faceIds.join(QStringLiteral(", ")); +} + +void AppController::rebuildFilteredRecords() +{ + QVector items; + m_filteredRecords.clear(); + const QDate today = QDate::currentDate(); + + for (const AttendanceRecord &record : m_allRecords) { + const QString classId = effectiveClassId(record); + if (m_selectedClassId != "全部" && 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, + effectiveClassId(record), + 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::effectiveClassId(const AttendanceRecord &record) const +{ + return m_studentClassMappingModel.toMap().value(record.studentId, record.classId); +} + +QString AppController::mappedClassName(const AttendanceRecord &record) const +{ + const QString classId = effectiveClassId(record); + return m_classMappingModel.toMap().value(classId, classId); +} + +void AppController::mergeFaceItems() +{ + const QMap studentNameMap = m_studentMappingModel.toMap(); + const QMap studentClassMap = m_studentClassMappingModel.toMap(); + const QMap classNameMap = m_classMappingModel.toMap(); + + QSet allIds; + for (const QString &id : m_facePhotoIds) { + 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(); + const QString studentId = m_faceIdMapping.value(id); + const QString classId = studentClassMap.value(studentId); + const QString className = classNameMap.value(classId, classId); + const bool hasStudent = !studentId.trimmed().isEmpty(); + items.push_back({ + id, + studentId, + hasPhoto, + photoUrl, + studentNameMap.value(studentId), + classId, + className, + hasStudent ? QStringLiteral("已绑定") : QStringLiteral("未绑定") + }); + } + + 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/src/AppController.h b/src/AppController.h new file mode 100644 index 0000000..b399db6 --- /dev/null +++ b/src/AppController.h @@ -0,0 +1,201 @@ +#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 "StudentProfileListModel.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(StudentProfileListModel* studentProfileModel READ studentProfileModel CONSTANT) + Q_PROPERTY(MappingListModel* studentMappingModel READ studentMappingModel CONSTANT) + Q_PROPERTY(MappingListModel* studentClassMappingModel READ studentClassMappingModel 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(QStringList classSelectionOptions READ classSelectionOptions NOTIFY studentProfilesChanged) + Q_PROPERTY(QString serverUrl READ serverUrl WRITE setServerUrl NOTIFY serverUrlChanged) + Q_PROPERTY(QString apiToken READ apiToken WRITE setApiToken NOTIFY apiTokenChanged) + Q_PROPERTY(QString studentProfileSearchText READ studentProfileSearchText WRITE setStudentProfileSearchText NOTIFY studentProfileFilterChanged) + Q_PROPERTY(QString studentProfileClassFilter READ studentProfileClassFilter WRITE setStudentProfileClassFilter NOTIFY studentProfileFilterChanged) + Q_PROPERTY(QString studentProfileBindingFilter READ studentProfileBindingFilter WRITE setStudentProfileBindingFilter NOTIFY studentProfileFilterChanged) + Q_PROPERTY(QStringList studentProfileClassFilterOptions READ studentProfileClassFilterOptions NOTIFY studentProfilesChanged) + 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(); + StudentProfileListModel *studentProfileModel(); + MappingListModel *studentMappingModel(); + MappingListModel *studentClassMappingModel(); + MappingListModel *classMappingModel(); + FaceListModel *faceModel(); + + QStringList classOptions() const; + QStringList studentOptions() const; + QStringList classSelectionOptions() const; + QString serverUrl() const; + QString apiToken() const; + QString studentProfileSearchText() const; + QString studentProfileClassFilter() const; + QString studentProfileBindingFilter() const; + QStringList studentProfileClassFilterOptions() 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); + void setApiToken(const QString &token); + void setStudentProfileSearchText(const QString &text); + void setStudentProfileClassFilter(const QString &classFilter); + void setStudentProfileBindingFilter(const QString &bindingFilter); + + 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 addStudentProfile(); + Q_INVOKABLE void updateStudentProfile(int index, + const QString &studentId, + const QString &studentName, + const QString &classId); + Q_INVOKABLE void removeStudentProfile(int index); + Q_INVOKABLE QString classDisplayForId(const QString &classId) const; + Q_INVOKABLE QString classIdFromDisplay(const QString &display) const; + Q_INVOKABLE void refreshFaces(); + Q_INVOKABLE void updateFaceStudentId(int index, const QString &studentId); + Q_INVOKABLE void saveFaceMappings(); + Q_INVOKABLE void deleteFace(const QString &studentId, bool deleteRecords); + Q_INVOKABLE void testConnection(); + Q_INVOKABLE void saveServerConfig(const QString &url, const QString &token); + +signals: + void filtersChanged(); + void serverUrlChanged(); + void apiTokenChanged(); + void statusMessageChanged(); + void reconnectStateChanged(); + void summaryChanged(); + void statisticsChanged(); + void studentProfilesChanged(); + void studentProfileFilterChanged(); + +private: + void loadLocalState(); + void saveServerUrl(); + void saveApiToken(); + void saveMappingsToLocal(); + void loadMappingsFromLocal(); + void saveTimeSlotsToLocal(); + void loadTimeSlotsFromLocal(); + QString localSettingsPath(const QString &fileName) const; + void updateFilterOptions(); + void rebuildStudentProfiles(); + void applyStudentProfilesToMappings(); + bool validateMappings(QString *message) const; + bool validateFaceMappings(QString *message) const; + QString faceBindingTextForStudent(const QString &studentId) const; + void rebuildFilteredRecords(); + void rebuildStatistics(); + QString mappedStudentName(const AttendanceRecord &record) const; + QString effectiveClassId(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; + StudentProfileListModel m_studentProfileModel; + MappingListModel m_studentMappingModel; + MappingListModel m_studentClassMappingModel; + MappingListModel m_classMappingModel; + FaceListModel m_faceModel; + + QVector m_allRecords; + QVector m_filteredRecords; + QStringList m_classOptions; + QStringList m_studentOptions; + QString m_serverUrl; + QString m_apiToken; + QString m_studentProfileSearchText; + QString m_studentProfileClassFilter = "全部"; + QString m_studentProfileBindingFilter = "全部"; + 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; +}; + +#endif diff --git a/src/AttendanceTypes.h b/src/AttendanceTypes.h new file mode 100644 index 0000000..63d2c6f --- /dev/null +++ b/src/AttendanceTypes.h @@ -0,0 +1,54 @@ +#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/src/CryptoUtils.cpp b/src/CryptoUtils.cpp new file mode 100644 index 0000000..fcad252 --- /dev/null +++ b/src/CryptoUtils.cpp @@ -0,0 +1,48 @@ +#include "CryptoUtils.h" +#include "crypto/tiny_aes.hpp" + +namespace { +QByteArray removePkcs7Padding(const QByteArray &data) +{ + if (data.isEmpty()) { + return {}; + } + + const int padding = static_cast(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/src/CryptoUtils.h b/src/CryptoUtils.h new file mode 100644 index 0000000..3763751 --- /dev/null +++ b/src/CryptoUtils.h @@ -0,0 +1,12 @@ +#ifndef CRYPTO_UTILS_H +#define CRYPTO_UTILS_H + +#include + +class CryptoUtils +{ +public: + static QByteArray decryptAes128CbcBase64(const QByteArray &encryptedBase64); +}; + +#endif diff --git a/src/FaceListModel.cpp b/src/FaceListModel.cpp new file mode 100644 index 0000000..4973905 --- /dev/null +++ b/src/FaceListModel.cpp @@ -0,0 +1,93 @@ +#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; + case StudentNameRole: return item.studentName; + case ClassIdRole: return item.classId; + case ClassNameRole: return item.className; + case BindingStatusRole: return item.bindingStatus; + 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"}, + {StudentNameRole, "studentName"}, + {ClassIdRole, "classId"}, + {ClassNameRole, "className"}, + {BindingStatusRole, "bindingStatus"} + }; +} + +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/src/FaceListModel.h b/src/FaceListModel.h new file mode 100644 index 0000000..2b65e82 --- /dev/null +++ b/src/FaceListModel.h @@ -0,0 +1,55 @@ +#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, + StudentNameRole, + ClassIdRole, + ClassNameRole, + BindingStatusRole + }; + + 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; + QString studentName; + QString classId; + QString className; + QString bindingStatus; + }; + + 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/src/MappingListModel.cpp b/src/MappingListModel.cpp new file mode 100644 index 0000000..1ff47f6 --- /dev/null +++ b/src/MappingListModel.cpp @@ -0,0 +1,132 @@ +#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/src/MappingListModel.h b/src/MappingListModel.h new file mode 100644 index 0000000..2a4a5c7 --- /dev/null +++ b/src/MappingListModel.h @@ -0,0 +1,48 @@ +#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/src/RecordListModel.cpp b/src/RecordListModel.cpp new file mode 100644 index 0000000..3850932 --- /dev/null +++ b/src/RecordListModel.cpp @@ -0,0 +1,59 @@ +#include "RecordListModel.h" + +RecordListModel::RecordListModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int RecordListModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_items.size(); +} + +QVariant RecordListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { + return {}; + } + + const Item &item = m_items.at(index.row()); + switch (role) { + case TimestampRole: return item.timestamp; + case ClassIdRole: return item.classId; + case ClassNameRole: return item.className; + case StudentIdRole: return item.studentId; + case NameRole: return item.name; + case ConfidenceRole: return item.confidence; + case ConfidenceTextRole: return QString::number(item.confidence * 100.0, 'f', 2) + "%"; + case StatusRole: return item.status; + case StatusTextRole: return item.success ? QStringLiteral("成功") : QStringLiteral("失败"); + case SuccessRole: return item.success; + default: return {}; + } +} + +QHash 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/src/RecordListModel.h b/src/RecordListModel.h new file mode 100644 index 0000000..d367d5b --- /dev/null +++ b/src/RecordListModel.h @@ -0,0 +1,53 @@ +#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/src/StatisticsListModel.cpp b/src/StatisticsListModel.cpp new file mode 100644 index 0000000..bd10986 --- /dev/null +++ b/src/StatisticsListModel.cpp @@ -0,0 +1,53 @@ +#include "StatisticsListModel.h" + +StatisticsListModel::StatisticsListModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int StatisticsListModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_items.size(); +} + +QVariant StatisticsListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) { + return {}; + } + + const Item &item = m_items.at(index.row()); + switch (role) { + case StudentIdRole: return item.studentId; + case NameRole: return item.name; + case TotalCountRole: return item.totalCount; + case TodayCountRole: return item.todayCount; + case LastCheckInRole: return item.lastCheckIn; + case AverageConfidenceRole: return item.averageConfidence; + case AverageConfidenceTextRole: return QString::number(item.averageConfidence * 100.0, 'f', 2) + "%"; + default: return {}; + } +} + +QHash 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/src/StatisticsListModel.h b/src/StatisticsListModel.h new file mode 100644 index 0000000..0fff392 --- /dev/null +++ b/src/StatisticsListModel.h @@ -0,0 +1,48 @@ +#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/src/StudentProfileListModel.cpp b/src/StudentProfileListModel.cpp new file mode 100644 index 0000000..a1cc2c8 --- /dev/null +++ b/src/StudentProfileListModel.cpp @@ -0,0 +1,116 @@ +#include "StudentProfileListModel.h" + +StudentProfileListModel::StudentProfileListModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int StudentProfileListModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return m_items.size(); +} + +QVariant StudentProfileListModel::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 StudentNameRole: return item.studentName; + case ClassIdRole: return item.classId; + case ClassNameRole: return item.className; + case FaceIdsRole: return item.faceIds; + case BindingStatusRole: return item.bindingStatus; + default: return {}; + } +} + +bool StudentProfileListModel::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 StudentIdRole: item.studentId = value.toString().trimmed(); break; + case StudentNameRole: item.studentName = value.toString().trimmed(); break; + case ClassIdRole: item.classId = value.toString().trimmed(); break; + default: return false; + } + + emit dataChanged(index, index, {role}); + return true; +} + +Qt::ItemFlags StudentProfileListModel::flags(const QModelIndex &index) const +{ + if (!index.isValid()) { + return Qt::NoItemFlags; + } + return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable; +} + +QHash StudentProfileListModel::roleNames() const +{ + return { + {StudentIdRole, "studentId"}, + {StudentNameRole, "studentName"}, + {ClassIdRole, "classId"}, + {ClassNameRole, "className"}, + {FaceIdsRole, "faceIds"}, + {BindingStatusRole, "bindingStatus"} + }; +} + +void StudentProfileListModel::setProfiles(const QVector &items) +{ + beginResetModel(); + m_items = items; + endResetModel(); +} + +QVector StudentProfileListModel::profiles() const +{ + return m_items; +} + +void StudentProfileListModel::addProfile(const QString &studentId, const QString &studentName, const QString &classId) +{ + beginInsertRows(QModelIndex(), m_items.size(), m_items.size()); + m_items.push_back({studentId.trimmed(), studentName.trimmed(), classId.trimmed(), {}, {}, {}}); + endInsertRows(); +} + +void StudentProfileListModel::updateProfile(int index, + const QString &studentId, + const QString &studentName, + const QString &classId) +{ + if (index < 0 || index >= m_items.size()) { + return; + } + + Item &item = m_items[index]; + item.studentId = studentId.trimmed(); + item.studentName = studentName.trimmed(); + item.classId = classId.trimmed(); + emit dataChanged(this->index(index), this->index(index), {StudentIdRole, StudentNameRole, ClassIdRole}); +} + +void StudentProfileListModel::removeProfile(int index) +{ + if (index < 0 || index >= m_items.size()) { + return; + } + + beginRemoveRows(QModelIndex(), index, index); + m_items.removeAt(index); + endRemoveRows(); +} diff --git a/src/StudentProfileListModel.h b/src/StudentProfileListModel.h new file mode 100644 index 0000000..5098bdb --- /dev/null +++ b/src/StudentProfileListModel.h @@ -0,0 +1,54 @@ +#ifndef STUDENT_PROFILE_LIST_MODEL_H +#define STUDENT_PROFILE_LIST_MODEL_H + +#include +#include +#include +#include +#include +#include +#include + +class StudentProfileListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + StudentIdRole = Qt::UserRole + 1, + StudentNameRole, + ClassIdRole, + ClassNameRole, + FaceIdsRole, + BindingStatusRole + }; + + struct Item { + QString studentId; + QString studentName; + QString classId; + QString className; + QString faceIds; + QString bindingStatus; + }; + + explicit StudentProfileListModel(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 setProfiles(const QVector &items); + QVector profiles() const; + + void addProfile(const QString &studentId, const QString &studentName, const QString &classId); + void updateProfile(int index, const QString &studentId, const QString &studentName, const QString &classId); + void removeProfile(int index); + +private: + QVector m_items; +}; + +#endif diff --git a/src/TimeSlotListModel.cpp b/src/TimeSlotListModel.cpp new file mode 100644 index 0000000..98e7919 --- /dev/null +++ b/src/TimeSlotListModel.cpp @@ -0,0 +1,146 @@ +#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/src/TimeSlotListModel.h b/src/TimeSlotListModel.h new file mode 100644 index 0000000..1c5e07e --- /dev/null +++ b/src/TimeSlotListModel.h @@ -0,0 +1,48 @@ +#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/src/crypto/tiny_aes.hpp b/src/crypto/tiny_aes.hpp new file mode 100644 index 0000000..dfb2da5 --- /dev/null +++ b/src/crypto/tiny_aes.hpp @@ -0,0 +1,217 @@ +/* + * 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