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() }) }} /> { setTermId(value ?? ''); setTaskPage(1) }} options={terms.data?.map((term) => ({ value: term.id, label: `${term.name}${term.isCurrent ? ' · 当前' : term.isArchived ? ' · 已归档' : ''}` }))} /> setClassKeyword(event.target.value)} onPressEnter={() => { setTaskPage(1); setTaskFilters({ keyword: taskKeyword.trim(), classKeyword: classKeyword.trim() }) }} />} onClick={() => { setTaskPage(1); setTaskFilters({ keyword: taskKeyword.trim(), classKeyword: classKeyword.trim() }) }}>查询 { setTaskKeyword(''); setClassKeyword(''); setTaskFilters({ keyword: '', classKeyword: '' }); setTermId(terms.data?.find((item) => item.isCurrent)?.id ?? ''); setTaskPage(1) }}>重置共 {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} 名学生 { setLocation(undefined); form.setFieldsValue({ name: '', attendanceDate: dayjs(), checkInMethod: 'Manual', checkInDurationMinutes: 15, locationRadiusMeters: 100 }); setCreateOpen(true) }}>新建考勤表 { setSheetKeyword(event.target.value); setSheetPage(1) }} /> { setSheetStatus(value); setSheetPage(1) }} options={[{ value: 'Draft', label: '草稿' }, { value: 'Submitted', label: '已提交' }]} /> { setSheetMethod(value); setSheetPage(1) }} options={[{ value: 'Manual', label: '教师点名' }, { value: 'QrCode', label: '扫码签到' }, { value: 'Location', label: '定位签到' }]} />{sheets.isLoading ? : sheets.data?.items.map((sheet) => setSelectedSheetId(sheet.id)}>{sheet.name}{online(sheet.checkInMethod) && {methodName(sheet.checkInMethod)}}{dayjs(sheet.attendanceDate).format('YYYY/M/D')}已签 {sheet.checkedInCount} · 出勤 {sheet.presentCount}{sheet.absentCount ? ` · 缺勤 ${sheet.absentCount}` : ''}{sheet.lateCount ? ` · 迟到 ${sheet.lateCount}` : ''}{submitted(sheet.status) ? '已提交' : '草稿'}{draft(sheet.status) && { event.stopPropagation(); void removeSheet(sheet) }} />})}{!sheets.isLoading && !sheets.data?.items.length && }{(sheets.data?.total ?? 0) > 12 && }>} +