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" // 全局设计变量:颜色、字体和响应式断点集中定义,后续页面和组件只引用这些变量。 // 这样桌面端与 Android 端能保持统一风格,也便于毕业设计截图统一视觉效果。 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 int pendingStudentIdChangeIndex: -1 property string pendingStudentIdChangeNewId: "" property string pendingStudentIdChangeName: "" property string pendingStudentIdChangeClassDisplay: "" property string editingFaceId: "" property string editingFaceOriginalStudentId: "" 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 // 窗口拖拽缩放时先标记 resizingWindow,部分复杂列表和动画可据此降低刷新频率。 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 是本文件内部的轻量设计系统:输入框、按钮、空状态、标题等 // 都在这里统一样式,主页面只负责组合业务内容。 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 } } // 函数作用:把记录筛选栏的当前条件提交给 C++ 后端。 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); // 预设只负责改写日期输入框,真正的统计重算仍统一交给 C++ 控制器完成。 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); } // 函数作用:打开时间段编辑弹窗,index 为 -1 时表示新增。 function openTimeSlotDialog(index) { editingTimeSlotIndex = index; if (index >= 0) { // 编辑模式从模型读取当前行,避免 QML delegate 中维护重复状态。 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(); } // 函数作用:保存时间段弹窗中的内容,并同步到 C++ 后端。 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; } // 函数作用:在普通选项数组中查找指定值的下标,找不到时返回 0。 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 requestStudentProfileCommit(index, studentId, studentName, classDisplay) { var impactMessage = appController.studentIdChangeImpactMessage(index, studentId); if (impactMessage.length > 0) { // 学号变更会连带人脸绑定和历史记录显示,先暂存待提交值,等确认弹窗回调再真正保存。 pendingStudentIdChangeIndex = index; pendingStudentIdChangeNewId = studentId; pendingStudentIdChangeName = studentName; pendingStudentIdChangeClassDisplay = classDisplay; studentIdChangeDialog.text = impactMessage; studentIdChangeDialog.open(); return; } commitStudentProfile(index, studentId, studentName, classDisplay); } // 函数作用:判断某条学生档案是否符合搜索、班级和绑定状态过滤条件。 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; } // 函数作用:打开人脸档案编辑弹窗,并填充当前绑定信息。 function openFaceEditor(faceId, studentId, studentName, classId) { editingFaceId = faceId; editingFaceOriginalStudentId = studentId; faceEditStudentIdField.text = studentId; faceEditStudentNameField.text = studentName; faceEditClassCombo.currentIndex = classComboIndex(classId); faceEditDialog.open(); } // 函数作用:直接保存人脸编辑弹窗中的绑定信息。 function saveFaceEditorNow() { var savedLocally = appController.editFaceProfile(editingFaceId, faceEditStudentIdField.text, faceEditStudentNameField.text, appController.classIdFromDisplay(faceEditClassCombo.currentText)); if (!savedLocally) { faceEditDialog.open(); return; } markMappingsDirty(); appController.saveFaceMappings(); } // 函数作用:保存人脸编辑前检查绑定学号变化是否需要确认。 function requestSaveFaceEditor() { var oldId = editingFaceOriginalStudentId ? editingFaceOriginalStudentId.trim() : ""; var newId = faceEditStudentIdField.text ? faceEditStudentIdField.text.trim() : ""; if (oldId.length > 0 && oldId !== newId) { // 已绑定人脸改到另一个学号时影响范围较大,交给确认弹窗完成最后一次用户确认。 faceStudentIdChangeDialog.text = "修改该人脸绑定会影响识别后发送给 STM32 的学号、考勤记录显示、班级筛选和导出结果。确定继续吗?"; faceStudentIdChangeDialog.open(); return; } saveFaceEditorNow(); } // 函数作用:页面加载完成后初始化默认记录筛选和统计日期区间。 Component.onCompleted: { applyRecordFilters(); applyStatisticsPreset(); } Connections { target: appController // 函数作用:监听 C++ 状态消息变化,并转换为右下角 toast 提示。 function onStatusMessageChanged() { if (!appController.statusMessage || appController.statusMessage.length === 0) { return; } // 后端保存成功后清除脏标记,界面不再提示用户有未保存配置。 if (appController.statusMessage.indexOf("映射配置已保存") >= 0 || 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)"] defaultSuffix: "csv" onAccepted: appController.exportFilteredRecords(selectedFile) } FileDialog { id: statisticsExportDialog fileMode: FileDialog.SaveFile nameFilters: ["CSV 文件 (*.csv)"] defaultSuffix: "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 // 时间段新增/编辑共用同一个弹窗,通过 editingTimeSlotIndex 判断保存方式。 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: "确定删除人脸编号 " + pendingDeleteFaceId + " 吗?" wrapMode: Text.WordWrap font.pixelSize: 18 font.weight: appBoldWeight color: ink900 } AppCheckBox { id: deleteFaceRecordsCheck text: "同时删除该学生的所有考勤记录" checked: false } } onAccepted: appController.deleteFace(pendingDeleteFaceId, deleteFaceRecordsCheck.checked) } Dialog { id: faceEditDialog // 编辑人脸档案时只改绑定关系和学生资料,不修改 K210 生成的人脸编号。 modal: true anchors.centerIn: Overlay.overlay width: phoneLayout ? Math.min(window.width - 32, 420) : 460 title: "编辑人脸档案" 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 Label { Layout.fillWidth: true text: "人脸编号: " + editingFaceId color: ink900 font.pixelSize: 18 font.weight: appBoldWeight elide: Text.ElideRight } AppTextField { id: faceEditStudentIdField Layout.fillWidth: true placeholderText: "真实学号" } AppTextField { id: faceEditStudentNameField Layout.fillWidth: true placeholderText: "姓名" } AppComboBox { id: faceEditClassCombo Layout.fillWidth: true model: appController.classSelectionOptions } Label { Layout.fillWidth: true text: "人脸编号由 K210 生成,不要改成学号;这里只编辑学号、姓名和班级。留空学号可解除绑定。" wrapMode: Text.WordWrap color: ink500 font.pixelSize: 12 } } onAccepted: requestSaveFaceEditor() } MessageDialog { id: faceStudentIdChangeDialog title: "确认修改绑定学号" buttons: MessageDialog.Yes | MessageDialog.No onAccepted: saveFaceEditorNow() } MessageDialog { id: studentIdChangeDialog title: "确认修改学号" buttons: MessageDialog.Yes | MessageDialog.No onAccepted: { commitStudentProfile(pendingStudentIdChangeIndex, pendingStudentIdChangeNewId, pendingStudentIdChangeName, pendingStudentIdChangeClassDisplay); } } 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 // 桌面侧边栏导航;窄屏时隐藏,改用下方的移动端 TabBar。 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 // 五个主页面共用 StackLayout:考勤记录、统计分析、配置中心、人脸管理、关于。 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 内切换行布局。 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: phoneLayout ? 1 : 4 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() } SecondaryButton { Layout.fillWidth: phoneLayout Layout.preferredWidth: phoneLayout ? -1 : 120 visible: mappingsExpanded text: "新增班级" onClicked: { appController.classMappingModel.addEntry("", ""); markMappingsDirty(); } } Label { Layout.fillWidth: phoneLayout Layout.alignment: Qt.AlignVCenter visible: mappingsExpanded text: mappingsDirty ? "有未保存改动" : "配置已保存" color: mappingsDirty ? goldColor : successColor font.pixelSize: 12 font.weight: appBoldWeight } } } ListView { id: classMappingListView visible: mappingsExpanded Layout.fillWidth: true Layout.preferredHeight: visible ? Math.max(phoneLayout ? 112 : 64, contentHeight) : 0 clip: false interactive: false spacing: 8 model: appController.classMappingModel // 班级列表直接编辑模型,保存按钮再统一把完整配置同步到服务端。 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 // 桌面端让编号和名称保持两列,方便核对 class_id 与 class_name。 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: { if (appController.removeClassMapping(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: { if (appController.removeClassMapping(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 ? 444 : 404 model: appController.faceModel // 人脸卡片展示的是控制器合并后的结果:照片缓存、绑定学号、学生档案和班级名称。 delegate: Rectangle { width: Math.min(phoneLayout ? 300 : 244, GridView.view.cellWidth - 18) height: phoneLayout ? 420 : 380 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" // 照片由 C++ 下载到本地缓存后以 file:// URL 暴露,QML 只负责显示。 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: "人脸编号: " + model.faceId color: ink900 font.pixelSize: compactLayout ? 15 : 16 font.weight: appBoldWeight } ColumnLayout { Layout.fillWidth: true spacing: 4 Label { Layout.fillWidth: true text: model.studentId.length > 0 ? "学号: " + model.studentId : "学号: 未绑定" color: model.studentId.length > 0 ? ink700 : goldColor font.pixelSize: 12 font.weight: model.studentId.length > 0 ? appBoldWeight : Font.Normal elide: Text.ElideRight } 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" : "#f9ebcf") Label { anchors.centerIn: parent text: model.studentId.length === 0 ? "未绑定学生" : (model.studentName.length > 0 ? "已绑定学生档案" : "待补全学生信息") color: model.studentId.length === 0 ? goldColor : (model.studentName.length > 0 ? successColor : goldColor) 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 } RowLayout { Layout.fillWidth: true SecondaryButton { Layout.fillWidth: true text: "编辑" onClicked: openFaceEditor(model.faceId, model.studentId, model.studentName, model.classId) } 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 ColumnLayout { id: aboutContentLayout width: aboutScrollView.availableWidth 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 } 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 } } } }