From fdba798935d87fd2c231bf47d9749b7cd3398eb9 Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 2 Oct 2026 11:42:13 +0800 Subject: [PATCH] feat(web): migrate teacher attendance workflow Add the React attendance console with manual, QR, and location check-in flows, record editing, Excel exchange, submission, risk signals, and course statistics. Preserve server-side task scope and archived-task restrictions. --- web-react/MIGRATION_MATRIX.md | 1 + web-react/README.md | 1 + web-react/package-lock.json | 336 ++++++++++++++++++ web-react/package.json | 3 + web-react/src/app/App.tsx | 2 + web-react/src/layout/AppShell.tsx | 4 + web-react/src/pages/TeacherAttendancePage.tsx | 110 ++++++ web-react/src/pages/teacher-attendance.css | 1 + 8 files changed, 458 insertions(+) create mode 100644 web-react/src/pages/TeacherAttendancePage.tsx create mode 100644 web-react/src/pages/teacher-attendance.css diff --git a/web-react/MIGRATION_MATRIX.md b/web-react/MIGRATION_MATRIX.md index 6abbb13..9260289 100644 --- a/web-react/MIGRATION_MATRIX.md +++ b/web-react/MIGRATION_MATRIX.md @@ -36,6 +36,7 @@ | `/campus-registrations` | 校园支付与报名 | Student | 开放项目、报名、审核状态、撤销、校园余额支付 | verification | | `/approvals` | 我的审批申请 | Student | 免修、缓考、课程替代申请与审核结果 | verification | | `/teacher-roster` | 选课名单 | Teacher | 本人授课教学班、学生联系与特殊支持信息、Excel 导出 | verification | +| `/teacher-attendance` | 教学点名 | Teacher、教务管理、Counselor | 教学班筛选、人工/扫码/定位签到、Excel、提交、风险信号与课程统计 | verification | | `/activate` | 学生账号激活 | 公开 | 学籍选项、身份匹配、强密码和一次性激活 | verification | | `/verify/:verificationCode?` | 电子凭证验真 | 公开 | 唯一编号、实时状态、签发信息和 PDF 哈希 | verification | | `/classroom-repairs/report/:classroomId` | 教室扫码报修 | 公开 | 教室设备、故障类别、联系方式和工单提交 | verification | diff --git a/web-react/README.md b/web-react/README.md index 56d5b2f..1b786fd 100644 --- a/web-react/README.md +++ b/web-react/README.md @@ -41,6 +41,7 @@ npm.cmd run build - 迁移学生实验预约、实验成绩、校园报名缴费及免修/缓考/课程替代申请闭环。 - 迁移公开账号激活、电子凭证验真、教室扫码报修及场地信息屏。 - 迁移教师选课名单工作台,支持按学期查看本人教学班、学生支持信息和 Excel 导出。 +- 迁移教师教学点名工作台,覆盖人工/扫码/定位签到、Excel 导入导出、提交及课程出勤统计。 ## 迁移纪律 diff --git a/web-react/package-lock.json b/web-react/package-lock.json index ddb1eb5..7f9c752 100644 --- a/web-react/package-lock.json +++ b/web-react/package-lock.json @@ -10,9 +10,11 @@ "dependencies": { "@ant-design/icons": "6.3.4", "@capacitor/core": "8.5.0", + "@capacitor/geolocation": "8.2.2", "@tanstack/react-query": "5.104.0", "antd": "6.6.5", "axios": "1.20.0", + "qrcode": "1.5.4", "react": "19.3.0", "react-dom": "19.3.0", "react-router-dom": "7.18.4", @@ -20,6 +22,7 @@ }, "devDependencies": { "@types/node": "26.6.3", + "@types/qrcode": "1.5.6", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", @@ -120,6 +123,24 @@ "tslib": "^2.1.0" } }, + "node_modules/@capacitor/geolocation": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.2.tgz", + "integrity": "sha512-X8ryw6CfL4LspJnnfVL4++DUKKZVfdUIA9JljWVRljEDIQxZQZtz6xf7qstht+ymc+le6oa5EaUChVA6KsCYlA==", + "license": "MIT", + "dependencies": { + "@capacitor/synapse": "^1.0.4" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/synapse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", + "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", + "license": "ISC" + }, "node_modules/@emotion/hash": { "version": "0.8.0", "license": "MIT" @@ -1079,6 +1100,16 @@ "undici-types": "~8.9.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.3.0", "devOptional": true, @@ -1471,6 +1502,30 @@ "node": ">= 6.0.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/antd": { "version": "6.6.5", "license": "MIT", @@ -1558,6 +1613,26 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "license": "MIT", @@ -1565,6 +1640,24 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "license": "MIT", @@ -1613,6 +1706,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "license": "MIT", @@ -1628,6 +1730,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "license": "MIT", @@ -1640,6 +1748,12 @@ "node": ">= 0.4" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "license": "MIT", @@ -1693,6 +1807,19 @@ } } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "funding": [ @@ -1747,6 +1874,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "license": "MIT", @@ -1834,6 +1970,15 @@ "node": ">= 6" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-mobile": { "version": "5.0.0", "license": "MIT" @@ -2114,6 +2259,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", @@ -2159,6 +2316,51 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "dev": true, @@ -2175,6 +2377,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.28", "dev": true, @@ -2209,6 +2420,23 @@ "node": ">=10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "19.3.0", "license": "MIT", @@ -2264,6 +2492,21 @@ "react-dom": ">=18" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rolldown": { "version": "1.2.12", "dev": true, @@ -2307,6 +2550,12 @@ "compute-scroll-into-view": "^3.0.2" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "license": "MIT" @@ -2323,6 +2572,32 @@ "version": "0.2.1", "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stylis": { "version": "4.4.0", "license": "MIT" @@ -2467,6 +2742,67 @@ } } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zustand": { "version": "5.0.15", "license": "MIT", diff --git a/web-react/package.json b/web-react/package.json index 9dd481d..9498967 100644 --- a/web-react/package.json +++ b/web-react/package.json @@ -11,9 +11,11 @@ "dependencies": { "@ant-design/icons": "6.3.4", "@capacitor/core": "8.5.0", + "@capacitor/geolocation": "8.2.2", "@tanstack/react-query": "5.104.0", "antd": "6.6.5", "axios": "1.20.0", + "qrcode": "1.5.4", "react": "19.3.0", "react-dom": "19.3.0", "react-router-dom": "7.18.4", @@ -21,6 +23,7 @@ }, "devDependencies": { "@types/node": "26.6.3", + "@types/qrcode": "1.5.6", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", diff --git a/web-react/src/app/App.tsx b/web-react/src/app/App.tsx index a844ee7..1f6c687 100644 --- a/web-react/src/app/App.tsx +++ b/web-react/src/app/App.tsx @@ -37,6 +37,7 @@ const StudentExperimentGradesPage = lazy(() => import('../pages/StudentExperimen const CampusRegistrationsPage = lazy(() => import('../pages/CampusRegistrationsPage')) const StudentApprovalsPage = lazy(() => import('../pages/StudentApprovalsPage')) const TeacherRosterPage = lazy(() => import('../pages/TeacherRosterPage')) +const TeacherAttendancePage = lazy(() => import('../pages/TeacherAttendancePage')) const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage')) const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage')) const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage')) @@ -116,6 +117,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/web-react/src/layout/AppShell.tsx b/web-react/src/layout/AppShell.tsx index fc21820..a6d69b7 100644 --- a/web-react/src/layout/AppShell.tsx +++ b/web-react/src/layout/AppShell.tsx @@ -34,6 +34,7 @@ import { BankOutlined, ApartmentOutlined, TeamOutlined, + CarryOutOutlined, } from '@ant-design/icons' import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd' import { useQuery } from '@tanstack/react-query' @@ -102,6 +103,9 @@ export default function AppShell() { ...(roles.includes('Teacher') ? [ { key: '/teacher-roster', icon: , label: '选课名单' }, ] : []), + ...(roles.some((role) => ['Teacher', 'SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role)) ? [ + { key: '/teacher-attendance', icon: , label: '教学点名' }, + ] : []), ...(user?.roles.includes('SuperAdmin') ? [{ key: '/users', icon: , label: '用户与权限' }] : []), diff --git a/web-react/src/pages/TeacherAttendancePage.tsx b/web-react/src/pages/TeacherAttendancePage.tsx new file mode 100644 index 0000000..5e474d0 --- /dev/null +++ b/web-react/src/pages/TeacherAttendancePage.tsx @@ -0,0 +1,110 @@ +import { Capacitor } from '@capacitor/core' +import { Geolocation } from '@capacitor/geolocation' +import { CheckOutlined, ClockCircleOutlined, DeleteOutlined, DownloadOutlined, EnvironmentOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, UploadOutlined, WarningOutlined } from '@ant-design/icons' +import { Alert, App, Button, DatePicker, Empty, Form, Input, InputNumber, Modal, Pagination, Radio, Select, Skeleton, Table, Tag } from 'antd' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import dayjs from 'dayjs' +import QRCode from 'qrcode' +import { useEffect, useMemo, useRef, useState } from 'react' +import { downloadApiFile, importExcel } from '../api/files' +import http, { apiErrorMessage } from '../api/http' +import PageHeader from '../components/PageHeader' +import './teacher-attendance.css' + +type Mode = 'rollcall' | 'statistics' +type CheckInMethod = 'Manual' | 'QrCode' | 'Location' +type AttendanceStatus = 'Present' | 'Absent' | 'Late' | 'Leave' | 'Excused' +interface Term { id: string; name: string; isCurrent: boolean; isArchived: boolean; isEnabled: boolean } +interface Paged { items: T[]; total: number; page: number; pageSize: number } +interface Task { id: string; status: string; taskNumber: string; name: string; academicTermId: string; termName: string; courseCode: string; courseName: string; studentCount: number; sheetCount: number } +interface Sheet { id: string; name: string; attendanceDate: string; status: string | number; checkInMethod: string | number; checkInStartsAt?: string; checkInEndsAt?: string; checkedInCount: number; presentCount: number; absentCount: number; lateCount: number; leaveCount: number; excusedCount: number; totalCount: number } +interface RecordRow { studentId: string; studentNumber: string; name: string; className: string; status: AttendanceStatus | number; notes?: string; checkInAt?: string; checkedInMethod?: string | number; checkInDistanceMeters?: number; isExempt: boolean; isDeferred: boolean; attemptCount: number; failedAttemptCount: number; riskFlags: string[]; checkInAudit?: { ipAddress?: string; devicePlatform?: string; deviceCode?: string; sameIpStudentCount: number } } +interface SheetDetail { canEdit: boolean; sheet: Sheet & { taskNumber: string; courseCode: string; courseName: string; locationRadiusMeters?: number; records: RecordRow[]; riskSummary?: { riskStudentCount: number; sharedDeviceStudentCount: number } } } +interface StudentStat { studentId: string; studentNumber: string; studentName: string; className: string; attendanceRate?: number; presentCount: number; absentCount: number; lateCount: number; leaveCount: number; excusedCount: number; requiredCount: number } +interface Statistics { course: { courseName: string; courseCode: string; taskNumber: string; termName: string }; summary: { overallAttendanceRate?: number; studentCount: number; submittedSheetCount: number; perfectAttendanceCount: number; abnormalStudentCount: number; recordCount: number }; statusDistribution: Array<{ status: string | number; label: string; count: number }>; sessions: Array<{ attendanceDate: string; attendanceRate?: number }>; students: StudentStat[] } +interface CreateValues { name: string; attendanceDate: dayjs.Dayjs; checkInMethod: CheckInMethod; checkInDurationMinutes: number; locationRadiusMeters: number } + +const statuses: Array<{ value: AttendanceStatus; label: string }> = [{ value: 'Present', label: '出勤' }, { value: 'Absent', label: '缺勤' }, { value: 'Late', label: '迟到' }, { value: 'Leave', label: '请假' }, { value: 'Excused', label: '免修' }] +const statusNames: Record = { Present: '出勤', Absent: '缺勤', Late: '迟到', Leave: '请假', Excused: '免修', 1: '出勤', 2: '缺勤', 3: '迟到', 4: '请假', 5: '免修' } +const statusValues: Record = { 1: 'Present', 2: 'Absent', 3: 'Late', 4: 'Leave', 5: 'Excused' } +const riskLabels: Record = { SharedDevice: '多人共用设备', HighFrequency: '高频尝试', RepeatedFailures: '多次失败', MissingDeviceId: '缺少设备标识' } +const methodName = (value: string | number) => value === 'QrCode' || value === 2 ? '扫码签到' : value === 'Location' || value === 3 ? '定位签到' : '教师点名' +const online = (value: string | number) => value === 'QrCode' || value === 'Location' || value === 2 || value === 3 +const draft = (value: string | number) => value === 'Draft' || value === 1 +const submitted = (value: string | number) => value === 'Submitted' || value === 2 +const normalizedStatus = (value: AttendanceStatus | number) => typeof value === 'number' ? statusValues[value] : value +const serverTime = (value?: string) => !value ? Number.NaN : new Date(/(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`).getTime() +const rate = (value?: number) => value == null ? '暂无' : `${value.toFixed(1)}%` + +function Trend({ sessions }: { sessions: Statistics['sessions'] }) { + if (!sessions.length) return + const width = Math.max(520, sessions.length * 72), height = 190, pad = 30 + const points = sessions.map((item, index) => `${pad + index * (width - pad * 2) / Math.max(1, sessions.length - 1)},${height - pad - (item.attendanceRate ?? 0) / 100 * (height - pad * 2)}`).join(' ') + return
{[0, 25, 50, 75, 100].map((tick) => {tick}%)}{sessions.map((item, index) => {dayjs(item.attendanceDate).format('M/D')})}
+} + +export default function TeacherAttendancePage() { + const { message, modal } = App.useApp(); const client = useQueryClient(); const [form] = Form.useForm() + const createMethod = Form.useWatch('checkInMethod', form) + const [mode, setMode] = useState('rollcall'); const [termId, setTermId] = useState(); const [taskPage, setTaskPage] = useState(1); const [taskKeyword, setTaskKeyword] = useState(''); const [classKeyword, setClassKeyword] = useState(''); const [taskFilters, setTaskFilters] = useState({ keyword: '', classKeyword: '' }) + const [selectedTaskId, setSelectedTaskId] = useState(); const [sheetPage, setSheetPage] = useState(1); const [sheetKeyword, setSheetKeyword] = useState(''); const [sheetStatus, setSheetStatus] = useState(); const [sheetMethod, setSheetMethod] = useState(); const [selectedSheetId, setSelectedSheetId] = useState(); const [records, setRecords] = useState([]) + const [createOpen, setCreateOpen] = useState(false); const [location, setLocation] = useState<{ latitude: number; longitude: number; accuracy: number }>(); const [locating, setLocating] = useState(false); const [qrOpen, setQrOpen] = useState(false); const [qrImage, setQrImage] = useState(''); const [qrExpires, setQrExpires] = useState(0); const [now, setNow] = useState(Date.now()); const fileRef = useRef(null) + const [studentKeyword, setStudentKeyword] = useState(''); const [classFilter, setClassFilter] = useState(); const [attentionFilter, setAttentionFilter] = useState() + const terms = useQuery({ queryKey: ['attendance-terms'], queryFn: async () => (await http.get('/base-data/terms')).data }) + const tasks = useQuery({ queryKey: ['attendance-tasks', termId ?? 'all', taskFilters, taskPage], enabled: terms.isSuccess, queryFn: async () => (await http.get>('/attendance/my-tasks', { params: { academicTermId: termId || undefined, keyword: taskFilters.keyword || undefined, classKeyword: taskFilters.classKeyword || undefined, page: taskPage, pageSize: 12 } })).data }) + const selectedTask = tasks.data?.items.find((item) => item.id === selectedTaskId) + const sheets = useQuery({ queryKey: ['attendance-sheets', selectedTaskId, sheetKeyword, sheetStatus, sheetMethod, sheetPage], enabled: !!selectedTaskId, queryFn: async () => (await http.get>('/attendance/sheets', { params: { teachingTaskId: selectedTaskId, keyword: sheetKeyword || undefined, status: sheetStatus, checkInMethod: sheetMethod, page: sheetPage, pageSize: 12 } })).data }) + const detail = useQuery({ queryKey: ['attendance-sheet', selectedSheetId], enabled: !!selectedSheetId, queryFn: async () => (await http.get(`/attendance/sheets/${selectedSheetId}`)).data }) + const statistics = useQuery({ queryKey: ['attendance-statistics', selectedTaskId], enabled: mode === 'statistics' && !!selectedTaskId, queryFn: async () => (await http.get(`/attendance/tasks/${selectedTaskId}/statistics`)).data }) + useEffect(() => { if (terms.data && termId === undefined) setTermId(terms.data.find((item) => item.isCurrent)?.id ?? '') }, [termId, terms.data]) + useEffect(() => { if (!tasks.data) return; setSelectedTaskId((value) => tasks.data.items.some((item) => item.id === value) ? value : tasks.data.items[0]?.id) }, [tasks.data]) + useEffect(() => { setSelectedSheetId(undefined); setSheetPage(1); setSheetKeyword(''); setSheetStatus(undefined); setSheetMethod(undefined) }, [selectedTaskId]) + useEffect(() => { if (detail.data) setRecords(detail.data.sheet.records.map((item) => ({ ...item, status: normalizedStatus(item.status) }))) }, [detail.data]) + useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer) }, []) + useEffect(() => { if (!qrOpen || !selectedSheetId) return; const timer = window.setInterval(() => void refreshQr(false), 10000); return () => window.clearInterval(timer) }, [qrOpen, selectedSheetId]) + + const invalidate = async () => { await Promise.all([client.invalidateQueries({ queryKey: ['attendance-sheets'] }), client.invalidateQueries({ queryKey: ['attendance-sheet'] }), client.invalidateQueries({ queryKey: ['attendance-statistics'] }), client.invalidateQueries({ queryKey: ['attendance-tasks'] })]) } + const create = useMutation({ mutationFn: async (values: CreateValues) => { let anchor = location; if (values.checkInMethod === 'Location' && !anchor) anchor = await captureLocation(); return http.post('/attendance/sheets', { teachingTaskId: selectedTaskId, name: values.name.trim(), attendanceDate: values.attendanceDate.startOf('day').toISOString(), checkInMethod: values.checkInMethod, checkInDurationMinutes: values.checkInMethod === 'Manual' ? null : values.checkInDurationMinutes, targetLatitude: values.checkInMethod === 'Location' ? anchor?.latitude : null, targetLongitude: values.checkInMethod === 'Location' ? anchor?.longitude : null, locationRadiusMeters: values.checkInMethod === 'Location' ? values.locationRadiusMeters : null }) }, onSuccess: async (response, values) => { setCreateOpen(false); message.success(values.checkInMethod === 'Manual' ? '考勤表已建立' : '签到活动已发起'); await invalidate(); setSelectedSheetId(response.data.id); if (response.data.checkInMethod === 'QrCode') await openQr(response.data.id) }, onError: (error) => message.error(apiErrorMessage(error)) }) + const save = useMutation({ mutationFn: () => http.put(`/attendance/sheets/${selectedSheetId}/records`, { records: records.map((item) => ({ studentId: item.studentId, status: item.status, notes: item.notes })) }), onSuccess: async () => { message.success('考勤记录已保存'); await invalidate() }, onError: (error) => message.error(apiErrorMessage(error)) }) + const submit = useMutation({ mutationFn: () => http.post(`/attendance/sheets/${selectedSheetId}/submit`), onSuccess: async () => { message.success('考勤表已提交'); await invalidate() }, onError: (error) => message.error(apiErrorMessage(error)) }) + + async function captureLocation() { + setLocating(true) + try { + if (Capacitor.isNativePlatform()) { + let permission = await Geolocation.checkPermissions() + if (permission.location !== 'granted') permission = await Geolocation.requestPermissions({ permissions: ['location'] }) + if (permission.location !== 'granted') throw new Error('location permission denied') + } + const position = Capacitor.isNativePlatform() ? await Geolocation.getCurrentPosition({ enableHighAccuracy: true, timeout: 12000, maximumAge: 0 }) : await new Promise((resolve, reject) => { + if (!navigator.geolocation) { reject(new Error('当前设备不支持定位')); return } + navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 12000, maximumAge: 0 }) + }) + const value = { latitude: Number(position.coords.latitude.toFixed(7)), longitude: Number(position.coords.longitude.toFixed(7)), accuracy: Math.round(position.coords.accuracy) }; setLocation(value); message.success('已获取教师当前位置'); return value + } catch { message.error(Capacitor.isNativePlatform() ? '无法获取精确位置,请检查 App 定位权限。' : '无法获取位置,请允许浏览器使用位置信息。'); throw new Error('location unavailable') } finally { setLocating(false) } + } + function checkInOpen(sheet?: Sheet) { return !!sheet && draft(sheet.status) && online(sheet.checkInMethod) && serverTime(sheet.checkInStartsAt) <= now && now < serverTime(sheet.checkInEndsAt) } + function remaining(sheet?: Sheet) { const ms = serverTime(sheet?.checkInEndsAt) - now; return ms <= 0 ? '签到已结束' : `${Math.floor(ms / 60000)}分${String(Math.floor(ms % 60000 / 1000)).padStart(2, '0')}秒后结束` } + async function refreshQr(showError: boolean, sheetId = selectedSheetId) { if (!sheetId) return false; try { const { data } = await http.get<{ token: string; expiresAt: string }>(`/attendance/sheets/${sheetId}/qr-challenge`); const base = import.meta.env.VITE_PUBLIC_BASE_URL ?? window.location.origin; setQrImage(await QRCode.toDataURL(`${base}/my-attendance?token=${encodeURIComponent(data.token)}`, { width: 360, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#172b4d', light: '#ffffff' } })); setQrExpires(serverTime(data.expiresAt)); return true } catch (error) { if (showError) message.error(apiErrorMessage(error)); return false } } + async function openQr(sheetId = selectedSheetId) { if (await refreshQr(true, sheetId)) setQrOpen(true) } + async function closeCheckIn() { if (!selectedSheetId) return; modal.confirm({ title: '提前结束签到?', content: '学生将不能再扫码或定位签到,教师仍可调整名单。', okText: '结束签到', cancelText: '继续签到', onOk: async () => { try { await http.post(`/attendance/sheets/${selectedSheetId}/close-check-in`); setQrOpen(false); message.success('签到已结束'); await invalidate() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + async function removeSheet(item: Sheet) { modal.confirm({ title: `删除考勤表“${item.name}”?`, okText: '删除', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/attendance/sheets/${item.id}`); if (selectedSheetId === item.id) setSelectedSheetId(undefined); message.success('考勤表已删除'); await invalidate() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) } + async function handleImport(file?: File) { if (!file || !selectedSheetId) return; try { const { data } = await importExcel(`/attendance/sheets/${selectedSheetId}/import`, file); message.success(`已从 Excel 更新 ${data.updated} 条记录`); await invalidate() } catch (error) { message.error(apiErrorMessage(error)) } finally { if (fileRef.current) fileRef.current.value = '' } } + async function exportFile(path: string, name: string) { try { await downloadApiFile(path, name) } catch (error) { message.error(apiErrorMessage(error)) } } + const classOptions = useMemo(() => [...new Set((statistics.data?.students ?? []).map((item) => item.className))].sort((a, b) => a.localeCompare(b, 'zh-CN')), [statistics.data]) + const filteredStudents = useMemo(() => (statistics.data?.students ?? []).filter((item) => { const keyword = studentKeyword.trim().toLowerCase(); if (keyword && !`${item.studentNumber} ${item.studentName}`.toLowerCase().includes(keyword)) return false; if (classFilter && item.className !== classFilter) return false; if (attentionFilter === 'abnormal' && item.absentCount === 0 && item.lateCount === 0) return false; return !(attentionFilter === 'below90' && (item.attendanceRate == null || item.attendanceRate >= 90)) }), [statistics.data, studentKeyword, classFilter, attentionFilter]) + const activeSheet = detail.data?.sheet + if (terms.isLoading) return
+ if (terms.isError) return
+ return
+ } loading={tasks.isFetching} onClick={() => void client.invalidateQueries({ queryKey: ['attendance-tasks'] })}>刷新} /> +
} placeholder="课程、任务号或教学班" onChange={(event) => setTaskKeyword(event.target.value)} onPressEnter={() => { setTaskPage(1); setTaskFilters({ keyword: taskKeyword.trim(), classKeyword: classKeyword.trim() }) }} /> setClassKeyword(event.target.value)} onPressEnter={() => { setTaskPage(1); setTaskFilters({ keyword: taskKeyword.trim(), classKeyword: classKeyword.trim() }) }} />共 {tasks.data?.total ?? 0} 个教学班 setMode(event.target.value)} optionType="button" options={[{ value: 'rollcall', label: '点名记录' }, { value: 'statistics', label: '课程统计' }]} />
+ {tasks.isError ? :
+ + {mode === 'rollcall' ? <>
{!selectedTask ? : <>
{selectedTask.courseName}{selectedTask.taskNumber} · {selectedTask.studentCount} 名学生
{ setSheetKeyword(event.target.value); setSheetPage(1) }} /> { setSheetMethod(value); setSheetPage(1) }} options={[{ value: 'Manual', label: '教师点名' }, { value: 'QrCode', label: '扫码签到' }, { value: 'Location', label: '定位签到' }]} />
{sheets.isLoading ? : sheets.data?.items.map((sheet) => )}{!sheets.isLoading && !sheets.data?.items.length && }
{(sheets.data?.total ?? 0) > 12 && }}
+ :
{!selectedTask ? : statistics.isLoading ? : statistics.isError ? : statistics.data && <>
课程出勤档案{statistics.data.course.courseName}{statistics.data.course.courseCode} · {statistics.data.course.taskNumber} · {statistics.data.course.termName}
课程总体出勤率{rate(statistics.data.summary.overallAttendanceRate)}出勤与迟到计为到课
{[['统计学生', statistics.data.summary.studentCount], ['已提交点名', statistics.data.summary.submittedSheetCount], ['全勤学生', statistics.data.summary.perfectAttendanceCount], ['异常学生', statistics.data.summary.abnormalStudentCount]].map(([label, value]) =>
{label}
{value}
)}
{statistics.data.summary.submittedSheetCount > 0 ?

考勤状态构成

{statistics.data.statusDistribution.map((item) =>
{item.label}{item.count}
)}

历次点名出勤率

: }
学生出勤明细显示 {filteredStudents.length} / {statistics.data.students.length} 人
} value={studentKeyword} onChange={(event) => setStudentKeyword(event.target.value)} placeholder="搜索学号或姓名" />
rowKey="studentId" size="small" pagination={false} scroll={{ x: 900, y: 480 }} dataSource={filteredStudents} columns={[{ title: '学号', dataIndex: 'studentNumber', width: 125, fixed: 'left' }, { title: '姓名', dataIndex: 'studentName', width: 100, fixed: 'left' }, { title: '行政班', dataIndex: 'className', width: 150 }, { title: '出勤率', dataIndex: 'attendanceRate', width: 150, sorter: (a, b) => (a.attendanceRate ?? 101) - (b.attendanceRate ?? 101), render: (value?: number) =>
{rate(value)}
}, ...(['presentCount', 'absentCount', 'lateCount', 'leaveCount', 'excusedCount', 'requiredCount'] as const).map((key, index) => ({ title: ['出勤', '缺勤', '迟到', '请假', '免修', '应到'][index], dataIndex: key, width: 70, sorter: (a: StudentStat, b: StudentStat) => a[key] - b[key] }))]} />
}
} +
} + setCreateOpen(false)} onOk={() => form.submit()} width={650}>
create.mutate(values)}> before.checkInMethod !== after.checkInMethod}>{({ getFieldValue }) => getFieldValue('checkInMethod') !== 'Manual' &&
{getFieldValue('checkInMethod') === 'Location' && <>
教师当前位置作为签到点{location ? `已定位,精度约 ${location.accuracy} 米` : '创建前需要允许设备获取精确位置'}
}
}
+ setQrOpen(false)} footer={<>}>
{activeSheet?.courseCode} · {activeSheet?.taskNumber}{activeSheet?.name}{activeSheet?.courseName}
{qrImage && 课堂签到二维码}

{checkInOpen(activeSheet) ? `动态二维码 · ${Math.max(0, Math.ceil((qrExpires - now) / 1000))} 秒内有效` : '本次签到已结束'}

二维码每 10 秒自动刷新,过期截图和旧链接不能签到
+
+} diff --git a/web-react/src/pages/teacher-attendance.css b/web-react/src/pages/teacher-attendance.css new file mode 100644 index 0000000..3b70fef --- /dev/null +++ b/web-react/src/pages/teacher-attendance.css @@ -0,0 +1 @@ +.teacher-attendance-page{gap:16px}.attendance-filters{padding:10px 14px;display:flex;align-items:center;gap:8px;border:1px solid var(--line);background:white}.attendance-filters>.ant-input-affix-wrapper{width:215px}.attendance-filters>.ant-select{width:185px}.attendance-filters>span{color:var(--ink-muted);font-size:9px}.attendance-mode{margin-left:auto}.attendance-workspace{display:grid;grid-template-columns:250px minmax(320px,1fr) minmax(430px,1.35fr);min-height:590px;border:1px solid var(--line);background:white}.attendance-workspace.statistics-mode{grid-template-columns:250px minmax(0,1fr)}.attendance-task-list{max-height:760px;overflow:auto;border-right:1px solid var(--line)}.attendance-task-list>button,.attendance-sheet-list>button{width:100%;padding:13px 14px;display:grid;gap:4px;position:relative;text-align:left;border:0;border-bottom:1px solid #edf0f4;background:white;cursor:pointer}.attendance-task-list>button:hover,.attendance-sheet-list>button:hover{background:#f6f8fb}.attendance-task-list>button.active,.attendance-sheet-list>button.active{padding-left:11px;border-left:3px solid var(--teal);background:var(--teal-soft)}.attendance-task-list>button.archived{opacity:.6}.attendance-task-list button span{color:var(--teal);font:700 8px var(--font-data)}.attendance-task-list button strong{color:var(--brand-deep);font-size:10px}.attendance-task-list button small{color:var(--ink-muted);font-size:8px}.attendance-task-list>.ant-pagination,.attendance-sheets>.ant-pagination{padding:12px;display:flex;justify-content:center}.attendance-sheets{min-width:0;display:flex;flex-direction:column;border-right:1px solid var(--line)}.attendance-sheets>header,.attendance-records>header{padding:13px 15px;display:flex;align-items:center;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);background:#f8fafb}.attendance-sheets header strong,.attendance-sheets header span,.attendance-records header strong,.attendance-records header span{display:block}.attendance-sheets header strong,.attendance-records header strong{color:var(--brand-deep);font-size:11px}.attendance-sheets header span,.attendance-records header span{margin-top:3px;color:var(--ink-muted);font-size:8px}.sheet-filters{padding:9px 10px;display:grid;grid-template-columns:1fr 105px 115px;gap:6px;border-bottom:1px solid var(--line)}.attendance-sheet-list{flex:1;max-height:590px;overflow:auto}.attendance-sheet-list button>div{display:flex;align-items:center;justify-content:space-between}.attendance-sheet-list button strong{font-size:10px}.attendance-sheet-list button strong>.ant-tag{margin-left:5px;font-size:7px}.attendance-sheet-list button span,.attendance-sheet-list button small{color:var(--ink-muted);font-size:8px}.attendance-sheet-list button em{color:var(--brand);font-size:8px;font-style:normal;font-weight:700}.delete-sheet{position:absolute;right:13px;bottom:12px;color:var(--danger)}.attendance-records{min-width:0;max-height:760px;overflow:auto}.attendance-records>header>div:last-child{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:5px}.check-in-console{padding:14px 16px;display:grid;gap:4px;color:#667085;background:#f2f4f7}.check-in-console.open{color:#d8f4ec;background:#17395e}.check-in-console>span{display:flex;align-items:center;gap:7px;font-size:8px;font-weight:800;letter-spacing:.1em}.check-in-console>span i,.qr-stage p i{width:7px;height:7px;border-radius:50%;background:#94a3b8}.check-in-console.open>span i,.qr-stage p.active i{background:#4fe0b1;box-shadow:0 0 0 4px rgba(79,224,177,.14)}.check-in-console>strong{font:700 29px var(--font-data)}.check-in-console>strong small{font-size:8px;font-weight:500}.check-in-console>p{margin:0;font-size:8px}.record-batch{padding:8px 12px;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--line)}.record-batch>span{color:var(--ink-muted);font-size:8px}.attendance-records .ant-table{font-size:9px}.record-audit{display:flex;flex-wrap:wrap;gap:3px 5px}.record-audit strong,.record-audit span{width:100%;font-size:8px}.record-audit strong{color:var(--teal)}.record-audit span,.muted{color:var(--ink-muted)}.record-audit .ant-tag{font-size:7px}.attendance-statistics{min-width:0;background:#f5f7fa}.attendance-statistics>header{padding:16px 20px;display:flex;align-items:center;justify-content:space-between;background:white;border-bottom:1px solid var(--line)}.attendance-statistics header span,.attendance-statistics header strong,.attendance-statistics header small{display:block}.attendance-statistics header span{color:var(--teal);font:700 8px var(--font-data)}.attendance-statistics header strong{margin:4px 0;color:var(--brand-deep);font-size:15px}.attendance-statistics header small{color:var(--ink-muted);font-size:8px}.attendance-summary{display:grid;grid-template-columns:1.4fr repeat(4,1fr);background:white;border-bottom:1px solid var(--line)}.attendance-summary>div{padding:20px;color:white;background:var(--brand-deep)}.attendance-summary>div span,.attendance-summary>div strong,.attendance-summary>div small{display:block}.attendance-summary>div span{font-size:8px}.attendance-summary>div strong{margin:6px 0;font:700 34px var(--font-data)}.attendance-summary>div small{color:#c2ccdc;font-size:7px}.attendance-summary dl{margin:0;padding:18px 14px;display:grid;align-content:center;border-right:1px solid var(--line)}.attendance-summary dt{color:var(--ink-muted);font-size:8px}.attendance-summary dd{margin:5px 0 0;color:var(--brand-deep);font:700 21px var(--font-data)}.attendance-analytics{padding:13px;display:grid;grid-template-columns:.8fr 1.2fr;gap:12px}.attendance-analytics>article{min-width:0;padding:14px;border:1px solid var(--line);background:white}.attendance-analytics h3{margin:0 0 12px;color:var(--brand-deep);font-size:11px}.status-distribution{display:grid;gap:12px}.status-distribution>div{display:grid;grid-template-columns:55px 1fr 30px;align-items:center;gap:8px;font-size:8px}.status-distribution i,.student-rate i{height:6px;overflow:hidden;background:#edf0f4}.status-distribution i b,.student-rate i b{display:block;height:100%;background:var(--teal)}.attendance-trend{overflow-x:auto}.attendance-trend svg{min-width:520px;width:100%}.attendance-trend line{stroke:#e4e8ef}.attendance-trend polyline{fill:none;stroke:var(--teal);stroke-width:3}.attendance-trend circle{fill:white;stroke:var(--teal);stroke-width:3}.attendance-trend text{fill:#7a8492;font-size:8px}.student-statistics{margin:0 13px 13px;border:1px solid var(--line);background:white}.student-statistics>header{padding:11px 13px;display:flex;align-items:center;justify-content:space-between}.student-statistics>header>div:last-child{display:flex;gap:6px}.student-statistics .ant-input-affix-wrapper{width:190px}.student-statistics .ant-select{width:145px}.student-rate{display:grid;grid-template-columns:1fr 48px;align-items:center;gap:7px}.student-rate strong{font-size:8px;text-align:right}.student-rate.low i b{background:var(--danger)}.online-settings{padding:12px;display:grid;grid-template-columns:1fr 1fr;gap:0 12px;border:1px solid var(--line);background:#f7f9fb}.location-anchor{grid-column:1/-1;padding-top:10px;display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:9px;border-top:1px solid var(--line)}.location-anchor>.anticon{color:var(--teal);font-size:20px}.location-anchor strong,.location-anchor span{display:block}.location-anchor strong{font-size:9px}.location-anchor span{color:var(--ink-muted);font-size:8px}.check-in-methods{width:100%;display:grid;grid-template-columns:repeat(3,1fr)}.check-in-methods .ant-radio-button-wrapper{text-align:center}.qr-stage{display:grid;justify-items:center;gap:10px;text-align:center}.qr-stage>div span,.qr-stage>div strong,.qr-stage>div small{display:block}.qr-stage>div span{color:var(--teal);font:700 8px var(--font-data)}.qr-stage>div strong{margin:4px;color:var(--brand-deep);font-size:17px}.qr-stage>img{width:min(340px,75vw);box-shadow:0 0 0 1px var(--line),0 12px 30px rgba(23,43,77,.12)}.qr-stage>p{margin:0;display:flex;align-items:center;gap:7px;color:var(--ink-muted);font-size:10px;font-weight:700}.qr-stage>small{color:var(--ink-muted);font-size:8px}.full-width{width:100%}@media(max-width:1200px){.attendance-workspace,.attendance-workspace.statistics-mode{grid-template-columns:220px minmax(0,1fr)}.attendance-records{grid-column:1/-1;border-top:1px solid var(--line)}.attendance-sheets{border-right:0}.attendance-summary{grid-template-columns:1.4fr repeat(2,1fr)}.attendance-analytics{grid-template-columns:1fr}.attendance-filters{flex-wrap:wrap}.attendance-mode{margin-left:0}}@media(max-width:760px){.attendance-workspace,.attendance-workspace.statistics-mode{grid-template-columns:1fr}.attendance-task-list{max-height:220px;border-right:0;border-bottom:1px solid var(--line)}.attendance-sheets,.attendance-statistics{grid-column:1}.attendance-filters>*{width:100%!important}.attendance-summary{grid-template-columns:repeat(2,1fr)}.attendance-summary>div{grid-column:1/-1}.student-statistics>header,.attendance-statistics>header{align-items:flex-start;flex-direction:column;gap:10px}.student-statistics>header>div:last-child{width:100%;display:grid}.student-statistics .ant-input-affix-wrapper,.student-statistics .ant-select{width:100%}.online-settings{grid-template-columns:1fr}.check-in-methods{grid-template-columns:1fr}.attendance-records>header{align-items:flex-start;flex-direction:column}.attendance-records>header>div:last-child{justify-content:flex-start}}